collapsarr 0.1.3__py3-none-any.whl
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.
- collapsarr/__init__.py +15 -0
- collapsarr/__main__.py +25 -0
- collapsarr/arr/__init__.py +76 -0
- collapsarr/arr/client.py +71 -0
- collapsarr/arr/files.py +211 -0
- collapsarr/arr/models.py +147 -0
- collapsarr/arr/routes.py +290 -0
- collapsarr/arr/service.py +222 -0
- collapsarr/arr/webhooks.py +200 -0
- collapsarr/auth.py +83 -0
- collapsarr/config.py +95 -0
- collapsarr/database.py +82 -0
- collapsarr/downmix/__init__.py +54 -0
- collapsarr/downmix/apply.py +241 -0
- collapsarr/downmix/pipeline.py +235 -0
- collapsarr/downmix/probe.py +310 -0
- collapsarr/downmix/remux.py +284 -0
- collapsarr/downmix/targets.py +145 -0
- collapsarr/frontend.py +61 -0
- collapsarr/health.py +111 -0
- collapsarr/jobs/__init__.py +49 -0
- collapsarr/jobs/failure_notify.py +145 -0
- collapsarr/jobs/history.py +153 -0
- collapsarr/jobs/models.py +90 -0
- collapsarr/jobs/queue.py +382 -0
- collapsarr/jobs/routes.py +193 -0
- collapsarr/jobs/scheduler.py +402 -0
- collapsarr/main.py +212 -0
- collapsarr/media/__init__.py +38 -0
- collapsarr/media/models.py +132 -0
- collapsarr/media/routes.py +88 -0
- collapsarr/media/service.py +241 -0
- collapsarr/notify/__init__.py +37 -0
- collapsarr/notify/dispatch.py +162 -0
- collapsarr/notify/models.py +63 -0
- collapsarr/notify/routes.py +105 -0
- collapsarr/notify/service.py +80 -0
- collapsarr/settings/__init__.py +29 -0
- collapsarr/settings/models.py +107 -0
- collapsarr/settings/routes.py +157 -0
- collapsarr/settings/service.py +158 -0
- collapsarr/static/apple-touch-icon.png +0 -0
- collapsarr/static/assets/index-Dfozi1oM.js +68 -0
- collapsarr/static/assets/index-J2qCuzXj.css +1 -0
- collapsarr/static/favicon-32.png +0 -0
- collapsarr/static/favicon.svg +10 -0
- collapsarr/static/index.html +18 -0
- collapsarr-0.1.3.dist-info/METADATA +218 -0
- collapsarr-0.1.3.dist-info/RECORD +52 -0
- collapsarr-0.1.3.dist-info/WHEEL +4 -0
- collapsarr-0.1.3.dist-info/entry_points.txt +2 -0
- collapsarr-0.1.3.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Validate a produced remux temp file and atomically swap it in (COL-18).
|
|
2
|
+
|
|
3
|
+
:func:`~collapsarr.downmix.remux.run_remux` (COL-17) writes a *temp* file —
|
|
4
|
+
in the same directory as the original, containing every original stream plus
|
|
5
|
+
the newly-encoded downmix track(s) — but deliberately stops short of touching
|
|
6
|
+
the original. This module is that final, safety-critical step: it validates
|
|
7
|
+
the temp file against the original and, **only** if it passes, atomically
|
|
8
|
+
renames it over the original.
|
|
9
|
+
|
|
10
|
+
This is the app's core data-safety guarantee. Collapsarr mutates users' real,
|
|
11
|
+
often-irreplaceable media libraries in place, so the contract here is
|
|
12
|
+
absolute: **on any outcome other than a fully-validated success, the original
|
|
13
|
+
file is left byte-for-byte untouched and the temp file is discarded.** No
|
|
14
|
+
long-term backup of the original is ever made — the operation is strictly
|
|
15
|
+
additive and the temp/rename dance *is* the safety window (see
|
|
16
|
+
``docs/plans/2026-07-20-collapsarr-v1-design.md``, "Remux safety").
|
|
17
|
+
|
|
18
|
+
Two checks gate the swap, both via :func:`~collapsarr.downmix.probe.probe_media_summary`
|
|
19
|
+
(real ffprobe, same injectable-runner seam as the rest of the engine):
|
|
20
|
+
|
|
21
|
+
1. **Duration** — the temp's container duration must match the original's
|
|
22
|
+
within :data:`DEFAULT_DURATION_TOLERANCE_SECONDS`. Re-encoding one audio
|
|
23
|
+
track and remuxing legitimately shifts the container duration by a few
|
|
24
|
+
codec-frame boundaries (tens of milliseconds — an observed AC3 remux of a
|
|
25
|
+
0.428s source came out 0.512s, ~84ms longer). The tolerance sits an order
|
|
26
|
+
of magnitude above that yet far below any real truncation/corruption,
|
|
27
|
+
which manifests as seconds-to-minutes. Erring *tight* is the safe
|
|
28
|
+
direction: a false reject merely wastes the remux and leaves the original
|
|
29
|
+
intact, whereas a false accept could swap in a corrupt file — so the
|
|
30
|
+
tolerance is chosen to comfortably admit legitimate remuxes and nothing
|
|
31
|
+
looser.
|
|
32
|
+
2. **Stream count** — the temp must have exactly ``original stream count +
|
|
33
|
+
added_track_count`` streams. All streams are counted (video/audio/
|
|
34
|
+
subtitle), so a remux that silently dropped or duplicated a stream is
|
|
35
|
+
caught even though its duration would look fine.
|
|
36
|
+
|
|
37
|
+
The swap itself uses :meth:`Path.rename` (``rename(2)``), which on POSIX
|
|
38
|
+
atomically replaces the destination in a single syscall. Collapsarr's target
|
|
39
|
+
deployment is POSIX (Linux/macOS/Docker, the *arr ecosystem), and COL-17
|
|
40
|
+
guarantees the temp file lives in the original's own directory — hence on the
|
|
41
|
+
same filesystem — so the rename never degrades to a non-atomic cross-device
|
|
42
|
+
copy. There is therefore never a moment where the original is partially
|
|
43
|
+
written or missing.
|
|
44
|
+
|
|
45
|
+
Mirrors the testing pattern of :mod:`collapsarr.downmix.remux`: real committed
|
|
46
|
+
fixture media under ``tests/fixtures/downmix/`` driven through the actual
|
|
47
|
+
ffmpeg/ffprobe binaries for the success path and each real failure path
|
|
48
|
+
(a genuinely truncated temp for duration, a genuinely stream-dropped temp for
|
|
49
|
+
stream count), plus an injectable ``runner`` for fast, binary-free unit tests.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import subprocess
|
|
55
|
+
from collections.abc import Callable, Sequence
|
|
56
|
+
from dataclasses import dataclass
|
|
57
|
+
from enum import Enum
|
|
58
|
+
from pathlib import Path
|
|
59
|
+
|
|
60
|
+
from collapsarr.downmix.probe import (
|
|
61
|
+
MediaSummary,
|
|
62
|
+
probe_media_summary,
|
|
63
|
+
)
|
|
64
|
+
from collapsarr.downmix.remux import RemuxResult
|
|
65
|
+
|
|
66
|
+
_DEFAULT_FFPROBE_PATH = "ffprobe"
|
|
67
|
+
_DEFAULT_TIMEOUT = 30.0
|
|
68
|
+
|
|
69
|
+
# See the module docstring for the full rationale. Sub-second, comfortably
|
|
70
|
+
# above the tens-of-ms shift a legitimate re-encode/remux introduces, far
|
|
71
|
+
# below any real truncation. Baked into the safety contract; override per-call
|
|
72
|
+
# only with good reason (and never loosen it lightly).
|
|
73
|
+
DEFAULT_DURATION_TOLERANCE_SECONDS = 0.5
|
|
74
|
+
|
|
75
|
+
_Runner = Callable[[Sequence[str], float], "subprocess.CompletedProcess[str]"]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ApplyFailureReason(Enum):
|
|
79
|
+
"""Why an :func:`apply_remux_result` validation refused to swap."""
|
|
80
|
+
|
|
81
|
+
DURATION_MISMATCH = "duration_mismatch"
|
|
82
|
+
STREAM_COUNT_MISMATCH = "stream_count_mismatch"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True, slots=True)
|
|
86
|
+
class ApplyResult:
|
|
87
|
+
"""Outcome of an :func:`apply_remux_result` attempt, shaped for job history.
|
|
88
|
+
|
|
89
|
+
On success ``applied_path`` is the original path (now holding the remuxed
|
|
90
|
+
file) and ``failure_reason`` is ``None``. On a validation failure
|
|
91
|
+
``applied_path`` is ``None``, ``failure_reason`` names the specific check
|
|
92
|
+
that failed, and — guaranteed — the original file is unchanged and the
|
|
93
|
+
temp file has been deleted. ``detail`` is always a human-readable summary
|
|
94
|
+
(the concrete durations/counts involved) suitable for logging.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
success: bool
|
|
98
|
+
applied_path: Path | None
|
|
99
|
+
failure_reason: ApplyFailureReason | None
|
|
100
|
+
detail: str
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def apply_remux_result(
|
|
104
|
+
original_path: str | Path,
|
|
105
|
+
remux_result: RemuxResult,
|
|
106
|
+
added_track_count: int,
|
|
107
|
+
*,
|
|
108
|
+
duration_tolerance_seconds: float = DEFAULT_DURATION_TOLERANCE_SECONDS,
|
|
109
|
+
ffprobe_path: str = _DEFAULT_FFPROBE_PATH,
|
|
110
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
111
|
+
runner: _Runner | None = None,
|
|
112
|
+
) -> ApplyResult:
|
|
113
|
+
"""Validate ``remux_result``'s temp file and atomically swap it over the original.
|
|
114
|
+
|
|
115
|
+
Re-probes both the (still-untouched) original and the temp file, then:
|
|
116
|
+
|
|
117
|
+
- if the temp's container duration is within
|
|
118
|
+
``duration_tolerance_seconds`` of the original's **and** its total
|
|
119
|
+
stream count equals the original's plus ``added_track_count``, renames
|
|
120
|
+
the temp over the original (atomic on POSIX / same filesystem) and
|
|
121
|
+
returns ``ApplyResult(success=True, ...)``;
|
|
122
|
+
- otherwise deletes the temp file, leaves the original byte-for-byte
|
|
123
|
+
untouched, and returns ``ApplyResult(success=False, ...)`` naming the
|
|
124
|
+
failing check.
|
|
125
|
+
|
|
126
|
+
The original is never modified on any path except the successful atomic
|
|
127
|
+
rename, and no backup copy of it is ever created — the temp file is the
|
|
128
|
+
only extra artifact, and it is gone by the time this returns.
|
|
129
|
+
|
|
130
|
+
``added_track_count`` is the number of new downmix tracks the remux was
|
|
131
|
+
asked to add (i.e. ``len(qualifying_targets)``); the original's own stream
|
|
132
|
+
count is measured fresh here rather than trusted from the caller.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
ValueError: ``remux_result`` did not succeed (so there is no temp
|
|
136
|
+
file to apply), or ``added_track_count`` is negative — both are
|
|
137
|
+
caller/programmer errors, mirroring
|
|
138
|
+
:func:`~collapsarr.downmix.remux.run_remux`'s use of ``ValueError``
|
|
139
|
+
for bad inputs.
|
|
140
|
+
collapsarr.downmix.probe.FfprobeError: either file could not be
|
|
141
|
+
probed (missing binary, timeout, non-zero exit, unparseable/
|
|
142
|
+
incomplete output). The temp file is deleted and the original left
|
|
143
|
+
untouched before the error propagates, so no orphan is left and no
|
|
144
|
+
unvalidated swap ever happens.
|
|
145
|
+
"""
|
|
146
|
+
if not remux_result.success or remux_result.temp_file_path is None:
|
|
147
|
+
raise ValueError(
|
|
148
|
+
"apply_remux_result requires a successful RemuxResult with a temp file"
|
|
149
|
+
)
|
|
150
|
+
if added_track_count < 0:
|
|
151
|
+
raise ValueError(f"added_track_count must not be negative, got {added_track_count}")
|
|
152
|
+
|
|
153
|
+
original = Path(original_path)
|
|
154
|
+
temp = remux_result.temp_file_path
|
|
155
|
+
|
|
156
|
+
# Any inability to *validate* must never green-light a swap. Probe errors
|
|
157
|
+
# therefore clean up the temp (no orphan, matching run_remux's contract)
|
|
158
|
+
# and propagate, leaving the original untouched.
|
|
159
|
+
try:
|
|
160
|
+
original_summary = probe_media_summary(
|
|
161
|
+
original, ffprobe_path=ffprobe_path, timeout=timeout, runner=runner
|
|
162
|
+
)
|
|
163
|
+
temp_summary = probe_media_summary(
|
|
164
|
+
temp, ffprobe_path=ffprobe_path, timeout=timeout, runner=runner
|
|
165
|
+
)
|
|
166
|
+
except BaseException:
|
|
167
|
+
_remove_if_exists(temp)
|
|
168
|
+
raise
|
|
169
|
+
|
|
170
|
+
duration_delta = abs(temp_summary.duration_seconds - original_summary.duration_seconds)
|
|
171
|
+
if duration_delta > duration_tolerance_seconds:
|
|
172
|
+
_remove_if_exists(temp)
|
|
173
|
+
return ApplyResult(
|
|
174
|
+
success=False,
|
|
175
|
+
applied_path=None,
|
|
176
|
+
failure_reason=ApplyFailureReason.DURATION_MISMATCH,
|
|
177
|
+
detail=_duration_mismatch_detail(
|
|
178
|
+
original_summary, temp_summary, duration_delta, duration_tolerance_seconds
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
expected_stream_count = original_summary.stream_count + added_track_count
|
|
183
|
+
if temp_summary.stream_count != expected_stream_count:
|
|
184
|
+
_remove_if_exists(temp)
|
|
185
|
+
return ApplyResult(
|
|
186
|
+
success=False,
|
|
187
|
+
applied_path=None,
|
|
188
|
+
failure_reason=ApplyFailureReason.STREAM_COUNT_MISMATCH,
|
|
189
|
+
detail=_stream_count_mismatch_detail(
|
|
190
|
+
original_summary, temp_summary, added_track_count, expected_stream_count
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Both checks passed: atomically replace the original. On POSIX / same
|
|
195
|
+
# filesystem this is a single rename(2) — no partial-write window.
|
|
196
|
+
temp.rename(original)
|
|
197
|
+
return ApplyResult(
|
|
198
|
+
success=True,
|
|
199
|
+
applied_path=original,
|
|
200
|
+
failure_reason=None,
|
|
201
|
+
detail=(
|
|
202
|
+
f"applied remux over {str(original)!r}: "
|
|
203
|
+
f"{original_summary.stream_count}->{temp_summary.stream_count} streams, "
|
|
204
|
+
f"duration {original_summary.duration_seconds:.3f}s->"
|
|
205
|
+
f"{temp_summary.duration_seconds:.3f}s "
|
|
206
|
+
f"(delta {duration_delta:.3f}s within {duration_tolerance_seconds:.3f}s)"
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _duration_mismatch_detail(
|
|
212
|
+
original: MediaSummary,
|
|
213
|
+
temp: MediaSummary,
|
|
214
|
+
delta: float,
|
|
215
|
+
tolerance: float,
|
|
216
|
+
) -> str:
|
|
217
|
+
return (
|
|
218
|
+
f"duration mismatch: original {original.duration_seconds:.3f}s vs "
|
|
219
|
+
f"remux {temp.duration_seconds:.3f}s (delta {delta:.3f}s exceeds "
|
|
220
|
+
f"tolerance {tolerance:.3f}s); original left untouched, temp discarded"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _stream_count_mismatch_detail(
|
|
225
|
+
original: MediaSummary,
|
|
226
|
+
temp: MediaSummary,
|
|
227
|
+
added_track_count: int,
|
|
228
|
+
expected: int,
|
|
229
|
+
) -> str:
|
|
230
|
+
return (
|
|
231
|
+
f"stream-count mismatch: remux has {temp.stream_count} streams, expected "
|
|
232
|
+
f"{expected} (original {original.stream_count} + {added_track_count} added); "
|
|
233
|
+
f"original left untouched, temp discarded"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _remove_if_exists(path: Path) -> None:
|
|
238
|
+
try:
|
|
239
|
+
path.unlink()
|
|
240
|
+
except FileNotFoundError:
|
|
241
|
+
pass
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""End-to-end single-file downmix pipeline (COL-19).
|
|
2
|
+
|
|
3
|
+
The demoable tracer bullet for the Downmix Engine: wires the four prior
|
|
4
|
+
modules — :mod:`~collapsarr.downmix.probe` (COL-15),
|
|
5
|
+
:mod:`~collapsarr.downmix.targets` (COL-16),
|
|
6
|
+
:mod:`~collapsarr.downmix.remux` (COL-17), and
|
|
7
|
+
:mod:`~collapsarr.downmix.apply` (COL-18) — into one callable pipeline for a
|
|
8
|
+
single file: probe -> detect qualifying targets -> (no-op if none) -> remux
|
|
9
|
+
-> validate/apply.
|
|
10
|
+
|
|
11
|
+
Enabled targets, the language allow-list, and codec/bitrate overrides come
|
|
12
|
+
from a caller-supplied :class:`~collapsarr.downmix.targets.DownmixSettings`.
|
|
13
|
+
There is no persisted Settings model yet (that's future work); this pipeline
|
|
14
|
+
simply takes one as an argument, the same way :func:`detect_qualifying_targets`
|
|
15
|
+
and :func:`run_remux` already do.
|
|
16
|
+
|
|
17
|
+
:func:`run_downmix_pipeline` never raises for a *runtime* failure at any
|
|
18
|
+
stage (ffprobe/ffmpeg missing, non-zero exit, timeout, or a failed
|
|
19
|
+
validate-and-apply) — every outcome, success or failure, comes back as one
|
|
20
|
+
:class:`PipelineResult` shaped for job history: which stage produced it
|
|
21
|
+
(:class:`PipelineOutcome`), the tracks added on success, and — on failure —
|
|
22
|
+
the underlying :class:`~collapsarr.downmix.remux.RemuxResult` or
|
|
23
|
+
:class:`~collapsarr.downmix.apply.ApplyResult` (whichever stage failed),
|
|
24
|
+
reusing their exit-code/stderr/failure-reason fields rather than
|
|
25
|
+
re-inventing them. A file with no qualifying targets is reported as a
|
|
26
|
+
distinct, non-error outcome (:attr:`PipelineOutcome.NOTHING_TO_DO`), never
|
|
27
|
+
as a failure.
|
|
28
|
+
|
|
29
|
+
Not yet wired into a job queue — that's COL-20's concern. This module is the
|
|
30
|
+
callable, single-file seam a job runner will later invoke per file.
|
|
31
|
+
|
|
32
|
+
Tests mirror the rest of the engine: real committed fixture media under
|
|
33
|
+
``tests/fixtures/downmix/`` driven through the actual ffmpeg/ffprobe binaries
|
|
34
|
+
end-to-end for the success, nothing-to-do, and real-ffmpeg-failure paths
|
|
35
|
+
(skipped when ffmpeg/ffprobe aren't installed), plus an injectable ``runner``
|
|
36
|
+
for fast, environment-independent unit tests of the stage-by-stage control
|
|
37
|
+
flow.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import subprocess
|
|
43
|
+
from collections.abc import Callable, Sequence
|
|
44
|
+
from dataclasses import dataclass
|
|
45
|
+
from enum import Enum
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
|
|
48
|
+
from collapsarr.downmix.apply import (
|
|
49
|
+
DEFAULT_DURATION_TOLERANCE_SECONDS,
|
|
50
|
+
ApplyResult,
|
|
51
|
+
apply_remux_result,
|
|
52
|
+
)
|
|
53
|
+
from collapsarr.downmix.probe import FfprobeError, probe_audio_streams
|
|
54
|
+
from collapsarr.downmix.remux import RemuxResult, run_remux
|
|
55
|
+
from collapsarr.downmix.targets import DownmixSettings, QualifyingTarget, detect_qualifying_targets
|
|
56
|
+
|
|
57
|
+
_DEFAULT_FFPROBE_PATH = "ffprobe"
|
|
58
|
+
_DEFAULT_FFMPEG_PATH = "ffmpeg"
|
|
59
|
+
_DEFAULT_PROBE_TIMEOUT = 30.0
|
|
60
|
+
_DEFAULT_REMUX_TIMEOUT = 3600.0
|
|
61
|
+
|
|
62
|
+
_Runner = Callable[[Sequence[str], float], "subprocess.CompletedProcess[str]"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class PipelineOutcome(Enum):
|
|
66
|
+
"""Which stage of the pipeline a :class:`PipelineResult` came from."""
|
|
67
|
+
|
|
68
|
+
SUCCESS = "success"
|
|
69
|
+
NOTHING_TO_DO = "nothing_to_do"
|
|
70
|
+
PROBE_FAILED = "probe_failed"
|
|
71
|
+
REMUX_FAILED = "remux_failed"
|
|
72
|
+
APPLY_FAILED = "apply_failed"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True, slots=True)
|
|
76
|
+
class PipelineResult:
|
|
77
|
+
"""Structured outcome of one end-to-end :func:`run_downmix_pipeline` run.
|
|
78
|
+
|
|
79
|
+
Shaped for job history. ``success`` is ``True`` only for
|
|
80
|
+
:attr:`PipelineOutcome.SUCCESS` — a no-op (nothing to do) is reported
|
|
81
|
+
separately via ``outcome`` rather than folded into either success or
|
|
82
|
+
failure, since it is neither: nothing was attempted, nothing failed.
|
|
83
|
+
|
|
84
|
+
``tracks_added`` is populated (non-empty) only on success — the
|
|
85
|
+
``(language, target)`` pairs that were added.
|
|
86
|
+
|
|
87
|
+
``remux_result``/``apply_result`` carry the raw result from whichever
|
|
88
|
+
stage ran, so a caller gets the ffmpeg exit code/stderr
|
|
89
|
+
(:class:`~collapsarr.downmix.remux.RemuxResult`) or the validation
|
|
90
|
+
failure reason/detail
|
|
91
|
+
(:class:`~collapsarr.downmix.apply.ApplyResult`) straight from the
|
|
92
|
+
source rather than this module re-deriving them. Both are ``None`` when
|
|
93
|
+
the pipeline never reached that stage (e.g. a probe failure, or nothing
|
|
94
|
+
to do).
|
|
95
|
+
|
|
96
|
+
``detail`` is always a human-readable one-line summary suitable for
|
|
97
|
+
logging, regardless of outcome.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
outcome: PipelineOutcome
|
|
101
|
+
success: bool
|
|
102
|
+
detail: str
|
|
103
|
+
tracks_added: tuple[QualifyingTarget, ...] = ()
|
|
104
|
+
remux_result: RemuxResult | None = None
|
|
105
|
+
apply_result: ApplyResult | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def run_downmix_pipeline(
|
|
109
|
+
file_path: str | Path,
|
|
110
|
+
settings: DownmixSettings,
|
|
111
|
+
*,
|
|
112
|
+
ffprobe_path: str = _DEFAULT_FFPROBE_PATH,
|
|
113
|
+
ffmpeg_path: str = _DEFAULT_FFMPEG_PATH,
|
|
114
|
+
probe_timeout: float = _DEFAULT_PROBE_TIMEOUT,
|
|
115
|
+
remux_timeout: float = _DEFAULT_REMUX_TIMEOUT,
|
|
116
|
+
duration_tolerance_seconds: float = DEFAULT_DURATION_TOLERANCE_SECONDS,
|
|
117
|
+
runner: _Runner | None = None,
|
|
118
|
+
) -> PipelineResult:
|
|
119
|
+
"""Run the full downmix pipeline for a single file, end to end.
|
|
120
|
+
|
|
121
|
+
In order: :func:`~collapsarr.downmix.probe.probe_audio_streams`,
|
|
122
|
+
:func:`~collapsarr.downmix.targets.detect_qualifying_targets`,
|
|
123
|
+
:func:`~collapsarr.downmix.remux.run_remux`, and
|
|
124
|
+
:func:`~collapsarr.downmix.apply.apply_remux_result` — stopping early
|
|
125
|
+
(and reporting why) as soon as a stage has nothing left to hand the
|
|
126
|
+
next one.
|
|
127
|
+
|
|
128
|
+
``runner`` overrides how every subprocess call across all three stages
|
|
129
|
+
is invoked (signature ``(command, timeout) -> subprocess.CompletedProcess``,
|
|
130
|
+
the same seam :func:`~collapsarr.downmix.probe.probe_audio_streams`,
|
|
131
|
+
:func:`~collapsarr.downmix.remux.run_remux`, and
|
|
132
|
+
:func:`~collapsarr.downmix.apply.apply_remux_result` each already
|
|
133
|
+
accept); it defaults to real subprocesses and exists so tests can drive
|
|
134
|
+
the whole pipeline's control flow without the ``ffmpeg``/``ffprobe``
|
|
135
|
+
binaries installed.
|
|
136
|
+
|
|
137
|
+
Never raises for a runtime failure at any stage:
|
|
138
|
+
|
|
139
|
+
- a probing failure (missing binary, non-zero exit, unparseable output)
|
|
140
|
+
is reported as :attr:`PipelineOutcome.PROBE_FAILED`;
|
|
141
|
+
- no qualifying targets is reported as
|
|
142
|
+
:attr:`PipelineOutcome.NOTHING_TO_DO` (``success=True`` — this is not
|
|
143
|
+
an error);
|
|
144
|
+
- an ffmpeg remux failure is reported as
|
|
145
|
+
:attr:`PipelineOutcome.REMUX_FAILED`, carrying the
|
|
146
|
+
:class:`~collapsarr.downmix.remux.RemuxResult` (exit code + stderr);
|
|
147
|
+
the original file is untouched (:func:`run_remux`'s own guarantee);
|
|
148
|
+
- a failed validate-and-apply (duration/stream-count mismatch, or the
|
|
149
|
+
temp/original becoming unprobeable after the remux) is reported as
|
|
150
|
+
:attr:`PipelineOutcome.APPLY_FAILED`, carrying the
|
|
151
|
+
:class:`~collapsarr.downmix.apply.ApplyResult` when one was produced;
|
|
152
|
+
the original file is untouched
|
|
153
|
+
(:func:`~collapsarr.downmix.apply.apply_remux_result`'s own
|
|
154
|
+
guarantee).
|
|
155
|
+
|
|
156
|
+
On success, the original file has been atomically replaced by the
|
|
157
|
+
remuxed version (all original streams intact, plus the newly added
|
|
158
|
+
downmix track(s)), and ``tracks_added`` lists what was added.
|
|
159
|
+
"""
|
|
160
|
+
path = Path(file_path)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
streams = probe_audio_streams(
|
|
164
|
+
path, ffprobe_path=ffprobe_path, timeout=probe_timeout, runner=runner
|
|
165
|
+
)
|
|
166
|
+
except FfprobeError as exc:
|
|
167
|
+
return PipelineResult(
|
|
168
|
+
outcome=PipelineOutcome.PROBE_FAILED,
|
|
169
|
+
success=False,
|
|
170
|
+
detail=f"failed to probe audio streams of {str(path)!r}: {exc}",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
targets = detect_qualifying_targets(streams, settings)
|
|
174
|
+
if not targets:
|
|
175
|
+
return PipelineResult(
|
|
176
|
+
outcome=PipelineOutcome.NOTHING_TO_DO,
|
|
177
|
+
success=True,
|
|
178
|
+
detail=f"no qualifying downmix targets for {str(path)!r}; nothing to do",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
remux_result = run_remux(
|
|
182
|
+
path,
|
|
183
|
+
streams,
|
|
184
|
+
targets,
|
|
185
|
+
settings,
|
|
186
|
+
ffmpeg_path=ffmpeg_path,
|
|
187
|
+
timeout=remux_timeout,
|
|
188
|
+
runner=runner,
|
|
189
|
+
)
|
|
190
|
+
if not remux_result.success:
|
|
191
|
+
return PipelineResult(
|
|
192
|
+
outcome=PipelineOutcome.REMUX_FAILED,
|
|
193
|
+
success=False,
|
|
194
|
+
detail=(
|
|
195
|
+
f"ffmpeg remux failed for {str(path)!r} "
|
|
196
|
+
f"(exit code {remux_result.returncode}): {remux_result.stderr.strip()}"
|
|
197
|
+
),
|
|
198
|
+
remux_result=remux_result,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
apply_result = apply_remux_result(
|
|
203
|
+
path,
|
|
204
|
+
remux_result,
|
|
205
|
+
added_track_count=len(targets),
|
|
206
|
+
duration_tolerance_seconds=duration_tolerance_seconds,
|
|
207
|
+
ffprobe_path=ffprobe_path,
|
|
208
|
+
timeout=probe_timeout,
|
|
209
|
+
runner=runner,
|
|
210
|
+
)
|
|
211
|
+
except FfprobeError as exc:
|
|
212
|
+
return PipelineResult(
|
|
213
|
+
outcome=PipelineOutcome.APPLY_FAILED,
|
|
214
|
+
success=False,
|
|
215
|
+
detail=f"failed to validate remux result for {str(path)!r}: {exc}",
|
|
216
|
+
remux_result=remux_result,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if not apply_result.success:
|
|
220
|
+
return PipelineResult(
|
|
221
|
+
outcome=PipelineOutcome.APPLY_FAILED,
|
|
222
|
+
success=False,
|
|
223
|
+
detail=apply_result.detail,
|
|
224
|
+
remux_result=remux_result,
|
|
225
|
+
apply_result=apply_result,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
return PipelineResult(
|
|
229
|
+
outcome=PipelineOutcome.SUCCESS,
|
|
230
|
+
success=True,
|
|
231
|
+
detail=apply_result.detail,
|
|
232
|
+
tracks_added=tuple(targets),
|
|
233
|
+
remux_result=remux_result,
|
|
234
|
+
apply_result=apply_result,
|
|
235
|
+
)
|