rekordbox-edit 0.6.0.dev48__tar.gz → 0.7.0.dev7__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 (95) hide show
  1. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/CHANGELOG.md +34 -1
  2. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/PKG-INFO +1 -1
  3. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/docs/commands/convert.md +18 -6
  4. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/pyproject.toml +1 -1
  5. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/rekordbox_edit/_click.py +5 -4
  6. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/rekordbox_edit/api/convert.py +133 -56
  7. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/rekordbox_edit/cli/convert.py +7 -3
  8. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/rekordbox_edit/models.py +19 -5
  9. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/rekordbox_edit/utils.py +0 -1
  10. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/api/test_convert.py +542 -75
  11. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/api/test_edit.py +3 -1
  12. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/cli/test_utils.py +2 -2
  13. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/conftest.py +2 -2
  14. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/test_models.py +1 -1
  15. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/test_utils.py +0 -2
  16. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/uv.lock +22 -22
  17. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/.agent-style/RULES.md +0 -0
  18. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/.agent-style/claude-code.md +0 -0
  19. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/.github/actions/build-release-notes/action.yml +0 -0
  20. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/.github/actions/commit-check/action.yml +0 -0
  21. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/.github/actions/commitizen-bump/action.yml +0 -0
  22. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/.github/actions/commitizen-bump/commitizen-bump.sh +0 -0
  23. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/.github/actions/e2e/action.yml +0 -0
  24. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/.github/actions/install/action.yml +0 -0
  25. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/.github/actions/lint/action.yml +0 -0
  26. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/.github/actions/test/action.yml +0 -0
  27. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/.github/workflows/cd.yml +0 -0
  28. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/.github/workflows/ci.yml +0 -0
  29. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/.github/workflows/publish.yml +0 -0
  30. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/.github/workflows/release.yml +0 -0
  31. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/.gitignore +0 -0
  32. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/.pre-commit-config.yaml +0 -0
  33. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/.python-version +0 -0
  34. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/.readthedocs.yaml +0 -0
  35. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/AGENTS.md +0 -0
  36. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/CLAUDE.md +0 -0
  37. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/CONTRIBUTING.md +0 -0
  38. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/LICENSE +0 -0
  39. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/Makefile +0 -0
  40. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/README.md +0 -0
  41. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/codecov.yml +0 -0
  42. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/docker-compose.yml +0 -0
  43. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/docs/api.md +0 -0
  44. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/docs/commands/edit.md +0 -0
  45. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/docs/commands/search.md +0 -0
  46. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/docs/filtering.md +0 -0
  47. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/docs/index.md +0 -0
  48. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/docs/stylesheets/extra.css +0 -0
  49. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/mkdocs.yml +0 -0
  50. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/rekordbox_edit/__init__.py +0 -0
  51. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/rekordbox_edit/api/__init__.py +0 -0
  52. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/rekordbox_edit/api/_utils.py +0 -0
  53. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/rekordbox_edit/api/edit.py +0 -0
  54. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/rekordbox_edit/api/search.py +0 -0
  55. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/rekordbox_edit/cli/__init__.py +0 -0
  56. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/rekordbox_edit/cli/_utils.py +0 -0
  57. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/rekordbox_edit/cli/edit.py +0 -0
  58. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/rekordbox_edit/cli/main.py +0 -0
  59. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/rekordbox_edit/cli/search.py +0 -0
  60. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/rekordbox_edit/display.py +0 -0
  61. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/rekordbox_edit/logger.py +0 -0
  62. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/rekordbox_edit/query.py +0 -0
  63. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/renovate.json5 +0 -0
  64. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/ruff.toml +0 -0
  65. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/__init__.py +0 -0
  66. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/api/__init__.py +0 -0
  67. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/api/test_search.py +0 -0
  68. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/api/test_utils.py +0 -0
  69. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/cli/__init__.py +0 -0
  70. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/cli/test_convert.py +0 -0
  71. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/cli/test_edit.py +0 -0
  72. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/cli/test_main.py +0 -0
  73. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/cli/test_search.py +0 -0
  74. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/e2e/Dockerfile +0 -0
  75. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/e2e/__init__.py +0 -0
  76. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/e2e/__snapshots__/test_journey/test_search_full_json_snapshot[macos].json +0 -0
  77. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/e2e/__snapshots__/test_journey/test_search_full_json_snapshot[windows].json +0 -0
  78. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/e2e/__snapshots__/test_journey.ambr +0 -0
  79. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/e2e/conftest.py +0 -0
  80. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/e2e/fixtures/audio/01-flac-44_1k-16b.flac +0 -0
  81. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/e2e/fixtures/audio/02-flac-96k-24b.flac +0 -0
  82. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/e2e/fixtures/audio/03-alac-44_1k-16b.m4a +0 -0
  83. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/e2e/fixtures/audio/04-alac-48k-24b.m4a +0 -0
  84. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/e2e/fixtures/audio/05-aiff-44_1k-16b.aiff +0 -0
  85. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/e2e/fixtures/audio/06-wav-96k-24b.wav +0 -0
  86. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/e2e/fixtures/audio/07-mp3-44_1k-320cbr.mp3 +0 -0
  87. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/e2e/fixtures/audio/08-mp3-44_1k-v0vbr.mp3 +0 -0
  88. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/e2e/fixtures/audio/09-aac-44_1k-256kbps.m4a +0 -0
  89. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/e2e/fixtures/audio/10-/303/274/303/261/303/256c/303/266d/303/251-flac-44_1k-16b.flac" +0 -0
  90. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/e2e/fixtures/macos/master.6.8.6.db +0 -0
  91. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/e2e/fixtures/windows/master.6.8.6.db +0 -0
  92. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/e2e/test_journey.py +0 -0
  93. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/test_display.py +0 -0
  94. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/test_logger.py +0 -0
  95. {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev7}/tests/test_query.py +0 -0
@@ -1,4 +1,37 @@
1
- ## v0.6.0.dev48 (2026-06-12)
1
+ ## v0.7.0.dev7 (2026-06-13)
2
+
3
+
4
+ - chore(deps): update linters to v0.0.46 (#98)
5
+ - Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6
+ - chore: ruff formatting
7
+ - fix(convert): encode MP3 at the target bit depth and sample rate
8
+ - Pass ar=44100 and sample_fmt=s16p to libmp3lame instead of inheriting
9
+ the source rate, so MP3 output always matches the 16-bit/44.1 kHz
10
+ conversion target. MP3 ConvertOps report the target instead of None.
11
+ - fix(convert): update MP3 bit depth and sample rate in database
12
+ - Converting to MP3 left the source values (e.g. 24-bit/96 kHz) on the
13
+ record. Rekordbox stores MP3s as 16-bit with the real sample rate, per
14
+ the e2e database fixtures, so write those after conversion.
15
+ - feat(convert): report audio properties on ConvertOp
16
+ - Add source/output file type, bit depth, and sample rate fields so
17
+ dry-run previews and JSON output describe each conversion fully.
18
+ Source fields mirror the database record, the output sample rate
19
+ clamps to the source, and MP3 output leaves bit depth and sample rate
20
+ to the encoder.
21
+ - fix(convert): respect bit depth and sample rate between hi-res formats
22
+ - Hi-res conversions now explicitly target 16-bit/44.1 kHz. The target
23
+ sample rate clamps to the source so nothing is ever up-sampled,
24
+ down-sampled conversions count as lossy so --delete-originals lossless
25
+ keeps those originals, and the database record is updated with the
26
+ converted file's bit depth and sample rate.
27
+ - Fixes #92
28
+ - BREAKING(convert): replace --delete/--keep with --delete-originals enum
29
+ - The tri-state boolean becomes an explicit mode: 'none' never deletes,
30
+ 'all' always deletes, 'lossless' (default) deletes only for hi-res
31
+ output formats. Also drops the unsupported 'alac' --format-out choice,
32
+ which crashed at conversion time.
33
+
34
+ ## v0.6.0 (2026-06-12)
2
35
 
3
36
 
4
37
  - chore(deps): update dependency mkdocstrings to v1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rekordbox-edit
3
- Version: 0.6.0.dev48
3
+ Version: 0.7.0.dev7
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
@@ -4,14 +4,26 @@ Convert audio files between formats and update the Rekordbox database to point a
4
4
 
5
5
  ## Supported Formats
6
6
 
7
- - **Input:** FLAC, AIFF, WAV (lossless only — lossy sources are skipped)
8
- - **Output:** AIFF (default), FLAC, WAV, ALAC, or MP3 (320kbps CBR)
7
+ - **Input:** FLAC, AIFF, WAV (hi-res formats only — lossy-compressed sources are skipped)
8
+ - **Output:** AIFF (default), FLAC, WAV, or MP3 (320kbps CBR)
9
9
 
10
10
  Tracks already in the target format are skipped, as are tracks whose output file already exists (override with `--overwrite`).
11
11
 
12
+ ## Bit Depth and Sample Rate
13
+
14
+ All conversions target **16-bit / 44.1 kHz**, with a few nuances:
15
+
16
+ - A source already at the target bit depth and sample rate converts losslessly.
17
+ - A higher-resolution source (say 24-bit or 96 kHz) is down-sampled to the target, and is considered **lossy** even between lossless formats.
18
+ - Other than conversion to MP3, which always encode to 44.1 kHz, a source with a lower sample rate than the target fidelity keeps its original sample rate.
19
+
12
20
  ## Originals: Delete or Keep
13
21
 
14
- After a successful conversion the original file is **deleted** for lossless output (you can always convert back) and **kept** for MP3 output (the quality loss is one-way). Override either default with `--delete` or `--keep`.
22
+ `--delete-originals` controls what happens to the source file after a successful conversion:
23
+
24
+ - `lossless` (default) — delete the original only when the conversion lost no audio information; keep it when the conversion was lossy (MP3 output or down-sampled hi-res output)
25
+ - `all` — always delete the original
26
+ - `none` — never delete the original
15
27
 
16
28
  ## Examples
17
29
 
@@ -23,10 +35,10 @@ rbe convert --format-out aiff --format flac --dry-run
23
35
  rbe convert --format-out wav --artist "Burial" --yes
24
36
 
25
37
  # Convert to MP3 but delete originals
26
- rbe convert --format-out mp3 --playlist "Export" --yes --delete
38
+ rbe convert --format-out mp3 --playlist "Export" --yes --delete-originals all
27
39
 
28
40
  # Keep originals when converting to AIFF
29
- rbe convert --format-out aiff --format flac --yes --keep
41
+ rbe convert --format-out aiff --format flac --yes --delete-originals none
30
42
 
31
43
  # Get just the IDs of files that would be converted
32
44
  rbe convert --format-out aiff --format flac --print ids --dry-run
@@ -37,7 +49,7 @@ rbe search --artist "Lauryn Hill" --print ids | rbe convert --yes
37
49
 
38
50
  ### Guardrails
39
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.
40
- - Editing while Rekordbox is open risks corrupting your database. By default `convert` warns; in a non-interactive mode (e.g. `--print ids`) it throws an error.
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.
41
53
 
42
54
  ## Reference
43
55
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "rekordbox-edit"
7
- version = "0.6.0.dev48"
7
+ version = "0.7.0.dev7"
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"
@@ -158,9 +158,10 @@ edit_click_options = [
158
158
 
159
159
  convert_click_options = [
160
160
  click.option(
161
- "--delete/--keep",
162
- default=None,
163
- help="Delete or keep original files after conversion (default: delete for lossless, keep for MP3)",
161
+ "--delete-originals",
162
+ type=click.Choice(["none", "lossless", "all"], case_sensitive=False),
163
+ default="lossless",
164
+ help="When to delete original files after conversion: 'lossless' deletes them only when no audio information was lost (down-sampling and MP3 output count as lossy), 'all' always deletes them, 'none' never deletes them (default: lossless)",
164
165
  ),
165
166
  click.option(
166
167
  "--overwrite",
@@ -169,7 +170,7 @@ convert_click_options = [
169
170
  ),
170
171
  click.option(
171
172
  "--format-out",
172
- type=click.Choice(["aiff", "flac", "wav", "alac", "mp3"], case_sensitive=False),
173
+ type=click.Choice(["aiff", "flac", "wav", "mp3"], case_sensitive=False),
173
174
  default="aiff",
174
175
  help="Output format (default: aiff)",
175
176
  ),
@@ -4,7 +4,7 @@ import logging
4
4
  import os
5
5
  import posixpath
6
6
  from pathlib import Path
7
- from typing import Tuple
7
+ from typing import Literal, Tuple
8
8
 
9
9
  import ffmpeg
10
10
  from ffmpeg import Error as FfmpegError
@@ -26,51 +26,85 @@ from rekordbox_edit.utils import (
26
26
  get_audio_info,
27
27
  get_extension_for_format,
28
28
  get_file_type_for_format,
29
+ get_file_type_name,
29
30
  )
30
31
 
31
32
  logger = logging.getLogger(__name__)
32
33
 
34
+ TARGET_BIT_DEPTH = 16
35
+ TARGET_SAMPLE_RATE = 44100
36
+
37
+ _HI_RES_CODECS = {
38
+ "aiff": "pcm_s16be",
39
+ "wav": "pcm_s16le",
40
+ "flac": "flac",
41
+ }
42
+
33
43
 
34
44
  # ── ffmpeg helpers ────────────────────────────────────────────────────────
35
45
 
36
46
 
37
- def _convert_to_lossless(input_path, output_path, output_format):
38
- """Convert lossless file to another lossless format, preserving bit depth."""
39
- from rekordbox_edit.utils import ffmpeg_in_path, get_ffmpeg_directions
47
+ def _effective_sample_rate(source_rate: int | None) -> int:
48
+ """The output sample rate for a conversion: the target, clamped to the
49
+ source rate so a conversion never up-samples."""
50
+ if source_rate and source_rate < TARGET_SAMPLE_RATE:
51
+ return source_rate
52
+ return TARGET_SAMPLE_RATE
40
53
 
41
- if not ffmpeg_in_path():
42
- raise Exception(f"FFmpeg not found in PATH.{get_ffmpeg_directions()}")
43
54
 
44
- audio_info = get_audio_info(input_path)
55
+ def _classify_source_fidelity(
56
+ source_path,
57
+ ) -> Tuple[Literal["lossless", "lossy"], int]:
58
+ """Probe a source file and return the conversion's fidelity along with
59
+ its effective sample rate. "lossless" means no audio information is lost:
60
+ the source is at the target bit depth and at or below the target sample
61
+ rate. An unknown bit depth counts as lossy so originals are kept when in
62
+ doubt.
63
+ """
64
+ audio_info = get_audio_info(source_path)
45
65
  bit_depth = audio_info["bit_depth"]
66
+ sample_rate = audio_info["sample_rate"]
46
67
  logger.debug(
47
- f"Source audio: bit_depth={bit_depth}, sample_rate={audio_info.get('sample_rate')}, channels={audio_info.get('channels')}"
68
+ f"Source audio: bit_depth={bit_depth}, sample_rate={sample_rate}, "
69
+ f"channels={audio_info.get('channels')}"
48
70
  )
71
+ effective_rate = _effective_sample_rate(sample_rate)
72
+ if bit_depth == TARGET_BIT_DEPTH and sample_rate == effective_rate:
73
+ return "lossless", effective_rate
74
+ return "lossy", effective_rate
49
75
 
50
- codec_maps = {
51
- "aiff": {16: "pcm_s16be", 24: "pcm_s24be", 32: "pcm_s32be"},
52
- "wav": {16: "pcm_s16le", 24: "pcm_s24le", 32: "pcm_s32le"},
53
- "flac": None,
54
- }
55
76
 
56
- if output_format.value not in codec_maps:
57
- raise Exception(f"Unsupported lossless format: {output_format}")
77
+ def _convert_to_hi_res(input_path, output_path, output_format, sample_rate):
78
+ """Convert a hi-res file to another hi-res format at the target bit
79
+ depth and the given sample rate, down-sampling higher-resolution
80
+ sources."""
81
+ from rekordbox_edit.utils import ffmpeg_in_path, get_ffmpeg_directions
82
+
83
+ if not ffmpeg_in_path():
84
+ raise Exception(f"FFmpeg not found in PATH.{get_ffmpeg_directions()}")
58
85
 
59
- codec_map = codec_maps[output_format.value]
60
- if codec_map is None:
61
- codec = output_format.value
62
- elif bit_depth in codec_map:
63
- codec = codec_map[bit_depth]
64
- else:
65
- codec = list(codec_map.values())[0]
66
- logger.debug(f"bit_depth={bit_depth} not in codec map, falling back to {codec}")
86
+ codec = _HI_RES_CODECS.get(output_format.value)
87
+ if codec is None:
88
+ raise Exception(f"Unsupported hi-res format: {output_format}")
67
89
 
68
- logger.debug(f"Selected codec: {codec} (bit_depth={bit_depth})")
90
+ output_kwargs = {
91
+ "acodec": codec,
92
+ "ar": sample_rate,
93
+ "map_metadata": 0,
94
+ "write_id3v2": 1,
95
+ }
96
+ # PCM codecs fix the bit depth by name; the flac encoder needs it spelled out.
97
+ if output_format.value == "flac":
98
+ output_kwargs["sample_fmt"] = "s16"
99
+
100
+ logger.debug(
101
+ f"Selected codec: {codec} targeting {TARGET_BIT_DEPTH}-bit/{sample_rate} Hz"
102
+ )
69
103
 
70
104
  try:
71
105
  (
72
106
  ffmpeg.input(input_path)
73
- .output(output_path, acodec=codec, map_metadata=0, write_id3v2=1)
107
+ .output(output_path, **output_kwargs)
74
108
  .overwrite_output()
75
109
  .run(capture_stdout=True, capture_stderr=True)
76
110
  )
@@ -88,7 +122,8 @@ def _convert_to_lossless(input_path, output_path, output_format):
88
122
 
89
123
 
90
124
  def _convert_to_mp3(input_path, mp3_path):
91
- """Convert lossless file to MP3 320kbps CBR."""
125
+ """Convert hi-res file to MP3 320kbps CBR at the target bit depth and
126
+ sample rate."""
92
127
  from rekordbox_edit.utils import ffmpeg_in_path, get_ffmpeg_directions
93
128
 
94
129
  if not ffmpeg_in_path():
@@ -101,6 +136,10 @@ def _convert_to_mp3(input_path, mp3_path):
101
136
  mp3_path,
102
137
  acodec="libmp3lame",
103
138
  audio_bitrate="320k",
139
+ ar=TARGET_SAMPLE_RATE,
140
+ # libmp3lame takes planar input; s16p quantizes to 16-bit
141
+ # before encoding.
142
+ sample_fmt="s16p",
104
143
  map_metadata=0,
105
144
  write_id3v2=1,
106
145
  )
@@ -140,18 +179,18 @@ def _update_database_record(
140
179
  if not file_type:
141
180
  raise Exception(f"Unsupported output format: {output_format}")
142
181
 
143
- if output_format.upper() in ["AIFF", "FLAC", "WAV"]:
182
+ converted_sample_rate = converted_audio_info["sample_rate"]
183
+ if converted_sample_rate:
184
+ content.SampleRate = converted_sample_rate
185
+
186
+ if output_format.upper() == "MP3":
187
+ # Rekordbox records MP3s as 16-bit (see the e2e DB fixtures); probes
188
+ # report no bit depth for them.
189
+ content.BitDepth = 16
190
+ else:
144
191
  converted_bit_depth = converted_audio_info["bit_depth"]
145
- database_bit_depth = getattr(content, "BitDepth", None)
146
- if (
147
- database_bit_depth
148
- and converted_bit_depth
149
- and converted_bit_depth != database_bit_depth
150
- ):
151
- raise Exception(
152
- f"Bit depth mismatch for lossless transcode: "
153
- f"database={database_bit_depth}, file={converted_bit_depth}"
154
- )
192
+ if converted_bit_depth:
193
+ content.BitDepth = converted_bit_depth
155
194
 
156
195
  content.FileNameL = new_filename
157
196
  content.FolderPath = converted_full_path
@@ -227,10 +266,26 @@ def _classify_convert(content, args: ConvertRequest) -> ConvertOp | SkippedTrack
227
266
  f"skip convert id={content.ID} reason=output_file_exists path={output_path}"
228
267
  )
229
268
  return SkippedTrack(id=str(content.ID), reason="output_file_exists")
269
+ # The DB SampleRate stands in for the probe here so dry-run previews match;
270
+ # the convert loop re-probes and reconciles. MP3 always encodes at the target.
271
+ if args.format_out.upper() == "MP3":
272
+ output_sample_rate = TARGET_SAMPLE_RATE
273
+ else:
274
+ output_sample_rate = _effective_sample_rate(content.SampleRate)
230
275
  return ConvertOp(
231
276
  id=str(content.ID),
232
277
  source_path=content.FolderPath or "",
233
278
  output_path=output_path,
279
+ source_file_type=(
280
+ get_file_type_name(content.FileType)
281
+ if content.FileType is not None
282
+ else None
283
+ ),
284
+ source_bit_depth=content.BitDepth,
285
+ source_sample_rate=content.SampleRate,
286
+ output_file_type=args.format_out.upper(),
287
+ output_bit_depth=TARGET_BIT_DEPTH,
288
+ output_sample_rate=output_sample_rate,
234
289
  )
235
290
 
236
291
 
@@ -300,17 +355,10 @@ def convert(
300
355
 
301
356
  assert db.session is not None
302
357
 
303
- should_delete = (
304
- args.delete if args.delete is not None else args.format_out.upper() != "MP3"
305
- )
306
- logger.debug(
307
- f"convert should_delete={should_delete} "
308
- f"(args.delete={args.delete}, format_out={args.format_out})"
309
- )
310
-
311
358
  # content_map enables per-op live FolderPath / FileNameL reads in the loop.
312
359
  content_map = {str(c.ID): c for c in contents}
313
360
  converted_ops: list[ConvertOp] = []
361
+ lossless_op_ids: set[str] = set()
314
362
  try:
315
363
  for i, op in enumerate(ops, 1):
316
364
  content = content_map[op.id]
@@ -321,10 +369,17 @@ def convert(
321
369
  raise RuntimeError(f"Source not found: {src}")
322
370
 
323
371
  if args.format_out.upper() == "MP3":
372
+ output_sample_rate = TARGET_SAMPLE_RATE
324
373
  success = _convert_to_mp3(src, op.output_path)
325
374
  else:
326
- success = _convert_to_lossless(
327
- src, op.output_path, OutputFormats(args.format_out.lower())
375
+ fidelity, output_sample_rate = _classify_source_fidelity(src)
376
+ if fidelity == "lossless":
377
+ lossless_op_ids.add(op.id)
378
+ success = _convert_to_hi_res(
379
+ src,
380
+ op.output_path,
381
+ OutputFormats(args.format_out.lower()),
382
+ output_sample_rate,
328
383
  )
329
384
 
330
385
  if not success:
@@ -340,7 +395,12 @@ def convert(
340
395
  args.format_out.upper(),
341
396
  )
342
397
  converted_ops.append(
343
- ConvertOp(id=op.id, source_path=src, output_path=op.output_path)
398
+ op.model_copy(
399
+ update={
400
+ "source_path": src,
401
+ "output_sample_rate": output_sample_rate,
402
+ }
403
+ )
344
404
  )
345
405
 
346
406
  db.session.commit()
@@ -355,15 +415,32 @@ def convert(
355
415
  _rollback_and_cleanup(db, converted_ops)
356
416
  raise
357
417
 
418
+ if args.delete_originals == "all":
419
+ deletable_ops = converted_ops
420
+ elif args.delete_originals == "lossless":
421
+ deletable_ops = [op for op in converted_ops if op.id in lossless_op_ids]
422
+ else:
423
+ deletable_ops = []
424
+ logger.debug(
425
+ f"convert delete_originals={args.delete_originals}: deleting "
426
+ f"{len(deletable_ops)}/{len(converted_ops)} source file(s)"
427
+ )
428
+
358
429
  deleted = 0
359
- if should_delete:
360
- for op in converted_ops:
361
- try:
362
- os.remove(op.source_path)
363
- deleted += 1
364
- except Exception as e:
365
- logger.warning(f"Failed to delete {op.source_path}: {e}")
366
- logger.debug(f"convert deleted {deleted}/{len(converted_ops)} source file(s)")
430
+ for op in deletable_ops:
431
+ try:
432
+ os.remove(op.source_path)
433
+ deleted += 1
434
+ except Exception as e:
435
+ logger.warning(f"Failed to delete {op.source_path}: {e}")
436
+
437
+ kept = len(converted_ops) - len(deletable_ops)
438
+ if (
439
+ args.delete_originals == "lossless"
440
+ and kept
441
+ and args.format_out.upper() != "MP3"
442
+ ):
443
+ logger.info(f"Kept {kept} original file(s) whose conversion was lossy")
367
444
 
368
445
  converted_ids = [op.id for op in converted_ops]
369
446
  try:
@@ -46,11 +46,15 @@ logger = logging.getLogger(__name__)
46
46
  )
47
47
  @with_database(writes=True)
48
48
  def convert_command(db, **kwargs):
49
- """Convert lossless audio files between formats and update RekordBox database.
49
+ """Convert hi-res audio files between formats and update RekordBox database.
50
50
 
51
- Supports conversion from any lossless format (FLAC, AIFF, WAV) to:
52
- AIFF, FLAC, WAV, ALAC, or MP3. Skips lossy formats and files already in
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
53
  the target format.
54
+
55
+ Lossless conversions target 16-bit/44.1 kHz: higher-resolution sources
56
+ are down-sampled, and sources below the target keep their own sample
57
+ rate rather than being up-sampled.
54
58
  """
55
59
  print_opt = kwargs.pop("print_opt", None)
56
60
  dry_run = kwargs.pop("dry_run", False)
@@ -74,14 +74,18 @@ class EditRequest(FilterArgs):
74
74
  multi: bool = False
75
75
 
76
76
 
77
+ DeleteOriginalsMode: TypeAlias = Literal["none", "lossless", "all"]
78
+
79
+
77
80
  class ConvertRequest(FilterArgs):
78
81
  """Inputs for convert(): the shared track filters plus output format and original-file handling."""
79
82
 
80
83
  format_out: str = "aiff"
81
- delete: bool | None = None
82
- """Whether to delete the original files after conversion: True deletes them,
83
- False keeps them, and None applies the per-format default (delete for
84
- lossless output, keep for MP3)."""
84
+ delete_originals: DeleteOriginalsMode = "lossless"
85
+ """When to delete original files after conversion: "all" always deletes
86
+ them, "none" never deletes them, and "lossless" (the default) deletes them
87
+ only when the conversion loses no audio information. Down-sampling to the
88
+ conversion target counts as lossy, as does MP3 output."""
85
89
  overwrite: bool = False
86
90
 
87
91
 
@@ -143,11 +147,21 @@ class EditOp(BaseModel):
143
147
 
144
148
 
145
149
  class ConvertOp(BaseModel):
146
- """A planned or performed conversion: track ID + source/output paths."""
150
+ """A planned or performed conversion: track ID, source/output paths, and
151
+ the file type, bit depth, and sample rate on each side. Source audio
152
+ fields mirror the database record; output fields reflect the conversion
153
+ target, with the sample rate clamped to the source so a conversion never
154
+ up-samples."""
147
155
 
148
156
  id: str
149
157
  source_path: str
150
158
  output_path: str
159
+ source_file_type: str | None = None
160
+ source_bit_depth: int | None = None
161
+ source_sample_rate: int | None = None
162
+ output_file_type: str | None = None
163
+ output_bit_depth: int | None = None
164
+ output_sample_rate: int | None = None
151
165
 
152
166
 
153
167
  # ── Response envelopes ────────────────────────────────────────────────────
@@ -54,7 +54,6 @@ def get_extension_for_format(format_name: str):
54
54
  "AIFF": ".aiff",
55
55
  "FLAC": ".flac",
56
56
  "WAV": ".wav",
57
- "ALAC": ".m4a",
58
57
  }
59
58
  extension = _get_extension_for_format.get(format_name.upper())
60
59
  if extension is None: