Store relative paths in the DB to make beets library portable#6460
Store relative paths in the DB to make beets library portable#6460
Conversation
Convert item paths to relative on write and back to absolute on read, keeping the database free of hardcoded library directory. Fix tests to account for absolute path return values.
|
Thank you for the PR! The changelog has not been updated, so here is a friendly reminder to check if you need to add an entry. |
7fb98e0 to
68d0d21
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #6460 +/- ##
==========================================
+ Coverage 69.74% 69.85% +0.11%
==========================================
Files 145 147 +2
Lines 18495 18596 +101
Branches 3007 3021 +14
==========================================
+ Hits 12899 12991 +92
- Misses 4967 4972 +5
- Partials 629 633 +4
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
PR make beets store item/album path in DB as library-relative, then expand back to absolute on read. Goal: portable DB when music folder move.
Changes:
- Add context var for active music dir, used by DB path encode/decode.
- Move path normalize/expand into
dbcoretypes + query layer, plus add migration to rewrite old rows. - Propagate context into thread pools/pipeline; update tests to expect absolute paths on public API.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
beets/context.py |
Add context var for music dir. |
beets/library/library.py |
Set music dir context early; register new migration. |
beets/dbcore/pathutils.py |
New helpers to normalize/expand paths for DB. |
beets/dbcore/types.py |
PathType encode/decode moved into DB type layer. |
beets/dbcore/query.py |
PathQuery now compares using DB-relative representation. |
beets/library/models.py |
Adjust non-PathQuery path field queries to match stored relative value. |
beets/library/migrations.py |
Add RelativePathMigration; add chunk size constant usage. |
beets/dbcore/db.py |
Add base migration chunk size constant. |
beets/util/pipeline.py |
Copy context into pipeline worker threads. |
beets/util/__init__.py |
Propagate context into par_map thread pool. |
beetsplug/replaygain.py |
Wrap pool work + callbacks in copied context. |
beetsplug/ipfs.py |
Create temp lib with /ipfs/ dir; store item paths relative to that root. |
beets/ui/__init__.py |
Override library DB path from BEETS_LIBRARY env var. |
test/test_library.py |
Update path expectations; assert raw stored relative paths. |
test/test_query.py |
Add query test for absolute input path. |
test/library/test_migrations.py |
Add migration test for absolute->relative rewrite. |
test/ui/test_ui.py |
Update ls -p output expectations to absolute paths. |
test/ui/commands/test_list.py |
Update $path output expectations to absolute paths. |
test/plugins/test_ipfs.py |
Normalize expected /ipfs/... path string. |
test/test_files.py |
Remove one remove/prune test (no replacement in this PR). |
68d0d21 to
7588539
Compare
0e2a815 to
65f87ba
Compare
4f52af6 to
c1b11aa
Compare
4473908 to
8637db7
Compare
| if not path or not os.path.isabs(path): | ||
| return path | ||
|
|
||
| music_dir = context.get_music_dir() | ||
| if not music_dir: | ||
| return path | ||
|
|
||
| if _is_same_path_or_child(path, music_dir): | ||
| return os.path.relpath(path, music_dir) | ||
|
|
There was a problem hiding this comment.
grug worry normalize_path_for_db do string prefix check before path normalized. If caller pass path like "/music/../other/file.mp3", helper think it child of music_dir and store "../other/file.mp3". Later expand make absolute outside library, break invariant "outside music dir stored as-is". Fix: normalize/abspath path (and music_dir) before _is_same_path_or_child/relpath, or use util.normpath on inputs inside normalize_path_for_db.
| if not path or not os.path.isabs(path): | |
| return path | |
| music_dir = context.get_music_dir() | |
| if not music_dir: | |
| return path | |
| if _is_same_path_or_child(path, music_dir): | |
| return os.path.relpath(path, music_dir) | |
| if not path: | |
| return path | |
| music_dir = context.get_music_dir() | |
| if not music_dir: | |
| return path | |
| # Normalize paths before checking directory relationship to avoid | |
| # misclassifying paths with ".." components as children of music_dir. | |
| normalized_path = util.normpath(path) | |
| normalized_music_dir = util.normpath(music_dir) | |
| if not os.path.isabs(normalized_path): | |
| return path | |
| if _is_same_path_or_child(normalized_path, normalized_music_dir): | |
| return os.path.relpath(normalized_path, normalized_music_dir) |
| # Query parsing needs the library root, but keeping it scoped here | ||
| # avoids leaking one Library's directory into another's work. | ||
| with context.music_dir(self.directory): | ||
| if isinstance(query, str): | ||
| query, parsed_sort = parse_query_string(query, model_cls) | ||
| elif isinstance(query, (list, tuple)): | ||
| query, parsed_sort = parse_query_parts(query, model_cls) |
There was a problem hiding this comment.
grug see Library._fetch set music_dir only while parsing query. But Results build Item/Album later when iterating, and PathType.from_sql need music_dir then. If other Library made after this one, context reset and paths expand against wrong root. grug think wrap whole Results materialization/iteration in this library music_dir_context (or make Results/_awaken run under stored context) so paths always expand with right library.
4ec83e6 to
63b5f89
Compare
Move path relativization/expansion logic from Item._setitem/__getitem__ into dbcore layer (PathType.to_sql/from_sql and PathQuery), so all models benefit without per-model overrides. Propagate contextvars to pipeline and replaygain pool threads so the library root context variable is available during background processing.
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.
5ae4c12 to
23ed852
Compare
dc952de to
318f2fd
Compare
Migrate Item & Album Paths to Library-Relative Storage
Fixes: #133
Core Problem Solved
Before: Beets stored absolute file system paths in SQLite (e.g.
/home/user/Music/Artist/album/track.mp3). This made library databases non-portable — moving the music directory or sharing a database across machines broke all path references.After: Paths are stored relative to the music directory (e.g.
Artist/album/track.mp3), and expanded back to absolute paths transparently on read. The database is now portable.Architecture Changes
1. Context Variable for Music Directory (
beets/context.py)A new
contextvars.ContextVar(_music_dir_var) holds the active music directory, set once duringLibrary.__init__viacontext.set_music_dir(). This avoids passinglibrary.directorythrough every call stack.2. Path Relativization Moved to the DB Layer (
beets/dbcore/)Previously, path conversion lived in
Item._setitem/Item.__getitem__— model-specific overrides. It is now pushed down intoPathType.to_sql/PathType.from_sqlindbcore/types.py, through two helpers in the newbeets/dbcore/pathutils.py:normalize_path_for_db(path)expand_path_from_db(path)All models using
PathType(currentlyItemandAlbum) benefit automatically — no per-model overrides required.3.
PathQueryUpdated for Relative Storage (beets/dbcore/query.py)Queries like
path:/home/user/Music/Artistnow normalize the search term to its relative form before hitting the database, so SQL comparisons match stored values correctly. Bothcol_clause(SQL path) andmatch(in-memory path) usenormalize_path_for_db.4. One-Time Database Migration (
RelativePathMigration)Existing absolute paths in
pathandartpathcolumns are migrated on startup:self.directoryassignment was moved beforesuper().__init__()inLibrary.__init__so the migration can access the music dir when it runs.5. Context Propagation to Background Threads
The music dir context variable must be available in worker threads (pipeline stages, replaygain pool). Two propagation points were added:
beets/util/pipeline.py:Pipeline.run_parallel()snapshots the calling context withcontextvars.copy_context()and passes a per-thread copy to eachPipelineThread. Each stage coroutine is invoked viactx.run(...).beetsplug/replaygain.py: Pool workers and their callbacks are wrapped inctx.run(...)soexpand_path_from_dbworks correctly inside the process pool.beets/util/__init__.py:par_mapsimilarly propagates context into its thread pool workers.Data Flow: Read & Write Path
Key Invariants
item.pathalways returns an absolutebytespath.beetsplug/ipfs.pyfix)./) are skipped.