feat: add Sonarr support with background scanning and progress tracking
Add Sonarr integration alongside existing Radarr functionality. Implement background ffprobe scanning with ThreadPoolExecutor, progress tracking API, and separate navigation for movies/series. Series are grouped by show with collapsible episodes. Reduce Gunicorn workers to 1, add scan status endpoint, and update README with Sonarr configuration instructions.
This commit is contained in:
+1
-1
@@ -8,4 +8,4 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app.py .
|
||||
COPY templates ./templates
|
||||
EXPOSE 8099
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:8099", "--workers", "2", "--threads", "4", "--timeout", "120", "app:app"]
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:8099", "--workers", "1", "--threads", "4", "--timeout", "120", "app:app"]
|
||||
|
||||
@@ -32,7 +32,16 @@ docker compose up -d --build
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
Beim ersten Laden erscheinen im Log `Gescannt:`-Einträge.
|
||||
Die Seite lädt zuerst die Library-Metadaten. Die ffprobe-Scans laufen danach im Hintergrund; oben wird der Fortschritt angezeigt. Für Sonarr die optionalen Variablen und den Serien-Volume-Mount in `docker-compose.yml` aktivieren. Die Navigation trennt Filme und Serien, Serien sind pro Show aufklappbar.
|
||||
|
||||
Sonarr benötigt:
|
||||
|
||||
```yaml
|
||||
SONARR_URL: "http://sonarr:8989"
|
||||
SONARR_API_KEY: "..."
|
||||
SONARR_MEDIA_PATH: "/data/serien"
|
||||
LOCAL_SERIES_PATH: "/media/serien"
|
||||
```
|
||||
|
||||
## Bei weiterem Scanfehler
|
||||
|
||||
|
||||
@@ -3,75 +3,46 @@ import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from flask import Flask, render_template, jsonify
|
||||
from flask import Flask, jsonify, render_template, request
|
||||
|
||||
RADARR_URL = os.environ.get("RADARR_URL", "http://radarr:7878").rstrip("/")
|
||||
RADARR_API_KEY = os.environ.get("RADARR_API_KEY", "")
|
||||
RADARR_MEDIA_PATH = os.environ.get("RADARR_MEDIA_PATH", "/data/filme").rstrip("/")
|
||||
LOCAL_MEDIA_PATH = os.environ.get("LOCAL_MEDIA_PATH", "/media/filme").rstrip("/")
|
||||
SONARR_URL = os.environ.get("SONARR_URL", "").rstrip("/")
|
||||
SONARR_API_KEY = os.environ.get("SONARR_API_KEY", "")
|
||||
SONARR_MEDIA_PATH = os.environ.get("SONARR_MEDIA_PATH", "/data/serien").rstrip("/")
|
||||
LOCAL_SERIES_PATH = os.environ.get("LOCAL_SERIES_PATH", "/media/serien").rstrip("/")
|
||||
DB_PATH = os.environ.get("DB_PATH", "/data/cache.db")
|
||||
REQUEST_TIMEOUT = int(os.environ.get("REQUEST_TIMEOUT", "20"))
|
||||
|
||||
logging.basicConfig(
|
||||
level=os.environ.get("LOG_LEVEL", "INFO").upper(),
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
)
|
||||
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger("radarr-language-dashboard")
|
||||
|
||||
app = Flask(__name__)
|
||||
executor = ThreadPoolExecutor(max_workers=int(os.environ.get("SCAN_WORKERS", "2")))
|
||||
jobs = {"radarr": {"state": "idle", "total": 0, "done": 0, "error": None}, "sonarr": {"state": "idle", "total": 0, "done": 0, "error": None}}
|
||||
job_lock = threading.Lock()
|
||||
|
||||
LANG_NAMES = {
|
||||
"de": "Deutsch", "deu": "Deutsch", "ger": "Deutsch",
|
||||
"en": "Englisch", "eng": "Englisch",
|
||||
"ja": "Japanisch", "jpn": "Japanisch",
|
||||
"ko": "Koreanisch", "kor": "Koreanisch",
|
||||
"fr": "Französisch", "fra": "Französisch", "fre": "Französisch",
|
||||
"es": "Spanisch", "spa": "Spanisch",
|
||||
"it": "Italienisch", "ita": "Italienisch",
|
||||
"ru": "Russisch", "rus": "Russisch",
|
||||
"zh": "Chinesisch", "zho": "Chinesisch", "chi": "Chinesisch",
|
||||
"und": "Unbekannt", "": "Unbekannt",
|
||||
}
|
||||
|
||||
FLAGS = {
|
||||
"Deutsch": "🇩🇪",
|
||||
"Englisch": "🇬🇧",
|
||||
"Japanisch": "🇯🇵",
|
||||
"Koreanisch": "🇰🇷",
|
||||
"Französisch": "🇫🇷",
|
||||
"Spanisch": "🇪🇸",
|
||||
"Italienisch": "🇮🇹",
|
||||
"Russisch": "🇷🇺",
|
||||
"Chinesisch": "🇨🇳",
|
||||
"Unbekannt": "❓",
|
||||
}
|
||||
LANG_NAMES = {"de":"Deutsch", "deu":"Deutsch", "ger":"Deutsch", "en":"Englisch", "eng":"Englisch", "ja":"Japanisch", "jpn":"Japanisch", "ko":"Koreanisch", "kor":"Koreanisch", "fr":"Französisch", "fra":"Französisch", "fre":"Französisch", "es":"Spanisch", "spa":"Spanisch", "it":"Italienisch", "ita":"Italienisch", "ru":"Russisch", "rus":"Russisch", "zh":"Chinesisch", "zho":"Chinesisch", "chi":"Chinesisch", "und":"Unbekannt", "":"Unbekannt"}
|
||||
FLAGS = {"Deutsch":"🇩🇪", "Englisch":"🇬🇧", "Japanisch":"🇯🇵", "Koreanisch":"🇰🇷", "Französisch":"🇫🇷", "Spanisch":"🇪🇸", "Italienisch":"🇮🇹", "Russisch":"🇷🇺", "Chinesisch":"🇨🇳", "Unbekannt":"❓"}
|
||||
|
||||
def db():
|
||||
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
con = sqlite3.connect(DB_PATH)
|
||||
con.row_factory = sqlite3.Row
|
||||
con.execute("""
|
||||
CREATE TABLE IF NOT EXISTS media_cache (
|
||||
path TEXT PRIMARY KEY,
|
||||
mtime REAL NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
scanned_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
con.execute("CREATE TABLE IF NOT EXISTS media_cache (path TEXT PRIMARY KEY, mtime REAL NOT NULL, data TEXT NOT NULL, scanned_at TEXT NOT NULL)")
|
||||
return con
|
||||
|
||||
def radarr_get(endpoint):
|
||||
if not RADARR_API_KEY:
|
||||
raise RuntimeError("RADARR_API_KEY ist nicht gesetzt.")
|
||||
r = requests.get(
|
||||
f"{RADARR_URL}/api/v3/{endpoint.lstrip('/')}",
|
||||
headers={"X-Api-Key": RADARR_API_KEY},
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
def api_get(base, key, endpoint):
|
||||
if not key:
|
||||
raise RuntimeError(f"API-Key für {base} ist nicht gesetzt.")
|
||||
r = requests.get(f"{base}/api/v3/{endpoint.lstrip('/')}", headers={"X-Api-Key": key}, timeout=REQUEST_TIMEOUT)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
@@ -81,215 +52,144 @@ def norm_lang(code):
|
||||
return {"code": code or "und", "name": name, "flag": FLAGS.get(name, "🌐")}
|
||||
|
||||
def unique_langs(items):
|
||||
seen, out = set(), []
|
||||
out, seen = [], set()
|
||||
for item in items:
|
||||
key = item["name"]
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
out.append(item)
|
||||
if item["name"] not in seen:
|
||||
seen.add(item["name"]); out.append(item)
|
||||
return out
|
||||
|
||||
def map_radarr_path(path):
|
||||
if not path:
|
||||
return None
|
||||
if RADARR_MEDIA_PATH and path.startswith(RADARR_MEDIA_PATH):
|
||||
suffix = path[len(RADARR_MEDIA_PATH):].lstrip("/")
|
||||
return str(Path(LOCAL_MEDIA_PATH) / suffix)
|
||||
def map_path(path, remote, local):
|
||||
if not path: return None
|
||||
if remote and path.startswith(remote):
|
||||
return str(Path(local) / path[len(remote):].lstrip("/"))
|
||||
return path
|
||||
|
||||
def run_ffprobe(path):
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"ffprobe", "-v", "error",
|
||||
"-show_entries",
|
||||
"format=duration,size,bit_rate:stream=index,codec_type,codec_name,profile,channels,channel_layout:stream_tags=language,title",
|
||||
"-of", "json",
|
||||
path,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=90,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(proc.stderr.strip() or "ffprobe fehlgeschlagen")
|
||||
proc = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=size:stream=index,codec_type,codec_name,channels,channel_layout:stream_tags=language,title", "-of", "json", path], capture_output=True, text=True, timeout=90)
|
||||
if proc.returncode != 0: raise RuntimeError(proc.stderr.strip() or "ffprobe fehlgeschlagen")
|
||||
raw, audios, subs, details, video = json.loads(proc.stdout or "{}"), [], [], [], None
|
||||
for stream in raw.get("streams", []):
|
||||
tags = stream.get("tags") or {}; lang = norm_lang(tags.get("language")); kind = stream.get("codec_type")
|
||||
if kind == "video" and not video: video = stream.get("codec_name")
|
||||
elif kind == "audio":
|
||||
audios.append(lang); details.append({"language": lang, "codec": stream.get("codec_name"), "channels": stream.get("channels"), "layout": stream.get("channel_layout"), "title": tags.get("title")})
|
||||
elif kind == "subtitle": subs.append(lang)
|
||||
return {"audio_languages": unique_langs(audios), "subtitle_languages": unique_langs(subs), "audio_details": details, "video_codec": video or "—", "size": int((raw.get("format") or {}).get("size") or 0)}
|
||||
|
||||
raw = json.loads(proc.stdout or "{}")
|
||||
audios, subs, audio_details = [], [], []
|
||||
video_codec = None
|
||||
|
||||
for s in raw.get("streams", []):
|
||||
stype = s.get("codec_type")
|
||||
tags = s.get("tags") or {}
|
||||
lang = norm_lang(tags.get("language"))
|
||||
|
||||
if stype == "video" and not video_codec:
|
||||
video_codec = s.get("codec_name")
|
||||
elif stype == "audio":
|
||||
audios.append(lang)
|
||||
audio_details.append({
|
||||
"language": lang,
|
||||
"codec": s.get("codec_name"),
|
||||
"channels": s.get("channels"),
|
||||
"layout": s.get("channel_layout"),
|
||||
"title": tags.get("title"),
|
||||
})
|
||||
elif stype == "subtitle":
|
||||
subs.append(lang)
|
||||
|
||||
fmt = raw.get("format") or {}
|
||||
return {
|
||||
"audio_languages": unique_langs(audios),
|
||||
"subtitle_languages": unique_langs(subs),
|
||||
"audio_details": audio_details,
|
||||
"video_codec": video_codec or "—",
|
||||
"size": int(fmt.get("size") or 0),
|
||||
}
|
||||
|
||||
def cached_probe(path):
|
||||
def cached(path):
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
msg = f"Datei nicht gefunden: {p}"
|
||||
log.warning(msg)
|
||||
return {"error": msg}
|
||||
if not p.exists(): return {"error": f"Datei nicht gefunden: {p}"}
|
||||
mtime = p.stat().st_mtime; con = db(); row = con.execute("SELECT mtime,data FROM media_cache WHERE path=?", (str(p),)).fetchone(); con.close()
|
||||
if row and float(row["mtime"]) == float(mtime): return json.loads(row["data"])
|
||||
return None
|
||||
|
||||
def scan_one(path):
|
||||
p = Path(path)
|
||||
if not p.exists(): data = {"error": f"Datei nicht gefunden: {p}"}
|
||||
else:
|
||||
try: data = run_ffprobe(str(p)); log.info("Gescannt: %s", p)
|
||||
except Exception as exc: data = {"error": str(exc)}; log.exception("Scanfehler für %s", p)
|
||||
mtime = p.stat().st_mtime
|
||||
con = db()
|
||||
row = con.execute(
|
||||
"SELECT mtime, data FROM media_cache WHERE path = ?",
|
||||
(str(p),),
|
||||
).fetchone()
|
||||
|
||||
if row and float(row["mtime"]) == float(mtime):
|
||||
con.close()
|
||||
return json.loads(row["data"])
|
||||
|
||||
try:
|
||||
data = run_ffprobe(str(p))
|
||||
log.info("Gescannt: %s", p)
|
||||
except Exception as e:
|
||||
data = {"error": str(e)}
|
||||
log.exception("Scanfehler für %s", p)
|
||||
|
||||
con.execute(
|
||||
"""INSERT INTO media_cache(path, mtime, data, scanned_at)
|
||||
VALUES(?,?,?,?)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
mtime=excluded.mtime,
|
||||
data=excluded.data,
|
||||
scanned_at=excluded.scanned_at""",
|
||||
(str(p), mtime, json.dumps(data), datetime.utcnow().isoformat()),
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
con = db(); con.execute("INSERT INTO media_cache(path,mtime,data,scanned_at) VALUES(?,?,?,?) ON CONFLICT(path) DO UPDATE SET mtime=excluded.mtime,data=excluded.data,scanned_at=excluded.scanned_at", (str(p), mtime, json.dumps(data), datetime.utcnow().isoformat())); con.commit(); con.close()
|
||||
return data
|
||||
|
||||
def format_size(size):
|
||||
if not size:
|
||||
return "—"
|
||||
if not size: return "—"
|
||||
value = float(size)
|
||||
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
||||
if value < 1024 or unit == "TB":
|
||||
return f"{value:.1f} {unit}"
|
||||
if value < 1024 or unit == "TB": return f"{value:.1f} {unit}"
|
||||
value /= 1024
|
||||
|
||||
def language_state(audio_languages):
|
||||
names = {x["name"] for x in audio_languages}
|
||||
if "Deutsch" in names and "Englisch" in names:
|
||||
return {"class": "ok", "label": "DE + EN"}
|
||||
if "Deutsch" in names:
|
||||
return {"class": "warn", "label": "Deutsch"}
|
||||
if "Englisch" in names:
|
||||
return {"class": "bad", "label": "English only"}
|
||||
def language_state(audios):
|
||||
names = {x["name"] for x in audios}
|
||||
if {"Deutsch", "Englisch"} <= names: return {"class":"ok", "label":"DE + EN"}
|
||||
if "Deutsch" in names: return {"class":"warn", "label":"Deutsch"}
|
||||
if "Englisch" in names: return {"class":"bad", "label":"English only"}
|
||||
return {"class":"neutral", "label":"Andere"}
|
||||
|
||||
def library():
|
||||
movies = radarr_get("movie")
|
||||
def media_row(name, year, media_file, remote, local, extra=None):
|
||||
path = media_file.get("path") if media_file else None
|
||||
if not path and media_file and media_file.get("relativePath"): path = str(Path(media_file.get("basePath") or "") / media_file["relativePath"])
|
||||
local_path = map_path(path, remote, local); info = cached(local_path) if local_path else {"error":"Kein Dateipfad erhalten"}
|
||||
info = info or {"pending": True}; audios = info.get("audio_languages", [])
|
||||
quality = ((((media_file or {}).get("quality") or {}).get("quality") or {}).get("name")) or "—"
|
||||
row = {"title":name, "year":year, "quality":quality if media_file else "Keine Datei", "audio_languages":audios, "subtitle_languages":info.get("subtitle_languages", []), "video_codec":info.get("video_codec", "—"), "size":format_size(info.get("size") or (media_file or {}).get("size")), "radarr_path":path or "—", "local_path":local_path or "—", "state":language_state(audios) if audios else {"class":"neutral", "label":"Scan ausstehend" if info.get("pending") else ("Fehler" if info.get("error") else "Keine Spuren")}, "error":info.get("error"), "missing":not bool(media_file), "pending":bool(info.get("pending"))}
|
||||
if extra: row.update(extra)
|
||||
return row
|
||||
|
||||
def radarr_rows():
|
||||
rows = []
|
||||
for movie in api_get(RADARR_URL, RADARR_API_KEY, "movie"):
|
||||
mf = movie.get("movieFile"); path = (mf or {}).get("path") or ((movie.get("path") and (mf or {}).get("relativePath")) and str(Path(movie["path"]) / mf["relativePath"]))
|
||||
if mf and path: mf = dict(mf); mf["path"] = path
|
||||
rows.append(media_row(movie.get("title", "—"), movie.get("year"), mf, RADARR_MEDIA_PATH, LOCAL_MEDIA_PATH))
|
||||
return sorted(rows, key=lambda x: x["title"].lower())
|
||||
|
||||
for movie in movies:
|
||||
mf = movie.get("movieFile")
|
||||
def sonarr_groups():
|
||||
groups = []
|
||||
for series in api_get(SONARR_URL, SONARR_API_KEY, "series"):
|
||||
episodes = api_get(SONARR_URL, SONARR_API_KEY, f"episode?seriesId={series['id']}&includeSeries=false")
|
||||
files = {f["id"]: f for f in api_get(SONARR_URL, SONARR_API_KEY, f"episodefile?seriesId={series['id']}")}
|
||||
items = []
|
||||
for episode in sorted(episodes, key=lambda x: (x.get("seasonNumber", 0), x.get("episodeNumber", 0))):
|
||||
ef = files.get(episode.get("episodeFileId"));
|
||||
if ef: ef = dict(ef); ef["path"] = str(Path(series.get("path") or "") / ef.get("relativePath", ""))
|
||||
label = f"S{episode.get('seasonNumber', 0):02d}E{episode.get('episodeNumber', 0):02d} · {episode.get('title') or '—'}"
|
||||
items.append(media_row(label, None, ef, SONARR_MEDIA_PATH, LOCAL_SERIES_PATH, {"episode":True}))
|
||||
groups.append({"title":series.get("title", "—"), "year":series.get("year"), "episodes":items})
|
||||
return sorted(groups, key=lambda x: x["title"].lower())
|
||||
|
||||
if not mf:
|
||||
rows.append({
|
||||
"title": movie.get("title", "—"),
|
||||
"year": movie.get("year"),
|
||||
"quality": "Keine Datei",
|
||||
"audio_languages": [],
|
||||
"subtitle_languages": [],
|
||||
"video_codec": "—",
|
||||
"size": "—",
|
||||
"radarr_path": "—",
|
||||
"local_path": "—",
|
||||
"state": {"class": "neutral", "label": "Keine Datei"},
|
||||
"error": None,
|
||||
"missing": True,
|
||||
})
|
||||
continue
|
||||
|
||||
radarr_path = mf.get("path")
|
||||
if not radarr_path and mf.get("relativePath"):
|
||||
radarr_path = str(Path(movie.get("path") or "") / mf["relativePath"])
|
||||
|
||||
local_path = map_radarr_path(radarr_path)
|
||||
info = cached_probe(local_path) if local_path else {"error": "Kein Dateipfad erhalten"}
|
||||
|
||||
quality = (((mf.get("quality") or {}).get("quality") or {}).get("name")) or "—"
|
||||
audios = info.get("audio_languages", [])
|
||||
|
||||
rows.append({
|
||||
"title": movie.get("title", "—"),
|
||||
"year": movie.get("year"),
|
||||
"quality": quality,
|
||||
"audio_languages": audios,
|
||||
"subtitle_languages": info.get("subtitle_languages", []),
|
||||
"video_codec": info.get("video_codec", "—"),
|
||||
"size": format_size(info.get("size") or mf.get("size")),
|
||||
"radarr_path": radarr_path or "—",
|
||||
"local_path": local_path or "—",
|
||||
"state": language_state(audios),
|
||||
"error": info.get("error"),
|
||||
"missing": False,
|
||||
})
|
||||
|
||||
rows.sort(key=lambda x: x["title"].lower())
|
||||
return rows
|
||||
def start_scan(source, force=False):
|
||||
with job_lock:
|
||||
if jobs[source]["state"] == "running" or (jobs[source]["state"] in ("done", "error") and not force): return
|
||||
jobs[source] = {"state":"running", "total":0, "done":0, "error":None}
|
||||
def work():
|
||||
try:
|
||||
paths = []
|
||||
if source == "radarr":
|
||||
for row in radarr_rows():
|
||||
if row["local_path"] != "—" and row["pending"]: paths.append(row["local_path"])
|
||||
else:
|
||||
for group in sonarr_groups():
|
||||
paths.extend(r["local_path"] for r in group["episodes"] if r["local_path"] != "—" and r["pending"])
|
||||
with job_lock: jobs[source]["total"] = len(paths)
|
||||
for path in paths:
|
||||
scan_one(path)
|
||||
with job_lock: jobs[source]["done"] += 1
|
||||
with job_lock: jobs[source]["state"] = "done"
|
||||
except Exception as exc:
|
||||
log.exception("%s-Scan fehlgeschlagen", source); jobs[source]["error"] = str(exc); jobs[source]["state"] = "error"
|
||||
executor.submit(work)
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
error = None
|
||||
rows = []
|
||||
error = None; rows = []; series = []
|
||||
try:
|
||||
rows = library()
|
||||
except Exception as e:
|
||||
log.exception("Fehler beim Laden der Library")
|
||||
error = str(e)
|
||||
|
||||
return render_template(
|
||||
"index.html",
|
||||
rows=rows,
|
||||
error=error,
|
||||
radarr_url=RADARR_URL,
|
||||
radarr_media_path=RADARR_MEDIA_PATH,
|
||||
local_media_path=LOCAL_MEDIA_PATH,
|
||||
)
|
||||
rows = radarr_rows()
|
||||
if any(row["pending"] for row in rows): start_scan("radarr")
|
||||
except Exception as exc: error = str(exc)
|
||||
if SONARR_URL and SONARR_API_KEY:
|
||||
try:
|
||||
series = sonarr_groups()
|
||||
if any(row["pending"] for group in series for row in group["episodes"]): start_scan("sonarr")
|
||||
except Exception as exc: log.exception("Sonarr nicht erreichbar"); error = f"{error + ' | ' if error else ''}Sonarr: {exc}"
|
||||
return render_template("index.html", rows=rows, series=series, error=error, sonarr_enabled=bool(SONARR_URL and SONARR_API_KEY), radarr_media_path=RADARR_MEDIA_PATH, local_media_path=LOCAL_MEDIA_PATH, local_series_path=LOCAL_SERIES_PATH)
|
||||
|
||||
@app.post("/api/rescan")
|
||||
def api_rescan():
|
||||
source = request.args.get("source", "radarr")
|
||||
if source not in jobs: return jsonify({"ok":False, "error":"Unbekannte Quelle"}), 400
|
||||
con = db()
|
||||
con.execute("DELETE FROM media_cache")
|
||||
con.commit()
|
||||
con.close()
|
||||
log.info("Cache geleert")
|
||||
if source == "radarr": con.execute("DELETE FROM media_cache")
|
||||
else: con.execute("DELETE FROM media_cache WHERE path LIKE ?", (LOCAL_SERIES_PATH.rstrip("/") + "/%",))
|
||||
con.commit(); con.close(); start_scan(source, force=True)
|
||||
return jsonify({"ok":True})
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"radarr_url": RADARR_URL,
|
||||
"radarr_media_path": RADARR_MEDIA_PATH,
|
||||
"local_media_path": LOCAL_MEDIA_PATH,
|
||||
})
|
||||
@app.get("/api/scan-status")
|
||||
def scan_status():
|
||||
with job_lock: return jsonify(jobs)
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8099)
|
||||
@app.get("/health")
|
||||
def health(): return jsonify({"ok":True, "radarr_url":RADARR_URL, "sonarr_enabled":bool(SONARR_URL and SONARR_API_KEY)})
|
||||
|
||||
if __name__ == "__main__": app.run(host="0.0.0.0", port=8099)
|
||||
|
||||
@@ -9,17 +9,24 @@ services:
|
||||
RADARR_URL: "http://radarr:7878"
|
||||
RADARR_API_KEY: "CHANGE_ME"
|
||||
|
||||
# Optional: Sonarr aktivieren (auskommentieren und Werte anpassen)
|
||||
# SONARR_URL: "http://sonarr:8989"
|
||||
# SONARR_API_KEY: "CHANGE_ME"
|
||||
|
||||
# Pfad, den Radarr in seiner API meldet:
|
||||
RADARR_MEDIA_PATH: "/data/filme"
|
||||
|
||||
# Pfad derselben Dateien INNERHALB dieses Containers:
|
||||
LOCAL_MEDIA_PATH: "/media/filme"
|
||||
# SONARR_MEDIA_PATH: "/data/serien"
|
||||
# LOCAL_SERIES_PATH: "/media/serien"
|
||||
|
||||
DB_PATH: "/data/cache.db"
|
||||
LOG_LEVEL: "INFO"
|
||||
volumes:
|
||||
# Host-Pfad deiner Film-Library -> interner Dashboard-Pfad
|
||||
- /nesflix/filme:/media/filme:ro
|
||||
# - /nesflix/serien:/media/serien:ro
|
||||
- ./data:/data
|
||||
networks:
|
||||
- media
|
||||
|
||||
+22
-306
@@ -1,311 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Radarr Language Dashboard</title>
|
||||
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Language Dashboard</title>
|
||||
<style>
|
||||
:root{
|
||||
color-scheme:dark;
|
||||
--bg:#181818;
|
||||
--panel:#202020;
|
||||
--panel2:#262626;
|
||||
--line:#373737;
|
||||
--text:#dedede;
|
||||
--muted:#929292;
|
||||
--yellow:#f4c430;
|
||||
--blue:#60a9e6;
|
||||
--green:#159447;
|
||||
--red:#bd4d4d;
|
||||
--orange:#ba812d;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{
|
||||
margin:0;
|
||||
background:var(--bg);
|
||||
color:var(--text);
|
||||
font-family:Inter,system-ui,-apple-system,"Segoe UI",sans-serif;
|
||||
font-size:13px;
|
||||
}
|
||||
header{
|
||||
height:58px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:15px;
|
||||
padding:0 20px;
|
||||
background:#242424;
|
||||
border-bottom:1px solid #343434;
|
||||
}
|
||||
.logo{
|
||||
color:var(--yellow);
|
||||
font-size:22px;
|
||||
font-weight:900;
|
||||
letter-spacing:1px;
|
||||
}
|
||||
.head-title{font-weight:700;font-size:14px}
|
||||
.head-sub{font-size:12px;color:var(--muted);margin-top:2px}
|
||||
.actions{margin-left:auto}
|
||||
button,input,select{
|
||||
font:inherit;
|
||||
color:var(--text);
|
||||
background:#292929;
|
||||
border:1px solid #444;
|
||||
border-radius:5px;
|
||||
}
|
||||
button{padding:7px 10px;cursor:pointer}
|
||||
button:hover{border-color:#777}
|
||||
main{padding:14px 20px 30px}
|
||||
.toolbar{
|
||||
display:grid;
|
||||
grid-template-columns:minmax(280px,1fr) 190px 190px 170px;
|
||||
gap:8px;
|
||||
margin-bottom:10px;
|
||||
}
|
||||
input,select{padding:7px 10px;height:34px}
|
||||
.stats{
|
||||
display:flex;
|
||||
gap:8px;
|
||||
margin-bottom:10px;
|
||||
flex-wrap:wrap;
|
||||
}
|
||||
.stat{
|
||||
background:#242424;
|
||||
border:1px solid #363636;
|
||||
padding:5px 9px;
|
||||
border-radius:5px;
|
||||
color:#bbb;
|
||||
}
|
||||
.tablewrap{
|
||||
border:1px solid #353535;
|
||||
border-radius:6px;
|
||||
overflow:hidden;
|
||||
background:#202020;
|
||||
}
|
||||
table{
|
||||
width:100%;
|
||||
border-collapse:collapse;
|
||||
table-layout:fixed;
|
||||
}
|
||||
thead th{
|
||||
padding:8px 10px;
|
||||
background:#282828;
|
||||
border-bottom:1px solid #444;
|
||||
color:#cfcfcf;
|
||||
font-size:12px;
|
||||
font-weight:650;
|
||||
text-align:left;
|
||||
position:sticky;
|
||||
top:0;
|
||||
}
|
||||
tbody td{
|
||||
padding:7px 10px;
|
||||
border-bottom:1px solid #343434;
|
||||
vertical-align:middle;
|
||||
}
|
||||
tbody tr:hover{background:#252525}
|
||||
tbody tr:last-child td{border-bottom:0}
|
||||
.col-title{width:28%}
|
||||
.col-quality{width:12%}
|
||||
.col-state{width:10%}
|
||||
.col-audio{width:17%}
|
||||
.col-subs{width:17%}
|
||||
.col-video{width:8%}
|
||||
.col-size{width:8%}
|
||||
.title{
|
||||
color:var(--blue);
|
||||
font-weight:600;
|
||||
white-space:nowrap;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
}
|
||||
.year{color:var(--muted);font-size:11px;margin-left:5px}
|
||||
.badges{display:flex;gap:4px;flex-wrap:wrap}
|
||||
.badge{
|
||||
display:inline-flex;
|
||||
gap:4px;
|
||||
align-items:center;
|
||||
padding:2px 6px;
|
||||
border-radius:999px;
|
||||
border:1px solid #484848;
|
||||
background:#303030;
|
||||
white-space:nowrap;
|
||||
font-size:11px;
|
||||
}
|
||||
.quality{
|
||||
background:#0e7138;
|
||||
border-color:#19894b;
|
||||
color:#fff;
|
||||
}
|
||||
.state{
|
||||
font-size:11px;
|
||||
font-weight:700;
|
||||
padding:3px 7px;
|
||||
border-radius:4px;
|
||||
display:inline-block;
|
||||
}
|
||||
.state.ok{background:#126f39;color:#d9ffe8}
|
||||
.state.warn{background:#77561e;color:#ffebc4}
|
||||
.state.bad{background:#763030;color:#ffd9d9}
|
||||
.state.neutral{background:#3b3b3b;color:#ccc}
|
||||
.muted{color:var(--muted)}
|
||||
.error-icon{
|
||||
color:#ff7777;
|
||||
cursor:help;
|
||||
font-size:12px;
|
||||
margin-left:6px;
|
||||
}
|
||||
.banner{
|
||||
padding:9px 11px;
|
||||
border:1px solid #693b3b;
|
||||
background:#372424;
|
||||
border-radius:5px;
|
||||
margin-bottom:10px;
|
||||
color:#ffd1d1;
|
||||
}
|
||||
.hidden{display:none}
|
||||
@media(max-width:1100px){
|
||||
.toolbar{grid-template-columns:1fr 1fr}
|
||||
.col-video,.col-size{display:none}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo">RADARR</div>
|
||||
<div>
|
||||
<div class="head-title">Language Dashboard</div>
|
||||
<div class="head-sub">Audio- und Untertitelspuren aus den Mediendateien</div>
|
||||
</div>
|
||||
<div class="actions"><button id="rescan">↻ Neu scannen</button></div>
|
||||
</header>
|
||||
|
||||
:root{color-scheme:dark;--bg:#181818;--panel:#222;--line:#3a3a3a;--text:#dedede;--muted:#929292;--yellow:#f4c430;--blue:#60a9e6}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:13px Inter,system-ui,sans-serif}header{height:58px;display:flex;align-items:center;gap:15px;padding:0 20px;background:#242424;border-bottom:1px solid #343434}.logo{color:var(--yellow);font-size:22px;font-weight:900;letter-spacing:1px}.head-title{font-weight:700}.head-sub{font-size:12px;color:var(--muted);margin-top:2px}.actions{margin-left:auto}button,input,select{font:inherit;color:var(--text);background:#292929;border:1px solid #444;border-radius:5px}button{padding:7px 10px;cursor:pointer}main{padding:14px 20px 30px}.nav{display:flex;gap:4px;margin-bottom:12px;border-bottom:1px solid var(--line)}.nav button{border:0;border-radius:5px 5px 0 0;background:transparent;color:#aaa}.nav button.active{background:#303030;color:#fff}.view{display:none}.view.active{display:block}.toolbar{display:grid;grid-template-columns:minmax(280px,1fr) 190px 190px 170px;gap:8px;margin-bottom:10px}input,select{padding:7px 10px;height:34px}.stats{display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap}.stat{background:#242424;border:1px solid #363636;padding:5px 9px;border-radius:5px;color:#bbb}.scan{color:#f4c430}.banner{padding:9px 11px;border:1px solid #693b3b;background:#372424;border-radius:5px;margin-bottom:10px;color:#ffd1d1}.tablewrap{border:1px solid #353535;border-radius:6px;overflow:hidden;background:var(--panel)}table{width:100%;border-collapse:collapse;table-layout:fixed}th{padding:8px 10px;background:#282828;border-bottom:1px solid #444;text-align:left;font-size:12px}td{padding:7px 10px;border-bottom:1px solid #343434;vertical-align:middle}.col-title{width:28%}.col-quality{width:12%}.col-state{width:10%}.col-audio{width:17%}.col-subs{width:17%}.col-video{width:8%}.col-size{width:8%}.title{color:var(--blue);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:inline-block;max-width:90%;vertical-align:bottom}.year{color:var(--muted);font-size:11px;margin-left:5px}.badges{display:flex;gap:4px;flex-wrap:wrap}.badge{display:inline-flex;gap:4px;align-items:center;padding:2px 6px;border-radius:999px;border:1px solid #484848;background:#303030;white-space:nowrap;font-size:11px}.quality{background:#0e7138;border-color:#19894b}.state{font-size:11px;font-weight:700;padding:3px 7px;border-radius:4px;display:inline-block}.state.ok{background:#126f39;color:#d9ffe8}.state.warn{background:#77561e;color:#ffebc4}.state.bad{background:#763030;color:#ffd9d9}.state.neutral{background:#3b3b3b;color:#ccc}.muted{color:var(--muted)}.hidden{display:none}.series{border-bottom:1px solid var(--line)}.series:last-child{border:0}.series summary{padding:12px;cursor:pointer;background:#282828;font-weight:700}.series summary:hover{background:#303030}.series table{border-radius:0}.series td:first-child{padding-left:28px}@media(max-width:1100px){.toolbar{grid-template-columns:1fr 1fr}.col-video,.col-size{display:none}}
|
||||
</style></head><body>
|
||||
<header><div class="logo">MEDIA</div><div><div class="head-title">Language Dashboard</div><div class="head-sub">Audio- und Untertitelspuren aus den Mediendateien</div></div><div class="actions"><button id="rescan">↻ Neu scannen</button></div></header>
|
||||
<main>
|
||||
{% if error %}
|
||||
<div class="banner"><strong>Fehler:</strong> {{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="toolbar">
|
||||
<input id="search" type="search" placeholder="Film suchen …">
|
||||
<select id="audioFilter">
|
||||
<option value="">Alle Audio-Sprachen</option>
|
||||
<option value="Deutsch">🇩🇪 Deutsch</option>
|
||||
<option value="Englisch">🇬🇧 Englisch</option>
|
||||
<option value="Japanisch">🇯🇵 Japanisch</option>
|
||||
<option value="Koreanisch">🇰🇷 Koreanisch</option>
|
||||
</select>
|
||||
<select id="subFilter">
|
||||
<option value="">Alle Untertitel</option>
|
||||
<option value="Deutsch">🇩🇪 Deutsch</option>
|
||||
<option value="Englisch">🇬🇧 Englisch</option>
|
||||
<option value="Japanisch">🇯🇵 Japanisch</option>
|
||||
<option value="Koreanisch">🇰🇷 Koreanisch</option>
|
||||
</select>
|
||||
<select id="stateFilter">
|
||||
<option value="">Alle Status</option>
|
||||
<option value="DE + EN">DE + EN</option>
|
||||
<option value="Deutsch">Deutsch</option>
|
||||
<option value="English only">English only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<span class="stat" id="visibleCount"></span>
|
||||
<span class="stat">Radarr: {{ radarr_media_path }}</span>
|
||||
<span class="stat">Dashboard: {{ local_media_path }}</span>
|
||||
</div>
|
||||
|
||||
<div class="tablewrap">
|
||||
<table id="movies">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-title">Movie</th>
|
||||
<th class="col-quality">Quality</th>
|
||||
<th class="col-state">Status</th>
|
||||
<th class="col-audio">Audio</th>
|
||||
<th class="col-subs">Untertitel</th>
|
||||
<th class="col-video">Video</th>
|
||||
<th class="col-size">Größe</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr
|
||||
data-title="{{ (r.title ~ ' ' ~ (r.year or ''))|lower }}"
|
||||
data-audio="{{ r.audio_languages|map(attribute='name')|join(',') }}"
|
||||
data-subs="{{ r.subtitle_languages|map(attribute='name')|join(',') }}"
|
||||
data-state="{{ r.state.label }}"
|
||||
>
|
||||
<td>
|
||||
<span class="title">{{ r.title }}</span>
|
||||
<span class="year">{{ r.year or '' }}</span>
|
||||
{% if r.error %}
|
||||
<span class="error-icon" title="{{ r.error }}">⚠</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if r.missing %}
|
||||
<span class="badge">Keine Datei</span>
|
||||
{% else %}
|
||||
<span class="badge quality">{{ r.quality }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><span class="state {{ r.state.class }}">{{ r.state.label }}</span></td>
|
||||
<td>
|
||||
<div class="badges">
|
||||
{% for lang in r.audio_languages %}
|
||||
<span class="badge">{{ lang.flag }} {{ lang.name }}</span>
|
||||
{% else %}<span class="muted">—</span>{% endfor %}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="badges">
|
||||
{% for lang in r.subtitle_languages %}
|
||||
<span class="badge">{{ lang.flag }} {{ lang.name }}</span>
|
||||
{% else %}<span class="muted">—</span>{% endfor %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="col-video">{{ r.video_codec }}</td>
|
||||
<td class="col-size">{{ r.size }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if error %}<div class="banner"><strong>Fehler:</strong> {{ error }}</div>{% endif %}
|
||||
<nav class="nav"><button class="active" data-view="radarr">RADARR · Filme</button>{% if sonarr_enabled %}<button data-view="sonarr">SONARR · Serien</button>{% endif %}</nav>
|
||||
<section id="radarr" class="view active" data-source="radarr">
|
||||
<div class="toolbar"><input id="search" type="search" placeholder="Film suchen …"><select id="audioFilter"><option value="">Alle Audio-Sprachen</option><option>Deutsch</option><option>Englisch</option><option>Japanisch</option><option>Koreanisch</option></select><select id="subFilter"><option value="">Alle Untertitel</option><option>Deutsch</option><option>Englisch</option><option>Japanisch</option><option>Koreanisch</option></select><select id="stateFilter"><option value="">Alle Status</option><option>DE + EN</option><option>Deutsch</option><option>English only</option></select></div>
|
||||
<div class="stats"><span class="stat" id="visibleCount"></span><span class="stat scan" id="radarrStatus">Scan wird vorbereitet …</span><span class="stat">Radarr: {{ radarr_media_path }}</span></div>
|
||||
<div class="tablewrap"><table id="movies"><thead><tr><th class="col-title">Film</th><th class="col-quality">Quality</th><th class="col-state">Status</th><th class="col-audio">Audio</th><th class="col-subs">Untertitel</th><th class="col-video">Video</th><th class="col-size">Größe</th></tr></thead><tbody>
|
||||
{% for r in rows %}<tr data-title="{{ (r.title ~ ' ' ~ (r.year or ''))|lower }}" data-audio="{{ r.audio_languages|map(attribute='name')|join(',') }}" data-subs="{{ r.subtitle_languages|map(attribute='name')|join(',') }}" data-state="{{ r.state.label }}"><td><span class="title">{{ r.title }}</span><span class="year">{{ r.year or '' }}</span>{% if r.error %} ⚠{% endif %}</td><td><span class="badge {% if not r.missing %}quality{% endif %}">{{ r.quality }}</span></td><td><span class="state {{ r.state.class }}">{{ r.state.label }}</span></td><td><div class="badges">{% for l in r.audio_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td><div class="badges">{% for l in r.subtitle_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td>{{ r.video_codec }}</td><td>{{ r.size }}</td></tr>{% endfor %}
|
||||
</tbody></table></div></section>
|
||||
{% if sonarr_enabled %}<section id="sonarr" class="view" data-source="sonarr"><div class="stats"><span class="stat" id="seriesCount">{{ series|length }} Serien</span><span class="stat scan" id="sonarrStatus">Scan wird vorbereitet …</span><span class="stat">Dashboard: {{ local_series_path }}</span></div><div class="tablewrap" id="seriesList">
|
||||
{% for group in series %}<details class="series" open><summary>{{ group.title }} <span class="muted">({{ group.year or '—' }}) · {{ group.episodes|length }} Episoden</span></summary><table><thead><tr><th class="col-title">Episode</th><th class="col-quality">Quality</th><th class="col-state">Status</th><th class="col-audio">Audio</th><th class="col-subs">Untertitel</th><th class="col-video">Video</th><th class="col-size">Größe</th></tr></thead><tbody>{% for r in group.episodes %}<tr><td><span class="title">{{ r.title }}</span></td><td><span class="badge {% if not r.missing %}quality{% endif %}">{{ r.quality }}</span></td><td><span class="state {{ r.state.class }}">{{ r.state.label }}</span></td><td><div class="badges">{% for l in r.audio_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td><div class="badges">{% for l in r.subtitle_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td>{{ r.video_codec }}</td><td>{{ r.size }}</td></tr>{% endfor %}</tbody></table></details>{% endfor %}
|
||||
</div></section>{% endif %}
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const rows=[...document.querySelectorAll('#movies tbody tr')];
|
||||
const search=document.querySelector('#search');
|
||||
const audio=document.querySelector('#audioFilter');
|
||||
const subs=document.querySelector('#subFilter');
|
||||
const state=document.querySelector('#stateFilter');
|
||||
const count=document.querySelector('#visibleCount');
|
||||
|
||||
function apply(){
|
||||
const q=search.value.trim().toLowerCase();
|
||||
let visible=0;
|
||||
rows.forEach(r=>{
|
||||
const ok=
|
||||
(!q || r.dataset.title.includes(q)) &&
|
||||
(!audio.value || r.dataset.audio.includes(audio.value)) &&
|
||||
(!subs.value || r.dataset.subs.includes(subs.value)) &&
|
||||
(!state.value || r.dataset.state===state.value);
|
||||
r.classList.toggle('hidden',!ok);
|
||||
if(ok) visible++;
|
||||
});
|
||||
count.textContent=`${visible} Filme`;
|
||||
}
|
||||
[search,audio,subs,state].forEach(x=>x.addEventListener(x===search?'input':'change',apply));
|
||||
apply();
|
||||
|
||||
document.querySelector('#rescan').addEventListener('click',async()=>{
|
||||
const b=document.querySelector('#rescan');
|
||||
b.disabled=true;b.textContent='Scanne …';
|
||||
try{
|
||||
await fetch('/api/rescan',{method:'POST'});
|
||||
location.reload();
|
||||
}finally{b.disabled=false}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
const rows=[...document.querySelectorAll('#movies tbody tr')], search=document.querySelector('#search'), audio=document.querySelector('#audioFilter'), subs=document.querySelector('#subFilter'), state=document.querySelector('#stateFilter'), count=document.querySelector('#visibleCount');
|
||||
function apply(){const q=search.value.trim().toLowerCase();let n=0;rows.forEach(r=>{const ok=(!q||r.dataset.title.includes(q))&&(!audio.value||r.dataset.audio.includes(audio.value))&&(!subs.value||r.dataset.subs.includes(subs.value))&&(!state.value||r.dataset.state===state.value);r.classList.toggle('hidden',!ok);if(ok)n++});count.textContent=`${n} Filme`};[search,audio,subs,state].forEach(x=>x.addEventListener(x===search?'input':'change',apply));apply();
|
||||
document.querySelectorAll('.nav button').forEach(b=>b.onclick=()=>{document.querySelectorAll('.nav button').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.view').forEach(x=>x.classList.remove('active'));b.classList.add('active');document.querySelector('#'+b.dataset.view).classList.add('active');document.querySelector('#rescan').dataset.source=b.dataset.view});
|
||||
function statusText(s){if(s.state==='running')return `Scan: ${s.done}/${s.total||'?'} Dateien`;if(s.state==='done')return 'Scan abgeschlossen';if(s.state==='error')return 'Scanfehler: '+s.error;return 'Scan wird vorbereitet …'}
|
||||
const seenRunning={radarr:false,sonarr:false};async function poll(){try{const data=await (await fetch('/api/scan-status')).json();document.querySelector('#radarrStatus').textContent=statusText(data.radarr);const sonarr=document.querySelector('#sonarrStatus');if(sonarr)sonarr.textContent=statusText(data.sonarr);['radarr','sonarr'].forEach(source=>{if(data[source].state==='running')seenRunning[source]=true;if(seenRunning[source]&&['done','error'].includes(data[source].state)){seenRunning[source]=false;setTimeout(()=>location.reload(),700)}})}catch(e){} };setInterval(poll,1200);poll();
|
||||
document.querySelector('#rescan').onclick=async()=>{const b=document.querySelector('#rescan');b.disabled=true;b.textContent='Scanne …';await fetch('/api/rescan?source='+(b.dataset.source||'radarr'),{method:'POST'});b.disabled=false;b.textContent='↻ Neu scannen';poll()};
|
||||
</script></body></html>
|
||||
|
||||
Reference in New Issue
Block a user