Add smart_serial() function with smartctl -i command execution, fallback to -d sat mode, and Serial Number: field regex extraction with WWN/0x prefix filtering to prefer physical serial over logical identifiers. Add serial_futures dict with service_check_executor.submit() calls for parallel serial reads alongside smart_futures/temperature_futures. Update raid_status() disk
922 lines
52 KiB
Python
922 lines
52 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 ", " deutsch ", " german dubbed ", " ger dub ", " german dl ")), ("Englisch", (" english ", " eng ")), ("Japanisch", (" japanese ", " japan ", " jpn ")), ("Französisch", (" french ", " francais ", " fra ")), ("Spanisch", (" spanish ", " esp "))]
|
|
codes = {"Deutsch": "de", "Englisch": "en", "Japanisch": "ja", "Französisch": "fr", "Spanisch": "es"}
|
|
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)
|