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.
Files changed (52) hide show
  1. collapsarr/__init__.py +15 -0
  2. collapsarr/__main__.py +25 -0
  3. collapsarr/arr/__init__.py +76 -0
  4. collapsarr/arr/client.py +71 -0
  5. collapsarr/arr/files.py +211 -0
  6. collapsarr/arr/models.py +147 -0
  7. collapsarr/arr/routes.py +290 -0
  8. collapsarr/arr/service.py +222 -0
  9. collapsarr/arr/webhooks.py +200 -0
  10. collapsarr/auth.py +83 -0
  11. collapsarr/config.py +95 -0
  12. collapsarr/database.py +82 -0
  13. collapsarr/downmix/__init__.py +54 -0
  14. collapsarr/downmix/apply.py +241 -0
  15. collapsarr/downmix/pipeline.py +235 -0
  16. collapsarr/downmix/probe.py +310 -0
  17. collapsarr/downmix/remux.py +284 -0
  18. collapsarr/downmix/targets.py +145 -0
  19. collapsarr/frontend.py +61 -0
  20. collapsarr/health.py +111 -0
  21. collapsarr/jobs/__init__.py +49 -0
  22. collapsarr/jobs/failure_notify.py +145 -0
  23. collapsarr/jobs/history.py +153 -0
  24. collapsarr/jobs/models.py +90 -0
  25. collapsarr/jobs/queue.py +382 -0
  26. collapsarr/jobs/routes.py +193 -0
  27. collapsarr/jobs/scheduler.py +402 -0
  28. collapsarr/main.py +212 -0
  29. collapsarr/media/__init__.py +38 -0
  30. collapsarr/media/models.py +132 -0
  31. collapsarr/media/routes.py +88 -0
  32. collapsarr/media/service.py +241 -0
  33. collapsarr/notify/__init__.py +37 -0
  34. collapsarr/notify/dispatch.py +162 -0
  35. collapsarr/notify/models.py +63 -0
  36. collapsarr/notify/routes.py +105 -0
  37. collapsarr/notify/service.py +80 -0
  38. collapsarr/settings/__init__.py +29 -0
  39. collapsarr/settings/models.py +107 -0
  40. collapsarr/settings/routes.py +157 -0
  41. collapsarr/settings/service.py +158 -0
  42. collapsarr/static/apple-touch-icon.png +0 -0
  43. collapsarr/static/assets/index-Dfozi1oM.js +68 -0
  44. collapsarr/static/assets/index-J2qCuzXj.css +1 -0
  45. collapsarr/static/favicon-32.png +0 -0
  46. collapsarr/static/favicon.svg +10 -0
  47. collapsarr/static/index.html +18 -0
  48. collapsarr-0.1.3.dist-info/METADATA +218 -0
  49. collapsarr-0.1.3.dist-info/RECORD +52 -0
  50. collapsarr-0.1.3.dist-info/WHEEL +4 -0
  51. collapsarr-0.1.3.dist-info/entry_points.txt +2 -0
  52. 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
+ )