diff --git a/README.md b/README.md
index 7bb4346..c65fe44 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@ Media Max ist ein kompaktes Media-Control-Dashboard für Radarr, Sonarr und SABn
- Download-Sprachen aus üblichen Release-Kürzeln wie `Eng`, `Ger`, `Fre`, `Ita` und `Spa`
- Dashboard mit Diensten, CPU, RAM, Speicher, I/O Pressure und Bibliotheksstatistiken
- Datastore mit Kategorien, Gesamtauslastung, I/O Pressure und RAID-Status
+- Graphics-Tab mit NVIDIA-GPU-Modell, Treiber, UUID, Seriennummer, Temperatur, Auslastung, VRAM, Leistung, Clocks und GPU-Prozessen
- RAID-Rebuild-Fortschritt, Geschwindigkeit, Restzeit, Plattenmodell, SMART, Temperatur und Hersteller-Seriennummer
- Parallele Hintergrundscans mit konfigurierbarer Worker-Anzahl
- PWA-Unterstützung für HTTPS-fähige Installationen auf Desktop und Android
@@ -109,6 +110,31 @@ Für SMART, Temperaturen und echte Hersteller-Seriennummern benötigt der Contai
Die I/O Pressure basiert auf Linux PSI (`/proc/pressure/io`). Wenn der Kernel PSI nicht unterstützt, wird die Anzeige als nicht verfügbar markiert.
+### NVIDIA Graphics
+
+Der Graphics-Tab verwendet `nvidia-smi` im Container. Optional kann der Pfad über `NVIDIA_SMI_BIN` gesetzt werden:
+
+```env
+NVIDIA_SMI_BIN=nvidia-smi
+```
+
+Für NVIDIA Container Toolkit kann der Service zusätzlich mit GPU-Zugriff gestartet werden:
+
+```yaml
+deploy:
+ resources:
+ reservations:
+ devices:
+ - driver: nvidia
+ count: all
+ capabilities: [gpu]
+environment:
+ NVIDIA_VISIBLE_DEVICES: all
+ NVIDIA_DRIVER_CAPABILITIES: compute,utility
+```
+
+Wenn `nvidia-smi` nicht erreichbar ist, bleibt der Tab verfügbar und zeigt den Grund an.
+
## Logging
```env
diff --git a/app.py b/app.py
index 49a08e0..0969c80 100644
--- a/app.py
+++ b/app.py
@@ -43,6 +43,7 @@ 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")
+NVIDIA_SMI_BIN = os.environ.get("NVIDIA_SMI_BIN", "nvidia-smi")
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")
@@ -353,6 +354,40 @@ def io_pressure():
except (OSError, ValueError):
return {"some": {}, "full": {}, "available": False}
+def graphics_snapshot():
+ """Read NVIDIA GPU details with short, non-blocking subprocess timeouts."""
+ query = "index,name,uuid,serial,pci.bus_id,driver_version,pstate,temperature.gpu,utilization.gpu,utilization.memory,memory.total,memory.used,memory.free,power.draw,power.limit,fan.speed,clocks.current.graphics,clocks.current.memory,clocks.max.graphics,clocks.max.memory,compute_mode"
+ try:
+ result = subprocess.run([NVIDIA_SMI_BIN, f"--query-gpu={query}", "--format=csv,noheader,nounits"], capture_output=True, text=True, timeout=5, check=False)
+ except (OSError, subprocess.SubprocessError):
+ return {"available": False, "gpus": [], "processes": [], "error": "NVIDIA SMI nicht verfügbar"}
+ if result.returncode != 0 or not result.stdout.strip():
+ return {"available": False, "gpus": [], "processes": [], "error": "Keine NVIDIA-GPU erkannt"}
+ fields = query.split(",")
+ gpus = []
+ for line in result.stdout.splitlines():
+ values = [value.strip() for value in line.split(",")]
+ if len(values) < len(fields):
+ continue
+ gpu = dict(zip(fields, values))
+ for key in ("index", "temperature.gpu", "utilization.gpu", "utilization.memory", "memory.total", "memory.used", "memory.free", "clocks.current.graphics", "clocks.current.memory", "clocks.max.graphics", "clocks.max.memory"):
+ try: gpu[key] = int(float(gpu[key]))
+ except (TypeError, ValueError): gpu[key] = None
+ for key in ("power.draw", "power.limit"):
+ try: gpu[key] = round(float(gpu[key]), 1)
+ except (TypeError, ValueError): gpu[key] = None
+ gpus.append(gpu)
+ processes = []
+ try:
+ proc = subprocess.run([NVIDIA_SMI_BIN, "--query-compute-apps=pid,process_name,used_gpu_memory", "--format=csv,noheader,nounits"], capture_output=True, text=True, timeout=5, check=False)
+ for line in proc.stdout.splitlines():
+ values = [value.strip() for value in line.split(",")]
+ if len(values) >= 3:
+ processes.append({"pid": values[0], "name": values[1], "memory": values[2]})
+ except (OSError, subprocess.SubprocessError):
+ pass
+ return {"available": bool(gpus), "gpus": gpus, "processes": processes, "error": None if gpus else "Keine NVIDIA-GPU erkannt", "updated_at": time.time()}
+
def smart_status(device):
if not SMART_ENABLED:
return "Nicht aktiviert"
@@ -992,6 +1027,14 @@ def datastore():
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("/api/graphics")
+def graphics():
+ try:
+ return jsonify(graphics_snapshot())
+ except Exception as exc:
+ log.error("GPU-Status konnte nicht abgefragt werden: %s", error_summary(exc))
+ return jsonify({"available": False, "gpus": [], "processes": [], "error": "GPU-Status momentan nicht verfügbar"})
+
@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)})
diff --git a/templates/index.html b/templates/index.html
index 39331a3..c2e8758 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -60,10 +60,10 @@ if ('serviceWorker' in navigator) {
.settings-modal .setting-help{display:block;margin:-3px 0 12px;color:var(--muted);font-size:11px}.settings-modal .setting-select{width:100%;height:38px}.pwa-install{display:none}
@media(max-width:700px){body{font-size:12px;padding-bottom:env(safe-area-inset-bottom)}header{height:auto;min-height:62px;padding:9px 12px;gap:9px;align-items:center}.brand-mark,.brand-mark svg{width:30px;height:30px}.brand-name{font-size:14px}.head-title{font-size:12px}.head-sub{font-size:10px}.actions{gap:4px}.actions button{height:34px;padding:0 8px}.settings-button{font-size:15px}.actions #rescan{font-size:0}.actions #rescan::after{content:'↻';font-size:16px}main{padding:10px 10px 24px}.nav{margin-bottom:10px;padding:4px;overflow-x:auto;scrollbar-width:none}.nav::-webkit-scrollbar{display:none}.nav button{flex:0 0 auto;white-space:nowrap;padding:8px 10px;font-size:11px}.toolbar{grid-template-columns:1fr;gap:7px;padding:8px}.toolbar input,.toolbar select{width:100%;height:38px}.stats{gap:6px}.stat{padding:6px 8px}.tablewrap{overflow-x:auto;border-radius:8px}table{min-width:860px}.dashboard-hero{margin-bottom:10px;padding:18px;border-radius:11px}.dashboard-hero h1{font-size:21px}.dashboard-hero p{font-size:11px;line-height:1.4}.overview-grid{grid-template-columns:1fr 1fr;gap:7px}.overview-card{min-height:84px;padding:12px 10px;gap:8px}.overview-icon{width:32px;height:32px;font-size:18px}.overview-card strong{font-size:18px}.overview-card span:not(.overview-icon){font-size:9px}.overview-card small{font-size:9px}.dashboard-columns{gap:8px}.system-panel,.service-panel{padding:14px;border-radius:10px}.system-meters{gap:12px}.panel-heading{margin-bottom:13px}.panel-heading h2{font-size:14px}.panel-heading p{font-size:10px}.disk-row{grid-template-columns:82px 1fr auto;gap:7px}.disk-values{font-size:10px}.download-hero{padding:18px;margin-bottom:10px}.download-hero h2{font-size:19px}.download-hero p{font-size:11px}.download-stats{grid-template-columns:1fr 1fr;gap:7px}.download-card{min-height:78px;padding:10px;gap:8px}.card-icon{width:30px;height:30px;font-size:17px}.card-label{font-size:9px}.download-card strong{font-size:17px}.download-card small{font-size:9px}.settings-modal{max-height:calc(100dvh - 24px);overflow:auto;padding:18px}.settings-head{margin-bottom:15px}.settings-modal label{margin-top:13px}}
@media(max-width:390px){.brand-name{display:none}.head-sub{display:none}.overview-grid{grid-template-columns:1fr}.dashboard-hero{padding:15px}.download-stats{grid-template-columns:1fr}}
-
+
- Dashboard RADARR · Filme {% if sonarr_enabled %} SONARR · Serien {% endif %} Ohne Deutsch {{ missing_german_movies|length + (missing_german_series|map(attribute='episodes')|map('length')|sum) }} {% if sab_enabled %} SABNZBD · Downloads 0 {% endif %} Datastore
+ Dashboard RADARR · Filme {% if sonarr_enabled %} SONARR · Serien {% endif %} Ohne Deutsch {{ missing_german_movies|length + (missing_german_series|map(attribute='episodes')|map('length')|sum) }} {% if sab_enabled %} SABNZBD · Downloads 0 {% endif %} Datastore Graphics
MEDIA MAX
Dashboard Deine gesamte Medienbibliothek und Systemauslastung auf einen Blick.
Live
▣ Filme {{ rows|length }} Radarr Library
▤ Serien {{ series|length }} Sonarr Shows
⚠ Ohne Deutsch {{ missing_german_movies|length + (missing_german_series|map(attribute='episodes')|map('length')|sum) }} Audio oder Untertitel prüfen
↓ Downloads — SABnzbd wird geladen …
Systemauslastung Aktuelle Werte aus dem Dashboard-Container
Wird geladen … Laufwerke werden geladen …
Dienste Verbindung und Bibliotheksstatus
R Radarr Filme & Qualität
Aktiv S Sonarr Serien & Episoden
{% if sonarr_enabled %}Aktiv{% else %}Nicht konfiguriert{% endif %} ↓ SABnzbd Downloads & Queue
{% if sab_enabled %}Wird geprüft …{% else %}Nicht konfiguriert{% endif %}
Alle Audio-Sprachen Deutsch Englisch Japanisch Koreanisch Alle Untertitel Deutsch Englisch Japanisch Koreanisch Alle Status DE + EN Deutsch English only
@@ -79,7 +79,7 @@ if ('serviceWorker' in navigator) {
{% for group in missing_german_series %}{{ group.title }} ({{ group.year or '—' }}) · {{ group.episodes|length }} Episoden ohne Deutsch Episode Qualität Status Audio Untertitel Video Größe {% for r in group.episodes %}{{ r.title }} {{ r.quality }} Ohne Deutsch {% for l in r.audio_languages %}{{ l.flag }} {{ l.name }} {% else %}— {% endfor %}
{% for l in r.subtitle_languages %}{{ l.flag }} {{ l.name }} {% else %}— {% endfor %}
{{ r.video_codec }} {{ r.size }} {% endfor %}
{% endfor %}
{% if not missing_german_movies and not missing_german_series %}Alle vorhandenen Filme und Episoden haben deutsches Audio oder deutsche Untertitel.
{% endif %}
{% if sab_enabled %}SABNZBD · DOWNLOAD CENTER
Download-Übersicht Queue, Fortschritt und verbleibender Speicherplatz auf einen Blick.
Verbinde mit SABnzbd …
↓ Noch zu laden — Wird berechnet …
◉ Aktive Downloads — Verlauf: —
↯ Geschwindigkeit — Aktuelle Queue
Download Status Fortschritt Größe / Rest Restzeit Quelle Sprache Downloads werden geladen …
{% endif %}
-MEDIA MAX · DATASTORE
Datastore Speicherbelegung, Medienkategorien und RAID-Status von /nesflix.
Wird geladen …
Speicher nach Kategorie Anhand der tatsächlichen Verzeichnisse
— — —
RAID Rebuild —
Geschwindigkeit — Verbleibend —
+MEDIA MAX · GRAPHICS
NVIDIA Graphics GPU-Auslastung, Speicher, Temperatur, Leistung und laufende Prozesse.
Wird geladen …
GPU-Daten werden geladen …
GPU-Prozesse Aktuelle Compute-Prozesse laut NVIDIA SMI
MEDIA MAX · DATASTORE
Datastore Speicherbelegung, Medienkategorien und RAID-Status von /nesflix.
Wird geladen …
Speicher nach Kategorie Anhand der tatsächlichen Verzeichnisse
— — —
RAID Rebuild —
Geschwindigkeit — Verbleibend —
Einstellungen Deine Anzeige- und Sprachpräferenzen
× Bevorzugte Audio-Sprache Keine Präferenz Deutsch Englisch Japanisch Koreanisch Bevorzugte Untertitel-Sprache Keine Präferenz Deutsch Englisch Japanisch Koreanisch Abbrechen Speichern