diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index ca60f50ca..664dc93e9 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -1058,6 +1058,8 @@ class Transaction: class Migration(ABC): """Define a one-time data migration that runs during database startup.""" + CHUNK_SIZE: ClassVar[int] = 1000 + db: Database @cached_classproperty diff --git a/beets/library/library.py b/beets/library/library.py index d93d030eb..e7df73e1d 100644 --- a/beets/library/library.py +++ b/beets/library/library.py @@ -23,6 +23,7 @@ class Library(dbcore.Database): _migrations = ( (migrations.MultiGenreFieldMigration, (Item, Album)), (migrations.LyricsMetadataInFlexFieldsMigration, (Item,)), + (migrations.RelativePathMigration, (Item, Album)), ) def __init__( @@ -33,11 +34,11 @@ class Library(dbcore.Database): replacements=None, ): timeout = beets.config["timeout"].as_number() - super().__init__(path, timeout=timeout) - self.directory = normpath(directory or platformdirs.user_music_path()) context.set_music_dir(self.directory) + super().__init__(path, timeout=timeout) + self.path_formats = path_formats self.replacements = replacements diff --git a/beets/library/migrations.py b/beets/library/migrations.py index 30501dab1..e9ff9de63 100644 --- a/beets/library/migrations.py +++ b/beets/library/migrations.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from contextlib import suppress from functools import cached_property from typing import TYPE_CHECKING, NamedTuple, TypeVar @@ -9,6 +10,7 @@ from confuse.exceptions import ConfigError import beets from beets import ui 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 unique_list from beets.util.lyrics import Lyrics @@ -17,6 +19,7 @@ if TYPE_CHECKING: from collections.abc import Iterator from beets.dbcore.db import Model + from beets.library import Library T = TypeVar("T") @@ -81,7 +84,7 @@ class MultiGenreFieldMigration(Migration): migrated = total - len(to_migrate) ui.print_(f"Migrating genres for {total} {table}...") - for batch in chunks(to_migrate, 1000): + for batch in chunks(to_migrate, self.CHUNK_SIZE): with self.db.transaction() as tx: tx.mutate_many( f"UPDATE {table} SET genres = ? WHERE id = ?", @@ -106,6 +109,8 @@ class LyricsRow(NamedTuple): class LyricsMetadataInFlexFieldsMigration(Migration): """Move legacy inline lyrics metadata into dedicated flexible fields.""" + CHUNK_SIZE = 100 + def _migrate_data(self, model_cls: type[Model], _: set[str]) -> None: """Migrate legacy lyrics to move metadata to flex attributes.""" table = model_cls._table @@ -140,7 +145,7 @@ class LyricsMetadataInFlexFieldsMigration(Migration): ui.print_(f"Migrating lyrics for {total} {table}...") lyr_fields = ["backend", "url", "language", "translation_language"] - for batch in chunks(to_migrate, 100): + for batch in chunks(to_migrate, self.CHUNK_SIZE): lyrics_batch = [Lyrics.from_legacy_text(r.lyrics) for r in batch] ids_with_lyrics = [ (lyr, r.id) for lyr, r in zip(lyrics_batch, batch) @@ -181,3 +186,44 @@ class LyricsMetadataInFlexFieldsMigration(Migration): ) ui.print_(f"Migration complete: {migrated} of {total} {table} updated") + + +class RelativePathMigration(Migration): + """Migrate path field to contain value relative to the music directory.""" + + db: Library + + def _migrate_field(self, model_cls: type[Model], field: str) -> None: + table = model_cls._table + + with self.db.transaction() as tx: + rows = tx.query(f"SELECT id, {field} FROM {table}") # type: ignore[assignment] + + total = len(rows) + to_migrate = [r for r in rows if r[field] and os.path.isabs(r[field])] + if not to_migrate: + return + + migrated = total - len(to_migrate) + ui.print_(f"Migrating {field} for {total} {table}...") + for batch in chunks(to_migrate, self.CHUNK_SIZE): + with self.db.transaction() as tx: + tx.mutate_many( + f"UPDATE {table} SET {field} = ? WHERE id = ?", + [(normalize_path_for_db(r[field]), r["id"]) for r in batch], + ) + + migrated += len(batch) + + ui.print_( + f" Migrated {migrated} {table} " + f"({migrated}/{total} processed)..." + ) + + ui.print_(f"Migration complete: {migrated} of {total} {table} updated") + + def _migrate_data( + self, model_cls: type[Model], current_fields: set[str] + ) -> None: + for field in {"path", "artpath"} & current_fields: + self._migrate_field(model_cls, field) diff --git a/test/library/test_migrations.py b/test/library/test_migrations.py index 5bfd5298f..34710f67f 100644 --- a/test/library/test_migrations.py +++ b/test/library/test_migrations.py @@ -1,3 +1,4 @@ +import os import textwrap import pytest @@ -139,3 +140,52 @@ class TestLyricsMetadataInFlexFieldsMigration: assert helper.lib.migration_exists( "lyrics_metadata_in_flex_fields", "items" ) + + +class TestRelativePathMigration: + @pytest.fixture + def helper(self, monkeypatch): + # do not apply migrations upon library initialization + monkeypatch.setattr("beets.library.library.Library._migrations", ()) + + helper = TestHelper() + helper.setup_beets() + + # and now configure the migrations to be tested + monkeypatch.setattr( + "beets.library.library.Library._migrations", + ((migrations.RelativePathMigration, (Item,)),), + ) + yield helper + + helper.teardown_beets() + + def test_migrate(self, helper: TestHelper): + relative_path = "foo/bar/baz.mp3" + absolute_path = os.fsencode(helper.lib_path / relative_path) + + # need to insert the path directly into the database to bypass the path setter + helper.lib._connection().execute( + "INSERT INTO items (id, path) VALUES (?, ?)", (1, absolute_path) + ) + old_stored_path = ( + helper.lib._connection() + .execute("select path from items where id=?", (1,)) + .fetchone()[0] + ) + assert old_stored_path == absolute_path + + helper.lib._migrate() + + item = helper.lib.get_item(1) + assert item + + # and now we have a relative path + stored_path = ( + helper.lib._connection() + .execute("select path from items where id=?", (item.id,)) + .fetchone()[0] + ) + assert stored_path == os.fsencode(relative_path) + # and the item.path property still returns an absolute path + assert item.path == absolute_path