rekordbox-edit 0.7.0.dev64__tar.gz → 0.7.0.dev71__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.
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.gitignore +1 -3
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/AGENTS.md +4 -1
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/CHANGELOG.md +15 -3
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/PKG-INFO +1 -1
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/docs/commands/convert.md +2 -1
- rekordbox_edit-0.7.0.dev71/docs/faqs.md +50 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/docs/filtering.md +2 -2
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/mkdocs.yml +1 -3
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/pyproject.toml +1 -1
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/_click.py +4 -1
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/api/convert.py +24 -12
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/cli/convert.py +14 -4
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/models.py +1 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/query.py +3 -3
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/utils.py +129 -43
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/api/test_convert.py +179 -79
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/cli/test_convert.py +26 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/cli/test_edit.py +6 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/test_display.py +1 -1
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/test_query.py +8 -8
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/test_utils.py +131 -27
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/uv.lock +1 -1
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.agent-style/RULES.md +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.agent-style/claude-code.md +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.github/actions/build-release-notes/action.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.github/actions/commit-check/action.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.github/actions/commitizen-bump/action.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.github/actions/commitizen-bump/commitizen-bump.sh +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.github/actions/e2e/action.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.github/actions/install/action.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.github/actions/lint/action.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.github/actions/test/action.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.github/workflows/cd.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.github/workflows/ci.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.github/workflows/publish.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.github/workflows/release.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.pre-commit-config.yaml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.python-version +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.readthedocs.yaml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/CLAUDE.md +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/CONTRIBUTING.md +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/LICENSE +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/Makefile +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/README.md +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/codecov.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/docker-compose.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/docs/api.md +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/docs/commands/edit.md +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/docs/commands/search.md +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/docs/index.md +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/docs/stylesheets/extra.css +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/__init__.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/api/__init__.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/api/_utils.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/api/edit.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/api/search.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/cli/__init__.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/cli/_utils.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/cli/edit.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/cli/main.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/cli/search.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/display.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/logger.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/renovate.json5 +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/ruff.toml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/__init__.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/api/__init__.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/api/test_edit.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/api/test_search.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/api/test_utils.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/cli/__init__.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/cli/test_main.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/cli/test_search.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/cli/test_utils.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/conftest.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/Dockerfile +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/__init__.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/__snapshots__/test_journey/test_search_full_json_snapshot[macos].json +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/__snapshots__/test_journey/test_search_full_json_snapshot[windows].json +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/__snapshots__/test_journey.ambr +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/conftest.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/fixtures/audio/01-flac-44_1k-16b.flac +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/fixtures/audio/02-flac-96k-24b.flac +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/fixtures/audio/03-alac-44_1k-16b.m4a +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/fixtures/audio/04-alac-48k-24b.m4a +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/fixtures/audio/05-aiff-44_1k-16b.aiff +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/fixtures/audio/06-wav-96k-24b.wav +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/fixtures/audio/07-mp3-44_1k-320cbr.mp3 +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/fixtures/audio/08-mp3-44_1k-v0vbr.mp3 +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/fixtures/audio/09-aac-44_1k-256kbps.m4a +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/fixtures/audio/10-/303/274/303/261/303/256c/303/266d/303/251-flac-44_1k-16b.flac" +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/fixtures/macos/master.6.8.6.db +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/fixtures/windows/master.6.8.6.db +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/test_journey.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/test_logger.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/test_models.py +0 -0
|
@@ -91,7 +91,10 @@ Every module should get and use its own `logger` for all logging purposes and fo
|
|
|
91
91
|
- Commit descriptions should be terse and use active tenses (e.g. "add feature")
|
|
92
92
|
- Commit bodies are completely optional, and should be used to describe changes when they aren't easily inferred by the main message, they should not include agent conversation context, decisions, or plan notes.
|
|
93
93
|
- The agent may commit. But the user always handles pushes, PR creation, and rebases after merges.
|
|
94
|
-
- Do not commit spec, brainstorming, or design documents (e.g. anything under `docs
|
|
94
|
+
- Do not commit spec, brainstorming, or design documents (e.g. anything under `ai-docs/`).
|
|
95
|
+
- **Save plans to:** `ai-docs/YYYYMMDD-HHmm-{session-slug}/plan.md` instead of the plugin or skill presets.
|
|
96
|
+
- **Save specs to:** `ai-docs/YYYYMMDD-HHmm-{session-slug}/specs.md` instead of the plugin or skill presets.
|
|
97
|
+
- **Save decision records to:** `ai-docs/decisions/{topic}.md` (not session-scoped).
|
|
95
98
|
|
|
96
99
|
## Tooling
|
|
97
100
|
|
|
@@ -1,6 +1,18 @@
|
|
|
1
|
-
## v0.7.0.
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
## v0.7.0.dev71 (2026-07-31)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
- chore: update AGENTS.md
|
|
5
|
+
- docs: pave FAQ page based on convert + analysis research
|
|
6
|
+
- feat(convert): verify source codec against FileType before converting
|
|
7
|
+
- test(cli): mock rekordbox pid check in edit CLI tests
|
|
8
|
+
- feat: add probe/file-type matcher and codec_mismatch skip reason
|
|
9
|
+
- feat(utils): probe codec and container in get_audio_info
|
|
10
|
+
- feat: adds display and filtering support for all rekordbox file types
|
|
11
|
+
- - creates a FileTypeInfo registry to map all the different dimensions of
|
|
12
|
+
FileType under one database code
|
|
13
|
+
- adds AAC and ALAC .m4a FileTypes (4 and 6)
|
|
14
|
+
- adds .mp4 FileType (3)
|
|
15
|
+
- adds video FileType (16)
|
|
4
16
|
- chore(deps): update dependency ty to v0.0.64 (#152)
|
|
5
17
|
- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
|
|
6
18
|
- chore(deps): update pre-commit hook astral-sh/ruff-pre-commit to v0.16.0 (#149)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rekordbox-edit
|
|
3
|
-
Version: 0.7.0.
|
|
3
|
+
Version: 0.7.0.dev71
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# convert
|
|
2
2
|
|
|
3
|
-
Convert audio files between formats and update the Rekordbox database to point at the new files.
|
|
3
|
+
Convert audio files between formats and update the Rekordbox database to point at the new files. Cues, analysis, beatgrids, and all other metadata are preserved — see [Frequently Asked Questions](../faqs.md) for more info.
|
|
4
4
|
|
|
5
5
|
## Supported Formats
|
|
6
6
|
|
|
@@ -50,6 +50,7 @@ rbe search --artist "Lauryn Hill" --print ids | rbe convert --yes
|
|
|
50
50
|
### Guardrails
|
|
51
51
|
- Without flags, `convert` shows every planned change and asks once before applying. `--interactive` confirms each track individually; `--dry-run` previews without writing; `--yes` confirms the default choice for all prompts without asking.
|
|
52
52
|
- Editing while Rekordbox is open risks corrupting your database. By default `convert` warns and asks for confirmation (defaulting to no, so a `--yes` would exit); in a non-interactive mode (e.g. `--print ids`) it throws an error.
|
|
53
|
+
- Before a large run, walk through the checklist in [What Should I Do Before Converting?](../faqs.md#what-should-i-do-before-converting)
|
|
53
54
|
|
|
54
55
|
## Reference
|
|
55
56
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Frequently Asked Questions
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
## How Does Rekordbox's Analysis Work?
|
|
6
|
+
|
|
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
|
+
|
|
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
|
+
|
|
11
|
+
## How Does Converting a Track Affect the Analysis?
|
|
12
|
+
|
|
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
|
+
|
|
15
|
+
`rbe convert` therefore only has to update what identifies the file: the track's database row (the file path, sample rate, bit depth, and similar columns) and the path recorded inside the analysis files. It rewrites nothing else.
|
|
16
|
+
|
|
17
|
+
There are a few minor things left stale by a conversion (without any consequence that I have observed yet):
|
|
18
|
+
|
|
19
|
+
- The waveform still draws the old audio: identical unless you converted to MP3, and then only imperceptibly off.
|
|
20
|
+
- A FLAC-specific seek index goes stale when you convert away from FLAC, which would be removed on any later analysis.
|
|
21
|
+
|
|
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
|
+
|
|
24
|
+
## Does Converting to MP3 Shift the Audio?
|
|
25
|
+
|
|
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
|
+
|
|
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
|
+
|
|
30
|
+
## Will My Cues and Beatgrid Survive?
|
|
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.
|
|
33
|
+
|
|
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
|
+
|
|
36
|
+
## Should I Re-Analyze After Converting?
|
|
37
|
+
|
|
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
|
+
|
|
40
|
+
If you do re-analyze, the outcome depends on whether the conversion changed the audio samples. A pure container swap (WAV to FLAC or AIFF at the same depth and rate) reproduces the previous analysis exactly. A conversion that resamples, reduces bit depth, or lossily compresses to MP3 produces a slightly different estimate: about half a BPM of tempo difference, a first beat shifted by tens of milliseconds, cues sitting up to roughly 20 ms off the new grid. The new grid is not necessarily more or less correct than the previous; your ears and eyes will be the best judges of that. Ultimately it is the same song being analyzed, but Rekordbox made a fresh estimate on different underlying data. I would argue, though, that an analysis of a high-res file is more likely to be accurate than one of a lower-fidelity file.
|
|
41
|
+
|
|
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
|
+
|
|
44
|
+
## What Should I Do Before Converting?
|
|
45
|
+
|
|
46
|
+
- **Preview with `--dry-run`.** See exactly which tracks will convert and where the files will go before anything is written.
|
|
47
|
+
- **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.
|
|
49
|
+
- **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
|
+
- **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.
|
|
@@ -17,7 +17,7 @@ Repeating a filter, or combining different filters, matches tracks that satisfy
|
|
|
17
17
|
| `--exact-album TEXT` | album name is exactly `TEXT` |
|
|
18
18
|
| `--playlist TEXT` | playlist name contains `TEXT` |
|
|
19
19
|
| `--exact-playlist TEXT` | playlist name is exactly `TEXT` |
|
|
20
|
-
| `--format FMT` | file
|
|
20
|
+
| `--format FMT` | Rekordbox file type is `FMT` (`mp3`, `mp4`, `aac`, `flac`, `alac`, `wav`, `aiff`, `video`, `invalid`) |
|
|
21
21
|
| `--path TEXT` | file path contains `TEXT` (matched against the folder path, filename, or both) |
|
|
22
22
|
| `--exact-path TEXT` | file path is exactly `TEXT` (resolved to an absolute path before matching) |
|
|
23
23
|
| `--first N` | return only the first N results |
|
|
@@ -104,4 +104,4 @@ rbe search --artist "Daft Punk" --artist "Justice" --print ids \
|
|
|
104
104
|
| rbe convert --format-out mp3 --dry-run
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
-
For richer pipelines, `--print json` emits the same selection as structured data for `jq` and other shell tools. Use the [API functions](
|
|
107
|
+
For richer pipelines, `--print json` emits the same selection as structured data for `jq` and other shell tools. Use the [API functions](api.md#functions) if you want to build a more complex python script.
|
|
@@ -4,9 +4,6 @@ site_url: https://rekordbox-edit.readthedocs.io/
|
|
|
4
4
|
repo_url: https://github.com/jviall/rekordbox-edit
|
|
5
5
|
repo_name: jviall/rekordbox-edit
|
|
6
6
|
|
|
7
|
-
exclude_docs: |
|
|
8
|
-
superpowers/
|
|
9
|
-
|
|
10
7
|
theme:
|
|
11
8
|
name: material
|
|
12
9
|
palette:
|
|
@@ -60,4 +57,5 @@ nav:
|
|
|
60
57
|
- search: commands/search.md
|
|
61
58
|
- edit: commands/edit.md
|
|
62
59
|
- convert: commands/convert.md
|
|
60
|
+
- FAQs: faqs.md
|
|
63
61
|
- API Reference: api.md
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "rekordbox-edit"
|
|
7
|
-
version = "0.7.0.
|
|
7
|
+
version = "0.7.0.dev71"
|
|
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,7 +91,10 @@ global_click_filters = [
|
|
|
91
91
|
),
|
|
92
92
|
click.option(
|
|
93
93
|
"--format",
|
|
94
|
-
type=click.Choice(
|
|
94
|
+
type=click.Choice(
|
|
95
|
+
["mp3", "mp4", "aac", "flac", "alac", "wav", "aiff", "video", "invalid"],
|
|
96
|
+
case_sensitive=False,
|
|
97
|
+
),
|
|
95
98
|
multiple=True,
|
|
96
99
|
help="Find tracks of this format",
|
|
97
100
|
),
|
|
@@ -21,12 +21,14 @@ from rekordbox_edit.models import (
|
|
|
21
21
|
)
|
|
22
22
|
from rekordbox_edit.query import get_filtered_content
|
|
23
23
|
from rekordbox_edit.utils import (
|
|
24
|
-
|
|
24
|
+
FILE_TYPES,
|
|
25
|
+
AudioInfo,
|
|
25
26
|
OutputFormats,
|
|
26
27
|
get_audio_info,
|
|
27
28
|
get_extension_for_format,
|
|
28
29
|
get_file_type_for_format,
|
|
29
30
|
get_file_type_name,
|
|
31
|
+
probe_matches_file_type,
|
|
30
32
|
)
|
|
31
33
|
|
|
32
34
|
logger = logging.getLogger(__name__)
|
|
@@ -34,8 +36,8 @@ logger = logging.getLogger(__name__)
|
|
|
34
36
|
TARGET_BIT_DEPTH = 16
|
|
35
37
|
TARGET_SAMPLE_RATE = 44100
|
|
36
38
|
|
|
37
|
-
# Rekordbox FileType codes RBE converts from: the
|
|
38
|
-
_INPUT_FILE_TYPES = {
|
|
39
|
+
# Rekordbox FileType codes RBE converts from: the lossless whitelist.
|
|
40
|
+
_INPUT_FILE_TYPES = {code for code, info in FILE_TYPES.items() if info.convertable}
|
|
39
41
|
|
|
40
42
|
_HI_RES_CODECS = {
|
|
41
43
|
"aiff": "pcm_s16be",
|
|
@@ -55,16 +57,14 @@ def _effective_sample_rate(source_rate: int | None) -> int:
|
|
|
55
57
|
return TARGET_SAMPLE_RATE
|
|
56
58
|
|
|
57
59
|
|
|
58
|
-
def
|
|
59
|
-
|
|
60
|
+
def _classify_fidelity(
|
|
61
|
+
audio_info: AudioInfo,
|
|
60
62
|
) -> Tuple[Literal["lossless", "lossy"], int]:
|
|
61
|
-
"""
|
|
62
|
-
|
|
63
|
-
the
|
|
64
|
-
|
|
65
|
-
doubt.
|
|
63
|
+
"""The conversion's fidelity and effective sample rate for a probed
|
|
64
|
+
source. "lossless" means no audio information is lost: the source is at
|
|
65
|
+
the target bit depth and at or below the target sample rate. An unknown
|
|
66
|
+
bit depth counts as lossy so originals are kept when in doubt.
|
|
66
67
|
"""
|
|
67
|
-
audio_info = get_audio_info(source_path)
|
|
68
68
|
bit_depth = audio_info["bit_depth"]
|
|
69
69
|
sample_rate = audio_info["sample_rate"]
|
|
70
70
|
logger.debug(
|
|
@@ -374,13 +374,25 @@ def convert(
|
|
|
374
374
|
if not os.path.exists(src):
|
|
375
375
|
raise RuntimeError(f"Source not found: {src}")
|
|
376
376
|
|
|
377
|
+
audio_info = get_audio_info(src)
|
|
378
|
+
if not probe_matches_file_type(
|
|
379
|
+
content.FileType, audio_info["codec"], audio_info["container"]
|
|
380
|
+
):
|
|
381
|
+
logger.warning(
|
|
382
|
+
f"Skipping {content.FileNameL}: probed codec "
|
|
383
|
+
f"{audio_info['codec']!r} does not match its Rekordbox "
|
|
384
|
+
f"file type {get_file_type_name(content.FileType)!r}"
|
|
385
|
+
)
|
|
386
|
+
skipped.append(SkippedTrack(id=op.id, reason="codec_mismatch"))
|
|
387
|
+
continue
|
|
388
|
+
|
|
377
389
|
if args.format_out.upper() == "MP3":
|
|
378
390
|
output_sample_rate = TARGET_SAMPLE_RATE
|
|
379
391
|
success = _run_ffmpeg(
|
|
380
392
|
src, op.output_path, _mp3_output_kwargs(output_sample_rate), "mp3"
|
|
381
393
|
)
|
|
382
394
|
else:
|
|
383
|
-
fidelity, output_sample_rate =
|
|
395
|
+
fidelity, output_sample_rate = _classify_fidelity(audio_info)
|
|
384
396
|
if fidelity == "lossless":
|
|
385
397
|
lossless_op_ids.add(op.id)
|
|
386
398
|
output_format = OutputFormats(args.format_out.lower())
|
|
@@ -48,9 +48,10 @@ logger = logging.getLogger(__name__)
|
|
|
48
48
|
def convert_command(db, **kwargs):
|
|
49
49
|
"""Convert hi-res audio files between formats and update RekordBox database.
|
|
50
50
|
|
|
51
|
-
Supports conversion from any hi-res format (FLAC, AIFF, WAV) to:
|
|
52
|
-
AIFF, FLAC, WAV, or MP3. Skips lossy formats and files already
|
|
53
|
-
the target format.
|
|
51
|
+
Supports conversion from any hi-res format (FLAC, ALAC, AIFF, WAV) to:
|
|
52
|
+
AIFF, FLAC, WAV, or MP3. Skips lossy formats, video, and files already
|
|
53
|
+
in the target format. Each source's codec is verified against its
|
|
54
|
+
database file type before converting; mismatches are skipped.
|
|
54
55
|
|
|
55
56
|
Lossless conversions target 16-bit/44.1 kHz: higher-resolution sources
|
|
56
57
|
are down-sampled, and sources below the target keep their own sample
|
|
@@ -122,6 +123,9 @@ def convert_command(db, **kwargs):
|
|
|
122
123
|
return
|
|
123
124
|
response = convert(db, args)
|
|
124
125
|
|
|
126
|
+
# The preview cannot surface codec_mismatch (dry runs never probe), so
|
|
127
|
+
# report skips found only during the live run.
|
|
128
|
+
_report_skips([s for s in response.result.skipped if s.reason == "codec_mismatch"])
|
|
125
129
|
_print_convert_result(response, print_opt, scripting_mode, dry_run=False)
|
|
126
130
|
|
|
127
131
|
|
|
@@ -129,17 +133,23 @@ def _report_skips(skipped) -> None:
|
|
|
129
133
|
already_target = sum(1 for s in skipped if s.reason == "already_target_format")
|
|
130
134
|
unsupported = sum(1 for s in skipped if s.reason == "unsupported_source_format")
|
|
131
135
|
conflicts = sum(1 for s in skipped if s.reason == "output_file_exists")
|
|
136
|
+
mismatches = sum(1 for s in skipped if s.reason == "codec_mismatch")
|
|
132
137
|
if already_target:
|
|
133
138
|
logger.warning(f"Skipping {already_target} file(s): already in target format")
|
|
134
139
|
if unsupported:
|
|
135
140
|
logger.warning(
|
|
136
141
|
f"Skipping {unsupported} file(s): unsupported source format "
|
|
137
|
-
"(only FLAC, AIFF, WAV are converted)"
|
|
142
|
+
"(only FLAC, ALAC, AIFF, WAV are converted)"
|
|
138
143
|
)
|
|
139
144
|
if conflicts:
|
|
140
145
|
logger.warning(
|
|
141
146
|
f"Skipping {conflicts} file(s): output exists (use --overwrite to convert)"
|
|
142
147
|
)
|
|
148
|
+
if mismatches:
|
|
149
|
+
logger.warning(
|
|
150
|
+
f"Skipping {mismatches} file(s): file content does not match its "
|
|
151
|
+
"Rekordbox file type"
|
|
152
|
+
)
|
|
143
153
|
|
|
144
154
|
|
|
145
155
|
def _print_convert_result(
|
|
@@ -132,7 +132,7 @@ class CollectionQuery:
|
|
|
132
132
|
|
|
133
133
|
def by_format(self, format_name: str) -> "CollectionQuery":
|
|
134
134
|
"""Filter by file format."""
|
|
135
|
-
from rekordbox_edit.utils import
|
|
135
|
+
from rekordbox_edit.utils import get_file_type_codes_for_format
|
|
136
136
|
|
|
137
137
|
if not format_name:
|
|
138
138
|
logger.warning("Empty format filter has no effect")
|
|
@@ -141,8 +141,8 @@ class CollectionQuery:
|
|
|
141
141
|
new_inst = self._copy()
|
|
142
142
|
|
|
143
143
|
try:
|
|
144
|
-
|
|
145
|
-
condition = DjmdContent.FileType
|
|
144
|
+
file_type_codes = get_file_type_codes_for_format(format_name)
|
|
145
|
+
condition = DjmdContent.FileType.in_(file_type_codes)
|
|
146
146
|
new_inst._conditions.append(condition)
|
|
147
147
|
except ValueError:
|
|
148
148
|
logger.warning(f"Invalid format: {format_name}")
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
import logging
|
|
4
4
|
import platform
|
|
5
5
|
import shutil
|
|
6
|
+
from dataclasses import dataclass
|
|
6
7
|
from enum import Enum
|
|
8
|
+
from typing import TypedDict
|
|
7
9
|
|
|
8
10
|
import click
|
|
9
11
|
import ffmpeg
|
|
@@ -17,61 +19,131 @@ class UserQuit(Exception):
|
|
|
17
19
|
pass
|
|
18
20
|
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"""
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class FileTypeInfo:
|
|
24
|
+
"""One Rekordbox FileType code: how RBE names, filters, probes, and
|
|
25
|
+
converts it. ``codecs`` are ffprobe codec_name prefixes and
|
|
26
|
+
``containers`` are format_name substrings; a probe must satisfy both
|
|
27
|
+
(WAV and AIFF share PCM codecs, so their containers disambiguate)."""
|
|
28
|
+
|
|
29
|
+
code: int
|
|
30
|
+
name: str
|
|
31
|
+
token: str
|
|
32
|
+
extension: str | None = None
|
|
33
|
+
codecs: tuple[str, ...] = ()
|
|
34
|
+
containers: tuple[str, ...] = ()
|
|
35
|
+
convertable: bool = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
FILE_TYPES: dict[int, FileTypeInfo] = {
|
|
39
|
+
# Corrupt or empty content, independent of container.
|
|
40
|
+
0: FileTypeInfo(code=0, name="INVALID", token="invalid"),
|
|
41
|
+
1: FileTypeInfo(code=1, name="MP3", token="mp3", extension=".mp3", codecs=("mp3",)),
|
|
42
|
+
# 3 is the .mp4 container regardless of content: audio-only AAC/ALAC
|
|
43
|
+
# .mp4 files land here alongside video .mp4, so RBE never converts it.
|
|
44
|
+
3: FileTypeInfo(code=3, name="MP4", token="mp4"),
|
|
45
|
+
4: FileTypeInfo(code=4, name="AAC", token="aac", codecs=("aac",)),
|
|
46
|
+
5: FileTypeInfo(
|
|
47
|
+
code=5,
|
|
48
|
+
name="FLAC",
|
|
49
|
+
token="flac",
|
|
50
|
+
extension=".flac",
|
|
51
|
+
codecs=("flac",),
|
|
52
|
+
convertable=True,
|
|
53
|
+
),
|
|
54
|
+
6: FileTypeInfo(
|
|
55
|
+
code=6,
|
|
56
|
+
name="ALAC",
|
|
57
|
+
token="alac",
|
|
58
|
+
codecs=("alac",),
|
|
59
|
+
convertable=True,
|
|
60
|
+
),
|
|
61
|
+
11: FileTypeInfo(
|
|
62
|
+
code=11,
|
|
63
|
+
name="WAV",
|
|
64
|
+
token="wav",
|
|
65
|
+
extension=".wav",
|
|
66
|
+
codecs=("pcm_",),
|
|
67
|
+
containers=("wav",),
|
|
68
|
+
convertable=True,
|
|
69
|
+
),
|
|
70
|
+
12: FileTypeInfo(
|
|
71
|
+
code=12,
|
|
72
|
+
name="AIFF",
|
|
73
|
+
token="aiff",
|
|
74
|
+
extension=".aiff",
|
|
75
|
+
codecs=("pcm_",),
|
|
76
|
+
containers=("aiff",),
|
|
77
|
+
convertable=True,
|
|
78
|
+
),
|
|
79
|
+
# Catch-all for non-mp4 video containers (avi, m4v, mov, mpg).
|
|
80
|
+
16: FileTypeInfo(code=16, name="VIDEO", token="video"),
|
|
81
|
+
}
|
|
23
82
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"""
|
|
83
|
+
|
|
84
|
+
def get_file_type_name(file_type_code: int | None) -> str | None:
|
|
85
|
+
"""Map a Rekordbox FileType code to a display name, or None if unmapped."""
|
|
27
86
|
if file_type_code is None:
|
|
28
87
|
return None
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def get_file_type_for_format(format_name: str):
|
|
41
|
-
"""Get file type code for format name (case-insensitive)."""
|
|
88
|
+
info = FILE_TYPES.get(file_type_code)
|
|
89
|
+
return info.name if info else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_file_type_codes_for_format(format_name: str) -> set[int]:
|
|
93
|
+
"""All FileType codes a format token matches (case-insensitive).
|
|
94
|
+
|
|
95
|
+
Tokens mirror FileType values one to one.
|
|
96
|
+
"""
|
|
42
97
|
if not format_name:
|
|
43
98
|
raise ValueError("Format name cannot be empty or None")
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if
|
|
99
|
+
token = format_name.lower()
|
|
100
|
+
codes = {code for code, info in FILE_TYPES.items() if token == info.token}
|
|
101
|
+
if not codes:
|
|
47
102
|
raise ValueError(f"Unknown format: {format_name}")
|
|
48
|
-
return
|
|
103
|
+
return codes
|
|
49
104
|
|
|
50
105
|
|
|
51
|
-
def
|
|
52
|
-
"""
|
|
106
|
+
def get_file_type_for_format(format_name: str) -> int:
|
|
107
|
+
"""The FileType code Rekordbox records for files RBE writes in this
|
|
108
|
+
output format (case-insensitive). Raises for non-output formats."""
|
|
53
109
|
if not format_name:
|
|
54
110
|
raise ValueError("Format name cannot be empty or None")
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
111
|
+
token = format_name.lower()
|
|
112
|
+
for code, info in FILE_TYPES.items():
|
|
113
|
+
if info.extension and token == info.token:
|
|
114
|
+
return code
|
|
115
|
+
raise ValueError(f"Unknown format: {format_name}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_extension_for_format(format_name: str) -> str:
|
|
119
|
+
"""Get file extension for an output format name (case-insensitive)."""
|
|
120
|
+
code = get_file_type_for_format(format_name)
|
|
121
|
+
extension = FILE_TYPES[code].extension
|
|
122
|
+
assert extension is not None # get_file_type_for_format only returns such codes
|
|
64
123
|
return extension
|
|
65
124
|
|
|
66
125
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
126
|
+
def probe_matches_file_type(
|
|
127
|
+
file_type_code: int | None, codec: str | None, container: str | None
|
|
128
|
+
) -> bool:
|
|
129
|
+
"""Whether a probed codec/container is consistent with a Rekordbox
|
|
130
|
+
FileType code. Unknown codes never match, so callers treat them as
|
|
131
|
+
mismatches instead of converting blind."""
|
|
132
|
+
if file_type_code is None:
|
|
133
|
+
return False
|
|
134
|
+
info = FILE_TYPES.get(file_type_code)
|
|
135
|
+
if info is None:
|
|
136
|
+
return False
|
|
137
|
+
if info.codecs and not (codec or "").startswith(info.codecs):
|
|
138
|
+
return False
|
|
139
|
+
if info.containers:
|
|
140
|
+
if not container or not any(c in container for c in info.containers):
|
|
141
|
+
return False
|
|
142
|
+
return True
|
|
72
143
|
|
|
73
144
|
|
|
74
|
-
class
|
|
145
|
+
class OutputFormats(Enum):
|
|
146
|
+
MP3 = "mp3"
|
|
75
147
|
FLAC = "flac"
|
|
76
148
|
AIFF = "aiff"
|
|
77
149
|
WAV = "wav"
|
|
@@ -99,12 +171,24 @@ or https://ffmpeg.org/download.html
|
|
|
99
171
|
"""
|
|
100
172
|
|
|
101
173
|
|
|
102
|
-
|
|
174
|
+
class AudioInfo(TypedDict):
|
|
175
|
+
"""Fields extracted from an ffmpeg probe of one audio file."""
|
|
176
|
+
|
|
177
|
+
bit_depth: int | None
|
|
178
|
+
sample_rate: int
|
|
179
|
+
channels: int
|
|
180
|
+
bitrate: int | None
|
|
181
|
+
codec: str | None
|
|
182
|
+
container: str | None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def get_audio_info(file_path) -> AudioInfo:
|
|
103
186
|
"""Get audio information from file using ffmpeg probe.
|
|
104
187
|
|
|
105
188
|
Returns None for any field that cannot be determined from the probe data.
|
|
106
|
-
Callers are responsible for handling None values and applying
|
|
107
|
-
assumptions (e.g. MP3 has no true bit depth).
|
|
189
|
+
Callers are responsible for handling None values and applying
|
|
190
|
+
format-specific assumptions (e.g. MP3 has no true bit depth). ``codec``
|
|
191
|
+
is the stream's codec_name and ``container`` the probe's format_name.
|
|
108
192
|
"""
|
|
109
193
|
try:
|
|
110
194
|
# Check if ffmpeg is available first
|
|
@@ -163,6 +247,8 @@ def get_audio_info(file_path) -> dict[str, int | None]:
|
|
|
163
247
|
"sample_rate": int(audio_stream.get("sample_rate", 44100)),
|
|
164
248
|
"channels": int(audio_stream.get("channels", 2)),
|
|
165
249
|
"bitrate": bitrate,
|
|
250
|
+
"codec": audio_stream.get("codec_name"),
|
|
251
|
+
"container": probe.get("format", {}).get("format_name"),
|
|
166
252
|
}
|
|
167
253
|
except Exception as e:
|
|
168
254
|
logger.error(f"Failed to get audio info for {file_path}: {e}")
|