rekordbox-edit 0.7.0.dev71__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 (98) hide show
  1. rekordbox_edit-0.8.0.dev6/.github/actions/build-release-notes/action.yml +25 -0
  2. rekordbox_edit-0.8.0.dev6/.github/actions/build-release-notes/build_release_notes.py +212 -0
  3. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/.github/workflows/cd.yml +2 -2
  4. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/.github/workflows/publish.yml +2 -2
  5. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/.pre-commit-config.yaml +1 -1
  6. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/CHANGELOG.md +18 -1
  7. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/PKG-INFO +1 -1
  8. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/docs/faqs.md +17 -8
  9. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/docs/filtering.md +9 -4
  10. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/pyproject.toml +3 -3
  11. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/_click.py +4 -3
  12. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/cli/_utils.py +1 -1
  13. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/models.py +1 -1
  14. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/query.py +24 -48
  15. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/renovate.json5 +2 -2
  16. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/test_query.py +66 -84
  17. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/uv.lock +1 -1
  18. rekordbox_edit-0.7.0.dev71/.github/actions/build-release-notes/action.yml +0 -88
  19. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/.agent-style/RULES.md +0 -0
  20. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/.agent-style/claude-code.md +0 -0
  21. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/.github/actions/commit-check/action.yml +0 -0
  22. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/.github/actions/commitizen-bump/action.yml +0 -0
  23. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/.github/actions/commitizen-bump/commitizen-bump.sh +0 -0
  24. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/.github/actions/e2e/action.yml +0 -0
  25. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/.github/actions/install/action.yml +0 -0
  26. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/.github/actions/lint/action.yml +0 -0
  27. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/.github/actions/test/action.yml +0 -0
  28. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/.github/workflows/ci.yml +0 -0
  29. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/.github/workflows/release.yml +0 -0
  30. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/.gitignore +0 -0
  31. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/.python-version +0 -0
  32. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/.readthedocs.yaml +0 -0
  33. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/AGENTS.md +0 -0
  34. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/CLAUDE.md +0 -0
  35. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/CONTRIBUTING.md +0 -0
  36. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/LICENSE +0 -0
  37. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/Makefile +0 -0
  38. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/README.md +0 -0
  39. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/codecov.yml +0 -0
  40. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/docker-compose.yml +0 -0
  41. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/docs/api.md +0 -0
  42. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/docs/commands/convert.md +0 -0
  43. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/docs/commands/edit.md +0 -0
  44. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/docs/commands/search.md +0 -0
  45. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/docs/index.md +0 -0
  46. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/docs/stylesheets/extra.css +0 -0
  47. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/mkdocs.yml +0 -0
  48. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/__init__.py +0 -0
  49. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/api/__init__.py +0 -0
  50. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/api/_utils.py +0 -0
  51. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/api/convert.py +0 -0
  52. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/api/edit.py +0 -0
  53. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/api/search.py +0 -0
  54. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/cli/__init__.py +0 -0
  55. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/cli/convert.py +0 -0
  56. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/cli/edit.py +0 -0
  57. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/cli/main.py +0 -0
  58. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/cli/search.py +0 -0
  59. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/display.py +0 -0
  60. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/logger.py +0 -0
  61. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/rekordbox_edit/utils.py +0 -0
  62. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/ruff.toml +0 -0
  63. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/__init__.py +0 -0
  64. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/api/__init__.py +0 -0
  65. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/api/test_convert.py +0 -0
  66. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/api/test_edit.py +0 -0
  67. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/api/test_search.py +0 -0
  68. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/api/test_utils.py +0 -0
  69. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/cli/__init__.py +0 -0
  70. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/cli/test_convert.py +0 -0
  71. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/cli/test_edit.py +0 -0
  72. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/cli/test_main.py +0 -0
  73. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/cli/test_search.py +0 -0
  74. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/cli/test_utils.py +0 -0
  75. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/conftest.py +0 -0
  76. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/e2e/Dockerfile +0 -0
  77. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/e2e/__init__.py +0 -0
  78. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/e2e/__snapshots__/test_journey/test_search_full_json_snapshot[macos].json +0 -0
  79. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/e2e/__snapshots__/test_journey/test_search_full_json_snapshot[windows].json +0 -0
  80. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/e2e/__snapshots__/test_journey.ambr +0 -0
  81. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/e2e/conftest.py +0 -0
  82. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/e2e/fixtures/audio/01-flac-44_1k-16b.flac +0 -0
  83. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/e2e/fixtures/audio/02-flac-96k-24b.flac +0 -0
  84. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/e2e/fixtures/audio/03-alac-44_1k-16b.m4a +0 -0
  85. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/e2e/fixtures/audio/04-alac-48k-24b.m4a +0 -0
  86. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/e2e/fixtures/audio/05-aiff-44_1k-16b.aiff +0 -0
  87. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/e2e/fixtures/audio/06-wav-96k-24b.wav +0 -0
  88. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/e2e/fixtures/audio/07-mp3-44_1k-320cbr.mp3 +0 -0
  89. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/e2e/fixtures/audio/08-mp3-44_1k-v0vbr.mp3 +0 -0
  90. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/e2e/fixtures/audio/09-aac-44_1k-256kbps.m4a +0 -0
  91. {rekordbox_edit-0.7.0.dev71 → 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
  92. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/e2e/fixtures/macos/master.6.8.6.db +0 -0
  93. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/e2e/fixtures/windows/master.6.8.6.db +0 -0
  94. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/e2e/test_journey.py +0 -0
  95. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/test_display.py +0 -0
  96. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/test_logger.py +0 -0
  97. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/test_models.py +0 -0
  98. {rekordbox_edit-0.7.0.dev71 → rekordbox_edit-0.8.0.dev6}/tests/test_utils.py +0 -0
@@ -0,0 +1,25 @@
1
+ name: "Build Release Notes"
2
+ description: "Generate categorized, per-commit release notes with each commit's PR resolved and its author credited"
3
+ inputs:
4
+ from_tag:
5
+ description: "Tag to start the changelog range from (exclusive). Leave empty for full history."
6
+ required: false
7
+ default: ""
8
+ to_tag:
9
+ description: "Tag to end the changelog range at (inclusive)"
10
+ required: true
11
+ github_token:
12
+ description: "GitHub token for API access"
13
+ required: true
14
+ runs:
15
+ using: "composite"
16
+ steps:
17
+ - name: Generate release notes
18
+ shell: bash
19
+ run: python3 "${GITHUB_ACTION_PATH}/build_release_notes.py"
20
+ env:
21
+ FROM_TAG: ${{ inputs.from_tag }}
22
+ TO_TAG: ${{ inputs.to_tag }}
23
+ OUTPUT_FILE: release-notes.md
24
+ REPO: ${{ github.repository }}
25
+ GH_TOKEN: ${{ inputs.github_token }}
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env python3
2
+ """Build categorized release notes from commits, linking each commit's PR.
3
+
4
+ Lists one entry per commit (not per PR) between ``FROM_TAG`` (exclusive) and
5
+ ``TO_TAG`` (inclusive). Each entry links the pull request that introduced the
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
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import re
17
+ import subprocess
18
+ import sys
19
+ from dataclasses import dataclass, field
20
+
21
+ # Category matchers, evaluated in order; first match wins. Mirrors the
22
+ # conventional-commit types accepted by the project's commitizen schema.
23
+ _BREAKING_TITLE = re.compile(r"^(BREAKING(\(.*\))?:|(\w+)?(\(.*\))?!:)")
24
+ _BREAKING_BODY = re.compile(r"BREAKING[\- ]CHANGE")
25
+ _CATEGORY_TITLE_RULES: list[tuple[str, re.Pattern[str]]] = [
26
+ ("## Features", re.compile(r"^feat(\(.*\))?:")),
27
+ ("## Fixes", re.compile(r"^fix(?!\(deps\))(\(.*\))?:")),
28
+ (
29
+ "## Chores",
30
+ re.compile(
31
+ r"^(chore(?!\(deps\))|refactor|ci|docs|style|perf|test|build)(\(.*\))?:"
32
+ ),
33
+ ),
34
+ ("## Dependency Updates", re.compile(r"^(chore|fix)\(deps\):")),
35
+ ]
36
+ _BREAKING_TITLE_HEADER = "## Breaking Changes"
37
+ _UNCATEGORIZED_HEADER = "## Uncategorized"
38
+ _CATEGORY_ORDER = [
39
+ _BREAKING_TITLE_HEADER,
40
+ "## Features",
41
+ "## Fixes",
42
+ "## Chores",
43
+ "## Dependency Updates",
44
+ _UNCATEGORIZED_HEADER,
45
+ ]
46
+
47
+ # Commits that are noise in a changelog: merge commits are dropped at the
48
+ # ``git log`` level (--no-merges); version-bump commits are filtered here.
49
+ _SKIP_SUBJECT = re.compile(r"^bump:")
50
+ # A trailing "(#NN)" left by squash merges, stripped so it is not duplicated
51
+ # alongside the resolved reference.
52
+ _TRAILING_PR = re.compile(r"\s*\(#\d+\)\s*$")
53
+
54
+ _RECORD_SEP = "\x1e"
55
+ _FIELD_SEP = "\x1f"
56
+
57
+
58
+ @dataclass
59
+ class Commit:
60
+ sha: str
61
+ subject: str
62
+ body: str
63
+ author: str
64
+
65
+
66
+ @dataclass
67
+ class Category:
68
+ header: str
69
+ lines: list[str] = field(default_factory=list)
70
+
71
+
72
+ def _run(args: list[str]) -> subprocess.CompletedProcess[str]:
73
+ return subprocess.run(args, capture_output=True, text=True, encoding="utf-8")
74
+
75
+
76
+ def read_commits(from_tag: str, to_tag: str) -> list[Commit]:
77
+ rng = f"{from_tag}..{to_tag}" if from_tag else to_tag
78
+ fmt = _FIELD_SEP.join(["%H", "%s", "%b", "%an"]) + _RECORD_SEP
79
+ result = _run(["git", "log", "--no-merges", f"--format={fmt}", rng])
80
+ if result.returncode != 0:
81
+ raise SystemExit(f"git log failed for range '{rng}': {result.stderr.strip()}")
82
+
83
+ commits: list[Commit] = []
84
+ for record in result.stdout.split(_RECORD_SEP):
85
+ record = record.strip("\n")
86
+ if not record:
87
+ continue
88
+ sha, subject, body, author = (record.split(_FIELD_SEP) + ["", "", ""])[:4]
89
+ commits.append(
90
+ Commit(
91
+ sha=sha,
92
+ subject=subject.strip(),
93
+ body=body.strip(),
94
+ author=author.strip(),
95
+ )
96
+ )
97
+ return commits
98
+
99
+
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)
119
+ if not chosen:
120
+ return None
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
157
+
158
+
159
+ def categorize(commit: Commit) -> str:
160
+ if _BREAKING_TITLE.match(commit.subject) or _BREAKING_BODY.search(commit.body):
161
+ return _BREAKING_TITLE_HEADER
162
+ for header, pattern in _CATEGORY_TITLE_RULES:
163
+ if pattern.match(commit.subject):
164
+ return header
165
+ return _UNCATEGORIZED_HEADER
166
+
167
+
168
+ def render_line(commit: Commit, pr: tuple[int, str] | None) -> str:
169
+ subject = _TRAILING_PR.sub("", commit.subject)
170
+ if pr is not None:
171
+ number, login = pr
172
+ credit = f"#{number} by @{login}" if login else f"#{number}"
173
+ return f"- {subject} ({credit})"
174
+ return f"- {subject} ({commit.sha[:7]} by {commit.author})"
175
+
176
+
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
+
181
+ categories = {header: Category(header) for header in _CATEGORY_ORDER}
182
+ for commit in included:
183
+ line = render_line(commit, pr_map.get(commit.sha))
184
+ categories[categorize(commit)].lines.append(line)
185
+
186
+ sections = [
187
+ f"{cat.header}\n" + "\n".join(cat.lines)
188
+ for header in _CATEGORY_ORDER
189
+ if (cat := categories[header]).lines
190
+ ]
191
+ return "\n\n".join(sections) if sections else "_No changes._"
192
+
193
+
194
+ def main() -> None:
195
+ to_tag = os.environ.get("TO_TAG", "").strip()
196
+ if not to_tag:
197
+ raise SystemExit("TO_TAG is required")
198
+ from_tag = os.environ.get("FROM_TAG", "").strip()
199
+ repo = os.environ.get("REPO") or os.environ.get("GITHUB_REPOSITORY", "")
200
+ if not repo:
201
+ raise SystemExit("REPO (or GITHUB_REPOSITORY) is required")
202
+ output_file = os.environ.get("OUTPUT_FILE", "release-notes.md")
203
+
204
+ commits = read_commits(from_tag, to_tag)
205
+ notes = build_notes(commits, repo)
206
+ with open(output_file, "w", encoding="utf-8") as handle:
207
+ handle.write(notes + "\n")
208
+ print(notes, file=sys.stderr)
209
+
210
+
211
+ if __name__ == "__main__":
212
+ main()
@@ -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,6 +1,23 @@
1
- ## v0.7.0.dev71 (2026-07-31)
1
+ ## v0.8.0.dev6 (2026-08-01)
2
2
 
3
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)
18
+
19
+
20
+ - ci: more precise changelogs on releases
4
21
  - chore: update AGENTS.md
5
22
  - docs: pave FAQ page based on convert + analysis research
6
23
  - feat(convert): verify source codec against FileType before converting
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rekordbox-edit
3
- Version: 0.7.0.dev71
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.dev71"
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"
@@ -91,8 +91,8 @@ message_template = "{{change_type}}{{scope}}: {{message}}"
91
91
  example = "feat(convert): add new conversion feature"
92
92
  schema = "<type>(<scope>): <body>"
93
93
  schema_pattern = "^(BREAKING|feat|fix|refactor|ci|test|docs|chore)(\\([^)]*\\))?!?: .+"
94
- bump_pattern = "^(BREAKING|feat|fix|perf|hotfix|refactor)"
95
- bump_map = { "BREAKING" = "MAJOR", "feat" = "MINOR", "fix" = "PATCH", "refactor" = "PATCH" }
94
+ bump_pattern = "^((BREAKING[\\-\\ ]CHANGE|BREAKING|feat|fix|perf|hotfix|refactor)(\\(.+\\))?!?):"
95
+ bump_map = { "^.+!$" = "MAJOR", "^BREAKING" = "MAJOR", "^feat" = "MINOR", "^fix" = "PATCH", "^refactor" = "PATCH" }
96
96
 
97
97
  [[tool.commitizen.customize.questions]]
98
98
  type = "list"
@@ -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/",