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.
Files changed (95) hide show
  1. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/CHANGELOG.md +13 -3
  2. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/PKG-INFO +1 -1
  3. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/docs/filtering.md +1 -1
  4. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/pyproject.toml +1 -1
  5. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/_click.py +4 -1
  6. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/api/convert.py +24 -12
  7. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/cli/convert.py +14 -4
  8. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/models.py +1 -0
  9. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/query.py +3 -3
  10. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/utils.py +129 -43
  11. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/api/test_convert.py +179 -79
  12. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/cli/test_convert.py +26 -0
  13. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/cli/test_edit.py +6 -0
  14. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/test_display.py +1 -1
  15. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/test_query.py +8 -8
  16. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/test_utils.py +131 -27
  17. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/uv.lock +1 -1
  18. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.agent-style/RULES.md +0 -0
  19. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.agent-style/claude-code.md +0 -0
  20. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.github/actions/build-release-notes/action.yml +0 -0
  21. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.github/actions/commit-check/action.yml +0 -0
  22. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.github/actions/commitizen-bump/action.yml +0 -0
  23. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.github/actions/commitizen-bump/commitizen-bump.sh +0 -0
  24. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.github/actions/e2e/action.yml +0 -0
  25. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.github/actions/install/action.yml +0 -0
  26. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.github/actions/lint/action.yml +0 -0
  27. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.github/actions/test/action.yml +0 -0
  28. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.github/workflows/cd.yml +0 -0
  29. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.github/workflows/ci.yml +0 -0
  30. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.github/workflows/publish.yml +0 -0
  31. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.github/workflows/release.yml +0 -0
  32. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.gitignore +0 -0
  33. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.pre-commit-config.yaml +0 -0
  34. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.python-version +0 -0
  35. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/.readthedocs.yaml +0 -0
  36. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/AGENTS.md +0 -0
  37. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/CLAUDE.md +0 -0
  38. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/CONTRIBUTING.md +0 -0
  39. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/LICENSE +0 -0
  40. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/Makefile +0 -0
  41. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/README.md +0 -0
  42. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/codecov.yml +0 -0
  43. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/docker-compose.yml +0 -0
  44. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/docs/api.md +0 -0
  45. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/docs/commands/convert.md +0 -0
  46. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/docs/commands/edit.md +0 -0
  47. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/docs/commands/search.md +0 -0
  48. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/docs/index.md +0 -0
  49. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/docs/stylesheets/extra.css +0 -0
  50. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/mkdocs.yml +0 -0
  51. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/__init__.py +0 -0
  52. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/api/__init__.py +0 -0
  53. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/api/_utils.py +0 -0
  54. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/api/edit.py +0 -0
  55. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/api/search.py +0 -0
  56. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/cli/__init__.py +0 -0
  57. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/cli/_utils.py +0 -0
  58. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/cli/edit.py +0 -0
  59. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/cli/main.py +0 -0
  60. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/cli/search.py +0 -0
  61. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/display.py +0 -0
  62. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/rekordbox_edit/logger.py +0 -0
  63. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/renovate.json5 +0 -0
  64. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/ruff.toml +0 -0
  65. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/__init__.py +0 -0
  66. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/api/__init__.py +0 -0
  67. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/api/test_edit.py +0 -0
  68. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/api/test_search.py +0 -0
  69. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/api/test_utils.py +0 -0
  70. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/cli/__init__.py +0 -0
  71. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/cli/test_main.py +0 -0
  72. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/cli/test_search.py +0 -0
  73. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/cli/test_utils.py +0 -0
  74. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/conftest.py +0 -0
  75. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/Dockerfile +0 -0
  76. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/__init__.py +0 -0
  77. {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
  78. {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
  79. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/__snapshots__/test_journey.ambr +0 -0
  80. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/conftest.py +0 -0
  81. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/fixtures/audio/01-flac-44_1k-16b.flac +0 -0
  82. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/fixtures/audio/02-flac-96k-24b.flac +0 -0
  83. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/fixtures/audio/03-alac-44_1k-16b.m4a +0 -0
  84. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/fixtures/audio/04-alac-48k-24b.m4a +0 -0
  85. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/fixtures/audio/05-aiff-44_1k-16b.aiff +0 -0
  86. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/fixtures/audio/06-wav-96k-24b.wav +0 -0
  87. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/fixtures/audio/07-mp3-44_1k-320cbr.mp3 +0 -0
  88. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/fixtures/audio/08-mp3-44_1k-v0vbr.mp3 +0 -0
  89. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/fixtures/audio/09-aac-44_1k-256kbps.m4a +0 -0
  90. {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
  91. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/fixtures/macos/master.6.8.6.db +0 -0
  92. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/fixtures/windows/master.6.8.6.db +0 -0
  93. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/e2e/test_journey.py +0 -0
  94. {rekordbox_edit-0.7.0.dev64 → rekordbox_edit-0.7.0.dev69}/tests/test_logger.py +0 -0
  95. {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.dev64 (2026-07-31)
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.dev64
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 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 |
@@ -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.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(["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}")