import os import textwrap from typing import ClassVar import pytest from beets.dbcore import types from beets.library import migrations from beets.library.models import Album, Item from beets.test.helper import TestHelper from beets.util import cached_classproperty, path_as_posix Migration = tuple[ type[migrations.Migration], tuple[type[Item] | type[Album], ...] ] class MigrationTestHelper(TestHelper): """Provide a shared harness for exercising one library migration at a time. The helper builds a library that starts in a pre-migration state, then re-enables the migration under test so each subclass can verify the data transformation in isolation. """ migration: ClassVar[Migration] @classmethod def setup_previous_state(cls, monkeypatch: pytest.MonkeyPatch) -> None: """Shape the library into the legacy state expected by the migration. Subclasses override this hook to create older schema or metadata layouts before the test library is initialized. """ @pytest.fixture(autouse=True) def setup(self, monkeypatch): """Initialize the test library around the migration under test. The fixture first prevents automatic migrations so legacy test data can be created safely, then restores the target migration and tears down the temporary library after each test. """ # do not apply migrations upon library initialization monkeypatch.setattr("beets.library.library.Library._migrations", ()) self.setup_previous_state(monkeypatch) self.setup_beets() # and now configure the migrations to be tested monkeypatch.setattr( "beets.library.library.Library._migrations", (self.migration,) ) try: yield finally: self.teardown_beets() class TestMultiGenreFieldMigration(MigrationTestHelper): """Verify legacy single-value genres are promoted to multi-value fields.""" migration = (migrations.MultiGenreFieldMigration, (Item, Album)) @classmethod def setup_previous_state(cls, monkeypatch: pytest.MonkeyPatch) -> None: """Expose the legacy genre columns required to exercise the migration.""" # add genre field to both models to make sure this column is created monkeypatch.setattr( "beets.library.models.Item._fields", {**Item._fields, "genre": types.STRING}, ) monkeypatch.setattr( "beets.library.models.Album._fields", {**Album._fields, "genre": types.STRING}, ) monkeypatch.setattr( "beets.library.models.Album.item_keys", {*Album.item_keys, "genre"} ) def test_migrate(self): """Ensure genre data is split once and preserved when already populated.""" self.config["lastgenre"]["separator"] = " - " expected_item_genres = [] for genre, initial_genres, expected_genres in [ # already existing value is not overwritten ("Item Rock", ("Ignored",), ("Ignored",)), ("", (), ()), ("Rock", (), ("Rock",)), # multiple genres are split on one of default separators ("Item Rock; Alternative", (), ("Item Rock", "Alternative")), # multiple genres are split the first (lastgenre) separator ONLY ("Item - Rock, Alternative", (), ("Item", "Rock, Alternative")), ]: self.add_item(genre=genre, genres=initial_genres) expected_item_genres.append(expected_genres) unmigrated_album = self.add_album( genre="Album Rock / Alternative", genres=[] ) expected_item_genres.append(("Album Rock", "Alternative")) self.lib._migrate() actual_item_genres = [tuple(i.genres) for i in self.lib.items()] assert actual_item_genres == expected_item_genres unmigrated_album.load() assert unmigrated_album.genres == ["Album Rock", "Alternative"] # remove cached initial db tables data del self.lib.db_tables assert self.lib.migration_exists("multi_genre_field", "items") assert self.lib.migration_exists("multi_genre_field", "albums") class MultiArtistFieldMigrationTestMixin(MigrationTestHelper): """Share coverage for migrations that split artist-like text into lists.""" str_field: ClassVar[str] list_field: ClassVar[str] migration_cls: ClassVar[type[migrations.MultiValueFieldMigration]] @cached_classproperty def migration(cls) -> Migration: """Bind each concrete test class to its corresponding migration.""" return (cls.migration_cls, (Item,)) @classmethod def setup_previous_state(cls, monkeypatch: pytest.MonkeyPatch) -> None: """Expose the legacy scalar field expected by the migration.""" # add legacy str field to make sure this column is created monkeypatch.setattr( "beets.library.models.Item._fields", {**Item._fields, cls.str_field: types.STRING}, ) def test_migrate(self): """Ensure existing list values win and legacy text splits consistently.""" expected_list_values = [] for str_value, initial_list_value, expected_list_value in [ # already existing value is not overwritten ("Artist", ("Ignored",), ("Ignored",)), ("", (), ()), ("Artist", (), ("Artist",)), # multiple values are split on one of separators ("Artist; Another Artist", (), ("Artist", "Another Artist")), # multiple values are split by the existing separator ONLY ("Artist, Another; Artist", (), ("Artist, Another", "Artist")), ]: data = { self.str_field: str_value, self.list_field: initial_list_value, } self.add_item(**data) expected_list_values.append(expected_list_value) self.lib._migrate() actual_list_values = [ tuple(i[self.list_field]) for i in self.lib.items() ] assert actual_list_values == expected_list_values # remove cached initial db tables data del self.lib.db_tables assert self.lib.migration_exists( f"multi_{self.str_field}_field", "items" ) class TestMultiRemixerFieldMigration(MultiArtistFieldMigrationTestMixin): str_field = "remixer" list_field = "remixers" migration_cls = migrations.MultiRemixerFieldMigration class TestMultiLyricistFieldMigration(MultiArtistFieldMigrationTestMixin): str_field = "lyricist" list_field = "lyricists" migration_cls = migrations.MultiLyricistFieldMigration class TestMultiComposerFieldMigration(MultiArtistFieldMigrationTestMixin): str_field = "composer" list_field = "composers" migration_cls = migrations.MultiComposerFieldMigration class TestMultiArrangerFieldMigration(MultiArtistFieldMigrationTestMixin): str_field = "arranger" list_field = "arrangers" migration_cls = migrations.MultiArrangerFieldMigration class TestLyricsMetadataInFlexFieldsMigration(MigrationTestHelper): """Verify embedded lyrics metadata moves into dedicated flexible fields.""" migration = (migrations.LyricsMetadataInFlexFieldsMigration, (Item,)) def test_migrate(self, is_importable): """Ensure extracted metadata is preserved without mutating plain lyrics.""" lyrics_item = self.add_item( lyrics=textwrap.dedent(""" [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/1/""") ) instrumental_lyrics_item = self.add_item(lyrics="[Instrumental]") self.lib._migrate() lyrics_item.load() assert lyrics_item.lyrics == textwrap.dedent( """ [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""" ) assert lyrics_item.lyrics_backend == "lrclib" assert lyrics_item.lyrics_url == "https://lrclib.net/api/1/" if is_importable("langdetect"): assert lyrics_item.lyrics_language == "EN" assert lyrics_item.lyrics_translation_language == "FR" else: with pytest.raises(AttributeError): instrumental_lyrics_item.lyrics_language with pytest.raises(AttributeError): instrumental_lyrics_item.lyrics_translation_language with pytest.raises(AttributeError): instrumental_lyrics_item.lyrics_backend with pytest.raises(AttributeError): instrumental_lyrics_item.lyrics_url with pytest.raises(AttributeError): instrumental_lyrics_item.lyrics_language with pytest.raises(AttributeError): instrumental_lyrics_item.lyrics_translation_language # remove cached initial db tables data del self.lib.db_tables assert self.lib.migration_exists( "lyrics_metadata_in_flex_fields", "items" ) class TestRemoveInheritedArtpathMigration(MigrationTestHelper): """Verify inherited artpath flex attributes are removed from items.""" migration = (migrations.RemoveInheritedArtpathMigration, (Item,)) def test_migrate(self): """Ensure the inherited artpath flex attribute is removed from items.""" item = self.add_item(artpath="/abs/path/to/cover.jpg") self.lib._migrate() item.load() with pytest.raises(AttributeError): item.artpath # remove cached initial db tables data del self.lib.db_tables assert self.lib.migration_exists("remove_inherited_artpath", "items") class TestRelativePathMigration(MigrationTestHelper): """Verify stored item paths are rewritten to library-relative values.""" migration = (migrations.RelativePathMigration, (Item,)) def test_migrate(self): """Ensure stored paths become relative while the public API stays stable.""" relative_path = os.path.join("foo", "bar", "baz.mp3") abs_string_path = str(self.lib_path / relative_path) abs_bytes_path = os.fsencode(abs_string_path) # need to insert the path directly into the database to bypass the path setter self.lib._connection().executemany( "INSERT INTO items (id, path) VALUES (?, ?)", ( (1, abs_bytes_path), # add a string path to ensure the migration handles it (2, abs_string_path), ), ) old_stored_path = ( self.lib._connection() .execute("select path from items where id=?", (1,)) .fetchone()[0] ) assert old_stored_path == abs_bytes_path self.lib._migrate() item = self.lib.get_item(1) assert item # and now we have a relative path stored_path = ( self.lib._connection() .execute("select path from items where id=?", (item.id,)) .fetchone()[0] ) assert stored_path == path_as_posix(os.fsencode(relative_path)) # and the item.path property still returns an absolute path assert item.path == abs_bytes_path # also check the string path was migrated correctly str_item = self.lib.get_item(2) assert str_item assert str_item.path == abs_bytes_path class TestMigrationBackup(MigrationTestHelper): """Tests for the backup-before-migration feature.""" migration = (migrations.LyricsMetadataInFlexFieldsMigration, (Item,)) db_on_disk = True @classmethod def setup_previous_state(cls, monkeypatch): monkeypatch.setattr( "beets.library.models.Item._fields", {**Item._fields, "lyrics": types.STRING}, ) @pytest.mark.parametrize( "config_value, expected_count", [(True, 1), (False, 0)] ) def test_backup_config(self, config_value, expected_count): self.config["create_backup_before_migrations"] = config_value self.add_item(lyrics="some lyrics") db_path = self.lib.path self.lib._migrate() backups = [ f for f in os.listdir(os.path.dirname(db_path)) 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" )