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
collapsarr/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Collapsarr — an *arr-family companion app.
|
|
2
|
+
|
|
3
|
+
Detects monitored media files that are missing a lower-channel-count audio
|
|
4
|
+
track and adds one via FFmpeg, without ever touching the original track.
|
|
5
|
+
|
|
6
|
+
This package hosts the FastAPI backend. The public entry points are
|
|
7
|
+
:func:`collapsarr.main.create_app` (the application factory) and the
|
|
8
|
+
module-level ``app`` instance it builds.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
__all__ = ["__version__"]
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
collapsarr/__main__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Console entry point: ``python -m collapsarr`` (and the ``collapsarr`` script).
|
|
2
|
+
|
|
3
|
+
Starts the ASGI server bound to the configured host/port.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .config import get_settings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
"""Run the Collapsarr API server using the configured host and port."""
|
|
13
|
+
import uvicorn
|
|
14
|
+
|
|
15
|
+
settings = get_settings()
|
|
16
|
+
uvicorn.run(
|
|
17
|
+
"collapsarr.main:app",
|
|
18
|
+
host=settings.host,
|
|
19
|
+
port=settings.port,
|
|
20
|
+
log_level=settings.log_level.lower(),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
main()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Sonarr/Radarr instance connection management (COL-11, COL-12, COL-13, COL-14).
|
|
2
|
+
|
|
3
|
+
Home for arr-integration concerns per ``docs/TRACKER.md``: instance config
|
|
4
|
+
model, connectivity/version checks, the monitored-file-list client, remote
|
|
5
|
+
path mapping, and the inbound import/upgrade webhook handler.
|
|
6
|
+
|
|
7
|
+
This module is imported for its side effect of registering
|
|
8
|
+
:class:`~collapsarr.arr.models.ArrInstance` with
|
|
9
|
+
:data:`collapsarr.database.Base.metadata` — see
|
|
10
|
+
:func:`collapsarr.database.init_db`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .client import ConnectivityResult, check_connectivity
|
|
16
|
+
from .files import AudioInfo, MonitoredFile, fetch_monitored_files
|
|
17
|
+
from .models import ArrInstance, ConnectivityStatus, InstanceType, RemotePathMapping, resolve_path
|
|
18
|
+
from .service import (
|
|
19
|
+
InstanceNotFoundError,
|
|
20
|
+
PathMappingNotFoundError,
|
|
21
|
+
create_instance,
|
|
22
|
+
create_path_mapping,
|
|
23
|
+
delete_instance,
|
|
24
|
+
delete_path_mapping,
|
|
25
|
+
get_instance,
|
|
26
|
+
get_path_mapping,
|
|
27
|
+
list_instances,
|
|
28
|
+
list_path_mappings,
|
|
29
|
+
update_instance,
|
|
30
|
+
update_path_mapping,
|
|
31
|
+
)
|
|
32
|
+
from .webhooks import (
|
|
33
|
+
OnFileReadyHook,
|
|
34
|
+
ResolvedWebhookFile,
|
|
35
|
+
WebhookFile,
|
|
36
|
+
WebhookValidationError,
|
|
37
|
+
default_on_file_ready_hook,
|
|
38
|
+
parse_radarr_webhook,
|
|
39
|
+
parse_sonarr_webhook,
|
|
40
|
+
parse_webhook_payload,
|
|
41
|
+
resolve_webhook_file,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"ArrInstance",
|
|
46
|
+
"AudioInfo",
|
|
47
|
+
"ConnectivityResult",
|
|
48
|
+
"ConnectivityStatus",
|
|
49
|
+
"InstanceNotFoundError",
|
|
50
|
+
"InstanceType",
|
|
51
|
+
"MonitoredFile",
|
|
52
|
+
"OnFileReadyHook",
|
|
53
|
+
"PathMappingNotFoundError",
|
|
54
|
+
"RemotePathMapping",
|
|
55
|
+
"ResolvedWebhookFile",
|
|
56
|
+
"WebhookFile",
|
|
57
|
+
"WebhookValidationError",
|
|
58
|
+
"check_connectivity",
|
|
59
|
+
"create_instance",
|
|
60
|
+
"create_path_mapping",
|
|
61
|
+
"default_on_file_ready_hook",
|
|
62
|
+
"delete_instance",
|
|
63
|
+
"delete_path_mapping",
|
|
64
|
+
"fetch_monitored_files",
|
|
65
|
+
"get_instance",
|
|
66
|
+
"get_path_mapping",
|
|
67
|
+
"list_instances",
|
|
68
|
+
"list_path_mappings",
|
|
69
|
+
"parse_radarr_webhook",
|
|
70
|
+
"parse_sonarr_webhook",
|
|
71
|
+
"parse_webhook_payload",
|
|
72
|
+
"resolve_path",
|
|
73
|
+
"resolve_webhook_file",
|
|
74
|
+
"update_instance",
|
|
75
|
+
"update_path_mapping",
|
|
76
|
+
]
|
collapsarr/arr/client.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""HTTP client for validating connectivity to a Sonarr/Radarr instance.
|
|
2
|
+
|
|
3
|
+
Sonarr and Radarr expose an identical ``/api/v3/system/status`` endpoint
|
|
4
|
+
(authenticated via the ``X-Api-Key`` header) that returns instance metadata
|
|
5
|
+
including its running version. :func:`check_connectivity` calls it and
|
|
6
|
+
reports success/failure without ever raising, so callers can persist the
|
|
7
|
+
result unconditionally.
|
|
8
|
+
|
|
9
|
+
Tests inject a ``transport`` (``httpx.MockTransport``) built from recorded
|
|
10
|
+
fixture responses instead of making real network calls.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
_STATUS_PATH = "/api/v3/system/status"
|
|
20
|
+
_DEFAULT_TIMEOUT = 10.0
|
|
21
|
+
_ERROR_BODY_LIMIT = 500
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class ConnectivityResult:
|
|
26
|
+
"""Outcome of a connectivity/version check against an Arr instance."""
|
|
27
|
+
|
|
28
|
+
ok: bool
|
|
29
|
+
version: str | None = None
|
|
30
|
+
error: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check_connectivity(
|
|
34
|
+
base_url: str,
|
|
35
|
+
api_key: str,
|
|
36
|
+
*,
|
|
37
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
38
|
+
transport: httpx.BaseTransport | None = None,
|
|
39
|
+
) -> ConnectivityResult:
|
|
40
|
+
"""Call the instance's system/status endpoint and report the result.
|
|
41
|
+
|
|
42
|
+
Never raises: network errors, timeouts, non-2xx responses, and malformed
|
|
43
|
+
payloads are all captured as a failed :class:`ConnectivityResult` so the
|
|
44
|
+
service layer can persist success/failure state without a try/except.
|
|
45
|
+
"""
|
|
46
|
+
url = f"{base_url.rstrip('/')}{_STATUS_PATH}"
|
|
47
|
+
if transport is not None:
|
|
48
|
+
client = httpx.Client(timeout=timeout, transport=transport)
|
|
49
|
+
else:
|
|
50
|
+
client = httpx.Client(timeout=timeout)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
with client:
|
|
54
|
+
response = client.get(url, headers={"X-Api-Key": api_key})
|
|
55
|
+
response.raise_for_status()
|
|
56
|
+
except httpx.HTTPStatusError as exc:
|
|
57
|
+
detail = f"HTTP {exc.response.status_code}: {exc.response.text}"[:_ERROR_BODY_LIMIT]
|
|
58
|
+
return ConnectivityResult(ok=False, error=detail)
|
|
59
|
+
except httpx.HTTPError as exc:
|
|
60
|
+
return ConnectivityResult(ok=False, error=str(exc))
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
payload = response.json()
|
|
64
|
+
except ValueError:
|
|
65
|
+
return ConnectivityResult(ok=False, error="Invalid JSON in status response")
|
|
66
|
+
|
|
67
|
+
version = payload.get("version") if isinstance(payload, dict) else None
|
|
68
|
+
if not isinstance(version, str) or not version:
|
|
69
|
+
return ConnectivityResult(ok=False, error="Status response missing 'version' field")
|
|
70
|
+
|
|
71
|
+
return ConnectivityResult(ok=True, version=version)
|
collapsarr/arr/files.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Pull the monitored media-file list from a configured Sonarr/Radarr instance.
|
|
2
|
+
|
|
3
|
+
Per the PRD, Collapsarr discovers work by pulling *monitored* file lists from
|
|
4
|
+
the Arr APIs rather than scanning arbitrary folders itself. Sonarr and Radarr
|
|
5
|
+
expose that information differently:
|
|
6
|
+
|
|
7
|
+
- Sonarr has no single "all monitored episode files" endpoint. Series are
|
|
8
|
+
fetched via ``GET /api/v3/series`` (each with a ``monitored`` flag), and for
|
|
9
|
+
every *monitored* series its files are fetched via
|
|
10
|
+
``GET /api/v3/episodefile?seriesId=<id>``.
|
|
11
|
+
- Radarr's ``GET /api/v3/movie`` returns every movie in one call, each with
|
|
12
|
+
``monitored``/``hasFile`` flags and (when present) an embedded
|
|
13
|
+
``movieFile`` object — no second request needed.
|
|
14
|
+
|
|
15
|
+
Both variants are normalized to the same :class:`MonitoredFile` shape and
|
|
16
|
+
reached through the single :func:`fetch_monitored_files` entry point, which
|
|
17
|
+
dispatches on :attr:`~collapsarr.arr.models.ArrInstance.type` so callers don't
|
|
18
|
+
need to special-case Sonarr vs. Radarr.
|
|
19
|
+
|
|
20
|
+
Audio metadata is taken from the Arr APIs' ``mediaInfo`` block, which reports
|
|
21
|
+
*aggregate* fields (codec, total channel count, language list, stream count)
|
|
22
|
+
rather than a per-stream breakdown — that is the granularity normalized into
|
|
23
|
+
:class:`AudioInfo`. A per-stream probe (e.g. via ffprobe) is out of scope here.
|
|
24
|
+
|
|
25
|
+
Unlike :func:`collapsarr.arr.client.check_connectivity`, which never raises so
|
|
26
|
+
a connectivity outcome can always be persisted, this module lets
|
|
27
|
+
``httpx.HTTPError`` propagate: a failed fetch has no sensible default (an
|
|
28
|
+
empty list would be indistinguishable from "no monitored files"), so callers
|
|
29
|
+
decide how to handle it.
|
|
30
|
+
|
|
31
|
+
Tests inject a ``transport`` (``httpx.MockTransport``) built from recorded
|
|
32
|
+
fixture responses instead of making real network calls.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
|
|
39
|
+
import httpx
|
|
40
|
+
|
|
41
|
+
from .models import ArrInstance, InstanceType
|
|
42
|
+
|
|
43
|
+
_SERIES_PATH = "/api/v3/series"
|
|
44
|
+
_EPISODE_FILE_PATH = "/api/v3/episodefile"
|
|
45
|
+
_MOVIE_PATH = "/api/v3/movie"
|
|
46
|
+
_DEFAULT_TIMEOUT = 10.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class AudioInfo:
|
|
51
|
+
"""Aggregate audio-stream metadata as exposed by an Arr API's ``mediaInfo``.
|
|
52
|
+
|
|
53
|
+
All fields are ``None`` when the underlying file has no ``mediaInfo``
|
|
54
|
+
block, or when a given sub-field wasn't present/wasn't of the expected
|
|
55
|
+
type.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
codec: str | None = None
|
|
59
|
+
channels: float | None = None
|
|
60
|
+
languages: str | None = None
|
|
61
|
+
stream_count: int | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class MonitoredFile:
|
|
66
|
+
"""A single monitored media file, normalized across Sonarr and Radarr."""
|
|
67
|
+
|
|
68
|
+
instance_id: int
|
|
69
|
+
media_title: str
|
|
70
|
+
file_path: str
|
|
71
|
+
source_file_id: int | None = None
|
|
72
|
+
audio: AudioInfo | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def fetch_monitored_files(
|
|
76
|
+
instance: ArrInstance,
|
|
77
|
+
*,
|
|
78
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
79
|
+
transport: httpx.BaseTransport | None = None,
|
|
80
|
+
) -> list[MonitoredFile]:
|
|
81
|
+
"""Fetch the normalized monitored-file list for a configured instance.
|
|
82
|
+
|
|
83
|
+
Dispatches to the Sonarr or Radarr variant based on ``instance.type`` —
|
|
84
|
+
both return the same :class:`MonitoredFile` shape.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
httpx.HTTPError: on a network failure or a non-2xx response from the
|
|
88
|
+
instance. This function does not swallow errors the way
|
|
89
|
+
:func:`collapsarr.arr.client.check_connectivity` does.
|
|
90
|
+
"""
|
|
91
|
+
if instance.type is InstanceType.SONARR:
|
|
92
|
+
return _fetch_sonarr_files(instance, timeout=timeout, transport=transport)
|
|
93
|
+
if instance.type is InstanceType.RADARR:
|
|
94
|
+
return _fetch_radarr_files(instance, timeout=timeout, transport=transport)
|
|
95
|
+
raise ValueError(f"Unsupported instance type: {instance.type!r}") # pragma: no cover
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _build_client(timeout: float, transport: httpx.BaseTransport | None) -> httpx.Client:
|
|
99
|
+
if transport is not None:
|
|
100
|
+
return httpx.Client(timeout=timeout, transport=transport)
|
|
101
|
+
return httpx.Client(timeout=timeout)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _extract_audio_info(media_info: object) -> AudioInfo | None:
|
|
105
|
+
"""Normalize an Arr ``mediaInfo`` block into :class:`AudioInfo`, or ``None``."""
|
|
106
|
+
if not isinstance(media_info, dict):
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
codec = media_info.get("audioCodec")
|
|
110
|
+
channels = media_info.get("audioChannels")
|
|
111
|
+
languages = media_info.get("audioLanguages")
|
|
112
|
+
stream_count = media_info.get("audioStreamCount")
|
|
113
|
+
|
|
114
|
+
return AudioInfo(
|
|
115
|
+
codec=codec if isinstance(codec, str) else None,
|
|
116
|
+
channels=float(channels) if isinstance(channels, int | float) else None,
|
|
117
|
+
languages=languages if isinstance(languages, str) else None,
|
|
118
|
+
stream_count=stream_count if isinstance(stream_count, int) else None,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _fetch_sonarr_files(
|
|
123
|
+
instance: ArrInstance, *, timeout: float, transport: httpx.BaseTransport | None
|
|
124
|
+
) -> list[MonitoredFile]:
|
|
125
|
+
base_url = instance.base_url.rstrip("/")
|
|
126
|
+
headers = {"X-Api-Key": instance.api_key}
|
|
127
|
+
results: list[MonitoredFile] = []
|
|
128
|
+
|
|
129
|
+
with _build_client(timeout, transport) as client:
|
|
130
|
+
series_response = client.get(f"{base_url}{_SERIES_PATH}", headers=headers)
|
|
131
|
+
series_response.raise_for_status()
|
|
132
|
+
series_list = series_response.json()
|
|
133
|
+
if not isinstance(series_list, list):
|
|
134
|
+
return results
|
|
135
|
+
|
|
136
|
+
for series in series_list:
|
|
137
|
+
if not isinstance(series, dict) or not series.get("monitored"):
|
|
138
|
+
continue
|
|
139
|
+
series_id = series.get("id")
|
|
140
|
+
series_title = series.get("title")
|
|
141
|
+
if series_id is None or not isinstance(series_title, str):
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
files_response = client.get(
|
|
145
|
+
f"{base_url}{_EPISODE_FILE_PATH}",
|
|
146
|
+
params={"seriesId": series_id},
|
|
147
|
+
headers=headers,
|
|
148
|
+
)
|
|
149
|
+
files_response.raise_for_status()
|
|
150
|
+
episode_files = files_response.json()
|
|
151
|
+
if not isinstance(episode_files, list):
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
for episode_file in episode_files:
|
|
155
|
+
if not isinstance(episode_file, dict):
|
|
156
|
+
continue
|
|
157
|
+
path = episode_file.get("path")
|
|
158
|
+
if not isinstance(path, str) or not path:
|
|
159
|
+
continue
|
|
160
|
+
file_id = episode_file.get("id")
|
|
161
|
+
results.append(
|
|
162
|
+
MonitoredFile(
|
|
163
|
+
instance_id=instance.id,
|
|
164
|
+
media_title=series_title,
|
|
165
|
+
file_path=path,
|
|
166
|
+
source_file_id=file_id if isinstance(file_id, int) else None,
|
|
167
|
+
audio=_extract_audio_info(episode_file.get("mediaInfo")),
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return results
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _fetch_radarr_files(
|
|
175
|
+
instance: ArrInstance, *, timeout: float, transport: httpx.BaseTransport | None
|
|
176
|
+
) -> list[MonitoredFile]:
|
|
177
|
+
base_url = instance.base_url.rstrip("/")
|
|
178
|
+
headers = {"X-Api-Key": instance.api_key}
|
|
179
|
+
results: list[MonitoredFile] = []
|
|
180
|
+
|
|
181
|
+
with _build_client(timeout, transport) as client:
|
|
182
|
+
response = client.get(f"{base_url}{_MOVIE_PATH}", headers=headers)
|
|
183
|
+
response.raise_for_status()
|
|
184
|
+
movies = response.json()
|
|
185
|
+
if not isinstance(movies, list):
|
|
186
|
+
return results
|
|
187
|
+
|
|
188
|
+
for movie in movies:
|
|
189
|
+
if not isinstance(movie, dict):
|
|
190
|
+
continue
|
|
191
|
+
if not movie.get("monitored") or not movie.get("hasFile"):
|
|
192
|
+
continue
|
|
193
|
+
movie_file = movie.get("movieFile")
|
|
194
|
+
if not isinstance(movie_file, dict):
|
|
195
|
+
continue
|
|
196
|
+
path = movie_file.get("path")
|
|
197
|
+
title = movie.get("title")
|
|
198
|
+
if not isinstance(path, str) or not path or not isinstance(title, str):
|
|
199
|
+
continue
|
|
200
|
+
file_id = movie_file.get("id")
|
|
201
|
+
results.append(
|
|
202
|
+
MonitoredFile(
|
|
203
|
+
instance_id=instance.id,
|
|
204
|
+
media_title=title,
|
|
205
|
+
file_path=path,
|
|
206
|
+
source_file_id=file_id if isinstance(file_id, int) else None,
|
|
207
|
+
audio=_extract_audio_info(movie_file.get("mediaInfo")),
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
return results
|
collapsarr/arr/models.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""ORM model for a configured Sonarr/Radarr instance connection.
|
|
2
|
+
|
|
3
|
+
Each :class:`ArrInstance` row is a single Sonarr or Radarr connection: a name,
|
|
4
|
+
its type, where to reach it, and the API key to authenticate with. Instances
|
|
5
|
+
of either type coexist independently — nothing here assumes at most one
|
|
6
|
+
Sonarr or one Radarr.
|
|
7
|
+
|
|
8
|
+
Connectivity is not verified at the model layer; :mod:`collapsarr.arr.service`
|
|
9
|
+
calls :mod:`collapsarr.arr.client` on save and stores the outcome in the
|
|
10
|
+
``status``/``status_error``/``version``/``status_checked_at`` columns.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import enum
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
|
|
18
|
+
from sqlalchemy import Enum as SAEnum
|
|
19
|
+
from sqlalchemy import ForeignKey, Integer, String, Text, UniqueConstraint
|
|
20
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
21
|
+
|
|
22
|
+
from collapsarr.database import Base
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InstanceType(enum.StrEnum):
|
|
26
|
+
"""The two supported *arr backends."""
|
|
27
|
+
|
|
28
|
+
SONARR = "sonarr"
|
|
29
|
+
RADARR = "radarr"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConnectivityStatus(enum.StrEnum):
|
|
33
|
+
"""Outcome of the most recent connectivity/version check for an instance."""
|
|
34
|
+
|
|
35
|
+
UNKNOWN = "unknown"
|
|
36
|
+
OK = "ok"
|
|
37
|
+
ERROR = "error"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _utcnow() -> datetime:
|
|
41
|
+
return datetime.now(UTC)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ArrInstance(Base):
|
|
45
|
+
"""A configured Sonarr or Radarr connection.
|
|
46
|
+
|
|
47
|
+
``base_url`` and ``api_key`` are the only fields required to talk to the
|
|
48
|
+
remote instance; the ``status*``/``version`` columns cache the result of
|
|
49
|
+
the last connectivity check performed by the service layer on save.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
__tablename__ = "arr_instances"
|
|
53
|
+
__table_args__ = (UniqueConstraint("name", name="uq_arr_instances_name"),)
|
|
54
|
+
|
|
55
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
56
|
+
name: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
|
57
|
+
type: Mapped[InstanceType] = mapped_column(
|
|
58
|
+
SAEnum(
|
|
59
|
+
InstanceType,
|
|
60
|
+
values_callable=lambda enum_cls: [member.value for member in enum_cls],
|
|
61
|
+
),
|
|
62
|
+
nullable=False,
|
|
63
|
+
index=True,
|
|
64
|
+
)
|
|
65
|
+
base_url: Mapped[str] = mapped_column(String(500), nullable=False)
|
|
66
|
+
api_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
67
|
+
|
|
68
|
+
status: Mapped[ConnectivityStatus] = mapped_column(
|
|
69
|
+
SAEnum(
|
|
70
|
+
ConnectivityStatus,
|
|
71
|
+
values_callable=lambda enum_cls: [member.value for member in enum_cls],
|
|
72
|
+
),
|
|
73
|
+
nullable=False,
|
|
74
|
+
default=ConnectivityStatus.UNKNOWN,
|
|
75
|
+
)
|
|
76
|
+
status_error: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
|
77
|
+
status_checked_at: Mapped[datetime | None] = mapped_column(nullable=True, default=None)
|
|
78
|
+
version: Mapped[str | None] = mapped_column(String(50), nullable=True, default=None)
|
|
79
|
+
|
|
80
|
+
created_at: Mapped[datetime] = mapped_column(default=_utcnow)
|
|
81
|
+
updated_at: Mapped[datetime] = mapped_column(default=_utcnow, onupdate=_utcnow)
|
|
82
|
+
|
|
83
|
+
def __repr__(self) -> str:
|
|
84
|
+
return f"ArrInstance(id={self.id!r}, name={self.name!r}, type={self.type!r})"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class RemotePathMapping(Base):
|
|
88
|
+
"""A remote-to-local path prefix mapping for an ArrInstance.
|
|
89
|
+
|
|
90
|
+
Ordered mappings allow Collapsarr to translate container-relative paths
|
|
91
|
+
(e.g., ``/tv/Show/Season 01/file.mkv`` reported by a Sonarr/Radarr API
|
|
92
|
+
running in Docker) into host-local paths (e.g., ``/mnt/media/...``) that
|
|
93
|
+
Collapsarr can actually read/write on disk.
|
|
94
|
+
|
|
95
|
+
Mappings are applied in order; the first matching remote_prefix wins.
|
|
96
|
+
Unmapped paths pass through unchanged.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
__tablename__ = "remote_path_mappings"
|
|
100
|
+
|
|
101
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
102
|
+
instance_id: Mapped[int] = mapped_column(
|
|
103
|
+
ForeignKey("arr_instances.id", ondelete="CASCADE"), nullable=False, index=True
|
|
104
|
+
)
|
|
105
|
+
remote_prefix: Mapped[str] = mapped_column(String(500), nullable=False)
|
|
106
|
+
local_prefix: Mapped[str] = mapped_column(String(500), nullable=False)
|
|
107
|
+
order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
108
|
+
|
|
109
|
+
created_at: Mapped[datetime] = mapped_column(default=_utcnow)
|
|
110
|
+
updated_at: Mapped[datetime] = mapped_column(default=_utcnow, onupdate=_utcnow)
|
|
111
|
+
|
|
112
|
+
def __repr__(self) -> str:
|
|
113
|
+
return (
|
|
114
|
+
f"RemotePathMapping(id={self.id!r}, instance_id={self.instance_id!r}, "
|
|
115
|
+
f"remote_prefix={self.remote_prefix!r}, local_prefix={self.local_prefix!r})"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def resolve_path(
|
|
120
|
+
file_path: str, mappings: list[RemotePathMapping] | None = None
|
|
121
|
+
) -> str:
|
|
122
|
+
"""Resolve a remote file path to a local path using configured mappings.
|
|
123
|
+
|
|
124
|
+
Applies the ordered list of path mappings to transform a file path as
|
|
125
|
+
reported by a Sonarr/Radarr API (e.g., a container-relative path) into
|
|
126
|
+
the local file path Collapsarr can actually read/write on disk.
|
|
127
|
+
|
|
128
|
+
The first mapping whose ``remote_prefix`` matches the start of
|
|
129
|
+
``file_path`` is applied; subsequent mappings are ignored. If no mapping
|
|
130
|
+
matches, the original ``file_path`` is returned unchanged.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
file_path: The path to resolve (typically as reported by Arr API).
|
|
134
|
+
mappings: Ordered list of RemotePathMapping objects. When ``None`` or
|
|
135
|
+
empty, ``file_path`` is returned unchanged.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
The resolved local path, or ``file_path`` if no mapping applies.
|
|
139
|
+
"""
|
|
140
|
+
if not mappings:
|
|
141
|
+
return file_path
|
|
142
|
+
|
|
143
|
+
for mapping in mappings:
|
|
144
|
+
if file_path.startswith(mapping.remote_prefix):
|
|
145
|
+
return file_path.replace(mapping.remote_prefix, mapping.local_prefix, 1)
|
|
146
|
+
|
|
147
|
+
return file_path
|