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
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
+ ]
@@ -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)
@@ -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
@@ -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