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,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()