Files
language-dashboard/app.py
T
nessi 806886da46 feat: expand language detection patterns with 8 additional languages and enhanced token matching
Add Italian/Portuguese/Korean/Chinese/Dutch/Russian to patterns list with standard/ISO 639-2 codes (ita/por/pt br/kor/chi/zho/nld/ned/rus). Enhance German patterns with " ger "/" deu " tokens and French with " français "/" fre " variants. Add corresponding language codes to codes dict (it/pt/ko/zh/nl/ru). Format patterns list with multi-line structure for readability
2026-08-17 19:33:39 +02:00

934 lines
53 KiB
Python

import json
import logging
from logging.handlers import TimedRotatingFileHandler
import os
import sqlite3
import subprocess
import threading
import time
import shutil
import re
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
import requests
from flask import Flask, jsonify, render_template, request, send_from_directory
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("/")
SAB_URL = os.environ.get("SAB_URL", "").rstrip("/")
SAB_API_KEY = os.environ.get("SAB_API_KEY", "")
JELLYFIN_URL = os.environ.get("JELLYFIN_URL", "http://jellyfin:8096").rstrip("/")
SEERR_URL = os.environ.get("SEERR_URL", "http://seerr:5055").rstrip("/")
PROWLARR_URL = os.environ.get("PROWLARR_URL", "http://prowlarr:9696").rstrip("/")
EASYNEWS_INDEXER_URL = os.environ.get("EASYNEWS_INDEXER_URL", "http://easynews-as-indexer:8081").rstrip("/")
QBITTORRENT_URL = os.environ.get("QBITTORRENT_URL", "").rstrip("/")
BAZARR_URL = os.environ.get("BAZARR_URL", "http://bazarr:6767").rstrip("/")
LINGARR_URL = os.environ.get("LINGARR_URL", "http://lingarr:9876").rstrip("/")
LIBRETRANSLATE_URL = os.environ.get("LIBRETRANSLATE_URL", "http://libretranslate:5000").rstrip("/")
SONARR_PROWLARR_PROXY_URL = os.environ.get("SONARR_PROWLARR_PROXY_URL", "http://sonarr-prowlarr-proxy:8080").rstrip("/")
RADARR_PROWLARR_PROXY_URL = os.environ.get("RADARR_PROWLARR_PROXY_URL", "http://radarr-prowlarr-proxy:8080").rstrip("/")
SERVICE_CHECK_TIMEOUT = float(os.environ.get("SERVICE_CHECK_TIMEOUT", "3"))
SERVICE_REFRESH_SECONDS = int(os.environ.get("SERVICE_REFRESH_SECONDS", "30"))
DB_PATH = os.environ.get("DB_PATH", "/data/cache.db")
REQUEST_TIMEOUT = int(os.environ.get("REQUEST_TIMEOUT", "20"))
DATASTORE_PATH = os.environ.get("DATASTORE_PATH", "/nesflix").rstrip("/") or "/"
RAID_DEVICE = os.environ.get("RAID_DEVICE", "/dev/md127")
HOST_DEVICE_PATH = os.environ.get("HOST_DEVICE_PATH", "/host-dev")
HOST_SYS_PATH = os.environ.get("HOST_SYS_PATH", "/host-sys")
STORAGE_CACHE_SECONDS = int(os.environ.get("STORAGE_CACHE_SECONDS", "900"))
SMART_ENABLED = os.environ.get("SMART_ENABLED", "false").lower() in {"1", "true", "yes", "on"}
LOG_FILE = os.environ.get("LOG_FILE", "/data/media-max.log")
LOG_RETENTION_HOURS = max(1, int(os.environ.get("LOG_RETENTION_HOURS", "6")))
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("media-max")
def error_summary(exc):
"""Return one short, useful log line instead of a connection traceback."""
if isinstance(exc, requests.RequestException):
return f"{exc.__class__.__name__}: Dienst nicht erreichbar"
return " ".join(str(exc).split())[:240] or exc.__class__.__name__
try:
file_handler = TimedRotatingFileHandler(LOG_FILE, when="H", interval=LOG_RETENTION_HOURS, backupCount=1, encoding="utf-8", delay=True, utc=True)
file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
log.addHandler(file_handler)
except OSError as exc:
log.warning("Datei-Logging nicht verfügbar (%s): %s", LOG_FILE, exc)
app = Flask(__name__)
@app.get("/sw.js")
def service_worker():
"""Serve the worker from the origin root so it can control the whole app."""
response = send_from_directory(app.static_folder, "sw.js", mimetype="application/javascript")
response.headers["Service-Worker-Allowed"] = "/"
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return response
executor = ThreadPoolExecutor(max_workers=int(os.environ.get("SCAN_WORKERS", "2")))
scan_executor = ThreadPoolExecutor(max_workers=int(os.environ.get("SCAN_WORKERS", "4")))
jobs = {"radarr": {"state": "idle", "total": 0, "done": 0, "error": None}, "sonarr": {"state": "idle", "total": 0, "done": 0, "error": None}}
job_lock = threading.Lock()
system_lock = threading.Lock()
previous_cpu = None
service_status_lock = threading.Lock()
service_check_executor = ThreadPoolExecutor(max_workers=16)
datastore_cache_lock = threading.Lock()
datastore_cache = {"stored_at": 0.0, "snapshot": None, "raid": None, "raid_at": 0.0}
raid_log_state = None
hardware_log_once = set()
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":"❓"}
SERVICE_DEFINITIONS = [
{"key": "radarr", "name": "Radarr", "url": f"{RADARR_URL}/api/v3/system/status", "api_key": RADARR_API_KEY, "kind": "api"},
{"key": "sonarr", "name": "Sonarr", "url": f"{SONARR_URL}/api/v3/system/status" if SONARR_URL else "", "api_key": SONARR_API_KEY, "kind": "api"},
{"key": "sabnzbd", "name": "SABnzbd", "url": f"{SAB_URL}/api?mode=version&output=json&apikey={SAB_API_KEY}" if SAB_URL else "", "api_key": "", "kind": "api"},
{"key": "jellyfin", "name": "Jellyfin", "url": JELLYFIN_URL, "api_key": "", "kind": "http"},
{"key": "seerr", "name": "Seerr", "url": SEERR_URL, "api_key": "", "kind": "http"},
{"key": "prowlarr", "name": "Prowlarr", "url": PROWLARR_URL, "api_key": "", "kind": "http"},
{"key": "easynews", "name": "Easynews as Indexer", "url": EASYNEWS_INDEXER_URL, "api_key": "", "kind": "http"},
{"key": "qbittorrent", "name": "qBittorrent", "url": QBITTORRENT_URL, "api_key": "", "kind": "http"},
{"key": "bazarr", "name": "Bazarr", "url": BAZARR_URL, "api_key": "", "kind": "http"},
{"key": "lingarr", "name": "Lingarr", "url": LINGARR_URL, "api_key": "", "kind": "http"},
{"key": "libretranslate", "name": "LibreTranslate", "url": LIBRETRANSLATE_URL, "api_key": "", "kind": "http"},
{"key": "sonarr_prowlarr_proxy", "name": "Sonarr-Prowlarr Proxy", "url": SONARR_PROWLARR_PROXY_URL, "api_key": "", "kind": "http"},
{"key": "radarr_prowlarr_proxy", "name": "Radarr-Prowlarr Proxy", "url": RADARR_PROWLARR_PROXY_URL, "api_key": "", "kind": "http"},
{"key": "dashboard", "name": "Media Max", "url": "", "api_key": "", "kind": "self"},
]
service_status = {service["key"]: {"key": service["key"], "name": service["name"], "status": "checking", "label": "Prüfe …"} for service in SERVICE_DEFINITIONS}
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)")
return con
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()
def check_service(service):
result = {"key": service["key"], "name": service["name"]}
if service["kind"] == "self":
result.update(status="online", label="Aktiv", detail="Dashboard läuft")
return result
if not service["url"]:
result.update(status="error", label="Nicht konfiguriert", detail="URL fehlt")
return result
try:
headers = {"X-Api-Key": service["api_key"]} if service["api_key"] else {}
response = requests.get(service["url"], headers=headers, timeout=SERVICE_CHECK_TIMEOUT, allow_redirects=False)
if 200 <= response.status_code < 400:
result.update(status="online", label="Aktiv", detail=f"HTTP {response.status_code}")
elif response.status_code in (401, 403) or response.status_code >= 500:
result.update(status="error", label="Fehler", detail=f"HTTP {response.status_code}")
else:
result.update(status="online", label="Erreichbar", detail=f"HTTP {response.status_code}")
except requests.Timeout:
result.update(status="timeout", label="Timeout", detail=f"> {SERVICE_CHECK_TIMEOUT:g}s")
except requests.RequestException as exc:
result.update(status="offline", label="Offline", detail=exc.__class__.__name__)
return result
def refresh_services():
futures = [service_check_executor.submit(check_service, service) for service in SERVICE_DEFINITIONS]
results = [future.result() for future in futures]
with service_status_lock:
service_status.update({result["key"]: result for result in results})
def service_monitor():
while True:
try:
refresh_services()
except Exception as exc:
log.error("Dienststatus konnte nicht aktualisiert werden: %s", error_summary(exc))
time.sleep(max(5, SERVICE_REFRESH_SECONDS))
threading.Thread(target=service_monitor, name="service-monitor", daemon=True).start()
@app.get("/api/services")
def services():
with service_status_lock:
values = list(service_status.values())
online = sum(1 for service in values if service["status"] == "online")
return jsonify({"services": values, "online": online, "total": len(values), "updated_at": time.time()})
def sab_get(mode, **params):
if not SAB_URL or not SAB_API_KEY:
raise RuntimeError("SAB_URL oder SAB_API_KEY ist nicht gesetzt.")
query = {"output": "json", "apikey": SAB_API_KEY, "mode": mode, **params}
r = requests.get(f"{SAB_URL}/api", params=query, timeout=REQUEST_TIMEOUT)
r.raise_for_status()
data = r.json()
if data.get("error"):
raise RuntimeError(data["error"])
return data
def parse_size_bytes(value):
if isinstance(value, (int, float)):
return int(value)
match = re.search(r"([\d.,]+)\s*(B|KB|MB|GB|TB)", str(value or ""), re.IGNORECASE)
if not match:
return 0
number = float(match.group(1).replace(',', '.'))
multiplier = {"B": 1, "KB": 1024, "MB": 1024**2, "GB": 1024**3, "TB": 1024**4}[match.group(2).upper()]
return int(number * multiplier)
def format_bytes(size):
value = float(size or 0)
for unit in ("B", "KB", "MB", "GB", "TB"):
if value < 1024 or unit == "TB":
return f"{value:.1f} {unit}"
value /= 1024
def _normalized_path_part(value):
return re.sub(r"[^a-z0-9]+", "", str(value or "").casefold())
def _datastore_category(relative_path, category_roots):
parts = [part for part in Path(relative_path).parts if part not in (".", "")]
normalized = {_normalized_path_part(part) for part in parts}
if normalized & {"download", "downloads", "sabnzbd", "nzb"}:
return "downloads"
if normalized & {"anime"}:
return "anime"
if normalized & {"kdrama", "kdramas", "kdramen", "korean", "koreanischeserien"}:
return "kdrama"
if parts and _normalized_path_part(parts[0]) in category_roots["movies"]:
return "movies"
if parts and _normalized_path_part(parts[0]) in category_roots["series"]:
return "series"
return "other"
def scan_datastore_storage():
root = Path(DATASTORE_PATH)
log.info("Datastore-Scan gestartet: %s", DATASTORE_PATH)
categories = {key: {"key": key, "label": label, "bytes": 0, "files": 0} for key, label in (
("movies", "Filme"), ("series", "Serien"), ("anime", "Anime"),
("kdrama", "K-Dramen"), ("downloads", "Downloads"), ("other", "Sonstiges")
)}
if not root.exists() or not root.is_dir():
log.error("Datastore-Pfad nicht verfügbar: %s", DATASTORE_PATH)
return {"error": f"Datastore {DATASTORE_PATH} ist nicht verfügbar.", "path": DATASTORE_PATH, "categories": list(categories.values())}
category_roots = {
"movies": {"filme", "film", "movies", "movie"},
"series": {"serien", "serie", "series", "tv", "shows"},
}
try:
for current, directories, files in os.walk(root, followlinks=False):
directories[:] = [directory for directory in directories if not os.path.islink(os.path.join(current, directory))]
relative = os.path.relpath(current, root)
for filename in files:
full_path = os.path.join(current, filename)
if os.path.islink(full_path):
continue
try:
size = os.path.getsize(full_path)
except OSError:
continue
category = categories[_datastore_category(relative, category_roots)]
category["bytes"] += size
category["files"] += 1
usage = shutil.disk_usage(root)
except OSError as exc:
log.error("Datastore konnte nicht gelesen werden (%s): %s", DATASTORE_PATH, error_summary(exc))
return {"error": "Datastore momentan nicht verfügbar", "path": DATASTORE_PATH, "categories": list(categories.values())}
used = usage.used
values = []
for category in categories.values():
category["size"] = format_bytes(category["bytes"])
category["percent"] = round(category["bytes"] / used * 100, 1) if used else 0
values.append(category)
log.info("Datastore-Scan abgeschlossen: %s belegt, %s frei, %.1f%% genutzt", format_bytes(usage.used), format_bytes(usage.free), usage.used / usage.total * 100 if usage.total else 0)
return {"error": None, "path": DATASTORE_PATH, "total_bytes": usage.total, "used_bytes": usage.used,
"free_bytes": usage.free, "total": format_bytes(usage.total), "used": format_bytes(usage.used),
"free": format_bytes(usage.free), "percent": round(usage.used / usage.total * 100, 1) if usage.total else 0,
"categories": values, "scanned_at": time.time()}
def _read_command(command, timeout=4, warn_on_nonzero=True):
command_text = " ".join(str(part) for part in command)
log.debug("Systembefehl gestartet: %s", command_text)
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=False)
if result.returncode != 0 and warn_on_nonzero:
log.warning("Systembefehl beendet mit Code %s: %s%s", result.returncode, command_text, f" · {result.stderr.strip()[:180]}" if result.stderr.strip() else "")
return result.stdout, result.stderr
except (OSError, subprocess.SubprocessError) as exc:
log.error("Systembefehl fehlgeschlagen: %s · %s", command_text, exc)
return "", str(exc)
def _flatten_lsblk(nodes):
result = []
for node in nodes or []:
result.append(node)
result.extend(_flatten_lsblk(node.get("children")))
return result
def _device_serial(device):
"""Read a disk serial from sysfs when lsblk cannot expose it in Docker."""
name = Path(device).name
for path in (Path(HOST_SYS_PATH) / "class/block" / name / "device/serial", Path(HOST_SYS_PATH) / "block" / name / "device/serial", Path("/sys/class/block") / name / "device/serial", Path("/sys/block") / name / "device/serial"):
try:
value = path.read_text().strip()
if value:
return value
except OSError:
continue
# Some controllers expose the serial only through udev's by-id links.
for by_id in (Path(HOST_DEVICE_PATH) / "disk/by-id", Path("/dev/disk/by-id")):
try:
for link in by_id.iterdir():
if link.name.startswith(("wwn-", "scsi-", "ata-", "nvme-")) and name in str(link.resolve()):
serial = re.sub(r"^(?:ata|scsi|nvme)-", "", link.name)
if serial and not serial.startswith("part"):
return serial
except OSError:
continue
return ""
def format_transfer_rate(value):
"""Convert mdstat rates such as 133628K/sec into a readable value."""
if not value or value == "—":
return "—"
match = re.match(r"([\d.]+)\s*([KMGTP]?)(?:B)?/sec", value, re.IGNORECASE)
if not match:
return value
amount = float(match.group(1))
unit = match.group(2).upper()
multiplier = {"": 1, "K": 1024, "M": 1024 ** 2, "G": 1024 ** 3, "T": 1024 ** 4, "P": 1024 ** 5}.get(unit, 1)
bytes_per_second = amount * multiplier
units = ((1024 ** 3, "GB/s"), (1024 ** 2, "MB/s"), (1024, "KB/s"))
for threshold, label in units:
if bytes_per_second >= threshold:
return f"{bytes_per_second / threshold:.1f} {label}"
return f"{bytes_per_second:.0f} B/s"
def format_remaining_time(value):
"""Convert mdstat values such as 450.1min into a compact German duration."""
if not value or value == "—":
return "—"
match = re.match(r"([\d.]+)(min|sec|hours?|days?)", value, re.IGNORECASE)
if not match:
return value
amount = float(match.group(1))
unit = match.group(2).lower()
total_minutes = amount / 60 if unit.startswith("sec") else amount if unit.startswith("min") else amount * 60 if unit.startswith("hour") else amount * 1440
total_minutes = max(0, round(total_minutes))
days, remainder = divmod(total_minutes, 1440)
hours, minutes = divmod(remainder, 60)
parts = []
if days:
parts.append(f"{days} T")
if hours or days:
parts.append(f"{hours} Std.")
if minutes and len(parts) < 2:
parts.append(f"{minutes} Min.")
return " ".join(parts) or "< 1 Min."
def io_pressure():
"""Read Linux PSI I/O pressure, if the kernel exposes it."""
try:
values = {}
for line in Path("/proc/pressure/io").read_text().splitlines():
parts = line.split()
if not parts:
continue
values[parts[0]] = {key: float(value) for key, value in (item.split("=", 1) for item in parts[1:] if "=" in item)}
return {"some": values.get("some", {}), "full": values.get("full", {}), "available": bool(values)}
except (OSError, ValueError):
return {"some": {}, "full": {}, "available": False}
def smart_status(device):
if not SMART_ENABLED:
return "Nicht aktiviert"
if not shutil.which("smartctl"):
return "smartctl nicht installiert"
smart_device = device.replace("/dev/", f"{HOST_DEVICE_PATH.rstrip('/')}/", 1)
smart_output, smart_error = _read_command(["smartctl", "-H", smart_device], timeout=6, warn_on_nonzero=False)
if not re.search(r"(?:overall-health|Health Status|SMART support is)", smart_output, re.IGNORECASE):
sat_output, sat_error = _read_command(["smartctl", "-H", "-d", "sat", smart_device], timeout=6, warn_on_nonzero=False)
if sat_output:
smart_output, smart_error = sat_output, sat_error
health = re.search(r"(?:SMART overall-health self-assessment test result|SMART Health Status):\s*(.+)", smart_output, re.IGNORECASE)
return health.group(1).strip() if health else ("Fehler: " + smart_error.strip()[:80] if smart_error.strip() else "Nicht unterstützt")
def smart_serial(device):
"""Read the manufacturer serial, not a WWN or SCSI by-id alias."""
if not shutil.which("smartctl"):
return ""
smart_device = device.replace("/dev/", f"{HOST_DEVICE_PATH.rstrip('/')}/", 1)
for command in (["smartctl", "-i", "-d", "sat", smart_device], ["smartctl", "-i", smart_device]):
output, _ = _read_command(command, timeout=6, warn_on_nonzero=False)
match = re.search(r"^\s*Serial Number:\s*(\S+)\s*$", output, re.IGNORECASE | re.MULTILINE)
if match and not match.group(1).lower().startswith(("wwn-", "0x")):
return match.group(1).strip()
return ""
def smart_temperature(device):
"""Read the drive temperature in a controller-friendly way."""
if not SMART_ENABLED or not shutil.which("smartctl"):
return "Nicht verfügbar"
smart_device = device.replace("/dev/", f"{HOST_DEVICE_PATH.rstrip('/')}/", 1)
def extract(output):
lines = output.splitlines()
# Seagate and similar drives often expose both Airflow_Temperature
# and Temperature_Celsius. Prefer the latter because it is the
# physical drive temperature shown by the usual terminal command.
ordered = [line for line in lines if re.search(r"Temperature_Celsius", line, re.IGNORECASE)]
ordered += [line for line in lines if line not in ordered and re.search(r"Current Drive Temperature|Drive Temperature|^\s*Temperature:\s*", line, re.IGNORECASE)]
ordered += [line for line in lines if line not in ordered and re.search(r"temperature|airflow", line, re.IGNORECASE)]
for line in ordered:
raw_temperature = re.search(r"Temperature_Celsius.*-\s*(-?\d{1,3})\s*(?:\([^)]*\))?\s*$", line, re.IGNORECASE)
if raw_temperature and 0 < int(raw_temperature.group(1)) < 150:
return f"{int(raw_temperature.group(1))} °C"
explicit = re.search(r"(\d{1,3})\s*(?:°\s*C|Celsius|degrees?\s*C)\b", line, re.IGNORECASE)
if explicit and 0 < int(explicit.group(1)) < 150:
return f"{int(explicit.group(1))} °C"
numbers = [int(value) for value in re.findall(r"\b\d{1,3}\b", line)]
plausible = [value for value in numbers if 0 < value < 150]
if plausible:
return f"{plausible[-1]} °C"
return None
for command in (
["smartctl", "-x", "-d", "sat", smart_device],
["smartctl", "-A", "-d", "sat", smart_device],
["smartctl", "-A", smart_device],
["smartctl", "-x", smart_device],
):
output, _ = _read_command(command, timeout=6, warn_on_nonzero=False)
temperature = extract(output)
if temperature:
return temperature
return "Nicht verfügbar"
def raid_status():
global raid_log_state
mdstat, mdstat_error = "", ""
try:
mdstat = Path("/proc/mdstat").read_text()
except OSError as exc:
mdstat_error = str(exc)
log.error("/proc/mdstat konnte nicht gelesen werden: %s", exc)
array_name = Path(RAID_DEVICE).name
array_line = next((line for line in mdstat.splitlines() if line.startswith(f"{array_name} :")), "")
if not array_line:
log.warning("RAID-Array %s wurde in /proc/mdstat nicht gefunden", RAID_DEVICE)
profile_match = re.search(r"(raid\d+)", array_line)
# Match the member count on this RAID's own line. Searching all of
# /proc/mdstat can accidentally pick a different array's [x/y] value.
members_match = re.search(r"\[(\d+)/(\d+)\]\s+\[([^\]]+)\]", array_line)
mdstat_members = []
for member_name, slot in re.findall(r"\b([A-Za-z0-9][A-Za-z0-9._-]*)\[(\d+)\]", array_line):
member_suffix = re.search(rf"\b{re.escape(member_name)}\[{re.escape(slot)}\]\(([^)]+)\)", array_line)
member_state = member_suffix.group(1).lower() if member_suffix else "active"
member_status = "failed" if "f" in member_state else ("spare" if "s" in member_state else "active")
mdstat_members.append({"device": f"/dev/{member_name}", "slot": slot, "state": member_state, "status": member_status})
progress_match = re.search(r"(?:recovery|resync|reshape|check)\s*=\s*([\d.]+)%", mdstat, re.IGNORECASE)
finish_match = re.search(r"finish=([^\s]+)", mdstat)
speed_match = re.search(r"speed=([^\s]+)", mdstat)
mdadm_output, mdadm_error = _read_command(["mdadm", "--detail", RAID_DEVICE])
detail = {}
for key, pattern in (("raid_devices", r"Raid Device\s*:\s*(\d+)"), ("active_devices", r"Active Devices\s*:\s*(\d+)"),
("working_devices", r"Working Devices\s*:\s*(\d+)"), ("failed_devices", r"Failed Devices\s*:\s*(\d+)"),
("spare_devices", r"Spare Devices\s*:\s*(\d+)")):
match = re.search(pattern, mdadm_output, re.IGNORECASE)
if match:
detail[key] = int(match.group(1))
if profile_match:
detail["level"] = profile_match.group(1).upper()
elif mdadm_output:
level = re.search(r"Raid Level\s*:\s*(\S+)", mdadm_output, re.IGNORECASE)
detail["level"] = level.group(1).upper() if level else "RAID"
devices = []
for line in mdadm_output.splitlines():
match = re.match(r"\s*\d+\s+\d+\s+\d+\s+(\d+|-)\s+(.+?)\s+(/dev/\S+)\s*$", line)
if match:
state_text, device = match.group(2), match.group(3)
devices.append({"device": device, "slot": match.group(1), "state": state_text, "status": "active" if "active" in state_text.lower() else ("spare" if "spare" in state_text.lower() else "failed")})
known_devices = {Path(disk["device"]).name for disk in devices}
for disk in mdstat_members:
if Path(disk["device"]).name not in known_devices:
devices.append(disk)
lsblk_output, _ = _read_command(["lsblk", "-J", "-b", "-o", "NAME,SIZE,MODEL,SERIAL,TYPE,PATH"])
try:
block_devices = {item.get("path") or f"/dev/{item.get('name')}": item for item in _flatten_lsblk(json.loads(lsblk_output).get("blockdevices", []))}
except (ValueError, AttributeError):
block_devices = {}
smart_futures = {}
serial_futures = {}
temperature_futures = {}
if shutil.which("smartctl"):
smart_futures = {disk["device"]: service_check_executor.submit(smart_status, disk["device"]) for disk in devices}
serial_futures = {disk["device"]: service_check_executor.submit(smart_serial, disk["device"]) for disk in devices}
temperature_futures = {disk["device"]: service_check_executor.submit(smart_temperature, disk["device"]) for disk in devices}
for disk in devices:
info = block_devices.get(disk["device"], {})
parent = re.sub(r"(p)?\d+$", "", disk["device"])
info = info or block_devices.get(parent, {})
if not info:
host_device = disk["device"].replace("/dev/", f"{HOST_DEVICE_PATH.rstrip('/')}/", 1)
host_lsblk, _ = _read_command(["lsblk", "-J", "-b", "-o", "NAME,SIZE,MODEL,SERIAL,TYPE,PATH", host_device])
try:
host_items = _flatten_lsblk(json.loads(host_lsblk).get("blockdevices", []))
info = host_items[0] if host_items else {}
except (ValueError, AttributeError):
info = {}
serial = (serial_futures[disk["device"]].result() if disk["device"] in serial_futures else "") or info.get("serial") or _device_serial(disk["device"])
disk.update({"size": format_bytes(info.get("size") or 0) if info.get("size") else "—", "model": info.get("model") or "—", "serial": serial or "—"})
serial_log_key = "serial:" + disk["device"]
if not serial and serial_log_key not in hardware_log_once:
log.warning("Keine Seriennummer für %s gefunden; Host-Controller stellt möglicherweise keine bereit", disk["device"])
hardware_log_once.add(serial_log_key)
if SMART_ENABLED:
disk["smart"] = smart_futures[disk["device"]].result() if disk["device"] in smart_futures else smart_status(disk["device"])
smart_log_key = "smart:" + disk["device"]
if disk["smart"] == "PASSED" and "smart-info:" + disk["device"] not in hardware_log_once:
log.info("SMART-Status %s: PASSED", disk["device"])
hardware_log_once.add("smart-info:" + disk["device"])
elif disk["smart"] != "PASSED" and smart_log_key not in hardware_log_once:
log.warning("SMART-Status für %s nicht verfügbar: %s", disk["device"], disk["smart"])
hardware_log_once.add(smart_log_key)
disk["temperature"] = temperature_futures[disk["device"]].result() if disk["device"] in temperature_futures else smart_temperature(disk["device"])
missing = members_match and members_match.group(1) != members_match.group(2)
failed = detail.get("failed_devices", 0) > 0 or any(disk["status"] == "failed" for disk in devices)
rebuilding = bool(progress_match)
if failed and not array_line:
status = "failed"
elif rebuilding:
status = "rebuilding"
elif failed or missing:
status = "degraded"
elif array_line or mdadm_output:
status = "ok"
else:
status = "failed"
mdstat_active = int(members_match.group(1)) if members_match else 0
mdstat_total = int(members_match.group(2)) if members_match else 0
active_devices = mdstat_active or detail.get("active_devices", 0)
raid_devices = mdstat_total or detail.get("raid_devices", 0) or len(devices)
failed_devices = detail.get("failed_devices", 0) or max(0, raid_devices - active_devices)
signature = (status, active_devices, raid_devices, failed_devices, rebuilding)
if signature != raid_log_state:
log.info("RAID-Status %s: %s (%s/%s aktiv, %s fehlend%s)", RAID_DEVICE, status.upper(), active_devices, raid_devices, failed_devices, ", Rebuild läuft" if rebuilding else "")
raid_log_state = signature
return {"device": RAID_DEVICE, "status": status, "label": {"ok": "OK", "degraded": "DEGRADED", "failed": "FAILED", "rebuilding": "REBUILDING"}.get(status, "UNKNOWN"),
"level": detail.get("level", "—"), "raid_devices": raid_devices,
"active_devices": active_devices, "failed_devices": failed_devices,
"devices": devices, "recovery": {"active": rebuilding, "percent": float(progress_match.group(1)) if progress_match else 0,
"finish": format_remaining_time(finish_match.group(1)) if finish_match else "—", "speed": format_transfer_rate(speed_match.group(1)) if speed_match else "—"},
"error": None if array_line or mdadm_output else (mdadm_error or mdstat_error or "RAID-Status nicht verfügbar"), "updated_at": time.time()}
def datastore_snapshot():
now = time.time()
with datastore_cache_lock:
if datastore_cache["snapshot"] is None or now - datastore_cache["stored_at"] >= max(30, STORAGE_CACHE_SECONDS):
fresh_storage = scan_datastore_storage()
if fresh_storage.get("error") and datastore_cache["snapshot"]:
datastore_cache["snapshot"] = dict(datastore_cache["snapshot"])
datastore_cache["snapshot"]["stale"] = True
datastore_cache["snapshot"]["warning"] = fresh_storage["error"]
else:
datastore_cache["snapshot"] = fresh_storage
datastore_cache["stored_at"] = now
storage = datastore_cache["snapshot"]
current_raid = raid_status()
with datastore_cache_lock:
if current_raid.get("status") == "failed" and current_raid.get("error") and datastore_cache.get("raid") and now - datastore_cache.get("raid_at", 0) < 60:
current_raid = dict(datastore_cache["raid"])
current_raid["stale"] = True
current_raid["warning"] = "Vorübergehend keine aktuelle RAID-Antwort; letzter gültiger Stand wird angezeigt."
elif current_raid.get("status") != "failed":
datastore_cache["raid"] = dict(current_raid)
datastore_cache["raid_at"] = now
return {"storage": storage, "raid": current_raid, "io_pressure": io_pressure(), "cache_seconds": STORAGE_CACHE_SECONDS}
def cpu_usage_percent():
global previous_cpu
try:
fields = Path("/proc/stat").read_text().splitlines()[0].split()[1:]
values = [int(value) for value in fields]
idle = values[3] + (values[4] if len(values) > 4 else 0)
total = sum(values)
with system_lock:
current = (total, idle)
if previous_cpu is None:
previous_cpu = current
return 0.0
total_delta = total - previous_cpu[0]
idle_delta = idle - previous_cpu[1]
previous_cpu = current
return round(max(0.0, min(100.0, (1 - idle_delta / total_delta) * 100)), 1) if total_delta else 0.0
except (OSError, ValueError, IndexError):
return 0.0
def system_metrics():
memory = {line.split(":", 1)[0]: int(line.split()[1]) * 1024 for line in Path("/proc/meminfo").read_text().splitlines() if ":" in line}
total_memory = memory.get("MemTotal", 0)
available_memory = memory.get("MemAvailable", memory.get("MemFree", 0))
used_memory = max(0, total_memory - available_memory)
# Filme und Serien liegen auf demselben Host-Speicher (/nesflix) und
# werden im Container über die Medien-Mounts sichtbar gemacht.
mount_paths = [("System", "/"), ("Medien", "/media/filme"), ("Medien", "/media/serien")]
disks, seen_devices = [], set()
for label, path in mount_paths:
try:
target = Path(path)
if not target.exists():
continue
device = target.stat().st_dev
if device in seen_devices:
continue
seen_devices.add(device)
usage = shutil.disk_usage(target)
disks.append({"label": label, "path": path, "total": format_bytes(usage.total), "used": format_bytes(usage.used), "free": format_bytes(usage.free), "used_bytes": usage.used, "total_bytes": usage.total, "percent": round(usage.used / usage.total * 100, 1) if usage.total else 0})
except OSError:
continue
try:
load = os.getloadavg()
except (AttributeError, OSError):
load = (0, 0, 0)
try:
uptime_seconds = float(Path("/proc/uptime").read_text().split()[0])
except (OSError, ValueError, IndexError):
uptime_seconds = 0
try:
process_count = sum(1 for entry in Path("/proc").iterdir() if entry.name.isdigit())
except OSError:
process_count = 0
return {"cpu_percent": cpu_usage_percent(), "memory": {"used": format_bytes(used_memory), "total": format_bytes(total_memory), "percent": round(used_memory / total_memory * 100, 1) if total_memory else 0}, "disks": disks, "load": [round(value, 2) for value in load], "uptime_seconds": round(uptime_seconds), "processes": process_count, "timestamp": time.time()}
def release_languages(name):
value = f" {name.lower().replace('.', ' ').replace('-', ' ')} "
found = []
patterns = [
("Deutsch", (" german ", " german dubbed ", " deutsch ", " ger ", " deu ", " german dl ")),
("Englisch", (" english ", " eng ")),
("Japanisch", (" japanese ", " japan ", " jpn ")),
("Französisch", (" french ", " francais ", " français ", " fre ", " fra ")),
("Spanisch", (" spanish ", " esp ", " spa ")),
("Italienisch", (" italian ", " ita ")),
("Portugiesisch", (" portuguese ", " por ", " pt br ")),
("Koreanisch", (" korean ", " kor ")),
("Chinesisch", (" chinese ", " chi ", " zho ")),
("Niederländisch", (" dutch ", " nld ", " ned ")),
("Russisch", (" russian ", " rus ")),
]
codes = {"Deutsch": "de", "Englisch": "en", "Japanisch": "ja", "Französisch": "fr", "Spanisch": "es", "Italienisch": "it", "Portugiesisch": "pt", "Koreanisch": "ko", "Chinesisch": "zh", "Niederländisch": "nl", "Russisch": "ru"}
for language, tokens in patterns:
if any(token in value for token in tokens): found.append(norm_lang(codes[language]))
return unique_langs(found)
def sab_downloads():
queue = sab_get("queue", start=0, limit=100).get("queue") or {}
history = sab_get("history", start=0, limit=30, failed_only=0).get("history") or {}
wanted = []
try:
if RADARR_API_KEY: wanted.extend({"title": x.get("movie", {}).get("title") or x.get("title", ""), "source": "Radarr"} for x in api_get(RADARR_URL, RADARR_API_KEY, "queue?includeUnknownMovieItems=true").get("records", []))
if SONARR_URL and SONARR_API_KEY: wanted.extend({"title": x.get("title", ""), "source": "Sonarr"} for x in api_get(SONARR_URL, SONARR_API_KEY, "queue?includeUnknownSeriesItems=true").get("records", []))
except Exception as exc:
log.warning("Radarr/Sonarr-Queue konnte für SAB-Zuordnung nicht geladen werden: %s", error_summary(exc))
def make_item(item, completed=False):
title = item.get("name") or item.get("filename") or item.get("nzb_name") or "—"
title_lower = title.lower()
source = "SABnzbd"
for candidate in wanted:
candidate_title = candidate["title"].lower()
if candidate_title and (candidate_title in title_lower or title_lower in candidate_title): source = candidate["source"]; break
percentage = 100.0 if completed else float(item.get("percentage") or 0)
return {"title": title, "status": item.get("status") or ("Completed" if completed else "Queued"), "percentage": percentage, "size": item.get("size") or "—", "remaining": item.get("sizeleft") or ("0 B" if completed else "—"), "timeleft": item.get("timeleft") or "—", "speed": item.get("speed") or "—", "category": item.get("cat") or item.get("category") or "—", "source": source, "languages": release_languages(title), "completed": completed}
items = [make_item(item) for item in queue.get("slots", [])]
items.extend(make_item(item, completed=True) for item in history.get("slots", [])[:15])
remaining_bytes = sum(parse_size_bytes(item.get("sizeleft")) for item in queue.get("slots", []))
return {"items": items, "queue_status": queue.get("status", "Idle"), "speed": queue.get("speed", "0 B/s"),
"paused": bool(queue.get("paused_all")), "active_count": len(queue.get("slots", [])),
"history_count": len(history.get("slots", [])[:15]), "remaining_bytes": remaining_bytes,
"remaining": format_bytes(remaining_bytes), "error": None}
def norm_lang(code):
code = (code or "").strip().lower()
name = LANG_NAMES.get(code, code.upper() if code else "Unbekannt")
return {"code": code or "und", "name": name, "flag": FLAGS.get(name, "🌐")}
def unique_langs(items):
out, seen = [], set()
for item in items:
if item["name"] not in seen:
seen.add(item["name"]); out.append(item)
return out
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
EXTERNAL_SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".vtt", ".sub", ".idx", ".sup"}
def external_subtitle_files(path):
media = Path(path)
prefix = f"{media.stem}."
# Nicht glob() verwenden: Mediennamen enthalten oft eckige Klammern
# wie [Bluray-1080p], die glob als Pattern-Zeichen interpretieren würde.
try:
return sorted(candidate for candidate in media.parent.iterdir() if candidate.is_file() and candidate.name.startswith(prefix) and candidate.suffix.lower() in EXTERNAL_SUBTITLE_EXTENSIONS)
except OSError:
return []
def external_subtitle_languages(path):
media = Path(path)
languages = []
for candidate in external_subtitle_files(path):
tokens = {token for token in re.split(r"[. _()\[\]-]+", candidate.stem.lower()) if token}
if tokens & {"de", "deu", "ger", "german", "deutsch"}:
languages.append(norm_lang("de"))
if tokens & {"en", "eng", "english"}:
languages.append(norm_lang("en"))
if tokens & {"fr", "fra", "fre", "french", "französisch"}:
languages.append(norm_lang("fr"))
if tokens & {"es", "spa", "spanish", "spanisch"}:
languages.append(norm_lang("es"))
if tokens & {"it", "ita", "italian", "italienisch"}:
languages.append(norm_lang("it"))
return unique_langs(languages)
def run_ffprobe(path):
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)
subtitles = unique_langs(subs + external_subtitle_languages(path))
return {"audio_languages": unique_langs(audios), "subtitle_languages": subtitles, "external_subtitle_files": [str(file) for file in external_subtitle_files(path)], "audio_details": details, "video_codec": video or "—", "size": int((raw.get("format") or {}).get("size") or 0)}
def cached(path):
p = Path(path)
if not p.exists(): return {"error": f"Datei nicht gefunden: {p}", "pending": True}
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):
data = json.loads(row["data"])
current_subtitles = [str(file) for file in external_subtitle_files(path)]
if "external_subtitle_files" in data and data["external_subtitle_files"] == current_subtitles:
return 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.error("Scanfehler für %s: %s", p, error_summary(exc))
mtime = p.stat().st_mtime
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 "—"
value = float(size)
for unit in ["B", "KB", "MB", "GB", "TB"]:
if value < 1024 or unit == "TB": return f"{value:.1f} {unit}"
value /= 1024
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 has_german_track(row):
languages = row.get("audio_languages", []) + row.get("subtitle_languages", [])
return any(language.get("name") == "Deutsch" for language in languages)
def is_german_review_candidate(row):
"""Only review media that exists or is currently being downloaded."""
return not row.get("missing") or bool(row.get("downloading"))
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", [])
if local_path:
info["subtitle_languages"] = unique_langs(info.get("subtitle_languages", []) + external_subtitle_languages(local_path))
downloading = bool((extra or {}).get("downloading"))
quality = ((((media_file or {}).get("quality") or {}).get("quality") or {}).get("name")) or "—"
row = {"title":name, "year":year, "quality":"Downloading" if downloading else (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":{"class":"downloading", "label":"Downloading"} if downloading else (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")), "downloading":downloading}
if extra: row.update(extra)
return row
def radarr_rows():
rows = []
queue = api_get(RADARR_URL, RADARR_API_KEY, "queue?includeUnknownMovieItems=true")
queued_movies = {item.get("movieId") for item in queue.get("records", [])}
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, {"downloading": movie.get("id") in queued_movies}))
return sorted(rows, key=lambda x: x["title"].lower())
def sonarr_groups():
groups = []
queue = api_get(SONARR_URL, SONARR_API_KEY, "queue?includeUnknownSeriesItems=true")
queued_episodes = {item.get("episodeId") for item in queue.get("records", [])}
tags = api_get(SONARR_URL, SONARR_API_KEY, "tag")
tag_names = {str(tag.get("id")): str(tag.get("label") or tag.get("name") or "").strip().casefold() for tag in tags}
def category_for(series):
normalized_tags = {
re.sub(r"[^a-z0-9]+", "", tag_names.get(str(tag_id), ""))
for tag_id in (series.get("tags") or [])
}
if "anime" in normalized_tags:
return "anime", "Anime"
if "kdrama" in normalized_tags:
return "kdrama", "K-Dramen"
return "standard", "Standard"
for series in api_get(SONARR_URL, SONARR_API_KEY, "series"):
category_key, category_label = category_for(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, "season":episode.get("seasonNumber", 0), "downloading": episode.get("id") in queued_episodes}))
groups.append({"title":series.get("title", "—"), "year":series.get("year"), "episodes":items,
"category":category_key, "category_label":category_label})
return sorted(groups, key=lambda x: x["title"].lower())
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)
futures = [scan_executor.submit(scan_one, path) for path in paths]
for future in as_completed(futures):
future.result()
with job_lock: jobs[source]["done"] += 1
with job_lock: jobs[source]["state"] = "done"
except Exception as exc:
log.error("%s-Scan fehlgeschlagen: %s", source, error_summary(exc)); jobs[source]["error"] = str(exc); jobs[source]["state"] = "error"
executor.submit(work)
@app.route("/")
def index():
error = None; rows = []; series = []
try:
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.error("Sonarr nicht erreichbar: %s", error_summary(exc)); error = f"{error + ' | ' if error else ''}Sonarr nicht erreichbar"
missing_german_movies = [row for row in rows if is_german_review_candidate(row) and not has_german_track(row)]
missing_german_series = []
for group in series:
episodes = [episode for episode in group["episodes"] if is_german_review_candidate(episode) and not has_german_track(episode)]
if episodes:
missing_german_series.append({**group, "episodes": episodes})
series_categories = [
{"key": key, "label": label, "series": [group for group in series if group["category"] == key]}
for key, label in (("anime", "Anime"), ("kdrama", "K-Dramen"), ("standard", "Standard"))
]
dashboard_stats = {
"movie_total": len(rows),
"movie_files": sum(1 for row in rows if not row.get("missing")),
"movie_missing": sum(1 for row in rows if row.get("missing") and not row.get("downloading")),
"movie_downloading": sum(1 for row in rows if row.get("downloading")),
"series_total": len(series),
"episode_total": sum(len(group["episodes"]) for group in series),
"episode_files": sum(1 for group in series for row in group["episodes"] if not row.get("missing")),
"episode_missing": sum(1 for group in series for row in group["episodes"] if row.get("missing") and not row.get("downloading")),
}
return render_template("index.html", rows=rows, series=series, series_categories=series_categories,
missing_german_movies=missing_german_movies,
missing_german_series=missing_german_series, error=error,
dashboard_stats=dashboard_stats,
sonarr_enabled=bool(SONARR_URL and SONARR_API_KEY), sab_enabled=bool(SAB_URL and SAB_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.keys(), "all"): return jsonify({"ok":False, "error":"Unbekannte Quelle"}), 400
con = db()
if source in ("radarr", "all"): con.execute("DELETE FROM media_cache WHERE path NOT LIKE ?", (LOCAL_SERIES_PATH.rstrip("/") + "/%",))
if source in ("sonarr", "all"): con.execute("DELETE FROM media_cache WHERE path LIKE ?", (LOCAL_SERIES_PATH.rstrip("/") + "/%",))
con.commit(); con.close()
if source == "all":
start_scan("radarr", force=True)
if SONARR_URL and SONARR_API_KEY:
start_scan("sonarr", force=True)
else:
start_scan(source, force=True)
return jsonify({"ok":True})
@app.get("/api/scan-status")
def scan_status():
with job_lock: return jsonify(jobs)
@app.get("/api/downloads")
def downloads():
if not SAB_URL or not SAB_API_KEY:
return jsonify({"items": [], "queue_status": "Nicht konfiguriert", "speed": "—", "paused": False, "active_count": 0, "history_count": 0, "remaining": "—", "error": "SAB_URL oder SAB_API_KEY ist nicht gesetzt."})
try:
return jsonify(sab_downloads())
except Exception as exc:
log.error("SABnzbd konnte nicht abgefragt werden: %s", error_summary(exc))
return jsonify({"items": [], "queue_status": "Nicht erreichbar", "speed": "—", "paused": False, "active_count": 0, "history_count": 0, "remaining": "—", "error": "SABnzbd ist momentan nicht erreichbar."}), 502
@app.get("/api/system")
def system():
try:
return jsonify(system_metrics())
except Exception as exc:
log.error("Systemmetriken konnten nicht gelesen werden: %s", error_summary(exc))
return jsonify({"error": "Systemmetriken momentan nicht verfügbar", "cpu_percent": 0, "memory": {}, "disks": []}), 500
@app.get("/api/datastore")
def datastore():
try:
return jsonify(datastore_snapshot())
except Exception as exc:
log.error("Datastore konnte nicht abgefragt werden: %s", error_summary(exc))
return jsonify({"storage": {"error": "Datastore momentan nicht verfügbar", "path": DATASTORE_PATH, "categories": []}, "raid": {"status": "failed", "label": "FAILED", "error": "RAID-Status momentan nicht verfügbar"}}), 500
@app.get("/health")
def health(): return jsonify({"ok":True, "radarr_url":RADARR_URL, "sonarr_enabled":bool(SONARR_URL and SONARR_API_KEY), "sab_enabled":bool(SAB_URL and SAB_API_KEY)})
if __name__ == "__main__": app.run(host="0.0.0.0", port=8099)