Merge branch 'master' into edit

This commit is contained in:
Alok Saboo
2026-07-12 20:14:33 -04:00
committed by GitHub
9 changed files with 109 additions and 11 deletions

View File

@@ -37,6 +37,7 @@ class Library(dbcore.Database):
(migrations.MultiArrangerFieldMigration, (Item,)),
(migrations.RelativePathMigration, (Item, Album)),
(migrations.RemoveInheritedArtpathMigration, (Item,)),
(migrations.InstrumentalLyricsInFlexFieldMigration, (Item,)),
)
replacements: Replacements

View File

@@ -13,7 +13,7 @@ from beets.dbcore.db import Migration
from beets.dbcore.pathutils import normalize_path_for_db
from beets.dbcore.types import MULTI_VALUE_DELIMITER
from beets.util import chunks, unique_list
from beets.util.lyrics import Lyrics
from beets.util.lyrics import INSTRUMENTAL_LYRICS, Lyrics
if TYPE_CHECKING:
from beets.dbcore.db import Model
@@ -284,3 +284,46 @@ class RemoveInheritedArtpathMigration(Migration):
tx.mutate(f"DELETE FROM {flex_table} WHERE key == 'artpath'")
ui.print_(f"Migration complete: {total} {table} updated")
class InstrumentalLyricsInFlexFieldMigration(Migration):
"""Move legacy instrumental lyrics markers into a flexible field."""
def _migrate_data(self, model_cls: type[Model], _: set[str]) -> None:
"""Migrate legacy instrumental markers out of the lyrics text."""
table = model_cls._table
flex_table = model_cls._flex_table
with self.db.transaction() as tx:
rows = tx.query(
f"""
SELECT id
FROM {table}
WHERE lyrics == ?
""",
(INSTRUMENTAL_LYRICS,),
)
if not rows:
return
total = len(rows)
ui.print_(f"Migrating instrumental lyrics for {total} {table}...")
with self.db.transaction() as tx:
tx.mutate(
f"""
UPDATE {table}
SET lyrics = ''
WHERE lyrics == ?
""",
(INSTRUMENTAL_LYRICS,),
)
tx.mutate_many(
f"""
INSERT INTO {flex_table} (entity_id, key, value)
VALUES (?, 'lyrics_instrumental', '1')
""",
[(r["id"],) for r in rows],
)
ui.print_(f"Migration complete: {total} of {total} {table} updated")

View File

@@ -21,8 +21,8 @@ class Lyrics:
"""Represent lyrics text together with structured source metadata.
This value object keeps the canonical lyrics body, optional provenance, and
optional translation metadata synchronized across fetching, translation, and
persistence.
optional instrumental and translation metadata synchronized across fetching,
translation, and persistence.
"""
ORIGINAL_PAT = re.compile(r"[^\n]+ / ")
@@ -33,12 +33,21 @@ class Lyrics:
text: str
backend: str | None = None
url: str | None = None
instrumental: bool = False
language: str | None = None
translation_language: str | None = None
translations: list[str] = field(default_factory=list)
def handle_instrumental(self) -> None:
"""Track instrumental matches without storing marker text as lyrics."""
if self.text == INSTRUMENTAL_LYRICS:
self.instrumental = True
self.text = ""
def __post_init__(self) -> None:
"""Populate missing language metadata from the current text."""
"""Normalize lyrics metadata and infer missing language information."""
self.handle_instrumental()
try:
import langdetect
except ImportError:
@@ -47,7 +56,7 @@ class Lyrics:
# Set seed to 0 for deterministic results
langdetect.DetectorFactory.seed = 0
if not self.text or self.text == INSTRUMENTAL_LYRICS:
if not self.text:
return
if not self.language:

View File

@@ -993,6 +993,7 @@ BACKEND_BY_NAME = {
class LyricsPlugin(LyricsRequestHandler, plugins.BeetsPlugin):
item_types: ClassVar[dict[str, types.Type]] = {
"lyrics_url": types.STRING,
"lyrics_instrumental": types.BOOLEAN,
"lyrics_backend": types.STRING,
"lyrics_language": types.STRING,
"lyrics_translation_language": types.STRING,
@@ -1166,9 +1167,15 @@ class LyricsPlugin(LyricsRequestHandler, plugins.BeetsPlugin):
)
return
for key in ("backend", "url", "language", "translation_language"):
for key in (
"backend",
"url",
"instrumental",
"language",
"translation_language",
):
item_key = f"lyrics_{key}"
if value := getattr(new_lyrics, key):
if (value := getattr(new_lyrics, key)) is not None:
item[item_key] = value
elif item_key in item:
del item[item_key]

View File

@@ -53,6 +53,10 @@ Bug fixes
example a multi-disc album whose cover lives in the album root rather than a
per-disc directory); the missing art is skipped instead. :bug:`4692`
- :doc:`plugins/tidal`: Normalize Tidal album types to lowercase.
- :doc:`plugins/lyrics`: Leave lyrics empty when a source reports an
instrumental track, and store that state in ``lyrics_instrumental`` flexible
attribute. Existing ``[Instrumental]`` lyrics are migrated automatically.
:bug:`6719`
..
For plugin developers

View File

@@ -30,10 +30,14 @@ also sets a few useful flexible attributes:
- ``lyrics_backend``: name of the backend that provided the lyrics
- ``lyrics_url``: URL of the page where the lyrics were found
- ``lyrics_instrumental``: whether the backend marked the track as instrumental
- ``lyrics_language``: original language of the lyrics
- ``lyrics_translation_language``: language of the lyrics translation (if
translation is enabled)
When a backend reports an instrumental track, beets leaves the lyrics text empty
and sets ``lyrics_instrumental`` instead.
If the ``import.write`` config option is on, then the lyrics will also be
written to the files' tags.

View File

@@ -346,3 +346,32 @@ class TestMigrationBackup(MigrationTestHelper):
if os.fsdecode(f).endswith(".bak")
]
assert len(backups) == expected_count
class TestInstrumentalLyricsInFlexFieldMigration(MigrationTestHelper):
"""Verify legacy instrumental markers move out of canonical lyrics."""
migration = (migrations.InstrumentalLyricsInFlexFieldMigration, (Item,))
def test_migrate(self):
"""Ensure exact instrumental markers become metadata, not lyric text."""
instrumental_item = self.add_item(lyrics="[Instrumental]")
regular_item = self.add_item(lyrics="Regular lyrics")
self.lib._migrate()
instrumental_item.load()
regular_item.load()
assert instrumental_item.lyrics == ""
assert instrumental_item.lyrics_instrumental == "1"
assert regular_item.lyrics == "Regular lyrics"
with pytest.raises(AttributeError, match="lyrics_instrumental"):
regular_item.lyrics_instrumental
# remove cached initial db tables data
del self.lib.db_tables
assert self.lib.migration_exists(
"instrumental_lyrics_in_flex_field", "items"
)

View File

@@ -350,6 +350,7 @@ class TestLyricsPlugin(LyricsPluginMixin):
item = helper.lib.get_item(item.id)
assert item.lyrics_url == lyrics.url
assert item.lyrics_instrumental == "0"
assert item.lyrics_backend == lyrics.backend
if is_importable("langdetect"):
assert item.lyrics_language == "EN"
@@ -621,9 +622,7 @@ class TestLRCLibLyrics(LyricsBackendTest):
[lyrics_match(duration=1)], None, id="none: duration too short"
),
pytest.param(
[lyrics_match(instrumental=True)],
"[Instrumental]",
id="instrumental track",
[lyrics_match(instrumental=True)], "", id="instrumental track"
),
pytest.param(
[lyrics_match(syncedLyrics=None)],

View File

@@ -9,7 +9,8 @@ class TestLyrics:
"[Instrumental]", "lrclib", url="https://lrclib.net/api/1"
)
assert lyrics.full_text == "[Instrumental]"
assert lyrics.full_text == ""
assert lyrics.instrumental
assert lyrics.backend == "lrclib"
assert lyrics.url == "https://lrclib.net/api/1"
assert lyrics.language is None
@@ -38,3 +39,4 @@ class TestLyrics:
assert lyrics.translation_language == (
"FR" if langdetect_available else None
)
assert not lyrics.instrumental