- stacks/rpi-media: docker-compose и идемпотентный provision.py - servers/rpi.md: раздел «Медиацентр», почему NTFS и SFTP на роутере, закрыт хвост про резерв адреса - AGENTS.md: роль малины Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
340 lines
18 KiB
Python
340 lines
18 KiB
Python
#!/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]()
|