rekordbox-edit 0.7.0.dev64__tar.gz → 0.7.0.dev69__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/CHANGELOG.md +13 -3
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/PKG-INFO +1 -1
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/docs/filtering.md +1 -1
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/pyproject.toml +1 -1
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/_click.py +4 -1
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/api/convert.py +24 -12
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/cli/convert.py +14 -4
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/models.py +1 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/query.py +3 -3
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/utils.py +129 -43
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/api/test_convert.py +179 -79
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/cli/test_convert.py +26 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/cli/test_edit.py +6 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/test_display.py +1 -1
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/test_query.py +8 -8
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/test_utils.py +131 -27
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/uv.lock +1 -1
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.agent-style/RULES.md +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.agent-style/claude-code.md +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.github/actions/build-release-notes/action.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.github/actions/commit-check/action.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.github/actions/commitizen-bump/action.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.github/actions/commitizen-bump/commitizen-bump.sh +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.github/actions/e2e/action.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.github/actions/install/action.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.github/actions/lint/action.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.github/actions/test/action.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.github/workflows/cd.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.github/workflows/ci.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.github/workflows/publish.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.github/workflows/release.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.gitignore +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.pre-commit-config.yaml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.python-version +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.readthedocs.yaml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/AGENTS.md +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/CLAUDE.md +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/CONTRIBUTING.md +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/LICENSE +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/Makefile +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/README.md +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/codecov.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/docker-compose.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/docs/api.md +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/docs/commands/convert.md +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/docs/commands/edit.md +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/docs/commands/search.md +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/docs/index.md +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/docs/stylesheets/extra.css +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/mkdocs.yml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/__init__.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/api/__init__.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/api/_utils.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/api/edit.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/api/search.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/cli/__init__.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/cli/_utils.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/cli/edit.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/cli/main.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/cli/search.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/display.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/logger.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/renovate.json5 +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/ruff.toml +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/__init__.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/api/__init__.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/api/test_edit.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/api/test_search.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/api/test_utils.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/cli/__init__.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/cli/test_main.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/cli/test_search.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/cli/test_utils.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/conftest.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/Dockerfile +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/__init__.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/__snapshots__/test_journey/test_search_full_json_snapshot[macos].json +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/__snapshots__/test_journey/test_search_full_json_snapshot[windows].json +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/__snapshots__/test_journey.ambr +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/conftest.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/fixtures/audio/01-flac-44_1k-16b.flac +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/fixtures/audio/02-flac-96k-24b.flac +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/fixtures/audio/03-alac-44_1k-16b.m4a +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/fixtures/audio/04-alac-48k-24b.m4a +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/fixtures/audio/05-aiff-44_1k-16b.aiff +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/fixtures/audio/06-wav-96k-24b.wav +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/fixtures/audio/07-mp3-44_1k-320cbr.mp3 +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/fixtures/audio/08-mp3-44_1k-v0vbr.mp3 +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/fixtures/audio/09-aac-44_1k-256kbps.m4a +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/fixtures/audio/10-/303/274/303/261/303/256c/303/266d/303/251-flac-44_1k-16b.flac" +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/fixtures/macos/master.6.8.6.db +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/fixtures/windows/master.6.8.6.db +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/test_journey.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/test_logger.py +0 -0
- {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/test_models.py +0 -0
|
@@ -1,6 +1,16 @@
|
|
|
1
|
-
## v0.7.0.
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
## v0.7.0.dev69 (2026-07-31)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
- feat(convert): verify source codec against FileType before converting
|
|
5
|
+
- test(cli): mock rekordbox pid check in edit CLI tests
|
|
6
|
+
- feat: add probe/file-type matcher and codec_mismatch skip reason
|
|
7
|
+
- feat(utils): probe codec and container in get_audio_info
|
|
8
|
+
- feat: adds display and filtering support for all rekordbox file types
|
|
9
|
+
- - creates a FileTypeInfo registry to map all the different dimensions of
|
|
10
|
+
FileType under one database code
|
|
11
|
+
- adds AAC and ALAC .m4a FileTypes (4 and 6)
|
|
12
|
+
- adds .mp4 FileType (3)
|
|
13
|
+
- adds video FileType (16)
|
|
4
14
|
- chore(deps): update dependency ty to v0.0.64 (#152)
|
|
5
15
|
- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
|
|
6
16
|
- chore(deps): update pre-commit hook astral-sh/ruff-pre-commit to v0.16.0 (#149)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rekordbox-edit
|
|
3
|
-
Version: 0.7.0.
|
|
3
|
+
Version: 0.7.0.dev69
|
|
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
|
|
@@ -17,7 +17,7 @@ Repeating a filter, or combining different filters, matches tracks that satisfy
|
|
|
17
17
|
| `--exact-album TEXT` | album name is exactly `TEXT` |
|
|
18
18
|
| `--playlist TEXT` | playlist name contains `TEXT` |
|
|
19
19
|
| `--exact-playlist TEXT` | playlist name is exactly `TEXT` |
|
|
20
|
-
| `--format FMT` | file
|
|
20
|
+
| `--format FMT` | Rekordbox file type is `FMT` (`mp3`, `mp4`, `aac`, `flac`, `alac`, `wav`, `aiff`, `video`, `invalid`) |
|
|
21
21
|
| `--path TEXT` | file path contains `TEXT` (matched against the folder path, filename, or both) |
|
|
22
22
|
| `--exact-path TEXT` | file path is exactly `TEXT` (resolved to an absolute path before matching) |
|
|
23
23
|
| `--first N` | return only the first N results |
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "rekordbox-edit"
|
|
7
|
-
version = "0.7.0.
|
|
7
|
+
version = "0.7.0.dev69"
|
|
8
8
|
description = "Tools for managing and modifying a RekordBox library en-masse"
|
|
9
9
|
authors = [{ name = "James Viall", email= "jamesviall@pm.me"}]
|
|
10
10
|
license = "MIT"
|
|
@@ -91,7 +91,10 @@ global_click_filters = [
|
|
|
91
91
|
),
|
|
92
92
|
click.option(
|
|
93
93
|
"--format",
|
|
94
|
-
type=click.Choice(
|
|
94
|
+
type=click.Choice(
|
|
95
|
+
["mp3", "mp4", "aac", "flac", "alac", "wav", "aiff", "video", "invalid"],
|
|
96
|
+
case_sensitive=False,
|
|
97
|
+
),
|
|
95
98
|
multiple=True,
|
|
96
99
|
help="Find tracks of this format",
|
|
97
100
|
),
|
|
@@ -21,12 +21,14 @@ from rekordbox_edit.models import (
|
|
|
21
21
|
)
|
|
22
22
|
from rekordbox_edit.query import get_filtered_content
|
|
23
23
|
from rekordbox_edit.utils import (
|
|
24
|
-
|
|
24
|
+
FILE_TYPES,
|
|
25
|
+
AudioInfo,
|
|
25
26
|
OutputFormats,
|
|
26
27
|
get_audio_info,
|
|
27
28
|
get_extension_for_format,
|
|
28
29
|
get_file_type_for_format,
|
|
29
30
|
get_file_type_name,
|
|
31
|
+
probe_matches_file_type,
|
|
30
32
|
)
|
|
31
33
|
|
|
32
34
|
logger = logging.getLogger(__name__)
|
|
@@ -34,8 +36,8 @@ logger = logging.getLogger(__name__)
|
|
|
34
36
|
TARGET_BIT_DEPTH = 16
|
|
35
37
|
TARGET_SAMPLE_RATE = 44100
|
|
36
38
|
|
|
37
|
-
# Rekordbox FileType codes RBE converts from: the
|
|
38
|
-
_INPUT_FILE_TYPES = {
|
|
39
|
+
# Rekordbox FileType codes RBE converts from: the lossless whitelist.
|
|
40
|
+
_INPUT_FILE_TYPES = {code for code, info in FILE_TYPES.items() if info.convertable}
|
|
39
41
|
|
|
40
42
|
_HI_RES_CODECS = {
|
|
41
43
|
"aiff": "pcm_s16be",
|
|
@@ -55,16 +57,14 @@ def _effective_sample_rate(source_rate: int | None) -> int:
|
|
|
55
57
|
return TARGET_SAMPLE_RATE
|
|
56
58
|
|
|
57
59
|
|
|
58
|
-
def
|
|
59
|
-
|
|
60
|
+
def _classify_fidelity(
|
|
61
|
+
audio_info: AudioInfo,
|
|
60
62
|
) -> Tuple[Literal["lossless", "lossy"], int]:
|
|
61
|
-
"""
|
|
62
|
-
|
|
63
|
-
the
|
|
64
|
-
|
|
65
|
-
doubt.
|
|
63
|
+
"""The conversion's fidelity and effective sample rate for a probed
|
|
64
|
+
source. "lossless" means no audio information is lost: the source is at
|
|
65
|
+
the target bit depth and at or below the target sample rate. An unknown
|
|
66
|
+
bit depth counts as lossy so originals are kept when in doubt.
|
|
66
67
|
"""
|
|
67
|
-
audio_info = get_audio_info(source_path)
|
|
68
68
|
bit_depth = audio_info["bit_depth"]
|
|
69
69
|
sample_rate = audio_info["sample_rate"]
|
|
70
70
|
logger.debug(
|
|
@@ -374,13 +374,25 @@ def convert(
|
|
|
374
374
|
if not os.path.exists(src):
|
|
375
375
|
raise RuntimeError(f"Source not found: {src}")
|
|
376
376
|
|
|
377
|
+
audio_info = get_audio_info(src)
|
|
378
|
+
if not probe_matches_file_type(
|
|
379
|
+
content.FileType, audio_info["codec"], audio_info["container"]
|
|
380
|
+
):
|
|
381
|
+
logger.warning(
|
|
382
|
+
f"Skipping {content.FileNameL}: probed codec "
|
|
383
|
+
f"{audio_info['codec']!r} does not match its Rekordbox "
|
|
384
|
+
f"file type {get_file_type_name(content.FileType)!r}"
|
|
385
|
+
)
|
|
386
|
+
skipped.append(SkippedTrack(id=op.id, reason="codec_mismatch"))
|
|
387
|
+
continue
|
|
388
|
+
|
|
377
389
|
if args.format_out.upper() == "MP3":
|
|
378
390
|
output_sample_rate = TARGET_SAMPLE_RATE
|
|
379
391
|
success = _run_ffmpeg(
|
|
380
392
|
src, op.output_path, _mp3_output_kwargs(output_sample_rate), "mp3"
|
|
381
393
|
)
|
|
382
394
|
else:
|
|
383
|
-
fidelity, output_sample_rate =
|
|
395
|
+
fidelity, output_sample_rate = _classify_fidelity(audio_info)
|
|
384
396
|
if fidelity == "lossless":
|
|
385
397
|
lossless_op_ids.add(op.id)
|
|
386
398
|
output_format = OutputFormats(args.format_out.lower())
|
|
@@ -48,9 +48,10 @@ logger = logging.getLogger(__name__)
|
|
|
48
48
|
def convert_command(db, **kwargs):
|
|
49
49
|
"""Convert hi-res audio files between formats and update RekordBox database.
|
|
50
50
|
|
|
51
|
-
Supports conversion from any hi-res format (FLAC, AIFF, WAV) to:
|
|
52
|
-
AIFF, FLAC, WAV, or MP3. Skips lossy formats and files already
|
|
53
|
-
the target format.
|
|
51
|
+
Supports conversion from any hi-res format (FLAC, ALAC, AIFF, WAV) to:
|
|
52
|
+
AIFF, FLAC, WAV, or MP3. Skips lossy formats, video, and files already
|
|
53
|
+
in the target format. Each source's codec is verified against its
|
|
54
|
+
database file type before converting; mismatches are skipped.
|
|
54
55
|
|
|
55
56
|
Lossless conversions target 16-bit/44.1 kHz: higher-resolution sources
|
|
56
57
|
are down-sampled, and sources below the target keep their own sample
|
|
@@ -122,6 +123,9 @@ def convert_command(db, **kwargs):
|
|
|
122
123
|
return
|
|
123
124
|
response = convert(db, args)
|
|
124
125
|
|
|
126
|
+
# The preview cannot surface codec_mismatch (dry runs never probe), so
|
|
127
|
+
# report skips found only during the live run.
|
|
128
|
+
_report_skips([s for s in response.result.skipped if s.reason == "codec_mismatch"])
|
|
125
129
|
_print_convert_result(response, print_opt, scripting_mode, dry_run=False)
|
|
126
130
|
|
|
127
131
|
|
|
@@ -129,17 +133,23 @@ def _report_skips(skipped) -> None:
|
|
|
129
133
|
already_target = sum(1 for s in skipped if s.reason == "already_target_format")
|
|
130
134
|
unsupported = sum(1 for s in skipped if s.reason == "unsupported_source_format")
|
|
131
135
|
conflicts = sum(1 for s in skipped if s.reason == "output_file_exists")
|
|
136
|
+
mismatches = sum(1 for s in skipped if s.reason == "codec_mismatch")
|
|
132
137
|
if already_target:
|
|
133
138
|
logger.warning(f"Skipping {already_target} file(s): already in target format")
|
|
134
139
|
if unsupported:
|
|
135
140
|
logger.warning(
|
|
136
141
|
f"Skipping {unsupported} file(s): unsupported source format "
|
|
137
|
-
"(only FLAC, AIFF, WAV are converted)"
|
|
142
|
+
"(only FLAC, ALAC, AIFF, WAV are converted)"
|
|
138
143
|
)
|
|
139
144
|
if conflicts:
|
|
140
145
|
logger.warning(
|
|
141
146
|
f"Skipping {conflicts} file(s): output exists (use --overwrite to convert)"
|
|
142
147
|
)
|
|
148
|
+
if mismatches:
|
|
149
|
+
logger.warning(
|
|
150
|
+
f"Skipping {mismatches} file(s): file content does not match its "
|
|
151
|
+
"Rekordbox file type"
|
|
152
|
+
)
|
|
143
153
|
|
|
144
154
|
|
|
145
155
|
def _print_convert_result(
|
|
@@ -132,7 +132,7 @@ class CollectionQuery:
|
|
|
132
132
|
|
|
133
133
|
def by_format(self, format_name: str) -> "CollectionQuery":
|
|
134
134
|
"""Filter by file format."""
|
|
135
|
-
from rekordbox_edit.utils import
|
|
135
|
+
from rekordbox_edit.utils import get_file_type_codes_for_format
|
|
136
136
|
|
|
137
137
|
if not format_name:
|
|
138
138
|
logger.warning("Empty format filter has no effect")
|
|
@@ -141,8 +141,8 @@ class CollectionQuery:
|
|
|
141
141
|
new_inst = self._copy()
|
|
142
142
|
|
|
143
143
|
try:
|
|
144
|
-
|
|
145
|
-
condition = DjmdContent.FileType
|
|
144
|
+
file_type_codes = get_file_type_codes_for_format(format_name)
|
|
145
|
+
condition = DjmdContent.FileType.in_(file_type_codes)
|
|
146
146
|
new_inst._conditions.append(condition)
|
|
147
147
|
except ValueError:
|
|
148
148
|
logger.warning(f"Invalid format: {format_name}")
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
import logging
|
|
4
4
|
import platform
|
|
5
5
|
import shutil
|
|
6
|
+
from dataclasses import dataclass
|
|
6
7
|
from enum import Enum
|
|
8
|
+
from typing import TypedDict
|
|
7
9
|
|
|
8
10
|
import click
|
|
9
11
|
import ffmpeg
|
|
@@ -17,61 +19,131 @@ class UserQuit(Exception):
|
|
|
17
19
|
pass
|
|
18
20
|
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"""
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class FileTypeInfo:
|
|
24
|
+
"""One Rekordbox FileType code: how RBE names, filters, probes, and
|
|
25
|
+
converts it. ``codecs`` are ffprobe codec_name prefixes and
|
|
26
|
+
``containers`` are format_name substrings; a probe must satisfy both
|
|
27
|
+
(WAV and AIFF share PCM codecs, so their containers disambiguate)."""
|
|
28
|
+
|
|
29
|
+
code: int
|
|
30
|
+
name: str
|
|
31
|
+
token: str
|
|
32
|
+
extension: str | None = None
|
|
33
|
+
codecs: tuple[str, ...] = ()
|
|
34
|
+
containers: tuple[str, ...] = ()
|
|
35
|
+
convertable: bool = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
FILE_TYPES: dict[int, FileTypeInfo] = {
|
|
39
|
+
# Corrupt or empty content, independent of container.
|
|
40
|
+
0: FileTypeInfo(code=0, name="INVALID", token="invalid"),
|
|
41
|
+
1: FileTypeInfo(code=1, name="MP3", token="mp3", extension=".mp3", codecs=("mp3",)),
|
|
42
|
+
# 3 is the .mp4 container regardless of content: audio-only AAC/ALAC
|
|
43
|
+
# .mp4 files land here alongside video .mp4, so RBE never converts it.
|
|
44
|
+
3: FileTypeInfo(code=3, name="MP4", token="mp4"),
|
|
45
|
+
4: FileTypeInfo(code=4, name="AAC", token="aac", codecs=("aac",)),
|
|
46
|
+
5: FileTypeInfo(
|
|
47
|
+
code=5,
|
|
48
|
+
name="FLAC",
|
|
49
|
+
token="flac",
|
|
50
|
+
extension=".flac",
|
|
51
|
+
codecs=("flac",),
|
|
52
|
+
convertable=True,
|
|
53
|
+
),
|
|
54
|
+
6: FileTypeInfo(
|
|
55
|
+
code=6,
|
|
56
|
+
name="ALAC",
|
|
57
|
+
token="alac",
|
|
58
|
+
codecs=("alac",),
|
|
59
|
+
convertable=True,
|
|
60
|
+
),
|
|
61
|
+
11: FileTypeInfo(
|
|
62
|
+
code=11,
|
|
63
|
+
name="WAV",
|
|
64
|
+
token="wav",
|
|
65
|
+
extension=".wav",
|
|
66
|
+
codecs=("pcm_",),
|
|
67
|
+
containers=("wav",),
|
|
68
|
+
convertable=True,
|
|
69
|
+
),
|
|
70
|
+
12: FileTypeInfo(
|
|
71
|
+
code=12,
|
|
72
|
+
name="AIFF",
|
|
73
|
+
token="aiff",
|
|
74
|
+
extension=".aiff",
|
|
75
|
+
codecs=("pcm_",),
|
|
76
|
+
containers=("aiff",),
|
|
77
|
+
convertable=True,
|
|
78
|
+
),
|
|
79
|
+
# Catch-all for non-mp4 video containers (avi, m4v, mov, mpg).
|
|
80
|
+
16: FileTypeInfo(code=16, name="VIDEO", token="video"),
|
|
81
|
+
}
|
|
23
82
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"""
|
|
83
|
+
|
|
84
|
+
def get_file_type_name(file_type_code: int | None) -> str | None:
|
|
85
|
+
"""Map a Rekordbox FileType code to a display name, or None if unmapped."""
|
|
27
86
|
if file_type_code is None:
|
|
28
87
|
return None
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def get_file_type_for_format(format_name: str):
|
|
41
|
-
"""Get file type code for format name (case-insensitive)."""
|
|
88
|
+
info = FILE_TYPES.get(file_type_code)
|
|
89
|
+
return info.name if info else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_file_type_codes_for_format(format_name: str) -> set[int]:
|
|
93
|
+
"""All FileType codes a format token matches (case-insensitive).
|
|
94
|
+
|
|
95
|
+
Tokens mirror FileType values one to one.
|
|
96
|
+
"""
|
|
42
97
|
if not format_name:
|
|
43
98
|
raise ValueError("Format name cannot be empty or None")
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if
|
|
99
|
+
token = format_name.lower()
|
|
100
|
+
codes = {code for code, info in FILE_TYPES.items() if token == info.token}
|
|
101
|
+
if not codes:
|
|
47
102
|
raise ValueError(f"Unknown format: {format_name}")
|
|
48
|
-
return
|
|
103
|
+
return codes
|
|
49
104
|
|
|
50
105
|
|
|
51
|
-
def
|
|
52
|
-
"""
|
|
106
|
+
def get_file_type_for_format(format_name: str) -> int:
|
|
107
|
+
"""The FileType code Rekordbox records for files RBE writes in this
|
|
108
|
+
output format (case-insensitive). Raises for non-output formats."""
|
|
53
109
|
if not format_name:
|
|
54
110
|
raise ValueError("Format name cannot be empty or None")
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
111
|
+
token = format_name.lower()
|
|
112
|
+
for code, info in FILE_TYPES.items():
|
|
113
|
+
if info.extension and token == info.token:
|
|
114
|
+
return code
|
|
115
|
+
raise ValueError(f"Unknown format: {format_name}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_extension_for_format(format_name: str) -> str:
|
|
119
|
+
"""Get file extension for an output format name (case-insensitive)."""
|
|
120
|
+
code = get_file_type_for_format(format_name)
|
|
121
|
+
extension = FILE_TYPES[code].extension
|
|
122
|
+
assert extension is not None # get_file_type_for_format only returns such codes
|
|
64
123
|
return extension
|
|
65
124
|
|
|
66
125
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
126
|
+
def probe_matches_file_type(
|
|
127
|
+
file_type_code: int | None, codec: str | None, container: str | None
|
|
128
|
+
) -> bool:
|
|
129
|
+
"""Whether a probed codec/container is consistent with a Rekordbox
|
|
130
|
+
FileType code. Unknown codes never match, so callers treat them as
|
|
131
|
+
mismatches instead of converting blind."""
|
|
132
|
+
if file_type_code is None:
|
|
133
|
+
return False
|
|
134
|
+
info = FILE_TYPES.get(file_type_code)
|
|
135
|
+
if info is None:
|
|
136
|
+
return False
|
|
137
|
+
if info.codecs and not (codec or "").startswith(info.codecs):
|
|
138
|
+
return False
|
|
139
|
+
if info.containers:
|
|
140
|
+
if not container or not any(c in container for c in info.containers):
|
|
141
|
+
return False
|
|
142
|
+
return True
|
|
72
143
|
|
|
73
144
|
|
|
74
|
-
class
|
|
145
|
+
class OutputFormats(Enum):
|
|
146
|
+
MP3 = "mp3"
|
|
75
147
|
FLAC = "flac"
|
|
76
148
|
AIFF = "aiff"
|
|
77
149
|
WAV = "wav"
|
|
@@ -99,12 +171,24 @@ or https://ffmpeg.org/download.html
|
|
|
99
171
|
"""
|
|
100
172
|
|
|
101
173
|
|
|
102
|
-
|
|
174
|
+
class AudioInfo(TypedDict):
|
|
175
|
+
"""Fields extracted from an ffmpeg probe of one audio file."""
|
|
176
|
+
|
|
177
|
+
bit_depth: int | None
|
|
178
|
+
sample_rate: int
|
|
179
|
+
channels: int
|
|
180
|
+
bitrate: int | None
|
|
181
|
+
codec: str | None
|
|
182
|
+
container: str | None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def get_audio_info(file_path) -> AudioInfo:
|
|
103
186
|
"""Get audio information from file using ffmpeg probe.
|
|
104
187
|
|
|
105
188
|
Returns None for any field that cannot be determined from the probe data.
|
|
106
|
-
Callers are responsible for handling None values and applying
|
|
107
|
-
assumptions (e.g. MP3 has no true bit depth).
|
|
189
|
+
Callers are responsible for handling None values and applying
|
|
190
|
+
format-specific assumptions (e.g. MP3 has no true bit depth). ``codec``
|
|
191
|
+
is the stream's codec_name and ``container`` the probe's format_name.
|
|
108
192
|
"""
|
|
109
193
|
try:
|
|
110
194
|
# Check if ffmpeg is available first
|
|
@@ -163,6 +247,8 @@ def get_audio_info(file_path) -> dict[str, int | None]:
|
|
|
163
247
|
"sample_rate": int(audio_stream.get("sample_rate", 44100)),
|
|
164
248
|
"channels": int(audio_stream.get("channels", 2)),
|
|
165
249
|
"bitrate": bitrate,
|
|
250
|
+
"codec": audio_stream.get("codec_name"),
|
|
251
|
+
"container": probe.get("format", {}).get("format_name"),
|
|
166
252
|
}
|
|
167
253
|
except Exception as e:
|
|
168
254
|
logger.error(f"Failed to get audio info for {file_path}: {e}")
|