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/arr/routes.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""HTTP REST endpoints for arr instance config & path mappings (COL-27).
|
|
2
|
+
|
|
3
|
+
Thin CRUD layer wrapping :mod:`collapsarr.arr.service`, exposed as a FastAPI
|
|
4
|
+
:class:`~fastapi.APIRouter` mounted under ``/api`` by
|
|
5
|
+
:func:`collapsarr.main.create_app`. Because everything under ``/api`` is gated
|
|
6
|
+
by the API-key middleware (COL-26), every route here inherits key-based auth --
|
|
7
|
+
no per-route auth wiring is needed.
|
|
8
|
+
|
|
9
|
+
Resource shape follows Sonarr/Radarr conventions: an instance is a named
|
|
10
|
+
Sonarr/Radarr connection (``/api/instances``), and remote path mappings are
|
|
11
|
+
managed per instance as a nested sub-resource
|
|
12
|
+
(``/api/instances/{instance_id}/path-mappings``), mirroring how the mappings
|
|
13
|
+
belong to a single instance in the data model. Request/response bodies use the
|
|
14
|
+
same field names as the ORM models so the surface round-trips cleanly.
|
|
15
|
+
|
|
16
|
+
Service-layer ``LookupError`` subclasses (:class:`~collapsarr.arr.service.
|
|
17
|
+
InstanceNotFoundError`, :class:`~collapsarr.arr.service.PathMappingNotFoundError`)
|
|
18
|
+
are translated to ``404`` responses; creation against a missing instance is
|
|
19
|
+
likewise a ``404``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
|
|
26
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
27
|
+
from pydantic import BaseModel, ConfigDict
|
|
28
|
+
from sqlalchemy.orm import Session
|
|
29
|
+
|
|
30
|
+
from ..database import get_session
|
|
31
|
+
from .models import ConnectivityStatus, InstanceType
|
|
32
|
+
from .service import (
|
|
33
|
+
InstanceNotFoundError,
|
|
34
|
+
PathMappingNotFoundError,
|
|
35
|
+
create_instance,
|
|
36
|
+
create_path_mapping,
|
|
37
|
+
delete_instance,
|
|
38
|
+
delete_path_mapping,
|
|
39
|
+
get_instance,
|
|
40
|
+
get_path_mapping,
|
|
41
|
+
list_instances,
|
|
42
|
+
list_path_mappings,
|
|
43
|
+
update_instance,
|
|
44
|
+
update_path_mapping,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
router = APIRouter(prefix="/api", tags=["arr"])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --- schemas -----------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class InstanceCreate(BaseModel):
|
|
54
|
+
"""Request body to create an arr instance."""
|
|
55
|
+
|
|
56
|
+
name: str
|
|
57
|
+
type: InstanceType
|
|
58
|
+
base_url: str
|
|
59
|
+
api_key: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class InstanceUpdate(BaseModel):
|
|
63
|
+
"""Request body to update an arr instance; omitted fields are left unchanged.
|
|
64
|
+
|
|
65
|
+
``type`` is not updatable -- switching a configured connection between
|
|
66
|
+
Sonarr and Radarr is a delete-and-recreate, matching the service layer.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
name: str | None = None
|
|
70
|
+
base_url: str | None = None
|
|
71
|
+
api_key: str | None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class InstanceRead(BaseModel):
|
|
75
|
+
"""Response shape for an arr instance, including cached connectivity state."""
|
|
76
|
+
|
|
77
|
+
model_config = ConfigDict(from_attributes=True)
|
|
78
|
+
|
|
79
|
+
id: int
|
|
80
|
+
name: str
|
|
81
|
+
type: InstanceType
|
|
82
|
+
base_url: str
|
|
83
|
+
api_key: str
|
|
84
|
+
status: ConnectivityStatus
|
|
85
|
+
status_error: str | None
|
|
86
|
+
status_checked_at: datetime | None
|
|
87
|
+
version: str | None
|
|
88
|
+
created_at: datetime
|
|
89
|
+
updated_at: datetime
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class PathMappingCreate(BaseModel):
|
|
93
|
+
"""Request body to create a remote path mapping."""
|
|
94
|
+
|
|
95
|
+
remote_prefix: str
|
|
96
|
+
local_prefix: str
|
|
97
|
+
order: int = 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class PathMappingUpdate(BaseModel):
|
|
101
|
+
"""Request body to update a path mapping; omitted fields are left unchanged."""
|
|
102
|
+
|
|
103
|
+
remote_prefix: str | None = None
|
|
104
|
+
local_prefix: str | None = None
|
|
105
|
+
order: int | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class PathMappingRead(BaseModel):
|
|
109
|
+
"""Response shape for a remote path mapping."""
|
|
110
|
+
|
|
111
|
+
model_config = ConfigDict(from_attributes=True)
|
|
112
|
+
|
|
113
|
+
id: int
|
|
114
|
+
instance_id: int
|
|
115
|
+
remote_prefix: str
|
|
116
|
+
local_prefix: str
|
|
117
|
+
order: int
|
|
118
|
+
created_at: datetime
|
|
119
|
+
updated_at: datetime
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# --- instance endpoints ------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@router.get("/instances", response_model=list[InstanceRead])
|
|
126
|
+
def list_instances_endpoint(session: Session = Depends(get_session)) -> list[object]:
|
|
127
|
+
"""List all configured Sonarr/Radarr instances, ordered by id."""
|
|
128
|
+
return list(list_instances(session))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@router.post("/instances", response_model=InstanceRead, status_code=201)
|
|
132
|
+
def create_instance_endpoint(
|
|
133
|
+
body: InstanceCreate, session: Session = Depends(get_session)
|
|
134
|
+
) -> object:
|
|
135
|
+
"""Create an instance; connectivity is validated and cached on save."""
|
|
136
|
+
return create_instance(
|
|
137
|
+
session,
|
|
138
|
+
name=body.name,
|
|
139
|
+
instance_type=body.type,
|
|
140
|
+
base_url=body.base_url,
|
|
141
|
+
api_key=body.api_key,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@router.get("/instances/{instance_id}", response_model=InstanceRead)
|
|
146
|
+
def get_instance_endpoint(
|
|
147
|
+
instance_id: int, session: Session = Depends(get_session)
|
|
148
|
+
) -> object:
|
|
149
|
+
"""Fetch a single instance, or ``404`` if it does not exist."""
|
|
150
|
+
instance = get_instance(session, instance_id)
|
|
151
|
+
if instance is None:
|
|
152
|
+
raise HTTPException(status_code=404, detail=f"No arr instance with id={instance_id}")
|
|
153
|
+
return instance
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@router.put("/instances/{instance_id}", response_model=InstanceRead)
|
|
157
|
+
def update_instance_endpoint(
|
|
158
|
+
instance_id: int,
|
|
159
|
+
body: InstanceUpdate,
|
|
160
|
+
session: Session = Depends(get_session),
|
|
161
|
+
) -> object:
|
|
162
|
+
"""Update an instance's fields and re-validate connectivity."""
|
|
163
|
+
try:
|
|
164
|
+
return update_instance(
|
|
165
|
+
session,
|
|
166
|
+
instance_id,
|
|
167
|
+
name=body.name,
|
|
168
|
+
base_url=body.base_url,
|
|
169
|
+
api_key=body.api_key,
|
|
170
|
+
)
|
|
171
|
+
except InstanceNotFoundError as exc:
|
|
172
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@router.delete("/instances/{instance_id}", status_code=204)
|
|
176
|
+
def delete_instance_endpoint(
|
|
177
|
+
instance_id: int, session: Session = Depends(get_session)
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Delete an instance (cascades to its path mappings)."""
|
|
180
|
+
try:
|
|
181
|
+
delete_instance(session, instance_id)
|
|
182
|
+
except InstanceNotFoundError as exc:
|
|
183
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# --- path-mapping endpoints --------------------------------------------------
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _require_instance(session: Session, instance_id: int) -> None:
|
|
190
|
+
if get_instance(session, instance_id) is None:
|
|
191
|
+
raise HTTPException(status_code=404, detail=f"No arr instance with id={instance_id}")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@router.get(
|
|
195
|
+
"/instances/{instance_id}/path-mappings",
|
|
196
|
+
response_model=list[PathMappingRead],
|
|
197
|
+
)
|
|
198
|
+
def list_path_mappings_endpoint(
|
|
199
|
+
instance_id: int, session: Session = Depends(get_session)
|
|
200
|
+
) -> list[object]:
|
|
201
|
+
"""List an instance's path mappings in application order."""
|
|
202
|
+
_require_instance(session, instance_id)
|
|
203
|
+
return list(list_path_mappings(session, instance_id))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@router.post(
|
|
207
|
+
"/instances/{instance_id}/path-mappings",
|
|
208
|
+
response_model=PathMappingRead,
|
|
209
|
+
status_code=201,
|
|
210
|
+
)
|
|
211
|
+
def create_path_mapping_endpoint(
|
|
212
|
+
instance_id: int,
|
|
213
|
+
body: PathMappingCreate,
|
|
214
|
+
session: Session = Depends(get_session),
|
|
215
|
+
) -> object:
|
|
216
|
+
"""Create a path mapping under an instance, or ``404`` if it is missing."""
|
|
217
|
+
try:
|
|
218
|
+
return create_path_mapping(
|
|
219
|
+
session,
|
|
220
|
+
instance_id,
|
|
221
|
+
remote_prefix=body.remote_prefix,
|
|
222
|
+
local_prefix=body.local_prefix,
|
|
223
|
+
order=body.order,
|
|
224
|
+
)
|
|
225
|
+
except InstanceNotFoundError as exc:
|
|
226
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _load_mapping(session: Session, instance_id: int, mapping_id: int) -> object:
|
|
230
|
+
mapping = get_path_mapping(session, mapping_id)
|
|
231
|
+
if mapping is None or mapping.instance_id != instance_id:
|
|
232
|
+
raise HTTPException(
|
|
233
|
+
status_code=404,
|
|
234
|
+
detail=f"No path mapping with id={mapping_id} for instance id={instance_id}",
|
|
235
|
+
)
|
|
236
|
+
return mapping
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@router.get(
|
|
240
|
+
"/instances/{instance_id}/path-mappings/{mapping_id}",
|
|
241
|
+
response_model=PathMappingRead,
|
|
242
|
+
)
|
|
243
|
+
def get_path_mapping_endpoint(
|
|
244
|
+
instance_id: int,
|
|
245
|
+
mapping_id: int,
|
|
246
|
+
session: Session = Depends(get_session),
|
|
247
|
+
) -> object:
|
|
248
|
+
"""Fetch one path mapping, scoped to its instance."""
|
|
249
|
+
return _load_mapping(session, instance_id, mapping_id)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@router.put(
|
|
253
|
+
"/instances/{instance_id}/path-mappings/{mapping_id}",
|
|
254
|
+
response_model=PathMappingRead,
|
|
255
|
+
)
|
|
256
|
+
def update_path_mapping_endpoint(
|
|
257
|
+
instance_id: int,
|
|
258
|
+
mapping_id: int,
|
|
259
|
+
body: PathMappingUpdate,
|
|
260
|
+
session: Session = Depends(get_session),
|
|
261
|
+
) -> object:
|
|
262
|
+
"""Update a path mapping's fields, scoped to its instance."""
|
|
263
|
+
_load_mapping(session, instance_id, mapping_id)
|
|
264
|
+
try:
|
|
265
|
+
return update_path_mapping(
|
|
266
|
+
session,
|
|
267
|
+
mapping_id,
|
|
268
|
+
remote_prefix=body.remote_prefix,
|
|
269
|
+
local_prefix=body.local_prefix,
|
|
270
|
+
order=body.order,
|
|
271
|
+
)
|
|
272
|
+
except PathMappingNotFoundError as exc:
|
|
273
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@router.delete(
|
|
277
|
+
"/instances/{instance_id}/path-mappings/{mapping_id}",
|
|
278
|
+
status_code=204,
|
|
279
|
+
)
|
|
280
|
+
def delete_path_mapping_endpoint(
|
|
281
|
+
instance_id: int,
|
|
282
|
+
mapping_id: int,
|
|
283
|
+
session: Session = Depends(get_session),
|
|
284
|
+
) -> None:
|
|
285
|
+
"""Delete a path mapping, scoped to its instance."""
|
|
286
|
+
_load_mapping(session, instance_id, mapping_id)
|
|
287
|
+
try:
|
|
288
|
+
delete_path_mapping(session, mapping_id)
|
|
289
|
+
except PathMappingNotFoundError as exc:
|
|
290
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Service-layer CRUD for :class:`~collapsarr.arr.models.ArrInstance`.
|
|
2
|
+
|
|
3
|
+
HTTP exposure is a separate (API) epic's concern — this module is the whole
|
|
4
|
+
surface: plain functions taking a SQLAlchemy :class:`~sqlalchemy.orm.Session`
|
|
5
|
+
and returning/mutating ORM objects. Creating or updating an instance always
|
|
6
|
+
re-validates connectivity via :func:`collapsarr.arr.client.check_connectivity`
|
|
7
|
+
and persists the outcome, per COL-11's acceptance criteria.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from sqlalchemy import select
|
|
16
|
+
from sqlalchemy.orm import Session
|
|
17
|
+
|
|
18
|
+
from .client import check_connectivity
|
|
19
|
+
from .models import ArrInstance, ConnectivityStatus, InstanceType, RemotePathMapping
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InstanceNotFoundError(LookupError):
|
|
23
|
+
"""Raised when an operation targets an instance id that does not exist."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PathMappingNotFoundError(LookupError):
|
|
27
|
+
"""Raised when an operation targets a path-mapping id that does not exist."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _apply_connectivity_check(
|
|
31
|
+
instance: ArrInstance, *, transport: httpx.BaseTransport | None
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Run the connectivity/version check and stamp its outcome onto ``instance``."""
|
|
34
|
+
result = check_connectivity(instance.base_url, instance.api_key, transport=transport)
|
|
35
|
+
instance.status = ConnectivityStatus.OK if result.ok else ConnectivityStatus.ERROR
|
|
36
|
+
instance.status_error = result.error
|
|
37
|
+
instance.version = result.version
|
|
38
|
+
instance.status_checked_at = datetime.now(UTC)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def create_instance(
|
|
42
|
+
session: Session,
|
|
43
|
+
*,
|
|
44
|
+
name: str,
|
|
45
|
+
instance_type: InstanceType,
|
|
46
|
+
base_url: str,
|
|
47
|
+
api_key: str,
|
|
48
|
+
transport: httpx.BaseTransport | None = None,
|
|
49
|
+
) -> ArrInstance:
|
|
50
|
+
"""Create a new instance config and validate connectivity before returning it.
|
|
51
|
+
|
|
52
|
+
The row is persisted regardless of whether the connectivity check
|
|
53
|
+
succeeds — ``status``/``status_error`` record the outcome so failed
|
|
54
|
+
instances remain visible (and editable) rather than being silently
|
|
55
|
+
dropped.
|
|
56
|
+
"""
|
|
57
|
+
instance = ArrInstance(
|
|
58
|
+
name=name,
|
|
59
|
+
type=instance_type,
|
|
60
|
+
base_url=base_url.rstrip("/"),
|
|
61
|
+
api_key=api_key,
|
|
62
|
+
)
|
|
63
|
+
_apply_connectivity_check(instance, transport=transport)
|
|
64
|
+
session.add(instance)
|
|
65
|
+
session.commit()
|
|
66
|
+
session.refresh(instance)
|
|
67
|
+
return instance
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def list_instances(session: Session) -> list[ArrInstance]:
|
|
71
|
+
"""Return all configured instances, both types, ordered by id."""
|
|
72
|
+
return list(session.scalars(select(ArrInstance).order_by(ArrInstance.id)))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_instance(session: Session, instance_id: int) -> ArrInstance | None:
|
|
76
|
+
"""Return the instance with ``instance_id``, or ``None`` if it doesn't exist."""
|
|
77
|
+
return session.get(ArrInstance, instance_id)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def update_instance(
|
|
81
|
+
session: Session,
|
|
82
|
+
instance_id: int,
|
|
83
|
+
*,
|
|
84
|
+
name: str | None = None,
|
|
85
|
+
base_url: str | None = None,
|
|
86
|
+
api_key: str | None = None,
|
|
87
|
+
transport: httpx.BaseTransport | None = None,
|
|
88
|
+
) -> ArrInstance:
|
|
89
|
+
"""Update the given fields on an instance and re-validate connectivity.
|
|
90
|
+
|
|
91
|
+
Only fields passed explicitly (non-``None``) are changed. Connectivity is
|
|
92
|
+
re-checked on every save, matching :func:`create_instance`, so a fixed API
|
|
93
|
+
key or corrected URL is reflected in ``status`` immediately.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
InstanceNotFoundError: if ``instance_id`` does not exist.
|
|
97
|
+
"""
|
|
98
|
+
instance = get_instance(session, instance_id)
|
|
99
|
+
if instance is None:
|
|
100
|
+
raise InstanceNotFoundError(f"No arr instance with id={instance_id}")
|
|
101
|
+
|
|
102
|
+
if name is not None:
|
|
103
|
+
instance.name = name
|
|
104
|
+
if base_url is not None:
|
|
105
|
+
instance.base_url = base_url.rstrip("/")
|
|
106
|
+
if api_key is not None:
|
|
107
|
+
instance.api_key = api_key
|
|
108
|
+
|
|
109
|
+
_apply_connectivity_check(instance, transport=transport)
|
|
110
|
+
session.commit()
|
|
111
|
+
session.refresh(instance)
|
|
112
|
+
return instance
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def delete_instance(session: Session, instance_id: int) -> None:
|
|
116
|
+
"""Delete an instance config.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
InstanceNotFoundError: if ``instance_id`` does not exist.
|
|
120
|
+
"""
|
|
121
|
+
instance = get_instance(session, instance_id)
|
|
122
|
+
if instance is None:
|
|
123
|
+
raise InstanceNotFoundError(f"No arr instance with id={instance_id}")
|
|
124
|
+
session.delete(instance)
|
|
125
|
+
session.commit()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def list_path_mappings(session: Session, instance_id: int) -> list[RemotePathMapping]:
|
|
129
|
+
"""Return an instance's path mappings in their configured application order.
|
|
130
|
+
|
|
131
|
+
Used by the webhook handler (COL-14) to resolve a webhook-reported remote
|
|
132
|
+
path to a local one via :func:`collapsarr.arr.models.resolve_path`. Does
|
|
133
|
+
not raise for an unknown ``instance_id`` -- it simply returns an empty
|
|
134
|
+
list, matching :func:`resolve_path`'s "no mappings" pass-through.
|
|
135
|
+
"""
|
|
136
|
+
return list(
|
|
137
|
+
session.scalars(
|
|
138
|
+
select(RemotePathMapping)
|
|
139
|
+
.where(RemotePathMapping.instance_id == instance_id)
|
|
140
|
+
.order_by(RemotePathMapping.order)
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def get_path_mapping(session: Session, mapping_id: int) -> RemotePathMapping | None:
|
|
146
|
+
"""Return the path mapping with ``mapping_id``, or ``None`` if it doesn't exist."""
|
|
147
|
+
return session.get(RemotePathMapping, mapping_id)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def create_path_mapping(
|
|
151
|
+
session: Session,
|
|
152
|
+
instance_id: int,
|
|
153
|
+
*,
|
|
154
|
+
remote_prefix: str,
|
|
155
|
+
local_prefix: str,
|
|
156
|
+
order: int = 0,
|
|
157
|
+
) -> RemotePathMapping:
|
|
158
|
+
"""Create a path mapping for an instance and return it.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
InstanceNotFoundError: if ``instance_id`` does not exist. Mappings are
|
|
162
|
+
meaningless without their instance, so creation is rejected rather
|
|
163
|
+
than leaving a dangling row.
|
|
164
|
+
"""
|
|
165
|
+
if get_instance(session, instance_id) is None:
|
|
166
|
+
raise InstanceNotFoundError(f"No arr instance with id={instance_id}")
|
|
167
|
+
|
|
168
|
+
mapping = RemotePathMapping(
|
|
169
|
+
instance_id=instance_id,
|
|
170
|
+
remote_prefix=remote_prefix,
|
|
171
|
+
local_prefix=local_prefix,
|
|
172
|
+
order=order,
|
|
173
|
+
)
|
|
174
|
+
session.add(mapping)
|
|
175
|
+
session.commit()
|
|
176
|
+
session.refresh(mapping)
|
|
177
|
+
return mapping
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def update_path_mapping(
|
|
181
|
+
session: Session,
|
|
182
|
+
mapping_id: int,
|
|
183
|
+
*,
|
|
184
|
+
remote_prefix: str | None = None,
|
|
185
|
+
local_prefix: str | None = None,
|
|
186
|
+
order: int | None = None,
|
|
187
|
+
) -> RemotePathMapping:
|
|
188
|
+
"""Update the given fields on a path mapping and return it.
|
|
189
|
+
|
|
190
|
+
Only fields passed explicitly (non-``None``) are changed, matching
|
|
191
|
+
:func:`update_instance`.
|
|
192
|
+
|
|
193
|
+
Raises:
|
|
194
|
+
PathMappingNotFoundError: if ``mapping_id`` does not exist.
|
|
195
|
+
"""
|
|
196
|
+
mapping = get_path_mapping(session, mapping_id)
|
|
197
|
+
if mapping is None:
|
|
198
|
+
raise PathMappingNotFoundError(f"No path mapping with id={mapping_id}")
|
|
199
|
+
|
|
200
|
+
if remote_prefix is not None:
|
|
201
|
+
mapping.remote_prefix = remote_prefix
|
|
202
|
+
if local_prefix is not None:
|
|
203
|
+
mapping.local_prefix = local_prefix
|
|
204
|
+
if order is not None:
|
|
205
|
+
mapping.order = order
|
|
206
|
+
|
|
207
|
+
session.commit()
|
|
208
|
+
session.refresh(mapping)
|
|
209
|
+
return mapping
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def delete_path_mapping(session: Session, mapping_id: int) -> None:
|
|
213
|
+
"""Delete a path mapping.
|
|
214
|
+
|
|
215
|
+
Raises:
|
|
216
|
+
PathMappingNotFoundError: if ``mapping_id`` does not exist.
|
|
217
|
+
"""
|
|
218
|
+
mapping = get_path_mapping(session, mapping_id)
|
|
219
|
+
if mapping is None:
|
|
220
|
+
raise PathMappingNotFoundError(f"No path mapping with id={mapping_id}")
|
|
221
|
+
session.delete(mapping)
|
|
222
|
+
session.commit()
|