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>
This commit is contained in:
gilfanovr
2026-10-11 16:12:31 +03:00
co-authored by Claude Opus 5.5
parent 880ad61253
commit 08aa787b95
5 changed files with 488 additions and 5 deletions
+112
View File
@@ -0,0 +1,112 @@
# Медиацентр на rpi. Разворачивается в /opt/media (docker compose up -d).
# Подробности и грабли — servers/rpi.md → «Медиацентр».
#
# Все *arr и qBittorrent видят один и тот же корень /data (= /mnt/backup/media),
# иначе импорт из torrents/ в library/ превращается в копирование вместо хардлинка
# и раздача занимает место дважды.
x-common: &common
restart: unless-stopped
environment: &env
PUID: "1000"
PGID: "1000"
TZ: Europe/Moscow
UMASK: "002"
services:
jellyfin:
image: jellyfin/jellyfin:latest
container_name: jellyfin
# host-сеть: иначе LG/iOS не находят сервер автообнаружением (UDP 7359 и DLNA-мультикаст
# не проходят через docker-бридж)
network_mode: host
user: "1000:1000"
restart: unless-stopped
environment:
TZ: Europe/Moscow
JELLYFIN_PublishedServerUrl: http://192.168.10.25:8096
volumes:
- ./config/jellyfin:/config
# кэш и транскоды — на HDD, а не на SD
- /mnt/backup/media/.cache/jellyfin:/cache
- /mnt/backup/media/library:/data/library:ro
qbittorrent:
<<: *common
image: lscr.io/linuxserver/qbittorrent:latest
container_name: qbittorrent
environment:
<<: *env
WEBUI_PORT: "8080"
TORRENTING_PORT: "6881"
volumes:
- ./config/qbittorrent:/config
- /mnt/backup/media:/data
ports:
- "8080:8080"
- "6881:6881"
- "6881:6881/udp"
prowlarr:
<<: *common
image: lscr.io/linuxserver/prowlarr:latest
container_name: prowlarr
volumes:
- ./config/prowlarr:/config
ports:
- "9696:9696"
flaresolverr:
# нужен Prowlarr для трекеров за Cloudflare (часть публичных)
image: ghcr.io/flaresolverr/flaresolverr:latest
container_name: flaresolverr
restart: unless-stopped
environment:
TZ: Europe/Moscow
LOG_LEVEL: info
ports:
- "8191:8191"
sonarr:
<<: *common
image: lscr.io/linuxserver/sonarr:latest
container_name: sonarr
volumes:
- ./config/sonarr:/config
- /mnt/backup/media:/data
ports:
- "8989:8989"
radarr:
<<: *common
image: lscr.io/linuxserver/radarr:latest
container_name: radarr
volumes:
- ./config/radarr:/config
- /mnt/backup/media:/data
ports:
- "7878:7878"
bazarr:
<<: *common
image: lscr.io/linuxserver/bazarr:latest
container_name: bazarr
volumes:
- ./config/bazarr:/config
- /mnt/backup/media/library:/data/library
ports:
- "6767:6767"
seerr:
# Seerr = слившиеся Jellyseerr + Overseerr. ⚠️ образ fallenbagel/jellyseerr заброшен
# (последний — 2025-08) и не логинится в Jellyfin 12 (400 на AuthenticateByName)
image: ghcr.io/seerr-team/seerr:latest
container_name: seerr
init: true
restart: unless-stopped
environment:
TZ: Europe/Moscow
volumes:
- ./config/seerr:/app/config
ports:
- "5055:5055"
+339
View File
@@ -0,0 +1,339 @@
#!/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]()