Skip to content

Commit 68d0d21

Browse files
committed
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.
1 parent 6072275 commit 68d0d21

4 files changed

Lines changed: 106 additions & 5 deletions

File tree

beets/dbcore/db.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from abc import ABC, abstractmethod
2727
from collections import defaultdict
2828
from collections.abc import Mapping
29-
from contextlib import contextmanager
29+
from contextlib import contextmanager, suppress
3030
from dataclasses import dataclass
3131
from functools import cached_property
3232
from sqlite3 import Connection, sqlite_version_info
@@ -1058,6 +1058,8 @@ def script(self, statements: str):
10581058
class Migration(ABC):
10591059
"""Define a one-time data migration that runs during database startup."""
10601060

1061+
CHUNK_SIZE: ClassVar[int] = 1000
1062+
10611063
db: Database
10621064

10631065
@cached_classproperty

beets/library/library.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class Library(dbcore.Database):
2323
_migrations = (
2424
(migrations.MultiGenreFieldMigration, (Item, Album)),
2525
(migrations.LyricsMetadataInFlexFieldsMigration, (Item,)),
26+
(migrations.RelativePathMigration, (Item, Album)),
2627
)
2728

2829
def __init__(
@@ -33,11 +34,11 @@ def __init__(
3334
replacements=None,
3435
):
3536
timeout = beets.config["timeout"].as_number()
36-
super().__init__(path, timeout=timeout)
37-
3837
self.directory = normpath(directory or platformdirs.user_music_path())
3938
context.set_music_dir(self.directory)
4039

40+
super().__init__(path, timeout=timeout)
41+
4142
self.path_formats = path_formats
4243
self.replacements = replacements
4344

beets/library/migrations.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import os
34
from contextlib import suppress
45
from functools import cached_property
56
from typing import TYPE_CHECKING, NamedTuple, TypeVar
@@ -17,6 +18,7 @@
1718
from collections.abc import Iterator
1819

1920
from beets.dbcore.db import Model
21+
from beets.library import Library
2022

2123
T = TypeVar("T")
2224

@@ -81,7 +83,7 @@ def _migrate_data(
8183
migrated = total - len(to_migrate)
8284

8385
ui.print_(f"Migrating genres for {total} {table}...")
84-
for batch in chunks(to_migrate, 1000):
86+
for batch in chunks(to_migrate, self.CHUNK_SIZE):
8587
with self.db.transaction() as tx:
8688
tx.mutate_many(
8789
f"UPDATE {table} SET genres = ? WHERE id = ?",
@@ -106,6 +108,8 @@ class LyricsRow(NamedTuple):
106108
class LyricsMetadataInFlexFieldsMigration(Migration):
107109
"""Move legacy inline lyrics metadata into dedicated flexible fields."""
108110

111+
CHUNK_SIZE = 100
112+
109113
def _migrate_data(self, model_cls: type[Model], _: set[str]) -> None:
110114
"""Migrate legacy lyrics to move metadata to flex attributes."""
111115
table = model_cls._table
@@ -140,7 +144,7 @@ def _migrate_data(self, model_cls: type[Model], _: set[str]) -> None:
140144

141145
ui.print_(f"Migrating lyrics for {total} {table}...")
142146
lyr_fields = ["backend", "url", "language", "translation_language"]
143-
for batch in chunks(to_migrate, 100):
147+
for batch in chunks(to_migrate, self.CHUNK_SIZE):
144148
lyrics_batch = [Lyrics.from_legacy_text(r.lyrics) for r in batch]
145149
ids_with_lyrics = [
146150
(lyr, r.id) for lyr, r in zip(lyrics_batch, batch)
@@ -181,3 +185,47 @@ def _migrate_data(self, model_cls: type[Model], _: set[str]) -> None:
181185
)
182186

183187
ui.print_(f"Migration complete: {migrated} of {total} {table} updated")
188+
189+
190+
class RelativePathMigration(Migration):
191+
"""Migrate path field to contain value relative to the music directory."""
192+
193+
db: Library
194+
195+
def _migrate_field(self, model_cls: type[Model], field: str) -> None:
196+
table = model_cls._table
197+
198+
with self.db.transaction() as tx:
199+
rows = tx.query(f"SELECT id, {field} FROM {table}") # type: ignore[assignment]
200+
201+
total = len(rows)
202+
to_migrate = [r for r in rows if r[field] and r[field].startswith(b"/")]
203+
if not to_migrate:
204+
return
205+
206+
migrated = total - len(to_migrate)
207+
ui.print_(f"Migrating {field} for {total} {table}...")
208+
for batch in chunks(to_migrate, self.CHUNK_SIZE):
209+
with self.db.transaction() as tx:
210+
tx.mutate_many(
211+
f"UPDATE {table} SET {field} = ? WHERE id = ?",
212+
[
213+
(os.path.relpath(r[field], self.db.directory), r["id"])
214+
for r in batch
215+
],
216+
)
217+
218+
migrated += len(batch)
219+
220+
ui.print_(
221+
f" Migrated {migrated} {table} "
222+
f"({migrated}/{total} processed)..."
223+
)
224+
225+
ui.print_(f"Migration complete: {migrated} of {total} {table} updated")
226+
227+
def _migrate_data(
228+
self, model_cls: type[Model], current_fields: set[str]
229+
) -> None:
230+
for field in {"path", "artpath"} & current_fields:
231+
self._migrate_field(model_cls, field)

test/library/test_migrations.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
import textwrap
23

34
import pytest
@@ -139,3 +140,52 @@ def test_migrate(self, helper: TestHelper, is_importable):
139140
assert helper.lib.migration_exists(
140141
"lyrics_metadata_in_flex_fields", "items"
141142
)
143+
144+
145+
class TestRelativePathMigration:
146+
@pytest.fixture
147+
def helper(self, monkeypatch):
148+
# do not apply migrations upon library initialization
149+
monkeypatch.setattr("beets.library.library.Library._migrations", ())
150+
151+
helper = TestHelper()
152+
helper.setup_beets()
153+
154+
# and now configure the migrations to be tested
155+
monkeypatch.setattr(
156+
"beets.library.library.Library._migrations",
157+
((migrations.RelativePathMigration, (Item,)),),
158+
)
159+
yield helper
160+
161+
helper.teardown_beets()
162+
163+
def test_migrate(self, helper: TestHelper):
164+
relative_path = "foo/bar/baz.mp3"
165+
absolute_path = os.fsencode(helper.lib_path / relative_path)
166+
167+
# need to insert the path directly into the database to bypass the path setter
168+
helper.lib._connection().execute(
169+
"INSERT INTO items (id, path) VALUES (?, ?)", (1, absolute_path)
170+
)
171+
old_stored_path = (
172+
helper.lib._connection()
173+
.execute("select path from items where id=?", (1,))
174+
.fetchone()[0]
175+
)
176+
assert old_stored_path == absolute_path
177+
178+
helper.lib._migrate()
179+
180+
item = helper.lib.get_item(1)
181+
assert item
182+
183+
# and now we have a relative path
184+
stored_path = (
185+
helper.lib._connection()
186+
.execute("select path from items where id=?", (item.id,))
187+
.fetchone()[0]
188+
)
189+
assert stored_path == os.fsencode(relative_path)
190+
# and the item.path property still returns an absolute path
191+
assert item.path == absolute_path

0 commit comments

Comments
 (0)