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:
Šarūnas Nejus 2026-03-23 09:22:10 +00:00
parent f5de3ce85e
commit 43555e2b66
No known key found for this signature in database
4 changed files with 103 additions and 4 deletions

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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