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.
- doblarr/__init__.py +10 -0
- doblarr/__main__.py +8 -0
- doblarr/artifacts.py +54 -0
- doblarr/auth.py +39 -0
- doblarr/cache.py +48 -0
- doblarr/cli.py +104 -0
- doblarr/clients/__init__.py +1 -0
- doblarr/clients/base.py +104 -0
- doblarr/clients/plex.py +91 -0
- doblarr/clients/radarr.py +26 -0
- doblarr/clients/sonarr.py +32 -0
- doblarr/clients/translator.py +341 -0
- doblarr/clients/voicebox.py +232 -0
- doblarr/config.example.yaml +120 -0
- doblarr/config.py +161 -0
- doblarr/config_schema.py +168 -0
- doblarr/discovery.py +230 -0
- doblarr/errors.py +48 -0
- doblarr/events.py +62 -0
- doblarr/ffmpeg.py +64 -0
- doblarr/jobs.py +332 -0
- doblarr/library_service.py +115 -0
- doblarr/logging_setup.py +97 -0
- doblarr/model_pool.py +34 -0
- doblarr/models.py +76 -0
- doblarr/pipeline.py +344 -0
- doblarr/plex_labels.py +114 -0
- doblarr/presets.py +19 -0
- doblarr/recipes.py +166 -0
- doblarr/review.py +73 -0
- doblarr/routes/__init__.py +1 -0
- doblarr/routes/configuration.py +32 -0
- doblarr/routes/jobs.py +284 -0
- doblarr/routes/library.py +64 -0
- doblarr/routes/series.py +182 -0
- doblarr/routes/titles.py +299 -0
- doblarr/routes/voice_catalog.py +159 -0
- doblarr/scheduler.py +69 -0
- doblarr/server.py +175 -0
- doblarr/services.py +66 -0
- doblarr/stages/__init__.py +19 -0
- doblarr/stages/audition.py +113 -0
- doblarr/stages/common.py +174 -0
- doblarr/stages/diarize.py +162 -0
- doblarr/stages/extract.py +80 -0
- doblarr/stages/fit_timing.py +188 -0
- doblarr/stages/mix.py +227 -0
- doblarr/stages/mux.py +150 -0
- doblarr/stages/prepare.py +48 -0
- doblarr/stages/quality.py +174 -0
- doblarr/stages/separate.py +68 -0
- doblarr/stages/synthesize.py +339 -0
- doblarr/stages/transcribe.py +275 -0
- doblarr/stages/translate.py +77 -0
- doblarr/store.py +190 -0
- doblarr/subtitles.py +56 -0
- doblarr/telemetry.py +59 -0
- doblarr/versions.py +99 -0
- doblarr/voices.py +133 -0
- doblarr/web/android-chrome-192x192.png +0 -0
- doblarr/web/android-chrome-512x512.png +0 -0
- doblarr/web/apple-touch-icon.png +0 -0
- doblarr/web/favicon-16x16.png +0 -0
- doblarr/web/favicon-32x32.png +0 -0
- doblarr/web/favicon.ico +0 -0
- doblarr/web/index.html +383 -0
- doblarr/web/js/api.js +46 -0
- doblarr/web/js/app.js +339 -0
- doblarr/web/js/dom.js +21 -0
- doblarr/web/js/episodes.js +76 -0
- doblarr/web/js/identity.js +24 -0
- doblarr/web/js/jobs-data.js +9 -0
- doblarr/web/js/jobs.js +240 -0
- doblarr/web/js/library.js +147 -0
- doblarr/web/js/recipes.js +119 -0
- doblarr/web/js/review.js +127 -0
- doblarr/web/js/settings-model.js +118 -0
- doblarr/web/js/settings.js +112 -0
- doblarr/web/js/state.js +15 -0
- doblarr/web/js/title-routing.js +24 -0
- doblarr/web/js/title.js +504 -0
- doblarr/web/js/voice-picker.js +99 -0
- doblarr/web/logo.png +0 -0
- doblarr/web/site.webmanifest +20 -0
- doblarr/web/styles.css +233 -0
- doblarr/webhooks.py +68 -0
- doblarr-0.1.0.dist-info/METADATA +443 -0
- doblarr-0.1.0.dist-info/RECORD +92 -0
- doblarr-0.1.0.dist-info/WHEEL +5 -0
- doblarr-0.1.0.dist-info/entry_points.txt +2 -0
- doblarr-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
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)."""
|
doblarr/clients/base.py
ADDED
|
@@ -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)
|
doblarr/clients/plex.py
ADDED
|
@@ -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}")
|