rekordbox-edit 0.5.1.dev4__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.
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/CHANGELOG.md +31 -3
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/PKG-INFO +1 -1
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/pyproject.toml +1 -1
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/rekordbox_edit/__init__.py +2 -2
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/rekordbox_edit/_click.py +2 -1
- rekordbox_edit-0.6.0.dev10/rekordbox_edit/api/__init__.py +11 -0
- rekordbox_edit-0.6.0.dev10/rekordbox_edit/api/_utils.py +31 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/rekordbox_edit/api/convert.py +181 -151
- rekordbox_edit-0.6.0.dev10/rekordbox_edit/api/edit.py +109 -0
- rekordbox_edit-0.6.0.dev10/rekordbox_edit/api/search.py +19 -0
- rekordbox_edit-0.6.0.dev10/rekordbox_edit/cli/_utils.py +79 -0
- rekordbox_edit-0.6.0.dev10/rekordbox_edit/cli/convert.py +173 -0
- rekordbox_edit-0.6.0.dev10/rekordbox_edit/cli/edit.py +139 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/rekordbox_edit/cli/search.py +15 -6
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/rekordbox_edit/logger.py +1 -1
- rekordbox_edit-0.6.0.dev10/rekordbox_edit/models.py +196 -0
- rekordbox_edit-0.6.0.dev10/tests/api/test_convert.py +897 -0
- rekordbox_edit-0.6.0.dev10/tests/api/test_edit.py +166 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/tests/api/test_search.py +10 -9
- rekordbox_edit-0.6.0.dev10/tests/api/test_utils.py +56 -0
- rekordbox_edit-0.6.0.dev10/tests/cli/test_convert.py +270 -0
- rekordbox_edit-0.6.0.dev10/tests/cli/test_edit.py +233 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/tests/cli/test_search.py +23 -36
- rekordbox_edit-0.6.0.dev10/tests/cli/test_utils.py +171 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/tests/conftest.py +4 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/tests/test_display.py +1 -3
- rekordbox_edit-0.6.0.dev10/tests/test_models.py +173 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/uv.lock +1 -1
- rekordbox_edit-0.5.1.dev4/rekordbox_edit/api/__init__.py +0 -11
- rekordbox_edit-0.5.1.dev4/rekordbox_edit/api/_utils.py +0 -18
- rekordbox_edit-0.5.1.dev4/rekordbox_edit/api/edit.py +0 -88
- rekordbox_edit-0.5.1.dev4/rekordbox_edit/api/search.py +0 -10
- rekordbox_edit-0.5.1.dev4/rekordbox_edit/cli/_utils.py +0 -109
- rekordbox_edit-0.5.1.dev4/rekordbox_edit/cli/convert.py +0 -110
- rekordbox_edit-0.5.1.dev4/rekordbox_edit/cli/edit.py +0 -78
- rekordbox_edit-0.5.1.dev4/rekordbox_edit/models.py +0 -131
- rekordbox_edit-0.5.1.dev4/tests/api/test_convert.py +0 -755
- rekordbox_edit-0.5.1.dev4/tests/api/test_edit.py +0 -98
- rekordbox_edit-0.5.1.dev4/tests/api/test_utils.py +0 -23
- rekordbox_edit-0.5.1.dev4/tests/cli/test_convert.py +0 -172
- rekordbox_edit-0.5.1.dev4/tests/cli/test_edit.py +0 -111
- rekordbox_edit-0.5.1.dev4/tests/cli/test_utils.py +0 -236
- rekordbox_edit-0.5.1.dev4/tests/test_models.py +0 -149
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/.agent-style/RULES.md +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/.agent-style/claude-code.md +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/.github/actions/build-release-notes/action.yml +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/.github/actions/commitizen-bump/action.yml +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/.github/actions/commitizen-bump/commitizen-bump.sh +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/.github/actions/install/action.yml +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/.github/actions/lint/action.yml +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/.github/actions/test/action.yml +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/.github/workflows/cd.yml +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/.github/workflows/ci.yml +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/.github/workflows/publish.yml +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/.github/workflows/release.yml +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/.gitignore +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/.pre-commit-config.yaml +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/.python-version +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/AGENTS.md +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/CLAUDE.md +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/CONTRIBUTING.md +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/LICENSE +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/Makefile +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/README.md +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/codecov.yml +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/rekordbox_edit/cli/__init__.py +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/rekordbox_edit/cli/main.py +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/rekordbox_edit/display.py +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/rekordbox_edit/query.py +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/rekordbox_edit/utils.py +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/renovate.json5 +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/ruff.toml +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/tests/__init__.py +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/tests/api/__init__.py +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/tests/cli/__init__.py +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/tests/cli/test_main.py +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/tests/test_logger.py +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/tests/test_query.py +0 -0
- {rekordbox_edit-0.5.1.dev4 → rekordbox_edit-0.6.0.dev10}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,34 @@
|
|
|
1
|
-
## v0.
|
|
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
|
|
4
32
|
- chore(deps): update linters to v0.15.16 (#76)
|
|
5
33
|
- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
|
|
6
34
|
- fix: handle stdin BOM character on windows
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rekordbox-edit
|
|
3
|
-
Version: 0.
|
|
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.
|
|
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,
|
|
7
|
+
from rekordbox_edit.api import convert, edit, search
|
|
8
8
|
|
|
9
|
-
__all__ = ["search", "
|
|
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.
|
|
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
|
|
17
|
-
from rekordbox_edit.models import
|
|
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
|
-
# ──
|
|
34
|
+
# ── ffmpeg helpers ────────────────────────────────────────────────────────
|
|
30
35
|
|
|
31
36
|
|
|
32
|
-
def
|
|
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,
|
|
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
|
|
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=
|
|
105
|
-
audio_bitrate=
|
|
106
|
-
map_metadata=
|
|
107
|
-
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
|
|
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:
|
|
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
|
|
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
|
|
170
|
+
for op in converted_ops:
|
|
183
171
|
try:
|
|
184
|
-
os.remove(
|
|
185
|
-
logger.debug(f"Cleaned up {
|
|
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
|
|
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
|
-
|
|
204
|
-
|
|
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
|
|
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
|
-
# ──
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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,
|
|
315
|
-
content = content_map[
|
|
316
|
-
|
|
317
|
-
|
|
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(
|
|
324
|
-
raise RuntimeError(f"Source not found: {
|
|
320
|
+
if not os.path.exists(src):
|
|
321
|
+
raise RuntimeError(f"Source not found: {src}")
|
|
325
322
|
|
|
326
|
-
if
|
|
327
|
-
success =
|
|
323
|
+
if args.format_out.upper() == "MP3":
|
|
324
|
+
success = _convert_to_mp3(src, op.output_path)
|
|
328
325
|
else:
|
|
329
|
-
success =
|
|
330
|
-
|
|
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 {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
+
)
|