rekordbox-edit 0.5.1.dev3__tar.gz → 0.6.0.dev10__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 (79) hide show
  1. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/CHANGELOG.md +33 -3
  2. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/PKG-INFO +1 -1
  3. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/pyproject.toml +1 -1
  4. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/rekordbox_edit/__init__.py +2 -2
  5. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/rekordbox_edit/_click.py +2 -1
  6. rekordbox_edit-0.6.0.dev10/rekordbox_edit/api/__init__.py +11 -0
  7. rekordbox_edit-0.6.0.dev10/rekordbox_edit/api/_utils.py +31 -0
  8. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/rekordbox_edit/api/convert.py +181 -151
  9. rekordbox_edit-0.6.0.dev10/rekordbox_edit/api/edit.py +109 -0
  10. rekordbox_edit-0.6.0.dev10/rekordbox_edit/api/search.py +19 -0
  11. rekordbox_edit-0.6.0.dev10/rekordbox_edit/cli/_utils.py +79 -0
  12. rekordbox_edit-0.6.0.dev10/rekordbox_edit/cli/convert.py +173 -0
  13. rekordbox_edit-0.6.0.dev10/rekordbox_edit/cli/edit.py +139 -0
  14. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/rekordbox_edit/cli/search.py +15 -6
  15. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/rekordbox_edit/logger.py +1 -1
  16. rekordbox_edit-0.6.0.dev10/rekordbox_edit/models.py +196 -0
  17. rekordbox_edit-0.6.0.dev10/tests/api/test_convert.py +897 -0
  18. rekordbox_edit-0.6.0.dev10/tests/api/test_edit.py +166 -0
  19. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/tests/api/test_search.py +10 -9
  20. rekordbox_edit-0.6.0.dev10/tests/api/test_utils.py +56 -0
  21. rekordbox_edit-0.6.0.dev10/tests/cli/test_convert.py +270 -0
  22. rekordbox_edit-0.6.0.dev10/tests/cli/test_edit.py +233 -0
  23. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/tests/cli/test_search.py +23 -36
  24. rekordbox_edit-0.6.0.dev10/tests/cli/test_utils.py +171 -0
  25. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/tests/conftest.py +4 -0
  26. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/tests/test_display.py +1 -3
  27. rekordbox_edit-0.6.0.dev10/tests/test_models.py +173 -0
  28. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/uv.lock +20 -20
  29. rekordbox_edit-0.5.1.dev3/rekordbox_edit/api/__init__.py +0 -11
  30. rekordbox_edit-0.5.1.dev3/rekordbox_edit/api/_utils.py +0 -18
  31. rekordbox_edit-0.5.1.dev3/rekordbox_edit/api/edit.py +0 -88
  32. rekordbox_edit-0.5.1.dev3/rekordbox_edit/api/search.py +0 -10
  33. rekordbox_edit-0.5.1.dev3/rekordbox_edit/cli/_utils.py +0 -109
  34. rekordbox_edit-0.5.1.dev3/rekordbox_edit/cli/convert.py +0 -110
  35. rekordbox_edit-0.5.1.dev3/rekordbox_edit/cli/edit.py +0 -78
  36. rekordbox_edit-0.5.1.dev3/rekordbox_edit/models.py +0 -131
  37. rekordbox_edit-0.5.1.dev3/tests/api/test_convert.py +0 -755
  38. rekordbox_edit-0.5.1.dev3/tests/api/test_edit.py +0 -98
  39. rekordbox_edit-0.5.1.dev3/tests/api/test_utils.py +0 -23
  40. rekordbox_edit-0.5.1.dev3/tests/cli/test_convert.py +0 -172
  41. rekordbox_edit-0.5.1.dev3/tests/cli/test_edit.py +0 -111
  42. rekordbox_edit-0.5.1.dev3/tests/cli/test_utils.py +0 -236
  43. rekordbox_edit-0.5.1.dev3/tests/test_models.py +0 -149
  44. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/.agent-style/RULES.md +0 -0
  45. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/.agent-style/claude-code.md +0 -0
  46. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/.github/actions/build-release-notes/action.yml +0 -0
  47. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/.github/actions/commitizen-bump/action.yml +0 -0
  48. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/.github/actions/commitizen-bump/commitizen-bump.sh +0 -0
  49. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/.github/actions/install/action.yml +0 -0
  50. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/.github/actions/lint/action.yml +0 -0
  51. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/.github/actions/test/action.yml +0 -0
  52. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/.github/workflows/cd.yml +0 -0
  53. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/.github/workflows/ci.yml +0 -0
  54. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/.github/workflows/publish.yml +0 -0
  55. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/.github/workflows/release.yml +0 -0
  56. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/.gitignore +0 -0
  57. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/.pre-commit-config.yaml +0 -0
  58. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/.python-version +0 -0
  59. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/AGENTS.md +0 -0
  60. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/CLAUDE.md +0 -0
  61. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/CONTRIBUTING.md +0 -0
  62. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/LICENSE +0 -0
  63. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/Makefile +0 -0
  64. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/README.md +0 -0
  65. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/codecov.yml +0 -0
  66. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/rekordbox_edit/cli/__init__.py +0 -0
  67. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/rekordbox_edit/cli/main.py +0 -0
  68. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/rekordbox_edit/display.py +0 -0
  69. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/rekordbox_edit/query.py +0 -0
  70. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/rekordbox_edit/utils.py +0 -0
  71. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/renovate.json5 +0 -0
  72. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/ruff.toml +0 -0
  73. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/tests/__init__.py +0 -0
  74. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/tests/api/__init__.py +0 -0
  75. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/tests/cli/__init__.py +0 -0
  76. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/tests/cli/test_main.py +0 -0
  77. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/tests/test_logger.py +0 -0
  78. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/tests/test_query.py +0 -0
  79. {rekordbox_edit-0.5.1.dev3 → rekordbox_edit-0.6.0.dev10}/tests/test_utils.py +0 -0
@@ -1,6 +1,36 @@
1
- ## v0.5.1.dev3 (2026-06-07)
2
-
3
-
1
+ ## v0.6.0.dev10 (2026-06-08)
2
+
3
+
4
+ - test: restore coverage lost during api/cli redesign
5
+ - Restore tests for ffmpeg conversion internals, post-commit/rollback
6
+ paths, and the CLI preview-confirm-commit default flow that the
7
+ single-function API redesign dropped. Coverage recovers from 87% to
8
+ 97% (baseline pre-redesign was 98%) and the test count from 200 to
9
+ 238.
10
+ - refactor: privatize module-internal helpers and unify cli print helpers
11
+ - - api/convert.py: prefix module-private helpers with underscore
12
+ (_convert_to_lossless, _convert_to_mp3, _update_database_record,
13
+ _cleanup_converted_files, _rollback_and_cleanup, _get_output_path)
14
+ - cli/{edit,convert}.py: rename _render_*_response to _print_*_result
15
+ - fix(api,cli): fixups post refactor
16
+ - - convert(): on post-commit re-query failure, fall back to pre-mutation
17
+ snapshot tracks instead of an empty list, so the response validator
18
+ doesn't raise after a successful commit
19
+ - cli/convert.py: remove duplicate "Deleted N" log in the default flow
20
+ - cli/edit.py: log "No changes to make." in --yes path when no edits
21
+ - Adds a regression test for the post-commit re-query fallback.
22
+ - feat(api,cli)!: redesign around single-function commands
23
+ - Replace the plan/execute split with one function per command that takes
24
+ an optional dry_run kwarg. Each command (search, edit, convert) now
25
+ returns a typed response envelope with tracks plus a command-specific
26
+ result. CLI restructured to preview-then-commit by default and to call
27
+ the API exactly once when --yes or --dry-run is given.
28
+ - BREAKING CHANGE: drops plan_edit/plan_convert. The public API surface is
29
+ now search/edit/convert; old EditPlanArgs/ConvertPlanArgs types removed.
30
+ - feat: add new `--print json` option
31
+ - refactor: Allow extra columns to ride with Track instances
32
+ - chore(deps): update linters to v0.15.16 (#76)
33
+ - Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4
34
  - fix: handle stdin BOM character on windows
5
35
  - chore(deps): update linters to v0.0.43 (#73)
6
36
  - Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rekordbox-edit
3
- Version: 0.5.1.dev3
3
+ Version: 0.6.0.dev10
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.5.1.dev3"
7
+ version = "0.6.0.dev10"
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"
@@ -4,6 +4,6 @@ __version__ = "0.1.0"
4
4
  __author__ = "James Viall"
5
5
  __email__ = "jamesviall@pm.me"
6
6
 
7
- from rekordbox_edit.api import convert, edit, plan_convert, plan_edit, search
7
+ from rekordbox_edit.api import convert, edit, search
8
8
 
9
- __all__ = ["search", "plan_edit", "edit", "plan_convert", "convert"]
9
+ __all__ = ["search", "edit", "convert"]
@@ -8,6 +8,7 @@ class PrintChoice(Enum):
8
8
  IDS = 1
9
9
  INFO = 2
10
10
  DEBUG = 3
11
+ JSON = 4
11
12
 
12
13
 
13
14
  print_option = click.option(
@@ -15,7 +16,7 @@ print_option = click.option(
15
16
  "print_opt", # avoid shadowing the print() function
16
17
  default="info",
17
18
  type=click.Choice(PrintChoice, case_sensitive=False),
18
- help="Configures the kind of console output you want from the command, if any. The 'ids' option can be used to pipe a list of resulting content IDs into to another command.",
19
+ help="Configures the kind of console output you want from the command, if any. Use 'ids' to pipe a list of resulting content IDs or 'json' to pipe full track records into another command.",
19
20
  )
20
21
 
21
22
  track_ids_argument = click.argument("track-ids", type=str, required=False, nargs=-1)
@@ -0,0 +1,11 @@
1
+ """Public API for rekordbox-edit.
2
+
3
+ Usage:
4
+ from rekordbox_edit.api import search, edit, convert
5
+ """
6
+
7
+ from rekordbox_edit.api.convert import convert
8
+ from rekordbox_edit.api.edit import edit
9
+ from rekordbox_edit.api.search import search
10
+
11
+ __all__ = ["search", "edit", "convert"]
@@ -0,0 +1,31 @@
1
+ from collections.abc import Sequence
2
+
3
+ from pyrekordbox.db6 import DjmdContent
4
+
5
+ from rekordbox_edit.models import ConvertOp, EditOp, Track
6
+
7
+ _COLUMN_KEYS = tuple(c.key for c in DjmdContent.__table__.columns)
8
+
9
+
10
+ def _track_from_content(content: DjmdContent) -> Track:
11
+ data = {k: getattr(content, k) for k in _COLUMN_KEYS}
12
+ data["ID"] = str(data["ID"])
13
+ # association_proxy attributes; not present in __table__.columns
14
+ data["ArtistName"] = content.ArtistName
15
+ data["AlbumName"] = content.AlbumName
16
+ return Track(**data)
17
+
18
+
19
+ def _order_tracks_by_op(
20
+ contents: Sequence[DjmdContent], ops: Sequence[EditOp | ConvertOp]
21
+ ) -> list[Track]:
22
+ """Build Track list in op order from raw DjmdContent rows.
23
+
24
+ Builds the id -> content lookup internally so callers don't have to. Ops
25
+ whose id is not in `contents` are silently skipped (the caller already
26
+ knows about the divergence from upstream logic).
27
+ """
28
+ content_map = {str(c.ID): c for c in contents}
29
+ return [
30
+ _track_from_content(content_map[op.id]) for op in ops if op.id in content_map
31
+ ]
@@ -8,13 +8,18 @@ from typing import Tuple
8
8
 
9
9
  import ffmpeg
10
10
  from ffmpeg import Error as FfmpegError
11
- from pydantic import BaseModel
12
11
  from pyrekordbox import Rekordbox6Database
13
12
  from pyrekordbox.db6 import DjmdContent
14
13
  from sqlalchemy import select
15
14
 
16
- from rekordbox_edit.api._utils import _track_from_content
17
- from rekordbox_edit.models import ConvertPlanArgs, Track
15
+ from rekordbox_edit.api._utils import _order_tracks_by_op
16
+ from rekordbox_edit.models import (
17
+ ConvertArgs,
18
+ ConvertOp,
19
+ ConvertResponse,
20
+ ConvertResult,
21
+ SkippedTrack,
22
+ )
18
23
  from rekordbox_edit.query import get_filtered_content
19
24
  from rekordbox_edit.utils import (
20
25
  OutputFormats,
@@ -26,10 +31,10 @@ from rekordbox_edit.utils import (
26
31
  logger = logging.getLogger(__name__)
27
32
 
28
33
 
29
- # ── Helpers ──────────────────────────────────────────────────────────────
34
+ # ── ffmpeg helpers ────────────────────────────────────────────────────────
30
35
 
31
36
 
32
- def convert_to_lossless(input_path, output_path, output_format):
37
+ def _convert_to_lossless(input_path, output_path, output_format):
33
38
  """Convert lossless file to another lossless format, preserving bit depth."""
34
39
  from rekordbox_edit.utils import ffmpeg_in_path, get_ffmpeg_directions
35
40
 
@@ -62,12 +67,10 @@ def convert_to_lossless(input_path, output_path, output_format):
62
67
 
63
68
  logger.debug(f"Selected codec: {codec} (bit_depth={bit_depth})")
64
69
 
65
- output_options = {"acodec": codec, "map_metadata": 0, "write_id3v2": 1}
66
-
67
70
  try:
68
71
  (
69
72
  ffmpeg.input(input_path)
70
- .output(output_path, **output_options)
73
+ .output(output_path, acodec=codec, map_metadata=0, write_id3v2=1)
71
74
  .overwrite_output()
72
75
  .run(capture_stdout=True, capture_stderr=True)
73
76
  )
@@ -84,7 +87,7 @@ def convert_to_lossless(input_path, output_path, output_format):
84
87
  raise e
85
88
 
86
89
 
87
- def convert_to_mp3(input_path, mp3_path):
90
+ def _convert_to_mp3(input_path, mp3_path):
88
91
  """Convert lossless file to MP3 320kbps CBR."""
89
92
  from rekordbox_edit.utils import ffmpeg_in_path, get_ffmpeg_directions
90
93
 
@@ -92,24 +95,18 @@ def convert_to_mp3(input_path, mp3_path):
92
95
  raise Exception(f"FFmpeg not found in PATH.{get_ffmpeg_directions()}")
93
96
 
94
97
  try:
95
- acodec = "libmp3lame"
96
- audio_bitrate = "320k"
97
- map_metadata = 0
98
- write_id3v2 = 1
99
-
100
98
  (
101
99
  ffmpeg.input(input_path)
102
100
  .output(
103
101
  mp3_path,
104
- acodec=acodec,
105
- audio_bitrate=audio_bitrate,
106
- map_metadata=map_metadata,
107
- write_id3v2=write_id3v2,
102
+ acodec="libmp3lame",
103
+ audio_bitrate="320k",
104
+ map_metadata=0,
105
+ write_id3v2=1,
108
106
  )
109
107
  .overwrite_output()
110
108
  .run(capture_stdout=True, capture_stderr=True)
111
109
  )
112
-
113
110
  logger.debug(f"Conversion to mp3 succeeded: {mp3_path}")
114
111
  return True
115
112
  except FfmpegError as e:
@@ -123,7 +120,7 @@ def convert_to_mp3(input_path, mp3_path):
123
120
  raise e
124
121
 
125
122
 
126
- def update_database_record(
123
+ def _update_database_record(
127
124
  db, content_id, new_filename, new_folder, output_format
128
125
  ) -> None:
129
126
  """Update database record with new file information."""
@@ -146,17 +143,14 @@ def update_database_record(
146
143
  if output_format.upper() in ["AIFF", "FLAC", "WAV"]:
147
144
  converted_bit_depth = converted_audio_info["bit_depth"]
148
145
  database_bit_depth = getattr(content, "BitDepth", None)
149
- logger.debug(
150
- f"Bit depth check: database={database_bit_depth}, file={converted_bit_depth}"
151
- )
152
-
153
146
  if (
154
147
  database_bit_depth
155
148
  and converted_bit_depth
156
149
  and converted_bit_depth != database_bit_depth
157
150
  ):
158
151
  raise Exception(
159
- f"Bit depth mismatch for lossless transcode: database={database_bit_depth}, file={converted_bit_depth}"
152
+ f"Bit depth mismatch for lossless transcode: "
153
+ f"database={database_bit_depth}, file={converted_bit_depth}"
160
154
  )
161
155
 
162
156
  content.FileNameL = new_filename
@@ -166,28 +160,22 @@ def update_database_record(
166
160
  # FLAC stores bitrate as 0 in Rekordbox to represent VBR
167
161
  if output_format.upper() == "FLAC":
168
162
  content.BitRate = 0
169
- logger.debug(
170
- f"Set FileType={file_type}, BitRate=0 (FLAC), FolderPath={converted_full_path}"
171
- )
172
163
  else:
173
164
  content.BitRate = converted_bitrate
174
- logger.debug(
175
- f"Set FileType={file_type}, BitRate={converted_bitrate}, FolderPath={converted_full_path}"
176
- )
177
165
 
178
166
 
179
- def cleanup_converted_files(converted_files) -> None:
180
- """Clean up converted files on error or rollback."""
167
+ def _cleanup_converted_files(converted_ops: list[ConvertOp]) -> None:
168
+ """Clean up converted output files on error or rollback."""
181
169
  logger.debug("Cleaning up converted files due to aborted conversion.")
182
- for file_info in converted_files:
170
+ for op in converted_ops:
183
171
  try:
184
- os.remove(file_info["output_path"])
185
- logger.debug(f"Cleaned up {file_info['output_path']}")
172
+ os.remove(op.output_path)
173
+ logger.debug(f"Cleaned up {op.output_path}")
186
174
  except Exception:
187
175
  pass
188
176
 
189
177
 
190
- def rollback_and_cleanup(db, converted_files) -> None:
178
+ def _rollback_and_cleanup(db, converted_ops: list[ConvertOp]) -> None:
191
179
  """Roll back the database session and clean up any converted files."""
192
180
  logger.debug("Attempting DB session rollback.")
193
181
  rollback_error = None
@@ -200,15 +188,13 @@ def rollback_and_cleanup(db, converted_files) -> None:
200
188
  "Check the state of your rekordbox library and consider reverting to a backup database if something's not right"
201
189
  )
202
190
  rollback_error = e
203
- else:
204
- logger.debug("No DB session to rollback.")
205
- if converted_files:
206
- cleanup_converted_files(converted_files)
191
+ if converted_ops:
192
+ _cleanup_converted_files(converted_ops)
207
193
  if rollback_error:
208
194
  raise rollback_error
209
195
 
210
196
 
211
- def get_output_path(content, output_format) -> Tuple[str, str, str]:
197
+ def _get_output_path(content, output_format) -> Tuple[str, str, str]:
212
198
  """Calculate output path for a content item."""
213
199
  src_folder_path = os.path.normpath(content.FolderPath or "")
214
200
  src_file_name = content.FileNameL or ""
@@ -220,149 +206,193 @@ def get_output_path(content, output_format) -> Tuple[str, str, str]:
220
206
  return output_path, output_filename, src_dirname
221
207
 
222
208
 
223
- # ── Result types ──────────────────────────────────────────────────────────
224
-
225
-
226
- class ConvertPlan(BaseModel):
227
- files: list[Track]
228
- skipped: list[Track] # files skipped due to existing output (no overwrite)
229
- should_delete: bool
230
- format_out: str
231
-
209
+ # ── Classifier ────────────────────────────────────────────────────────────
232
210
 
233
- class ConvertResult(BaseModel):
234
- converted: list[dict] # {source_path, output_path, content_id}
235
- deleted: int
236
211
 
237
-
238
- # ── API functions ─────────────────────────────────────────────────────────
239
-
240
-
241
- def plan_convert(db: Rekordbox6Database, args: ConvertPlanArgs) -> ConvertPlan:
242
- """Determine which tracks need conversion and resolve delete behaviour."""
243
- should_delete = (
244
- args.delete if args.delete is not None else args.format_out.upper() != "MP3"
212
+ def _classify_convert(content, args: ConvertArgs) -> ConvertOp | SkippedTrack:
213
+ """Return ConvertOp if this track should be converted, or SkippedTrack with
214
+ reason if not."""
215
+ target = get_file_type_for_format(args.format_out)
216
+ mp3 = get_file_type_for_format("MP3")
217
+ m4a = get_file_type_for_format("M4A")
218
+ if content.FileType in (target, mp3, m4a):
219
+ logger.debug(
220
+ f"skip convert id={content.ID} reason=already_target_format "
221
+ f"file_type={content.FileType} target={target}"
222
+ )
223
+ return SkippedTrack(id=str(content.ID), reason="already_target_format")
224
+ output_path, _, _ = _get_output_path(content, args.format_out)
225
+ if not args.overwrite and os.path.exists(output_path):
226
+ logger.debug(
227
+ f"skip convert id={content.ID} reason=output_file_exists path={output_path}"
228
+ )
229
+ return SkippedTrack(id=str(content.ID), reason="output_file_exists")
230
+ return ConvertOp(
231
+ id=str(content.ID),
232
+ source_path=content.FolderPath or "",
233
+ output_path=output_path,
245
234
  )
246
235
 
247
- result = get_filtered_content(db, args)
248
- filtered_content = result.scalars().all()
249
-
250
- target_file_type = get_file_type_for_format(args.format_out)
251
- mp3_type = get_file_type_for_format("MP3")
252
- m4a_type = get_file_type_for_format("M4A")
253
236
 
254
- needs_conversion = [
255
- c
256
- for c in filtered_content
257
- if c.FileType != target_file_type
258
- and c.FileType != mp3_type
259
- and c.FileType != m4a_type
260
- ]
261
-
262
- if args.overwrite:
263
- files = needs_conversion
264
- skipped = []
265
- else:
266
- files, skipped = [], []
267
- for content in needs_conversion:
268
- output_path, _, _ = get_output_path(content, args.format_out)
269
- if os.path.exists(output_path):
270
- skipped.append(content)
271
- else:
272
- files.append(content)
273
-
274
- logger.debug(
275
- f"plan_convert: {len(files)} to convert, {len(skipped)} skipped (conflict)"
276
- )
237
+ # ── Public API ────────────────────────────────────────────────────────────
277
238
 
278
- return ConvertPlan(
279
- files=[_track_from_content(c) for c in files],
280
- skipped=[_track_from_content(c) for c in skipped],
281
- should_delete=should_delete,
282
- format_out=args.format_out,
283
- )
284
239
 
240
+ def convert(
241
+ db: Rekordbox6Database,
242
+ args: ConvertArgs,
243
+ *,
244
+ dry_run: bool = False,
245
+ ) -> ConvertResponse:
246
+ """Convert audio files to a target format and update the Rekordbox database.
285
247
 
286
- def convert(db: Rekordbox6Database, plan: ConvertPlan) -> ConvertResult:
287
- """Execute a ConvertPlan: convert files and update the database.
248
+ With `dry_run=True`, returns the planned conversions without any ffmpeg or
249
+ DB writes. With `dry_run=False` (default), commits the changes.
288
250
 
289
- Uses try/except BaseException so KeyboardInterrupt triggers rollback before
290
- the exception propagates to the caller.
251
+ The rollback block protects only pre-commit work; once commit lands, the
252
+ transaction is honoured even if the delete-originals loop or response
253
+ re-query later fails.
291
254
  """
292
255
  from rekordbox_edit.utils import ffmpeg_in_path, get_ffmpeg_directions
293
256
 
294
- if not plan.files:
295
- return ConvertResult(converted=[], deleted=0)
257
+ logger.debug(f"convert start format_out={args.format_out} dry_run={dry_run}")
296
258
 
297
259
  if not ffmpeg_in_path():
260
+ logger.debug("convert aborted: FFmpeg not in PATH")
298
261
  raise RuntimeError(
299
262
  f"FFmpeg is required but not found in PATH.{get_ffmpeg_directions()}"
300
263
  )
301
264
 
265
+ contents = get_filtered_content(db, args).scalars().all()
266
+ logger.debug(f"convert fetched {len(contents)} candidate(s) from filter")
267
+
268
+ ops: list[ConvertOp] = []
269
+ skipped: list[SkippedTrack] = []
270
+ for c in contents:
271
+ result = _classify_convert(c, args)
272
+ if isinstance(result, ConvertOp):
273
+ ops.append(result)
274
+ else:
275
+ skipped.append(result)
276
+ logger.debug(f"convert classified ops={len(ops)} skipped={len(skipped)}")
277
+
278
+ if dry_run:
279
+ logger.debug(f"convert dry-run return with {len(ops)} planned conversion(s)")
280
+ return ConvertResponse(
281
+ tracks=_order_tracks_by_op(contents, ops),
282
+ result=ConvertResult(
283
+ format_out=args.format_out,
284
+ converted=ops,
285
+ deleted=0,
286
+ skipped=skipped,
287
+ ),
288
+ )
289
+
290
+ if not ops:
291
+ return ConvertResponse(
292
+ tracks=[],
293
+ result=ConvertResult(
294
+ format_out=args.format_out,
295
+ converted=[],
296
+ deleted=0,
297
+ skipped=skipped,
298
+ ),
299
+ )
300
+
302
301
  assert db.session is not None
303
302
 
304
- ids = [t.ID for t in plan.files]
305
- contents = (
306
- db.session.execute(select(DjmdContent).where(DjmdContent.ID.in_(ids)))
307
- .scalars()
308
- .all()
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
309
  )
310
- content_map = {str(c.ID): c for c in contents}
311
310
 
312
- converted_files: list[dict] = []
311
+ # content_map enables per-op live FolderPath / FileNameL reads in the loop.
312
+ content_map = {str(c.ID): c for c in contents}
313
+ converted_ops: list[ConvertOp] = []
313
314
  try:
314
- for i, track in enumerate(plan.files, 1):
315
- content = content_map[track.ID]
316
- src_folder_path = content.FolderPath or ""
317
- output_path, output_filename, src_dirname = get_output_path(
318
- content, plan.format_out
319
- )
320
-
321
- logger.info(f"[{i}/{len(plan.files)}] {content.FileNameL}")
315
+ for i, op in enumerate(ops, 1):
316
+ content = content_map[op.id]
317
+ src = content.FolderPath or ""
318
+ logger.info(f"[{i}/{len(ops)}] {content.FileNameL}")
322
319
 
323
- if not os.path.exists(src_folder_path):
324
- raise RuntimeError(f"Source not found: {src_folder_path}")
320
+ if not os.path.exists(src):
321
+ raise RuntimeError(f"Source not found: {src}")
325
322
 
326
- if plan.format_out.upper() == "MP3":
327
- success = convert_to_mp3(src_folder_path, output_path)
323
+ if args.format_out.upper() == "MP3":
324
+ success = _convert_to_mp3(src, op.output_path)
328
325
  else:
329
- success = convert_to_lossless(
330
- src_folder_path, output_path, OutputFormats(plan.format_out.lower())
326
+ success = _convert_to_lossless(
327
+ src, op.output_path, OutputFormats(args.format_out.lower())
331
328
  )
332
329
 
333
330
  if not success:
334
- raise RuntimeError(f"Conversion failed for {src_folder_path}")
335
-
336
- if not os.path.exists(output_path):
337
- raise RuntimeError(f"Output file not created: {output_path}")
338
-
339
- update_database_record(
340
- db, content.ID, output_filename, src_dirname, plan.format_out.upper()
331
+ raise RuntimeError(f"Conversion failed for {src}")
332
+ if not os.path.exists(op.output_path):
333
+ raise RuntimeError(f"Output file not created: {op.output_path}")
334
+
335
+ _update_database_record(
336
+ db,
337
+ op.id,
338
+ os.path.basename(op.output_path),
339
+ os.path.dirname(op.output_path),
340
+ args.format_out.upper(),
341
341
  )
342
- converted_files.append(
343
- {
344
- "source_path": src_folder_path,
345
- "output_path": output_path,
346
- "content_id": content.ID,
347
- }
342
+ converted_ops.append(
343
+ ConvertOp(id=op.id, source_path=src, output_path=op.output_path)
348
344
  )
349
345
 
350
346
  db.session.commit()
351
347
  logger.info(
352
- f"\nConverted {len(converted_files)} files to {plan.format_out.upper()}"
348
+ f"\nConverted {len(converted_ops)} files to {args.format_out.upper()}"
353
349
  )
354
-
355
- deleted = 0
356
- if plan.should_delete:
357
- for file_info in converted_files:
358
- try:
359
- os.remove(file_info["source_path"])
360
- deleted += 1
361
- except Exception as e:
362
- logger.warning(f"Failed to delete {file_info['source_path']}: {e}")
363
-
364
- return ConvertResult(converted=converted_files, deleted=deleted)
365
-
350
+ logger.debug(f"convert committed {len(converted_ops)} conversion(s)")
366
351
  except BaseException:
367
- rollback_and_cleanup(db, converted_files)
352
+ logger.debug(
353
+ f"convert rolling back after {len(converted_ops)} partial conversion(s)"
354
+ )
355
+ _rollback_and_cleanup(db, converted_ops)
368
356
  raise
357
+
358
+ deleted = 0
359
+ if should_delete:
360
+ for op in converted_ops:
361
+ try:
362
+ os.remove(op.source_path)
363
+ deleted += 1
364
+ except Exception as e:
365
+ logger.warning(f"Failed to delete {op.source_path}: {e}")
366
+ logger.debug(f"convert deleted {deleted}/{len(converted_ops)} source file(s)")
367
+
368
+ converted_ids = [op.id for op in converted_ops]
369
+ try:
370
+ post_contents = (
371
+ db.session.execute(
372
+ select(DjmdContent).where(DjmdContent.ID.in_(converted_ids))
373
+ )
374
+ .scalars()
375
+ .all()
376
+ )
377
+ logger.debug(
378
+ f"convert post-commit re-query returned {len(post_contents)} track(s)"
379
+ )
380
+ tracks = _order_tracks_by_op(post_contents, converted_ops)
381
+ except Exception as e:
382
+ # Fall back to pre-mutation snapshots so the response stays valid;
383
+ # the commit succeeded, the caller should not see an alignment error.
384
+ logger.warning(
385
+ f"Failed to re-query tracks after commit; falling back to "
386
+ f"pre-mutation snapshots: {e}"
387
+ )
388
+ tracks = _order_tracks_by_op(list(content_map.values()), converted_ops)
389
+
390
+ return ConvertResponse(
391
+ tracks=tracks,
392
+ result=ConvertResult(
393
+ format_out=args.format_out,
394
+ converted=converted_ops,
395
+ deleted=deleted,
396
+ skipped=skipped,
397
+ ),
398
+ )