mirror of
https://github.com/beetbox/beets.git
synced 2026-03-30 18:24:01 +02:00
Migrate item/album paths to relative storage
Store paths relative to the music directory in the database instead of absolute paths. Add RelativePathMigration to handle existing absolute paths in `path` and `artpath` fields on startup. Also move `self.directory` assignment before `super().__init__()` so the migration can access it.
This commit is contained in:
parent
f5de3ce85e
commit
43555e2b66
4 changed files with 103 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue