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,200 @@
1
+ """Inbound Sonarr/Radarr "on import"/"on upgrade" webhook handling (COL-14).
2
+
3
+ Sonarr and Radarr both fire the same webhook event -- ``eventType: "Download"``
4
+ -- for both a fresh import and a quality upgrade, distinguished only by the
5
+ ``isUpgrade`` flag on the payload. Other eventTypes (``Test``, ``Grab``,
6
+ ``Rename``, ``Health``, ...) are accepted and acknowledged with 200 but not
7
+ otherwise acted on -- that matches Sonarr/Radarr's own webhook UI, whose
8
+ "Test" button expects a 2xx response for any configured event, not just
9
+ Download.
10
+
11
+ Neither Sonarr's nor Radarr's webhook payload carries an instance identifier
12
+ of its own, so :mod:`collapsarr.main` routes webhooks per configured instance
13
+ (``POST /api/webhook/arr/{instance_id}``) and this module dispatches parsing
14
+ on that instance's ``type`` rather than trying to sniff the payload shape.
15
+
16
+ Path resolution reuses :func:`collapsarr.arr.models.resolve_path` -- the same
17
+ instance + path-mapping logic used elsewhere -- so a webhook-reported
18
+ container path arrives at the "file ready" hook already translated to a
19
+ host-local path.
20
+
21
+ The "file ready" hook itself is intentionally pluggable: this module ships a
22
+ stub/log implementation (:func:`default_on_file_ready_hook`), and the Job
23
+ Queue & Scheduler epic (COL-22) wires the real one --
24
+ :meth:`collapsarr.jobs.scheduler.JobScheduler.on_file_ready`, which enqueues a
25
+ downmix job -- in via ``create_app(enable_scheduler=True)``.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import logging
31
+ from collections.abc import Callable
32
+ from dataclasses import dataclass
33
+ from typing import Any
34
+
35
+ from .models import ArrInstance, InstanceType, RemotePathMapping, resolve_path
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ # The eventType Sonarr/Radarr fire on both a fresh import and an upgrade;
40
+ # distinguished only by the payload's `isUpgrade` flag.
41
+ _DOWNLOAD_EVENT = "Download"
42
+
43
+
44
+ class WebhookValidationError(ValueError):
45
+ """Raised when a webhook payload is malformed for its declared event type."""
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class WebhookFile:
50
+ """File info extracted from a webhook payload, before path resolution."""
51
+
52
+ media_title: str
53
+ file_path: str
54
+ is_upgrade: bool
55
+ source_file_id: int | None = None
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class ResolvedWebhookFile:
60
+ """A webhook-reported file after instance + path-mapping resolution.
61
+
62
+ Normalized the same way :class:`collapsarr.arr.files.MonitoredFile` is,
63
+ so downstream consumers (e.g. the future Job Queue) see one consistent
64
+ shape regardless of whether a file was discovered via polling or a
65
+ webhook.
66
+ """
67
+
68
+ instance_id: int
69
+ instance_name: str
70
+ media_title: str
71
+ file_path: str
72
+ is_upgrade: bool
73
+ source_file_id: int | None = None
74
+
75
+
76
+ OnFileReadyHook = Callable[[ResolvedWebhookFile], None]
77
+
78
+
79
+ def default_on_file_ready_hook(file: ResolvedWebhookFile) -> None:
80
+ """Stub "file ready" hook: logs the resolved file.
81
+
82
+ The default fallback when no scheduler is wired. In production the real
83
+ hook (:meth:`collapsarr.jobs.scheduler.JobScheduler.on_file_ready`, which
84
+ enqueues a downmix job) is wired in via ``create_app(enable_scheduler=True)``.
85
+ """
86
+ action = "upgraded" if file.is_upgrade else "imported"
87
+ logger.info(
88
+ "arr webhook: %s ready (%s) from instance %r (id=%s): %s",
89
+ action,
90
+ file.media_title,
91
+ file.instance_name,
92
+ file.instance_id,
93
+ file.file_path,
94
+ )
95
+
96
+
97
+ def _require_dict(value: object, field: str) -> dict[str, Any]:
98
+ if not isinstance(value, dict):
99
+ raise WebhookValidationError(f"Missing or invalid '{field}' object in webhook payload")
100
+ return value
101
+
102
+
103
+ def _require_str(container: dict[str, Any], field: str) -> str:
104
+ value = container.get(field)
105
+ if not isinstance(value, str) or not value:
106
+ raise WebhookValidationError(f"Missing or invalid '{field}' field in webhook payload")
107
+ return value
108
+
109
+
110
+ def _optional_int(container: dict[str, Any], field: str) -> int | None:
111
+ value = container.get(field)
112
+ return value if isinstance(value, int) else None
113
+
114
+
115
+ def parse_sonarr_webhook(payload: dict[str, Any]) -> WebhookFile | None:
116
+ """Parse a Sonarr webhook payload into a :class:`WebhookFile`.
117
+
118
+ Returns ``None`` for a valid payload whose ``eventType`` is not
119
+ ``"Download"`` (import/upgrade) -- those are acknowledged but not acted
120
+ on. Raises :class:`WebhookValidationError` if a ``Download`` event is
121
+ missing the ``series``/``episodeFile`` data needed to resolve a file.
122
+ """
123
+ event_type = payload.get("eventType")
124
+ if not isinstance(event_type, str) or not event_type:
125
+ raise WebhookValidationError("Missing or invalid 'eventType' field in webhook payload")
126
+ if event_type != _DOWNLOAD_EVENT:
127
+ return None
128
+
129
+ series = _require_dict(payload.get("series"), "series")
130
+ media_title = _require_str(series, "title")
131
+
132
+ episode_file = _require_dict(payload.get("episodeFile"), "episodeFile")
133
+ file_path = _require_str(episode_file, "path")
134
+
135
+ return WebhookFile(
136
+ media_title=media_title,
137
+ file_path=file_path,
138
+ is_upgrade=bool(payload.get("isUpgrade", False)),
139
+ source_file_id=_optional_int(episode_file, "id"),
140
+ )
141
+
142
+
143
+ def parse_radarr_webhook(payload: dict[str, Any]) -> WebhookFile | None:
144
+ """Parse a Radarr webhook payload into a :class:`WebhookFile`.
145
+
146
+ Mirror of :func:`parse_sonarr_webhook` for Radarr's ``movie``/``movieFile``
147
+ shape.
148
+ """
149
+ event_type = payload.get("eventType")
150
+ if not isinstance(event_type, str) or not event_type:
151
+ raise WebhookValidationError("Missing or invalid 'eventType' field in webhook payload")
152
+ if event_type != _DOWNLOAD_EVENT:
153
+ return None
154
+
155
+ movie = _require_dict(payload.get("movie"), "movie")
156
+ media_title = _require_str(movie, "title")
157
+
158
+ movie_file = _require_dict(payload.get("movieFile"), "movieFile")
159
+ file_path = _require_str(movie_file, "path")
160
+
161
+ return WebhookFile(
162
+ media_title=media_title,
163
+ file_path=file_path,
164
+ is_upgrade=bool(payload.get("isUpgrade", False)),
165
+ source_file_id=_optional_int(movie_file, "id"),
166
+ )
167
+
168
+
169
+ def parse_webhook_payload(
170
+ instance_type: InstanceType, payload: dict[str, Any]
171
+ ) -> WebhookFile | None:
172
+ """Dispatch webhook parsing based on the target instance's configured type.
173
+
174
+ The payload itself carries no instance identifier or explicit
175
+ "this is Sonarr/Radarr" marker, so which parser to use is decided by
176
+ which :class:`~collapsarr.arr.models.ArrInstance` the webhook URL names
177
+ (looked up by id), not by sniffing the payload shape.
178
+ """
179
+ if instance_type is InstanceType.SONARR:
180
+ return parse_sonarr_webhook(payload)
181
+ if instance_type is InstanceType.RADARR:
182
+ return parse_radarr_webhook(payload)
183
+ msg = f"Unsupported instance type: {instance_type!r}"
184
+ raise WebhookValidationError(msg) # pragma: no cover
185
+
186
+
187
+ def resolve_webhook_file(
188
+ instance: ArrInstance,
189
+ file: WebhookFile,
190
+ mappings: list[RemotePathMapping] | None = None,
191
+ ) -> ResolvedWebhookFile:
192
+ """Apply instance + path-mapping resolution to a parsed webhook file."""
193
+ return ResolvedWebhookFile(
194
+ instance_id=instance.id,
195
+ instance_name=instance.name,
196
+ media_title=file.media_title,
197
+ file_path=resolve_path(file.file_path, mappings),
198
+ is_upgrade=file.is_upgrade,
199
+ source_file_id=file.source_file_id,
200
+ )
collapsarr/auth.py ADDED
@@ -0,0 +1,83 @@
1
+ """API-key authentication for the HTTP API (COL-26).
2
+
3
+ Every request under the ``/api`` prefix must present the instance's
4
+ auto-generated API key, matching the Sonarr/Radarr convention: the
5
+ ``X-Api-Key`` request header (or, as a fallback for callers that can only set a
6
+ query string -- e.g. a Sonarr/Radarr webhook URL -- an ``apikey`` query
7
+ parameter). The key itself lives on the persisted
8
+ :class:`~collapsarr.settings.models.GlobalSettings` row and is minted on first
9
+ run (see :func:`collapsarr.settings.models.generate_api_key`), so it is
10
+ retrievable and rotatable through the same Settings surface as every other
11
+ setting.
12
+
13
+ Non-``/api`` routes (the ``/health`` liveness probe and the interactive API
14
+ docs) are intentionally left open -- ``/health`` is an unauthenticated probe by
15
+ the same convention Sonarr/Radarr's ``/ping`` follows.
16
+
17
+ The check is wired as HTTP middleware by
18
+ :func:`collapsarr.main.create_app`; it reads the expected key from the
19
+ request-scoped session factory on ``app.state`` and compares it in constant
20
+ time.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import secrets
26
+ from collections.abc import Awaitable, Callable
27
+
28
+ from fastapi import Request, Response
29
+ from fastapi.responses import JSONResponse
30
+ from sqlalchemy.orm import Session, sessionmaker
31
+
32
+ from .settings.service import get_global_settings
33
+
34
+ API_KEY_HEADER = "X-Api-Key"
35
+ """Request header carrying the API key (Sonarr/Radarr convention)."""
36
+
37
+ API_KEY_QUERY = "apikey"
38
+ """Query-parameter fallback for callers that cannot set a custom header."""
39
+
40
+ PROTECTED_PREFIX = "/api"
41
+ """Only routes under this path prefix require the API key."""
42
+
43
+
44
+ def _extract_key(request: Request) -> str | None:
45
+ """Return the presented API key from the header, then query string, if any."""
46
+ header = request.headers.get(API_KEY_HEADER)
47
+ if header:
48
+ return header
49
+ return request.query_params.get(API_KEY_QUERY) or None
50
+
51
+
52
+ def _requires_api_key(path: str) -> bool:
53
+ """Whether ``path`` is a protected API route (i.e. under ``/api``)."""
54
+ return path.startswith(PROTECTED_PREFIX)
55
+
56
+
57
+ async def api_key_middleware(
58
+ request: Request,
59
+ call_next: Callable[[Request], Awaitable[Response]],
60
+ ) -> Response:
61
+ """Reject ``/api`` requests lacking a valid API key with ``401``.
62
+
63
+ Non-API routes pass straight through. For API routes the expected key is
64
+ read from the singleton settings row (created on demand, so the very first
65
+ request already has a key to check against) and compared to the presented
66
+ one in constant time; a missing or mismatched key yields a ``401`` before
67
+ the route handler ever runs.
68
+ """
69
+ if not _requires_api_key(request.url.path):
70
+ return await call_next(request)
71
+
72
+ session_factory: sessionmaker[Session] = request.app.state.session_factory
73
+ with session_factory() as session:
74
+ expected: str = get_global_settings(session).api_key
75
+
76
+ provided = _extract_key(request)
77
+ if provided is None or not secrets.compare_digest(provided, expected):
78
+ return JSONResponse(
79
+ status_code=401,
80
+ content={"detail": "Invalid or missing API key."},
81
+ )
82
+
83
+ return await call_next(request)
collapsarr/config.py ADDED
@@ -0,0 +1,95 @@
1
+ """Application configuration.
2
+
3
+ All settings load from environment variables (prefix ``COLLAPSARR_``) with the
4
+ documented defaults below, so a bare ``docker run`` or ``pip install`` works
5
+ without any configuration. An optional ``.env`` file in the working directory is
6
+ also read (see ``.env.example``).
7
+
8
+ Documented environment variables and their defaults:
9
+
10
+ =============================== ========================== ==================================
11
+ Environment variable Default Description
12
+ =============================== ========================== ==================================
13
+ ``COLLAPSARR_DATABASE_PATH`` ``/config/collapsarr.db`` Filesystem path to the SQLite DB.
14
+ ``COLLAPSARR_DATABASE_URL`` *(derived from path)* Full SQLAlchemy URL override.
15
+ ``COLLAPSARR_HOST`` ``0.0.0.0`` Bind address for the API server.
16
+ ``COLLAPSARR_PORT`` ``8282`` Bind port for the API server.
17
+ ``COLLAPSARR_LOG_LEVEL`` ``INFO`` Log level (passed to uvicorn).
18
+ ``COLLAPSARR_JOB_MAX_CONCURRENCY`` ``1`` Max downmix jobs run concurrently.
19
+ ``COLLAPSARR_SCAN_INTERVAL_HOURS`` ``6.0`` Hours between periodic library scans.
20
+ =============================== ========================== ==================================
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from functools import lru_cache
26
+
27
+ from pydantic import Field
28
+ from pydantic_settings import BaseSettings, SettingsConfigDict
29
+
30
+
31
+ class Settings(BaseSettings):
32
+ """Runtime configuration sourced from the environment.
33
+
34
+ Values are read once at process start. Use :func:`get_settings` to obtain
35
+ the cached singleton; construct :class:`Settings` directly (e.g. in tests)
36
+ when you need an isolated, overridden configuration.
37
+ """
38
+
39
+ model_config = SettingsConfigDict(
40
+ env_prefix="COLLAPSARR_",
41
+ env_file=".env",
42
+ env_file_encoding="utf-8",
43
+ extra="ignore",
44
+ )
45
+
46
+ database_path: str = Field(
47
+ default="/config/collapsarr.db",
48
+ description="Filesystem path to the SQLite database file.",
49
+ )
50
+ database_url: str | None = Field(
51
+ default=None,
52
+ description="Full SQLAlchemy database URL. Overrides database_path when set.",
53
+ )
54
+
55
+ host: str = Field(default="0.0.0.0", description="API server bind address.")
56
+ port: int = Field(default=8282, description="API server bind port.")
57
+ log_level: str = Field(default="INFO", description="Log level for the server.")
58
+ job_max_concurrency: int = Field(
59
+ default=1,
60
+ ge=1,
61
+ description=(
62
+ "Maximum number of downmix jobs the job queue (collapsarr.jobs) runs "
63
+ "concurrently. Read by JobQueue.from_settings()."
64
+ ),
65
+ )
66
+ scan_interval_hours: float = Field(
67
+ default=6.0,
68
+ gt=0,
69
+ description=(
70
+ "Interval, in hours, between periodic full-library scans that enqueue "
71
+ "downmix jobs for monitored files with qualifying targets. Read by the "
72
+ "background scheduler (collapsarr.jobs.scheduler.JobScheduler), which "
73
+ "also uses it as the de-duplication window: a file whose most recent job "
74
+ "reached a terminal state within this window is not re-enqueued, so a "
75
+ "webhook and a scheduled scan overlapping within one cycle can't "
76
+ "double-enqueue it."
77
+ ),
78
+ )
79
+
80
+ @property
81
+ def sqlalchemy_url(self) -> str:
82
+ """Resolve the effective SQLAlchemy database URL.
83
+
84
+ Uses ``database_url`` verbatim when provided, otherwise builds a SQLite
85
+ URL from ``database_path``.
86
+ """
87
+ if self.database_url:
88
+ return self.database_url
89
+ return f"sqlite:///{self.database_path}"
90
+
91
+
92
+ @lru_cache(maxsize=1)
93
+ def get_settings() -> Settings:
94
+ """Return the process-wide cached :class:`Settings` instance."""
95
+ return Settings()
collapsarr/database.py ADDED
@@ -0,0 +1,82 @@
1
+ """Database engine, session factory, and ORM base.
2
+
3
+ SQLAlchemy 2.0 style. The engine and session factory are built from
4
+ :class:`~collapsarr.config.Settings` so tests can point them at a throwaway
5
+ SQLite path. Both are attached to ``app.state`` in the application factory and
6
+ consumed by request handlers via the :func:`get_session` dependency.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterator
12
+ from pathlib import Path
13
+
14
+ from fastapi import Request
15
+ from sqlalchemy import Engine, create_engine
16
+ from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
17
+
18
+ from .config import Settings
19
+
20
+
21
+ class Base(DeclarativeBase):
22
+ """Declarative base class shared by all Collapsarr ORM models.
23
+
24
+ Feature tickets (instances, tracked media, job queue, settings) define their
25
+ tables against this base so a single ``Base.metadata`` drives schema
26
+ creation.
27
+ """
28
+
29
+
30
+ def create_engine_from_settings(settings: Settings) -> Engine:
31
+ """Create a SQLAlchemy :class:`Engine` from application settings.
32
+
33
+ For file-based SQLite URLs the parent directory is created if missing (the
34
+ ``/config`` volume in the Docker image), and ``check_same_thread`` is
35
+ disabled so the connection can be shared across FastAPI's worker threads.
36
+ """
37
+ url = settings.sqlalchemy_url
38
+ connect_args: dict[str, object] = {}
39
+
40
+ if url.startswith("sqlite"):
41
+ connect_args["check_same_thread"] = False
42
+ # Ensure the directory for a file-based SQLite DB exists.
43
+ if settings.database_url is None and settings.database_path != ":memory:":
44
+ db_path = Path(settings.database_path).expanduser()
45
+ db_path.parent.mkdir(parents=True, exist_ok=True)
46
+
47
+ return create_engine(url, connect_args=connect_args, future=True)
48
+
49
+
50
+ def create_session_factory(engine: Engine) -> sessionmaker[Session]:
51
+ """Build a configured :class:`sessionmaker` bound to ``engine``."""
52
+ return sessionmaker(
53
+ bind=engine,
54
+ autoflush=False,
55
+ autocommit=False,
56
+ expire_on_commit=False,
57
+ )
58
+
59
+
60
+ def init_db(engine: Engine) -> None:
61
+ """Create any tables registered on :class:`Base` that do not yet exist.
62
+
63
+ Feature packages (e.g. :mod:`collapsarr.arr`) are imported here, not at
64
+ module scope, so their models register with ``Base.metadata`` before
65
+ ``create_all`` runs without introducing an import-time cycle back to this
66
+ module (they import :class:`Base` from here).
67
+ """
68
+ from . import arr, jobs, media, notify, settings # noqa: F401
69
+
70
+ Base.metadata.create_all(bind=engine)
71
+
72
+
73
+ def get_session(request: Request) -> Iterator[Session]:
74
+ """FastAPI dependency yielding a request-scoped :class:`Session`.
75
+
76
+ The session factory is read from ``app.state`` (set up in the application
77
+ factory's lifespan), so each request gets a fresh session that is closed
78
+ when the request finishes.
79
+ """
80
+ session_factory: sessionmaker[Session] = request.app.state.session_factory
81
+ with session_factory() as session:
82
+ yield session
@@ -0,0 +1,54 @@
1
+ """Downmix engine: channel-layout detection, target selection, FFmpeg remux (COL-15+).
2
+
3
+ Home for downmix-engine concerns per ``docs/TRACKER.md``: FFprobe-based
4
+ audio-stream inspection, no-upmix target detection, the FFmpeg remux itself,
5
+ and validate + atomic swap.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .apply import (
11
+ DEFAULT_DURATION_TOLERANCE_SECONDS,
12
+ ApplyFailureReason,
13
+ ApplyResult,
14
+ apply_remux_result,
15
+ )
16
+ from .pipeline import PipelineOutcome, PipelineResult, run_downmix_pipeline
17
+ from .probe import (
18
+ AudioStreamInfo,
19
+ FfprobeError,
20
+ FfprobeNotFoundError,
21
+ MediaSummary,
22
+ probe_audio_streams,
23
+ probe_media_summary,
24
+ )
25
+ from .remux import RemuxResult, build_remux_command, run_remux
26
+ from .targets import (
27
+ DownmixSettings,
28
+ DownmixTarget,
29
+ QualifyingTarget,
30
+ detect_qualifying_targets,
31
+ )
32
+
33
+ __all__ = [
34
+ "DEFAULT_DURATION_TOLERANCE_SECONDS",
35
+ "ApplyFailureReason",
36
+ "ApplyResult",
37
+ "AudioStreamInfo",
38
+ "DownmixSettings",
39
+ "DownmixTarget",
40
+ "FfprobeError",
41
+ "FfprobeNotFoundError",
42
+ "MediaSummary",
43
+ "PipelineOutcome",
44
+ "PipelineResult",
45
+ "QualifyingTarget",
46
+ "RemuxResult",
47
+ "apply_remux_result",
48
+ "build_remux_command",
49
+ "detect_qualifying_targets",
50
+ "probe_audio_streams",
51
+ "probe_media_summary",
52
+ "run_downmix_pipeline",
53
+ "run_remux",
54
+ ]