From 8020c07acf9fd6893a77f42c2375256e5b41b350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 21 Jun 2026 10:33:47 +0100 Subject: [PATCH 1/2] Track instrumental lyrics metadata - Leave lyrics text empty when a backend reports an instrumental track and persist that state in lyrics_instrumental. --- beets/util/lyrics.py | 17 +++++++++++++---- beetsplug/lyrics.py | 11 +++++++++-- docs/changelog.rst | 3 +++ docs/plugins/lyrics.rst | 4 ++++ test/plugins/test_lyrics.py | 5 ++--- test/util/test_lyrics.py | 4 +++- 6 files changed, 34 insertions(+), 10 deletions(-) diff --git a/beets/util/lyrics.py b/beets/util/lyrics.py index 18d886e7f..2982564ec 100644 --- a/beets/util/lyrics.py +++ b/beets/util/lyrics.py @@ -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: diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index cd4771936..65c99a0be 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -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] diff --git a/docs/changelog.rst b/docs/changelog.rst index 4d906f777..170db9fd9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -49,6 +49,9 @@ 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. :bug:`6719` .. For plugin developers diff --git a/docs/plugins/lyrics.rst b/docs/plugins/lyrics.rst index 8fa2b4c1d..6cfa9648f 100644 --- a/docs/plugins/lyrics.rst +++ b/docs/plugins/lyrics.rst @@ -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. diff --git a/test/plugins/test_lyrics.py b/test/plugins/test_lyrics.py index 9753e27b9..4ef1fd17f 100644 --- a/test/plugins/test_lyrics.py +++ b/test/plugins/test_lyrics.py @@ -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)], diff --git a/test/util/test_lyrics.py b/test/util/test_lyrics.py index 459a26e4c..61b4e3016 100644 --- a/test/util/test_lyrics.py +++ b/test/util/test_lyrics.py @@ -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 From 95854c777017a5e7f5193340478a1e52677bb451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 28 Jun 2026 01:35:59 +0100 Subject: [PATCH 2/2] Add a migration --- beets/library/library.py | 1 + beets/library/migrations.py | 45 ++++++++++++++++++++++++++++++++- docs/changelog.rst | 3 ++- test/library/test_migrations.py | 29 +++++++++++++++++++++ 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/beets/library/library.py b/beets/library/library.py index 6ff15636b..4dbf95923 100644 --- a/beets/library/library.py +++ b/beets/library/library.py @@ -37,6 +37,7 @@ class Library(dbcore.Database): (migrations.MultiArrangerFieldMigration, (Item,)), (migrations.RelativePathMigration, (Item, Album)), (migrations.RemoveInheritedArtpathMigration, (Item,)), + (migrations.InstrumentalLyricsInFlexFieldMigration, (Item,)), ) replacements: Replacements diff --git a/beets/library/migrations.py b/beets/library/migrations.py index 0c5228182..0dc4f0c5e 100644 --- a/beets/library/migrations.py +++ b/beets/library/migrations.py @@ -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") diff --git a/docs/changelog.rst b/docs/changelog.rst index 170db9fd9..8f3617db4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -51,7 +51,8 @@ Bug fixes - :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. :bug:`6719` + attribute. Existing ``[Instrumental]`` lyrics are migrated automatically. + :bug:`6719` .. For plugin developers diff --git a/test/library/test_migrations.py b/test/library/test_migrations.py index 1aea58e05..2fe7c159a 100644 --- a/test/library/test_migrations.py +++ b/test/library/test_migrations.py @@ -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" + )