rekordbox-edit 0.7.0.dev72__tar.gz → 0.8.0.dev6__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 (97) hide show
  1. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/.github/actions/build-release-notes/build_release_notes.py +66 -23
  2. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/.github/workflows/cd.yml +2 -2
  3. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/.github/workflows/publish.yml +2 -2
  4. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/.pre-commit-config.yaml +1 -1
  5. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/CHANGELOG.md +17 -1
  6. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/PKG-INFO +1 -1
  7. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/docs/faqs.md +17 -8
  8. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/docs/filtering.md +9 -4
  9. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/pyproject.toml +1 -1
  10. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/_click.py +4 -3
  11. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/cli/_utils.py +1 -1
  12. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/models.py +1 -1
  13. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/query.py +24 -48
  14. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/renovate.json5 +2 -2
  15. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/test_query.py +66 -84
  16. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/uv.lock +1 -1
  17. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/.agent-style/RULES.md +0 -0
  18. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/.agent-style/claude-code.md +0 -0
  19. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/.github/actions/build-release-notes/action.yml +0 -0
  20. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/.github/actions/commit-check/action.yml +0 -0
  21. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/.github/actions/commitizen-bump/action.yml +0 -0
  22. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/.github/actions/commitizen-bump/commitizen-bump.sh +0 -0
  23. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/.github/actions/e2e/action.yml +0 -0
  24. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/.github/actions/install/action.yml +0 -0
  25. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/.github/actions/lint/action.yml +0 -0
  26. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/.github/actions/test/action.yml +0 -0
  27. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/.github/workflows/ci.yml +0 -0
  28. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/.github/workflows/release.yml +0 -0
  29. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/.gitignore +0 -0
  30. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/.python-version +0 -0
  31. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/.readthedocs.yaml +0 -0
  32. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/AGENTS.md +0 -0
  33. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/CLAUDE.md +0 -0
  34. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/CONTRIBUTING.md +0 -0
  35. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/LICENSE +0 -0
  36. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/Makefile +0 -0
  37. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/README.md +0 -0
  38. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/codecov.yml +0 -0
  39. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/docker-compose.yml +0 -0
  40. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/docs/api.md +0 -0
  41. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/docs/commands/convert.md +0 -0
  42. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/docs/commands/edit.md +0 -0
  43. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/docs/commands/search.md +0 -0
  44. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/docs/index.md +0 -0
  45. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/docs/stylesheets/extra.css +0 -0
  46. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/mkdocs.yml +0 -0
  47. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/__init__.py +0 -0
  48. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/api/__init__.py +0 -0
  49. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/api/_utils.py +0 -0
  50. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/api/convert.py +0 -0
  51. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/api/edit.py +0 -0
  52. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/api/search.py +0 -0
  53. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/cli/__init__.py +0 -0
  54. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/cli/convert.py +0 -0
  55. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/cli/edit.py +0 -0
  56. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/cli/main.py +0 -0
  57. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/cli/search.py +0 -0
  58. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/display.py +0 -0
  59. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/logger.py +0 -0
  60. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/utils.py +0 -0
  61. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/ruff.toml +0 -0
  62. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/__init__.py +0 -0
  63. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/api/__init__.py +0 -0
  64. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/api/test_convert.py +0 -0
  65. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/api/test_edit.py +0 -0
  66. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/api/test_search.py +0 -0
  67. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/api/test_utils.py +0 -0
  68. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/cli/__init__.py +0 -0
  69. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/cli/test_convert.py +0 -0
  70. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/cli/test_edit.py +0 -0
  71. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/cli/test_main.py +0 -0
  72. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/cli/test_search.py +0 -0
  73. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/cli/test_utils.py +0 -0
  74. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/conftest.py +0 -0
  75. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/e2e/Dockerfile +0 -0
  76. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/e2e/__init__.py +0 -0
  77. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/e2e/__snapshots__/test_journey/test_search_full_json_snapshot[macos].json +0 -0
  78. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/e2e/__snapshots__/test_journey/test_search_full_json_snapshot[windows].json +0 -0
  79. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/e2e/__snapshots__/test_journey.ambr +0 -0
  80. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/e2e/conftest.py +0 -0
  81. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/e2e/fixtures/audio/01-flac-44_1k-16b.flac +0 -0
  82. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/e2e/fixtures/audio/02-flac-96k-24b.flac +0 -0
  83. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/e2e/fixtures/audio/03-alac-44_1k-16b.m4a +0 -0
  84. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/e2e/fixtures/audio/04-alac-48k-24b.m4a +0 -0
  85. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/e2e/fixtures/audio/05-aiff-44_1k-16b.aiff +0 -0
  86. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/e2e/fixtures/audio/06-wav-96k-24b.wav +0 -0
  87. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/e2e/fixtures/audio/07-mp3-44_1k-320cbr.mp3 +0 -0
  88. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/e2e/fixtures/audio/08-mp3-44_1k-v0vbr.mp3 +0 -0
  89. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/e2e/fixtures/audio/09-aac-44_1k-256kbps.m4a +0 -0
  90. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/e2e/fixtures/audio/10-/303/274/303/261/303/256c/303/266d/303/251-flac-44_1k-16b.flac" +0 -0
  91. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/e2e/fixtures/macos/master.6.8.6.db +0 -0
  92. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/e2e/fixtures/windows/master.6.8.6.db +0 -0
  93. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/e2e/test_journey.py +0 -0
  94. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/test_display.py +0 -0
  95. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/test_logger.py +0 -0
  96. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/test_models.py +0 -0
  97. {rekordbox_edit-0.7.0.dev72 → rekordbox_edit-0.8.0.dev6}/tests/test_utils.py +0 -0
@@ -3,10 +3,10 @@
3
3
 
4
4
  Lists one entry per commit (not per PR) between ``FROM_TAG`` (exclusive) and
5
5
  ``TO_TAG`` (inclusive). Each entry links the pull request that introduced the
6
- commit and credits its author. The PR (number and author login) is resolved via
7
- ``GET /repos/{repo}/commits/{sha}/pulls``, which works for both squash and
8
- rebase merges. Commits with no associated PR fall back to a short-SHA reference
9
- and the git author name.
6
+ commit and credits its author. PRs are resolved through a batched GraphQL query
7
+ (``Commit.associatedPullRequests``), which works for both squash and rebase
8
+ merges without a per-commit round trip. Commits with no associated PR fall back
9
+ to a short-SHA reference and the git author name.
10
10
  """
11
11
 
12
12
  from __future__ import annotations
@@ -97,21 +97,63 @@ def read_commits(from_tag: str, to_tag: str) -> list[Commit]:
97
97
  return commits
98
98
 
99
99
 
100
- def resolve_pr(repo: str, sha: str) -> tuple[int, str] | None:
101
- """Return the (number, author_login) of a commit's PR, or None if unresolved."""
102
- result = _run(["gh", "api", f"repos/{repo}/commits/{sha}/pulls"])
103
- if result.returncode != 0 or not result.stdout.strip():
104
- return None
105
- try:
106
- prs = json.loads(result.stdout)
107
- except json.JSONDecodeError:
108
- return None
109
- merged = [pr for pr in prs if pr.get("merged_at")]
110
- chosen = merged[0] if merged else (prs[0] if prs else None)
100
+ # GitHub's GraphQL node limit is generous; keep chunks small enough that the
101
+ # query fits comfortably in a single command-line argument on any platform.
102
+ _CHUNK = 100
103
+
104
+ _COMMIT_FIELD = (
105
+ 'c{i}: object(oid: "{sha}") {{ ... on Commit {{ '
106
+ "associatedPullRequests(first: 5) {{ nodes {{ number merged author {{ login }} }} }} "
107
+ "}} }}"
108
+ )
109
+
110
+
111
+ def _chunks(items: list[str], size: int):
112
+ for start in range(0, len(items), size):
113
+ yield items[start : start + size]
114
+
115
+
116
+ def _pick_pr(nodes: list[dict]) -> tuple[int, str] | None:
117
+ merged = [pr for pr in nodes if pr.get("merged")]
118
+ chosen = merged[0] if merged else (nodes[0] if nodes else None)
111
119
  if not chosen:
112
120
  return None
113
- login = (chosen.get("user") or {}).get("login", "")
114
- return chosen["number"], login
121
+ return chosen["number"], (chosen.get("author") or {}).get("login", "")
122
+
123
+
124
+ def resolve_prs(repo: str, shas: list[str]) -> dict[str, tuple[int, str]]:
125
+ """Resolve every commit's PR (number, author_login) in batched GraphQL calls.
126
+
127
+ One request per ``_CHUNK`` commits replaces the previous per-commit REST call,
128
+ which dominated runtime. Commits with no associated PR are simply absent from
129
+ the returned mapping.
130
+ """
131
+ owner, _, name = repo.partition("/")
132
+ resolved: dict[str, tuple[int, str]] = {}
133
+ for chunk in _chunks(shas, _CHUNK):
134
+ fields = " ".join(
135
+ _COMMIT_FIELD.format(i=i, sha=sha) for i, sha in enumerate(chunk)
136
+ )
137
+ query = (
138
+ f'query {{ repository(owner: "{owner}", name: "{name}") {{ {fields} }} }}'
139
+ )
140
+ result = _run(["gh", "api", "graphql", "-f", f"query={query}"])
141
+ if result.returncode != 0 or not result.stdout.strip():
142
+ continue
143
+ try:
144
+ repository = (json.loads(result.stdout).get("data") or {}).get("repository")
145
+ except json.JSONDecodeError:
146
+ continue
147
+ if not repository:
148
+ continue
149
+ for i, sha in enumerate(chunk):
150
+ node = repository.get(f"c{i}")
151
+ if not node:
152
+ continue
153
+ pr = _pick_pr((node.get("associatedPullRequests") or {}).get("nodes") or [])
154
+ if pr is not None:
155
+ resolved[sha] = pr
156
+ return resolved
115
157
 
116
158
 
117
159
  def categorize(commit: Commit) -> str:
@@ -123,9 +165,8 @@ def categorize(commit: Commit) -> str:
123
165
  return _UNCATEGORIZED_HEADER
124
166
 
125
167
 
126
- def render_line(commit: Commit, repo: str) -> str:
168
+ def render_line(commit: Commit, pr: tuple[int, str] | None) -> str:
127
169
  subject = _TRAILING_PR.sub("", commit.subject)
128
- pr = resolve_pr(repo, commit.sha)
129
170
  if pr is not None:
130
171
  number, login = pr
131
172
  credit = f"#{number} by @{login}" if login else f"#{number}"
@@ -134,11 +175,13 @@ def render_line(commit: Commit, repo: str) -> str:
134
175
 
135
176
 
136
177
  def build_notes(commits: list[Commit], repo: str) -> str:
178
+ included = [c for c in commits if not _SKIP_SUBJECT.match(c.subject)]
179
+ pr_map = resolve_prs(repo, [c.sha for c in included])
180
+
137
181
  categories = {header: Category(header) for header in _CATEGORY_ORDER}
138
- for commit in commits:
139
- if _SKIP_SUBJECT.match(commit.subject):
140
- continue
141
- categories[categorize(commit)].lines.append(render_line(commit, repo))
182
+ for commit in included:
183
+ line = render_line(commit, pr_map.get(commit.sha))
184
+ categories[categorize(commit)].lines.append(line)
142
185
 
143
186
  sections = [
144
187
  f"{cat.header}\n" + "\n".join(cat.lines)
@@ -25,7 +25,7 @@ jobs:
25
25
  should_skip: ${{ steps.skip_check.outputs.should_skip }}
26
26
  steps:
27
27
  - id: skip_check
28
- uses: fkirc/skip-duplicate-actions@f75f66ce1886f00957d99748a42c724f4330bdcf # v5
28
+ uses: fkirc/skip-duplicate-actions@b974a9395958c231af965b70070979a577efa578 # v5
29
29
  with:
30
30
  concurrent_skipping: "same_content_newer"
31
31
  cancel_others: "true"
@@ -93,7 +93,7 @@ jobs:
93
93
  git push origin canary-v${{ steps.version.outputs.version }}
94
94
 
95
95
  - name: Publish to PyPI
96
- uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # release/v1
96
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
97
97
  with:
98
98
  skip-existing: true
99
99
 
@@ -39,7 +39,7 @@ jobs:
39
39
  name: dist
40
40
  path: dist/
41
41
  - name: Publish package distributions 📦 to TestPyPI
42
- uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # release/v1
42
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
43
43
  with:
44
44
  repository-url: https://test.pypi.org/legacy/
45
45
 
@@ -57,4 +57,4 @@ jobs:
57
57
  path: dist
58
58
  merge-multiple: true
59
59
  - name: Publish to PyPI
60
- uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # release/v1
60
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
@@ -18,7 +18,7 @@ repos:
18
18
  - id: check-toml
19
19
  - id: check-case-conflict
20
20
  - repo: https://github.com/commitizen-tools/commitizen
21
- rev: v4.16.5
21
+ rev: v4.17.0
22
22
  hooks:
23
23
  - id: commitizen
24
24
  stages: [commit-msg]
@@ -1,4 +1,20 @@
1
- ## v0.7.0.dev72 (2026-07-31)
1
+ ## v0.8.0.dev6 (2026-08-01)
2
+
3
+
4
+ - feat!: replace --exact-path with case-insensitive --resolved-path
5
+ - Path filters now always match case-insensitively, mirroring how NTFS
6
+ and APFS treat paths. --resolved-path makes its argument absolute by
7
+ pure string math instead of Path.resolve(), so results no longer
8
+ depend on mounted drives, on-disk casing, or symlinks.
9
+ - chore(deps): update pre-commit hook commitizen-tools/commitizen to v4.17.0 (#159)
10
+ - Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
11
+ - chore: reduce renovate schedule to weekends
12
+ - docs: add FAQ about converts impact on USB exports
13
+ - chore(deps): update github actions (#151)
14
+ - Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
15
+ - ci: optimize the build_release_notes script
16
+
17
+ ## v0.7.0 (2026-07-31)
2
18
 
3
19
 
4
20
  - ci: more precise changelogs on releases
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rekordbox-edit
3
- Version: 0.7.0.dev72
3
+ Version: 0.8.0.dev6
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
@@ -2,13 +2,13 @@
2
2
 
3
3
  Messing with your Rekordbox library is bound to raise some questions and concerns, particularly around preserving all the manual work you've done to organize, catalog, and prepare your library for gigs. This page is dedicated to answering those questions around what the impact and risks are of editing your rekordbox library or the underlying audio files using rekordbox-edit. If your question isn't answered here, please ask it in [a discussion](https://github.com/jviall/rekordbox-edit/discussions/new/choose).
4
4
 
5
- ## How Does Rekordbox's Analysis Work?
5
+ ## How does Rekordbox's analysis work?
6
6
 
7
7
  Analysis touches two places in your library. The database gets the summary values for things like tempo, key, and the flags Rekordbox uses to track analysis state. The heavier artifacts live in a per-track folder of analysis files (.ANLZ) containing the beat grid, waveforms, and phrase data. Your cues and loops live in neither; they are separate database records that only your own edits change.
8
8
 
9
9
  What analysis writes is a deterministic estimate. Deterministic, because the same file analyzed twice produces byte-identical results. An estimate, because tempo, grid, and key are inferred from the audio, and Rekordbox's inference is sometimes wrong. Both aspects are worth considering; when the analysis of a converted file produces a different result, it is simply a different estimate from different data.
10
10
 
11
- ## How Does Converting a Track Affect the Analysis?
11
+ ## How does converting a track affect its analysis?
12
12
 
13
13
  The reason conversion is safe comes down to how Rekordbox stores analysis: positions are timestamps. The beat grid is a list of times in milliseconds, cues are stored in milliseconds, and phrases are anchored to beats. None of it references sample counts or byte positions in the audio file. Change the codec, the sample rate, or the bit depth, and every stored position still names the same musical moment.
14
14
 
@@ -21,19 +21,19 @@ There are a few minor things left stale by a conversion (without any consequence
21
21
 
22
22
  Converting *to* FLAC does not create that seek index automatically either; only an analysis can. I have *not* tested (yet) whether that matters in practice, but it makes a stronger case for re-analyzing after a conversion to FLAC than for other targets. FLAC also is not the most ideal target format in general for Rekordbox (even though it's preferred for streaming and music hoarders), because only the more recent Pioneer devices support it. AIFF is what I would pick if you insist on lossless.
23
23
 
24
- ## Does Converting to MP3 Shift the Audio?
24
+ ## Does converting to MP3 shift the audio?
25
25
 
26
26
  No. `rbe` uses LAME's gapless-playback encoding to avoid any start-of-file offset, and across many tests the MP3 320 output never differed in timing, sample for sample, from its lossless source. Cues and the beat grid stay exactly in register.
27
27
 
28
28
  The only artifact is visual: the drawn waveform still reflects the lossless original rather than the MP3. A re-analysis would redraw it, but you'd be hard-pressed to notice the difference.
29
29
 
30
- ## Will My Cues and Beatgrid Survive?
30
+ ## Does convert impact cues or beatgrids?
31
31
 
32
- Yes. Cues, loops, and memory points are records in your library database, positioned in milliseconds and attached to the track's database entry, and `rbe convert` does not touch them. The beat grid lives in the analysis files, which convert also leaves alone. In all my testing, every cue and beat grid has been completely unchanged by a conversion, and works just as it did prior.
32
+ No. Cues, loops, and memory points are records in your library database, positioned in milliseconds and attached to the track's database entry, and `rbe convert` does not touch them. The beat grid lives in the analysis files, which convert also leaves alone. In all my testing, every cue and beat grid has been completely unchanged by a conversion, and works just as it did prior.
33
33
 
34
34
  This includes hand-tuned grids. Because convert never rewrites the grid a manually corrected beat grid survives conversion unchanged. The one operation that can replace it is a re-analysis you run yourself; see below.
35
35
 
36
- ## Should I Re-Analyze After Converting?
36
+ ## Should I re-analyze after converting?
37
37
 
38
38
  You do not need to. The existing analysis remains correct for the converted file, and Rekordbox never re-analyzes on its own; it waits for you to ask. Skipping re-analysis keeps your grid and cues exactly as they are.
39
39
 
@@ -41,10 +41,19 @@ If you do re-analyze, the outcome depends on whether the conversion changed the
41
41
 
42
42
  One real hazard: re-analysis replaces manually edited beat grids on any track that is not locked. If you plan to re-analyze after a bulk conversion, turn on Analysis Lock first for tracks whose grids you have tuned by hand.
43
43
 
44
- ## What Should I Do Before Converting?
44
+ ## What if I convert a track that's been exported to a USB?
45
+
46
+ Conversion only touches your library and the source file--it won't update your USB exports for you. The old version of the track that's on your USB will effectively be orphaned from the library. Depending on the library type (i.e. "Device Library" v.s. "One Device Library") re-syncing will either not do anything, or it will add your new converted versions but leave the stale copies behind. Manually transferring a playlist to a device and overwriting it (by drag-n-dropping in the library explorer tree) will also only add your new versions but not remove the old ones.
47
+
48
+ Perhaps a more crucial consequence of this orphaning of converted files on your USB, is that any cues, play counts, or other metadata of the converted files that is only on your USB and hasn't been synced back to Rekordbox will also be orphaned, and attempting to sync this back after a conversion won't work.
49
+
50
+ Ultimately, if you're planning on doing an in-place conversion of tracks in your library using this tool, you should consider making sure the cues, grid, and info on your USB sticks has been synced back to Rekordbox (if so desired), and be prepared to erase and freshly export your USBs in order to avoid any stale files left behind by a re-export.
51
+
52
+ ## What should I do before converting?
45
53
 
46
54
  - **Preview with `--dry-run`.** See exactly which tracks will convert and where the files will go before anything is written.
47
55
  - **Close Rekordbox.** Writing to the database while Rekordbox has it open risks corruption.
48
- - **Make fresh backups of your music and your Rekordbox database.** Conversion rewrites files and database rows in place; a current backup makes any surprise reversible.
56
+ - **Make fresh backups of your music and your Rekordbox database.** Conversion rewrites files and database rows in place; a current backup makes any mistake reversible.
57
+ - **Sync back cues, grids, and info from your USBs** Converting a track that has been exported will orphan the exported version and any un-synced metadata from your library.
49
58
  - **Keep your originals until you are satisfied.** The default `--delete-originals lossless` only deletes a source when no audio information was lost. Be deliberate before choosing `all`.
50
59
  - **Lock hand-tuned beat grids if you plan to re-analyze.** Conversion won't mess with your grids, but a later bulk re-analysis replaces grids on unlocked tracks. Analysis Lock exists for exactly this.
@@ -18,14 +18,16 @@ Repeating a filter, or combining different filters, matches tracks that satisfy
18
18
  | `--playlist TEXT` | playlist name contains `TEXT` |
19
19
  | `--exact-playlist TEXT` | playlist name is exactly `TEXT` |
20
20
  | `--format FMT` | Rekordbox file type is `FMT` (`mp3`, `mp4`, `aac`, `flac`, `alac`, `wav`, `aiff`, `video`, `invalid`) |
21
- | `--path TEXT` | file path contains `TEXT` (matched against the folder path, filename, or both) |
22
- | `--exact-path TEXT` | file path is exactly `TEXT` (resolved to an absolute path before matching) |
21
+ | `--path TEXT` | file path contains `TEXT`, case-insensitive |
22
+ | `--resolved-path TEXT` | file path contains `TEXT` after resolving it to an absolute path, case-insensitive |
23
23
  | `--first N` | return only the first N results |
24
24
  | `--last N` | return only the last N results |
25
25
 
26
26
  > [!TIP]
27
27
  > Filters that are "exact" are case-sensitive (e.g. `--exact-artist 'HOOBASTANK'` won't match "Hoobastank") whereas their counterparts (`--artist`) are case-insensitive and match substrings. Only one of either the `--first` and `--last` filters can be provided at a time as they're mutually exclusive.
28
28
 
29
+ > [!NOTE]
30
+ > Path filters match case-insensitively on every platform because the filesystems Rekordbox supports (NTFS and APFS) treat paths case-insensitively themselves.
29
31
 
30
32
  ### Examples
31
33
 
@@ -45,11 +47,14 @@ rbe search --playlist "house" --playlist "disco"
45
47
  # All the songs in my library that aren't in any playlist
46
48
  rbe search --playlist ""
47
49
 
48
- # The first 10 tracks whose path contains a folder or filename substring
50
+ # The first 10 tracks in a "Favorites" folder or with "track.wav" in the file path
49
51
  rbe search --path "Favorites/" --path "track.wav" --first 10
50
52
 
51
53
  # The track at an exact location
52
- rbe search --exact-path "/Users/djmustard/Music/banger.mp3"
54
+ rbe search --resolved-path "/Users/djmustard/Music/banger.mp3"
55
+
56
+ # Every track under a folder, given relative to the current directory
57
+ rbe search --resolved-path "../Music/house/"
53
58
  ```
54
59
 
55
60
  ## Track ID Arguments
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "rekordbox-edit"
7
- version = "0.7.0.dev72"
7
+ version = "0.8.0.dev6"
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"
@@ -81,13 +81,14 @@ global_click_filters = [
81
81
  "--path",
82
82
  type=str,
83
83
  multiple=True,
84
- help="Find tracks whose file paths include this value",
84
+ help="Find tracks whose file paths contain this value (case-insensitive)",
85
85
  ),
86
86
  click.option(
87
- "--exact-path",
87
+ "--resolved-path",
88
88
  type=str,
89
89
  multiple=True,
90
- help="Find tracks whose file paths are exactly this value",
90
+ help="Find tracks whose file paths contain this value after it is "
91
+ "resolved to an absolute path (case-insensitive)",
91
92
  ),
92
93
  click.option(
93
94
  "--format",
@@ -74,7 +74,7 @@ def _narrow_to_track_ids(args, ids: list[str]):
74
74
  "album",
75
75
  "exact_album",
76
76
  "path",
77
- "exact_path",
77
+ "resolved_path",
78
78
  "format",
79
79
  ):
80
80
  if hasattr(narrowed, field_name):
@@ -45,7 +45,7 @@ class FilterArgs(BaseModel):
45
45
  album: list[str] = []
46
46
  exact_album: list[str] = []
47
47
  path: list[str] = []
48
- exact_path: list[str] = []
48
+ resolved_path: list[str] = []
49
49
  format: list[str] = []
50
50
  first: int | None = Field(default=None, gt=0)
51
51
  last: int | None = Field(default=None, gt=0)
@@ -1,4 +1,5 @@
1
1
  import logging
2
+ import os
2
3
  from pathlib import Path
3
4
  from typing import List, Tuple, Union
4
5
 
@@ -148,57 +149,32 @@ class CollectionQuery:
148
149
  logger.warning(f"Invalid format: {format_name}")
149
150
  return new_inst
150
151
 
151
- def by_path(self, path_str: str, exact: bool = False) -> "CollectionQuery":
152
- """Filter by file path, matching against FolderPath and/or FileNameL.
153
-
154
- Paths get normalized to posix format.
155
- --path args arg matched as substrings.
156
- --exact-path argsl are resolved to absolute paths and must match exact.
157
-
158
- Args with a trailing '/' only query against FolderPath.
159
- Args with no parent folders in the path only query against FileNameL.
152
+ def by_path(self, path_str: str, resolved: bool = False) -> "CollectionQuery":
153
+ """Filter by case-insensitive substring of the track's file path.
154
+ FolderPath holds the full path to the file, including its name and
155
+ extension.
156
+
157
+ Separators get normalized to posix format, and a trailing '/' is kept
158
+ so the substring only matches directory components.
159
+ --path args match as given.
160
+ --resolved-path args are made absolute against the working directory
161
+ by pure string math (no filesystem access), so casing stays as typed
162
+ and symlinks are never followed.
160
163
  """
161
- new_inst = self._copy()
164
+ if not path_str:
165
+ return self
162
166
 
163
- is_dir_only = path_str.endswith("/") or path_str.endswith("\\")
167
+ new_inst = self._copy()
168
+ has_trailing_sep = path_str.endswith(("/", "\\"))
164
169
 
165
- if exact:
166
- resolved = Path(path_str).resolve()
167
- if is_dir_only:
168
- folder_part = resolved.as_posix() + "/"
169
- name_part = ""
170
- elif "/" not in path_str and "\\" not in path_str:
171
- folder_part = ""
172
- name_part = resolved.name
173
- else:
174
- folder_part = resolved.parent.as_posix() + "/"
175
- name_part = resolved.name
170
+ if resolved:
171
+ pattern = Path(os.path.abspath(path_str)).as_posix()
172
+ if has_trailing_sep and not pattern.endswith("/"):
173
+ pattern += "/"
176
174
  else:
177
- normalized = Path(path_str)
178
- if is_dir_only:
179
- folder_part = normalized.as_posix()
180
- name_part = ""
181
- else:
182
- parent_str = normalized.parent.as_posix()
183
- folder_part = "" if parent_str == "." else parent_str
184
- name_part = normalized.name
185
-
186
- conditions: list[ColumnElement[bool]] = []
187
- if folder_part:
188
- if exact:
189
- conditions.append(DjmdContent.FolderPath == folder_part)
190
- else:
191
- conditions.append(DjmdContent.FolderPath.ilike(f"%{folder_part}%"))
192
- if name_part:
193
- if exact:
194
- conditions.append(DjmdContent.FileNameL == name_part)
195
- else:
196
- conditions.append(DjmdContent.FileNameL.ilike(f"%{name_part}%"))
197
-
198
- if conditions:
199
- combined = and_(*conditions) if len(conditions) > 1 else conditions[0]
200
- new_inst._conditions.append(combined)
175
+ pattern = path_str.replace("\\", "/")
201
176
 
177
+ new_inst._conditions.append(DjmdContent.FolderPath.ilike(f"%{pattern}%"))
202
178
  return new_inst
203
179
 
204
180
  def limit(self, count: int) -> "CollectionQuery":
@@ -317,8 +293,8 @@ def get_filtered_content(
317
293
  for path in filters.path:
318
294
  query = query.by_path(path)
319
295
 
320
- for exact_path in filters.exact_path:
321
- query = query.by_path(exact_path, exact=True)
296
+ for resolved_path in filters.resolved_path:
297
+ query = query.by_path(resolved_path, resolved=True)
322
298
 
323
299
  if filters.match_all:
324
300
  query = query.match_all()
@@ -8,8 +8,8 @@
8
8
  ":enablePreCommit",
9
9
  ":automergePr",
10
10
  "helpers:pinGitHubActionDigests",
11
- "schedule:daily",
12
- "schedule:automergeDaily",
11
+ "schedule:weekends",
12
+ "schedule:automergeEarlyMondays",
13
13
  ],
14
14
 
15
15
  branchPrefix: "chore/deps/",
@@ -468,58 +468,44 @@ class TestCollectionQuery:
468
468
  assert " and " in stmt_str
469
469
  assert " or " not in stmt_str
470
470
 
471
- def test_by_path_filename_only(self):
472
- """A bare filename (no directory) adds a FileNameL ilike condition only.
473
- The FolderPath condition is skipped — the filter still runs with just FileNameL.
474
- """
471
+ def test_by_path_substring_against_folderpath_only(self):
472
+ """A --path arg adds a single case-insensitive substring condition on
473
+ FolderPath, which holds the full file path. FileNameL is never queried."""
475
474
  query = CollectionQuery()
476
- new_query = query.by_path("track.mp3")
475
+ new_query = query.by_path("Daft Punk")
477
476
 
478
477
  assert new_query is not query
479
478
  assert len(new_query._conditions) == 1
480
479
  condition_str = str(new_query._conditions[0])
481
- assert "FileNameL" in condition_str
480
+ assert "FolderPath" in condition_str
481
+ assert "FileNameL" not in condition_str
482
482
  assert "LIKE lower" in condition_str
483
- assert "FolderPath" not in condition_str
484
483
 
485
- def test_by_path_folder_only_forward_slash(self):
486
- """A trailing-forward-slash string adds a FolderPath ilike condition only.
487
- The FileNameL condition is skipped — the filter still runs with just FolderPath.
488
- """
484
+ def test_by_path_wraps_with_wildcards(self):
485
+ """The substring pattern is wrapped in % wildcards."""
489
486
  query = CollectionQuery()
490
- new_query = query.by_path("./Test/Folder/")
487
+ new_query = query.by_path("track.mp3")
491
488
 
492
- assert new_query is not query
493
- assert len(new_query._conditions) == 1
494
- condition_str = str(new_query._conditions[0])
495
- assert "FileNameL" not in condition_str
496
- assert "LIKE lower" in condition_str
497
- assert "FolderPath" in condition_str
489
+ condition_str = _compile(new_query._conditions[0])
490
+ assert "%track.mp3%" in condition_str
498
491
 
499
- def test_by_path_folder_and_filename(self):
500
- """A path with both parts adds a compound AND condition covering both columns."""
492
+ def test_by_path_preserves_trailing_slash(self):
493
+ """A trailing slash stays in the pattern so it only matches directory
494
+ components, not filename prefixes."""
501
495
  query = CollectionQuery()
502
- new_query = query.by_path("Music/Artist/track.mp3")
496
+ new_query = query.by_path("Daft Punk/")
503
497
 
504
- assert new_query is not query
505
- assert len(new_query._conditions) == 1
506
- condition_str = str(new_query._conditions[0])
507
- assert "FolderPath" in condition_str
508
- assert "FileNameL" in condition_str
509
- assert " AND " in condition_str
510
- assert condition_str.count("LIKE lower") == 2
498
+ condition_str = _compile(new_query._conditions[0])
499
+ assert "%Daft Punk/%" in condition_str
511
500
 
512
- @pytest.mark.skipif(
513
- platform.system() != "Windows", reason="backslash path parsing is Windows-only"
514
- )
515
501
  def test_by_path_backslash_normalised_to_forward_slash(self):
516
502
  """Backslash separators in --path input are normalised to forward slashes."""
517
503
  query = CollectionQuery()
518
- new_query = query.by_path("Music\\Artist\\track.mp3")
504
+ new_query = query.by_path("Music\\Artist\\")
519
505
 
520
- query_str = _compile(new_query._conditions[0])
521
- assert "\\" not in query_str
522
- assert "Music/Artist" in query_str
506
+ condition_str = _compile(new_query._conditions[0])
507
+ assert "\\" not in condition_str
508
+ assert "%Music/Artist/%" in condition_str
523
509
 
524
510
  def test_by_path_no_resolve(self):
525
511
  """by_path does NOT resolve the path."""
@@ -530,87 +516,83 @@ class TestCollectionQuery:
530
516
  # The raw string (or parts of it) must appear, not a resolved absolute path
531
517
  assert ".." in condition_str and "relative" in condition_str
532
518
 
533
- def test_by_path_ilike_wraps_with_wildcards(self):
534
- """The ilike condition includes % wildcards for substring matching."""
519
+ def test_by_path_empty_arg_adds_no_condition(self):
520
+ """An empty --path arg is a no-op rather than matching everything."""
535
521
  query = CollectionQuery()
536
- new_query = query.by_path("track.mp3")
522
+ new_query = query.by_path("")
537
523
 
538
- condition_str = _compile(new_query._conditions[0])
539
- assert "%" in condition_str
524
+ assert new_query._conditions == []
540
525
 
541
- # --- by_path exact mode ---
526
+ # --- by_path resolved mode ---
542
527
 
543
- def test_by_exact_path_filename_only(self):
544
- """Exact match on a bare filename produces an equality condition on FileNameL only."""
528
+ def test_by_resolved_path_resolves_relative_path(self):
529
+ """Resolved mode makes relative paths absolute against the cwd before querying."""
530
+ cwd = Path(os.getcwd()).as_posix()
545
531
  query = CollectionQuery()
546
- new_query = query.by_path("track.mp3", exact=True)
532
+ new_query = query.by_path("Album/track.mp3", resolved=True)
547
533
 
548
- assert len(new_query._conditions) == 1
549
- condition_str = str(new_query._conditions[0])
550
- assert "FolderPath" not in condition_str
551
- assert "FileNameL" in condition_str
552
- assert "=" in condition_str
553
- assert "LIKE lower" not in condition_str
534
+ condition_str = _compile(new_query._conditions[0])
535
+ assert f"%{cwd}/Album/track.mp3%" in condition_str
554
536
 
555
- def test_by_exact_path_folder_only(self):
556
- """Exact match on a only folder path produces equality check on FolderPath only."""
537
+ def test_by_resolved_path_substring_case_insensitive(self):
538
+ """Resolved mode is a case-insensitive substring check on FolderPath,
539
+ same as fuzzy mode, so a resolved folder path matches every track under it."""
557
540
  query = CollectionQuery()
558
- # Build a cross-platform absolute path using pathlib
559
- path = "/Test/Artist/"
560
- new_query = query.by_path(path, exact=True)
541
+ new_query = query.by_path("/Test/Artist/file.mp3", resolved=True)
561
542
 
562
543
  assert len(new_query._conditions) == 1
563
- condition_str = _compile(new_query._conditions[0])
544
+ condition_str = str(new_query._conditions[0])
564
545
  assert "FolderPath" in condition_str
565
546
  assert "FileNameL" not in condition_str
566
- assert "=" in condition_str
567
- assert path in condition_str
568
- assert "like lower" not in condition_str
547
+ assert "LIKE lower" in condition_str
569
548
 
570
- def test_by_exact_path_folder_and_filename(self):
571
- """Exact match on a full path produces a compound AND with equality on both columns."""
549
+ def test_by_resolved_path_is_lexical_and_preserves_casing(self):
550
+ """Resolution is pure string math: even for a directory that exists on
551
+ disk, the casing stays as typed instead of being rewritten to the
552
+ on-disk casing (Path.resolve would rewrite it on Windows)."""
553
+ miscased_cwd = Path(os.getcwd()).as_posix().swapcase()
572
554
  query = CollectionQuery()
573
- # Build a cross-platform absolute path using pathlib
574
- abs_path = "/Artist/track.mp3"
575
- new_query = query.by_path(abs_path, exact=True)
555
+ new_query = query.by_path(f"{miscased_cwd}/track.mp3", resolved=True)
576
556
 
577
- assert len(new_query._conditions) == 1
578
- condition_str = str(new_query._conditions[0])
579
- assert "FolderPath" in condition_str
580
- assert "FileNameL" in condition_str
581
- assert " AND " in condition_str
582
- assert "LIKE lower" not in condition_str
557
+ condition_str = _compile(new_query._conditions[0])
558
+ assert f"{miscased_cwd}/track.mp3" in condition_str
583
559
 
584
- def test_by_exact_path_resolves_relative_path(self):
585
- """Exact match resolves relative paths to absolute before querying."""
586
- cwd = Path(os.getcwd()).as_posix()
560
+ def test_by_resolved_path_preserves_trailing_slash(self):
561
+ """A trailing slash survives resolution so the pattern only matches
562
+ directory components."""
587
563
  query = CollectionQuery()
588
- new_query = query.by_path("Album/track.mp3", exact=True)
564
+ new_query = query.by_path("Album/", resolved=True)
589
565
 
590
566
  condition_str = _compile(new_query._conditions[0])
591
- # cwd should appear as the resolved folder part
592
- assert cwd in condition_str
567
+ assert f"{Path(os.getcwd()).as_posix()}/Album/" in condition_str
593
568
 
594
569
  @pytest.mark.skipif(
595
570
  platform.system() != "Windows", reason="backslash path parsing is Windows-only"
596
571
  )
597
- def test_by_exact_path_backslash_normalised_to_forward_slash(self):
598
- """Backslash separators in --exact-path input are normalised via as_posix()."""
572
+ def test_by_resolved_path_backslash_normalised_to_forward_slash(self):
573
+ """Backslash separators in --resolved-path input are normalised via as_posix()."""
599
574
  query = CollectionQuery()
600
575
  # Pass a Windows-style absolute path; as_posix() must produce forward slashes
601
576
  new_query = query.by_path(
602
- "C:\\Users\\foo\\music\\Artist\\track.mp3", exact=True
577
+ "C:\\Users\\foo\\music\\Artist\\track.mp3", resolved=True
603
578
  )
604
579
 
605
580
  condition_str = _compile(new_query._conditions[0])
606
581
  assert "\\" not in condition_str
607
- assert "/" in condition_str
582
+ assert "C:/Users/foo/music/Artist/track.mp3" in condition_str
583
+
584
+ def test_by_resolved_path_empty_arg_adds_no_condition(self):
585
+ """An empty --resolved-path arg is a no-op rather than matching everything."""
586
+ query = CollectionQuery()
587
+ new_query = query.by_path("", resolved=True)
588
+
589
+ assert new_query._conditions == []
608
590
 
609
591
  def test_by_path_returns_new_instance(self):
610
592
  """by_path always returns a new CollectionQuery instance."""
611
593
  query = CollectionQuery()
612
594
  assert query.by_path("track.mp3") is not query
613
- assert query.by_path("track.mp3", exact=True) is not query
595
+ assert query.by_path("track.mp3", resolved=True) is not query
614
596
 
615
597
 
616
598
  @pytest.fixture
@@ -699,9 +681,9 @@ class TestGetFilteredContent:
699
681
  get_filtered_content(mock_db, FilterArgs(path=["Music/track.mp3"]))
700
682
  mock_query.by_path.assert_called_once_with("Music/track.mp3")
701
683
 
702
- def test_exact_path(self, mock_db, mock_query):
703
- get_filtered_content(mock_db, FilterArgs(exact_path=["/Music/track.wav"]))
704
- mock_query.by_path.assert_called_once_with("/Music/track.wav", exact=True)
684
+ def test_resolved_path(self, mock_db, mock_query):
685
+ get_filtered_content(mock_db, FilterArgs(resolved_path=["/Music/track.wav"]))
686
+ mock_query.by_path.assert_called_once_with("/Music/track.wav", resolved=True)
705
687
 
706
688
  def test_format(self, mock_db, mock_query):
707
689
  get_filtered_content(mock_db, FilterArgs(format=["flac"]))
@@ -1268,7 +1268,7 @@ wheels = [
1268
1268
 
1269
1269
  [[package]]
1270
1270
  name = "rekordbox-edit"
1271
- version = "0.7.0.dev72"
1271
+ version = "0.8.0.dev6"
1272
1272
  source = { editable = "." }
1273
1273
  dependencies = [
1274
1274
  { name = "click" },