rekordbox-edit 0.7.0.dev44__tar.gz → 0.7.0.dev49__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.dev44 → rekordbox_edit-0.7.0.dev49}/.gitignore +2 -0
  2. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/CHANGELOG.md +12 -3
  3. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/PKG-INFO +1 -1
  4. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/pyproject.toml +1 -1
  5. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/rekordbox_edit/api/convert.py +83 -65
  6. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/rekordbox_edit/cli/convert.py +6 -0
  7. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/rekordbox_edit/display.py +3 -3
  8. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/rekordbox_edit/models.py +4 -1
  9. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/rekordbox_edit/utils.py +9 -6
  10. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/ruff.toml +2 -0
  11. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/api/test_convert.py +307 -163
  12. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/cli/test_convert.py +16 -0
  13. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/test_utils.py +4 -8
  14. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/uv.lock +1 -1
  15. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/.agent-style/RULES.md +0 -0
  16. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/.agent-style/claude-code.md +0 -0
  17. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/.github/actions/build-release-notes/action.yml +0 -0
  18. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/.github/actions/commit-check/action.yml +0 -0
  19. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/.github/actions/commitizen-bump/action.yml +0 -0
  20. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/.github/actions/commitizen-bump/commitizen-bump.sh +0 -0
  21. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/.github/actions/e2e/action.yml +0 -0
  22. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/.github/actions/install/action.yml +0 -0
  23. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/.github/actions/lint/action.yml +0 -0
  24. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/.github/actions/test/action.yml +0 -0
  25. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/.github/workflows/cd.yml +0 -0
  26. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/.github/workflows/ci.yml +0 -0
  27. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/.github/workflows/publish.yml +0 -0
  28. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/.github/workflows/release.yml +0 -0
  29. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/.pre-commit-config.yaml +0 -0
  30. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/.python-version +0 -0
  31. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/.readthedocs.yaml +0 -0
  32. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/AGENTS.md +0 -0
  33. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/CLAUDE.md +0 -0
  34. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/CONTRIBUTING.md +0 -0
  35. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/LICENSE +0 -0
  36. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/Makefile +0 -0
  37. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/README.md +0 -0
  38. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/codecov.yml +0 -0
  39. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/docker-compose.yml +0 -0
  40. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/docs/api.md +0 -0
  41. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/docs/commands/convert.md +0 -0
  42. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/docs/commands/edit.md +0 -0
  43. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/docs/commands/search.md +0 -0
  44. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/docs/filtering.md +0 -0
  45. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/docs/index.md +0 -0
  46. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/docs/stylesheets/extra.css +0 -0
  47. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/mkdocs.yml +0 -0
  48. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/rekordbox_edit/__init__.py +0 -0
  49. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/rekordbox_edit/_click.py +0 -0
  50. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/rekordbox_edit/api/__init__.py +0 -0
  51. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/rekordbox_edit/api/_utils.py +0 -0
  52. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/rekordbox_edit/api/edit.py +0 -0
  53. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/rekordbox_edit/api/search.py +0 -0
  54. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/rekordbox_edit/cli/__init__.py +0 -0
  55. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/rekordbox_edit/cli/_utils.py +0 -0
  56. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/rekordbox_edit/cli/edit.py +0 -0
  57. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/rekordbox_edit/cli/main.py +0 -0
  58. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/rekordbox_edit/cli/search.py +0 -0
  59. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/rekordbox_edit/logger.py +0 -0
  60. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/rekordbox_edit/query.py +0 -0
  61. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/renovate.json5 +0 -0
  62. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/__init__.py +0 -0
  63. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/api/__init__.py +0 -0
  64. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/api/test_edit.py +0 -0
  65. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/api/test_search.py +0 -0
  66. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/api/test_utils.py +0 -0
  67. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/cli/__init__.py +0 -0
  68. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/cli/test_edit.py +0 -0
  69. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/cli/test_main.py +0 -0
  70. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/cli/test_search.py +0 -0
  71. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/cli/test_utils.py +0 -0
  72. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/conftest.py +0 -0
  73. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/e2e/Dockerfile +0 -0
  74. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/e2e/__init__.py +0 -0
  75. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/e2e/__snapshots__/test_journey/test_search_full_json_snapshot[macos].json +0 -0
  76. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/e2e/__snapshots__/test_journey/test_search_full_json_snapshot[windows].json +0 -0
  77. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/e2e/__snapshots__/test_journey.ambr +0 -0
  78. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/e2e/conftest.py +0 -0
  79. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/e2e/fixtures/audio/01-flac-44_1k-16b.flac +0 -0
  80. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/e2e/fixtures/audio/02-flac-96k-24b.flac +0 -0
  81. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/e2e/fixtures/audio/03-alac-44_1k-16b.m4a +0 -0
  82. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/e2e/fixtures/audio/04-alac-48k-24b.m4a +0 -0
  83. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/e2e/fixtures/audio/05-aiff-44_1k-16b.aiff +0 -0
  84. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/e2e/fixtures/audio/06-wav-96k-24b.wav +0 -0
  85. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/e2e/fixtures/audio/07-mp3-44_1k-320cbr.mp3 +0 -0
  86. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/e2e/fixtures/audio/08-mp3-44_1k-v0vbr.mp3 +0 -0
  87. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/e2e/fixtures/audio/09-aac-44_1k-256kbps.m4a +0 -0
  88. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/e2e/fixtures/audio/10-/303/274/303/261/303/256c/303/266d/303/251-flac-44_1k-16b.flac" +0 -0
  89. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/e2e/fixtures/macos/master.6.8.6.db +0 -0
  90. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/e2e/fixtures/windows/master.6.8.6.db +0 -0
  91. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/e2e/test_journey.py +0 -0
  92. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/test_display.py +0 -0
  93. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/test_logger.py +0 -0
  94. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/test_models.py +0 -0
  95. {rekordbox_edit-0.7.0.dev44 → rekordbox_edit-0.7.0.dev49}/tests/test_query.py +0 -0
@@ -49,6 +49,8 @@ Thumbs.db
49
49
 
50
50
  .claude
51
51
  docs/superpowers
52
+ docs/decisions
53
+ research
52
54
 
53
55
  # MkDocs build output
54
56
  site/
@@ -1,6 +1,15 @@
1
- ## v0.7.0.dev44 (2026-07-16)
2
-
3
-
1
+ ## v0.7.0.dev49 (2026-07-17)
2
+
3
+
4
+ - chore: ignore research/decision docs
5
+ - fix(convert): update FileSize, OrgFilePath cols; PPTH ANLZ tag
6
+ - refactor(convert): merge shared logic of ffmpeg helpers
7
+ - Replace _convert_to_hi_res and _convert_to_mp3 with a single _run_ffmpeg
8
+ runner and two pure output-kwargs builders.
9
+ - fix(convert): skip unsupported source formats via input whitelist
10
+ - Previously we were skipping via a blacklist, which allowed
11
+ unknown/unsupported formats to create undefined behavior.
12
+ - refactor(convert): change get_file_type_name a simple display map
4
13
  - chore(deps): update dependency ty to v0.0.59 (#134)
5
14
  - Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6
15
  - chore(deps): update dependency syrupy to v5.5.3 (#133)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rekordbox-edit
3
- Version: 0.7.0.dev44
3
+ Version: 0.7.0.dev49
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,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "rekordbox-edit"
7
- version = "0.7.0.dev44"
7
+ version = "0.7.0.dev49"
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"
@@ -2,7 +2,6 @@
2
2
 
3
3
  import logging
4
4
  import os
5
- import posixpath
6
5
  from pathlib import Path
7
6
  from typing import Literal, Tuple
8
7
 
@@ -22,6 +21,7 @@ from rekordbox_edit.models import (
22
21
  )
23
22
  from rekordbox_edit.query import get_filtered_content
24
23
  from rekordbox_edit.utils import (
24
+ InputFormats,
25
25
  OutputFormats,
26
26
  get_audio_info,
27
27
  get_extension_for_format,
@@ -34,6 +34,9 @@ logger = logging.getLogger(__name__)
34
34
  TARGET_BIT_DEPTH = 16
35
35
  TARGET_SAMPLE_RATE = 44100
36
36
 
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
+
37
40
  _HI_RES_CODECS = {
38
41
  "aiff": "pcm_s16be",
39
42
  "wav": "pcm_s16le",
@@ -74,20 +77,13 @@ def _classify_source_fidelity(
74
77
  return "lossy", effective_rate
75
78
 
76
79
 
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()}")
85
-
80
+ def _hi_res_output_kwargs(output_format, sample_rate) -> dict:
81
+ """ffmpeg output kwargs for a hi-res conversion at the target bit depth and
82
+ the given sample rate."""
86
83
  codec = _HI_RES_CODECS.get(output_format.value)
87
84
  if codec is None:
88
85
  raise Exception(f"Unsupported hi-res format: {output_format}")
89
-
90
- output_kwargs = {
86
+ kwargs = {
91
87
  "acodec": codec,
92
88
  "ar": sample_rate,
93
89
  "map_metadata": 0,
@@ -95,35 +91,26 @@ def _convert_to_hi_res(input_path, output_path, output_format, sample_rate):
95
91
  }
96
92
  # PCM codecs fix the bit depth by name; the flac encoder needs it spelled out.
97
93
  if output_format.value == "flac":
98
- output_kwargs["sample_fmt"] = "s16"
94
+ kwargs["sample_fmt"] = "s16"
95
+ return kwargs
99
96
 
100
- logger.debug(
101
- f"Selected codec: {codec} targeting {TARGET_BIT_DEPTH}-bit/{sample_rate} Hz"
102
- )
103
97
 
104
- try:
105
- (
106
- ffmpeg.input(input_path)
107
- .output(output_path, **output_kwargs)
108
- .overwrite_output()
109
- .run(capture_stdout=True, capture_stderr=True)
110
- )
111
- logger.debug(f"Conversion to {output_format.value} succeeded: {output_path}")
112
- return True
113
- except FfmpegError as e:
114
- logger.error(f"FFmpeg conversion failed for {input_path}: {e}")
115
- if e.stderr:
116
- stderr = e.stderr.decode() if isinstance(e.stderr, bytes) else e.stderr
117
- logger.debug(f"FFmpeg stderr:\n{stderr}")
118
- return False
119
- except Exception as e:
120
- logger.error(f"Conversion failed for {input_path}: {e}")
121
- raise e
98
+ def _mp3_output_kwargs(sample_rate) -> dict:
99
+ """ffmpeg output kwargs for MP3 320kbps CBR at the given sample rate."""
100
+ return {
101
+ "acodec": "libmp3lame",
102
+ "audio_bitrate": "320k",
103
+ "ar": sample_rate,
104
+ # libmp3lame takes planar input; s16p quantizes to 16-bit before encoding.
105
+ "sample_fmt": "s16p",
106
+ "map_metadata": 0,
107
+ "write_id3v2": 1,
108
+ }
122
109
 
123
110
 
124
- def _convert_to_mp3(input_path, mp3_path):
125
- """Convert hi-res file to MP3 320kbps CBR at the target bit depth and
126
- sample rate."""
111
+ def _run_ffmpeg(input_path, output_path, output_kwargs: dict, label: str) -> bool:
112
+ """Run a single ffmpeg conversion. Returns True on success, False on an
113
+ ffmpeg error; re-raises anything else. `label` names the target for logs."""
127
114
  from rekordbox_edit.utils import ffmpeg_in_path, get_ffmpeg_directions
128
115
 
129
116
  if not ffmpeg_in_path():
@@ -132,21 +119,11 @@ def _convert_to_mp3(input_path, mp3_path):
132
119
  try:
133
120
  (
134
121
  ffmpeg.input(input_path)
135
- .output(
136
- mp3_path,
137
- acodec="libmp3lame",
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",
143
- map_metadata=0,
144
- write_id3v2=1,
145
- )
122
+ .output(output_path, **output_kwargs)
146
123
  .overwrite_output()
147
124
  .run(capture_stdout=True, capture_stderr=True)
148
125
  )
149
- logger.debug(f"Conversion to mp3 succeeded: {mp3_path}")
126
+ logger.debug(f"Conversion to {label} succeeded: {output_path}")
150
127
  return True
151
128
  except FfmpegError as e:
152
129
  logger.error(f"FFmpeg conversion failed for {input_path}: {e}")
@@ -160,15 +137,21 @@ def _convert_to_mp3(input_path, mp3_path):
160
137
 
161
138
 
162
139
  def _update_database_record(
163
- db, content_id, new_filename, new_folder, output_format
140
+ db: Rekordbox6Database,
141
+ content_id: str,
142
+ new_filename: str,
143
+ new_folder: str,
144
+ output_format: str,
164
145
  ) -> None:
165
146
  """Update database record with new file information."""
166
147
  content = db.get_content().filter_by(ID=content_id).first()
167
148
  if not content:
168
149
  raise Exception(f"Content record with ID {content_id} not found")
169
150
 
170
- converted_full_path = posixpath.join(new_folder, new_filename)
171
- converted_audio_info = get_audio_info(converted_full_path)
151
+ old_path = content.FolderPath
152
+ converted_file_path = Path(new_folder, new_filename)
153
+ converted_db_path = str(converted_file_path).replace("\\", "/")
154
+ converted_audio_info = get_audio_info(converted_file_path)
172
155
  converted_bitrate = converted_audio_info["bitrate"]
173
156
 
174
157
  if output_format.upper() == "MP3" and converted_bitrate is None:
@@ -193,8 +176,13 @@ def _update_database_record(
193
176
  content.BitDepth = converted_bit_depth
194
177
 
195
178
  content.FileNameL = new_filename
196
- content.FolderPath = converted_full_path
179
+ content.FolderPath = converted_db_path
197
180
  content.FileType = file_type
181
+ content.FileSize = os.path.getsize(converted_file_path)
182
+
183
+ # Original location: only update if it matches FolderPath
184
+ if content.OrgFolderPath == old_path:
185
+ content.OrgFolderPath = converted_db_path
198
186
 
199
187
  # FLAC stores bitrate as 0 in Rekordbox to represent VBR
200
188
  if output_format.upper() == "FLAC":
@@ -203,6 +191,24 @@ def _update_database_record(
203
191
  content.BitRate = converted_bitrate
204
192
 
205
193
 
194
+ def _update_anlz_paths(
195
+ db: Rekordbox6Database, content: DjmdContent, new_filename: str
196
+ ) -> None:
197
+ """Rewrite the PPTH path tag in a track's ANLZ files to the converted file
198
+ name, in rekordbox's device-relative ``?/<name>`` form.
199
+
200
+ No-op for tracks without an analysis.
201
+ """
202
+ if not content.AnalysisDataPath:
203
+ return
204
+ new_ppth = f"?/{new_filename}"
205
+ anlz_files = db.read_anlz_files(content.ID)
206
+ for anlz_path, anlz in anlz_files.items():
207
+ anlz.set_path(new_ppth)
208
+ anlz.save(anlz_path)
209
+ logger.debug(f"Updated PPTH of {anlz_path} to {new_ppth}")
210
+
211
+
206
212
  def _cleanup_converted_files(converted_ops: list[ConvertOp]) -> None:
207
213
  """Clean up converted output files on error or rollback."""
208
214
  logger.debug("Cleaning up converted files due to aborted conversion.")
@@ -252,14 +258,18 @@ def _classify_convert(content, args: ConvertRequest) -> ConvertOp | SkippedTrack
252
258
  """Return ConvertOp if this track should be converted, or SkippedTrack with
253
259
  reason if not."""
254
260
  target = get_file_type_for_format(args.format_out)
255
- mp3 = get_file_type_for_format("MP3")
256
- m4a = get_file_type_for_format("M4A")
257
- if content.FileType in (target, mp3, m4a):
261
+ if content.FileType == target:
258
262
  logger.debug(
259
263
  f"skip convert id={content.ID} reason=already_target_format "
260
264
  f"file_type={content.FileType} target={target}"
261
265
  )
262
266
  return SkippedTrack(id=str(content.ID), reason="already_target_format")
267
+ if content.FileType not in _INPUT_FILE_TYPES:
268
+ logger.debug(
269
+ f"skip convert id={content.ID} reason=unsupported_source_format "
270
+ f"file_type={content.FileType}"
271
+ )
272
+ return SkippedTrack(id=str(content.ID), reason="unsupported_source_format")
263
273
  output_path, _, _ = _get_output_path(content, args.format_out)
264
274
  if not args.overwrite and os.path.exists(output_path):
265
275
  logger.debug(
@@ -276,11 +286,7 @@ def _classify_convert(content, args: ConvertRequest) -> ConvertOp | SkippedTrack
276
286
  id=str(content.ID),
277
287
  source_path=content.FolderPath or "",
278
288
  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
- ),
289
+ source_file_type=get_file_type_name(content.FileType),
284
290
  source_bit_depth=content.BitDepth,
285
291
  source_sample_rate=content.SampleRate,
286
292
  output_file_type=args.format_out.upper(),
@@ -370,16 +376,19 @@ def convert(
370
376
 
371
377
  if args.format_out.upper() == "MP3":
372
378
  output_sample_rate = TARGET_SAMPLE_RATE
373
- success = _convert_to_mp3(src, op.output_path)
379
+ success = _run_ffmpeg(
380
+ src, op.output_path, _mp3_output_kwargs(output_sample_rate), "mp3"
381
+ )
374
382
  else:
375
383
  fidelity, output_sample_rate = _classify_source_fidelity(src)
376
384
  if fidelity == "lossless":
377
385
  lossless_op_ids.add(op.id)
378
- success = _convert_to_hi_res(
386
+ output_format = OutputFormats(args.format_out.lower())
387
+ success = _run_ffmpeg(
379
388
  src,
380
389
  op.output_path,
381
- OutputFormats(args.format_out.lower()),
382
- output_sample_rate,
390
+ _hi_res_output_kwargs(output_format, output_sample_rate),
391
+ output_format.value,
383
392
  )
384
393
 
385
394
  if not success:
@@ -415,6 +424,15 @@ def convert(
415
424
  _rollback_and_cleanup(db, converted_ops)
416
425
  raise
417
426
 
427
+ for op in converted_ops:
428
+ content = content_map[op.id]
429
+ try:
430
+ _update_anlz_paths(db, content, os.path.basename(op.output_path))
431
+ except Exception as e:
432
+ logger.warning(
433
+ f"Failed to update ANLZ path tags for {content.FileNameL}: {e}"
434
+ )
435
+
418
436
  if args.delete_originals == "all":
419
437
  deletable_ops = converted_ops
420
438
  elif args.delete_originals == "lossless":
@@ -127,9 +127,15 @@ def convert_command(db, **kwargs):
127
127
 
128
128
  def _report_skips(skipped) -> None:
129
129
  already_target = sum(1 for s in skipped if s.reason == "already_target_format")
130
+ unsupported = sum(1 for s in skipped if s.reason == "unsupported_source_format")
130
131
  conflicts = sum(1 for s in skipped if s.reason == "output_file_exists")
131
132
  if already_target:
132
133
  logger.warning(f"Skipping {already_target} file(s): already in target format")
134
+ if unsupported:
135
+ logger.warning(
136
+ f"Skipping {unsupported} file(s): unsupported source format "
137
+ "(only FLAC, AIFF, WAV are converted)"
138
+ )
133
139
  if conflicts:
134
140
  logger.warning(
135
141
  f"Skipping {conflicts} file(s): output exists (use --overwrite to convert)"
@@ -78,13 +78,13 @@ def _cell_value(track: Track, column: PrintableField) -> str:
78
78
  if column is PrintableField.FileType:
79
79
  if track.FileType is None:
80
80
  return ""
81
- try:
82
- return get_file_type_name(track.FileType)
83
- except ValueError:
81
+ name = get_file_type_name(track.FileType)
82
+ if name is None:
84
83
  logger.warning(
85
84
  f"Unexpected FileType [{track.FileType}] for track ID [{track.ID}]"
86
85
  )
87
86
  return "UNKNOWN"
87
+ return name
88
88
  if column is PrintableField.FolderPath:
89
89
  return os.path.dirname(track.FolderPath or "")
90
90
  value = getattr(track, column.value, None)
@@ -126,7 +126,10 @@ class Track(BaseModel):
126
126
 
127
127
 
128
128
  SkipReason: TypeAlias = Literal[
129
- "no_change", "already_target_format", "output_file_exists"
129
+ "no_change",
130
+ "already_target_format",
131
+ "unsupported_source_format",
132
+ "output_file_exists",
130
133
  ]
131
134
 
132
135
 
@@ -18,8 +18,14 @@ class UserQuit(Exception):
18
18
 
19
19
 
20
20
  # File type mappings for Rekordbox database
21
- def get_file_type_name(file_type_code: int):
22
- """Get human-readable name for file type code."""
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.
23
+
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
+ """
27
+ if file_type_code is None:
28
+ return None
23
29
  _get_file_type_name = {
24
30
  0: "MP3",
25
31
  1: "MP3",
@@ -28,10 +34,7 @@ def get_file_type_name(file_type_code: int):
28
34
  11: "WAV",
29
35
  12: "AIFF",
30
36
  }
31
- name = _get_file_type_name.get(file_type_code)
32
- if name is None:
33
- raise ValueError(f"Unknown file_type: {file_type_code}")
34
- return name
37
+ return _get_file_type_name.get(file_type_code)
35
38
 
36
39
 
37
40
  def get_file_type_for_format(format_name: str):
@@ -26,6 +26,8 @@ exclude = [
26
26
  "node_modules",
27
27
  "site-packages",
28
28
  "venv",
29
+ "docs",
30
+ "research"
29
31
  ]
30
32
 
31
33
  # Same as Black.