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.
Files changed (96) hide show
  1. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.gitignore +1 -3
  2. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/AGENTS.md +4 -1
  3. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/CHANGELOG.md +15 -3
  4. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/PKG-INFO +1 -1
  5. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/docs/commands/convert.md +2 -1
  6. rekordbox_edit-0.7.0.dev71/docs/faqs.md +50 -0
  7. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/docs/filtering.md +2 -2
  8. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/mkdocs.yml +1 -3
  9. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/pyproject.toml +1 -1
  10. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/_click.py +4 -1
  11. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/api/convert.py +24 -12
  12. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/cli/convert.py +14 -4
  13. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/models.py +1 -0
  14. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/query.py +3 -3
  15. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/utils.py +129 -43
  16. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/api/test_convert.py +179 -79
  17. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/cli/test_convert.py +26 -0
  18. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/cli/test_edit.py +6 -0
  19. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/test_display.py +1 -1
  20. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/test_query.py +8 -8
  21. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/test_utils.py +131 -27
  22. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/uv.lock +1 -1
  23. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.agent-style/RULES.md +0 -0
  24. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.agent-style/claude-code.md +0 -0
  25. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.github/actions/build-release-notes/action.yml +0 -0
  26. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.github/actions/commit-check/action.yml +0 -0
  27. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.github/actions/commitizen-bump/action.yml +0 -0
  28. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.github/actions/commitizen-bump/commitizen-bump.sh +0 -0
  29. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.github/actions/e2e/action.yml +0 -0
  30. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.github/actions/install/action.yml +0 -0
  31. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.github/actions/lint/action.yml +0 -0
  32. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.github/actions/test/action.yml +0 -0
  33. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.github/workflows/cd.yml +0 -0
  34. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.github/workflows/ci.yml +0 -0
  35. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.github/workflows/publish.yml +0 -0
  36. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.github/workflows/release.yml +0 -0
  37. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.pre-commit-config.yaml +0 -0
  38. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.python-version +0 -0
  39. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/.readthedocs.yaml +0 -0
  40. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/CLAUDE.md +0 -0
  41. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/CONTRIBUTING.md +0 -0
  42. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/LICENSE +0 -0
  43. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/Makefile +0 -0
  44. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/README.md +0 -0
  45. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/codecov.yml +0 -0
  46. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/docker-compose.yml +0 -0
  47. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/docs/api.md +0 -0
  48. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/docs/commands/edit.md +0 -0
  49. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/docs/commands/search.md +0 -0
  50. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/docs/index.md +0 -0
  51. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/docs/stylesheets/extra.css +0 -0
  52. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/__init__.py +0 -0
  53. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/api/__init__.py +0 -0
  54. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/api/_utils.py +0 -0
  55. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/api/edit.py +0 -0
  56. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/api/search.py +0 -0
  57. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/cli/__init__.py +0 -0
  58. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/cli/_utils.py +0 -0
  59. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/cli/edit.py +0 -0
  60. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/cli/main.py +0 -0
  61. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/cli/search.py +0 -0
  62. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/display.py +0 -0
  63. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/rekordbox_edit/logger.py +0 -0
  64. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/renovate.json5 +0 -0
  65. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/ruff.toml +0 -0
  66. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/__init__.py +0 -0
  67. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/api/__init__.py +0 -0
  68. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/api/test_edit.py +0 -0
  69. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/api/test_search.py +0 -0
  70. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/api/test_utils.py +0 -0
  71. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/cli/__init__.py +0 -0
  72. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/cli/test_main.py +0 -0
  73. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/cli/test_search.py +0 -0
  74. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/cli/test_utils.py +0 -0
  75. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/conftest.py +0 -0
  76. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/Dockerfile +0 -0
  77. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/__init__.py +0 -0
  78. {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
  79. {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
  80. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/__snapshots__/test_journey.ambr +0 -0
  81. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/conftest.py +0 -0
  82. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/fixtures/audio/01-flac-44_1k-16b.flac +0 -0
  83. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/fixtures/audio/02-flac-96k-24b.flac +0 -0
  84. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/fixtures/audio/03-alac-44_1k-16b.m4a +0 -0
  85. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/fixtures/audio/04-alac-48k-24b.m4a +0 -0
  86. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/fixtures/audio/05-aiff-44_1k-16b.aiff +0 -0
  87. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/fixtures/audio/06-wav-96k-24b.wav +0 -0
  88. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/fixtures/audio/07-mp3-44_1k-320cbr.mp3 +0 -0
  89. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/fixtures/audio/08-mp3-44_1k-v0vbr.mp3 +0 -0
  90. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/fixtures/audio/09-aac-44_1k-256kbps.m4a +0 -0
  91. {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
  92. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/fixtures/macos/master.6.8.6.db +0 -0
  93. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/fixtures/windows/master.6.8.6.db +0 -0
  94. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/e2e/test_journey.py +0 -0
  95. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/test_logger.py +0 -0
  96. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev71}/tests/test_models.py +0 -0
@@ -48,9 +48,7 @@ Thumbs.db
48
48
  .coverage
49
49
 
50
50
  .claude
51
- docs/superpowers
52
- docs/decisions
53
- research
51
+ ai-docs
54
52
 
55
53
  # MkDocs build output
56
54
  site/
@@ -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/superpowers/specs/`).
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.dev64 (2026-07-31)
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.dev64
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. Your cues, analysis, beatgrids, and all metadata are preserved.
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 format is `FMT` (`mp3`, `flac`, `aiff`, `wav`, `m4a`) |
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](/api/#functions) if you want to build a more complex python script.
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.dev64"
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(["mp3", "flac", "aiff", "wav", "m4a"], case_sensitive=False),
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
- InputFormats,
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 hi-res lossless whitelist.
38
- _INPUT_FILE_TYPES = {get_file_type_for_format(fmt.value) for fmt in InputFormats}
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 _classify_source_fidelity(
59
- source_path,
60
+ def _classify_fidelity(
61
+ audio_info: AudioInfo,
60
62
  ) -> Tuple[Literal["lossless", "lossy"], int]:
61
- """Probe a source file and return the conversion's fidelity along with
62
- its effective sample rate. "lossless" means no audio information is lost:
63
- the source is at the target bit depth and at or below the target sample
64
- rate. An unknown bit depth counts as lossy so originals are kept when in
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 = _classify_source_fidelity(src)
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 in
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(
@@ -130,6 +130,7 @@ SkipReason: TypeAlias = Literal[
130
130
  "already_target_format",
131
131
  "unsupported_source_format",
132
132
  "output_file_exists",
133
+ "codec_mismatch",
133
134
  ]
134
135
 
135
136
 
@@ -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 get_file_type_for_format
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
- file_type_code = get_file_type_for_format(format_name)
145
- condition = DjmdContent.FileType == file_type_code
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
- # File type mappings for Rekordbox database
21
- def get_file_type_name(file_type_code: int | None) -> str | None:
22
- """Map a Rekordbox FileType code to a display name, or None if unmapped.
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
- A total display map, not a guard: Rekordbox stores many codes RBE does not
25
- convert (AAC, ALAC, video). Each caller chooses its own fallback for None.
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
- _get_file_type_name = {
30
- 0: "MP3",
31
- 1: "MP3",
32
- 4: "M4A",
33
- 5: "FLAC",
34
- 11: "WAV",
35
- 12: "AIFF",
36
- }
37
- return _get_file_type_name.get(file_type_code)
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
- _get_file_type_for_format = {"MP3": 1, "M4A": 4, "FLAC": 5, "WAV": 11, "AIFF": 12}
45
- file_type = _get_file_type_for_format.get(format_name.upper())
46
- if file_type is None:
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 file_type
103
+ return codes
49
104
 
50
105
 
51
- def get_extension_for_format(format_name: str):
52
- """Get file extension for format name (case-insensitive)."""
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
- _get_extension_for_format = {
56
- "MP3": ".mp3",
57
- "AIFF": ".aiff",
58
- "FLAC": ".flac",
59
- "WAV": ".wav",
60
- }
61
- extension = _get_extension_for_format.get(format_name.upper())
62
- if extension is None:
63
- raise ValueError(f"Unknown format: {format_name}")
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
- class OutputFormats(Enum):
68
- MP3 = "mp3"
69
- FLAC = "flac"
70
- AIFF = "aiff"
71
- WAV = "wav"
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 InputFormats(Enum):
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
- def get_audio_info(file_path) -> dict[str, int | None]:
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 format-specific
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}")