rekordbox-edit 0.3.0.dev16__tar.gz → 0.4.0.dev18__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/.github/workflows/publish.yml +1 -0
  2. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/CHANGELOG.md +10 -1
  3. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/Makefile +3 -0
  4. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/PKG-INFO +11 -1
  5. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/README.md +10 -0
  6. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/pyproject.toml +2 -1
  7. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/rekordbox_edit/_click.py +12 -0
  8. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/rekordbox_edit/commands/convert.py +4 -0
  9. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/rekordbox_edit/commands/search.py +8 -0
  10. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/rekordbox_edit/query.py +68 -4
  11. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/tests/commands/test_search.py +13 -1
  12. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/tests/test_query.py +162 -1
  13. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/uv.lock +48 -1
  14. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/.github/actions/commitizen-bump/action.yml +0 -0
  15. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/.github/actions/commitizen-bump/commitizen-bump.sh +0 -0
  16. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/.github/actions/install/action.yml +0 -0
  17. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/.github/actions/lint/action.yml +0 -0
  18. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/.github/actions/test/action.yml +0 -0
  19. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/.github/workflows/cd.yml +0 -0
  20. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/.github/workflows/ci.yml +0 -0
  21. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/.github/workflows/release.yml +0 -0
  22. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/.gitignore +0 -0
  23. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/.pre-commit-config.yaml +0 -0
  24. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/CONTRIBUTING.md +0 -0
  25. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/LICENSE +0 -0
  26. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/codecov.yml +0 -0
  27. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/rekordbox_edit/__init__.py +0 -0
  28. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/rekordbox_edit/cli.py +0 -0
  29. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/rekordbox_edit/commands/__init__.py +0 -0
  30. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/rekordbox_edit/logger.py +0 -0
  31. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/rekordbox_edit/utils.py +0 -0
  32. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/ruff.toml +0 -0
  33. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/tests/__init__.py +0 -0
  34. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/tests/commands/__init__.py +0 -0
  35. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/tests/commands/test_convert.py +0 -0
  36. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/tests/conftest.py +0 -0
  37. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/tests/test_logger.py +0 -0
  38. {rekordbox_edit-0.3.0.dev16 → rekordbox_edit-0.4.0.dev18}/tests/test_utils.py +0 -0
@@ -1,6 +1,7 @@
1
1
  name: Publish to PyPI
2
2
 
3
3
  on:
4
+ workflow_dispatch:
4
5
  release:
5
6
  types: [released]
6
7
 
@@ -1,6 +1,15 @@
1
- ## v0.3.0.dev16 (2026-04-18)
1
+ ## v0.4.0.dev18 (2026-05-16)
2
2
 
3
3
 
4
+ - feat: Add --path and --exact-path search filters
5
+ - chore: add pytest-watcher and watch task in Make
6
+ - feat(CollectionQuery): add by_path query filter
7
+
8
+ ## v0.3.1 (2026-04-17)
9
+
10
+
11
+ - chore: version bump 0.3.1
12
+ - ci: allow dispatching of publish workflow
4
13
  - chore: big 'ol project rename cause it was too long
5
14
  - docs: update readme and contributing
6
15
  - chore: change commitizen config and providers to work with uv
@@ -3,6 +3,9 @@
3
3
  test:
4
4
  uv run pytest tests
5
5
 
6
+ watch:
7
+ uv run ptw .
8
+
6
9
  coverage:
7
10
  uv run pytest tests --cov=rekordbox_edit --junitxml=.coverage/junit.xml --cov-report=term-missing --cov-report=html --cov-report=xml
8
11
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rekordbox-edit
3
- Version: 0.3.0.dev16
3
+ Version: 0.4.0.dev18
4
4
  Summary: Tools for managing and modifying a RekordBox library en-masse
5
5
  Project-URL: Homepage, https://github.com/jviall/rekordbox-edit
6
6
  Project-URL: Repository, https://github.com/jviall/rekordbox-edit
@@ -151,6 +151,8 @@ Both commands support all filters. Multiple values create an OR filter unless `-
151
151
  - `--playlist TEXT`: Playlist name contains TEXT
152
152
  - `--exact-playlist TEXT`: Playlist name exactly matches TEXT
153
153
  - `--format [mp3|flac|aiff|wav|m4a]`: File format matches
154
+ - `--path TEXT`: File path contains TEXT (matched as a substring against the folder path, filename, or both)
155
+ - `--exact-path TEXT`: File path exactly matches TEXT (resolved to an absolute path before matching)
154
156
  - `--match-all`: Use AND logic (all filters must match)
155
157
  - `ids` args: Specifying any other input to a command that is not a defined option is interpreted as one or more Track IDs. This is useful for scripting.
156
158
 
@@ -174,6 +176,14 @@ rbe search --playlist "house" --playlist "disco"
174
176
 
175
177
  # Find all the songs in my library that aren't in any playlist
176
178
  rbe search --playlist ""
179
+
180
+ # Find tracks whose path contains a folder or filename substring
181
+ rbe search --path "Favorites/" --path "track.wav"
182
+ rbe search --path "Music/Artist/song.mp3"
183
+
184
+ # Find a track at an exact location
185
+ rbe search --exact-path "/Users/djmustard/Music/banger.mp3"
186
+ rbe search --exact-path "../Artist/track.mp3"
177
187
  ```
178
188
 
179
189
  ## Output
@@ -133,6 +133,8 @@ Both commands support all filters. Multiple values create an OR filter unless `-
133
133
  - `--playlist TEXT`: Playlist name contains TEXT
134
134
  - `--exact-playlist TEXT`: Playlist name exactly matches TEXT
135
135
  - `--format [mp3|flac|aiff|wav|m4a]`: File format matches
136
+ - `--path TEXT`: File path contains TEXT (matched as a substring against the folder path, filename, or both)
137
+ - `--exact-path TEXT`: File path exactly matches TEXT (resolved to an absolute path before matching)
136
138
  - `--match-all`: Use AND logic (all filters must match)
137
139
  - `ids` args: Specifying any other input to a command that is not a defined option is interpreted as one or more Track IDs. This is useful for scripting.
138
140
 
@@ -156,6 +158,14 @@ rbe search --playlist "house" --playlist "disco"
156
158
 
157
159
  # Find all the songs in my library that aren't in any playlist
158
160
  rbe search --playlist ""
161
+
162
+ # Find tracks whose path contains a folder or filename substring
163
+ rbe search --path "Favorites/" --path "track.wav"
164
+ rbe search --path "Music/Artist/song.mp3"
165
+
166
+ # Find a track at an exact location
167
+ rbe search --exact-path "/Users/djmustard/Music/banger.mp3"
168
+ rbe search --exact-path "../Artist/track.mp3"
159
169
  ```
160
170
 
161
171
  ## Output
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "rekordbox-edit"
7
- version = "0.3.0.dev16"
7
+ version = "0.4.0.dev18"
8
8
  description = "Tools for managing and modifying a RekordBox library en-masse"
9
9
  authors = [{ name = "James Viall", email= "jamesviall@pm.me"}]
10
10
  license = "MIT"
@@ -26,6 +26,7 @@ dev = [
26
26
  "ruff>=0.12.8,<1",
27
27
  "pytest-cov>=6.2.1,<7",
28
28
  "pytest-mock>=3.14.1,<4",
29
+ "pytest-watcher>=0.6.3,<1",
29
30
  "callee>=0.3.1,<1",
30
31
  "ty>=0.0.1,<1",
31
32
  ]
@@ -75,6 +75,18 @@ global_click_filters = [
75
75
  multiple=True,
76
76
  help="Find tracks whose Album names are exactly this value",
77
77
  ),
78
+ click.option(
79
+ "--path",
80
+ type=str,
81
+ multiple=True,
82
+ help="Find tracks whose file paths include this value",
83
+ ),
84
+ click.option(
85
+ "--exact-path",
86
+ type=str,
87
+ multiple=True,
88
+ help="Find tracks whose file paths are exactly this value",
89
+ ),
78
90
  click.option(
79
91
  "--format",
80
92
  type=click.Choice(["mp3", "flac", "aiff", "wav", "m4a"], case_sensitive=False),
@@ -295,6 +295,8 @@ def convert_command(
295
295
  exact_artist: List[str] | None,
296
296
  playlist: List[str] | None,
297
297
  exact_playlist: List[str] | None,
298
+ path: List[str] | None,
299
+ exact_path: List[str] | None,
298
300
  format: List[str] | None,
299
301
  match_all: bool,
300
302
  print_opt: PrintChoice | None,
@@ -395,6 +397,8 @@ def convert_command(
395
397
  exact_albums=exact_album,
396
398
  titles=title,
397
399
  exact_titles=exact_title,
400
+ paths=path,
401
+ exact_paths=exact_path,
398
402
  match_all=match_all,
399
403
  )
400
404
  filtered_content = result.scalars().all()
@@ -38,6 +38,8 @@ def search_command(
38
38
  exact_artist: List[str] | None,
39
39
  title: List[str] | None,
40
40
  exact_title: List[str] | None,
41
+ path: List[str] | None,
42
+ exact_path: List[str] | None,
41
43
  format: List[str] | None,
42
44
  match_all: bool,
43
45
  print_opt: PrintChoice | None,
@@ -75,6 +77,10 @@ def search_command(
75
77
  filters.append(f"playlist={playlist}")
76
78
  if exact_playlist:
77
79
  filters.append(f"exact_playlist={exact_playlist}")
80
+ if path:
81
+ filters.append(f"path={path}")
82
+ if exact_path:
83
+ filters.append(f"exact_path={exact_path}")
78
84
  if format:
79
85
  filters.append(f"format={format}")
80
86
  if match_all:
@@ -97,6 +103,8 @@ def search_command(
97
103
  exact_albums=exact_album,
98
104
  titles=title,
99
105
  exact_titles=exact_title,
106
+ paths=path,
107
+ exact_paths=exact_path,
100
108
  formats=format,
101
109
  match_all=match_all,
102
110
  )
@@ -1,4 +1,5 @@
1
1
  import logging
2
+ from pathlib import Path
2
3
  from typing import List, Tuple, Union
3
4
 
4
5
  from pyrekordbox import Rekordbox6Database
@@ -9,7 +10,7 @@ from pyrekordbox.db6.tables import (
9
10
  DjmdPlaylist,
10
11
  DjmdSongPlaylist,
11
12
  )
12
- from sqlalchemy import Result, and_, func, or_, select
13
+ from sqlalchemy import ColumnElement, Result, and_, func, or_, select
13
14
  from sqlalchemy.orm import aliased
14
15
 
15
16
  logger = logging.getLogger(__name__)
@@ -18,7 +19,7 @@ logger = logging.getLogger(__name__)
18
19
  class CollectionQuery:
19
20
  def __init__(self, match_all=False):
20
21
  self._stmt = select(DjmdContent)
21
- self._conditions = []
22
+ self._conditions: list[ColumnElement[bool]] = []
22
23
  self._limit_count = None
23
24
  self._match_all = match_all
24
25
 
@@ -143,6 +144,59 @@ class CollectionQuery:
143
144
  logger.warning(f"Invalid format: {format_name}")
144
145
  return new_inst
145
146
 
147
+ def by_path(self, path_str: str, exact: bool = False) -> "CollectionQuery":
148
+ """Filter by file path, matching against FolderPath and/or FileNameL.
149
+
150
+ Paths get normalized to posix format.
151
+ --path args arg matched as substrings.
152
+ --exact-path argsl are resolved to absolute paths and must match exact.
153
+
154
+ Args with a trailing '/' only query against FolderPath.
155
+ Args with no parent folders in the path only query against FileNameL.
156
+ """
157
+ new_inst = self._copy()
158
+
159
+ is_dir_only = path_str.endswith("/") or path_str.endswith("\\")
160
+
161
+ if exact:
162
+ resolved = Path(path_str).resolve()
163
+ if is_dir_only:
164
+ folder_part = resolved.as_posix() + "/"
165
+ name_part = ""
166
+ elif "/" not in path_str and "\\" not in path_str:
167
+ folder_part = ""
168
+ name_part = resolved.name
169
+ else:
170
+ folder_part = resolved.parent.as_posix() + "/"
171
+ name_part = resolved.name
172
+ else:
173
+ normalized = Path(path_str)
174
+ if is_dir_only:
175
+ folder_part = normalized.as_posix()
176
+ name_part = ""
177
+ else:
178
+ parent_str = normalized.parent.as_posix()
179
+ folder_part = "" if parent_str == "." else parent_str
180
+ name_part = normalized.name
181
+
182
+ conditions: list[ColumnElement[bool]] = []
183
+ if folder_part:
184
+ if exact:
185
+ conditions.append(DjmdContent.FolderPath == folder_part)
186
+ else:
187
+ conditions.append(DjmdContent.FolderPath.ilike(f"%{folder_part}%"))
188
+ if name_part:
189
+ if exact:
190
+ conditions.append(DjmdContent.FileNameL == name_part)
191
+ else:
192
+ conditions.append(DjmdContent.FileNameL.ilike(f"%{name_part}%"))
193
+
194
+ if conditions:
195
+ combined = and_(*conditions) if len(conditions) > 1 else conditions[0]
196
+ new_inst._conditions.append(combined)
197
+
198
+ return new_inst
199
+
146
200
  def limit(self, count: int) -> "CollectionQuery":
147
201
  """Limit query results to the first {count} items."""
148
202
  new_inst = self._copy()
@@ -204,6 +258,8 @@ def get_filtered_content(
204
258
  exact_albums: List[str] | None = None,
205
259
  titles: List[str] | None = None,
206
260
  exact_titles: List[str] | None = None,
261
+ paths: List[str] | None = None,
262
+ exact_paths: List[str] | None = None,
207
263
  match_all: bool = False,
208
264
  ) -> Result[Tuple[DjmdContent]]:
209
265
  """Query the Rekordbox database with the provided filters."""
@@ -254,8 +310,16 @@ def get_filtered_content(
254
310
  query = query.by_title(title)
255
311
 
256
312
  if exact_titles:
257
- for exact_title in exact_titles:
258
- query = query.by_title(exact_title, exact=True)
313
+ for title in exact_titles:
314
+ query = query.by_title(title, exact=True)
315
+
316
+ if paths:
317
+ for path in paths:
318
+ query = query.by_path(path)
319
+
320
+ if exact_paths:
321
+ for exact_path in exact_paths:
322
+ query = query.by_path(exact_path, exact=True)
259
323
 
260
324
  if match_all:
261
325
  query = query.match_all()
@@ -137,12 +137,24 @@ class TestSearchCommand:
137
137
 
138
138
  CliRunner().invoke(
139
139
  search_command,
140
- ["--artist", "Daft Punk", "--format", "flac", "--match-all"],
140
+ [
141
+ "--artist",
142
+ "Daft Punk",
143
+ "--format",
144
+ "flac",
145
+ "--path",
146
+ "song.mp3",
147
+ "--exact-path",
148
+ "/Music/album/track.wav",
149
+ "--match-all",
150
+ ],
141
151
  )
142
152
 
143
153
  call_kwargs = mock_get_filtered_content.call_args.kwargs
144
154
  assert call_kwargs["artists"] == ("Daft Punk",)
145
155
  assert call_kwargs["formats"] == ("flac",)
156
+ assert call_kwargs["paths"] == ("song.mp3",)
157
+ assert call_kwargs["exact_paths"] == ("/Music/album/track.wav",)
146
158
  assert call_kwargs["match_all"] is True
147
159
 
148
160
 
@@ -1,12 +1,21 @@
1
1
  #!/usr/bin/env python3
2
2
  """Tests for the CollectionQuery class."""
3
3
 
4
- import pytest
4
+ import os
5
+ import platform
6
+ from pathlib import Path
5
7
  from unittest.mock import MagicMock
6
8
 
9
+ import pytest
10
+ from sqlalchemy import ColumnElement
11
+
7
12
  from rekordbox_edit.query import CollectionQuery, get_filtered_content
8
13
 
9
14
 
15
+ def _compile(condition: ColumnElement[bool]) -> str:
16
+ return str(condition.compile(compile_kwargs={"literal_binds": True}))
17
+
18
+
10
19
  class TestCollectionQuery:
11
20
  """Test the CollectionQuery class."""
12
21
 
@@ -414,6 +423,150 @@ class TestCollectionQuery:
414
423
  assert " and " in stmt_str
415
424
  assert " or " not in stmt_str
416
425
 
426
+ def test_by_path_filename_only(self):
427
+ """A bare filename (no directory) adds a FileNameL ilike condition only.
428
+ The FolderPath condition is skipped — the filter still runs with just FileNameL.
429
+ """
430
+ query = CollectionQuery()
431
+ new_query = query.by_path("track.mp3")
432
+
433
+ assert new_query is not query
434
+ assert len(new_query._conditions) == 1
435
+ condition_str = str(new_query._conditions[0])
436
+ assert "FileNameL" in condition_str
437
+ assert "LIKE lower" in condition_str
438
+ assert "FolderPath" not in condition_str
439
+
440
+ def test_by_path_folder_only_forward_slash(self):
441
+ """A trailing-forward-slash string adds a FolderPath ilike condition only.
442
+ The FileNameL condition is skipped — the filter still runs with just FolderPath.
443
+ """
444
+ query = CollectionQuery()
445
+ new_query = query.by_path("./Test/Folder/")
446
+
447
+ assert new_query is not query
448
+ assert len(new_query._conditions) == 1
449
+ condition_str = str(new_query._conditions[0])
450
+ assert "FileNameL" not in condition_str
451
+ assert "LIKE lower" in condition_str
452
+ assert "FolderPath" in condition_str
453
+
454
+ def test_by_path_folder_and_filename(self):
455
+ """A path with both parts adds a compound AND condition covering both columns."""
456
+ query = CollectionQuery()
457
+ new_query = query.by_path("Music/Artist/track.mp3")
458
+
459
+ assert new_query is not query
460
+ assert len(new_query._conditions) == 1
461
+ condition_str = str(new_query._conditions[0])
462
+ assert "FolderPath" in condition_str
463
+ assert "FileNameL" in condition_str
464
+ assert " AND " in condition_str
465
+ assert condition_str.count("LIKE lower") == 2
466
+
467
+ @pytest.mark.skipif(
468
+ platform.system() != "Windows", reason="backslash path parsing is Windows-only"
469
+ )
470
+ def test_by_path_backslash_normalised_to_forward_slash(self):
471
+ """Backslash separators in --path input are normalised to forward slashes."""
472
+ query = CollectionQuery()
473
+ new_query = query.by_path("Music\\Artist\\track.mp3")
474
+
475
+ query_str = _compile(new_query._conditions[0])
476
+ assert "\\" not in query_str
477
+ assert "Music/Artist" in query_str
478
+
479
+ def test_by_path_no_resolve(self):
480
+ """by_path does NOT resolve the path."""
481
+ query = CollectionQuery()
482
+ new_query = query.by_path("../some/relative/track.mp3")
483
+
484
+ condition_str = _compile(new_query._conditions[0])
485
+ # The raw string (or parts of it) must appear, not a resolved absolute path
486
+ assert ".." in condition_str and "relative" in condition_str
487
+
488
+ def test_by_path_ilike_wraps_with_wildcards(self):
489
+ """The ilike condition includes % wildcards for substring matching."""
490
+ query = CollectionQuery()
491
+ new_query = query.by_path("track.mp3")
492
+
493
+ condition_str = _compile(new_query._conditions[0])
494
+ assert "%" in condition_str
495
+
496
+ # --- by_path exact mode ---
497
+
498
+ def test_by_exact_path_filename_only(self):
499
+ """Exact match on a bare filename produces an equality condition on FileNameL only."""
500
+ query = CollectionQuery()
501
+ new_query = query.by_path("track.mp3", exact=True)
502
+
503
+ assert len(new_query._conditions) == 1
504
+ condition_str = str(new_query._conditions[0])
505
+ assert "FolderPath" not in condition_str
506
+ assert "FileNameL" in condition_str
507
+ assert "=" in condition_str
508
+ assert "LIKE lower" not in condition_str
509
+
510
+ def test_by_exact_path_folder_only(self):
511
+ """Exact match on a only folder path produces equality check on FolderPath only."""
512
+ query = CollectionQuery()
513
+ # Build a cross-platform absolute path using pathlib
514
+ path = "/Test/Artist/"
515
+ new_query = query.by_path(path, exact=True)
516
+
517
+ assert len(new_query._conditions) == 1
518
+ condition_str = _compile(new_query._conditions[0])
519
+ assert "FolderPath" in condition_str
520
+ assert "FileNameL" not in condition_str
521
+ assert "=" in condition_str
522
+ assert path in condition_str
523
+ assert "like lower" not in condition_str
524
+
525
+ def test_by_exact_path_folder_and_filename(self):
526
+ """Exact match on a full path produces a compound AND with equality on both columns."""
527
+ query = CollectionQuery()
528
+ # Build a cross-platform absolute path using pathlib
529
+ abs_path = "/Artist/track.mp3"
530
+ new_query = query.by_path(abs_path, exact=True)
531
+
532
+ assert len(new_query._conditions) == 1
533
+ condition_str = str(new_query._conditions[0])
534
+ assert "FolderPath" in condition_str
535
+ assert "FileNameL" in condition_str
536
+ assert " AND " in condition_str
537
+ assert "LIKE lower" not in condition_str
538
+
539
+ def test_by_exact_path_resolves_relative_path(self):
540
+ """Exact match resolves relative paths to absolute before querying."""
541
+ cwd = Path(os.getcwd()).as_posix()
542
+ query = CollectionQuery()
543
+ new_query = query.by_path("Album/track.mp3", exact=True)
544
+
545
+ condition_str = _compile(new_query._conditions[0])
546
+ # cwd should appear as the resolved folder part
547
+ assert cwd in condition_str
548
+
549
+ @pytest.mark.skipif(
550
+ platform.system() != "Windows", reason="backslash path parsing is Windows-only"
551
+ )
552
+ def test_by_exact_path_backslash_normalised_to_forward_slash(self):
553
+ """Backslash separators in --exact-path input are normalised via as_posix()."""
554
+ query = CollectionQuery()
555
+ # Pass a Windows-style absolute path; as_posix() must produce forward slashes
556
+ new_query = query.by_path(
557
+ "C:\\Users\\foo\\music\\Artist\\track.mp3", exact=True
558
+ )
559
+
560
+ condition_str = _compile(new_query._conditions[0])
561
+ assert "\\" not in condition_str
562
+ assert "/" in condition_str
563
+
564
+ def test_by_path_returns_new_instance(self):
565
+ """by_path always returns a new CollectionQuery instance."""
566
+ query = CollectionQuery()
567
+ assert query.by_path("track.mp3") is not query
568
+ assert query.by_path("track.mp3", exact=True) is not query
569
+
417
570
 
418
571
  @pytest.fixture
419
572
  def mock_query(mocker):
@@ -495,6 +648,14 @@ class TestGetFilteredContent:
495
648
  get_filtered_content(mock_db, exact_playlists=["My Playlist"])
496
649
  mock_query.by_playlist.assert_called_once_with("My Playlist", exact=True)
497
650
 
651
+ def test_path(self, mock_db, mock_query):
652
+ get_filtered_content(mock_db, paths=["Music/track.mp3"])
653
+ mock_query.by_path.assert_called_once_with("Music/track.mp3")
654
+
655
+ def test_exact_path(self, mock_db, mock_query):
656
+ get_filtered_content(mock_db, exact_paths=["/Music/track.wav"])
657
+ mock_query.by_path.assert_called_once_with("/Music/track.wav", exact=True)
658
+
498
659
  def test_format(self, mock_db, mock_query):
499
660
  get_filtered_content(mock_db, formats=["flac"])
500
661
  mock_query.by_format.assert_called_once_with("flac")
@@ -869,6 +869,19 @@ wheels = [
869
869
  { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
870
870
  ]
871
871
 
872
+ [[package]]
873
+ name = "pytest-watcher"
874
+ version = "0.6.3"
875
+ source = { registry = "https://pypi.org/simple" }
876
+ dependencies = [
877
+ { name = "tomli", marker = "python_full_version < '3.11'" },
878
+ { name = "watchdog" },
879
+ ]
880
+ sdist = { url = "https://files.pythonhosted.org/packages/e6/d2/80606077b7fa8784417687f494ff801d7ab817d9a17fc94305811d5919bb/pytest_watcher-0.6.3.tar.gz", hash = "sha256:842dc904264df0ad2d5264153a66bb452fccfa46598cd6e0a5ef1d19afed9b13", size = 601878, upload-time = "2026-01-10T23:28:18.805Z" }
881
+ wheels = [
882
+ { url = "https://files.pythonhosted.org/packages/fc/3f/172d73600ad2771774cda108efb813fc724fc345e5240a81a1085f1ade5d/pytest_watcher-0.6.3-py3-none-any.whl", hash = "sha256:83e7748c933087e8276edb6078663e6afa9926434b4fd8b85cf6b32b1d5bec89", size = 12431, upload-time = "2026-01-10T23:28:17.64Z" },
883
+ ]
884
+
872
885
  [[package]]
873
886
  name = "python-dateutil"
874
887
  version = "2.9.0.post0"
@@ -972,7 +985,7 @@ wheels = [
972
985
 
973
986
  [[package]]
974
987
  name = "rekordbox-edit"
975
- version = "0.3.0.dev16"
988
+ version = "0.4.0.dev18"
976
989
  source = { editable = "." }
977
990
  dependencies = [
978
991
  { name = "click" },
@@ -989,6 +1002,7 @@ dev = [
989
1002
  { name = "pytest" },
990
1003
  { name = "pytest-cov" },
991
1004
  { name = "pytest-mock" },
1005
+ { name = "pytest-watcher" },
992
1006
  { name = "ruff" },
993
1007
  { name = "ty" },
994
1008
  ]
@@ -1009,6 +1023,7 @@ dev = [
1009
1023
  { name = "pytest", specifier = ">=8.4.1,<9" },
1010
1024
  { name = "pytest-cov", specifier = ">=6.2.1,<7" },
1011
1025
  { name = "pytest-mock", specifier = ">=3.14.1,<4" },
1026
+ { name = "pytest-watcher", specifier = ">=0.6.3,<1" },
1012
1027
  { name = "ruff", specifier = ">=0.12.8,<1" },
1013
1028
  { name = "ty", specifier = ">=0.0.1,<1" },
1014
1029
  ]
@@ -1332,6 +1347,38 @@ wheels = [
1332
1347
  { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" },
1333
1348
  ]
1334
1349
 
1350
+ [[package]]
1351
+ name = "watchdog"
1352
+ version = "6.0.0"
1353
+ source = { registry = "https://pypi.org/simple" }
1354
+ sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
1355
+ wheels = [
1356
+ { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" },
1357
+ { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" },
1358
+ { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" },
1359
+ { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" },
1360
+ { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" },
1361
+ { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" },
1362
+ { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" },
1363
+ { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" },
1364
+ { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" },
1365
+ { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" },
1366
+ { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" },
1367
+ { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" },
1368
+ { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" },
1369
+ { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" },
1370
+ { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
1371
+ { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
1372
+ { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
1373
+ { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
1374
+ { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
1375
+ { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
1376
+ { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
1377
+ { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
1378
+ { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
1379
+ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
1380
+ ]
1381
+
1335
1382
  [[package]]
1336
1383
  name = "wcwidth"
1337
1384
  version = "0.6.0"