Compare commits
46
Commits
cc7d719465
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00b841fa67 | ||
|
|
3fb2b34587 | ||
|
|
de0d360a3c | ||
|
|
3f418bb2a3 | ||
|
|
83dc8c08ae | ||
|
|
952797005f | ||
|
|
7313eb96c3 | ||
|
|
8c8e1be0db | ||
|
|
7d1987c4f4 | ||
|
|
6aa4032134 | ||
|
|
5d7e452aaf | ||
|
|
1882d4e137 | ||
|
|
a2f411c578 | ||
|
|
8ec43481c7 | ||
|
|
34af0a4ad9 | ||
|
|
67e588bfc8 | ||
|
|
dfbeec4dc0 | ||
|
|
58c8ad3bcc | ||
|
|
f5d8b4189c | ||
|
|
7469ec7b9f | ||
|
|
be68853a74 | ||
|
|
2096fc5d11 | ||
|
|
14a4c6ce7b | ||
|
|
806886da46 | ||
|
|
ffd995e50b | ||
|
|
75f6de91b6 | ||
|
|
d4c24f02d4 | ||
|
|
02449a80de | ||
|
|
b275164be9 | ||
|
|
c6c2437732 | ||
|
|
01b6a7fd4e | ||
|
|
c2e2f7552f | ||
|
|
f820a99c7c | ||
|
|
9373e93e7f | ||
|
|
310b36317e | ||
|
|
71752483f6 | ||
|
|
5723a5d2a0 | ||
|
|
cef785361f | ||
|
|
d9033cbb6e | ||
|
|
2f71f92165 | ||
|
|
62d19c26a1 | ||
|
|
c2df06dda8 | ||
|
|
e84bc9c695 | ||
|
|
1f658adf71 | ||
|
|
e3dae93d33 | ||
|
|
44fbfaded4 |
@@ -7,5 +7,6 @@ COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app.py .
|
||||
COPY templates ./templates
|
||||
COPY static ./static
|
||||
EXPOSE 8099
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:8099", "--workers", "1", "--threads", "4", "--timeout", "120", "app:app"]
|
||||
|
||||
@@ -1,43 +1,75 @@
|
||||
# Media Max
|
||||
|
||||
## Datastore
|
||||
Media Max ist ein kompaktes Media-Control-Dashboard für Radarr, Sonarr und SABnzbd. Es zeigt Medienqualität, Audio- und Untertitelspuren, fehlende deutsche Spuren, laufende Downloads sowie System-, Speicher- und RAID-Status.
|
||||
|
||||
Der Datastore-Tab zeigt den read-only Speicherstatus von `/nesflix`, Medienkategorien
|
||||
und den Linux-Software-RAID-Status. Größen-Scans werden standardmäßig 15 Minuten
|
||||
gecacht; RAID-/Recovery-Werte aktualisieren sich live. Konfigurierbar sind
|
||||
`DATASTORE_PATH`, `DATASTORE_HOST_PATH`, `RAID_DEVICE`, `STORAGE_CACHE_SECONDS` und
|
||||
optional `SMART_ENABLED=true` für SMART-Healthwerte.
|
||||
## Funktionen
|
||||
|
||||
## Wichtig: Path-Mapping
|
||||
- Radarr-Filme mit Qualität, Status, Audio, Untertiteln, Codec und Größe
|
||||
- Sonarr-Serien mit Episodenansicht und Kategorien über Tags:
|
||||
- `anime` → Anime
|
||||
- `kdrama` → K-Dramen
|
||||
- alle anderen → Standard
|
||||
- Separate Liste „Ohne Deutsch“ für vorhandene oder aktuell geladene Medien
|
||||
- Staffel 0 kann in den Einstellungen ein- oder ausgeblendet werden
|
||||
- Externe Untertitel (`.srt`, `.ass`, `.ssa`, `.vtt`, `.sub`, `.idx`, `.sup`) werden berücksichtigt
|
||||
- SABnzbd-Downloadübersicht mit aktiven Downloads und separater Queue-/Verifying-Tabelle
|
||||
- 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
|
||||
|
||||
Radarr kann seine Filme z.B. unter `/data/filme` sehen, während der Host sie unter
|
||||
`/nesflix/filme` hat. Das Dashboard unterstützt diese Abbildung explizit:
|
||||
## Konfiguration
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
RADARR_MEDIA_PATH: "/data/filme"
|
||||
LOCAL_MEDIA_PATH: "/media/filme"
|
||||
Persönliche Werte gehören in `.env`. Die Datei ist in `.gitignore` eingetragen. Als Vorlage dient `.env.example`:
|
||||
|
||||
volumes:
|
||||
- /nesflix/filme:/media/filme:ro
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### Radarr und Medienpfade
|
||||
|
||||
Radarr kann beispielsweise `/data/filme` verwenden, während Media Max die Dateien im Container unter `/media/filme` sieht:
|
||||
|
||||
```env
|
||||
RADARR_URL=http://radarr:7878
|
||||
RADARR_API_KEY=...
|
||||
RADARR_MEDIA_PATH=/data/filme
|
||||
LOCAL_MEDIA_PATH=/media/filme
|
||||
FILM_MEDIA_VOLUME=/nesflix/filme
|
||||
```
|
||||
|
||||
### Sonarr
|
||||
|
||||
```env
|
||||
SONARR_URL=http://sonarr:8989
|
||||
SONARR_API_KEY=...
|
||||
SONARR_MEDIA_PATH=/data/serien
|
||||
LOCAL_SERIES_PATH=/media/serien
|
||||
SERIES_MEDIA_VOLUME=/nesflix/serien
|
||||
```
|
||||
|
||||
### SABnzbd
|
||||
|
||||
```env
|
||||
SAB_URL=http://sabnzbd:8080
|
||||
SAB_API_KEY=...
|
||||
```
|
||||
|
||||
Der Download-Tab verwendet Queue und History aus SABnzbd. Für laufende Releases werden Sprachen aus dem Dateinamen abgeleitet; die Medienanalyse nach dem Import erfolgt über `ffprobe`.
|
||||
|
||||
## Dienststatus
|
||||
|
||||
Der Dashboard-Tab prüft zusätzlich Jellyfin, Seerr, Prowlarr, Easynews as Indexer,
|
||||
qBittorrent, Bazarr, Lingarr, LibreTranslate sowie die Sonarr-/Radarr-Prowlarr-Proxies.
|
||||
Die Prüfungen laufen parallel im Hintergrund und blockieren die Seite nicht.
|
||||
|
||||
Die Standardnamen funktionieren, wenn die Container im selben Docker-Netzwerk liegen.
|
||||
Für qBittorrent muss `QBITTORRENT_URL` auf eine aus dem Dashboard-Container erreichbare
|
||||
URL zeigen, zum Beispiel `http://host.docker.internal:8080` oder die Host-IP.
|
||||
Der Dashboard-Tab prüft die Dienste parallel mit kurzen Timeouts und aktualisiert sie regelmäßig:
|
||||
|
||||
```env
|
||||
JELLYFIN_URL=http://jellyfin:8096
|
||||
SEERR_URL=http://seerr:5055
|
||||
PROWLARR_URL=http://prowlarr:9696
|
||||
EASYNEWS_INDEXER_URL=http://easynews-as-indexer:8081
|
||||
QBITTORRENT_URL=http://HOST_ODER_IP:8080
|
||||
QBITTORRENT_URL=http://host.docker.internal:8080
|
||||
BAZARR_URL=http://bazarr:6767
|
||||
LINGARR_URL=http://lingarr:9876
|
||||
LIBRETRANSLATE_URL=http://libretranslate:5000
|
||||
@@ -47,73 +79,124 @@ SERVICE_CHECK_TIMEOUT=3
|
||||
SERVICE_REFRESH_SECONDS=30
|
||||
```
|
||||
|
||||
Für die bereits vorhandenen Container sind keine zusätzlichen Compose-Services nötig;
|
||||
sie müssen im gemeinsamen `media-stack_default`-Netzwerk erreichbar sein. Falls
|
||||
qBittorrent nicht über `host.docker.internal` erreichbar ist, kann unter Linux je nach
|
||||
Docker-Konfiguration dieser Eintrag ergänzt werden:
|
||||
Die Container müssen im gemeinsamen Docker-Netzwerk `media-stack_default` erreichbar sein. qBittorrent läuft außerhalb des Compose-Stacks und benötigt deshalb eine erreichbare URL. Bei Bedarf stellt `extra_hosts` den Namen `host.docker.internal` bereit.
|
||||
|
||||
## Scans und Performance
|
||||
|
||||
Der Bibliotheks-Sync läuft als serverseitiger Hintergrund-Thread und benötigt keinen geöffneten Browser. Beim Start und anschließend nach `SYNC_INTERVAL_SECONDS` werden Radarr, Sonarr und die Sprachprüfung aktualisiert. Über den Button „Neu scannen“ kann weiterhin manuell zwischen Radarr, Sonarr, allen Quellen und ausschließlich den aktuellen Einträgen der Liste „Ohne Deutsch“ gewählt werden.
|
||||
|
||||
```env
|
||||
SCAN_WORKERS=4
|
||||
REQUEST_TIMEOUT=20
|
||||
DB_PATH=/data/cache.db
|
||||
SYNC_INTERVAL_SECONDS=1800
|
||||
RAID_REFRESH_SECONDS=5
|
||||
IO_REFRESH_SECONDS=5
|
||||
GPU_REFRESH_SECONDS=5
|
||||
```
|
||||
|
||||
`SCAN_WORKERS` steuert die Anzahl paralleler `ffprobe`-Jobs. Höhere Werte beschleunigen große Bibliotheken, erhöhen aber die I/O-Last.
|
||||
`SYNC_INTERVAL_SECONDS` steuert den serverseitigen Sync-Takt; `1800` entspricht 30 Minuten, `18000` fünf Stunden und `0` deaktiviert die periodische Wiederholung (der Start-Sync bleibt aktiv).
|
||||
RAID, I/O-Pressure und GPU werden unabhängig vom Browser durch eigene Hintergrund-Monitoren aktualisiert. Die Werte werden aus dem Server-Cache geliefert; die drei Intervalle können separat angepasst werden.
|
||||
|
||||
## Datastore, RAID und SMART
|
||||
|
||||
Der Datastore ist standardmäßig `/nesflix` und wird read-only eingebunden:
|
||||
|
||||
```env
|
||||
DATASTORE_PATH=/nesflix
|
||||
DATASTORE_HOST_PATH=/nesflix
|
||||
RAID_DEVICE=/dev/md127
|
||||
STORAGE_CACHE_SECONDS=900
|
||||
SMART_ENABLED=true
|
||||
HOST_SYS_PATH=/host-sys
|
||||
```
|
||||
|
||||
Für SMART, Temperaturen und echte Hersteller-Seriennummern benötigt der Container Zugriff auf `/dev`, `/sys`, `/run/udev`, `SYS_RAWIO` sowie `smartctl` im Image. Die App liest keine RAID-Daten schreibend und führt keinen Rebuild aus.
|
||||
|
||||
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
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
environment:
|
||||
NVIDIA_VISIBLE_DEVICES: all
|
||||
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||
```
|
||||
|
||||
Ein Radarr-Pfad wie:
|
||||
Wenn `nvidia-smi` nicht erreichbar ist, bleibt der Tab verfügbar und zeigt den Grund an.
|
||||
|
||||
`/data/filme/Avatar (2009)/Avatar.mkv`
|
||||
## Logging
|
||||
|
||||
wird dadurch für ffprobe zu:
|
||||
|
||||
`/media/filme/Avatar (2009)/Avatar.mkv`
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Die persönlichen URLs, Pfade und API-Keys liegen in `.env`. Diese Datei ist in `.gitignore` eingetragen und bleibt bei `git pull` erhalten. Als Vorlage dient `.env.example`.
|
||||
|
||||
Beim ersten Start:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```env
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=/data/media-max.log
|
||||
LOG_RETENTION_HOURS=6
|
||||
```
|
||||
|
||||
Danach die Werte in `.env` eintragen. Sonarr und SABnzbd werden dort durch Auskommentieren der jeweiligen Variablen aktiviert. Wenn Sonarr aktiv ist, muss `SERIES_MEDIA_VOLUME` auf den echten Host-Pfad deiner Serien-Library zeigen; dieser Ordner wird in den Dashboard-Container nach `/media/serien` gemountet.
|
||||
Erwartbare Verbindungsfehler werden als kurze einzeilige Meldungen protokolliert. Die Logdatei wird standardmäßig alle sechs Stunden rotiert.
|
||||
|
||||
## Start
|
||||
|
||||
API-Keys und Pfade in `.env` anpassen:
|
||||
## Start mit Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d --build
|
||||
docker compose logs -f
|
||||
docker compose logs -f media-max
|
||||
```
|
||||
|
||||
Die Seite lädt zuerst die Library-Metadaten. Die ffprobe-Scans laufen danach im Hintergrund; oben wird der Fortschritt angezeigt. Für Sonarr die optionalen Variablen und den Serien-Volume-Mount in `docker-compose.yml` aktivieren. Die Navigation trennt Filme und Serien, Serien sind pro Show aufklappbar.
|
||||
|
||||
Sonarr benötigt:
|
||||
Der Compose-Stack bindet standardmäßig ein:
|
||||
|
||||
```yaml
|
||||
SONARR_URL: "http://sonarr:8989"
|
||||
SONARR_API_KEY: "..."
|
||||
SONARR_MEDIA_PATH: "/data/serien"
|
||||
LOCAL_SERIES_PATH: "/media/serien"
|
||||
volumes:
|
||||
- ${FILM_MEDIA_VOLUME}:/media/filme:ro
|
||||
- ${SERIES_MEDIA_VOLUME:-/nesflix/serien}:/media/serien:ro
|
||||
- ${DATASTORE_HOST_PATH:-/nesflix}:/nesflix:ro
|
||||
- /dev:/host-dev:ro
|
||||
- /sys:/host-sys:ro
|
||||
- /run/udev:/host-run-udev:ro
|
||||
- ./data:/data
|
||||
```
|
||||
|
||||
SABnzbd benötigt für den Download-Tab:
|
||||
Für Radarr, Sonarr und die weiteren Container wird das externe Netzwerk `media-stack_default` verwendet.
|
||||
|
||||
```yaml
|
||||
SAB_URL: "http://sabnzbd:8080"
|
||||
SAB_API_KEY: "..."
|
||||
```
|
||||
## Fehlersuche
|
||||
|
||||
Der API-Key steht in SABnzbd unter `Config > General`. Die laufenden Jobs kommen aus der SABnzbd-Queue, abgeschlossene und fehlgeschlagene Jobs aus der History. Die Sprache eines laufenden Releases wird aus dessen Namen abgeleitet; nach dem Import ist die Sprache im Radarr-/Sonarr-Scan verlässlich.
|
||||
|
||||
## Bei weiterem Scanfehler
|
||||
|
||||
Prüfe einen Pfad im Container:
|
||||
Prüfe zuerst, ob Media Max die Medien sieht:
|
||||
|
||||
```bash
|
||||
docker exec -it media-max sh
|
||||
ls -lah /media/filme
|
||||
ls -lah /media/serien
|
||||
find /media/filme -type f | head
|
||||
ffprobe -v error -show_entries stream=codec_type,codec_name:stream_tags=language -of json "/media/filme/DEIN/FILM.mkv"
|
||||
```
|
||||
|
||||
Teste anschließend `ffprobe` direkt im Container:
|
||||
|
||||
```bash
|
||||
ffprobe -v error \
|
||||
-show_entries stream=codec_type,codec_name:stream_tags=language \
|
||||
-of json "/media/filme/DEIN/FILM.mkv"
|
||||
```
|
||||
|
||||
Für SMART:
|
||||
|
||||
```bash
|
||||
smartctl -i -d sat /host-dev/sda
|
||||
smartctl -A -d sat /host-dev/sda
|
||||
```
|
||||
|
||||
Wenn eine PWA-Installation in Chrome nicht angeboten wird, muss Media Max über gültiges HTTPS aufgerufen werden. Nach Änderungen an Manifest oder Service Worker sollten alte Website-Daten bzw. eine alte Verknüpfung entfernt und die Seite neu geladen werden.
|
||||
|
||||
@@ -13,7 +13,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from flask import Flask, jsonify, render_template, request
|
||||
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", "")
|
||||
@@ -43,13 +43,25 @@ 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")
|
||||
LOG_RETENTION_HOURS = max(1, int(os.environ.get("LOG_RETENTION_HOURS", "6")))
|
||||
SYNC_INTERVAL_SECONDS = max(0, int(os.environ.get("SYNC_INTERVAL_SECONDS", "1800")))
|
||||
RAID_REFRESH_SECONDS = max(2, int(os.environ.get("RAID_REFRESH_SECONDS", "5")))
|
||||
IO_REFRESH_SECONDS = max(2, int(os.environ.get("IO_REFRESH_SECONDS", "5")))
|
||||
GPU_REFRESH_SECONDS = max(2, int(os.environ.get("GPU_REFRESH_SECONDS", "5")))
|
||||
|
||||
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"))
|
||||
@@ -57,18 +69,35 @@ try:
|
||||
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}}
|
||||
jobs = {"radarr": {"state": "idle", "total": 0, "done": 0, "error": None}, "sonarr": {"state": "idle", "total": 0, "done": 0, "error": None}, "missing": {"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}
|
||||
datastore_refresh_lock = threading.Lock()
|
||||
raid_refresh_lock = threading.Lock()
|
||||
datastore_cache = {"stored_at": 0.0, "snapshot": None, "raid": None, "raid_at": 0.0, "io": None, "io_at": 0.0}
|
||||
raid_log_state = None
|
||||
graphics_log_state = None
|
||||
graphics_process_log_state = None
|
||||
graphics_cache_lock = threading.Lock()
|
||||
graphics_cache = None
|
||||
hardware_log_once = set()
|
||||
library_cache_lock = threading.Lock()
|
||||
library_cache = {"rows": [], "series": [], "updated_at": 0.0, "error": None, "ready": False}
|
||||
|
||||
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":"❓"}
|
||||
@@ -138,8 +167,8 @@ def service_monitor():
|
||||
while True:
|
||||
try:
|
||||
refresh_services()
|
||||
except Exception:
|
||||
log.exception("Dienststatus konnte nicht aktualisiert werden")
|
||||
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()
|
||||
@@ -228,8 +257,8 @@ def scan_datastore_storage():
|
||||
category["files"] += 1
|
||||
usage = shutil.disk_usage(root)
|
||||
except OSError as exc:
|
||||
log.exception("Datastore konnte nicht gelesen werden: %s", DATASTORE_PATH)
|
||||
return {"error": f"Datastore konnte nicht gelesen werden: {exc}", "path": DATASTORE_PATH, "categories": list(categories.values())}
|
||||
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():
|
||||
@@ -322,6 +351,165 @@ def format_remaining_time(value):
|
||||
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 graphics_snapshot():
|
||||
"""Read NVIDIA GPU details with short, non-blocking subprocess timeouts."""
|
||||
global graphics_log_state, graphics_process_log_state
|
||||
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) as exc:
|
||||
signature = ("unavailable", str(exc))
|
||||
if signature != graphics_log_state:
|
||||
log.warning("GPU-Abfrage fehlgeschlagen: %s (%s)", NVIDIA_SMI_BIN, error_summary(exc))
|
||||
graphics_log_state = signature
|
||||
return {"available": False, "gpus": [], "processes": [], "error": "NVIDIA SMI nicht verfügbar"}
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
detail = " ".join((result.stderr or result.stdout or "keine Ausgabe").split())[:240]
|
||||
signature = ("failed", result.returncode, detail)
|
||||
if signature != graphics_log_state:
|
||||
log.warning("GPU-Abfrage fehlgeschlagen: %s (Code %s): %s", NVIDIA_SMI_BIN, result.returncode, detail)
|
||||
graphics_log_state = signature
|
||||
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_by_pid = {}
|
||||
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)
|
||||
if proc.returncode == 0:
|
||||
for line in proc.stdout.splitlines():
|
||||
values = [value.strip() for value in line.split(",")]
|
||||
if len(values) >= 3 and values[0].isdigit():
|
||||
processes_by_pid[values[0]] = {"pid": values[0], "name": values[1], "memory": values[2], "type": "compute"}
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
pmon_error = ""
|
||||
try:
|
||||
# Compute-apps misses NVENC/NVDEC. pmon also reports video/graphics
|
||||
# processes such as Jellyfin while a hardware transcode is running.
|
||||
pmon = subprocess.run([NVIDIA_SMI_BIN, "pmon", "-c", "1", "-s", "um"], capture_output=True, text=True, timeout=5, check=False)
|
||||
if pmon.returncode == 0:
|
||||
for line in pmon.stdout.splitlines():
|
||||
values = line.split()
|
||||
if not values or values[0].startswith("#") or len(values) < 8 or not values[1].isdigit():
|
||||
continue
|
||||
pid, process_type = values[1], values[2]
|
||||
command = " ".join(values[7:]) or "GPU-Prozess"
|
||||
processes_by_pid[pid] = {
|
||||
"pid": pid,
|
||||
"name": command,
|
||||
"memory": f"SM {values[3]}% · ENC {values[5]}% · DEC {values[6]}%",
|
||||
"type": process_type,
|
||||
"sm": values[3], "enc": values[5], "dec": values[6],
|
||||
}
|
||||
else:
|
||||
pmon_error = " ".join((pmon.stderr or "pmon ohne Ausgabe").split())[:180]
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
pmon_error = error_summary(exc)
|
||||
processes = list(processes_by_pid.values())
|
||||
process_signature = tuple((item["pid"], item["name"], item.get("type")) for item in processes)
|
||||
if process_signature != graphics_process_log_state:
|
||||
if processes:
|
||||
log.info("GPU-Prozesse erkannt: %s", ", ".join(f"{item['name']} (PID {item['pid']})" for item in processes))
|
||||
elif pmon_error:
|
||||
log.warning("GPU-Prozessabfrage fehlgeschlagen: %s", pmon_error)
|
||||
else:
|
||||
log.info("Keine GPU-Prozesse erkannt")
|
||||
graphics_process_log_state = process_signature
|
||||
signature = ("ok", tuple((gpu.get("index"), gpu.get("name"), gpu.get("temperature.gpu"), gpu.get("utilization.gpu")) for gpu in gpus))
|
||||
if signature != graphics_log_state:
|
||||
log.info("GPU-Status verfügbar: %s NVIDIA-GPU(s) via %s", len(gpus), NVIDIA_SMI_BIN)
|
||||
graphics_log_state = signature
|
||||
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"
|
||||
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 = "", ""
|
||||
@@ -375,6 +563,13 @@ def raid_status():
|
||||
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"])
|
||||
@@ -387,31 +582,22 @@ def raid_status():
|
||||
info = host_items[0] if host_items else {}
|
||||
except (ValueError, AttributeError):
|
||||
info = {}
|
||||
serial = info.get("serial") or _device_serial(disk["device"])
|
||||
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 and shutil.which("smartctl"):
|
||||
smart_device = disk["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)
|
||||
disk["smart"] = health.group(1).strip() if health else ("Fehler: " + smart_error.strip()[:80] if smart_error.strip() else "Nicht unterstützt")
|
||||
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 health and "smart-info:" + disk["device"] not in hardware_log_once:
|
||||
log.info("SMART-Status %s: %s", disk["device"], disk["smart"])
|
||||
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"])
|
||||
if not health and smart_log_key not in hardware_log_once:
|
||||
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)
|
||||
elif SMART_ENABLED and "smartctl" not in hardware_log_once:
|
||||
log.error("smartctl ist im Container nicht verfügbar; SMART kann nicht geprüft werden")
|
||||
hardware_log_once.add("smartctl")
|
||||
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)
|
||||
@@ -441,14 +627,89 @@ def raid_status():
|
||||
"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 refresh_datastore_storage():
|
||||
if not datastore_refresh_lock.acquire(blocking=False):
|
||||
return
|
||||
try:
|
||||
fresh_storage = scan_datastore_storage()
|
||||
with datastore_cache_lock:
|
||||
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"] = time.time()
|
||||
finally:
|
||||
datastore_refresh_lock.release()
|
||||
|
||||
def refresh_raid_status():
|
||||
if not raid_refresh_lock.acquire(blocking=False):
|
||||
return
|
||||
try:
|
||||
current = raid_status()
|
||||
with datastore_cache_lock:
|
||||
if current.get("status") == "failed" and current.get("error") and datastore_cache.get("raid"):
|
||||
cached = dict(datastore_cache["raid"])
|
||||
cached["stale"] = True
|
||||
cached["warning"] = "Vorübergehend keine aktuelle RAID-Antwort."
|
||||
datastore_cache["raid"] = cached
|
||||
else:
|
||||
datastore_cache["raid"] = current
|
||||
datastore_cache["raid_at"] = time.time()
|
||||
except Exception as exc:
|
||||
log.error("RAID-Refresh fehlgeschlagen: %s", error_summary(exc))
|
||||
finally:
|
||||
raid_refresh_lock.release()
|
||||
|
||||
def refresh_io_status():
|
||||
current = io_pressure()
|
||||
with datastore_cache_lock:
|
||||
datastore_cache["io"] = current
|
||||
datastore_cache["io_at"] = time.time()
|
||||
|
||||
def _hardware_loop(name, interval, refresh):
|
||||
log.info("%s-Monitor gestartet (Intervall: %ss)", name, interval)
|
||||
while True:
|
||||
try:
|
||||
refresh()
|
||||
except Exception as exc:
|
||||
log.error("%s-Monitor fehlgeschlagen: %s", name, error_summary(exc))
|
||||
time.sleep(interval)
|
||||
|
||||
def refresh_gpu_status():
|
||||
global graphics_cache
|
||||
current = graphics_snapshot()
|
||||
with graphics_cache_lock:
|
||||
graphics_cache = current
|
||||
|
||||
def hardware_status_monitor():
|
||||
"""Run slow hardware probes independently and in parallel."""
|
||||
for name, interval, refresh in (
|
||||
("RAID", RAID_REFRESH_SECONDS, refresh_raid_status),
|
||||
("I/O", IO_REFRESH_SECONDS, refresh_io_status),
|
||||
("GPU", GPU_REFRESH_SECONDS, refresh_gpu_status),
|
||||
):
|
||||
threading.Thread(target=_hardware_loop, args=(name, interval, refresh), name=f"{name.lower()}-monitor", daemon=True).start()
|
||||
|
||||
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):
|
||||
datastore_cache["snapshot"] = scan_datastore_storage()
|
||||
datastore_cache["stored_at"] = now
|
||||
storage = datastore_cache["snapshot"]
|
||||
return {"storage": storage, "raid": raid_status(), "cache_seconds": STORAGE_CACHE_SECONDS}
|
||||
refresh_due = storage is None or now - datastore_cache["stored_at"] >= max(30, STORAGE_CACHE_SECONDS)
|
||||
if storage is None:
|
||||
refresh_datastore_storage()
|
||||
elif refresh_due:
|
||||
threading.Thread(target=refresh_datastore_storage, name="datastore-refresh", daemon=True).start()
|
||||
with datastore_cache_lock:
|
||||
storage = datastore_cache["snapshot"]
|
||||
with datastore_cache_lock:
|
||||
current_raid = datastore_cache.get("raid")
|
||||
if current_raid is None:
|
||||
current_raid = {"device": RAID_DEVICE, "status": "unknown", "label": "WIRD GELADEN", "error": "RAID-Status wird geladen"}
|
||||
with datastore_cache_lock:
|
||||
current_io = datastore_cache.get("io") or {"available": False, "some": {}, "full": {}}
|
||||
return {"storage": storage, "raid": current_raid, "io_pressure": current_io, "cache_seconds": STORAGE_CACHE_SECONDS}
|
||||
|
||||
def cpu_usage_percent():
|
||||
global previous_cpu
|
||||
@@ -508,8 +769,20 @@ def system_metrics():
|
||||
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"}
|
||||
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)
|
||||
@@ -521,8 +794,8 @@ def sab_downloads():
|
||||
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:
|
||||
log.warning("Radarr/Sonarr-Queue konnte für SAB-Zuordnung nicht geladen werden", exc_info=True)
|
||||
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 "—"
|
||||
@@ -561,6 +834,7 @@ def map_path(path, remote, local):
|
||||
return path
|
||||
|
||||
EXTERNAL_SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".vtt", ".sub", ".idx", ".sup"}
|
||||
SCANNER_VERSION = 2
|
||||
|
||||
def external_subtitle_files(path):
|
||||
media = Path(path)
|
||||
@@ -589,18 +863,40 @@ def external_subtitle_languages(path):
|
||||
languages.append(norm_lang("it"))
|
||||
return unique_langs(languages)
|
||||
|
||||
def language_from_text(value):
|
||||
"""Infer a language from a subtitle/audio track title when the codec tag is `und`."""
|
||||
tokens = {token for token in re.split(r"[. _()\[\]-]+", str(value or "").lower()) if token}
|
||||
aliases = {
|
||||
"de": "de", "deu": "de", "ger": "de", "german": "de", "deutsch": "de",
|
||||
"en": "en", "eng": "en", "english": "en",
|
||||
"ja": "ja", "jpn": "ja", "japanese": "ja", "japanisch": "ja",
|
||||
"ko": "ko", "kor": "ko", "korean": "ko", "koreanisch": "ko",
|
||||
"fr": "fr", "fra": "fr", "fre": "fr", "french": "fr", "französisch": "fr",
|
||||
"es": "es", "spa": "es", "spanish": "es", "spanisch": "es",
|
||||
"it": "it", "ita": "it", "italian": "it", "italienisch": "it",
|
||||
}
|
||||
for token in tokens:
|
||||
if token in aliases:
|
||||
return norm_lang(aliases[token])
|
||||
return None
|
||||
|
||||
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")
|
||||
tags = stream.get("tags") or {}; kind = stream.get("codec_type")
|
||||
lang = norm_lang(tags.get("language"))
|
||||
# Viele ASS-Spuren werden von ffprobe nur als `und` gemeldet, obwohl
|
||||
# der Tracktitel z. B. „Deutsch - Undefined - ASS“ enthält.
|
||||
if kind == "subtitle" and lang["name"] == "Unbekannt":
|
||||
lang = language_from_text(tags.get("title")) or lang
|
||||
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)}
|
||||
return {"scanner_version": SCANNER_VERSION, "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)
|
||||
@@ -609,7 +905,7 @@ def cached(path):
|
||||
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:
|
||||
if data.get("scanner_version") == SCANNER_VERSION and "external_subtitle_files" in data and data["external_subtitle_files"] == current_subtitles:
|
||||
return data
|
||||
return None
|
||||
|
||||
@@ -617,8 +913,18 @@ def scan_one(path):
|
||||
p = Path(path)
|
||||
if not p.exists(): data = {"error": f"Datei nicht gefunden: {p}"}
|
||||
else:
|
||||
try: data = run_ffprobe(str(p)); log.info("Gescannt: %s", p)
|
||||
except Exception as exc: data = {"error": str(exc)}; log.exception("Scanfehler für %s", p)
|
||||
try:
|
||||
data = run_ffprobe(str(p)); log.info("Gescannt: %s", p)
|
||||
except Exception as exc:
|
||||
# Cache failed probes as completed attempts. A changed mtime will
|
||||
# invalidate this entry automatically and trigger a retry later.
|
||||
data = {
|
||||
"scanner_version": SCANNER_VERSION,
|
||||
"audio_languages": [], "subtitle_languages": [],
|
||||
"external_subtitle_files": [str(file) for file in external_subtitle_files(str(p))],
|
||||
"video_codec": "—", "size": 0, "error": error_summary(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
|
||||
@@ -700,7 +1006,7 @@ def sonarr_groups():
|
||||
"category":category_key, "category_label":category_label})
|
||||
return sorted(groups, key=lambda x: x["title"].lower())
|
||||
|
||||
def start_scan(source, force=False):
|
||||
def start_scan(source, force=False, rows_snapshot=None, series_snapshot=None, pending_only=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}
|
||||
@@ -708,37 +1014,96 @@ def start_scan(source, force=False):
|
||||
try:
|
||||
paths = []
|
||||
if source == "radarr":
|
||||
for row in radarr_rows():
|
||||
if row["local_path"] != "—" and row["pending"]: paths.append(row["local_path"])
|
||||
for row in rows_snapshot if rows_snapshot is not None else radarr_rows():
|
||||
if row["local_path"] != "—" and row["pending"] and not row.get("downloading"): paths.append(row["local_path"])
|
||||
elif source == "sonarr":
|
||||
for group in series_snapshot if series_snapshot is not None else sonarr_groups():
|
||||
paths.extend(r["local_path"] for r in group["episodes"] if r["local_path"] != "—" and r["pending"] and not r.get("downloading"))
|
||||
else:
|
||||
for group in sonarr_groups():
|
||||
paths.extend(r["local_path"] for r in group["episodes"] if r["local_path"] != "—" and r["pending"])
|
||||
for row in rows_snapshot if rows_snapshot is not None else radarr_rows():
|
||||
if row["local_path"] != "—" and is_german_review_candidate(row) and not row.get("downloading") and not has_german_track(row) and (not pending_only or row.get("pending")): paths.append(row["local_path"])
|
||||
if SONARR_URL and SONARR_API_KEY:
|
||||
for group in series_snapshot if series_snapshot is not None else sonarr_groups():
|
||||
paths.extend(r["local_path"] for r in group["episodes"] if r["local_path"] != "—" and is_german_review_candidate(r) and not r.get("downloading") and not has_german_track(r) and (not pending_only or r.get("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"
|
||||
# Publish the newly scanned metadata immediately to the cache.
|
||||
refresh_library_cache()
|
||||
except Exception as exc:
|
||||
log.exception("%s-Scan fehlgeschlagen", source); jobs[source]["error"] = str(exc); jobs[source]["state"] = "error"
|
||||
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 = []
|
||||
def refresh_library_cache():
|
||||
"""Refresh Radarr/Sonarr data outside the request thread."""
|
||||
rows = []
|
||||
series = []
|
||||
errors = []
|
||||
radarr_ok = False
|
||||
sonarr_ok = not (SONARR_URL and SONARR_API_KEY)
|
||||
try:
|
||||
rows = radarr_rows()
|
||||
if any(row["pending"] for row in rows): start_scan("radarr")
|
||||
except Exception as exc: error = str(exc)
|
||||
radarr_ok = True
|
||||
except Exception as exc:
|
||||
errors.append(f"Radarr nicht erreichbar: {error_summary(exc)}")
|
||||
if SONARR_URL and SONARR_API_KEY:
|
||||
try:
|
||||
series = sonarr_groups()
|
||||
if any(row["pending"] for group in series for row in group["episodes"]): start_scan("sonarr")
|
||||
except Exception as exc: log.exception("Sonarr nicht erreichbar"); error = f"{error + ' | ' if error else ''}Sonarr: {exc}"
|
||||
missing_german_movies = [row for row in rows if is_german_review_candidate(row) and not has_german_track(row)]
|
||||
sonarr_ok = True
|
||||
except Exception as exc:
|
||||
errors.append(f"Sonarr nicht erreichbar: {error_summary(exc)}")
|
||||
with library_cache_lock:
|
||||
# Keep the last good snapshot during a short service/DNS outage.
|
||||
if not radarr_ok:
|
||||
rows = library_cache["rows"]
|
||||
if not sonarr_ok:
|
||||
series = library_cache["series"]
|
||||
library_cache.update({"rows": rows, "series": series, "updated_at": time.time(), "error": " | ".join(errors) or None, "ready": True})
|
||||
if rows and any(row.get("pending") for row in rows):
|
||||
start_scan("radarr", force=True, rows_snapshot=rows)
|
||||
if series and any(row.get("pending") for group in series for row in group["episodes"]):
|
||||
start_scan("sonarr", force=True, series_snapshot=series)
|
||||
needs_missing_scan = any(
|
||||
row.get("local_path") != "—" and is_german_review_candidate(row) and row.get("pending") and not has_german_track(row)
|
||||
for row in rows
|
||||
) or any(
|
||||
episode.get("local_path") != "—" and is_german_review_candidate(episode) and episode.get("pending") and not has_german_track(episode)
|
||||
for group in series for episode in group["episodes"]
|
||||
)
|
||||
if needs_missing_scan:
|
||||
start_scan("missing", force=True, rows_snapshot=rows, series_snapshot=series, pending_only=True)
|
||||
log.info("Bibliotheks-Sync abgeschlossen: %s Filme, %s Serien", len(rows), len(series))
|
||||
|
||||
def background_library_sync():
|
||||
"""Keep library data and media metadata current without a browser tab."""
|
||||
log.info("Hintergrund-Sync gestartet (Intervall: %ss)", SYNC_INTERVAL_SECONDS or "deaktiviert")
|
||||
while True:
|
||||
try:
|
||||
refresh_library_cache()
|
||||
except Exception as exc:
|
||||
log.error("Hintergrund-Sync fehlgeschlagen: %s", error_summary(exc))
|
||||
if not SYNC_INTERVAL_SECONDS:
|
||||
return
|
||||
time.sleep(SYNC_INTERVAL_SECONDS)
|
||||
|
||||
threading.Thread(target=background_library_sync, name="library-sync", daemon=True).start()
|
||||
threading.Thread(target=hardware_status_monitor, name="hardware-monitor", daemon=True).start()
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
with library_cache_lock:
|
||||
rows = list(library_cache["rows"])
|
||||
series = list(library_cache["series"])
|
||||
error = library_cache["error"]
|
||||
# Unscanned files have no language information yet and must not be shown
|
||||
# as missing German while the background scanner is still processing them.
|
||||
missing_german_movies = [row for row in rows if is_german_review_candidate(row) and not row.get("pending") 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)]
|
||||
episodes = [episode for episode in group["episodes"] if is_german_review_candidate(episode) and not episode.get("pending") and not has_german_track(episode)]
|
||||
if episodes:
|
||||
missing_german_series.append({**group, "episodes": episodes})
|
||||
series_categories = [
|
||||
@@ -770,6 +1135,8 @@ def api_rescan():
|
||||
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("/") + "/%",))
|
||||
# The missing-German scan deliberately keeps the cache: it re-processes
|
||||
# only current candidates and leaves the rest of the library untouched.
|
||||
con.commit(); con.close()
|
||||
if source == "all":
|
||||
start_scan("radarr", force=True)
|
||||
@@ -783,6 +1150,11 @@ def api_rescan():
|
||||
def scan_status():
|
||||
with job_lock: return jsonify(jobs)
|
||||
|
||||
@app.get("/api/library-status")
|
||||
def library_status():
|
||||
with library_cache_lock:
|
||||
return jsonify({"ready": library_cache["ready"], "updated_at": library_cache["updated_at"], "error": library_cache["error"]})
|
||||
|
||||
@app.get("/api/downloads")
|
||||
def downloads():
|
||||
if not SAB_URL or not SAB_API_KEY:
|
||||
@@ -790,24 +1162,30 @@ def downloads():
|
||||
try:
|
||||
return jsonify(sab_downloads())
|
||||
except Exception as exc:
|
||||
log.exception("SABnzbd konnte nicht abgefragt werden")
|
||||
return jsonify({"items": [], "queue_status": "Fehler", "speed": "—", "paused": False, "active_count": 0, "history_count": 0, "remaining": "—", "error": str(exc)}), 502
|
||||
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.exception("Systemmetriken konnten nicht gelesen werden")
|
||||
return jsonify({"error": str(exc), "cpu_percent": 0, "memory": {}, "disks": []}), 500
|
||||
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.exception("Datastore konnte nicht abgefragt werden")
|
||||
return jsonify({"storage": {"error": str(exc), "path": DATASTORE_PATH, "categories": []}, "raid": {"status": "failed", "label": "FAILED", "error": str(exc)}}), 500
|
||||
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():
|
||||
with graphics_cache_lock:
|
||||
snapshot = graphics_cache
|
||||
return jsonify(snapshot or {"available": False, "gpus": [], "processes": [], "error": "GPU-Status wird geladen"})
|
||||
|
||||
@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)})
|
||||
|
||||
@@ -3,6 +3,7 @@ services:
|
||||
build: .
|
||||
container_name: media-max
|
||||
restart: unless-stopped
|
||||
gpus: all
|
||||
ports:
|
||||
- "8099:8099"
|
||||
devices:
|
||||
@@ -16,6 +17,14 @@ services:
|
||||
- "host.docker.internal:host-gateway"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NVIDIA_VISIBLE_DEVICES: ${NVIDIA_VISIBLE_DEVICES:-all}
|
||||
NVIDIA_DRIVER_CAPABILITIES: ${NVIDIA_DRIVER_CAPABILITIES:-compute,utility}
|
||||
# Serverseitiger Bibliotheks-Sync; 1800 = alle 30 Minuten, 0 = nur beim Start.
|
||||
SYNC_INTERVAL_SECONDS: ${SYNC_INTERVAL_SECONDS:-1800}
|
||||
RAID_REFRESH_SECONDS: ${RAID_REFRESH_SECONDS:-5}
|
||||
IO_REFRESH_SECONDS: ${IO_REFRESH_SECONDS:-5}
|
||||
GPU_REFRESH_SECONDS: ${GPU_REFRESH_SECONDS:-5}
|
||||
volumes:
|
||||
# Host-Pfad deiner Film-Library -> interner Dashboard-Pfad
|
||||
- ${FILM_MEDIA_VOLUME}:/media/filme:ro
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
{
|
||||
"name": "Media Max",
|
||||
"short_name": "Media Max",
|
||||
"id": "/",
|
||||
"lang": "de",
|
||||
"description": "Media Max: Radarr, Sonarr und SABnzbd im Überblick",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone"],
|
||||
"prefer_related_applications": false,
|
||||
"background_color": "#151619",
|
||||
"theme_color": "#1f252d",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{"src": "/static/media-mark.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable"}
|
||||
{"src": "/static/media-mark-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"},
|
||||
{"src": "/static/media-mark-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any"},
|
||||
{"src": "/static/media-mark-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"}
|
||||
]
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 803 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="192" height="192"><defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#293542"/><stop offset="1" stop-color="#15191f"/></linearGradient><filter id="glow"><feGaussianBlur stdDeviation="2.2" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><rect x="5" y="5" width="54" height="54" rx="15" fill="url(#bg)" stroke="#60a9e6" stroke-width="2.5"/><path d="M17 21h30M17 31h19M17 41h30" fill="none" stroke="#f4c430" stroke-width="4" stroke-linecap="round"/><circle cx="46" cy="31" r="7" fill="#159447" stroke="#c9ffe0" stroke-width="2" filter="url(#glow)"/></svg>
|
||||
|
After Width: | Height: | Size: 709 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="512" height="512"><defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#293542"/><stop offset="1" stop-color="#15191f"/></linearGradient><filter id="glow"><feGaussianBlur stdDeviation="2.2" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><rect x="5" y="5" width="54" height="54" rx="15" fill="url(#bg)" stroke="#60a9e6" stroke-width="2.5"/><path d="M17 21h30M17 31h19M17 41h30" fill="none" stroke="#f4c430" stroke-width="4" stroke-linecap="round"/><circle cx="46" cy="31" r="7" fill="#159447" stroke="#c9ffe0" stroke-width="2" filter="url(#glow)"/></svg>
|
||||
|
After Width: | Height: | Size: 709 B |
+9
-3
@@ -1,5 +1,5 @@
|
||||
const CACHE_NAME = 'media-max-shell-v2';
|
||||
const SHELL = ['/static/media-mark.svg', '/static/manifest.json'];
|
||||
const CACHE_NAME = 'media-max-shell-v7';
|
||||
const SHELL = ['/static/media-mark.svg', '/static/media-mark-192.png', '/static/media-mark-512.png', '/static/manifest.json'];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(SHELL)));
|
||||
@@ -16,5 +16,11 @@ self.addEventListener('fetch', event => {
|
||||
if (request.method !== 'GET') return;
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname.startsWith('/api/')) return;
|
||||
event.respondWith(fetch(request).catch(() => caches.match(request).then(response => response || caches.match('/static/manifest.json'))));
|
||||
// Navigation requests must stay real network requests. Returning an
|
||||
// offline document here can mask backend errors and make the app appear
|
||||
// disconnected after a scan/reload.
|
||||
if (request.mode === 'navigate') return;
|
||||
event.respondWith(fetch(request).catch(() => {
|
||||
return caches.match(request).then(response => response || new Response('', {status: 503}));
|
||||
}));
|
||||
});
|
||||
|
||||
+72
-21
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user