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,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
|
+
]
|