#!/usr/bin/env python3 """Первичная связка медиастека на rpi через API. Идемпотентно: повторный запуск ничего не ломает, уже сделанное пропускает. Запуск на малине: python3 /opt/media/provision.py [шаг ...] Шаги: qbit jellyfin arr prowlarr bazarr seerr (без аргументов — все). Пароль админа берётся из /opt/media/.admin-password, API-ключи — из config.xml. """ from http.cookiejar import CookieJar import json, re, subprocess, sys, time, urllib.error, urllib.parse, urllib.request BASE = "/opt/media" USER = "rus" PW = open(f"{BASE}/.admin-password").read().strip() LANGS = ["ru", "en"] # языки аудио/субтитров по умолчанию def http(method, url, data=None, headers=None, form=False, opener=None, timeout=60): body = None h = dict(headers or {}) if data is not None: if form: body = urllib.parse.urlencode(data).encode() h.setdefault("Content-Type", "application/x-www-form-urlencoded") else: body = json.dumps(data).encode() h.setdefault("Content-Type", "application/json") req = urllib.request.Request(url, data=body, headers=h, method=method) try: r = opener.open(req, timeout=timeout) if opener else urllib.request.urlopen(req, timeout=timeout) raw = r.read().decode() try: return r.status, json.loads(raw) if raw else None except ValueError: return r.status, raw except urllib.error.HTTPError as e: return e.code, e.read().decode()[:500] def apikey(app): return re.search(r"([^<]+)", open(f"{BASE}/config/{app}/config.xml").read()).group(1) def log(*a): print(*a, flush=True) # ---------------------------------------------------------------- qBittorrent def qbit(): cj = CookieJar() op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj)) q = "http://127.0.0.1:8080" hdr = {"Referer": q} def ok(st, body): # 5.x отвечает 204 без тела, старые — 200 "Ok." return body == "Ok." or (st in (200, 204) and any("SID" in c.name for c in cj)) st, body = http("POST", q + "/api/v2/auth/login", {"username": USER, "password": PW}, hdr, form=True, opener=op) if not ok(st, body): # первый запуск: admin + временный пароль из лога logs = subprocess.run(["docker", "logs", "qbittorrent"], capture_output=True, text=True).stdout m = re.findall(r"temporary password is provided for this session: (\S+)", logs) if not m: sys.exit("qbit: не могу войти ни с админским, ни с временным паролем") st, body = http("POST", q + "/api/v2/auth/login", {"username": "admin", "password": m[-1]}, hdr, form=True, opener=op) assert ok(st, body), (st, body) prefs = { "web_ui_username": USER, "web_ui_password": PW, "save_path": "/data/torrents/other", "temp_path_enabled": False, "auto_tmm_enabled": True, # путь по категории — иначе *arr не найдут файлы "torrent_changed_tmm_enabled": True, "category_changed_tmm_enabled": True, "listen_port": 6881, "upnp": False, "max_active_downloads": 3, "max_active_torrents": 10, "max_active_uploads": 5, "preallocate_all": False, "bypass_local_auth": False, } http("POST", q + "/api/v2/app/setPreferences", {"json": json.dumps(prefs)}, hdr, form=True, opener=op) for cat in ("movies", "tv"): http("POST", q + "/api/v2/torrents/createCategory", {"category": cat, "savePath": f"/data/torrents/{cat}"}, hdr, form=True, opener=op) # перелогин уже новым пользователем — проверка cj.clear() st, body = http("POST", q + "/api/v2/auth/login", {"username": USER, "password": PW}, hdr, form=True, opener=op) st, cats = http("GET", q + "/api/v2/torrents/categories", headers=hdr, opener=op) log("qbit: login", ok(st, body), "categories", sorted(cats) if isinstance(cats, dict) else cats) # ---------------------------------------------------------------- Jellyfin JF = "http://127.0.0.1:8096" JF_AUTH = 'MediaBrowser Client="provision", Device="rpi", DeviceId="provision-rpi", Version="1.0"' def jf_token(): st, r = http("POST", JF + "/Users/AuthenticateByName", {"Username": USER, "Pw": PW}, {"Authorization": JF_AUTH}) assert st == 200, (st, r) return r["AccessToken"] def jellyfin(): st, info = http("GET", JF + "/System/Info/Public") if not info.get("StartupWizardCompleted"): h = {"Authorization": JF_AUTH} http("POST", JF + "/Startup/Configuration", {"UICulture": "ru", "MetadataCountryCode": "RU", "PreferredMetadataLanguage": "ru"}, h) http("GET", JF + "/Startup/User", headers=h) st, r = http("POST", JF + "/Startup/User", {"Name": USER, "Password": PW}, h) log("jellyfin: user", st) http("POST", JF + "/Startup/RemoteAccess", {"EnableRemoteAccess": True, "EnableAutomaticPortMapping": False}, h) st, r = http("POST", JF + "/Startup/Complete", headers=h) log("jellyfin: wizard complete", st) tok = jf_token() h = {"Authorization": f'{JF_AUTH}, Token="{tok}"'} st, folders = http("GET", JF + "/Library/VirtualFolders", headers=h) have = {f["Name"] for f in folders} for name, ctype, path in (("Фильмы", "movies", "/data/library/movies"), ("Сериалы", "tvshows", "/data/library/tv")): if name in have: continue q = urllib.parse.urlencode({"name": name, "collectionType": ctype, "paths": path, "refreshLibrary": "true"}) st, r = http("POST", f"{JF}/Library/VirtualFolders?{q}", {"LibraryOptions": {"PreferredMetadataLanguage": "ru", "MetadataCountryCode": "RU", "EnableRealtimeMonitor": True, "SubtitleDownloadLanguages": [], "SaveSubtitlesWithMedia": True}}, h) log("jellyfin: library", name, st) # язык аудио/субтитров по умолчанию у пользователя st, me = http("GET", JF + "/Users/Me", headers=h) cfg = me["Configuration"] cfg.update({"AudioLanguagePreference": "rus", "SubtitleLanguagePreference": "rus", "PlayDefaultAudioTrack": False, "SubtitleMode": "Smart"}) st, r = http("POST", f"{JF}/Users/{me['Id']}/Configuration", cfg, h) log("jellyfin: user prefs", st) # аппаратного транскодинга на Pi 4 нет (V4L2 убран в 10.9) — ограничиваем потоки, чтобы не задушить малину st, enc = http("GET", JF + "/System/Configuration/encoding", headers=h) enc.update({"HardwareAccelerationType": "none", "EncodingThreadCount": 3, "TranscodingTempPath": "/cache/transcodes"}) st, r = http("POST", JF + "/System/Configuration/encoding", enc, h) log("jellyfin: encoding", st) # ---------------------------------------------------------------- Sonarr / Radarr ARR = { "radarr": {"url": "http://127.0.0.1:7878", "api": "v3", "root": "/data/library/movies", "cat": "movies", "catfield": "movieCategory", "inner": "http://radarr:7878"}, "sonarr": {"url": "http://127.0.0.1:8989", "api": "v3", "root": "/data/library/tv", "cat": "tv", "catfield": "tvCategory", "inner": "http://sonarr:8989"}, } def arr(): for name, a in ARR.items(): k = apikey(name) h = {"X-Api-Key": k} u = f"{a['url']}/api/{a['api']}" # авторизация: формы, пароль всегда («без пароля из LAN» требует AllowedHosts в новых версиях) st, hc = http("GET", u + "/config/host", headers=h) if hc.get("authenticationMethod") != "forms": hc.update({"authenticationMethod": "forms", "authenticationRequired": "enabled", "username": USER, "password": PW, "passwordConfirmation": PW}) st, r = http("PUT", u + "/config/host", hc, h) log(name, "auth", st) st, roots = http("GET", u + "/rootfolder", headers=h) if not any(r["path"].rstrip("/") == a["root"] for r in roots): st, r = http("POST", u + "/rootfolder", {"path": a["root"]}, h) log(name, "rootfolder", st, r if st >= 300 else "") st, dcs = http("GET", u + "/downloadclient", headers=h) if not any(d["implementation"] == "QBittorrent" for d in dcs): st, schema = http("GET", u + "/downloadclient/schema", headers=h) dc = next(s for s in schema if s["implementation"] == "QBittorrent") vals = {"host": "qbittorrent", "port": 8080, "username": USER, "password": PW, a["catfield"]: a["cat"]} for f in dc["fields"]: if f["name"] in vals: f["value"] = vals[f["name"]] dc.update({"name": "qBittorrent", "enable": True, "protocol": "torrent", "priority": 1}) st, r = http("POST", u + "/downloadclient", dc, h) log(name, "downloadclient", st, r if st >= 300 else "") # хардлинки вместо копирования при импорте st, mm = http("GET", u + "/config/mediamanagement", headers=h) # ⚠️ медиа живут на диске бэкапов: onedrive-backup падает при заполнении >90 % (~370 ГБ свободно). # 500 ГБ — порог с запасом, ниже которого *arr перестают импортировать. want = {"copyUsingHardlinks": True, "minimumFreeSpaceWhenImporting": 500_000} if any(mm.get(k) != v for k, v in want.items()): mm.update(want) st, r = http("PUT", u + "/config/mediamanagement", mm, h) log(name, "mediamanagement", st) log(name, "ok") # ---------------------------------------------------------------- Prowlarr def prowlarr(): k = apikey("prowlarr") h = {"X-Api-Key": k} u = "http://127.0.0.1:9696/api/v1" st, hc = http("GET", u + "/config/host", headers=h) if hc.get("authenticationMethod") != "forms": hc.update({"authenticationMethod": "forms", "authenticationRequired": "enabled", "username": USER, "password": PW, "passwordConfirmation": PW}) st, r = http("PUT", u + "/config/host", hc, h) log("prowlarr auth", st) # FlareSolverr как прокси с тегом flaresolverr st, tags = http("GET", u + "/tag", headers=h) tag = next((t for t in tags if t["label"] == "flaresolverr"), None) if not tag: st, tag = http("POST", u + "/tag", {"label": "flaresolverr"}, h) st, proxies = http("GET", u + "/indexerProxy", headers=h) if not any(p["implementation"] == "FlareSolverr" for p in proxies): st, schema = http("GET", u + "/indexerProxy/schema", headers=h) p = next(s for s in schema if s["implementation"] == "FlareSolverr") for f in p["fields"]: if f["name"] == "host": f["value"] = "http://flaresolverr:8191/" p.update({"name": "FlareSolverr", "tags": [tag["id"]]}) st, r = http("POST", u + "/indexerProxy", p, h) log("prowlarr flaresolverr", st, r if st >= 300 else "") # приложения: Prowlarr сам раздаёт индексаторы в Sonarr/Radarr st, apps = http("GET", u + "/applications", headers=h) have = {a["implementation"] for a in apps} st, schema = http("GET", u + "/applications/schema", headers=h) for name, impl in (("radarr", "Radarr"), ("sonarr", "Sonarr")): if impl in have: continue a = next(s for s in schema if s["implementation"] == impl) vals = {"prowlarrUrl": "http://prowlarr:9696", "baseUrl": ARR[name]["inner"], "apiKey": apikey(name)} for f in a["fields"]: if f["name"] in vals: f["value"] = vals[f["name"]] a.update({"name": impl, "syncLevel": "fullSync"}) st, r = http("POST", u + "/applications", a, h) log("prowlarr app", impl, st, r if st >= 300 else "") # публичные индексаторы без регистрации; приватные (RuTracker, Kinozal) — руками, нужен аккаунт st, idx = http("GET", u + "/indexer", headers=h) have = {i["definitionName"] for i in idx} st, schema = http("GET", u + "/indexer/schema", headers=h) by = {s["definitionName"]: s for s in schema} for d in ("rutor", "noname-club", "1337x", "thepiratebay", "eztv", "yts", "kinozal"): if d in have or d not in by: if d not in by: log("prowlarr indexer", d, "нет в схеме") continue s = by[d] if s.get("privacy") != "public": continue s.update({"name": s["name"], "enable": True, "appProfileId": 1, "priority": 25}) if d == "1337x": s["tags"] = [tag["id"]] # за Cloudflare try: # Prowlarr тестирует индексатор при добавлении; через FlareSolverr это долго st, r = http("POST", u + "/indexer", s, h, timeout=240) except OSError as e: log("prowlarr indexer", d, "не добавлен:", e) continue log("prowlarr indexer", d, st, (r if isinstance(r, str) else "")[:200] if st >= 300 else "") http("POST", u + "/command", {"name": "ApplicationIndexerSync"}, h) log("prowlarr ok") # ---------------------------------------------------------------- Bazarr def bazarr(): import yaml # python3-yaml есть в Raspberry Pi OS cfg = yaml.safe_load(open(f"{BASE}/config/bazarr/config/config.yaml")) h = {"X-API-KEY": cfg["auth"]["apikey"]} u = "http://127.0.0.1:6767/api" profile = {"profileId": 1, "name": "ru+en", "cutoff": None, "mustContain": [], "mustNotContain": [], "originalFormat": False, "tag": None, "items": [{"id": i + 1, "language": l, "forced": "False", "hi": "False", "audio_exclude": "False", "audio_only_include": "False"} for i, l in enumerate(LANGS)]} form = [ ("languages-enabled", json.dumps(LANGS)), ("languages-profiles", json.dumps([profile])), ("settings-general-serie_default_enabled", "true"), ("settings-general-serie_default_profile", "1"), ("settings-general-movie_default_enabled", "true"), ("settings-general-movie_default_profile", "1"), # без аккаунтов; OpenSubtitles.com (лучший для ru) требует логин — добавить руками ("settings-general-enabled_providers", json.dumps(["embeddedsubtitles", "podnapisi", "yifysubtitles"])), ("settings-general-use_sonarr", "true"), ("settings-general-use_radarr", "true"), ("settings-sonarr-ip", "sonarr"), ("settings-sonarr-port", "8989"), ("settings-sonarr-apikey", apikey("sonarr")), ("settings-radarr-ip", "radarr"), ("settings-radarr-port", "7878"), ("settings-radarr-apikey", apikey("radarr")), ("settings-auth-type", "form"), ("settings-auth-username", USER), ("settings-auth-password", PW), ] body = urllib.parse.urlencode(form).encode() req = urllib.request.Request(u + "/system/settings", data=body, headers=h, method="POST") try: st = urllib.request.urlopen(req, timeout=60).status except urllib.error.HTTPError as e: st = (e.code, e.read().decode()[:300]) log("bazarr settings", st) st, prof = http("GET", u + "/system/languages/profiles", headers=h) log("bazarr profiles", [p["name"] for p in prof] if isinstance(prof, list) else prof) # ---------------------------------------------------------------- Jellyseerr def jellyseerr(): js = "http://127.0.0.1:5055/api/v1" cj = CookieJar() op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj)) st, pub = http("GET", js + "/settings/public", opener=op) if not pub.get("initialized"): st, r = http("POST", js + "/auth/jellyfin", {"username": USER, "password": PW, "hostname": "192.168.10.25", "port": 8096, "useSsl": False, "urlBase": "", "email": "rus@localhost", "serverType": 2}, opener=op) log("seerr: jellyfin login", st, r if st >= 300 else "") st, libs = http("GET", js + "/settings/jellyfin/library?sync=true", opener=op) ids = ",".join(l["id"] for l in libs) if isinstance(libs, list) else "" http("GET", js + f"/settings/jellyfin/library?enable={ids}", opener=op) st, r = http("POST", js + "/settings/initialize", {}, opener=op) log("seerr: initialize", st) else: st, r = http("POST", js + "/auth/jellyfin", {"username": USER, "password": PW}, opener=op) log("seerr: login", st) for name, path, port in (("radarr", "/settings/radarr", 7878), ("sonarr", "/settings/sonarr", 8989)): st, cur = http("GET", js + path, opener=op) if cur: continue k = apikey(name) base = f"http://127.0.0.1:{port}/api/v3" st, profiles = http("GET", base + "/qualityprofile", headers={"X-Api-Key": k}) prof = next((p for p in profiles if p["name"] in ("HD-1080p", "HD - 720p/1080p")), profiles[0]) body = {"name": name.capitalize(), "hostname": name, "port": port, "apiKey": k, "useSsl": False, "baseUrl": "", "activeProfileId": prof["id"], "activeProfileName": prof["name"], "activeDirectory": ARR[name]["root"], "is4k": False, "isDefault": True, "externalUrl": "", "syncEnabled": True, "preventSearch": False} if name == "radarr": body["minimumAvailability"] = "released" else: body.update({"activeAnimeProfileId": prof["id"], "activeAnimeDirectory": ARR[name]["root"], "enableSeasonFolders": True}) st, r = http("POST", js + path, body, opener=op) log("seerr:", name, st, r if st >= 300 else "") log("seerr ok") STEPS = {"qbit": qbit, "jellyfin": jellyfin, "arr": arr, "prowlarr": prowlarr, "bazarr": bazarr, "seerr": jellyseerr} if __name__ == "__main__": for s in sys.argv[1:] or list(STEPS): log(f"=== {s}") STEPS[s]()