Skip to content

Commit d720106

Browse files
committed
Resurrect translation functionality
1 parent c315487 commit d720106

File tree

5 files changed

+268
-73
lines changed

5 files changed

+268
-73
lines changed

beetsplug/_typing.py

+20
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,23 @@ class Pagemap(TypedDict):
113113
"""Pagemap data with a single meta tags dict in a list."""
114114

115115
metatags: list[JSONDict]
116+
117+
118+
class TranslatorAPI:
119+
class Language(TypedDict):
120+
"""Language data returned by the translator API."""
121+
122+
language: str
123+
score: float
124+
125+
class Translation(TypedDict):
126+
"""Translation data returned by the translator API."""
127+
128+
text: str
129+
to: str
130+
131+
class Response(TypedDict):
132+
"""Response from the translator API."""
133+
134+
detectedLanguage: TranslatorAPI.Language
135+
translations: list[TranslatorAPI.Translation]

beetsplug/lyrics.py

+125-58
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,18 @@
4040
from beets.autotag.hooks import string_dist
4141

4242
if TYPE_CHECKING:
43+
from logging import Logger
44+
4345
from beets.importer import ImportTask
4446
from beets.library import Item
4547

46-
from ._typing import GeniusAPI, GoogleCustomSearchAPI, JSONDict, LRCLibAPI
48+
from ._typing import (
49+
GeniusAPI,
50+
GoogleCustomSearchAPI,
51+
JSONDict,
52+
LRCLibAPI,
53+
TranslatorAPI,
54+
)
4755

4856
USER_AGENT = f"beets/{beets.__version__}"
4957
INSTRUMENTAL_LYRICS = "[Instrumental]"
@@ -252,6 +260,12 @@ def fetch_json(self, url: str, params: JSONDict | None = None, **kwargs):
252260
self.debug("Fetching JSON from {}", url)
253261
return r_session.get(url, **kwargs).json()
254262

263+
def post_json(self, url: str, params: JSONDict | None = None, **kwargs):
264+
"""Send POST request and return JSON response."""
265+
url = self.format_url(url, params)
266+
self.debug("Posting JSON to {}", url)
267+
return r_session.post(url, **kwargs).json()
268+
255269
@contextmanager
256270
def handle_request(self) -> Iterator[None]:
257271
try:
@@ -760,6 +774,97 @@ def scrape(cls, html: str) -> str | None:
760774
return None
761775

762776

777+
@dataclass
778+
class Translator(RequestHandler):
779+
TRANSLATE_URL = "https://api.cognitive.microsofttranslator.com/translate"
780+
LINE_PARTS_RE = re.compile(r"^(\[\d\d:\d\d.\d\d\]|) *(.*)$")
781+
782+
_log: Logger
783+
api_key: str
784+
to_language: str
785+
from_languages: list[str]
786+
787+
@classmethod
788+
def from_config(
789+
cls,
790+
log: Logger,
791+
api_key: str,
792+
to_language: str,
793+
from_languages: list[str] | None = None,
794+
) -> Translator:
795+
return cls(
796+
log,
797+
api_key,
798+
to_language.upper(),
799+
[x.upper() for x in from_languages or []],
800+
)
801+
802+
def get_translations(self, texts: Iterable[str]) -> list[tuple[str, str]]:
803+
"""Return translations for the given texts.
804+
805+
To reduce the translation 'cost', we translate unique texts, and then
806+
map the translations back to the original texts.
807+
"""
808+
unique_texts = list(dict.fromkeys(texts))
809+
data: list[TranslatorAPI.Response] = self.post_json(
810+
self.TRANSLATE_URL,
811+
headers={"Ocp-Apim-Subscription-Key": self.api_key},
812+
json=[{"text": "|".join(unique_texts)}],
813+
params={"api-version": "3.0", "to": self.to_language},
814+
)
815+
816+
translations = data[0]["translations"][0]["text"].split("|")
817+
trans_by_text = dict(zip(unique_texts, translations))
818+
return list(zip(texts, (trans_by_text.get(t, "") for t in texts)))
819+
820+
@classmethod
821+
def split_line(cls, line: str) -> tuple[str, str]:
822+
"""Split line to (timestamp, text)."""
823+
if m := cls.LINE_PARTS_RE.match(line):
824+
return m[1], m[2]
825+
826+
return "", ""
827+
828+
def append_translations(self, lines: Iterable[str]) -> list[str]:
829+
"""Append translations to the given lyrics texts.
830+
831+
Lines may contain timestamps from LRCLib which need to be temporarily
832+
removed for the translation. They can take any of these forms:
833+
- empty
834+
Text - text only
835+
[00:00:00] - timestamp only
836+
[00:00:00] Text - timestamp with text
837+
"""
838+
# split into [(timestamp, text), ...]]
839+
ts_and_text = list(map(self.split_line, lines))
840+
timestamps = [ts for ts, _ in ts_and_text]
841+
text_pairs = self.get_translations([ln for _, ln in ts_and_text])
842+
843+
# only add the separator for non-empty translations
844+
texts = [" / ".join(filter(None, p)) for p in text_pairs]
845+
# only add the space between non-empty timestamps and texts
846+
return [" ".join(filter(None, p)) for p in zip(timestamps, texts)]
847+
848+
def translate(self, lyrics: str) -> str:
849+
"""Translate the given lyrics to the target language.
850+
851+
If the lyrics are already in the target language or not in any of
852+
of the source languages (if configured), they are returned as is.
853+
854+
The footer with the source URL is preserved, if present.
855+
"""
856+
lyrics_language = langdetect.detect(lyrics).upper()
857+
if lyrics_language == self.to_language or (
858+
self.from_languages and lyrics_language not in self.from_languages
859+
):
860+
return lyrics
861+
862+
lyrics, *url = lyrics.split("\n\nSource: ")
863+
with self.handle_request():
864+
translated_lines = self.append_translations(lyrics.splitlines())
865+
return "\n\nSource: ".join(["\n".join(translated_lines), *url])
866+
867+
763868
class LyricsPlugin(RequestHandler, plugins.BeetsPlugin):
764869
BACKEND_BY_NAME = {
765870
b.name: b for b in [LRCLib, Google, Genius, Tekstowo, MusiXmatch]
@@ -776,15 +881,24 @@ def backends(self) -> list[Backend]:
776881

777882
return [self.BACKEND_BY_NAME[c](self.config, self._log) for c in chosen]
778883

884+
@cached_property
885+
def translator(self) -> Translator | None:
886+
config = self.config["translate"]
887+
if config["api_key"].get() and config["to_language"].get():
888+
return Translator.from_config(self._log, **config.flatten())
889+
return None
890+
779891
def __init__(self):
780892
super().__init__()
781893
self.import_stages = [self.imported]
782894
self.config.add(
783895
{
784896
"auto": True,
785-
"bing_client_secret": None,
786-
"bing_lang_from": [],
787-
"bing_lang_to": None,
897+
"translate": {
898+
"api_key": None,
899+
"from_languages": [],
900+
"to_language": None,
901+
},
788902
"dist_thresh": 0.11,
789903
"google_API_key": None,
790904
"google_engine_ID": "009217259823014548361:lndtuqkycfu",
@@ -803,7 +917,7 @@ def __init__(self):
803917
],
804918
}
805919
)
806-
self.config["bing_client_secret"].redact = True
920+
self.config["translate"]["api_key"].redact = True
807921
self.config["google_API_key"].redact = True
808922
self.config["google_engine_ID"].redact = True
809923
self.config["genius_api_key"].redact = True
@@ -817,24 +931,6 @@ def __init__(self):
817931
# open yet.
818932
self.rest = None
819933

820-
self.config["bing_lang_from"] = [
821-
x.lower() for x in self.config["bing_lang_from"].as_str_seq()
822-
]
823-
824-
@cached_property
825-
def bing_access_token(self) -> str | None:
826-
params = {
827-
"client_id": "beets",
828-
"client_secret": self.config["bing_client_secret"],
829-
"scope": "https://api.microsofttranslator.com",
830-
"grant_type": "client_credentials",
831-
}
832-
833-
oauth_url = "https://datamarket.accesscontrol.windows.net/v2/OAuth2-13"
834-
with self.handle_request():
835-
r = r_session.post(oauth_url, params=params)
836-
return r.json()["access_token"]
837-
838934
def commands(self):
839935
cmd = ui.Subcommand("lyrics", help="fetch song lyrics")
840936
cmd.parser.add_option(
@@ -996,14 +1092,12 @@ def fetch_item_lyrics(self, item: Item, write: bool, force: bool) -> None:
9961092

9971093
if lyrics:
9981094
self.info("🟢 Found lyrics: {0}", item)
999-
if self.config["bing_client_secret"].get():
1000-
lang_from = langdetect.detect(lyrics)
1001-
if self.config["bing_lang_to"].get() != lang_from and (
1002-
not self.config["bing_lang_from"]
1003-
or (lang_from in self.config["bing_lang_from"].as_str_seq())
1004-
):
1005-
lyrics = self.append_translation(
1006-
lyrics, self.config["bing_lang_to"]
1095+
if translator := self.translator:
1096+
initial_lyrics = lyrics
1097+
if (lyrics := translator.translate(lyrics)) != initial_lyrics:
1098+
self.info(
1099+
"🟢 Added translation to {}",
1100+
self.config["translate_to"].get().upper(),
10071101
)
10081102
else:
10091103
self.info("🔴 Lyrics not found: {}", item)
@@ -1027,30 +1121,3 @@ def get_lyrics(self, artist: str, title: str, *args) -> str | None:
10271121
return f"{lyrics}\n\nSource: {url}"
10281122

10291123
return None
1030-
1031-
def append_translation(self, text, to_lang):
1032-
from xml.etree import ElementTree
1033-
1034-
if not (token := self.bing_access_token):
1035-
self.warn(
1036-
"Could not get Bing Translate API access token. "
1037-
"Check your 'bing_client_secret' password."
1038-
)
1039-
return text
1040-
1041-
# Extract unique lines to limit API request size per song
1042-
lines = text.split("\n")
1043-
unique_lines = set(lines)
1044-
url = "https://api.microsofttranslator.com/v2/Http.svc/Translate"
1045-
with self.handle_request():
1046-
text = self.fetch_text(
1047-
url,
1048-
headers={"Authorization": f"Bearer {token}"},
1049-
params={"text": "|".join(unique_lines), "to": to_lang},
1050-
)
1051-
if translated := ElementTree.fromstring(text.encode("utf-8")).text:
1052-
# Use a translation mapping dict to build resulting lyrics
1053-
translations = dict(zip(unique_lines, translated.split("|")))
1054-
return "".join(f"{ln} / {translations[ln]}\n" for ln in lines)
1055-
1056-
return text

docs/changelog.rst

+2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ New features:
1919
control the maximum allowed distance between the lyrics search result and the
2020
tagged item's artist and title. This is useful for preventing false positives
2121
when fetching lyrics.
22+
* :doc:`plugins/lyrics`: Rewrite lyrics translation functionality to use Azure
23+
AI Translator API and add relevant instructions to the documentation.
2224

2325
Bug fixes:
2426

docs/plugins/lyrics.rst

+36-15
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,10 @@ Default configuration:
3838
3939
lyrics:
4040
auto: yes
41-
bing_client_secret: null
42-
bing_lang_from: []
43-
bing_lang_to: null
41+
translate:
42+
api_key:
43+
from_languages: []
44+
to_language:
4445
dist_thresh: 0.11
4546
fallback: null
4647
force: no
@@ -52,12 +53,14 @@ Default configuration:
5253
The available options are:
5354

5455
- **auto**: Fetch lyrics automatically during import.
55-
- **bing_client_secret**: Your Bing Translation application password
56-
(see :ref:`lyrics-translation`)
57-
- **bing_lang_from**: By default all lyrics with a language other than
58-
``bing_lang_to`` are translated. Use a list of lang codes to restrict the set
59-
of source languages to translate.
60-
- **bing_lang_to**: Language to translate lyrics into.
56+
- **translate**:
57+
58+
- **api_key**: Api key to access your Azure Translator resource. (see
59+
:ref:`lyrics-translation`)
60+
- **from_languages**: By default all lyrics with a language other than
61+
``translate_to`` are translated. Use a list of language codes to restrict
62+
them.
63+
- **to_language**: Language code to translate lyrics to.
6164
- **dist_thresh**: The maximum distance between the artist and title
6265
combination of the music file and lyrics candidate to consider them a match.
6366
Lower values will make the plugin more strict, higher values will make it
@@ -165,10 +168,28 @@ After that, the lyrics plugin will fall back on other declared data sources.
165168
Activate On-the-Fly Translation
166169
-------------------------------
167170

168-
You need to register for a Microsoft Azure Marketplace free account and
169-
to the `Microsoft Translator API`_. Follow the four steps process, specifically
170-
at step 3 enter ``beets`` as *Client ID* and copy/paste the generated
171-
*Client secret* into your ``bing_client_secret`` configuration, alongside
172-
``bing_lang_to`` target ``language code``.
171+
We use Azure to optionally translate your lyrics. To set up the integration,
172+
follow these steps:
173173

174-
.. _Microsoft Translator API: https://docs.microsoft.com/en-us/azure/cognitive-services/translator/translator-how-to-signup
174+
1. `Create a Translator resource`_ on Azure.
175+
2. `Obtain its API key`_.
176+
3. Add the API key to your configuration as ``translate.api_key``.
177+
4. Configure your target language using the ``translate.to_language`` option.
178+
179+
180+
For example, with the following configuration
181+
182+
.. code-block:: yaml
183+
184+
lyrics:
185+
translate:
186+
api_key: YOUR_TRANSLATOR_API_KEY
187+
to_language: de
188+
189+
You should expect lyrics like this::
190+
191+
Original verse / Ursprünglicher Vers
192+
Some other verse / Ein anderer Vers
193+
194+
.. _create a Translator resource: https://learn.microsoft.com/en-us/azure/ai-services/translator/create-translator-resource
195+
.. _obtain its API key: https://learn.microsoft.com/en-us/python/api/overview/azure/ai-translation-text-readme?view=azure-python&preserve-view=true#get-an-api-key

0 commit comments

Comments
 (0)