Resurrect translation functionality

This commit is contained in:
Šarūnas Nejus 2024-10-13 23:42:55 +01:00
parent c315487bd2
commit d7201062a8
No known key found for this signature in database
GPG key ID: DD28F6704DBE3435
5 changed files with 268 additions and 73 deletions

View file

@ -113,3 +113,23 @@ class GoogleCustomSearchAPI:
"""Pagemap data with a single meta tags dict in a list.""" """Pagemap data with a single meta tags dict in a list."""
metatags: list[JSONDict] metatags: list[JSONDict]
class TranslatorAPI:
class Language(TypedDict):
"""Language data returned by the translator API."""
language: str
score: float
class Translation(TypedDict):
"""Translation data returned by the translator API."""
text: str
to: str
class Response(TypedDict):
"""Response from the translator API."""
detectedLanguage: TranslatorAPI.Language
translations: list[TranslatorAPI.Translation]

View file

@ -40,10 +40,18 @@ from beets import plugins, ui
from beets.autotag.hooks import string_dist from beets.autotag.hooks import string_dist
if TYPE_CHECKING: if TYPE_CHECKING:
from logging import Logger
from beets.importer import ImportTask from beets.importer import ImportTask
from beets.library import Item from beets.library import Item
from ._typing import GeniusAPI, GoogleCustomSearchAPI, JSONDict, LRCLibAPI from ._typing import (
GeniusAPI,
GoogleCustomSearchAPI,
JSONDict,
LRCLibAPI,
TranslatorAPI,
)
USER_AGENT = f"beets/{beets.__version__}" USER_AGENT = f"beets/{beets.__version__}"
INSTRUMENTAL_LYRICS = "[Instrumental]" INSTRUMENTAL_LYRICS = "[Instrumental]"
@ -252,6 +260,12 @@ class RequestHandler:
self.debug("Fetching JSON from {}", url) self.debug("Fetching JSON from {}", url)
return r_session.get(url, **kwargs).json() return r_session.get(url, **kwargs).json()
def post_json(self, url: str, params: JSONDict | None = None, **kwargs):
"""Send POST request and return JSON response."""
url = self.format_url(url, params)
self.debug("Posting JSON to {}", url)
return r_session.post(url, **kwargs).json()
@contextmanager @contextmanager
def handle_request(self) -> Iterator[None]: def handle_request(self) -> Iterator[None]:
try: try:
@ -760,6 +774,97 @@ class Google(SearchBackend):
return None return None
@dataclass
class Translator(RequestHandler):
TRANSLATE_URL = "https://api.cognitive.microsofttranslator.com/translate"
LINE_PARTS_RE = re.compile(r"^(\[\d\d:\d\d.\d\d\]|) *(.*)$")
_log: Logger
api_key: str
to_language: str
from_languages: list[str]
@classmethod
def from_config(
cls,
log: Logger,
api_key: str,
to_language: str,
from_languages: list[str] | None = None,
) -> Translator:
return cls(
log,
api_key,
to_language.upper(),
[x.upper() for x in from_languages or []],
)
def get_translations(self, texts: Iterable[str]) -> list[tuple[str, str]]:
"""Return translations for the given texts.
To reduce the translation 'cost', we translate unique texts, and then
map the translations back to the original texts.
"""
unique_texts = list(dict.fromkeys(texts))
data: list[TranslatorAPI.Response] = self.post_json(
self.TRANSLATE_URL,
headers={"Ocp-Apim-Subscription-Key": self.api_key},
json=[{"text": "|".join(unique_texts)}],
params={"api-version": "3.0", "to": self.to_language},
)
translations = data[0]["translations"][0]["text"].split("|")
trans_by_text = dict(zip(unique_texts, translations))
return list(zip(texts, (trans_by_text.get(t, "") for t in texts)))
@classmethod
def split_line(cls, line: str) -> tuple[str, str]:
"""Split line to (timestamp, text)."""
if m := cls.LINE_PARTS_RE.match(line):
return m[1], m[2]
return "", ""
def append_translations(self, lines: Iterable[str]) -> list[str]:
"""Append translations to the given lyrics texts.
Lines may contain timestamps from LRCLib which need to be temporarily
removed for the translation. They can take any of these forms:
- empty
Text - text only
[00:00:00] - timestamp only
[00:00:00] Text - timestamp with text
"""
# split into [(timestamp, text), ...]]
ts_and_text = list(map(self.split_line, lines))
timestamps = [ts for ts, _ in ts_and_text]
text_pairs = self.get_translations([ln for _, ln in ts_and_text])
# only add the separator for non-empty translations
texts = [" / ".join(filter(None, p)) for p in text_pairs]
# only add the space between non-empty timestamps and texts
return [" ".join(filter(None, p)) for p in zip(timestamps, texts)]
def translate(self, lyrics: str) -> str:
"""Translate the given lyrics to the target language.
If the lyrics are already in the target language or not in any of
of the source languages (if configured), they are returned as is.
The footer with the source URL is preserved, if present.
"""
lyrics_language = langdetect.detect(lyrics).upper()
if lyrics_language == self.to_language or (
self.from_languages and lyrics_language not in self.from_languages
):
return lyrics
lyrics, *url = lyrics.split("\n\nSource: ")
with self.handle_request():
translated_lines = self.append_translations(lyrics.splitlines())
return "\n\nSource: ".join(["\n".join(translated_lines), *url])
class LyricsPlugin(RequestHandler, plugins.BeetsPlugin): class LyricsPlugin(RequestHandler, plugins.BeetsPlugin):
BACKEND_BY_NAME = { BACKEND_BY_NAME = {
b.name: b for b in [LRCLib, Google, Genius, Tekstowo, MusiXmatch] b.name: b for b in [LRCLib, Google, Genius, Tekstowo, MusiXmatch]
@ -776,15 +881,24 @@ class LyricsPlugin(RequestHandler, plugins.BeetsPlugin):
return [self.BACKEND_BY_NAME[c](self.config, self._log) for c in chosen] return [self.BACKEND_BY_NAME[c](self.config, self._log) for c in chosen]
@cached_property
def translator(self) -> Translator | None:
config = self.config["translate"]
if config["api_key"].get() and config["to_language"].get():
return Translator.from_config(self._log, **config.flatten())
return None
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.import_stages = [self.imported] self.import_stages = [self.imported]
self.config.add( self.config.add(
{ {
"auto": True, "auto": True,
"bing_client_secret": None, "translate": {
"bing_lang_from": [], "api_key": None,
"bing_lang_to": None, "from_languages": [],
"to_language": None,
},
"dist_thresh": 0.11, "dist_thresh": 0.11,
"google_API_key": None, "google_API_key": None,
"google_engine_ID": "009217259823014548361:lndtuqkycfu", "google_engine_ID": "009217259823014548361:lndtuqkycfu",
@ -803,7 +917,7 @@ class LyricsPlugin(RequestHandler, plugins.BeetsPlugin):
], ],
} }
) )
self.config["bing_client_secret"].redact = True self.config["translate"]["api_key"].redact = True
self.config["google_API_key"].redact = True self.config["google_API_key"].redact = True
self.config["google_engine_ID"].redact = True self.config["google_engine_ID"].redact = True
self.config["genius_api_key"].redact = True self.config["genius_api_key"].redact = True
@ -817,24 +931,6 @@ class LyricsPlugin(RequestHandler, plugins.BeetsPlugin):
# open yet. # open yet.
self.rest = None self.rest = None
self.config["bing_lang_from"] = [
x.lower() for x in self.config["bing_lang_from"].as_str_seq()
]
@cached_property
def bing_access_token(self) -> str | None:
params = {
"client_id": "beets",
"client_secret": self.config["bing_client_secret"],
"scope": "https://api.microsofttranslator.com",
"grant_type": "client_credentials",
}
oauth_url = "https://datamarket.accesscontrol.windows.net/v2/OAuth2-13"
with self.handle_request():
r = r_session.post(oauth_url, params=params)
return r.json()["access_token"]
def commands(self): def commands(self):
cmd = ui.Subcommand("lyrics", help="fetch song lyrics") cmd = ui.Subcommand("lyrics", help="fetch song lyrics")
cmd.parser.add_option( cmd.parser.add_option(
@ -996,14 +1092,12 @@ class LyricsPlugin(RequestHandler, plugins.BeetsPlugin):
if lyrics: if lyrics:
self.info("🟢 Found lyrics: {0}", item) self.info("🟢 Found lyrics: {0}", item)
if self.config["bing_client_secret"].get(): if translator := self.translator:
lang_from = langdetect.detect(lyrics) initial_lyrics = lyrics
if self.config["bing_lang_to"].get() != lang_from and ( if (lyrics := translator.translate(lyrics)) != initial_lyrics:
not self.config["bing_lang_from"] self.info(
or (lang_from in self.config["bing_lang_from"].as_str_seq()) "🟢 Added translation to {}",
): self.config["translate_to"].get().upper(),
lyrics = self.append_translation(
lyrics, self.config["bing_lang_to"]
) )
else: else:
self.info("🔴 Lyrics not found: {}", item) self.info("🔴 Lyrics not found: {}", item)
@ -1027,30 +1121,3 @@ class LyricsPlugin(RequestHandler, plugins.BeetsPlugin):
return f"{lyrics}\n\nSource: {url}" return f"{lyrics}\n\nSource: {url}"
return None return None
def append_translation(self, text, to_lang):
from xml.etree import ElementTree
if not (token := self.bing_access_token):
self.warn(
"Could not get Bing Translate API access token. "
"Check your 'bing_client_secret' password."
)
return text
# Extract unique lines to limit API request size per song
lines = text.split("\n")
unique_lines = set(lines)
url = "https://api.microsofttranslator.com/v2/Http.svc/Translate"
with self.handle_request():
text = self.fetch_text(
url,
headers={"Authorization": f"Bearer {token}"},
params={"text": "|".join(unique_lines), "to": to_lang},
)
if translated := ElementTree.fromstring(text.encode("utf-8")).text:
# Use a translation mapping dict to build resulting lyrics
translations = dict(zip(unique_lines, translated.split("|")))
return "".join(f"{ln} / {translations[ln]}\n" for ln in lines)
return text

View file

@ -19,6 +19,8 @@ New features:
control the maximum allowed distance between the lyrics search result and the control the maximum allowed distance between the lyrics search result and the
tagged item's artist and title. This is useful for preventing false positives tagged item's artist and title. This is useful for preventing false positives
when fetching lyrics. when fetching lyrics.
* :doc:`plugins/lyrics`: Rewrite lyrics translation functionality to use Azure
AI Translator API and add relevant instructions to the documentation.
Bug fixes: Bug fixes:

View file

@ -38,9 +38,10 @@ Default configuration:
lyrics: lyrics:
auto: yes auto: yes
bing_client_secret: null translate:
bing_lang_from: [] api_key:
bing_lang_to: null from_languages: []
to_language:
dist_thresh: 0.11 dist_thresh: 0.11
fallback: null fallback: null
force: no force: no
@ -52,12 +53,14 @@ Default configuration:
The available options are: The available options are:
- **auto**: Fetch lyrics automatically during import. - **auto**: Fetch lyrics automatically during import.
- **bing_client_secret**: Your Bing Translation application password - **translate**:
(see :ref:`lyrics-translation`)
- **bing_lang_from**: By default all lyrics with a language other than - **api_key**: Api key to access your Azure Translator resource. (see
``bing_lang_to`` are translated. Use a list of lang codes to restrict the set :ref:`lyrics-translation`)
of source languages to translate. - **from_languages**: By default all lyrics with a language other than
- **bing_lang_to**: Language to translate lyrics into. ``translate_to`` are translated. Use a list of language codes to restrict
them.
- **to_language**: Language code to translate lyrics to.
- **dist_thresh**: The maximum distance between the artist and title - **dist_thresh**: The maximum distance between the artist and title
combination of the music file and lyrics candidate to consider them a match. combination of the music file and lyrics candidate to consider them a match.
Lower values will make the plugin more strict, higher values will make it 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.
Activate On-the-Fly Translation Activate On-the-Fly Translation
------------------------------- -------------------------------
You need to register for a Microsoft Azure Marketplace free account and We use Azure to optionally translate your lyrics. To set up the integration,
to the `Microsoft Translator API`_. Follow the four steps process, specifically follow these steps:
at step 3 enter ``beets`` as *Client ID* and copy/paste the generated
*Client secret* into your ``bing_client_secret`` configuration, alongside
``bing_lang_to`` target ``language code``.
.. _Microsoft Translator API: https://docs.microsoft.com/en-us/azure/cognitive-services/translator/translator-how-to-signup 1. `Create a Translator resource`_ on Azure.
2. `Obtain its API key`_.
3. Add the API key to your configuration as ``translate.api_key``.
4. Configure your target language using the ``translate.to_language`` option.
For example, with the following configuration
.. code-block:: yaml
lyrics:
translate:
api_key: YOUR_TRANSLATOR_API_KEY
to_language: de
You should expect lyrics like this::
Original verse / Ursprünglicher Vers
Some other verse / Ein anderer Vers
.. _create a Translator resource: https://learn.microsoft.com/en-us/azure/ai-services/translator/create-translator-resource
.. _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

View file

@ -17,6 +17,7 @@
import importlib.util import importlib.util
import os import os
import re import re
import textwrap
from functools import partial from functools import partial
from http import HTTPStatus from http import HTTPStatus
@ -509,3 +510,87 @@ class TestLRCLibLyrics(LyricsBackendTest):
lyrics, _ = fetch_lyrics() lyrics, _ = fetch_lyrics()
assert lyrics == expected_lyrics assert lyrics == expected_lyrics
class TestTranslation:
@pytest.fixture(autouse=True)
def _patch_bing(self, requests_mock):
def callback(request, _):
if b"Refrain" in request.body:
translations = (
""
"|[Refrain : Doja Cat]"
"|Difficile pour moi de te laisser partir (Te laisser partir, te laisser partir)" # noqa: E501
"|Mon corps ne me laissait pas le cacher (Cachez-le)"
"|Quoi quil arrive, je ne plierais pas (Ne plierait pas, ne plierais pas)" # noqa: E501
"|Chevauchant à travers le tonnerre, la foudre"
)
elif b"00:00.00" in request.body:
translations = (
""
"|[00:00.00] Quelques paroles synchronisées"
"|[00:01.00] Quelques paroles plus synchronisées"
)
else:
translations = (
""
"|Quelques paroles synchronisées"
"|Quelques paroles plus synchronisées"
)
return [
{
"detectedLanguage": {"language": "en", "score": 1.0},
"translations": [{"text": translations, "to": "fr"}],
}
]
requests_mock.post(lyrics.Translator.TRANSLATE_URL, json=callback)
@pytest.mark.parametrize(
"initial_lyrics, expected",
[
pytest.param(
"""
[Refrain: Doja Cat]
Hard for me to let you go (Let you go, let you go)
My body wouldn't let me hide it (Hide it)
No matter what, I wouldn't fold (Wouldn't fold, wouldn't fold)
Ridin' through the thunder, lightnin'""",
"""
[Refrain: Doja Cat] / [Refrain : Doja Cat]
Hard for me to let you go (Let you go, let you go) / Difficile pour moi de te laisser partir (Te laisser partir, te laisser partir)
My body wouldn't let me hide it (Hide it) / Mon corps ne me laissait pas le cacher (Cachez-le)
No matter what, I wouldn't fold (Wouldn't fold, wouldn't fold) / Quoi quil arrive, je ne plierais pas (Ne plierait pas, ne plierais pas)
Ridin' through the thunder, lightnin' / Chevauchant à travers le tonnerre, la foudre""", # noqa: E501
id="plain",
),
pytest.param(
"""
[00:00.00] Some synced lyrics
[00:00:50]
[00:01.00] Some more synced lyrics
Source: https://lrclib.net/api/123""",
"""
[00:00.00] Some synced lyrics / Quelques paroles synchronisées
[00:00:50]
[00:01.00] Some more synced lyrics / Quelques paroles plus synchronisées
Source: https://lrclib.net/api/123""", # noqa: E501
id="synced",
),
pytest.param(
"Quelques paroles",
"Quelques paroles",
id="already in the target language",
),
],
)
def test_translate(self, initial_lyrics, expected):
plugin = lyrics.LyricsPlugin()
bing = lyrics.Translator(plugin._log, "123", "FR", ["EN"])
assert bing.translate(
textwrap.dedent(initial_lyrics)
) == textwrap.dedent(expected)