Files
infra/stacks/rpi-media/provision.py
T
gilfanovrandClaude Opus 5.5 08aa787b95 rpi: медиацентр (Jellyfin, Seerr, qBittorrent, *arr) в Docker; диск роутера и резерв DHCP
- stacks/rpi-media: docker-compose и идемпотентный provision.py
- servers/rpi.md: раздел «Медиацентр», почему NTFS и SFTP на роутере,
  закрыт хвост про резерв адреса
- AGENTS.md: роль малины

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:12:31 +03:00

340 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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"<ApiKey>([^<]+)", 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]()