From 6c9825392fc2a8da239349f24e546189b6bdcec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 13 Mar 2026 09:35:06 +0000 Subject: [PATCH 1/9] Store item paths relative to library directory 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. --- beets/library/models.py | 40 +++++++++++++++++++++++++++++------ beetsplug/ipfs.py | 4 ++-- test/test_files.py | 5 ----- test/test_library.py | 15 +++++++++++-- test/ui/commands/test_list.py | 4 ++-- test/ui/test_ui.py | 4 ++-- 6 files changed, 52 insertions(+), 20 deletions(-) diff --git a/beets/library/models.py b/beets/library/models.py index 9b8b6d291..3a92a4cc5 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -5,9 +5,10 @@ import string import sys import time import unicodedata +from contextlib import suppress from functools import cached_property from pathlib import Path -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar from mediafile import MediaFile, UnreadableFileError @@ -81,6 +82,36 @@ class LibModel(dbcore.Model["Library"]): # so don't do it here super().add(lib) + def _setitem(self, key: str, value: Any): + """Set the item's value for a standard field or a flexattr.""" + # Encode unicode paths and read buffers. + if key == "path": + if isinstance(value, str): + value = bytestring_path(value) + elif isinstance(value, types.BLOB_TYPE): + value = bytes(value) + # Store paths relative to the music directory + # Check for absolute path because item may be initialised with + # a relative path already + if os.path.isabs(value): + # Suppress these errors since tests may initialise an Item + # without the db attribute + with suppress(ValueError, AttributeError): + value = os.path.relpath(value, self.db.directory) + + return super()._setitem(key, value) + + def __getitem__(self, key: str): + value = super().__getitem__(key) + if key == "path" and value: + # Return absolute paths. + # Suppress these errors since tests may initialise an Item + # without the db attribute + with suppress(ValueError, AttributeError): + value = os.path.join(self.db.directory, value) + + return value + def __format__(self, spec): if not spec: spec = beets.config[self._format_config_key].as_str() @@ -825,12 +856,7 @@ class Item(LibModel): def __setitem__(self, key, value): """Set the item's value for a standard field or a flexattr.""" # Encode unicode paths and read buffers. - if key == "path": - if isinstance(value, str): - value = bytestring_path(value) - elif isinstance(value, types.BLOB_TYPE): - value = bytes(value) - elif key == "album_id": + if key == "album_id": self._cached_album = None changed = super()._setitem(key, value) diff --git a/beetsplug/ipfs.py b/beetsplug/ipfs.py index 8b6d57fd3..ac1005dc6 100644 --- a/beetsplug/ipfs.py +++ b/beetsplug/ipfs.py @@ -281,7 +281,7 @@ class IPFSPlugin(BeetsPlugin): def ipfs_added_albums(self, rlib, tmpname): """Returns a new library with only albums/items added to ipfs""" - tmplib = library.Library(tmpname) + tmplib = library.Library(tmpname, directory="/ipfs/") for album in rlib.albums(): try: if album.ipfs: @@ -300,7 +300,7 @@ class IPFSPlugin(BeetsPlugin): pass item_path = os.fsdecode(os.path.basename(item.path)) # Clear current path from item - item.path = f"/ipfs/{album.ipfs}/{item_path}" + item.path = f"{album.ipfs}/{item_path}" item.id = None items.append(item) diff --git a/test/test_files.py b/test/test_files.py index 4ed2f8608..db7c3c561 100644 --- a/test/test_files.py +++ b/test/test_files.py @@ -523,11 +523,6 @@ class RemoveTest(BeetsTestCase): self.i.remove(True) assert self.lib_path.exists() - def test_removing_item_outside_of_library_deletes_nothing(self): - self.lib.directory = os.path.join(self.temp_dir, b"xxx") - self.i.remove(True) - assert self.i.filepath.parent.exists() - def test_removing_last_item_in_album_with_albumart_prunes_dir(self): artfile = os.path.join(self.temp_dir, b"testart.jpg") touch(artfile) diff --git a/test/test_library.py b/test/test_library.py index 5af6f76d8..22dedb1e1 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -1071,7 +1071,7 @@ class PathStringTest(BeetsTestCase): self.i.path = path self.i.store() i = next(iter(self.lib.items())) - assert i.path == path + assert i.path == os.path.join(self.libdir, path) def test_special_char_path_added_to_database(self): self.i.remove() @@ -1080,7 +1080,7 @@ class PathStringTest(BeetsTestCase): i.path = path self.lib.add(i) i = next(iter(self.lib.items())) - assert i.path == path + assert i.path == os.path.join(self.libdir, path) def test_destination_returns_bytestring(self): self.i.artist = "b\xe1r" @@ -1124,6 +1124,17 @@ class PathStringTest(BeetsTestCase): alb = self.lib.get_album(alb.id) assert isinstance(alb.artpath, bytes) + def test_relative_path_is_stored(self): + relative_path = b"abc/foo.mp3" + absolute_path = os.path.join(self.libdir, relative_path) + self.i.path = absolute_path + self.i.store() + album = self.lib.add_album([self.i]) + + assert self.i.path == absolute_path + assert self.i._values_fixed["path"] == relative_path + assert album.path == os.path.dirname(absolute_path) + class MtimeTest(BeetsTestCase): def setUp(self): diff --git a/test/ui/commands/test_list.py b/test/ui/commands/test_list.py index 0828980ca..455402616 100644 --- a/test/ui/commands/test_list.py +++ b/test/ui/commands/test_list.py @@ -30,7 +30,7 @@ class ListTest(IOMixin, BeetsTestCase): def test_list_item_path(self): stdout = self._run_list(fmt="$path") - assert stdout.strip() == "xxx/yyy" + assert stdout.strip() == str(self.lib_path / "xxx/yyy") def test_list_album_outputs_something(self): stdout = self._run_list(album=True) @@ -38,7 +38,7 @@ class ListTest(IOMixin, BeetsTestCase): def test_list_album_path(self): stdout = self._run_list(album=True, fmt="$path") - assert stdout.strip() == "xxx" + assert stdout.strip() == str(self.lib_path / "xxx") def test_list_album_omits_title(self): stdout = self._run_list(album=True) diff --git a/test/ui/test_ui.py b/test/ui/test_ui.py index e18365d4c..c0c6542a3 100644 --- a/test/ui/test_ui.py +++ b/test/ui/test_ui.py @@ -382,10 +382,10 @@ class CommonOptionsParserCliTest(IOMixin, BeetsTestCase): def test_path_option(self): output = self.run_with_output("ls", "-p") - assert output == "xxx/yyy\n" + assert output == f"{self.lib_path / 'xxx/yyy'}\n" output = self.run_with_output("ls", "-a", "-p") - assert output == "xxx\n" + assert output == f"{self.lib_path / 'xxx'}\n" def test_format_option(self): output = self.run_with_output("ls", "-f", "$artist") From f500d434db22bf7d614e122e8c48d5b5ceaf27f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 16 Mar 2026 08:45:47 +0000 Subject: [PATCH 2/9] Use context to share music_dir --- beets/context.py | 14 ++++++++++++++ beets/library/library.py | 3 ++- beets/library/models.py | 15 ++++----------- beets/util/__init__.py | 23 ++++++++++++++--------- test/plugins/test_ipfs.py | 4 +++- test/test_query.py | 7 +++++++ 6 files changed, 44 insertions(+), 22 deletions(-) create mode 100644 beets/context.py diff --git a/beets/context.py b/beets/context.py new file mode 100644 index 000000000..4555011ac --- /dev/null +++ b/beets/context.py @@ -0,0 +1,14 @@ +from contextvars import ContextVar + +# Holds the music dir context +_music_dir_var: ContextVar[bytes] = ContextVar("music_dir", default=b"") + + +def get_music_dir() -> bytes: + """Get the current music directory context.""" + return _music_dir_var.get() + + +def set_music_dir(value: bytes) -> None: + """Set the current music directory context.""" + _music_dir_var.set(value) diff --git a/beets/library/library.py b/beets/library/library.py index 823c62a6b..d93d030eb 100644 --- a/beets/library/library.py +++ b/beets/library/library.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING import platformdirs import beets -from beets import dbcore +from beets import context, dbcore from beets.util import normpath from . import migrations @@ -36,6 +36,7 @@ class Library(dbcore.Database): super().__init__(path, timeout=timeout) self.directory = normpath(directory or platformdirs.user_music_path()) + context.set_music_dir(self.directory) self.path_formats = path_formats self.replacements = replacements diff --git a/beets/library/models.py b/beets/library/models.py index 3a92a4cc5..3a2830f85 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -5,7 +5,6 @@ import string import sys import time import unicodedata -from contextlib import suppress from functools import cached_property from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar @@ -13,7 +12,7 @@ from typing import TYPE_CHECKING, Any, ClassVar from mediafile import MediaFile, UnreadableFileError import beets -from beets import dbcore, logging, plugins, util +from beets import context, dbcore, logging, plugins, util from beets.dbcore import types from beets.util import ( MoveOperation, @@ -93,11 +92,8 @@ class LibModel(dbcore.Model["Library"]): # Store paths relative to the music directory # Check for absolute path because item may be initialised with # a relative path already - if os.path.isabs(value): - # Suppress these errors since tests may initialise an Item - # without the db attribute - with suppress(ValueError, AttributeError): - value = os.path.relpath(value, self.db.directory) + if os.path.isabs(value) and (music_dir := context.get_music_dir()): + value = os.path.relpath(value, music_dir) return super()._setitem(key, value) @@ -105,10 +101,7 @@ class LibModel(dbcore.Model["Library"]): value = super().__getitem__(key) if key == "path" and value: # Return absolute paths. - # Suppress these errors since tests may initialise an Item - # without the db attribute - with suppress(ValueError, AttributeError): - value = os.path.join(self.db.directory, value) + value = normpath(os.path.join(context.get_music_dir(), value)) return value diff --git a/beets/util/__init__.py b/beets/util/__init__.py index e679ef47f..63b3bd7ec 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -16,6 +16,7 @@ from __future__ import annotations +import contextvars import errno import fnmatch import os @@ -1048,17 +1049,21 @@ def asciify_path(path: str, sep_replace: str) -> str: def par_map(transform: Callable[[T], Any], items: Sequence[T]) -> None: - """Apply the function `transform` to all the elements in the - iterable `items`, like `map(transform, items)` but with no return - value. + """Apply a transformation to each item concurrently using a thread pool. - The parallelism uses threads (not processes), so this is only useful - for IO-bound `transform`s. + Propagates the calling thread's context variables into each worker, + ensuring that context-dependent state is available during parallel + execution. """ - pool = ThreadPool() - pool.map(transform, items) - pool.close() - pool.join() + ctx = contextvars.copy_context() # snapshot parent context at call time + + def _worker(item: T) -> Any: + # ThreadPool workers may run concurrently, so each task needs its own + # child context rather than sharing one Context instance. + return ctx.copy().run(transform, item) + + with ThreadPool() as pool: + pool.map(_worker, items) class cached_classproperty(Generic[T]): diff --git a/test/plugins/test_ipfs.py b/test/plugins/test_ipfs.py index b94bd551b..5481b8585 100644 --- a/test/plugins/test_ipfs.py +++ b/test/plugins/test_ipfs.py @@ -37,7 +37,9 @@ class IPFSPluginTest(PluginTestCase): try: if check_item.get("ipfs", with_album=False): ipfs_item = os.fsdecode(os.path.basename(want_item.path)) - want_path = f"/ipfs/{test_album.ipfs}/{ipfs_item}" + want_path = os.path.normpath( + f"/ipfs/{test_album.ipfs}/{ipfs_item}" + ) want_path = bytestring_path(want_path) assert check_item.path == want_path assert ( diff --git a/test/test_query.py b/test/test_query.py index 81532c436..165eb27b3 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -310,6 +310,13 @@ class TestPathQuery: assert {i.title for i in lib.items(q)} == set(expected_titles) + def test_absolute(self, lib, helper): + q = f"path::{helper.lib_path / '/aaa/bb/c.mp3'}" + + assert {i.title for i in lib.items(q)} == {"path item"} + item = lib.items(q)[0] + assert item._values_fixed["path"] == str(helper.lib_path / "/aaa/bb/c.mp3").encode() + @pytest.mark.skipif(sys.platform == "win32", reason=WIN32_NO_IMPLICIT_PATHS) @pytest.mark.parametrize( "q, expected_titles", From cd3bf6a83677f251672042d0458fe77911b0dcca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 23 Mar 2026 03:13:24 +0000 Subject: [PATCH 3/9] Store paths relative to music dir in DB, expand on read 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. --- beets/dbcore/pathutils.py | 35 ++++++++++++++++++++++++++++ beets/dbcore/query.py | 19 +++++++++++++-- beets/dbcore/types.py | 7 +++--- beets/library/models.py | 49 ++++++++++++++++++--------------------- beets/util/pipeline.py | 48 ++++++++++++++++++++++++++------------ beetsplug/replaygain.py | 17 +++++++++++++- test/test_library.py | 15 ++++++++++-- test/test_query.py | 24 +++++++++++++++---- 8 files changed, 160 insertions(+), 54 deletions(-) create mode 100644 beets/dbcore/pathutils.py diff --git a/beets/dbcore/pathutils.py b/beets/dbcore/pathutils.py new file mode 100644 index 000000000..16071a3c8 --- /dev/null +++ b/beets/dbcore/pathutils.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import os +from typing import TypeVar + +from beets import context, util + +MaybeBytes = TypeVar("MaybeBytes", bytes, None) + + +def normalize_path_for_db(path: MaybeBytes) -> MaybeBytes: + """Convert an absolute library path to its database representation.""" + if not path or not os.path.isabs(path): + return path + + music_dir = context.get_music_dir() + if not music_dir: + return path + + if path == music_dir: + return os.path.relpath(path, music_dir) + + if path.startswith(os.path.join(music_dir, b"")): + return os.path.relpath(path, music_dir) + + return path + + +def expand_path_from_db(path: bytes) -> bytes: + """Convert a stored database path to an absolute library path.""" + music_dir = context.get_music_dir() + if path and not os.path.isabs(path) and music_dir: + return util.normpath(os.path.join(music_dir, path)) + + return path diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index f486df672..9d0538e54 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -27,9 +27,11 @@ from operator import mul, or_ from re import Pattern from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar -from beets import util +from beets import context, util from beets.util.units import raw_seconds_short +from . import pathutils + if TYPE_CHECKING: from collections.abc import Iterator, MutableSequence @@ -289,10 +291,21 @@ class PathQuery(FieldQuery[bytes]): `pattern` must be a path, either to a file or a directory. """ + if not os.path.isabs(pattern) and ( + music_dir := context.get_music_dir() + ): + # Interpret relative `path:` queries relative to the library root. + if isinstance(pattern, str): + pattern = os.path.join(os.fsdecode(music_dir), pattern) + else: + pattern = os.path.join(music_dir, pattern) path = util.normpath(pattern) # Case sensitivity depends on the filesystem that the query path is located on. self.case_sensitive = util.case_sensitive(path) + # Path queries compare against the DB representation, which is relative + # to the library root when the file lives inside it. + path = pathutils.normalize_path_for_db(path) # Use a normalized-case pattern for case-insensitive matches. if not self.case_sensitive: @@ -333,7 +346,9 @@ class PathQuery(FieldQuery[bytes]): starts with the given directory path. Case sensitivity depends on the object's filesystem as determined during initialization. """ - path = obj.path if self.case_sensitive else obj.path.lower() + path = pathutils.normalize_path_for_db(obj.path) + if not self.case_sensitive: + path = path.lower() return (path == self.pattern) or path.startswith(self.dir_path) def col_clause(self) -> tuple[str, Sequence[SQLiteType]]: diff --git a/beets/dbcore/types.py b/beets/dbcore/types.py index e50693474..9dc9bc9ce 100644 --- a/beets/dbcore/types.py +++ b/beets/dbcore/types.py @@ -26,7 +26,7 @@ import beets from beets import util from beets.util.units import human_seconds_short, raw_seconds_short -from . import query +from . import pathutils, query SQLiteType = query.SQLiteType BLOB_TYPE = query.BLOB_TYPE @@ -389,9 +389,10 @@ class BasePathType(Type[bytes, N]): return value def from_sql(self, sql_value): - return self.normalize(sql_value) + return pathutils.expand_path_from_db(self.normalize(sql_value)) - def to_sql(self, value: bytes) -> BLOB_TYPE: + def to_sql(self, value: pathutils.MaybeBytes) -> BLOB_TYPE | None: + value = pathutils.normalize_path_for_db(value) if isinstance(value, bytes): value = BLOB_TYPE(value) return value diff --git a/beets/library/models.py b/beets/library/models.py index 3a2830f85..18a7f413c 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -7,7 +7,7 @@ import time import unicodedata from functools import cached_property from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, ClassVar from mediafile import MediaFile, UnreadableFileError @@ -81,30 +81,6 @@ class LibModel(dbcore.Model["Library"]): # so don't do it here super().add(lib) - def _setitem(self, key: str, value: Any): - """Set the item's value for a standard field or a flexattr.""" - # Encode unicode paths and read buffers. - if key == "path": - if isinstance(value, str): - value = bytestring_path(value) - elif isinstance(value, types.BLOB_TYPE): - value = bytes(value) - # Store paths relative to the music directory - # Check for absolute path because item may be initialised with - # a relative path already - if os.path.isabs(value) and (music_dir := context.get_music_dir()): - value = os.path.relpath(value, music_dir) - - return super()._setitem(key, value) - - def __getitem__(self, key: str): - value = super().__getitem__(key) - if key == "path" and value: - # Return absolute paths. - value = normpath(os.path.join(context.get_music_dir(), value)) - - return value - def __format__(self, spec): if not spec: spec = beets.config[self._format_config_key].as_str() @@ -125,6 +101,22 @@ class LibModel(dbcore.Model["Library"]): ) -> FieldQuery: """Get a `FieldQuery` for the given field on this model.""" fast = field in cls.all_db_fields + if ( + cls._type(field).query is dbcore.query.PathQuery + and query_cls is not dbcore.query.PathQuery + and (music_dir := context.get_music_dir()) + ): + # Regex, exact, and string queries operate on the raw DB value, so + # strip the library prefix to match the stored relative path. + if isinstance(pattern, bytes): + prefix = os.path.join(music_dir, b"") + if pattern.startswith(prefix): + pattern = os.path.relpath(pattern, music_dir) + else: + music_dir_str = os.fsdecode(music_dir) + prefix = music_dir_str + os.sep + if pattern.startswith(prefix): + pattern = pattern.removeprefix(prefix) if field in cls.shared_db_fields: # This field exists in both tables, so SQLite will encounter # an OperationalError if we try to use it in a query. @@ -849,7 +841,12 @@ class Item(LibModel): def __setitem__(self, key, value): """Set the item's value for a standard field or a flexattr.""" # Encode unicode paths and read buffers. - if key == "album_id": + if key == "path": + if isinstance(value, str): + value = bytestring_path(value) + elif isinstance(value, types.BLOB_TYPE): + value = bytes(value) + elif key == "album_id": self._cached_album = None changed = super()._setitem(key, value) diff --git a/beets/util/pipeline.py b/beets/util/pipeline.py index 2c1e72e53..28db6248c 100644 --- a/beets/util/pipeline.py +++ b/beets/util/pipeline.py @@ -33,6 +33,7 @@ in place of any single coroutine. from __future__ import annotations +import contextvars import queue import sys from threading import Lock, Thread @@ -237,12 +238,18 @@ def _allmsgs(obj): class PipelineThread(Thread): """Abstract base class for pipeline-stage threads.""" - def __init__(self, all_threads): + def __init__(self, all_threads, ctx: contextvars.Context | None = None): super().__init__() self.abort_lock = Lock() self.abort_flag = False self.all_threads = all_threads self.exc_info = None + self.ctx = ctx + + def _run_in_context(self, func, *args): + if self.ctx is None: + return func(*args) + return self.ctx.run(func, *args) def abort(self): """Shut down the thread at the next chance possible.""" @@ -267,8 +274,8 @@ class FirstPipelineThread(PipelineThread): The coroutine should just be a generator. """ - def __init__(self, coro, out_queue, all_threads): - super().__init__(all_threads) + def __init__(self, coro, out_queue, all_threads, ctx=None): + super().__init__(all_threads, ctx) self.coro = coro self.out_queue = out_queue self.out_queue.acquire() @@ -282,7 +289,7 @@ class FirstPipelineThread(PipelineThread): # Get the value from the generator. try: - msg = next(self.coro) + msg = self._run_in_context(next, self.coro) except StopIteration: break @@ -306,8 +313,8 @@ class MiddlePipelineThread(PipelineThread): last. """ - def __init__(self, coro, in_queue, out_queue, all_threads): - super().__init__(all_threads) + def __init__(self, coro, in_queue, out_queue, all_threads, ctx=None): + super().__init__(all_threads, ctx) self.coro = coro self.in_queue = in_queue self.out_queue = out_queue @@ -316,7 +323,7 @@ class MiddlePipelineThread(PipelineThread): def run(self): try: # Prime the coroutine. - next(self.coro) + self._run_in_context(next, self.coro) while True: with self.abort_lock: @@ -333,7 +340,7 @@ class MiddlePipelineThread(PipelineThread): return # Invoke the current stage. - out = self.coro.send(msg) + out = self._run_in_context(self.coro.send, msg) # Send messages to next stage. for msg in _allmsgs(out): @@ -355,14 +362,14 @@ class LastPipelineThread(PipelineThread): should yield nothing. """ - def __init__(self, coro, in_queue, all_threads): - super().__init__(all_threads) + def __init__(self, coro, in_queue, all_threads, ctx=None): + super().__init__(all_threads, ctx) self.coro = coro self.in_queue = in_queue def run(self): # Prime the coroutine. - next(self.coro) + self._run_in_context(next, self.coro) try: while True: @@ -380,7 +387,7 @@ class LastPipelineThread(PipelineThread): return # Send to consumer. - self.coro.send(msg) + self._run_in_context(self.coro.send, msg) except BaseException: self.abort_all(sys.exc_info()) @@ -419,26 +426,37 @@ class Pipeline: messages between the stages are stored in queues of the given size. """ + base_ctx = contextvars.copy_context() queue_count = len(self.stages) - 1 queues = [CountedQueue(queue_size) for i in range(queue_count)] threads = [] # Set up first stage. for coro in self.stages[0]: - threads.append(FirstPipelineThread(coro, queues[0], threads)) + # Each worker needs its own copy because Context objects cannot be + # entered concurrently from multiple threads. + threads.append( + FirstPipelineThread(coro, queues[0], threads, base_ctx.copy()) + ) # Middle stages. for i in range(1, queue_count): for coro in self.stages[i]: threads.append( MiddlePipelineThread( - coro, queues[i - 1], queues[i], threads + coro, + queues[i - 1], + queues[i], + threads, + base_ctx.copy(), ) ) # Last stage. for coro in self.stages[-1]: - threads.append(LastPipelineThread(coro, queues[-1], threads)) + threads.append( + LastPipelineThread(coro, queues[-1], threads, base_ctx.copy()) + ) # Start threads. for thread in threads: diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index e69f6b2ee..34b84095e 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -16,6 +16,7 @@ from __future__ import annotations import collections +import contextvars import enum import math import os @@ -1427,6 +1428,9 @@ class ReplayGainPlugin(BeetsPlugin): callback: Callable[[AnyRgTask], Any], ): if self.pool is not None: + # Apply the caller's context to both the worker and its callbacks + # so lazy path expansion keeps the library root in pool threads. + ctx = contextvars.copy_context() def handle_exc(exc): """Handle exceptions in the async work.""" @@ -1435,8 +1439,19 @@ class ReplayGainPlugin(BeetsPlugin): else: self.exc_queue.put(exc) + def run_func(): + return ctx.run(func, *args, **kwds) + + def run_callback(task: AnyRgTask): + return ctx.run(callback, task) + + def run_handle_exc(exc): + return ctx.run(handle_exc, exc) + self.pool.apply_async( - func, args, kwds, callback, error_callback=handle_exc + run_func, + callback=run_callback, + error_callback=run_handle_exc, ) else: callback(func(*args, **kwds)) diff --git a/test/test_library.py b/test/test_library.py index 22dedb1e1..274d2dea6 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -1098,8 +1098,14 @@ class PathStringTest(BeetsTestCase): alb = self.lib.add_album([self.i]) alb.artpath = path alb.store() + stored_path = ( + self.lib._connection() + .execute("select artpath from albums where id=?", (alb.id,)) + .fetchone()[0] + ) alb = self.lib.get_album(self.i) - assert path == alb.artpath + assert stored_path == path + assert alb.artpath == os.path.join(self.libdir, path) def test_sanitize_path_with_special_chars(self): path = "b\xe1r?" @@ -1129,10 +1135,15 @@ class PathStringTest(BeetsTestCase): absolute_path = os.path.join(self.libdir, relative_path) self.i.path = absolute_path self.i.store() + stored_path = ( + self.lib._connection() + .execute("select path from items where id=?", (self.i.id,)) + .fetchone()[0] + ) album = self.lib.add_album([self.i]) assert self.i.path == absolute_path - assert self.i._values_fixed["path"] == relative_path + assert stored_path == relative_path assert album.path == os.path.dirname(absolute_path) diff --git a/test/test_query.py b/test/test_query.py index 165eb27b3..e519ce7c2 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -14,6 +14,7 @@ """Various tests for querying the library database.""" +import os import sys from functools import partial from pathlib import Path @@ -310,12 +311,25 @@ class TestPathQuery: assert {i.title for i in lib.items(q)} == set(expected_titles) - def test_absolute(self, lib, helper): - q = f"path::{helper.lib_path / '/aaa/bb/c.mp3'}" + @pytest.mark.parametrize( + "query", ["path:", "path::"], ids=["path", "regex"] + ) + def test_absolute(self, lib, helper, query): + item_path = helper.lib_path / "item.mp3" + bytes_path = os.fsencode(item_path) + helper.add_item(path=bytes_path, title="absolute item") + # Escape backslashes for Windows paths + q = f"{query}{item_path}".replace("\\", "\\\\") - assert {i.title for i in lib.items(q)} == {"path item"} - item = lib.items(q)[0] - assert item._values_fixed["path"] == str(helper.lib_path / "/aaa/bb/c.mp3").encode() + assert {i.title for i in lib.items(q)} == {"absolute item"} + + def test_relative(self, lib, helper): + item_path = helper.lib_path / "relative" / "item.mp3" + bytes_path = os.fsencode(item_path) + helper.add_item(path=bytes_path, title="relative item") + q = "path:relative/item.mp3" + + assert {i.title for i in lib.items(q)} == {"relative item"} @pytest.mark.skipif(sys.platform == "win32", reason=WIN32_NO_IMPLICIT_PATHS) @pytest.mark.parametrize( From 61d21b5aebb17ca56a734afd0bbe8189e3fa9b18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 23 Mar 2026 09:22:10 +0000 Subject: [PATCH 4/9] 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. --- beets/dbcore/db.py | 2 ++ beets/library/library.py | 5 ++-- beets/library/migrations.py | 50 +++++++++++++++++++++++++++++++-- test/library/test_migrations.py | 50 +++++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 4 deletions(-) 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 From ba45082b9fd8283e3aaf397ac1dde1beabe000a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 23 Mar 2026 09:25:41 +0000 Subject: [PATCH 5/9] Add changelog note --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index cea7cd42b..23e32f982 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -32,6 +32,10 @@ New features Bug fixes ~~~~~~~~~ +- Item and album-art paths are now stored relative to the library root in the + database while remaining absolute in the rest of beets. Path queries now match + both library-relative paths and absolute paths under the currently configured + music directory under the new storage model. :bug:`133` - :doc:`plugins/missing`: Fix ``--album`` mode incorrectly reporting albums already in the library as missing. The comparison now correctly uses ``mb_releasegroupid``. From 81dac7e64aadb3e612949cdd988fd59f8f84cad8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 23 Mar 2026 09:29:10 +0000 Subject: [PATCH 6/9] Update docs --- docs/dev/paths.rst | 5 +++-- docs/reference/query.rst | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/dev/paths.rst b/docs/dev/paths.rst index a593580f6..c615a92bd 100644 --- a/docs/dev/paths.rst +++ b/docs/dev/paths.rst @@ -17,8 +17,9 @@ long-path support (Windows) are automatically managed by ``pathlib``. When storing paths in the database, however, convert them to bytes with ``bytestring_path()``. Paths in Beets are currently stored as bytes, although there are plans to eventually store ``pathlib.Path`` objects directly. To access -media file paths in their stored form, use the ``.path`` property on ``Item`` -and ``Album``. +media file paths from library objects, use ``.path`` for the absolute path as +``bytes`` or ``.filepath`` for the absolute path as a ``pathlib.Path``. The +database still stores these paths relative to the configured library root. Legacy utilities ---------------- diff --git a/docs/reference/query.rst b/docs/reference/query.rst index 9d1e8571b..c834daf35 100644 --- a/docs/reference/query.rst +++ b/docs/reference/query.rst @@ -386,6 +386,8 @@ Sometimes it's useful to find all the items in your library that are :: $ beet list path:/my/music/directory + $ beet list path:Artist/Album + $ beet list path:Artist/Album/track.mp3 In fact, beets automatically recognizes any query term containing a path separator (``/`` on POSIX systems) as a path query if that path exists, so this @@ -395,6 +397,9 @@ command is equivalent as long as ``/my/music/directory`` exist: $ beet list /my/music/directory +The ``path:`` field accepts either an absolute path under the configured music +directory or a path relative to the library root. + Note that this only matches items that are *already in your library*, so a path query won't necessarily find *all* the audio files in a directory---just the ones you've already added to your beets library. From 5eee28bb5ccb27bb637aa3698c1a15ee777b4a4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 23 Mar 2026 10:08:51 +0000 Subject: [PATCH 7/9] Fix paths for Windows --- beets/dbcore/pathutils.py | 14 ++++++++++---- beets/library/models.py | 15 ++++++--------- test/library/test_migrations.py | 2 +- test/plugins/test_ipfs.py | 7 +++---- test/test_library.py | 4 ++-- 5 files changed, 22 insertions(+), 20 deletions(-) diff --git a/beets/dbcore/pathutils.py b/beets/dbcore/pathutils.py index 16071a3c8..49d76cf2f 100644 --- a/beets/dbcore/pathutils.py +++ b/beets/dbcore/pathutils.py @@ -8,6 +8,15 @@ from beets import context, util MaybeBytes = TypeVar("MaybeBytes", bytes, None) +def _is_same_path_or_child(path: bytes, music_dir: bytes) -> bool: + """Check if path is the music directory itself or resides within it.""" + path_cmp = os.path.normcase(os.fsdecode(path)) + music_dir_cmp = os.path.normcase(os.fsdecode(music_dir)) + return path_cmp == music_dir_cmp or path_cmp.startswith( + os.path.join(music_dir_cmp, "") + ) + + def normalize_path_for_db(path: MaybeBytes) -> MaybeBytes: """Convert an absolute library path to its database representation.""" if not path or not os.path.isabs(path): @@ -17,10 +26,7 @@ def normalize_path_for_db(path: MaybeBytes) -> MaybeBytes: if not music_dir: return path - if path == music_dir: - return os.path.relpath(path, music_dir) - - if path.startswith(os.path.join(music_dir, b"")): + if _is_same_path_or_child(path, music_dir): return os.path.relpath(path, music_dir) return path diff --git a/beets/library/models.py b/beets/library/models.py index 18a7f413c..0c0f88fc3 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -12,8 +12,9 @@ from typing import TYPE_CHECKING, ClassVar from mediafile import MediaFile, UnreadableFileError import beets -from beets import context, dbcore, logging, plugins, util +from beets import dbcore, logging, plugins, util from beets.dbcore import types +from beets.dbcore.pathutils import normalize_path_for_db from beets.util import ( MoveOperation, bytestring_path, @@ -104,19 +105,15 @@ class LibModel(dbcore.Model["Library"]): if ( cls._type(field).query is dbcore.query.PathQuery and query_cls is not dbcore.query.PathQuery - and (music_dir := context.get_music_dir()) ): # Regex, exact, and string queries operate on the raw DB value, so # strip the library prefix to match the stored relative path. if isinstance(pattern, bytes): - prefix = os.path.join(music_dir, b"") - if pattern.startswith(prefix): - pattern = os.path.relpath(pattern, music_dir) + pattern = normalize_path_for_db(pattern) else: - music_dir_str = os.fsdecode(music_dir) - prefix = music_dir_str + os.sep - if pattern.startswith(prefix): - pattern = pattern.removeprefix(prefix) + pattern = os.fsdecode( + normalize_path_for_db(util.bytestring_path(pattern)) + ) if field in cls.shared_db_fields: # This field exists in both tables, so SQLite will encounter # an OperationalError if we try to use it in a query. diff --git a/test/library/test_migrations.py b/test/library/test_migrations.py index 34710f67f..53c52fa74 100644 --- a/test/library/test_migrations.py +++ b/test/library/test_migrations.py @@ -161,7 +161,7 @@ class TestRelativePathMigration: helper.teardown_beets() def test_migrate(self, helper: TestHelper): - relative_path = "foo/bar/baz.mp3" + relative_path = os.path.join("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 diff --git a/test/plugins/test_ipfs.py b/test/plugins/test_ipfs.py index 5481b8585..00d6f0512 100644 --- a/test/plugins/test_ipfs.py +++ b/test/plugins/test_ipfs.py @@ -15,9 +15,9 @@ import os from unittest.mock import Mock, patch +from beets import util from beets.test import _common from beets.test.helper import PluginTestCase -from beets.util import bytestring_path from beetsplug.ipfs import IPFSPlugin @@ -37,10 +37,9 @@ class IPFSPluginTest(PluginTestCase): try: if check_item.get("ipfs", with_album=False): ipfs_item = os.fsdecode(os.path.basename(want_item.path)) - want_path = os.path.normpath( - f"/ipfs/{test_album.ipfs}/{ipfs_item}" + want_path = util.normpath( + os.path.join("/ipfs", test_album.ipfs, ipfs_item) ) - want_path = bytestring_path(want_path) assert check_item.path == want_path assert ( check_item.get("ipfs", with_album=False) diff --git a/test/test_library.py b/test/test_library.py index 274d2dea6..3f08feaeb 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -1094,7 +1094,7 @@ class PathStringTest(BeetsTestCase): assert isinstance(dest, bytes) def test_artpath_stores_special_chars(self): - path = b"b\xe1r" + path = bytestring_path("b\xe1r") alb = self.lib.add_album([self.i]) alb.artpath = path alb.store() @@ -1131,7 +1131,7 @@ class PathStringTest(BeetsTestCase): assert isinstance(alb.artpath, bytes) def test_relative_path_is_stored(self): - relative_path = b"abc/foo.mp3" + relative_path = os.path.join(b"abc", b"foo.mp3") absolute_path = os.path.join(self.libdir, relative_path) self.i.path = absolute_path self.i.store() From 2d776a8a22968fc50aba659eb4e33a8a541a5ec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 23 Mar 2026 14:12:31 +0000 Subject: [PATCH 8/9] Add ability to set temporary music dir context for ipfs --- beets/context.py | 11 ++++++++++ beets/library/library.py | 22 ++++++++++++++----- beetsplug/ipfs.py | 17 +++++++++------ test/plugins/test_ipfs.py | 45 +++++++++++++++++++++------------------ 4 files changed, 62 insertions(+), 33 deletions(-) diff --git a/beets/context.py b/beets/context.py index 4555011ac..5d56831a1 100644 --- a/beets/context.py +++ b/beets/context.py @@ -1,3 +1,4 @@ +from contextlib import contextmanager from contextvars import ContextVar # Holds the music dir context @@ -12,3 +13,13 @@ def get_music_dir() -> bytes: def set_music_dir(value: bytes) -> None: """Set the current music directory context.""" _music_dir_var.set(value) + + +@contextmanager +def music_dir(value: bytes): + """Temporarily bind the active music directory for query parsing.""" + token = _music_dir_var.set(value) + try: + yield + finally: + _music_dir_var.reset(token) diff --git a/beets/library/library.py b/beets/library/library.py index e7df73e1d..6a8d02f79 100644 --- a/beets/library/library.py +++ b/beets/library/library.py @@ -1,5 +1,6 @@ from __future__ import annotations +from contextlib import contextmanager from typing import TYPE_CHECKING import platformdirs @@ -32,10 +33,12 @@ class Library(dbcore.Database): directory: str | None = None, path_formats=((PF_KEY_DEFAULT, "$artist/$album/$track $title"),), replacements=None, + set_music_dir: bool = True, ): timeout = beets.config["timeout"].as_number() self.directory = normpath(directory or platformdirs.user_music_path()) - context.set_music_dir(self.directory) + if set_music_dir: + context.set_music_dir(self.directory) super().__init__(path, timeout=timeout) @@ -45,6 +48,12 @@ class Library(dbcore.Database): # Used for template substitution performance. self._memotable: dict[tuple[str, ...], str] = {} + @contextmanager + def music_dir_context(self): + """Temporarily bind this library's directory to path conversion.""" + with context.music_dir(self.directory): + yield self + # Adding objects to the database. def add(self, obj): @@ -95,10 +104,13 @@ class Library(dbcore.Database): # Parse the query, if necessary. try: parsed_sort = None - 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) + # 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) except dbcore.query.InvalidQueryArgumentValueError as exc: raise dbcore.InvalidQueryError(query, exc) diff --git a/beetsplug/ipfs.py b/beetsplug/ipfs.py index ac1005dc6..66ef7fea9 100644 --- a/beetsplug/ipfs.py +++ b/beetsplug/ipfs.py @@ -281,13 +281,16 @@ class IPFSPlugin(BeetsPlugin): def ipfs_added_albums(self, rlib, tmpname): """Returns a new library with only albums/items added to ipfs""" - tmplib = library.Library(tmpname, directory="/ipfs/") - for album in rlib.albums(): - try: - if album.ipfs: - self.create_new_album(album, tmplib) - except AttributeError: - pass + tmplib = library.Library( + tmpname, directory="/ipfs/", set_music_dir=False + ) + with tmplib.music_dir_context(): + for album in rlib.albums(): + try: + if album.ipfs: + self.create_new_album(album, tmplib) + except AttributeError: + pass return tmplib def create_new_album(self, album, tmplib): diff --git a/test/plugins/test_ipfs.py b/test/plugins/test_ipfs.py index 00d6f0512..4ac638062 100644 --- a/test/plugins/test_ipfs.py +++ b/test/plugins/test_ipfs.py @@ -29,27 +29,30 @@ class IPFSPluginTest(PluginTestCase): test_album = self.mk_test_album() ipfs = IPFSPlugin() added_albums = ipfs.ipfs_added_albums(self.lib, self.lib.path) - added_album = added_albums.get_album(1) - assert added_album.ipfs == test_album.ipfs - found = False - want_item = test_album.items()[2] - for check_item in added_album.items(): - try: - if check_item.get("ipfs", with_album=False): - ipfs_item = os.fsdecode(os.path.basename(want_item.path)) - want_path = util.normpath( - os.path.join("/ipfs", test_album.ipfs, ipfs_item) - ) - assert check_item.path == want_path - assert ( - check_item.get("ipfs", with_album=False) - == want_item.ipfs - ) - assert check_item.title == want_item.title - found = True - except AttributeError: - pass - assert found + with added_albums.music_dir_context(): + added_album = added_albums.get_album(1) + assert added_album.ipfs == test_album.ipfs + found = False + want_item = test_album.items()[2] + for check_item in added_album.items(): + try: + if check_item.get("ipfs", with_album=False): + ipfs_item = os.fsdecode( + os.path.basename(want_item.path) + ) + want_path = util.normpath( + os.path.join("/ipfs", test_album.ipfs, ipfs_item) + ) + assert check_item.path == want_path + assert ( + check_item.get("ipfs", with_album=False) + == want_item.ipfs + ) + assert check_item.title == want_item.title + found = True + except AttributeError: + pass + assert found def mk_test_album(self): items = [_common.item() for _ in range(3)] From 318f2fd564023ca61357a7dd82c197e68018fc1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 23 Mar 2026 20:30:48 +0000 Subject: [PATCH 9/9] Fix path tests on Windows --- test/test_library.py | 4 ++-- test/test_query.py | 55 ++++++++++++++++++++++++++++++++------------ test/test_sort.py | 23 ++++++++++-------- 3 files changed, 56 insertions(+), 26 deletions(-) diff --git a/test/test_library.py b/test/test_library.py index 3f08feaeb..e634fa052 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -925,10 +925,10 @@ class AlbumInfoTest(BeetsTestCase): def test_albuminfo_stores_art(self): ai = self.lib.get_album(self.i) - ai.artpath = "/my/great/art" + ai.artpath = os.fsdecode(np("/my/great/art")) ai.store() new_ai = self.lib.get_album(self.i) - assert new_ai.artpath == b"/my/great/art" + assert new_ai.artpath == np("/my/great/art") def test_albuminfo_for_two_items_doesnt_duplicate_row(self): i2 = item(self.lib) diff --git a/test/test_query.py b/test/test_query.py index e519ce7c2..2ce8f983d 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -21,6 +21,7 @@ from pathlib import Path import pytest +from beets import util from beets.dbcore import types from beets.dbcore.query import ( AndQuery, @@ -278,6 +279,23 @@ class TestPathQuery: and path separator detection across different platforms. """ + @staticmethod + def abs_query_path(path: str, trailing_sep: bool = False) -> str: + """Build a platform-correct absolute query path without normalizing it. + + On Windows, leading-slash paths are drive-rooted but Python 3.13 no + longer treats them as absolute. Prefix the current drive so explicit + path queries stay absolute while preserving raw segments such as ``..``. + """ + if os.path.__name__ == "ntpath" and path.startswith("/"): + drive, _ = os.path.splitdrive(os.fsdecode(util.normpath(os.sep))) + path = drive + path + + path = path.replace("/", os.sep) + if trailing_sep: + path = os.path.join(path, "") + return path.replace("\\", "\\\\") + @pytest.fixture(scope="class") def lib(self, helper): helper.add_item(path=b"/aaa/bb/c.mp3", title="path item") @@ -290,24 +308,32 @@ class TestPathQuery: return helper.lib @pytest.mark.parametrize( - "q, expected_titles", + "path, expected_titles, trailing_sep", [ - _p("path:/aaa/bb/c.mp3", ["path item"], id="exact-match"), - _p("path:/aaa", ["path item"], id="parent-dir-no-slash"), - _p("path:/aaa/", ["path item"], id="parent-dir-with-slash"), - _p("path:/aa", [], id="no-match-does-not-match-parent-dir"), - _p("path:/xyzzy/", [], id="no-match"), - _p("path:/b/", [], id="fragment-no-match"), - _p("path:/x/../aaa/bb", ["path item"], id="non-normalized"), - _p("path::c\\.mp3$", ["path item"], id="regex"), - _p("path:/c/_", ["with underscore"], id="underscore-escaped"), - _p("path:/c/%", ["with percent"], id="percent-escaped"), - _p("path:/c/\\\\x", ["with backslash"], id="backslash-escaped"), + _p("/aaa/bb/c.mp3", ["path item"], False, id="exact-match"), + _p("/aaa", ["path item"], False, id="parent-dir-no-slash"), + _p("/aaa", ["path item"], True, id="parent-dir-with-slash"), + _p("/aa", [], False, id="no-match-does-not-match-parent-dir"), + _p("/xyzzy", [], True, id="no-match"), + _p("/b", [], True, id="fragment-no-match"), + _p("/x/../aaa/bb", ["path item"], False, id="non-normalized"), + _p(r"c\.mp3$", ["path item"], False, id="regex"), + _p("/c/_", ["with underscore"], False, id="underscore-escaped"), + _p("/c/%", ["with percent"], False, id="percent-escaped"), + _p(r"/c/\x", ["with backslash"], False, id="backslash-escaped"), ], ) - def test_explicit(self, monkeypatch, lib, q, expected_titles): + def test_explicit( + self, monkeypatch, lib, path, expected_titles, trailing_sep + ): """Test explicit path queries with different path specifications.""" monkeypatch.setattr("beets.util.case_sensitive", lambda *_: True) + if path == r"c\.mp3$": + q = f"path::{path}" + elif path == r"/c/\x" and os.path.__name__ != "ntpath": + q = r"path:/c/\\x" + else: + q = f"path:{self.abs_query_path(path, trailing_sep=trailing_sep)}" assert {i.title for i in lib.items(q)} == set(expected_titles) @@ -318,7 +344,6 @@ class TestPathQuery: item_path = helper.lib_path / "item.mp3" bytes_path = os.fsencode(item_path) helper.add_item(path=bytes_path, title="absolute item") - # Escape backslashes for Windows paths q = f"{query}{item_path}".replace("\\", "\\\\") assert {i.title for i in lib.items(q)} == {"absolute item"} @@ -360,7 +385,7 @@ class TestPathQuery: self, lib, monkeypatch, case_sensitive, expected_titles ): """Test path matching with different case sensitivity settings.""" - q = "path:/a/b/c2.mp3" + q = f"path:{self.abs_query_path('/a/b/c2.mp3')}" monkeypatch.setattr( "beets.util.case_sensitive", lambda *_: case_sensitive ) diff --git a/test/test_sort.py b/test/test_sort.py index d7d651de5..2416860a5 100644 --- a/test/test_sort.py +++ b/test/test_sort.py @@ -14,16 +14,21 @@ """Various tests for querying the library database.""" +import os from unittest.mock import patch import beets.library -from beets import config, dbcore +from beets import config, dbcore, util from beets.dbcore import types from beets.library import Album from beets.test import _common from beets.test.helper import BeetsTestCase +def abs_test_path(path: str) -> str: + return os.fsdecode(util.normpath(path)) + + # A test case class providing a library with some dummy data and some # assertions involving that data. class DummyDataTestCase(BeetsTestCase): @@ -69,7 +74,7 @@ class DummyDataTestCase(BeetsTestCase): items[0].flex2 = "Flex2-A" items[0].album_id = albums[0].id items[0].artist_sort = None - items[0].path = "/path0.mp3" + items[0].path = abs_test_path("/path0.mp3") items[0].track = 1 items[1].title = "Baz qux" items[1].artist = "Two" @@ -80,7 +85,7 @@ class DummyDataTestCase(BeetsTestCase): items[1].flex2 = "Flex2-A" items[1].album_id = albums[0].id items[1].artist_sort = None - items[1].path = "/patH1.mp3" + items[1].path = abs_test_path("/patH1.mp3") items[1].track = 2 items[2].title = "Beets 4 eva" items[2].artist = "Three" @@ -91,7 +96,7 @@ class DummyDataTestCase(BeetsTestCase): items[2].flex2 = "Flex1-B" items[2].album_id = albums[1].id items[2].artist_sort = None - items[2].path = "/paTH2.mp3" + items[2].path = abs_test_path("/paTH2.mp3") items[2].track = 3 items[3].title = "Beets 4 eva" items[3].artist = "Three" @@ -102,7 +107,7 @@ class DummyDataTestCase(BeetsTestCase): items[3].flex2 = "Flex1-C" items[3].album_id = albums[2].id items[3].artist_sort = None - items[3].path = "/PATH3.mp3" + items[3].path = abs_test_path("/PATH3.mp3") items[3].track = 4 for item in items: self.lib.add(item) @@ -156,10 +161,10 @@ class SortFixedFieldTest(DummyDataTestCase): q = "" sort = dbcore.query.FixedFieldSort("path", True) results = self.lib.items(q, sort) - assert results[0]["path"] == b"/path0.mp3" - assert results[1]["path"] == b"/patH1.mp3" - assert results[2]["path"] == b"/paTH2.mp3" - assert results[3]["path"] == b"/PATH3.mp3" + assert results[0]["path"] == util.normpath("/path0.mp3") + assert results[1]["path"] == util.normpath("/patH1.mp3") + assert results[2]["path"] == util.normpath("/paTH2.mp3") + assert results[3]["path"] == util.normpath("/PATH3.mp3") class SortFlexFieldTest(DummyDataTestCase):