doblarr 0.1.0__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 (92) hide show
  1. doblarr/__init__.py +10 -0
  2. doblarr/__main__.py +8 -0
  3. doblarr/artifacts.py +54 -0
  4. doblarr/auth.py +39 -0
  5. doblarr/cache.py +48 -0
  6. doblarr/cli.py +104 -0
  7. doblarr/clients/__init__.py +1 -0
  8. doblarr/clients/base.py +104 -0
  9. doblarr/clients/plex.py +91 -0
  10. doblarr/clients/radarr.py +26 -0
  11. doblarr/clients/sonarr.py +32 -0
  12. doblarr/clients/translator.py +341 -0
  13. doblarr/clients/voicebox.py +232 -0
  14. doblarr/config.example.yaml +120 -0
  15. doblarr/config.py +161 -0
  16. doblarr/config_schema.py +168 -0
  17. doblarr/discovery.py +230 -0
  18. doblarr/errors.py +48 -0
  19. doblarr/events.py +62 -0
  20. doblarr/ffmpeg.py +64 -0
  21. doblarr/jobs.py +332 -0
  22. doblarr/library_service.py +115 -0
  23. doblarr/logging_setup.py +97 -0
  24. doblarr/model_pool.py +34 -0
  25. doblarr/models.py +76 -0
  26. doblarr/pipeline.py +344 -0
  27. doblarr/plex_labels.py +114 -0
  28. doblarr/presets.py +19 -0
  29. doblarr/recipes.py +166 -0
  30. doblarr/review.py +73 -0
  31. doblarr/routes/__init__.py +1 -0
  32. doblarr/routes/configuration.py +32 -0
  33. doblarr/routes/jobs.py +284 -0
  34. doblarr/routes/library.py +64 -0
  35. doblarr/routes/series.py +182 -0
  36. doblarr/routes/titles.py +299 -0
  37. doblarr/routes/voice_catalog.py +159 -0
  38. doblarr/scheduler.py +69 -0
  39. doblarr/server.py +175 -0
  40. doblarr/services.py +66 -0
  41. doblarr/stages/__init__.py +19 -0
  42. doblarr/stages/audition.py +113 -0
  43. doblarr/stages/common.py +174 -0
  44. doblarr/stages/diarize.py +162 -0
  45. doblarr/stages/extract.py +80 -0
  46. doblarr/stages/fit_timing.py +188 -0
  47. doblarr/stages/mix.py +227 -0
  48. doblarr/stages/mux.py +150 -0
  49. doblarr/stages/prepare.py +48 -0
  50. doblarr/stages/quality.py +174 -0
  51. doblarr/stages/separate.py +68 -0
  52. doblarr/stages/synthesize.py +339 -0
  53. doblarr/stages/transcribe.py +275 -0
  54. doblarr/stages/translate.py +77 -0
  55. doblarr/store.py +190 -0
  56. doblarr/subtitles.py +56 -0
  57. doblarr/telemetry.py +59 -0
  58. doblarr/versions.py +99 -0
  59. doblarr/voices.py +133 -0
  60. doblarr/web/android-chrome-192x192.png +0 -0
  61. doblarr/web/android-chrome-512x512.png +0 -0
  62. doblarr/web/apple-touch-icon.png +0 -0
  63. doblarr/web/favicon-16x16.png +0 -0
  64. doblarr/web/favicon-32x32.png +0 -0
  65. doblarr/web/favicon.ico +0 -0
  66. doblarr/web/index.html +383 -0
  67. doblarr/web/js/api.js +46 -0
  68. doblarr/web/js/app.js +339 -0
  69. doblarr/web/js/dom.js +21 -0
  70. doblarr/web/js/episodes.js +76 -0
  71. doblarr/web/js/identity.js +24 -0
  72. doblarr/web/js/jobs-data.js +9 -0
  73. doblarr/web/js/jobs.js +240 -0
  74. doblarr/web/js/library.js +147 -0
  75. doblarr/web/js/recipes.js +119 -0
  76. doblarr/web/js/review.js +127 -0
  77. doblarr/web/js/settings-model.js +118 -0
  78. doblarr/web/js/settings.js +112 -0
  79. doblarr/web/js/state.js +15 -0
  80. doblarr/web/js/title-routing.js +24 -0
  81. doblarr/web/js/title.js +504 -0
  82. doblarr/web/js/voice-picker.js +99 -0
  83. doblarr/web/logo.png +0 -0
  84. doblarr/web/site.webmanifest +20 -0
  85. doblarr/web/styles.css +233 -0
  86. doblarr/webhooks.py +68 -0
  87. doblarr-0.1.0.dist-info/METADATA +443 -0
  88. doblarr-0.1.0.dist-info/RECORD +92 -0
  89. doblarr-0.1.0.dist-info/WHEEL +5 -0
  90. doblarr-0.1.0.dist-info/entry_points.txt +2 -0
  91. doblarr-0.1.0.dist-info/licenses/LICENSE +21 -0
  92. doblarr-0.1.0.dist-info/top_level.txt +1 -0
doblarr/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Doblarr — AI dubbing for your media library.
2
+
3
+ Turns a foreign-language video into an added, translated audio track by
4
+ orchestrating: source separation, transcription/diarization, LLM translation,
5
+ voice-cloned TTS (via the voicebox service), time-fitting, mixing, and muxing.
6
+
7
+ Doblarr owns the movie-specific pipeline; voicebox owns voice cloning + TTS.
8
+ """
9
+
10
+ __version__ = "0.1.0"
doblarr/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Enable `python -m doblarr ...`."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
doblarr/artifacts.py ADDED
@@ -0,0 +1,54 @@
1
+ """Configuration-aware checkpoints and source-isolated working directories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from pathlib import Path
8
+
9
+ from .telemetry import write_json
10
+
11
+
12
+ def digest(value) -> str:
13
+ return hashlib.sha256(json.dumps(value, sort_keys=True, default=str).encode()).hexdigest()
14
+
15
+
16
+ def read_json(path: Path) -> dict:
17
+ try:
18
+ value = json.loads(path.read_text(encoding="utf-8"))
19
+ return value if isinstance(value, dict) else {}
20
+ except (OSError, ValueError):
21
+ return {}
22
+
23
+
24
+ def stamp(path: Path | None) -> dict | None:
25
+ if path is None:
26
+ return None
27
+ try:
28
+ stat = path.stat()
29
+ return {"path": str(path.resolve()), "size": stat.st_size, "mtime_ns": stat.st_mtime_ns}
30
+ except OSError:
31
+ return None
32
+
33
+
34
+ def media_work(root: Path, job) -> Path:
35
+ identity = digest([str(job.input_file.resolve()), job.source_lang])[:16]
36
+ return root / "media" / identity
37
+
38
+
39
+ def receipt_path(output: Path) -> Path:
40
+ return output.with_name(output.name + ".manifest.json")
41
+
42
+
43
+ def matches(outputs: list[Path], request: dict, force=False) -> bool:
44
+ if force or not outputs:
45
+ return False
46
+ saved = read_json(receipt_path(outputs[0]))
47
+ states = [stamp(p) for p in outputs]
48
+ return (all(s and s["size"] > 0 for s in states)
49
+ and saved.get("request") == request and saved.get("outputs") == states)
50
+
51
+
52
+ def record(outputs: list[Path], request: dict) -> None:
53
+ write_json(receipt_path(outputs[0]), {"request": request,
54
+ "outputs": [stamp(p) for p in outputs]})
doblarr/auth.py ADDED
@@ -0,0 +1,39 @@
1
+ """API-key authentication for the Doblarr HTTP API.
2
+
3
+ When `web.api_key` is set, every `/api/*` route (except `/api/health`) requires
4
+ the key via the `X-Api-Key` header (or `?api_key=` for convenience). When it is
5
+ empty — the self-hosted default — the API stays open and a warning is logged at
6
+ startup. The static UI mount is not covered by this dependency.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hmac
12
+ import logging
13
+ from collections.abc import Awaitable, Callable
14
+
15
+ from fastapi import HTTPException, Request
16
+
17
+ from .config import Config
18
+
19
+ log = logging.getLogger("doblarr.auth")
20
+
21
+ OPEN_PATHS = {"/api/health", "/api/health/ready"}
22
+
23
+
24
+ def build_api_key_dependency(config: Config) -> Callable[[Request], Awaitable[None]]:
25
+ """Return a FastAPI dependency enforcing `web.api_key` on API routes."""
26
+ if not (config.get("web", {}) or {}).get("api_key"):
27
+ log.warning("web.api_key is not set — the HTTP API is UNAUTHENTICATED; "
28
+ "set web.api_key in config.yaml to require X-Api-Key")
29
+
30
+ async def verify_api_key(request: Request) -> None:
31
+ api_key = (config.get("web", {}) or {}).get("api_key") or ""
32
+ if not api_key or request.url.path in OPEN_PATHS:
33
+ return
34
+ supplied = (request.headers.get("x-api-key")
35
+ or request.query_params.get("api_key") or "")
36
+ if not hmac.compare_digest(supplied, api_key):
37
+ raise HTTPException(status_code=401, detail="missing or invalid API key")
38
+
39
+ return verify_api_key
doblarr/cache.py ADDED
@@ -0,0 +1,48 @@
1
+ """Tiny in-memory TTL cache — dict + timestamps, FIFO eviction at max size.
2
+
3
+ Used for library scan results; intentionally dependency-free and process-local
4
+ (nothing persisted to disk).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import threading
10
+ import time
11
+
12
+
13
+ class TTLCache:
14
+ def __init__(self, ttl: float = 300.0, max_size: int = 64):
15
+ self.ttl = ttl
16
+ self.max_size = max_size
17
+ self._items: dict = {} # key -> (set-time, value), insertion ordered
18
+ self._lock = threading.Lock()
19
+
20
+ def get(self, key, ttl: float | None = None):
21
+ """Return the cached value, or None if missing/expired.
22
+
23
+ `ttl` overrides the instance default for this lookup, so a caller can
24
+ keep the TTL configurable at read time.
25
+ """
26
+ ttl = self.ttl if ttl is None else ttl
27
+ with self._lock:
28
+ item = self._items.get(key)
29
+ if item is None:
30
+ return None
31
+ ts, value = item
32
+ if time.monotonic() - ts >= ttl:
33
+ del self._items[key]
34
+ return None
35
+ return value
36
+
37
+ def set(self, key, value) -> None:
38
+ with self._lock:
39
+ self._items[key] = (time.monotonic(), value)
40
+ while len(self._items) > self.max_size:
41
+ self._items.pop(next(iter(self._items))) # evict oldest
42
+
43
+ def clear(self) -> None:
44
+ with self._lock:
45
+ self._items.clear()
46
+
47
+ def __len__(self) -> int:
48
+ return len(self._items)
doblarr/cli.py ADDED
@@ -0,0 +1,104 @@
1
+ """Doblarr command-line interface.
2
+
3
+ doblarr check # ping the voicebox service
4
+ doblarr dub MOVIE --to es --from ko [--subs FILE] [--dry-run]
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from . import __version__
14
+ from .clients.voicebox import VoiceboxClient, VoiceboxError
15
+ from .config import Config
16
+ from .logging_setup import setup_logging
17
+ from .models import DubJob
18
+ from .pipeline import run_job
19
+
20
+
21
+ def _cmd_check(args: argparse.Namespace, config: Config) -> int:
22
+ vb = VoiceboxClient(config["voicebox"]["base_url"])
23
+ try:
24
+ health = vb.health()
25
+ except VoiceboxError as exc:
26
+ print(f"voicebox NOT reachable: {exc}")
27
+ return 1
28
+ print(f"voicebox OK at {vb.base_url}: {health}")
29
+ return 0
30
+
31
+
32
+ def _cmd_serve(args: argparse.Namespace, config: Config) -> int:
33
+ import uvicorn
34
+
35
+ from .server import create_app
36
+
37
+ host = args.host or config.get("web", {}).get("host", "127.0.0.1")
38
+ port = args.port or config.get("web", {}).get("port", 6363)
39
+ print(f"Doblarr serving on http://{host}:{port} (UI + /api/library)")
40
+ # log_config=None: logging_setup already configured uvicorn's loggers.
41
+ uvicorn.run(create_app(config), host=host, port=int(port), log_config=None)
42
+ return 0
43
+
44
+
45
+ def _cmd_dub(args: argparse.Namespace, config: Config) -> int:
46
+ src = Path(args.input)
47
+ if not src.exists():
48
+ print(f"input not found: {src}")
49
+ return 1
50
+ job = DubJob(
51
+ input_file=src,
52
+ source_lang=args.source,
53
+ target_lang=args.to,
54
+ subtitle_file=Path(args.subs) if args.subs else None,
55
+ kind=args.kind,
56
+ )
57
+ # --dry-run forces a plan; otherwise dub.dry_run from config decides.
58
+ dry_run = args.dry_run if args.dry_run is not None else config["dub"].get("dry_run", True)
59
+ run_job(job, config, dry_run=dry_run)
60
+ return 0
61
+
62
+
63
+ def build_parser() -> argparse.ArgumentParser:
64
+ p = argparse.ArgumentParser(prog="doblarr", description="AI dubbing for your library")
65
+ p.add_argument("--version", action="version", version=f"doblarr {__version__}")
66
+ p.add_argument("-v", "--verbose", action="store_true")
67
+ p.add_argument("-c", "--config", default=None, help="path to config.yaml")
68
+ sub = p.add_subparsers(dest="command", required=True)
69
+
70
+ sub.add_parser("check", help="check the voicebox service is reachable")
71
+
72
+ s = sub.add_parser("serve", help="run the web UI + API server")
73
+ s.add_argument("--host", default=None)
74
+ s.add_argument("--port", default=None, type=int)
75
+
76
+ d = sub.add_parser("dub", help="dub a video into a target language")
77
+ d.add_argument("input", help="path to the video file")
78
+ d.add_argument("--to", required=True, help="target language code, e.g. es")
79
+ d.add_argument("--from", dest="source", default="auto",
80
+ help="source language code, e.g. ko (default: auto)")
81
+ d.add_argument("--subs", default=None, help="subtitle file for text + timing")
82
+ d.add_argument("--kind", choices=["full", "tease", "audition"], default="full",
83
+ help="full video, opening teaser, or representative audio audition")
84
+ d.add_argument("--dry-run", action="store_true", default=None,
85
+ help="print the plan without running heavy stages "
86
+ "(overrides dub.dry_run in config)")
87
+ return p
88
+
89
+
90
+ def main(argv: list[str] | None = None) -> int:
91
+ args = build_parser().parse_args(argv)
92
+ config = Config.load(args.config)
93
+ setup_logging(config, verbose=args.verbose, uvicorn=args.command == "serve")
94
+ if args.command == "check":
95
+ return _cmd_check(args, config)
96
+ if args.command == "serve":
97
+ return _cmd_serve(args, config)
98
+ if args.command == "dub":
99
+ return _cmd_dub(args, config)
100
+ return 2
101
+
102
+
103
+ if __name__ == "__main__":
104
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """External service clients (voicebox, translation providers)."""
@@ -0,0 +1,104 @@
1
+ """Shared HTTP base for the *arr-style service clients.
2
+
3
+ Wraps a `requests.Session` with a base URL, default timeout, and tenacity-based
4
+ retry: exponential backoff on transient failures (connection errors, timeouts,
5
+ 429 — honoring `Retry-After` — and 5xx), with errors mapped uniformly into the
6
+ `doblarr.errors` hierarchy. Per-request `timeout=` overrides are supported for
7
+ long-running calls (e.g. voicebox generation).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from contextlib import suppress
14
+ from typing import Any
15
+
16
+ import requests
17
+ from tenacity import Retrying, before_sleep_log, retry_if_exception, stop_after_attempt
18
+
19
+ from ..errors import ArrClientError
20
+
21
+ log = logging.getLogger("doblarr.clients.base")
22
+
23
+
24
+ class ArrClient:
25
+ """Session-backed HTTP client with retries and uniform errors."""
26
+
27
+ service = "service" # display name used in error messages
28
+ auth_label = "API key" # what a 401 rejects ("API key" / "token")
29
+ error_cls: type[ArrClientError] = ArrClientError
30
+ max_retries = 2 # retries after the first attempt
31
+ backoff = 0.5 # seconds; doubles each retry
32
+
33
+ def __init__(self, base_url: str, timeout: int = 30,
34
+ headers: dict | None = None):
35
+ self.base_url = base_url.rstrip("/")
36
+ self.timeout = timeout
37
+ self.session = requests.Session()
38
+ if headers:
39
+ self.session.headers.update(headers)
40
+
41
+ # -- internals --------------------------------------------------------
42
+ def _error(self, message: str, status: int | None = None) -> ArrClientError:
43
+ return self.error_cls(message, status=status)
44
+
45
+ @staticmethod
46
+ def _is_transient(exc: BaseException) -> bool:
47
+ return isinstance(exc, ArrClientError) and (
48
+ exc.status is None or exc.status == 429 or exc.status >= 500)
49
+
50
+ def _wait(self, retry_state) -> float:
51
+ """Exponential backoff, raised to `Retry-After` when the server asks."""
52
+ exc = retry_state.outcome.exception()
53
+ delay = self.backoff * (2 ** (retry_state.attempt_number - 1))
54
+ retry_after = getattr(exc, "retry_after", None)
55
+ if retry_after:
56
+ with suppress(TypeError, ValueError):
57
+ delay = max(delay, float(retry_after))
58
+ return delay
59
+
60
+ def _attempt(self, method: str, path: str, *,
61
+ timeout: int | None = None, **kwargs) -> requests.Response:
62
+ url = f"{self.base_url}{path}"
63
+ try:
64
+ resp = self.session.request(method, url,
65
+ timeout=timeout or self.timeout, **kwargs)
66
+ except requests.RequestException as exc:
67
+ raise self._error(
68
+ f"{self.service} unreachable at {self.base_url}: {exc}") from exc
69
+ if resp.status_code == 401:
70
+ raise self._error(
71
+ f"{self.service} rejected the {self.auth_label} (401)", status=401)
72
+ if not resp.ok:
73
+ err = self._error(
74
+ f"{self.service} {resp.status_code} on {method} {path}: "
75
+ f"{resp.text[:200]}", status=resp.status_code)
76
+ err.retry_after = resp.headers.get("Retry-After")
77
+ raise err
78
+ return resp
79
+
80
+ def _request(self, method: str, path: str, *,
81
+ timeout: int | None = None, **kwargs) -> requests.Response:
82
+ retryer = Retrying(
83
+ stop=stop_after_attempt(self.max_retries + 1),
84
+ wait=self._wait,
85
+ retry=retry_if_exception(self._is_transient),
86
+ before_sleep=before_sleep_log(log, logging.DEBUG),
87
+ reraise=True,
88
+ )
89
+ return retryer(self._attempt, method, path, timeout=timeout, **kwargs)
90
+
91
+ # -- verb helpers -----------------------------------------------------
92
+ def _get(self, path: str, params: dict | None = None,
93
+ timeout: int | None = None) -> Any:
94
+ return self._request("GET", path, params=params, timeout=timeout).json()
95
+
96
+ def _post(self, path: str, timeout: int | None = None, **kwargs) -> Any:
97
+ return self._request("POST", path, timeout=timeout, **kwargs).json()
98
+
99
+ def _put(self, path: str, params: dict | None = None,
100
+ timeout: int | None = None, **kwargs) -> None:
101
+ self._request("PUT", path, params=params, timeout=timeout, **kwargs)
102
+
103
+ def _delete(self, path: str, timeout: int | None = None, **kwargs) -> None:
104
+ self._request("DELETE", path, timeout=timeout, **kwargs)
@@ -0,0 +1,91 @@
1
+ """Minimal Plex client — enough to find items and manage labels.
2
+
3
+ Labels are added/removed non-destructively: `label[0].tag.tag=X` adds X while
4
+ keeping every existing label (Kometa's included), and `label[].tag.tag-=X` removes
5
+ just X. `label.locked=1` keeps the label through metadata refreshes.
6
+
7
+ The token travels in the `X-Plex-Token` header, not the query string, so it
8
+ stays out of URLs (and any logs that record them).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from ..errors import ArrClientError
14
+ from .base import ArrClient
15
+
16
+ TYPE_NUM = {"movie": 1, "show": 2}
17
+
18
+
19
+ class PlexError(ArrClientError, RuntimeError):
20
+ pass
21
+
22
+
23
+ class PlexClient(ArrClient):
24
+ service = "Plex"
25
+ auth_label = "token"
26
+ error_cls = PlexError
27
+
28
+ def __init__(self, base_url: str, token: str, timeout: int = 30):
29
+ super().__init__(base_url, timeout=timeout,
30
+ headers={"X-Plex-Token": token,
31
+ "Accept": "application/json"})
32
+ self.token = token
33
+
34
+ def sections(self) -> list[dict]:
35
+ data = self._get("/library/sections")
36
+ return [{"key": s["key"], "type": s["type"], "title": s["title"]}
37
+ for s in data["MediaContainer"].get("Directory", [])]
38
+
39
+ @staticmethod
40
+ def _meta_labels(m: dict) -> list[str]:
41
+ return [lbl["tag"] for lbl in m.get("Label", [])]
42
+
43
+ def find(self, section_key: str, type_num: int, title: str,
44
+ year: int | None) -> dict | None:
45
+ """Best match by exact (case-insensitive) title, preferring the right year."""
46
+ data = self._get(f"/library/sections/{section_key}/all",
47
+ {"type": type_num, "title": title})
48
+ cands = data["MediaContainer"].get("Metadata", [])
49
+ exact = [m for m in cands if m.get("title", "").strip().lower() == title.strip().lower()]
50
+ pool = exact or cands
51
+ pick = None
52
+ if year is not None:
53
+ pick = next((m for m in pool if m.get("year") == year), None)
54
+ pick = pick or (pool[0] if pool else None)
55
+ if not pick:
56
+ return None
57
+ return {"ratingKey": pick["ratingKey"], "title": pick.get("title"),
58
+ "year": pick.get("year"), "labels": self._meta_labels(pick)}
59
+
60
+ def items_with_label(self, section_key: str, type_num: int, label: str) -> list[dict]:
61
+ data = self._get(f"/library/sections/{section_key}/all",
62
+ {"type": type_num, "label": label})
63
+ return [{"ratingKey": m["ratingKey"], "title": m.get("title"),
64
+ "year": m.get("year")} for m in data["MediaContainer"].get("Metadata", [])]
65
+
66
+ def add_label(self, section_key: str, type_num: int, rating_key: str, label: str) -> None:
67
+ self._put(f"/library/sections/{section_key}/all", {
68
+ "type": type_num, "id": rating_key,
69
+ "label[0].tag.tag": label, "label.locked": 1,
70
+ })
71
+
72
+ def remove_label(self, section_key: str, type_num: int, rating_key: str, label: str) -> None:
73
+ self._put(f"/library/sections/{section_key}/all", {
74
+ "type": type_num, "id": rating_key,
75
+ "label[].tag.tag-": label, "label.locked": 1,
76
+ })
77
+
78
+ def refresh_item(self, rating_key: str) -> None:
79
+ """Refresh one item's metadata (picks up newly muxed audio tracks).
80
+
81
+ Documented as PUT; older Plex versions only accept POST, so fall back.
82
+ The response body is empty, hence `_request` instead of `_put`/`_post`.
83
+ """
84
+ path = f"/library/metadata/{rating_key}/refresh"
85
+ try:
86
+ self._request("PUT", path)
87
+ except PlexError as exc:
88
+ if exc.status in (404, 405):
89
+ self._request("POST", path)
90
+ else:
91
+ raise
@@ -0,0 +1,26 @@
1
+ """Minimal Radarr API client (read-only for now)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..errors import ArrClientError
6
+ from .base import ArrClient
7
+
8
+
9
+ class RadarrError(ArrClientError, RuntimeError):
10
+ pass
11
+
12
+
13
+ class RadarrClient(ArrClient):
14
+ service = "Radarr"
15
+ error_cls = RadarrError
16
+
17
+ def __init__(self, base_url: str, api_key: str, timeout: int = 30):
18
+ super().__init__(base_url, timeout=timeout,
19
+ headers={"X-Api-Key": api_key})
20
+ self.api_key = api_key
21
+
22
+ def system_status(self) -> dict:
23
+ return self._get("/api/v3/system/status")
24
+
25
+ def list_movies(self) -> list[dict]:
26
+ return self._get("/api/v3/movie")
@@ -0,0 +1,32 @@
1
+ """Minimal Sonarr API client (read-only for now)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..errors import ArrClientError
6
+ from .base import ArrClient
7
+
8
+
9
+ class SonarrError(ArrClientError, RuntimeError):
10
+ pass
11
+
12
+
13
+ class SonarrClient(ArrClient):
14
+ service = "Sonarr"
15
+ error_cls = SonarrError
16
+
17
+ def __init__(self, base_url: str, api_key: str, timeout: int = 30):
18
+ super().__init__(base_url, timeout=timeout,
19
+ headers={"X-Api-Key": api_key})
20
+ self.api_key = api_key
21
+
22
+ def system_status(self) -> dict:
23
+ return self._get("/api/v3/system/status")
24
+
25
+ def list_series(self) -> list[dict]:
26
+ return self._get("/api/v3/series")
27
+
28
+ def episode_files(self, series_id: int) -> list[dict]:
29
+ return self._get(f"/api/v3/episodefile?seriesId={series_id}")
30
+
31
+ def episodes(self, series_id: int) -> list[dict]:
32
+ return self._get(f"/api/v3/episode?seriesId={series_id}")