rekordbox-edit 0.6.0.dev48__tar.gz → 0.7.0.dev6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/CHANGELOG.md +32 -1
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/PKG-INFO +1 -1
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/docs/commands/convert.md +18 -6
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/pyproject.toml +1 -1
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/rekordbox_edit/_click.py +5 -4
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/rekordbox_edit/api/convert.py +133 -56
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/rekordbox_edit/cli/convert.py +7 -3
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/rekordbox_edit/models.py +19 -5
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/rekordbox_edit/utils.py +0 -1
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/api/test_convert.py +542 -75
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/api/test_edit.py +3 -1
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/cli/test_utils.py +2 -2
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/conftest.py +2 -2
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/test_models.py +1 -1
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/test_utils.py +0 -2
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/uv.lock +1 -1
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/.agent-style/RULES.md +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/.agent-style/claude-code.md +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/.github/actions/build-release-notes/action.yml +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/.github/actions/commit-check/action.yml +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/.github/actions/commitizen-bump/action.yml +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/.github/actions/commitizen-bump/commitizen-bump.sh +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/.github/actions/e2e/action.yml +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/.github/actions/install/action.yml +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/.github/actions/lint/action.yml +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/.github/actions/test/action.yml +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/.github/workflows/cd.yml +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/.github/workflows/ci.yml +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/.github/workflows/publish.yml +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/.github/workflows/release.yml +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/.gitignore +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/.pre-commit-config.yaml +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/.python-version +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/.readthedocs.yaml +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/AGENTS.md +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/CLAUDE.md +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/CONTRIBUTING.md +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/LICENSE +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/Makefile +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/README.md +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/codecov.yml +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/docker-compose.yml +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/docs/api.md +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/docs/commands/edit.md +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/docs/commands/search.md +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/docs/filtering.md +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/docs/index.md +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/docs/stylesheets/extra.css +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/mkdocs.yml +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/rekordbox_edit/__init__.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/rekordbox_edit/api/__init__.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/rekordbox_edit/api/_utils.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/rekordbox_edit/api/edit.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/rekordbox_edit/api/search.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/rekordbox_edit/cli/__init__.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/rekordbox_edit/cli/_utils.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/rekordbox_edit/cli/edit.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/rekordbox_edit/cli/main.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/rekordbox_edit/cli/search.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/rekordbox_edit/display.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/rekordbox_edit/logger.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/rekordbox_edit/query.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/renovate.json5 +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/ruff.toml +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/__init__.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/api/__init__.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/api/test_search.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/api/test_utils.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/cli/__init__.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/cli/test_convert.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/cli/test_edit.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/cli/test_main.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/cli/test_search.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/e2e/Dockerfile +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/e2e/__init__.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/e2e/__snapshots__/test_journey/test_search_full_json_snapshot[macos].json +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/e2e/__snapshots__/test_journey/test_search_full_json_snapshot[windows].json +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/e2e/__snapshots__/test_journey.ambr +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/e2e/conftest.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/e2e/fixtures/audio/01-flac-44_1k-16b.flac +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/e2e/fixtures/audio/02-flac-96k-24b.flac +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/e2e/fixtures/audio/03-alac-44_1k-16b.m4a +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/e2e/fixtures/audio/04-alac-48k-24b.m4a +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/e2e/fixtures/audio/05-aiff-44_1k-16b.aiff +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/e2e/fixtures/audio/06-wav-96k-24b.wav +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/e2e/fixtures/audio/07-mp3-44_1k-320cbr.mp3 +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/e2e/fixtures/audio/08-mp3-44_1k-v0vbr.mp3 +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/e2e/fixtures/audio/09-aac-44_1k-256kbps.m4a +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/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.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/e2e/fixtures/macos/master.6.8.6.db +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/e2e/fixtures/windows/master.6.8.6.db +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/e2e/test_journey.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/test_display.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/test_logger.py +0 -0
- {rekordbox_edit-0.6.0.dev48 → rekordbox_edit-0.7.0.dev6}/tests/test_query.py +0 -0
|
@@ -1,4 +1,35 @@
|
|
|
1
|
-
## v0.
|
|
1
|
+
## v0.7.0.dev6 (2026-06-12)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
- chore: ruff formatting
|
|
5
|
+
- fix(convert): encode MP3 at the target bit depth and sample rate
|
|
6
|
+
- Pass ar=44100 and sample_fmt=s16p to libmp3lame instead of inheriting
|
|
7
|
+
the source rate, so MP3 output always matches the 16-bit/44.1 kHz
|
|
8
|
+
conversion target. MP3 ConvertOps report the target instead of None.
|
|
9
|
+
- fix(convert): update MP3 bit depth and sample rate in database
|
|
10
|
+
- Converting to MP3 left the source values (e.g. 24-bit/96 kHz) on the
|
|
11
|
+
record. Rekordbox stores MP3s as 16-bit with the real sample rate, per
|
|
12
|
+
the e2e database fixtures, so write those after conversion.
|
|
13
|
+
- feat(convert): report audio properties on ConvertOp
|
|
14
|
+
- Add source/output file type, bit depth, and sample rate fields so
|
|
15
|
+
dry-run previews and JSON output describe each conversion fully.
|
|
16
|
+
Source fields mirror the database record, the output sample rate
|
|
17
|
+
clamps to the source, and MP3 output leaves bit depth and sample rate
|
|
18
|
+
to the encoder.
|
|
19
|
+
- fix(convert): respect bit depth and sample rate between hi-res formats
|
|
20
|
+
- Hi-res conversions now explicitly target 16-bit/44.1 kHz. The target
|
|
21
|
+
sample rate clamps to the source so nothing is ever up-sampled,
|
|
22
|
+
down-sampled conversions count as lossy so --delete-originals lossless
|
|
23
|
+
keeps those originals, and the database record is updated with the
|
|
24
|
+
converted file's bit depth and sample rate.
|
|
25
|
+
- Fixes #92
|
|
26
|
+
- BREAKING(convert): replace --delete/--keep with --delete-originals enum
|
|
27
|
+
- The tri-state boolean becomes an explicit mode: 'none' never deletes,
|
|
28
|
+
'all' always deletes, 'lossless' (default) deletes only for hi-res
|
|
29
|
+
output formats. Also drops the unsupported 'alac' --format-out choice,
|
|
30
|
+
which crashed at conversion time.
|
|
31
|
+
|
|
32
|
+
## v0.6.0 (2026-06-12)
|
|
2
33
|
|
|
3
34
|
|
|
4
35
|
- 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.
|
|
3
|
+
Version: 0.7.0.dev6
|
|
4
4
|
Summary: Tools for managing and modifying a RekordBox library en-masse
|
|
5
5
|
Project-URL: Homepage, https://github.com/jviall/rekordbox-edit
|
|
6
6
|
Project-URL: Repository, https://github.com/jviall/rekordbox-edit
|
|
@@ -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 (
|
|
8
|
-
- **Output:** AIFF (default), FLAC, WAV,
|
|
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
|
-
|
|
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 --
|
|
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.
|
|
7
|
+
version = "0.7.0.dev6"
|
|
8
8
|
description = "Tools for managing and modifying a RekordBox library en-masse"
|
|
9
9
|
authors = [{ name = "James Viall", email= "jamesviall@pm.me"}]
|
|
10
10
|
license = "MIT"
|
|
@@ -158,9 +158,10 @@ edit_click_options = [
|
|
|
158
158
|
|
|
159
159
|
convert_click_options = [
|
|
160
160
|
click.option(
|
|
161
|
-
"--delete
|
|
162
|
-
|
|
163
|
-
|
|
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", "
|
|
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
|
|
38
|
-
"""
|
|
39
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
if
|
|
61
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
49
|
+
"""Convert hi-res audio files between formats and update RekordBox database.
|
|
50
50
|
|
|
51
|
-
Supports conversion from any
|
|
52
|
-
AIFF, FLAC, WAV,
|
|
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
|
-
|
|
82
|
-
"""
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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 ────────────────────────────────────────────────────
|