Compare commits
28
Commits
75f6de91b6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f646339781 | ||
|
|
25abce19e8 | ||
|
|
15310c5e61 | ||
|
|
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 |
No files matched your search
@@ -7,5 +7,6 @@ COPY requirements.txt .
|
|||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY app.py .
|
COPY app.py .
|
||||||
COPY templates ./templates
|
COPY templates ./templates
|
||||||
|
COPY static ./static
|
||||||
EXPOSE 8099
|
EXPOSE 8099
|
||||||
CMD ["gunicorn", "--bind", "0.0.0.0:8099", "--workers", "1", "--threads", "4", "--timeout", "120", "app:app"]
|
CMD ["gunicorn", "--bind", "0.0.0.0:8099", "--workers", "1", "--threads", "4", "--timeout", "120", "app:app"]
|
||||||
@@ -1,43 +1,79 @@
|
|||||||
# Media Max
|
# 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
|
## Funktionen
|
||||||
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.
|
|
||||||
|
|
||||||
## Wichtig: Path-Mapping
|
- Tracearr-inspirierter Aufbau mit linker Navigation, dunkler Oberfläche und übersichtlichen Statuskarten
|
||||||
|
- 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
|
||||||
|
- Spracherkennung über Sprach-Tags und eindeutige Audio-/Untertitel-Titel, inklusive regionaler Codes (`de-DE`, `pt-BR`)
|
||||||
|
- Ersatzweise Dateimetadaten aus Radarr/Sonarr (mit `*` markiert); unbekannte Spuren werden nicht geraten
|
||||||
|
- 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
|
||||||
|
- Animierte Ansichtswechsel, gestaffelte Karten, Filter-Ergebnisse, aufklappende Serien und Einstellungen, Live-Status und fließende Fortschrittsanzeigen; berücksichtigt automatisch „Bewegung reduzieren“ im Betriebssystem
|
||||||
|
|
||||||
Radarr kann seine Filme z.B. unter `/data/filme` sehen, während der Host sie unter
|
## Konfiguration
|
||||||
`/nesflix/filme` hat. Das Dashboard unterstützt diese Abbildung explizit:
|
|
||||||
|
|
||||||
```yaml
|
Persönliche Werte gehören in `.env`. Die Datei ist in `.gitignore` eingetragen. Als Vorlage dient `.env.example`:
|
||||||
environment:
|
|
||||||
RADARR_MEDIA_PATH: "/data/filme"
|
|
||||||
LOCAL_MEDIA_PATH: "/media/filme"
|
|
||||||
|
|
||||||
volumes:
|
```bash
|
||||||
- /nesflix/filme:/media/filme:ro
|
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
|
## Dienststatus
|
||||||
|
|
||||||
Der Dashboard-Tab prüft zusätzlich Jellyfin, Seerr, Prowlarr, Easynews as Indexer,
|
Der Dashboard-Tab prüft die Dienste parallel mit kurzen Timeouts und aktualisiert sie regelmäßig:
|
||||||
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.
|
|
||||||
|
|
||||||
```env
|
```env
|
||||||
JELLYFIN_URL=http://jellyfin:8096
|
JELLYFIN_URL=http://jellyfin:8096
|
||||||
SEERR_URL=http://seerr:5055
|
SEERR_URL=http://seerr:5055
|
||||||
PROWLARR_URL=http://prowlarr:9696
|
PROWLARR_URL=http://prowlarr:9696
|
||||||
EASYNEWS_INDEXER_URL=http://easynews-as-indexer:8081
|
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
|
BAZARR_URL=http://bazarr:6767
|
||||||
LINGARR_URL=http://lingarr:9876
|
LINGARR_URL=http://lingarr:9876
|
||||||
LIBRETRANSLATE_URL=http://libretranslate:5000
|
LIBRETRANSLATE_URL=http://libretranslate:5000
|
||||||
@@ -47,73 +83,137 @@ SERVICE_CHECK_TIMEOUT=3
|
|||||||
SERVICE_REFRESH_SECONDS=30
|
SERVICE_REFRESH_SECONDS=30
|
||||||
```
|
```
|
||||||
|
|
||||||
Für die bereits vorhandenen Container sind keine zusätzlichen Compose-Services nötig;
|
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.
|
||||||
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
|
## Scans und Performance
|
||||||
Docker-Konfiguration dieser Eintrag ergänzt werden:
|
|
||||||
|
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.
|
||||||
|
Nach einem Scanner-Update werden ältere Cache-Einträge automatisch neu analysiert. Fehlgeschlagene Analysen können beim nächsten Bibliotheks-Sync nach mindestens fünf Minuten erneut versucht werden. Fehlerhafte oder noch nicht gescannte Dateien erscheinen nicht fälschlich in „Ohne Deutsch“.
|
||||||
|
`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
|
```yaml
|
||||||
extra_hosts:
|
deploy:
|
||||||
- "host.docker.internal:host-gateway"
|
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:
|
```env
|
||||||
|
LOG_LEVEL=INFO
|
||||||
`/media/filme/Avatar (2009)/Avatar.mkv`
|
LOG_FILE=/data/media-max.log
|
||||||
|
LOG_RETENTION_HOURS=6
|
||||||
## 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
## Start mit Docker Compose
|
||||||
|
|
||||||
API-Keys und Pfade in `.env` anpassen:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose down
|
|
||||||
docker compose up -d --build
|
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.
|
Der Compose-Stack bindet standardmäßig ein:
|
||||||
|
|
||||||
Sonarr benötigt:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
SONARR_URL: "http://sonarr:8989"
|
volumes:
|
||||||
SONARR_API_KEY: "..."
|
- ${FILM_MEDIA_VOLUME}:/media/filme:ro
|
||||||
SONARR_MEDIA_PATH: "/data/serien"
|
- ${SERIES_MEDIA_VOLUME:-/nesflix/serien}:/media/serien:ro
|
||||||
LOCAL_SERIES_PATH: "/media/serien"
|
- ${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
|
## Fehlersuche
|
||||||
SAB_URL: "http://sabnzbd:8080"
|
|
||||||
SAB_API_KEY: "..."
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
„Unbekannt“ bedeutet, dass eine Spur weder einen verwertbaren Sprach-Tag noch einen eindeutigen Spurtitel besitzt. Wenn auch Radarr/Sonarr keine Dateisprachen liefern, kann die App die Sprache nicht zuverlässig bestimmen. Die Originalsprache eines Films und Release-Kürzel wie `DL` sind kein Nachweis für die tatsächlichen Audiospuren.
|
||||||
|
|
||||||
## Bei weiterem Scanfehler
|
Prüfe zuerst, ob Media Max die Medien sieht:
|
||||||
|
|
||||||
Prüfe einen Pfad im Container:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec -it media-max sh
|
docker exec -it media-max sh
|
||||||
ls -lah /media/filme
|
ls -lah /media/filme
|
||||||
|
ls -lah /media/serien
|
||||||
find /media/filme -type f | head
|
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.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m unittest discover -s tests -v
|
||||||
|
node --check static/dashboard.js
|
||||||
|
node --test tests/test_dashboard_motion.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Tests verwenden gemockte Dienste und benötigen keine Medienbibliothek oder API-Schlüssel.
|
||||||
@@ -9,7 +9,7 @@ import time
|
|||||||
import shutil
|
import shutil
|
||||||
import re
|
import re
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -43,10 +43,15 @@ DATASTORE_PATH = os.environ.get("DATASTORE_PATH", "/nesflix").rstrip("/") or "/"
|
|||||||
RAID_DEVICE = os.environ.get("RAID_DEVICE", "/dev/md127")
|
RAID_DEVICE = os.environ.get("RAID_DEVICE", "/dev/md127")
|
||||||
HOST_DEVICE_PATH = os.environ.get("HOST_DEVICE_PATH", "/host-dev")
|
HOST_DEVICE_PATH = os.environ.get("HOST_DEVICE_PATH", "/host-dev")
|
||||||
HOST_SYS_PATH = os.environ.get("HOST_SYS_PATH", "/host-sys")
|
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"))
|
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"}
|
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_FILE = os.environ.get("LOG_FILE", "/data/media-max.log")
|
||||||
LOG_RETENTION_HOURS = max(1, int(os.environ.get("LOG_RETENTION_HOURS", "6")))
|
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")
|
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)s %(message)s")
|
||||||
log = logging.getLogger("media-max")
|
log = logging.getLogger("media-max")
|
||||||
@@ -75,19 +80,61 @@ def service_worker():
|
|||||||
return response
|
return response
|
||||||
executor = ThreadPoolExecutor(max_workers=int(os.environ.get("SCAN_WORKERS", "2")))
|
executor = ThreadPoolExecutor(max_workers=int(os.environ.get("SCAN_WORKERS", "2")))
|
||||||
scan_executor = ThreadPoolExecutor(max_workers=int(os.environ.get("SCAN_WORKERS", "4")))
|
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()
|
job_lock = threading.Lock()
|
||||||
system_lock = threading.Lock()
|
system_lock = threading.Lock()
|
||||||
previous_cpu = None
|
previous_cpu = None
|
||||||
service_status_lock = threading.Lock()
|
service_status_lock = threading.Lock()
|
||||||
service_check_executor = ThreadPoolExecutor(max_workers=16)
|
service_check_executor = ThreadPoolExecutor(max_workers=16)
|
||||||
datastore_cache_lock = threading.Lock()
|
datastore_cache_lock = threading.Lock()
|
||||||
datastore_cache = {"stored_at": 0.0, "snapshot": None, "raid": None, "raid_at": 0.0}
|
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
|
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()
|
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"}
|
LANGUAGE_DEFINITIONS = [
|
||||||
FLAGS = {"Deutsch":"🇩🇪", "Englisch":"🇬🇧", "Japanisch":"🇯🇵", "Koreanisch":"🇰🇷", "Französisch":"🇫🇷", "Spanisch":"🇪🇸", "Italienisch":"🇮🇹", "Russisch":"🇷🇺", "Chinesisch":"🇨🇳", "Unbekannt":"❓"}
|
("de", "Deutsch", "🇩🇪", "deu ger german deutsch"),
|
||||||
|
("en", "Englisch", "🇬🇧", "eng english englisch"),
|
||||||
|
("ja", "Japanisch", "🇯🇵", "jpn japanese japanisch"),
|
||||||
|
("ko", "Koreanisch", "🇰🇷", "kor korean koreanisch"),
|
||||||
|
("fr", "Französisch", "🇫🇷", "fra fre french français francais französisch"),
|
||||||
|
("es", "Spanisch", "🇪🇸", "spa spanish spanisch español espanol"),
|
||||||
|
("it", "Italienisch", "🇮🇹", "ita italian italienisch"),
|
||||||
|
("ru", "Russisch", "🇷🇺", "rus russian russisch"),
|
||||||
|
("zh", "Chinesisch", "🇨🇳", "zho chi chinese chinesisch mandarin cmn cantonese yue"),
|
||||||
|
("pt", "Portugiesisch", "🇵🇹", "por portuguese portugiesisch português portugues"),
|
||||||
|
("nl", "Niederländisch", "🇳🇱", "nld dut dutch niederländisch"),
|
||||||
|
("pl", "Polnisch", "🇵🇱", "pol polish polnisch"),
|
||||||
|
("tr", "Türkisch", "🇹🇷", "tur turkish türkisch"),
|
||||||
|
("ar", "Arabisch", "🇸🇦", "ara arabic arabisch"),
|
||||||
|
("hi", "Hindi", "🇮🇳", "hin hindi"),
|
||||||
|
("sv", "Schwedisch", "🇸🇪", "swe swedish schwedisch"),
|
||||||
|
("da", "Dänisch", "🇩🇰", "dan danish dänisch"),
|
||||||
|
("no", "Norwegisch", "🇳🇴", "nor nob nno nb nn norwegian norwegisch"),
|
||||||
|
("fi", "Finnisch", "🇫🇮", "fin finnish finnisch"),
|
||||||
|
("cs", "Tschechisch", "🇨🇿", "ces cze czech tschechisch"),
|
||||||
|
("el", "Griechisch", "🇬🇷", "ell gre greek griechisch"),
|
||||||
|
("hu", "Ungarisch", "🇭🇺", "hun hungarian ungarisch"),
|
||||||
|
("uk", "Ukrainisch", "🇺🇦", "ukr ukrainian ukrainisch"),
|
||||||
|
("ro", "Rumänisch", "🇷🇴", "ron rum romanian rumänisch"),
|
||||||
|
("th", "Thai", "🇹🇭", "tha thai"),
|
||||||
|
("vi", "Vietnamesisch", "🇻🇳", "vie vietnamese vietnamesisch"),
|
||||||
|
("id", "Indonesisch", "🇮🇩", "ind indonesian indonesisch"),
|
||||||
|
("he", "Hebräisch", "🇮🇱", "heb hebrew hebräisch"),
|
||||||
|
]
|
||||||
|
LANG_ALIASES = {alias: code for code, name, flag, aliases in LANGUAGE_DEFINITIONS
|
||||||
|
for alias in (code, name.casefold(), *aliases.split())}
|
||||||
|
LANG_NAMES = {code: name for code, name, flag, aliases in LANGUAGE_DEFINITIONS}
|
||||||
|
LANG_NAMES["und"] = "Unbekannt"
|
||||||
|
FLAGS = {name: flag for code, name, flag, aliases in LANGUAGE_DEFINITIONS}
|
||||||
|
FLAGS["Unbekannt"] = "❓"
|
||||||
|
|
||||||
SERVICE_DEFINITIONS = [
|
SERVICE_DEFINITIONS = [
|
||||||
{"key": "radarr", "name": "Radarr", "url": f"{RADARR_URL}/api/v3/system/status", "api_key": RADARR_API_KEY, "kind": "api"},
|
{"key": "radarr", "name": "Radarr", "url": f"{RADARR_URL}/api/v3/system/status", "api_key": RADARR_API_KEY, "kind": "api"},
|
||||||
@@ -351,6 +398,88 @@ def io_pressure():
|
|||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
return {"some": {}, "full": {}, "available": False}
|
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):
|
def smart_status(device):
|
||||||
if not SMART_ENABLED:
|
if not SMART_ENABLED:
|
||||||
return "Nicht aktiviert"
|
return "Nicht aktiviert"
|
||||||
@@ -532,29 +661,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 "—"},
|
"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()}
|
"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():
|
def refresh_datastore_storage():
|
||||||
now = time.time()
|
if not datastore_refresh_lock.acquire(blocking=False):
|
||||||
with datastore_cache_lock:
|
return
|
||||||
if datastore_cache["snapshot"] is None or now - datastore_cache["stored_at"] >= max(30, STORAGE_CACHE_SECONDS):
|
try:
|
||||||
fresh_storage = scan_datastore_storage()
|
fresh_storage = scan_datastore_storage()
|
||||||
|
with datastore_cache_lock:
|
||||||
if fresh_storage.get("error") and datastore_cache["snapshot"]:
|
if fresh_storage.get("error") and datastore_cache["snapshot"]:
|
||||||
datastore_cache["snapshot"] = dict(datastore_cache["snapshot"])
|
datastore_cache["snapshot"] = dict(datastore_cache["snapshot"])
|
||||||
datastore_cache["snapshot"]["stale"] = True
|
datastore_cache["snapshot"]["stale"] = True
|
||||||
datastore_cache["snapshot"]["warning"] = fresh_storage["error"]
|
datastore_cache["snapshot"]["warning"] = fresh_storage["error"]
|
||||||
else:
|
else:
|
||||||
datastore_cache["snapshot"] = fresh_storage
|
datastore_cache["snapshot"] = fresh_storage
|
||||||
datastore_cache["stored_at"] = now
|
datastore_cache["stored_at"] = time.time()
|
||||||
storage = datastore_cache["snapshot"]
|
finally:
|
||||||
current_raid = raid_status()
|
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:
|
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:
|
if current.get("status") == "failed" and current.get("error") and datastore_cache.get("raid"):
|
||||||
current_raid = dict(datastore_cache["raid"])
|
cached = dict(datastore_cache["raid"])
|
||||||
current_raid["stale"] = True
|
cached["stale"] = True
|
||||||
current_raid["warning"] = "Vorübergehend keine aktuelle RAID-Antwort; letzter gültiger Stand wird angezeigt."
|
cached["warning"] = "Vorübergehend keine aktuelle RAID-Antwort."
|
||||||
elif current_raid.get("status") != "failed":
|
datastore_cache["raid"] = cached
|
||||||
datastore_cache["raid"] = dict(current_raid)
|
else:
|
||||||
datastore_cache["raid_at"] = now
|
datastore_cache["raid"] = current
|
||||||
return {"storage": storage, "raid": current_raid, "io_pressure": io_pressure(), "cache_seconds": STORAGE_CACHE_SECONDS}
|
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:
|
||||||
|
storage = datastore_cache["snapshot"]
|
||||||
|
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():
|
def cpu_usage_percent():
|
||||||
global previous_cpu
|
global previous_cpu
|
||||||
@@ -612,10 +801,22 @@ def system_metrics():
|
|||||||
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()}
|
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):
|
def release_languages(name):
|
||||||
value = f" {name.lower().replace('.', ' ').replace('-', ' ')} "
|
value = " " + re.sub(r"[\W_]+", " ", str(name or "").casefold()).strip() + " "
|
||||||
found = []
|
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 "))]
|
patterns = [
|
||||||
codes = {"Deutsch": "de", "Englisch": "en", "Japanisch": "ja", "Französisch": "fr", "Spanisch": "es"}
|
("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:
|
for language, tokens in patterns:
|
||||||
if any(token in value for token in tokens): found.append(norm_lang(codes[language]))
|
if any(token in value for token in tokens): found.append(norm_lang(codes[language]))
|
||||||
return unique_langs(found)
|
return unique_langs(found)
|
||||||
@@ -649,9 +850,12 @@ def sab_downloads():
|
|||||||
"remaining": format_bytes(remaining_bytes), "error": None}
|
"remaining": format_bytes(remaining_bytes), "error": None}
|
||||||
|
|
||||||
def norm_lang(code):
|
def norm_lang(code):
|
||||||
code = (code or "").strip().lower()
|
value = str(code or "").strip().casefold()
|
||||||
name = LANG_NAMES.get(code, code.upper() if code else "Unbekannt")
|
code = LANG_ALIASES.get(value) or LANG_ALIASES.get(re.split(r"[-_]", value)[0]) or value
|
||||||
return {"code": code or "und", "name": name, "flag": FLAGS.get(name, "🌐")}
|
if code in {"", "und", "unknown", "undefined", "unbekannt", "unk", "mul", "zxx"}:
|
||||||
|
code = "und"
|
||||||
|
name = LANG_NAMES.get(code, code.upper())
|
||||||
|
return {"code": code, "name": name, "flag": FLAGS.get(name, "🌐")}
|
||||||
|
|
||||||
def unique_langs(items):
|
def unique_langs(items):
|
||||||
out, seen = [], set()
|
out, seen = [], set()
|
||||||
@@ -662,11 +866,13 @@ def unique_langs(items):
|
|||||||
|
|
||||||
def map_path(path, remote, local):
|
def map_path(path, remote, local):
|
||||||
if not path: return None
|
if not path: return None
|
||||||
if remote and path.startswith(remote):
|
if remote and (path == remote or path.startswith(remote.rstrip("/") + "/")):
|
||||||
return str(Path(local) / path[len(remote):].lstrip("/"))
|
return str(Path(local) / path[len(remote):].lstrip("/"))
|
||||||
return path
|
return path
|
||||||
|
|
||||||
EXTERNAL_SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".vtt", ".sub", ".idx", ".sup"}
|
EXTERNAL_SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".vtt", ".sub", ".idx", ".sup"}
|
||||||
|
SCANNER_VERSION = 3
|
||||||
|
SCAN_ERROR_RETRY_SECONDS = 300
|
||||||
|
|
||||||
def external_subtitle_files(path):
|
def external_subtitle_files(path):
|
||||||
media = Path(path)
|
media = Path(path)
|
||||||
@@ -682,40 +888,53 @@ def external_subtitle_languages(path):
|
|||||||
media = Path(path)
|
media = Path(path)
|
||||||
languages = []
|
languages = []
|
||||||
for candidate in external_subtitle_files(path):
|
for candidate in external_subtitle_files(path):
|
||||||
tokens = {token for token in re.split(r"[. _()\[\]-]+", candidate.stem.lower()) if token}
|
# Only inspect the suffix, never words from the movie title itself.
|
||||||
if tokens & {"de", "deu", "ger", "german", "deutsch"}:
|
suffix = candidate.stem[len(media.stem):]
|
||||||
languages.append(norm_lang("de"))
|
languages.extend(languages_from_text(suffix))
|
||||||
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)
|
return unique_langs(languages)
|
||||||
|
|
||||||
|
def languages_from_text(value):
|
||||||
|
tokens = re.findall(r"[^\W_]+", str(value or "").casefold())
|
||||||
|
return unique_langs([norm_lang(LANG_ALIASES[token]) for token in tokens if token in LANG_ALIASES])
|
||||||
|
|
||||||
|
def language_from_text(value):
|
||||||
|
"""Use unambiguous track titles; do not guess between multiple languages."""
|
||||||
|
languages = languages_from_text(value)
|
||||||
|
return languages[0] if len(languages) == 1 else None
|
||||||
|
|
||||||
def run_ffprobe(path):
|
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)
|
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")
|
if proc.returncode != 0: raise RuntimeError(proc.stderr.strip() or "ffprobe fehlgeschlagen")
|
||||||
raw, audios, subs, details, video = json.loads(proc.stdout or "{}"), [], [], [], None
|
raw, audios, subs, details, video = json.loads(proc.stdout or "{}"), [], [], [], None
|
||||||
for stream in raw.get("streams", []):
|
for stream in raw.get("streams", []):
|
||||||
tags = stream.get("tags") or {}; lang = norm_lang(tags.get("language")); kind = stream.get("codec_type")
|
tags = {key.casefold(): value for key, value in (stream.get("tags") or {}).items()}; 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 in {"audio", "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")
|
if kind == "video" and not video: video = stream.get("codec_name")
|
||||||
elif kind == "audio":
|
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")})
|
source = "title" if norm_lang(tags.get("language"))["code"] == "und" and lang["code"] != "und" else "tag"
|
||||||
|
audios.append(lang); details.append({"language": lang, "language_source": source, "codec": stream.get("codec_name"), "channels": stream.get("channels"), "layout": stream.get("channel_layout"), "title": tags.get("title")})
|
||||||
elif kind == "subtitle": subs.append(lang)
|
elif kind == "subtitle": subs.append(lang)
|
||||||
subtitles = unique_langs(subs + external_subtitle_languages(path))
|
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):
|
def cached(path):
|
||||||
p = Path(path)
|
p = Path(path)
|
||||||
if not p.exists(): return {"error": f"Datei nicht gefunden: {p}", "pending": True}
|
if not p.exists(): return {"error": f"Datei nicht gefunden: {p}", "pending": False}
|
||||||
mtime = p.stat().st_mtime; con = db(); row = con.execute("SELECT mtime,data FROM media_cache WHERE path=?", (str(p),)).fetchone(); con.close()
|
mtime = p.stat().st_mtime; con = db(); row = con.execute("SELECT mtime,data,scanned_at FROM media_cache WHERE path=?", (str(p),)).fetchone(); con.close()
|
||||||
if row and float(row["mtime"]) == float(mtime):
|
if row and float(row["mtime"]) == float(mtime):
|
||||||
data = json.loads(row["data"])
|
data = json.loads(row["data"])
|
||||||
|
if data.get("error"):
|
||||||
|
scanned_at = datetime.fromisoformat(row["scanned_at"])
|
||||||
|
if scanned_at.tzinfo is None:
|
||||||
|
scanned_at = scanned_at.replace(tzinfo=timezone.utc)
|
||||||
|
if (datetime.now(timezone.utc) - scanned_at).total_seconds() >= SCAN_ERROR_RETRY_SECONDS:
|
||||||
|
return None
|
||||||
current_subtitles = [str(file) for file in external_subtitle_files(path)]
|
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 data
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -723,10 +942,20 @@ def scan_one(path):
|
|||||||
p = Path(path)
|
p = Path(path)
|
||||||
if not p.exists(): data = {"error": f"Datei nicht gefunden: {p}"}
|
if not p.exists(): data = {"error": f"Datei nicht gefunden: {p}"}
|
||||||
else:
|
else:
|
||||||
try: data = run_ffprobe(str(p)); log.info("Gescannt: %s", p)
|
try:
|
||||||
except Exception as exc: data = {"error": str(exc)}; log.error("Scanfehler für %s: %s", p, error_summary(exc))
|
data = run_ffprobe(str(p)); log.info("Gescannt: %s", p)
|
||||||
|
except Exception as exc:
|
||||||
|
# Avoid immediate retry loops, but retry transient failures on a
|
||||||
|
# later sync even when the file itself has not changed.
|
||||||
|
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
|
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()
|
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.now(timezone.utc).isoformat())); con.commit(); con.close()
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def format_size(size):
|
def format_size(size):
|
||||||
@@ -740,7 +969,8 @@ def language_state(audios):
|
|||||||
names = {x["name"] for x in audios}
|
names = {x["name"] for x in audios}
|
||||||
if {"Deutsch", "Englisch"} <= names: return {"class":"ok", "label":"DE + EN"}
|
if {"Deutsch", "Englisch"} <= names: return {"class":"ok", "label":"DE + EN"}
|
||||||
if "Deutsch" in names: return {"class":"warn", "label":"Deutsch"}
|
if "Deutsch" in names: return {"class":"warn", "label":"Deutsch"}
|
||||||
if "Englisch" in names: return {"class":"bad", "label":"English only"}
|
if "Unbekannt" in names: return {"class":"neutral", "label":"Sprache prüfen"}
|
||||||
|
if names == {"Englisch"}: return {"class":"bad", "label":"English only"}
|
||||||
return {"class":"neutral", "label":"Andere"}
|
return {"class":"neutral", "label":"Andere"}
|
||||||
|
|
||||||
def has_german_track(row):
|
def has_german_track(row):
|
||||||
@@ -751,11 +981,35 @@ def is_german_review_candidate(row):
|
|||||||
"""Only review media that exists or is currently being downloaded."""
|
"""Only review media that exists or is currently being downloaded."""
|
||||||
return not row.get("missing") or bool(row.get("downloading"))
|
return not row.get("missing") or bool(row.get("downloading"))
|
||||||
|
|
||||||
|
def needs_german_review(row):
|
||||||
|
# Failed and unfinished scans cannot establish that German is absent.
|
||||||
|
return is_german_review_candidate(row) and not row.get("pending") and not row.get("error") and not has_german_track(row)
|
||||||
|
|
||||||
|
def file_metadata_languages(media_file):
|
||||||
|
"""Read file-level Arr metadata, never the movie's original language."""
|
||||||
|
media_file = media_file or {}
|
||||||
|
values = (media_file.get("mediaInfo") or {}).get("audioLanguages") or []
|
||||||
|
if isinstance(values, str):
|
||||||
|
values = re.split(r"[,/;|]+", values)
|
||||||
|
languages = []
|
||||||
|
for value in values:
|
||||||
|
language = norm_lang(value)
|
||||||
|
if language["code"] in LANG_NAMES and language["code"] != "und":
|
||||||
|
languages.append(language)
|
||||||
|
if not languages:
|
||||||
|
for value in media_file.get("languages") or []:
|
||||||
|
language = norm_lang(value.get("name") if isinstance(value, dict) else value)
|
||||||
|
if language["code"] in LANG_NAMES and language["code"] != "und":
|
||||||
|
languages.append(language)
|
||||||
|
return [{**language, "source": "arr"} for language in unique_langs(languages)]
|
||||||
|
|
||||||
def media_row(name, year, media_file, remote, local, extra=None):
|
def media_row(name, year, media_file, remote, local, extra=None):
|
||||||
path = media_file.get("path") if media_file else 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"])
|
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"}
|
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", [])
|
info = dict(info or {"pending": True}); audios = info.get("audio_languages", [])
|
||||||
|
if media_file and (not audios or all(language["code"] == "und" for language in audios)):
|
||||||
|
audios = file_metadata_languages(media_file) or audios
|
||||||
if local_path:
|
if local_path:
|
||||||
info["subtitle_languages"] = unique_langs(info.get("subtitle_languages", []) + external_subtitle_languages(local_path))
|
info["subtitle_languages"] = unique_langs(info.get("subtitle_languages", []) + external_subtitle_languages(local_path))
|
||||||
downloading = bool((extra or {}).get("downloading"))
|
downloading = bool((extra or {}).get("downloading"))
|
||||||
@@ -806,7 +1060,7 @@ def sonarr_groups():
|
|||||||
"category":category_key, "category_label":category_label})
|
"category":category_key, "category_label":category_label})
|
||||||
return sorted(groups, key=lambda x: x["title"].lower())
|
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:
|
with job_lock:
|
||||||
if jobs[source]["state"] == "running" or (jobs[source]["state"] in ("done", "error") and not force): return
|
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}
|
jobs[source] = {"state":"running", "total":0, "done":0, "error":None}
|
||||||
@@ -814,37 +1068,92 @@ def start_scan(source, force=False):
|
|||||||
try:
|
try:
|
||||||
paths = []
|
paths = []
|
||||||
if source == "radarr":
|
if source == "radarr":
|
||||||
for row in radarr_rows():
|
for row in rows_snapshot if rows_snapshot is not None else radarr_rows():
|
||||||
if row["local_path"] != "—" and row["pending"]: paths.append(row["local_path"])
|
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:
|
else:
|
||||||
for group in sonarr_groups():
|
for row in rows_snapshot if rows_snapshot is not None else radarr_rows():
|
||||||
paths.extend(r["local_path"] for r in group["episodes"] if r["local_path"] != "—" and r["pending"])
|
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")))
|
||||||
|
paths = list(dict.fromkeys(paths))
|
||||||
with job_lock: jobs[source]["total"] = len(paths)
|
with job_lock: jobs[source]["total"] = len(paths)
|
||||||
futures = [scan_executor.submit(scan_one, path) for path in paths]
|
futures = [scan_executor.submit(scan_one, path) for path in paths]
|
||||||
for future in as_completed(futures):
|
for future in as_completed(futures):
|
||||||
future.result()
|
future.result()
|
||||||
with job_lock: jobs[source]["done"] += 1
|
with job_lock: jobs[source]["done"] += 1
|
||||||
with job_lock: jobs[source]["state"] = "done"
|
with job_lock: jobs[source]["state"] = "done"
|
||||||
|
# Publish the newly scanned metadata immediately to the cache.
|
||||||
|
refresh_library_cache()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("%s-Scan fehlgeschlagen: %s", source, error_summary(exc)); 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)
|
executor.submit(work)
|
||||||
|
|
||||||
@app.route("/")
|
def refresh_library_cache():
|
||||||
def index():
|
"""Refresh Radarr/Sonarr data outside the request thread."""
|
||||||
error = None; rows = []; series = []
|
rows = []
|
||||||
|
series = []
|
||||||
|
errors = []
|
||||||
|
radarr_ok = False
|
||||||
|
sonarr_ok = not (SONARR_URL and SONARR_API_KEY)
|
||||||
try:
|
try:
|
||||||
rows = radarr_rows()
|
rows = radarr_rows()
|
||||||
if any(row["pending"] for row in rows): start_scan("radarr")
|
radarr_ok = True
|
||||||
except Exception as exc: error = str(exc)
|
except Exception as exc:
|
||||||
|
errors.append(f"Radarr nicht erreichbar: {error_summary(exc)}")
|
||||||
if SONARR_URL and SONARR_API_KEY:
|
if SONARR_URL and SONARR_API_KEY:
|
||||||
try:
|
try:
|
||||||
series = sonarr_groups()
|
series = sonarr_groups()
|
||||||
if any(row["pending"] for group in series for row in group["episodes"]): start_scan("sonarr")
|
sonarr_ok = True
|
||||||
except Exception as exc: log.error("Sonarr nicht erreichbar: %s", error_summary(exc)); error = f"{error + ' | ' if error else ''}Sonarr nicht erreichbar"
|
except Exception as exc:
|
||||||
missing_german_movies = [row for row in rows if is_german_review_candidate(row) and not has_german_track(row)]
|
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)
|
||||||
|
# Radarr/Sonarr already scan every pending file. Starting a missing-German
|
||||||
|
# job here too would probe the same files twice in parallel.
|
||||||
|
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"]
|
||||||
|
library_updated_at = library_cache["updated_at"]
|
||||||
|
library_ready = library_cache["ready"]
|
||||||
|
# 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 needs_german_review(row)]
|
||||||
missing_german_series = []
|
missing_german_series = []
|
||||||
for group in 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 needs_german_review(episode)]
|
||||||
if episodes:
|
if episodes:
|
||||||
missing_german_series.append({**group, "episodes": episodes})
|
missing_german_series.append({**group, "episodes": episodes})
|
||||||
series_categories = [
|
series_categories = [
|
||||||
@@ -861,10 +1170,22 @@ def index():
|
|||||||
"episode_files": sum(1 for group in series for row in group["episodes"] if not row.get("missing")),
|
"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")),
|
"episode_missing": sum(1 for group in series for row in group["episodes"] if row.get("missing") and not row.get("downloading")),
|
||||||
}
|
}
|
||||||
|
existing = [row for row in rows + [episode for group in series for episode in group["episodes"]] if not row.get("missing")]
|
||||||
|
language_review = [row for row in existing if row.get("error") or any(language["code"] == "und" for language in row.get("audio_languages", []))]
|
||||||
|
dashboard_stats.update({
|
||||||
|
"german_tracks": sum(1 for row in existing if has_german_track(row)),
|
||||||
|
"unknown_audio": sum(1 for row in existing if any(language["code"] == "und" for language in row.get("audio_languages", []))),
|
||||||
|
"pending": sum(1 for row in existing if row.get("pending")),
|
||||||
|
"scan_errors": sum(1 for row in existing if row.get("error")),
|
||||||
|
"existing_total": len(existing),
|
||||||
|
})
|
||||||
return render_template("index.html", rows=rows, series=series, series_categories=series_categories,
|
return render_template("index.html", rows=rows, series=series, series_categories=series_categories,
|
||||||
missing_german_movies=missing_german_movies,
|
missing_german_movies=missing_german_movies,
|
||||||
missing_german_series=missing_german_series, error=error,
|
missing_german_series=missing_german_series, error=error,
|
||||||
dashboard_stats=dashboard_stats,
|
dashboard_stats=dashboard_stats,
|
||||||
|
language_names=sorted(set(LANG_NAMES.values())),
|
||||||
|
language_review=language_review[:5], library_updated_at=library_updated_at,
|
||||||
|
library_ready=library_ready, sync_interval_seconds=SYNC_INTERVAL_SECONDS,
|
||||||
sonarr_enabled=bool(SONARR_URL and SONARR_API_KEY), sab_enabled=bool(SAB_URL and SAB_API_KEY),
|
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,
|
radarr_media_path=RADARR_MEDIA_PATH, local_media_path=LOCAL_MEDIA_PATH,
|
||||||
local_series_path=LOCAL_SERIES_PATH)
|
local_series_path=LOCAL_SERIES_PATH)
|
||||||
@@ -876,6 +1197,8 @@ def api_rescan():
|
|||||||
con = db()
|
con = db()
|
||||||
if source in ("radarr", "all"): con.execute("DELETE FROM media_cache WHERE path NOT LIKE ?", (LOCAL_SERIES_PATH.rstrip("/") + "/%",))
|
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("/") + "/%",))
|
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()
|
con.commit(); con.close()
|
||||||
if source == "all":
|
if source == "all":
|
||||||
start_scan("radarr", force=True)
|
start_scan("radarr", force=True)
|
||||||
@@ -889,6 +1212,11 @@ def api_rescan():
|
|||||||
def scan_status():
|
def scan_status():
|
||||||
with job_lock: return jsonify(jobs)
|
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")
|
@app.get("/api/downloads")
|
||||||
def downloads():
|
def downloads():
|
||||||
if not SAB_URL or not SAB_API_KEY:
|
if not SAB_URL or not SAB_API_KEY:
|
||||||
@@ -915,6 +1243,12 @@ def datastore():
|
|||||||
log.error("Datastore konnte nicht abgefragt werden: %s", error_summary(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
|
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")
|
@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)})
|
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: .
|
build: .
|
||||||
container_name: media-max
|
container_name: media-max
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
gpus: all
|
||||||
ports:
|
ports:
|
||||||
- "8099:8099"
|
- "8099:8099"
|
||||||
devices:
|
devices:
|
||||||
@@ -16,6 +17,14 @@ services:
|
|||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .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:
|
volumes:
|
||||||
# Host-Pfad deiner Film-Library -> interner Dashboard-Pfad
|
# Host-Pfad deiner Film-Library -> interner Dashboard-Pfad
|
||||||
- ${FILM_MEDIA_VOLUME}:/media/filme:ro
|
- ${FILM_MEDIA_VOLUME}:/media/filme:ro
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #0c0d0f;
|
||||||
|
--panel: #111316;
|
||||||
|
--sidebar: #181a1d;
|
||||||
|
--line: #292c31;
|
||||||
|
--text: #ededf0;
|
||||||
|
--muted: #969ba5;
|
||||||
|
--accent: #f4ce54;
|
||||||
|
--accent-hover: #f8de83;
|
||||||
|
--accent-contrast: #211b08;
|
||||||
|
--accent-soft: #2b2514;
|
||||||
|
--accent-border: #75622d;
|
||||||
|
--accent-text: #f5e5ad;
|
||||||
|
--green: #58caa0;
|
||||||
|
--amber: #e9b765;
|
||||||
|
--red: #ed858b;
|
||||||
|
--purple: #b397e9;
|
||||||
|
--motion-ease: cubic-bezier(.22, 1, .36, 1);
|
||||||
|
--motion-fast: 180ms;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, sans-serif; }
|
||||||
|
button, input, select { font: inherit; }
|
||||||
|
button, select, input { color: var(--text); background: var(--panel); border: 1px solid var(--line); border-radius: 7px; }
|
||||||
|
button { cursor: pointer; padding: 8px 12px; }
|
||||||
|
button:hover { background: #22262b; }
|
||||||
|
button:disabled { opacity: .55; cursor: wait; }
|
||||||
|
button:focus-visible, a:focus-visible, summary:focus-visible, select:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
h1, h2, h3, p { margin: 0; }
|
||||||
|
h1 { font-size: 27px; line-height: 1.25; font-weight: 650; letter-spacing: -.7px; }
|
||||||
|
h2 { font-size: 18px; font-weight: 600; letter-spacing: -.3px; }
|
||||||
|
h3 { font-size: 14px; font-weight: 600; }
|
||||||
|
small { font-size: 12px; }
|
||||||
|
code { color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
|
||||||
|
.icon { width: 20px; height: 20px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
|
.svg-definitions { position: absolute; width: 0; height: 0; overflow: hidden; }
|
||||||
|
.muted, .year, .metadata-note, .panel-note { color: var(--muted); }
|
||||||
|
.text-green { color: var(--green); }.text-amber { color: var(--amber); }.text-red { color: var(--red); }
|
||||||
|
.hidden, .view:not(.active) { display: none !important; }
|
||||||
|
.nowrap { white-space: nowrap; }
|
||||||
|
.skip-link { position: fixed; left: 20px; top: -60px; z-index: 100; padding: 10px; background: var(--accent); color: var(--accent-contrast); border-radius: 7px; }
|
||||||
|
.skip-link:focus { top: 10px; }
|
||||||
|
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 40; display: flex; flex-direction: column; width: 236px; background: var(--sidebar); border-right: 1px solid var(--line); }
|
||||||
|
.brand { height: 76px; display: flex; align-items: center; gap: 11px; padding: 0 24px; font-size: 23px; font-weight: 750; letter-spacing: -.6px; }
|
||||||
|
.brand-icon { width: 32px; height: 32px; padding: 5px; color: var(--accent); background: var(--accent-soft); border-radius: 9px; }
|
||||||
|
.brand-accent { font-weight: 500; color: #c9cdd4; }
|
||||||
|
.workspace { display: flex; align-items: center; gap: 12px; padding: 17px 23px; margin-bottom: 9px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
||||||
|
.workspace > .icon { color: var(--muted); }.workspace strong { display: block; font-size: 12px; font-weight: 600; }.workspace small { display: block; color: var(--muted); font-size: 10px; }.workspace .status-dot { margin-left: auto; }
|
||||||
|
.status-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--green); flex: 0 0 auto; }
|
||||||
|
.nav { padding: 0 12px; overflow-y: auto; }
|
||||||
|
.nav-label { padding: 18px 11px 8px; font-size: 11px; color: #8e949e; font-weight: 500; }
|
||||||
|
.nav button, .sidebar-bottom > button { display: flex; align-items: center; gap: 11px; width: 100%; border: none; padding: 11px 12px; margin: 2px 0; text-align: left; background: transparent; color: #b8bdc6; font-size: 13px; }
|
||||||
|
.nav button:hover, .sidebar-bottom > button:hover { background: #25282e; color: var(--text); }
|
||||||
|
.nav button.active { color: var(--accent-contrast); background: var(--accent); font-weight: 650; }
|
||||||
|
.nav button.active .nav-count { background: #211b0820; color: var(--accent-contrast); }
|
||||||
|
.nav-count { margin-left: auto; padding: 1px 7px; background: #2b2e34; border-radius: 5px; color: #c5c9d2; font-size: 10px; font-variant-numeric: tabular-nums; }
|
||||||
|
.sidebar-bottom { margin-top: auto; padding: 16px 12px 19px; }.sidebar-bottom > button { padding: 11px; }
|
||||||
|
.sidebar-footer { border-top: 1px solid var(--line); margin: 12px 9px 0; padding-top: 16px; display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 10px; }.sidebar-footer > span:last-child { margin-left: auto; font-size: 9px; }
|
||||||
|
.app-shell { margin-left: 236px; min-width: 0; }
|
||||||
|
.topbar { display: flex; align-items: center; gap: 12px; height: 65px; padding: 0 30px; position: sticky; top: 0; background: var(--bg); border-bottom: 1px solid var(--line); z-index: 20; }
|
||||||
|
.breadcrumb { display: flex; gap: 13px; color: #7d8490; font-size: 12px; white-space: nowrap; }.breadcrumb strong { color: #c5cbd3; font-weight: 500; }.breadcrumb > span { color: #454a54; }
|
||||||
|
.actions { display: flex; align-items: center; gap: 14px; margin-left: auto; }
|
||||||
|
.icon-button { display: inline-grid; place-items: center; width: 34px; height: 34px; padding: 0; font-size: 11px; font-weight: 650; }
|
||||||
|
.scan-control { display: flex; gap: 7px; align-items: center; }.scan-control select { height: 34px; padding: 0 8px; font-size: 11px; }
|
||||||
|
.primary-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; border-color: var(--accent); background: var(--accent); color: var(--accent-contrast); font-size: 12px; font-weight: 650; }.primary-button:hover { background: var(--accent-hover); }.primary-button .icon { width: 16px; height: 16px; }
|
||||||
|
.menu-toggle, .sidebar-shade { display: none; }
|
||||||
|
main { max-width: 1800px; margin: 0 auto; padding: 29px 30px 48px; }
|
||||||
|
.page-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 25px; }.page-heading p { margin-top: 7px; color: var(--muted); font-size: 12px; }.page-heading > .icon { color: var(--accent); }
|
||||||
|
.live-label { display: flex; align-items: center; gap: 7px; font-size: 11px; color: var(--muted); white-space: nowrap; }.source-label { color: #b6bdc9; border: 1px solid var(--line); border-radius: 5px; padding: 4px 9px; font-size: 11px; }
|
||||||
|
.summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 29px; }
|
||||||
|
.summary-card { display: flex; align-items: center; gap: 14px; padding: 19px 17px; min-height: 108px; text-align: left; background: var(--panel); border: 1px solid var(--line); border-radius: 9px; }
|
||||||
|
.summary-card[data-go]:hover { border-color: var(--accent-border); background: #1b1913; }.summary-card:not([data-go]) { cursor: default; }
|
||||||
|
.summary-card > div { min-width: 0; }.summary-card strong { display: block; font-size: 26px; font-weight: 650; line-height: 1.15; letter-spacing: -.7px; font-variant-numeric: tabular-nums; }.summary-card > div > span { display: block; color: #b5bbc5; font-size: 12px; margin-top: 5px; }.summary-card small { display: block; color: #767f8e; font-size: 10px; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.summary-icon { display: grid; place-items: center; width: 40px; height: 40px; flex: 0 0 auto; border-radius: 8px; background: var(--accent-soft); color: var(--accent); }.summary-icon.purple { background: #252037; color: var(--purple); }.summary-icon.amber { background: #302719; color: var(--amber); }.summary-icon.green { background: #142c24; color: var(--green); }
|
||||||
|
.section-title { display: flex; align-items: center; gap: 10px; margin: 24px 0 14px; }.section-title > .icon { color: var(--accent); }.section-title > .muted { margin-left: auto; font-size: 10px; }.section-title .badge { margin-left: 3px; }
|
||||||
|
.dashboard-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 18px; }.system-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); }
|
||||||
|
.panel { min-width: 0; padding: 20px; border: 1px solid var(--line); background: var(--panel); border-radius: 9px; }
|
||||||
|
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 21px; }.panel-heading > .icon { color: #686f7b; }.panel-heading > .muted { font-size: 10px; }
|
||||||
|
.metric-line { display: flex; align-items: center; justify-content: space-between; gap: 12px; font-size: 12px; }.metric-line strong { font-weight: 550; font-variant-numeric: tabular-nums; }
|
||||||
|
.track { height: 6px; border-radius: 4px; overflow: hidden; background: #252930; margin: 10px 0 8px; }.track > span { display: block; height: 100%; width: 0; border-radius: inherit; background: var(--accent); transition: width .4s ease; }.purple-track > span { background: var(--purple); }
|
||||||
|
.coverage-row + .coverage-row { margin-top: 23px; }.coverage-row > small { font-size: 10px; }
|
||||||
|
.library-footer { display: flex; align-items: center; gap: 7px; border-top: 1px solid var(--line); margin-top: 25px; padding-top: 13px; color: var(--muted); font-size: 10px; }.library-footer .icon { width: 13px; height: 13px; }
|
||||||
|
.language-metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }.language-metrics strong { display: block; font-size: 25px; font-weight: 600; }.language-metrics small { display: block; font-size: 10px; color: var(--muted); }
|
||||||
|
.panel-note { font-size: 11px; margin-top: 17px; line-height: 1.7; }.review-list { margin-top: 13px; border-top: 1px solid var(--line); padding-top: 7px; }.review-list > div { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 5px 0; }.review-list .title { color: #bdc3ce; font-size: 11px; }
|
||||||
|
.system-meters { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }.system-meters .metric-line strong { font-size: 18px; }.system-meters small { font-size: 10px; }
|
||||||
|
.system-details { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; border-top: 1px solid var(--line); padding-top: 17px; margin-top: 19px; }.system-details small { display: block; font-size: 10px; color: var(--muted); }.system-details strong { display: block; font-weight: 500; font-size: 12px; margin-top: 4px; }
|
||||||
|
.disk-list { margin-top: 18px; }.disk-row { display: grid; grid-template-columns: 74px minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 10px 0; border-top: 1px solid var(--line); font-size: 10px; }.disk-row small { display: block; font-size: 9px; color: var(--muted); }.disk-row .track { margin: 0; }.disk-values { text-align: right; }
|
||||||
|
.service-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 22px; }.service-row { display: flex; align-items: center; gap: 10px; min-width: 0; padding: 10px 0; border-bottom: 1px solid #22252a; }.service-row > div { min-width: 0; flex: 1; }.service-row strong { font-size: 11px; font-weight: 550; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }.service-row small { display: block; color: #727c8c; font-size: 9px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.service-logo { display: grid; place-items: center; border: 1px solid var(--line); border-radius: 6px; background: #1e2228; color: #a1afc2; width: 28px; height: 28px; font-size: 11px; font-weight: 650; flex: 0 0 auto; }.service-status { font-size: 9px; color: var(--muted); white-space: nowrap; }.service-status.online { color: var(--green); }.service-status.offline, .service-status.error { color: var(--red); }.service-status.timeout { color: var(--amber); }
|
||||||
|
.toolbar { display: grid; grid-template-columns: minmax(200px, 1fr) repeat(3, minmax(130px, 180px)); gap: 9px; margin-bottom: 15px; }.toolbar input, .toolbar select { width: 100%; height: 38px; font-size: 12px; padding: 0 11px; }.search-field { position: relative; }.search-field .icon { position: absolute; top: 10px; left: 12px; width: 17px; height: 17px; color: var(--muted); }.search-field input { padding-left: 38px; }
|
||||||
|
.stats { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; font-size: 11px; color: var(--muted); margin-bottom: 16px; }.stats > span:first-child { color: #cbd1da; }.scan-status { margin-left: auto; }.scan-status.running { color: var(--accent); }.scan-status.error { color: var(--red); }
|
||||||
|
.table-panel { padding: 0; overflow: hidden; }.table-scroll { overflow-x: auto; overscroll-behavior-x: contain; }table { border-collapse: collapse; width: 100%; table-layout: fixed; text-align: left; font-size: 11px; }.col-title { width: 27%; }.col-languages { width: 18%; }th { color: #969fac; font-size: 10px; font-weight: 500; background: #181b20; padding: 12px 13px; border-bottom: 1px solid var(--line); }td { padding: 13px; border-bottom: 1px solid #25282d; vertical-align: middle; }tbody tr:last-child td { border-bottom: none; }tbody tr:hover { background: #171c21; }.title { display: inline-block; max-width: 100%; font-size: 12px; color: #d4dae4; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; }.year { display: inline-block; margin-left: 6px; font-size: 10px; }.badges { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }.badge, .state { display: inline-block; font-size: 10px; padding: 3px 6px; border: 1px solid #333943; border-radius: 5px; background: #1d2229; color: #b9c3d1; line-height: 1.3; }.badge.quality, .state.ok { color: #83d7b6; background: #122c23; border-color: #254437; }.badge.unknown, .state.warn { color: #d9b97d; background: #2a2419; border-color: #473a25; }.state.bad { color: #e49b9f; background: #2d1b20; border-color: #4b2a33; }.state.downloading { color: #c7b1f0; background: #282037; border-color: #45345f; }.state.neutral { color: var(--muted); }.badge.audio-preferred, .badge.subtitle-preferred { border-color: var(--accent-border); background: var(--accent-soft); color: var(--accent-text); }.badge.subtitle-de { color: #83d7b6; }.badge sup { margin-left: 2px; }.error-hint { color: var(--amber); margin-left: 5px; display: inline-block; vertical-align: middle; }.error-hint .icon { width: 13px; height: 13px; }.metadata-note { font-size: 10px; margin-top: 14px; }
|
||||||
|
.category-heading { display: flex; align-items: center; justify-content: space-between; margin: 23px 0 12px; }.category-heading h2 { font-size: 13px; }.category-heading > span { font-size: 10px; }.series { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; margin-bottom: 8px; }.series summary { display: flex; align-items: center; gap: 10px; padding: 15px; cursor: pointer; list-style: none; }.series summary::-webkit-details-marker { display: none; }.series summary > strong { font-weight: 550; font-size: 12px; }.series summary > .muted { font-size: 10px; }.series summary > .icon { color: #697f98; width: 17px; height: 17px; }.series summary::after { content: '⌄'; margin-left: auto; color: var(--muted); }.series[open] summary::after { transform: rotate(180deg); }.series[open] summary { border-bottom: 1px solid var(--line); }.season-divider td { background: #141a20; color: #71869b; font-size: 10px; padding: 8px 13px; }.list-heading { font-size: 14px; margin: 24px 0 12px; }
|
||||||
|
.download-table { min-width: 870px; }.download-table th:nth-child(3) { width: 17%; }.progress-wrap { display: flex; gap: 8px; align-items: center; }.progress-wrap .track { flex: 1; margin: 0; }.progress-wrap > span { font-size: 9px; }.download-size small { color: var(--muted); display: block; font-size: 10px; }
|
||||||
|
.panel-stack { display: flex; flex-direction: column; gap: 18px; }.category-row { margin-top: 24px; }.category-row small { display: block; font-size: 10px; color: var(--muted); }.pressure-row { display: grid; grid-template-columns: 40px 45px 1fr; gap: 12px; align-items: center; font-size: 11px; margin-top: 12px; }.pressure-row .track { margin: 0; }.pressure-row strong { font-size: 10px; }.raid-disks { margin-top: 18px; display: grid; gap: 7px; }.raid-disk { padding: 11px; border: 1px solid var(--line); border-radius: 7px; display: flex; align-items: flex-start; gap: 9px; min-width: 0; }.raid-disk > div { min-width: 0; flex: 1; }.raid-disk strong { font-size: 11px; font-weight: 550; }.raid-disk small { display: block; color: var(--muted); font-size: 10px; overflow-wrap: anywhere; }.raid-disk > b { font-size: 9px; font-weight: 500; color: var(--muted); }.disk-status { width: 5px; height: 5px; border-radius: 50%; margin-top: 7px; background: var(--amber); flex: 0 0 auto; }.disk-status.active { background: var(--green); }.disk-status.failed { background: var(--red); }.raid-status.ok { color: var(--green); }.raid-status.degraded, .raid-status.rebuilding { color: var(--amber); }.raid-status.failed { color: var(--red); }.recovery-box { margin-top: 15px; padding: 12px; background: var(--accent-soft); border: 1px solid var(--accent-border); border-radius: 7px; }
|
||||||
|
.graphics-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 18px; }.graphics-meta-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }.graphics-meta-grid small { color: var(--muted); display: block; font-size: 10px; }.graphics-meta-grid strong { display: block; margin-top: 5px; font-size: 13px; font-weight: 500; }.graphics-details { display: grid; gap: 8px; margin-top: 20px; border-top: 1px solid var(--line); padding-top: 16px; color: var(--muted); font-size: 10px; overflow-wrap: anywhere; }.graphics-process { display: flex; gap: 20px; padding: 12px 0; border-bottom: 1px solid var(--line); font-size: 11px; flex-wrap: wrap; }.graphics-process strong { flex: 1; min-width: 150px; overflow-wrap: anywhere; font-weight: 550; }.graphics-process span { color: var(--muted); }
|
||||||
|
.empty-state { padding: 30px 16px; text-align: center; color: var(--muted); font-size: 12px; }.empty-state > .icon { color: var(--green); display: block; margin: 0 auto 10px; }.banner, .notice { display: flex; align-items: center; gap: 9px; padding: 11px 14px; border-radius: 7px; margin-bottom: 20px; color: #efb2b5; border: 1px solid #4a2a31; background: #26191e; font-size: 12px; overflow-wrap: anywhere; }.notice { color: var(--accent-text); border-color: var(--accent-border); background: var(--accent-soft); }
|
||||||
|
.settings-modal { width: min(440px, calc(100vw - 30px)); max-height: calc(100dvh - 40px); overflow-y: auto; border: 1px solid #3a424d; border-radius: 12px; padding: 24px; background: #191c21; color: var(--text); }.settings-modal::backdrop { background: #000a; }.settings-modal h2 { font-size: 20px; }.settings-modal p { font-size: 11px; margin-top: 5px; }.settings-modal label:not(.setting-toggle) { display: block; margin: 18px 0 7px; font-size: 12px; }.settings-modal select { width: 100%; height: 40px; padding: 0 10px; font-size: 12px; }.setting-toggle { display: flex; align-items: center; gap: 10px; margin-top: 23px; font-size: 12px; }.setting-toggle input { accent-color: var(--accent); width: 16px; height: 16px; }.settings-actions { display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid var(--line); margin-top: 22px; padding-top: 18px; }.settings-actions button { font-size: 12px; }
|
||||||
|
|
||||||
|
/* Keep motion short and local: monitoring updates never animate entire views. */
|
||||||
|
button, input, select, .panel, .series, .raid-disk, .badge, .state {
|
||||||
|
transition: background-color var(--motion-fast) ease, border-color var(--motion-fast) ease, color var(--motion-fast) ease, box-shadow var(--motion-fast) ease;
|
||||||
|
}
|
||||||
|
button { transition: background-color var(--motion-fast) ease, color var(--motion-fast) ease, border-color var(--motion-fast) ease, transform var(--motion-fast) var(--motion-ease); }
|
||||||
|
button:active:not(:disabled) { transform: scale(.97); }
|
||||||
|
.sidebar-shade:active:not(:disabled) { transform: none; }
|
||||||
|
.nav button .icon, .sidebar-bottom button .icon, .summary-icon, .brand-icon { transition: transform 280ms var(--motion-ease); }
|
||||||
|
.nav button:hover .icon, .nav button:focus-visible .icon { transform: translateX(2px); }
|
||||||
|
.nav button.active { animation: nav-select 300ms var(--motion-ease); }
|
||||||
|
.brand:hover .brand-icon { transform: rotate(-8deg) scale(1.08); }
|
||||||
|
#settingsOpen:hover .icon { transform: rotate(35deg); }
|
||||||
|
.summary-card { transition: transform 240ms var(--motion-ease), box-shadow 240ms ease, border-color var(--motion-fast) ease, background-color var(--motion-fast) ease; }
|
||||||
|
.summary-card[data-go]:hover, .summary-card[data-go]:focus-visible { transform: translateY(-4px); box-shadow: 0 8px 24px #0004; }
|
||||||
|
.summary-card[data-go]:active { transform: translateY(-1px) scale(.98); }
|
||||||
|
.summary-card[data-go]:hover .summary-icon { transform: scale(1.1) rotate(-4deg); }
|
||||||
|
.panel:hover, .series:hover, .raid-disk:hover { border-color: #414650; }
|
||||||
|
input:focus, select:focus { border-color: var(--accent-border); box-shadow: 0 0 0 3px #f4ce5412; }
|
||||||
|
.search-field .icon { transition: color var(--motion-fast) ease, transform var(--motion-fast) var(--motion-ease); }
|
||||||
|
.search-field:focus-within .icon { color: var(--accent); transform: scale(1.08); }
|
||||||
|
tbody tr { transition: background-color var(--motion-fast) ease; }
|
||||||
|
.badge:hover, .state:hover { border-color: #596373; }
|
||||||
|
.series summary::after { transition: transform 240ms var(--motion-ease); }
|
||||||
|
.series summary { transition: background-color var(--motion-fast) ease; }
|
||||||
|
.series summary:hover { background: #1b1e24; }
|
||||||
|
.series[open] > .table-scroll { animation: reveal-content 320ms var(--motion-ease); }
|
||||||
|
.track > span { position: relative; overflow: hidden; transition: width 650ms var(--motion-ease); }
|
||||||
|
#activeDownloadsBody .state.downloading { animation: status-glow 2.8s ease-in-out infinite; }
|
||||||
|
#activeDownloadsBody tr:has(.state.downloading) .track > span::after, .recovery-box .track > span::after {
|
||||||
|
content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, #fff5, transparent); transform: translateX(-100%); animation: progress-sweep 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.live-label .status-dot { animation: live-pulse 2.8s ease-in-out infinite; }
|
||||||
|
.is-scanning .icon { animation: scan-spin 1.1s linear infinite; }
|
||||||
|
.scan-status.running { animation: status-glow 2.4s ease-in-out infinite; }
|
||||||
|
.banner:not(.hidden), .notice:not(.hidden), .recovery-box:not(.hidden) { animation: reveal-content 320ms var(--motion-ease); }
|
||||||
|
.empty-state > .icon { animation: success-pop 500ms var(--motion-ease); }
|
||||||
|
.settings-modal[open] { animation: modal-enter 320ms var(--motion-ease); }
|
||||||
|
.settings-modal[open]::backdrop { animation: fade-in 240ms ease; }
|
||||||
|
body.sidebar-open .sidebar-shade { animation: fade-in 220ms ease; }
|
||||||
|
@keyframes nav-select { from { box-shadow: inset 0 0 0 20px #ffffff25; } to { box-shadow: inset 0 0 0 0 #ffffff00; } }
|
||||||
|
@keyframes reveal-content { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
@keyframes modal-enter { from { opacity: 0; transform: translateY(16px) scale(.97); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||||
|
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
@keyframes success-pop { from { opacity: 0; transform: scale(.7); } to { opacity: 1; transform: scale(1); } }
|
||||||
|
@keyframes scan-spin { to { transform: rotate(360deg); } }
|
||||||
|
@keyframes live-pulse { 0%, 100% { box-shadow: 0 0 0 0 #58caa000; } 50% { box-shadow: 0 0 0 5px #58caa020; } }
|
||||||
|
@keyframes status-glow { 0%, 100% { opacity: 1; } 50% { opacity: .65; } }
|
||||||
|
@keyframes progress-sweep { to { transform: translateX(100%); } }
|
||||||
|
@media (max-width: 1250px) {
|
||||||
|
.sidebar { width: 212px; }.app-shell { margin-left: 212px; }.brand { padding: 0 20px; font-size: 21px; }.topbar { padding: 0 22px; }main { padding: 25px 22px 40px; }
|
||||||
|
.summary-card { gap: 10px; padding: 17px 13px; }.summary-card strong { font-size: 23px; }.summary-icon { width: 34px; height: 34px; }
|
||||||
|
.service-list { grid-template-columns: 1fr; }.dashboard-grid { gap: 14px; }.toolbar { grid-template-columns: 1fr 1fr; }.media-table { min-width: 820px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 950px) {
|
||||||
|
.summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.dashboard-grid { grid-template-columns: 1fr; }.service-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }.breadcrumb { font-size: 11px; gap: 8px; }.actions { gap: 8px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.sidebar { transform: translateX(-100%); width: 250px; transition: transform .2s ease; z-index: 60; }.app-shell { margin-left: 0; }.menu-toggle { display: inline-grid; }.sidebar-shade { position: fixed; inset: 0; z-index: 50; border: 0; border-radius: 0; background: #0009; }.sidebar-shade:hover { background: #0009; }body.sidebar-open .sidebar { transform: none; }body.sidebar-open .sidebar-shade { display: block; }body.sidebar-open { overflow: hidden; }
|
||||||
|
.topbar { height: 59px; padding: 0 14px; gap: 9px; }.breadcrumb { color: var(--text); }.breadcrumb > :not(strong) { display: none; }.breadcrumb { font-size: 0; }.breadcrumb strong { font-size: 12px; }.actions { gap: 7px; }.scan-control { gap: 5px; }.scan-control select { max-width: 102px; font-size: 10px; }.scan-control .primary-button { padding: 8px; }.scan-control .primary-button > span { display: none; }
|
||||||
|
main { padding: 22px 15px 32px; }h1 { font-size: 23px; }.page-heading { gap: 10px; margin-bottom: 20px; align-items: flex-start; }.page-heading p { font-size: 11px; }.live-label { font-size: 0; margin-top: 9px; }.live-label > span:not(.status-dot) { display: none; }
|
||||||
|
.summary-grid { gap: 9px; margin-bottom: 22px; }.summary-card { min-height: 96px; padding: 14px 11px; gap: 10px; }.summary-card strong { font-size: 22px; }.summary-card small { font-size: 9px; }.summary-card > div > span { font-size: 11px; }.summary-icon { width: 31px; height: 31px; }.summary-icon > .icon { width: 17px; height: 17px; }
|
||||||
|
.section-title h2 { font-size: 16px; }.panel { padding: 17px; }.panel.table-panel { padding: 0; }.service-list { column-gap: 14px; }.service-row { gap: 6px; }.service-logo { width: 24px; height: 24px; }.service-row strong { font-size: 10px; }.service-status { font-size: 8px; }.system-meters { gap: 18px; }.system-details { gap: 9px; }.system-details strong { font-size: 10px; }.toolbar { gap: 7px; }.toolbar .search-field { grid-column: 1 / -1; }.toolbar select:last-child { grid-column: 1 / -1; }.stats { gap: 8px; font-size: 10px; }.scan-status { margin-left: 0; }.series summary { flex-wrap: wrap; gap: 7px; }.series summary > strong { max-width: calc(100% - 45px); overflow-wrap: anywhere; }.series summary > .muted { width: 100%; margin-left: 24px; }.series summary::after { position: absolute; right: 15px; }.series summary { position: relative; }
|
||||||
|
.language-metrics strong { font-size: 23px; }.language-metrics small { font-size: 9px; }.graphics-cards { grid-template-columns: minmax(0, 1fr); }.graphics-meta-grid { gap: 8px; grid-template-columns: repeat(2, 1fr); }.disk-row { grid-template-columns: 58px minmax(0, 1fr) auto; gap: 8px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
.service-list { grid-template-columns: 1fr; }.summary-icon { display: none; }.language-metrics { grid-template-columns: 1fr 1fr; }.scan-control select { max-width: 88px; }.topbar { gap: 6px; }.actions { gap: 5px; }.breadcrumb strong { font-size: 11px; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after, *::backdrop { scroll-behavior: auto !important; transition: none !important; animation: none !important; }
|
||||||
|
button:active:not(:disabled), .summary-card[data-go]:hover, .summary-card[data-go]:focus-visible, .summary-card[data-go]:active,
|
||||||
|
.nav button:hover .icon, .nav button:focus-visible .icon, .brand:hover .brand-icon, #settingsOpen:hover .icon,
|
||||||
|
.summary-card[data-go]:hover .summary-icon, .search-field:focus-within .icon { transform: none; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const config = JSON.parse(document.querySelector('#dashboardConfig').textContent);
|
||||||
|
const $ = selector => document.querySelector(selector);
|
||||||
|
const $$ = selector => [...document.querySelectorAll(selector)];
|
||||||
|
const html = value => String(value ?? '—').replace(/[&<>"']/g, char => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[char]));
|
||||||
|
const icon = name => `<svg class="icon" aria-hidden="true"><use href="#icon-${name}"/></svg>`;
|
||||||
|
const set = (id, value) => {
|
||||||
|
const node = $(`#${id}`);
|
||||||
|
if (!node || node.textContent === String(value)) return;
|
||||||
|
node.textContent = value;
|
||||||
|
if (node.matches('.summary-card strong, .metric-line strong, .nav-count')) {
|
||||||
|
animate(node, [{opacity: .45, transform: 'translateY(3px)'}, {opacity: 1, transform: 'translateY(0)'}], {duration: 300});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const percent = value => Math.max(0, Math.min(100, Number(value) || 0));
|
||||||
|
const bar = (id, value) => { const node = $(`#${id}`); if (node) node.style.width = `${percent(value)}%`; };
|
||||||
|
let language = localStorage.getItem('dashboard-language') === 'en' ? 'en' : 'de';
|
||||||
|
const t = (de, en) => language === 'en' ? en : de;
|
||||||
|
const number = value => Number(value || 0).toLocaleString(language === 'en' ? 'en-GB' : 'de-DE');
|
||||||
|
const empty = message => `<div class="empty-state">${html(message)}</div>`;
|
||||||
|
let activeView = 'dashboard';
|
||||||
|
const snapshots = {};
|
||||||
|
const motionPreference = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
const entranceAnimations = new WeakMap();
|
||||||
|
const renderedContainers = new WeakSet();
|
||||||
|
let viewInitialized = false;
|
||||||
|
|
||||||
|
function animate(node, frames, options = {}) {
|
||||||
|
if (motionPreference.matches || document.hidden || !node.getClientRects().length || typeof node.animate !== 'function') return;
|
||||||
|
entranceAnimations.get(node)?.cancel();
|
||||||
|
const animation = node.animate(frames, {duration: 420, easing: 'cubic-bezier(.22, 1, .36, 1)', fill: 'backwards', ...options});
|
||||||
|
entranceAnimations.set(node, animation);
|
||||||
|
}
|
||||||
|
|
||||||
|
motionPreference.addEventListener('change', event => {
|
||||||
|
if (event.matches) document.getAnimations().forEach(animation => animation.cancel());
|
||||||
|
});
|
||||||
|
|
||||||
|
function animateEntrance(root) {
|
||||||
|
const targets = root.querySelectorAll('.page-heading, .summary-card, .panel, .toolbar, .series, .service-row, .raid-disk, .disk-row, .graphics-process');
|
||||||
|
// Bound the work even when a media library contains thousands of entries.
|
||||||
|
[...targets].slice(0, 24).forEach((node, index) => {
|
||||||
|
animate(node, [{opacity: 0, transform: 'translateY(12px)'}, {opacity: 1, transform: 'translateY(0)'}], {delay: Math.min(index * 35, 210)});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContent(id, markup) {
|
||||||
|
const root = $(`#${id}`);
|
||||||
|
if (!root || root.innerHTML === markup) return;
|
||||||
|
const previousWidths = [...root.querySelectorAll('.track > span')].map(node => node.style.width);
|
||||||
|
root.innerHTML = markup;
|
||||||
|
root.querySelectorAll('.track > span').forEach((node, index) => {
|
||||||
|
const from = previousWidths[index] || '0%';
|
||||||
|
if (from !== node.style.width) animate(node, [{width: from}, {width: node.style.width}], {duration: 650});
|
||||||
|
});
|
||||||
|
// Polling updates must not make whole panels re-enter every few seconds.
|
||||||
|
if (!renderedContainers.has(root)) {
|
||||||
|
animateEntrance(root);
|
||||||
|
renderedContainers.add(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSidebar() {
|
||||||
|
document.body.classList.remove('sidebar-open');
|
||||||
|
$('#menuToggle').setAttribute('aria-expanded', 'false');
|
||||||
|
$('#sidebar').inert = window.matchMedia('(max-width:700px)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchView(view) {
|
||||||
|
const button = $(`.nav button[data-view="${view}"]`);
|
||||||
|
if (!button || !$(`#${view}`)) return;
|
||||||
|
const changed = !viewInitialized || activeView !== view;
|
||||||
|
activeView = view;
|
||||||
|
$$('.view').forEach(node => node.classList.toggle('active', node.id === view));
|
||||||
|
$$('.nav button').forEach(node => {
|
||||||
|
node.classList.toggle('active', node === button);
|
||||||
|
if (node === button) node.setAttribute('aria-current', 'page');
|
||||||
|
else node.removeAttribute('aria-current');
|
||||||
|
});
|
||||||
|
set('pageTitle', button.querySelector('[data-de]').textContent);
|
||||||
|
sessionStorage.setItem('dashboard-active-view', view);
|
||||||
|
if (['radarr', 'sonarr', 'missing'].includes(view)) $('#scanSource').value = view;
|
||||||
|
closeSidebar();
|
||||||
|
if (changed) {
|
||||||
|
animateEntrance($(`#${view}`));
|
||||||
|
viewInitialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function translateLayout() {
|
||||||
|
document.documentElement.lang = language;
|
||||||
|
localStorage.setItem('dashboard-language', language);
|
||||||
|
$$('[data-de][data-en]').forEach(node => { node.textContent = node.dataset[language]; });
|
||||||
|
$$('[data-placeholder-de]').forEach(node => { node.placeholder = node.dataset[language === 'en' ? 'placeholderEn' : 'placeholderDe']; });
|
||||||
|
set('languageToggle', language === 'en' ? 'DE' : 'EN');
|
||||||
|
switchView(activeView);
|
||||||
|
applyFilters('movie');
|
||||||
|
applyFilters('series');
|
||||||
|
updateMissingCounts();
|
||||||
|
Object.entries(snapshots).forEach(([key, value]) => renderers[key](value));
|
||||||
|
}
|
||||||
|
|
||||||
|
$$('.nav button').forEach(button => button.addEventListener('click', () => switchView(button.dataset.view)));
|
||||||
|
$$('[data-go]').forEach(button => button.addEventListener('click', () => switchView(button.dataset.go)));
|
||||||
|
$('#languageToggle').addEventListener('click', () => { language = language === 'de' ? 'en' : 'de'; translateLayout(); });
|
||||||
|
$('#menuToggle').addEventListener('click', () => {
|
||||||
|
const open = document.body.classList.toggle('sidebar-open');
|
||||||
|
$('#sidebar').inert = !open;
|
||||||
|
$('#menuToggle').setAttribute('aria-expanded', String(open));
|
||||||
|
if (open) $('.nav button.active').focus();
|
||||||
|
});
|
||||||
|
$('#sidebarShade').addEventListener('click', () => { closeSidebar(); $('#menuToggle').focus(); });
|
||||||
|
window.matchMedia('(max-width:700px)').addEventListener('change', closeSidebar);
|
||||||
|
document.addEventListener('keydown', event => {
|
||||||
|
if (event.key === 'Escape' && document.body.classList.contains('sidebar-open')) {
|
||||||
|
closeSidebar(); $('#menuToggle').focus();
|
||||||
|
}
|
||||||
|
if (event.key === 'Tab' && document.body.classList.contains('sidebar-open')) {
|
||||||
|
const controls = $$('#sidebar a, #sidebar button');
|
||||||
|
const first = controls[0], last = controls[controls.length - 1];
|
||||||
|
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
||||||
|
if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function seasonVisible(row) {
|
||||||
|
return localStorage.getItem('show-season-zero') === 'true' || row.dataset.season !== '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters(prefix) {
|
||||||
|
const search = $(`#${prefix}Search`);
|
||||||
|
if (!search) return;
|
||||||
|
const query = search.value.trim().toLowerCase();
|
||||||
|
const audio = $(`#${prefix}Audio`).value, subs = $(`#${prefix}Subs`).value, state = $(`#${prefix}State`).value;
|
||||||
|
let visible = 0;
|
||||||
|
const matches = (row, title = '') => (!query || `${row.dataset.title} ${title}`.includes(query)) &&
|
||||||
|
(!audio || row.dataset.audio.includes(audio)) && (!subs || row.dataset.subs.includes(subs)) &&
|
||||||
|
(!state || row.dataset.state === state) && seasonVisible(row);
|
||||||
|
if (prefix === 'movie') {
|
||||||
|
$$('#movies tbody tr[data-title]').forEach(row => {
|
||||||
|
const show = matches(row); row.classList.toggle('hidden', !show); if (show) visible++;
|
||||||
|
});
|
||||||
|
set('movieCount', `${number(visible)} ${t('Filme', 'movies')}`);
|
||||||
|
} else {
|
||||||
|
$$('#seriesList .series').forEach(series => {
|
||||||
|
let count = 0;
|
||||||
|
series.querySelectorAll('tr[data-title]').forEach(row => {
|
||||||
|
const show = matches(row, series.dataset.title); row.classList.toggle('hidden', !show); if (show) count++;
|
||||||
|
});
|
||||||
|
series.classList.toggle('hidden', !count);
|
||||||
|
if (count) visible++;
|
||||||
|
updateSeasonDividers(series);
|
||||||
|
});
|
||||||
|
$$('#seriesList .series-category').forEach(category => category.classList.toggle('hidden', !category.querySelector('.series:not(.hidden)')));
|
||||||
|
set('seriesCount', `${number(visible)} ${t('Serien', 'series')}`);
|
||||||
|
}
|
||||||
|
$(`#${prefix}NoResults`)?.classList.toggle('hidden', visible > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function animateFilterResults(prefix) {
|
||||||
|
const root = $(prefix === 'movie' ? '#movies tbody' : '#seriesList');
|
||||||
|
if (!root) return;
|
||||||
|
const selector = prefix === 'movie' ? 'tr[data-title]:not(.hidden)' : '.series:not(.hidden)';
|
||||||
|
[...root.querySelectorAll(selector)].slice(0, 12).forEach((node, index) => {
|
||||||
|
animate(node, [{opacity: .35}, {opacity: 1}], {duration: 240, delay: index * 15});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSeasonDividers(root) {
|
||||||
|
root.querySelectorAll('.season-divider').forEach(divider => {
|
||||||
|
let next = divider.nextElementSibling, count = 0;
|
||||||
|
while (next && !next.classList.contains('season-divider')) {
|
||||||
|
if (!next.classList.contains('hidden')) count++;
|
||||||
|
next = next.nextElementSibling;
|
||||||
|
}
|
||||||
|
divider.classList.toggle('hidden', count === 0);
|
||||||
|
divider.firstElementChild.textContent = `${t('Staffel', 'Season')} ${divider.dataset.season}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$$('.series tbody').forEach(body => {
|
||||||
|
let season = null;
|
||||||
|
[...body.querySelectorAll('tr[data-season]')].forEach(row => {
|
||||||
|
if (row.dataset.season === season) return;
|
||||||
|
season = row.dataset.season;
|
||||||
|
const divider = document.createElement('tr');
|
||||||
|
divider.className = 'season-divider'; divider.dataset.season = season;
|
||||||
|
divider.innerHTML = `<td colspan="7">${t('Staffel', 'Season')} ${html(season)}</td>`;
|
||||||
|
row.before(divider);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$$('[data-filter] input, [data-filter] select').forEach(input => {
|
||||||
|
const saved = sessionStorage.getItem(`filter-${input.id}`);
|
||||||
|
if (saved !== null) input.value = saved;
|
||||||
|
else if (input.id.endsWith('Audio')) input.value = localStorage.getItem('preferred-audio') || '';
|
||||||
|
else if (input.id.endsWith('Subs')) input.value = localStorage.getItem('preferred-subs') || '';
|
||||||
|
input.addEventListener(input.type === 'search' ? 'input' : 'change', () => {
|
||||||
|
sessionStorage.setItem(`filter-${input.id}`, input.value);
|
||||||
|
const prefix = input.closest('[data-filter]').dataset.filter;
|
||||||
|
applyFilters(prefix);
|
||||||
|
animateFilterResults(prefix);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateMissingCounts() {
|
||||||
|
$$('#missing .missing-series').forEach(series => {
|
||||||
|
let count = 0;
|
||||||
|
series.querySelectorAll('tr[data-title]').forEach(row => {
|
||||||
|
const show = seasonVisible(row); row.classList.toggle('hidden', !show); if (show) count++;
|
||||||
|
});
|
||||||
|
series.classList.toggle('hidden', count === 0);
|
||||||
|
updateSeasonDividers(series);
|
||||||
|
});
|
||||||
|
const count = $$('#missing tr[data-title]:not(.hidden)').length;
|
||||||
|
set('missingNavCount', number(count)); set('dashboardMissing', number(count));
|
||||||
|
set('missingCount', `${number(count)} ${t('Einträge ohne Deutsch', 'entries without German')}`);
|
||||||
|
set('seasonZeroHint', localStorage.getItem('show-season-zero') === 'true' ? t('Staffel 0 eingeblendet', 'Season 0 included') : t('Staffel 0 ausgeblendet', 'Season 0 hidden'));
|
||||||
|
$('#missingEmpty').classList.toggle('hidden', count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightPreferences() {
|
||||||
|
$$('.media-table tr[data-title]').forEach(row => {
|
||||||
|
[[3, 'preferred-audio', 'audio-preferred'], [4, 'preferred-subs', 'subtitle-preferred']].forEach(([column, key, className]) => {
|
||||||
|
const preference = localStorage.getItem(key);
|
||||||
|
const badges = [...row.cells[column].querySelectorAll('.badge')];
|
||||||
|
badges.forEach(badge => {
|
||||||
|
badge.classList.toggle(className, badge.dataset.language === preference);
|
||||||
|
if (column === 4) badge.classList.toggle('subtitle-de', badge.dataset.language === 'Deutsch');
|
||||||
|
});
|
||||||
|
const preferred = badges.find(badge => badge.dataset.language === preference);
|
||||||
|
if (preferred) preferred.parentElement.prepend(preferred);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#settingsOpen').addEventListener('click', () => {
|
||||||
|
closeSidebar();
|
||||||
|
$('#preferredAudio').value = localStorage.getItem('preferred-audio') || '';
|
||||||
|
$('#preferredSubs').value = localStorage.getItem('preferred-subs') || '';
|
||||||
|
$('#showSeasonZero').checked = localStorage.getItem('show-season-zero') === 'true';
|
||||||
|
$('#settingsModal').showModal();
|
||||||
|
});
|
||||||
|
$('#settingsSave').addEventListener('click', () => {
|
||||||
|
localStorage.setItem('preferred-audio', $('#preferredAudio').value);
|
||||||
|
localStorage.setItem('preferred-subs', $('#preferredSubs').value);
|
||||||
|
localStorage.setItem('show-season-zero', String($('#showSeasonZero').checked));
|
||||||
|
for (const prefix of ['movie', 'series']) {
|
||||||
|
for (const [suffix, value] of [['Audio', $('#preferredAudio').value], ['Subs', $('#preferredSubs').value]]) {
|
||||||
|
const input = $(`#${prefix}${suffix}`);
|
||||||
|
if (input) { input.value = value; sessionStorage.setItem(`filter-${input.id}`, value); }
|
||||||
|
}
|
||||||
|
applyFilters(prefix);
|
||||||
|
}
|
||||||
|
highlightPreferences(); updateMissingCounts(); $('#settingsModal').close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 25000);
|
||||||
|
try {
|
||||||
|
const response = await fetch(path, {cache: 'no-store', ...options, signal: controller.signal});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok && !data.error && !data.storage?.error) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return data;
|
||||||
|
} finally { clearTimeout(timeout); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function duration(seconds) {
|
||||||
|
const minutes = Math.floor(Number(seconds || 0) / 60), days = Math.floor(minutes / 1440), hours = Math.floor(minutes % 1440 / 60);
|
||||||
|
return `${days ? `${days}${t(' T', 'd')} ` : ''}${hours}${t(' Std.', 'h')} ${minutes % 60}${t(' Min.', 'm')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSystem(data) {
|
||||||
|
if (data.error) { set('systemUpdated', data.error); return; }
|
||||||
|
const cpu = percent(data.cpu_percent), memory = data.memory || {};
|
||||||
|
set('cpuValue', `${cpu.toFixed(1)}%`); set('ramValue', `${percent(memory.percent).toFixed(1)}%`);
|
||||||
|
set('ramDetail', `${memory.used || '—'} / ${memory.total || '—'}`);
|
||||||
|
bar('cpuBar', cpu); bar('ramBar', memory.percent);
|
||||||
|
set('systemUpdated', `${t('Aktualisiert', 'Updated')} ${new Date().toLocaleTimeString(language === 'de' ? 'de-DE' : 'en-GB')}`);
|
||||||
|
renderContent('systemDetails', `<div><small>Load Average</small><strong>${(data.load || []).map(value => Number(value).toFixed(2)).join(' · ') || '—'}</strong></div><div><small>Uptime</small><strong>${html(duration(data.uptime_seconds))}</strong></div><div><small>${t('Prozesse', 'Processes')}</small><strong>${number(data.processes)}</strong></div>`);
|
||||||
|
renderContent('diskList', (data.disks || []).map(disk => `<div class="disk-row"><div>${html(disk.label)}<small>${html(disk.path)}</small></div><div class="track"><span style="width:${percent(disk.percent)}%"></span></div><div class="disk-values">${html(disk.used)} / ${html(disk.total)}<small>${percent(disk.percent).toFixed(1)}%</small></div></div>`).join('') || empty(t('Keine Laufwerke erkannt.', 'No disks detected.')));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderServices(data) {
|
||||||
|
set('serviceSummary', `${data.online || 0} / ${data.total || 0} online`);
|
||||||
|
const labels = {online: t('Aktiv', 'Online'), offline: 'Offline', timeout: 'Timeout', checking: t('Prüfe …', 'Checking …'), error: t('Fehler', 'Error')};
|
||||||
|
renderContent('serviceList', (data.services || []).map(service => `<div class="service-row"><span class="service-logo">${html(service.name.slice(0, 1))}</span><div><strong>${html(service.name)}</strong><small title="${html(service.detail)}">${html(service.detail || '—')}</small></div><span class="service-status ${html(service.status)}">${html(labels[service.status] || service.label)}</span></div>`).join(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadRow(item) {
|
||||||
|
const pct = percent(item.percentage);
|
||||||
|
const languages = (item.languages || []).map(lang => `<span class="badge">${html(lang.flag)} ${html(lang.name)}</span>`).join('') || `<span class="muted">${t('Nicht erkannt', 'Not detected')}</span>`;
|
||||||
|
return `<tr><td><span class="title" title="${html(item.title)}">${html(item.title)}</span></td><td><span class="state ${item.completed ? 'ok' : 'downloading'}">${html(item.status)}</span></td><td><div class="progress-wrap"><div class="track"><span style="width:${pct}%"></span></div><span>${pct.toFixed(0)}%</span></div></td><td class="download-size">${html(item.size)}<small>${html(item.remaining)}</small></td><td>${html(item.timeleft)}</td><td>${html(item.source)}</td><td><div class="badges">${languages}</div></td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDownloads(data) {
|
||||||
|
const count = Number(data.active_count) || 0;
|
||||||
|
set('dashboardDownloads', data.error ? '—' : number(count));
|
||||||
|
set('dashboardRemaining', data.error ? t('Nicht erreichbar', 'Unavailable') : `${t('Noch', 'Remaining')} ${data.remaining || '0 B'}`);
|
||||||
|
set('downloadNavCount', number(count)); $('#downloadNavCount')?.classList.toggle('hidden', count === 0);
|
||||||
|
set('sabRemaining', data.remaining || '—'); set('sabActive', number(count)); set('sabSpeed', data.speed || '—'); set('sabHistory', number(data.history_count));
|
||||||
|
set('sabQueueStatus', data.error ? t('Nicht erreichbar', 'Unavailable') : data.paused ? t('Pausiert', 'Paused') : `SABnzbd · ${data.queue_status || 'Idle'}`);
|
||||||
|
$('#downloadError').classList.toggle('hidden', !data.error); set('downloadError', data.error || '');
|
||||||
|
const items = data.items || [];
|
||||||
|
const active = items.filter(item => !item.completed && String(item.status).toLowerCase() === 'downloading');
|
||||||
|
const others = items.filter(item => !active.includes(item));
|
||||||
|
for (const [id, list] of [['activeDownloads', active], ['otherDownloads', others]]) {
|
||||||
|
set(`${id}Count`, number(list.length));
|
||||||
|
renderContent(`${id}Body`, list.length ? list.map(downloadRow).join('') : `<tr><td colspan="7" class="empty-state">${t('Keine Einträge.', 'No entries.')}</td></tr>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDatastore(data) {
|
||||||
|
const storage = data.storage || {}, raid = data.raid || {};
|
||||||
|
for (const [id, key] of [['Total', 'total'], ['Used', 'used'], ['Free', 'free']]) set(`datastore${id}`, storage[key] || '—');
|
||||||
|
set('datastorePercent', storage.percent == null ? '—' : `${storage.percent}%`);
|
||||||
|
set('datastorePath', storage.path || '—'); set('datastoreCapacityLabel', storage.total ? `${storage.used} / ${storage.total}` : storage.error || '—');
|
||||||
|
bar('datastoreCapacityBar', storage.percent);
|
||||||
|
set('datastoreUpdated', storage.scanned_at ? `${t('Speicher-Scan', 'Storage scan')} ${new Date(storage.scanned_at * 1000).toLocaleTimeString()}` : t('Nicht verfügbar', 'Unavailable'));
|
||||||
|
$('#storageWarning').classList.toggle('hidden', !storage.error && !storage.warning);
|
||||||
|
set('storageWarning', storage.error || storage.warning || '');
|
||||||
|
renderContent('datastoreCategories', (storage.categories || []).map(category => `<div class="category-row"><div class="metric-line"><span>${html(category.label)}</span><strong>${html(category.size || '0 B')}</strong></div><div class="track"><span style="width:${percent(category.percent)}%"></span></div><small>${Number(category.percent || 0).toFixed(1)}% ${t('vom belegten Speicher', 'of used storage')} · ${number(category.files)} ${t('Dateien', 'files')}</small></div>`).join('') || empty(t('Keine Kategorien erkannt.', 'No categories detected.')));
|
||||||
|
const io = data.io_pressure || {}, some = Number(io.some?.avg10) || 0, full = Number(io.full?.avg10) || 0;
|
||||||
|
const pressure = Math.max(some, full);
|
||||||
|
set('ioPressureLabel', io.available ? pressure >= 20 ? t('Hoch', 'High') : pressure >= 5 ? t('Beobachten', 'Watch') : t('Normal', 'Normal') : t('Nicht verfügbar', 'Unavailable'));
|
||||||
|
for (const [key, value] of [['Some', some], ['Full', full]]) { set(`ioPressure${key}`, io.available ? `${value.toFixed(1)}%` : '—'); bar(`ioPressure${key}Bar`, value); }
|
||||||
|
set('raidDevice', raid.device || '—'); set('raidStatusBadge', raid.label || 'UNKNOWN');
|
||||||
|
$('#raidStatusBadge').className = `badge raid-status ${raid.status || 'unknown'}`;
|
||||||
|
$('#raidWarning').classList.toggle('hidden', !raid.error && !raid.warning); set('raidWarning', raid.error || raid.warning || '');
|
||||||
|
renderContent('raidSummary', `<div><small>Level</small><strong>${html(raid.level)}</strong></div><div><small>${t('Aktive Laufwerke', 'Active drives')}</small><strong>${html(raid.active_devices)} / ${html(raid.raid_devices)}</strong></div><div><small>${t('Fehlend', 'Missing')}</small><strong>${html(raid.failed_devices ?? '—')}</strong></div>`);
|
||||||
|
const recovery = raid.recovery || {};
|
||||||
|
$('#raidRecovery').classList.toggle('hidden', !recovery.active); set('raidRecoveryPercent', `${Number(recovery.percent || 0).toFixed(1)}%`);
|
||||||
|
set('raidRecoverySpeed', recovery.speed || '—'); set('raidRecoveryFinish', recovery.finish || '—'); bar('raidRecoveryBar', recovery.percent);
|
||||||
|
renderContent('raidDisks', (raid.devices || []).map(disk => `<div class="raid-disk"><span class="disk-status ${html(disk.status)}"></span><div><strong>${html(disk.device)}</strong><small>Slot ${html(disk.slot)} · ${html(disk.model)}</small><small>${html(disk.size)} · Serial: ${html(disk.serial)}</small><small>SMART: ${html(disk.smart || t('Nicht aktiviert', 'Not enabled'))} · ${html(disk.temperature || '—')}</small></div><b>${html(disk.status)}</b></div>`).join('') || empty(t('Keine RAID-Mitglieder erkannt.', 'No RAID members detected.')));
|
||||||
|
}
|
||||||
|
|
||||||
|
const gpuValue = (value, unit = '') => value == null || value === '' ? '—' : `${html(value)}${unit}`;
|
||||||
|
function renderGraphics(data) {
|
||||||
|
const gpus = data.gpus || [];
|
||||||
|
set('graphicsStatus', data.available ? `${gpus.length} GPU${gpus.length === 1 ? '' : 's'}` : t('Nicht verfügbar', 'Unavailable'));
|
||||||
|
const average = key => {
|
||||||
|
const values = gpus.map(gpu => gpu[key]).filter(value => value != null && Number.isFinite(Number(value))).map(Number);
|
||||||
|
return values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : null;
|
||||||
|
};
|
||||||
|
const used = gpus.reduce((sum, gpu) => sum + (Number(gpu['memory.used']) || 0), 0), total = gpus.reduce((sum, gpu) => sum + (Number(gpu['memory.total']) || 0), 0);
|
||||||
|
const metrics = [['GPUs', gpus.length], [t('GPU-Auslastung', 'GPU usage'), gpuValue(average('utilization.gpu'), '%')], ['VRAM', total ? `${(used / 1024).toFixed(1)} / ${(total / 1024).toFixed(1)} GB` : '—'], [t('Temperatur', 'Temperature'), gpuValue(average('temperature.gpu'), ' °C')]];
|
||||||
|
renderContent('graphicsSummary', metrics.map(([label, value]) => `<article class="summary-card"><span class="summary-icon purple">${icon('chip')}</span><div><strong>${value}</strong><span>${html(label)}</span></div></article>`).join(''));
|
||||||
|
renderContent('graphicsCards', gpus.length ? gpus.map(gpu => {
|
||||||
|
const used = Number(gpu['memory.used']) || 0, total = Number(gpu['memory.total']) || 0;
|
||||||
|
return `<article class="panel"><div class="panel-heading"><div><small class="muted">GPU ${html(gpu.index)}</small><h2>${html(gpu.name)}</h2></div><span class="badge">${gpuValue(gpu['temperature.gpu'], ' °C')}</span></div><div class="graphics-meta-grid"><div><small>${t('GPU-Last', 'GPU load')}</small><strong>${gpuValue(gpu['utilization.gpu'], '%')}</strong></div><div><small>${t('Memory-Last', 'Memory load')}</small><strong>${gpuValue(gpu['utilization.memory'], '%')}</strong></div><div><small>${t('Leistung', 'Power')}</small><strong>${gpuValue(gpu['power.draw'], ' W')} / ${gpuValue(gpu['power.limit'], ' W')}</strong></div><div><small>P-State</small><strong>${html(gpu.pstate)}</strong></div></div><div class="coverage-row" style="margin-top:22px"><div class="metric-line"><span>VRAM</span><strong>${total ? `${(used / 1024).toFixed(1)} / ${(total / 1024).toFixed(1)} GB` : '—'}</strong></div><div class="track purple-track"><span style="width:${total ? percent(used / total * 100) : 0}%"></span></div></div><div class="graphics-details"><span>${t('Treiber', 'Driver')}: ${html(gpu.driver_version)} · PCI: ${html(gpu['pci.bus_id'])}</span><span>UUID: ${html(gpu.uuid)}</span><span>Serial: ${html(gpu.serial)}</span><span>Clocks: ${gpuValue(gpu['clocks.current.graphics'], ' MHz')} · Memory: ${gpuValue(gpu['clocks.current.memory'], ' MHz')}</span><span>Fan: ${gpuValue(gpu['fan.speed'], '%')} · Compute: ${html(gpu.compute_mode)}</span></div></article>`;
|
||||||
|
}).join('') : `<div class="panel">${empty(data.error || t('Keine NVIDIA-GPU erkannt.', 'No NVIDIA GPU detected.'))}</div>`);
|
||||||
|
renderContent('graphicsProcesses', (data.processes || []).map(process => `<div class="graphics-process"><strong>${html(process.name)}</strong><span>PID ${html(process.pid)}</span><span>${html(process.memory)}</span></div>`).join('') || empty(t('Keine GPU-Prozesse erkannt.', 'No GPU processes detected.')));
|
||||||
|
}
|
||||||
|
|
||||||
|
let scanRunning = false;
|
||||||
|
function renderScanStatus(data) {
|
||||||
|
scanRunning = false;
|
||||||
|
for (const source of ['radarr', 'sonarr', 'missing']) {
|
||||||
|
const job = data[source] || {};
|
||||||
|
const node = $(`#${source}Status`);
|
||||||
|
if (job.state === 'running') scanRunning = true;
|
||||||
|
if (!node) continue;
|
||||||
|
node.className = `scan-status ${job.state || ''}`;
|
||||||
|
node.textContent = job.state === 'running' ? `${t('Scan läuft', 'Scanning')} · ${job.done}/${job.total || '?'} · ${job.total ? Math.round(job.done / job.total * 100) : 0}%` :
|
||||||
|
job.state === 'done' ? t('Scan abgeschlossen', 'Scan complete') : job.state === 'error' ? `${t('Scanfehler', 'Scan error')}: ${job.error}` : t('Scan bereit', 'Ready to scan');
|
||||||
|
}
|
||||||
|
$('#rescan').disabled = scanRunning;
|
||||||
|
$('#rescan').classList.toggle('is-scanning', scanRunning);
|
||||||
|
$('#rescan').setAttribute('aria-busy', String(scanRunning));
|
||||||
|
const label = $('#rescan span');
|
||||||
|
label.textContent = scanRunning ? t('Scan läuft …', 'Scanning …') : t('Neu scannen', 'Rescan');
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#rescan').addEventListener('click', async () => {
|
||||||
|
$('#rescan').disabled = true; $('#rescan').classList.add('is-scanning');
|
||||||
|
$('#rescan').setAttribute('aria-busy', 'true'); $('#actionError').classList.add('hidden');
|
||||||
|
try {
|
||||||
|
const data = await api(`/api/rescan?source=${encodeURIComponent($('#scanSource').value)}`, {method: 'POST'});
|
||||||
|
if (!data.ok) throw new Error(data.error || t('Scan konnte nicht gestartet werden.', 'Could not start scan.'));
|
||||||
|
renderScanStatus(await api('/api/scan-status'));
|
||||||
|
} catch (error) {
|
||||||
|
set('actionError', `${t('Scan fehlgeschlagen', 'Scan failed')}: ${error.message}`); $('#actionError').classList.remove('hidden');
|
||||||
|
} finally {
|
||||||
|
$('#rescan').disabled = scanRunning;
|
||||||
|
$('#rescan').classList.toggle('is-scanning', scanRunning);
|
||||||
|
$('#rescan').setAttribute('aria-busy', String(scanRunning));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderers = {system: renderSystem, services: renderServices, downloads: renderDownloads, datastore: renderDatastore, graphics: renderGraphics, scans: renderScanStatus};
|
||||||
|
function monitor(key, path, interval, onError) {
|
||||||
|
const update = async () => {
|
||||||
|
if (!document.hidden) {
|
||||||
|
try { const data = await api(path); snapshots[key] = data; renderers[key](data); }
|
||||||
|
catch (error) { onError?.(error); }
|
||||||
|
}
|
||||||
|
setTimeout(update, interval);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollLibrary() {
|
||||||
|
if (!document.hidden && !$('#settingsModal').open) {
|
||||||
|
try {
|
||||||
|
const data = await api('/api/library-status');
|
||||||
|
if (data.ready && (!config.libraryReady || Number(data.updated_at) > config.libraryUpdatedAt)) {
|
||||||
|
sessionStorage.setItem('dashboard-active-view', activeView);
|
||||||
|
location.reload(); return;
|
||||||
|
}
|
||||||
|
} catch (error) { /* Keep the current snapshot during a connection outage. */ }
|
||||||
|
}
|
||||||
|
setTimeout(pollLibrary, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
switchView(sessionStorage.getItem('dashboard-active-view') || 'dashboard');
|
||||||
|
translateLayout(); highlightPreferences();
|
||||||
|
monitor('system', '/api/system', 5000, () => set('systemUpdated', t('Nicht erreichbar', 'Unavailable')));
|
||||||
|
monitor('services', '/api/services', 30000, () => set('serviceSummary', t('Nicht erreichbar', 'Unavailable')));
|
||||||
|
monitor('datastore', '/api/datastore', 5000, () => renderDatastore({storage: {error: t('Datastore nicht erreichbar.', 'Datastore unavailable.')}, raid: {error: t('RAID-Status nicht verfügbar.', 'RAID status unavailable.')}}));
|
||||||
|
monitor('graphics', '/api/graphics', 5000, () => renderGraphics({error: t('GPU-Status nicht verfügbar.', 'GPU status unavailable.')}));
|
||||||
|
monitor('scans', '/api/scan-status', 1500);
|
||||||
|
if (config.sabEnabled) monitor('downloads', '/api/downloads', 4000, () => renderDownloads({error: t('SABnzbd nicht erreichbar.', 'SABnzbd unavailable.')}));
|
||||||
|
pollLibrary();
|
||||||
|
if ('serviceWorker' in navigator) window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js?v=10', {scope: '/'}).catch(() => {}));
|
||||||
@@ -9,12 +9,12 @@
|
|||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"display_override": ["window-controls-overlay", "standalone"],
|
"display_override": ["window-controls-overlay", "standalone"],
|
||||||
"prefer_related_applications": false,
|
"prefer_related_applications": false,
|
||||||
"background_color": "#151619",
|
"background_color": "#0c0d0f",
|
||||||
"theme_color": "#1f252d",
|
"theme_color": "#101113",
|
||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
"icons": [
|
"icons": [
|
||||||
{"src": "/static/media-mark-192.svg", "sizes": "192x192", "type": "image/svg+xml", "purpose": "any"},
|
{"src": "/static/media-mark-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"},
|
||||||
{"src": "/static/media-mark-512.svg", "sizes": "512x512", "type": "image/svg+xml", "purpose": "any"},
|
{"src": "/static/media-mark-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any"},
|
||||||
{"src": "/static/media-mark-512.svg", "sizes": "512x512", "type": "image/svg+xml", "purpose": "maskable"}
|
{"src": "/static/media-mark-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 803 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
+9
-3
@@ -1,5 +1,5 @@
|
|||||||
const CACHE_NAME = 'media-max-shell-v4';
|
const CACHE_NAME = 'media-max-shell-v10';
|
||||||
const SHELL = ['/static/media-mark.svg', '/static/media-mark-192.svg', '/static/media-mark-512.svg', '/static/manifest.json'];
|
const SHELL = ['/static/media-mark.svg', '/static/media-mark-192.png', '/static/media-mark-512.png', '/static/manifest.json', '/static/dashboard.css?v=10', '/static/dashboard.js?v=10'];
|
||||||
|
|
||||||
self.addEventListener('install', event => {
|
self.addEventListener('install', event => {
|
||||||
event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(SHELL)));
|
event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(SHELL)));
|
||||||
@@ -16,5 +16,11 @@ self.addEventListener('fetch', event => {
|
|||||||
if (request.method !== 'GET') return;
|
if (request.method !== 'GET') return;
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (url.pathname.startsWith('/api/')) return;
|
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}));
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
+148
-226
@@ -1,228 +1,150 @@
|
|||||||
|
{% macro icon(name, class='') -%}
|
||||||
|
<svg class="icon {{ class }}" aria-hidden="true"><use href="#icon-{{ name }}"/></svg>
|
||||||
|
{%- endmacro %}
|
||||||
|
{% macro tr(de, en) -%}<span data-de="{{ de }}" data-en="{{ en }}">{{ de }}</span>{%- endmacro %}
|
||||||
|
{% macro languages(items) -%}
|
||||||
|
<div class="badges">{% for l in items %}<span class="badge {{ 'unknown' if l.code == 'und' else '' }}" data-language="{{ l.name }}" title="{{ 'Quelle: Radarr/Sonarr-Dateimetadaten (nicht direkt bestätigt)' if l.source == 'arr' else 'Quelle: Mediendatei / Spurtitel' }}">{{ l.flag }} {{ l.name }}{% if l.source == 'arr' %}<sup>*</sup>{% endif %}</span>{% else %}<span class="muted">—</span>{% endfor %}</div>
|
||||||
|
{%- endmacro %}
|
||||||
|
{% macro media_table(items, id='', episode=false, review=false) -%}
|
||||||
|
<div class="table-scroll"><table class="media-table" {% if id %}id="{{ id }}"{% endif %}>
|
||||||
|
<thead><tr><th class="col-title">{{ tr('Episode' if episode else 'Film', 'Episode' if episode else 'Movie') }}</th><th>{{ tr('Qualität', 'Quality') }}</th><th>{{ tr('Status', 'Status') }}</th><th class="col-languages">Audio</th><th class="col-languages">{{ tr('Untertitel', 'Subtitles') }}</th><th>{{ tr('Video', 'Video') }}</th><th>{{ tr('Größe', 'Size') }}</th></tr></thead>
|
||||||
|
<tbody>{% for r in items %}
|
||||||
|
<tr data-title="{{ (r.title ~ ' ' ~ (r.year or ''))|lower }}" data-audio="{{ r.audio_languages|map(attribute='name')|join(',') }}" data-subs="{{ r.subtitle_languages|map(attribute='name')|join(',') }}" data-state="{{ r.state.label }}" data-season="{{ r.season if episode else '' }}">
|
||||||
|
<td><span class="title" title="{{ r.title }}">{{ r.title }}</span>{% if r.year %}<span class="year">{{ r.year }}</span>{% endif %}{% if r.error %}<span class="error-hint" title="{{ r.error }}">{{ icon('alert') }}</span>{% endif %}</td>
|
||||||
|
<td><span class="badge {{ 'quality' if not r.missing else '' }}">{{ r.quality }}</span></td>
|
||||||
|
<td><span class="state {{ r.state.class }}">{{ r.state.label }}</span></td>
|
||||||
|
<td>{{ languages(r.audio_languages) }}</td><td>{{ languages(r.subtitle_languages) }}</td><td class="muted">{{ r.video_codec }}</td><td class="nowrap">{{ r.size }}</td>
|
||||||
|
</tr>{% else %}<tr class="empty-row"><td colspan="7" class="empty-state">{{ tr('Keine Einträge vorhanden.', 'No entries available.') }}</td></tr>{% endfor %}</tbody>
|
||||||
|
</table></div>
|
||||||
|
{%- endmacro %}
|
||||||
|
{% macro filters(prefix, series=false) -%}
|
||||||
|
<div class="toolbar" data-filter="{{ prefix }}">
|
||||||
|
<label class="search-field">{{ icon('search') }}<input type="search" id="{{ prefix }}Search" aria-label="{{ 'Serie' if series else 'Film' }} suchen" data-placeholder-de="{{ 'Serie' if series else 'Film' }} suchen …" data-placeholder-en="Search {{ 'series' if series else 'movies' }} …" placeholder="{{ 'Serie' if series else 'Film' }} suchen …"></label>
|
||||||
|
<select id="{{ prefix }}Audio" aria-label="Audio-Sprache"><option value="" data-de="Alle Audio-Sprachen" data-en="All audio languages">Alle Audio-Sprachen</option>{% for name in language_names %}<option>{{ name }}</option>{% endfor %}</select>
|
||||||
|
<select id="{{ prefix }}Subs" aria-label="Untertitel-Sprache"><option value="" data-de="Alle Untertitel" data-en="All subtitles">Alle Untertitel</option>{% for name in language_names %}<option>{{ name }}</option>{% endfor %}</select>
|
||||||
|
<select id="{{ prefix }}State" aria-label="Status"><option value="" data-de="Alle Status" data-en="All statuses">Alle Status</option>{% for name in ['DE + EN', 'Deutsch', 'English only', 'Andere', 'Sprache prüfen', 'Scan ausstehend', 'Fehler', 'Downloading'] %}<option>{{ name }}</option>{% endfor %}</select>
|
||||||
|
</div>
|
||||||
|
{%- endmacro %}
|
||||||
|
{% macro nav_button(view, symbol, de, en, count_id='') -%}
|
||||||
|
<button type="button" data-view="{{ view }}" {{ 'class=active' if view == 'dashboard' else '' }}>{{ icon(symbol) }}{{ tr(de, en) }}{% if count_id %}<span id="{{ count_id }}" class="nav-count">0</span>{% endif %}</button>
|
||||||
|
{%- endmacro %}
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<style>
|
<html lang="de">
|
||||||
#ioPressurePanel{max-width:none;margin:0 0 12px;padding:14px}.io-pressure-bars{display:grid;gap:10px}.io-pressure-bars>div{display:grid;grid-template-columns:42px 48px 1fr;align-items:center;gap:8px;color:#9da5b2;font-size:11px}.io-pressure-bars b{color:#e8eef5;text-align:right}.io-pressure-bars .datastore-progress{height:8px}.datastore-grid>.datastore-panel>.datastore-capacity{margin:0 0 12px;padding:14px}.datastore-grid>.raid-panel>#ioPressurePanel{margin:0 0 12px}@media(max-width:900px){ #ioPressurePanel{max-width:none}}
|
<head>
|
||||||
</style>
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<script>
|
<meta name="theme-color" content="#101113"><meta name="mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
document.addEventListener('DOMContentLoaded',()=>{const root=document.querySelector('#datastore');if(!root)return;const grid=root.querySelector('.datastore-grid');if(grid&&!document.querySelector('#ioPressurePanel')){const panel=document.createElement('section');panel.id='ioPressurePanel';panel.className='datastore-panel io-pressure-panel';panel.innerHTML='<div class="datastore-panel-head"><div><h3>I/O-Auslastung</h3><p>Festplatten-Druck aus Linux PSI</p></div><strong id="ioPressureLabel">Wird geladen …</strong></div><div class="io-pressure-bars"><div><span>Some</span><b id="ioPressureSome">—</b><div class="datastore-progress"><span id="ioPressureSomeBar"></span></div></div><div><span>Full</span><b id="ioPressureFull">—</b><div class="datastore-progress"><span id="ioPressureFullBar"></span></div></div></div>';grid.before(panel)}const pressure=async()=>{try{const data=await (await fetch('/api/datastore')).json();const io=data.io_pressure||{};const some=Number(io.some?.avg10)||0,full=Number(io.full?.avg10)||0;const level=Math.max(some,full),label=level>=20?'Hoch':level>=5?'Beobachten':'Normal';const set=(id,value)=>{const node=document.querySelector(id);if(node)node.textContent=value};set('#ioPressureLabel',io.available?`${label} · ${level.toFixed(1)}%`:'Nicht verfügbar');set('#ioPressureSome',`${some.toFixed(1)}%`);set('#ioPressureFull',`${full.toFixed(1)}%`);const a=document.querySelector('#ioPressureSomeBar'),b=document.querySelector('#ioPressureFullBar');if(a)a.style.width=`${Math.min(100,some)}%`;if(b)b.style.width=`${Math.min(100,full)}%`}catch(error){}};pressure();setInterval(pressure,5000);const disks=document.querySelector('#raidDisks');if(disks){const observer=new MutationObserver(()=>{if([...disks.querySelectorAll('.raid-disk')].some(row=>!row.querySelector('.raid-temperature')))setTimeout(pressure,0)});observer.observe(disks,{childList:true,subtree:true})}});
|
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}"><link rel="apple-touch-icon" href="{{ url_for('static', filename='media-mark-192.png') }}"><link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='media-mark.svg') }}">
|
||||||
</script>
|
<link rel="stylesheet" href="{{ url_for('static', filename='dashboard.css') }}?v=10"><title>Media Max</title>
|
||||||
<script>
|
<script src="{{ url_for('static', filename='dashboard.js') }}?v=10" defer></script>
|
||||||
document.addEventListener('DOMContentLoaded',()=>{const root=document.querySelector('#datastore'),grid=root?.querySelector('.datastore-grid'),capacity=root?.querySelector('.datastore-capacity'),pressure=root?.querySelector('#ioPressurePanel');if(!grid||!capacity||!pressure)return;const categories=grid.children[0],raid=grid.children[1];if(categories&&!categories.contains(capacity))categories.prepend(capacity);if(raid&&!raid.contains(pressure))raid.prepend(pressure)});
|
</head>
|
||||||
</script>
|
<body>
|
||||||
<script>
|
<svg class="svg-definitions" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
if ('serviceWorker' in navigator) {
|
<symbol id="icon-dashboard" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></symbol>
|
||||||
window.addEventListener('load', function () {
|
<symbol id="icon-movie" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M8 4l3 5m4-5 3 5"/></symbol>
|
||||||
navigator.serviceWorker.register('/sw.js?v=4', {scope: '/'}).catch(function () {});
|
<symbol id="icon-series" viewBox="0 0 24 24"><rect x="3" y="6" width="18" height="14" rx="2"/><path d="m8 2 4 4 4-4M8 23h8"/></symbol>
|
||||||
});
|
<symbol id="icon-alert" viewBox="0 0 24 24"><path d="m10.3 4-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3l-8-14a2 2 0 0 0-3.4 0Z"/><path d="M12 9v4m0 4h.01"/></symbol>
|
||||||
}
|
<symbol id="icon-download" viewBox="0 0 24 24"><path d="M12 3v12m-5-5 5 5 5-5M4 15v5h16v-5"/></symbol>
|
||||||
</script>
|
<symbol id="icon-disk" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="12" cy="10" r="4"/><path d="m12 10 5 5M6 18h.01"/></symbol>
|
||||||
<style>@media(max-width:700px){header .nav,main>.nav{display:none!important}#movies td:nth-child(4) .badge:nth-child(n+2),#movies td:nth-child(5) .badge:nth-child(n+2),#sonarr td:nth-child(4) .badge:nth-child(n+2),#sonarr td:nth-child(5) .badge:nth-child(n+2),#missing td:nth-child(4) .badge:nth-child(n+2),#missing td:nth-child(5) .badge:nth-child(n+2){display:none!important}.year{display:none!important}.title{max-width:100%!important}}</style>
|
<symbol id="icon-chip" viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14" rx="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M9 1v4m6-4v4M9 19v4m6-4v4M1 9h4m-4 6h4m14-6h4m-4 6h4"/></symbol>
|
||||||
<script>document.addEventListener('DOMContentLoaded',()=>{const applyPreferred=()=>{const audio=localStorage.getItem('preferred-audio')||'',subs=localStorage.getItem('preferred-subs')||'';document.querySelectorAll('#movies tbody tr,#sonarr tbody tr,#missing tbody tr').forEach(row=>{[[4,audio,'audio-preferred'],[5,subs,'subtitle-preferred']].forEach(([column,preference,className])=>{const badges=[...row.querySelectorAll(`td:nth-child(${column}) .badge`)];if(!badges.length)return;badges.forEach(badge=>badge.classList.remove(className));const preferred=preference?badges.find(badge=>badge.textContent.includes(preference)):null;if(preferred){preferred.classList.add(className);preferred.parentElement.prepend(preferred)}})})};applyPreferred();setInterval(applyPreferred,1000)});</script>
|
<symbol id="icon-settings" viewBox="0 0 24 24"><path d="m9 3-1 3-3 1-2 5 2 5 3 1 1 3h6l1-3 3-1 2-5-2-5-3-1-1-3Z"/><circle cx="12" cy="12" r="3"/></symbol>
|
||||||
<script>document.addEventListener('DOMContentLoaded',()=>{const nav=document.querySelector('.nav');if(nav)nav.classList.add('legacy-mobile-nav');const closeAll=()=>{document.body.classList.remove('mobile-nav-open');document.querySelectorAll('.mobile-drawer-clone,.mobile-drawer-shade,.mobile-nav-backdrop').forEach(node=>node.classList.remove('open'));document.querySelectorAll('.nav').forEach(node=>node.classList.remove('mobile-drawer-open'))};document.addEventListener('click',event=>{if(!window.matchMedia('(max-width:700px)').matches||!document.body.classList.contains('mobile-nav-open'))return;if(!event.target.closest('.mobile-drawer-clone,.mobile-nav,.brand'))closeAll()});document.addEventListener('keydown',event=>{if(event.key==='Escape')closeAll()});window.addEventListener('resize',()=>{if(!window.matchMedia('(max-width:700px)').matches)closeAll()})});</script>
|
<symbol id="icon-search" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6"/><path d="m15 15 6 6"/></symbol>
|
||||||
<style>@media(max-width:700px){.legacy-mobile-nav{display:none!important}.mobile-nav-backdrop,.mobile-drawer-shade{pointer-events:auto!important}}</style>
|
<symbol id="icon-refresh" viewBox="0 0 24 24"><path d="M20 8a8 8 0 1 0 0 8M20 3v5h-5"/></symbol>
|
||||||
<script>document.addEventListener('DOMContentLoaded',()=>{const style=document.createElement('style');style.textContent='@media(max-width:700px){ #movies,#sonarr table,#missing table{min-width:0!important;width:100%!important;table-layout:fixed!important}.tablewrap{overflow-x:hidden!important}.badges{max-width:100%!important;display:flex!important;flex-wrap:nowrap!important;overflow:hidden!important;max-height:22px!important}.badge{flex:0 1 auto!important;max-width:100%!important;overflow:hidden!important;text-overflow:ellipsis!important;font-size:9px!important;padding:2px 4px!important}.title{max-width:100%!important;overflow:hidden!important;text-overflow:ellipsis!important;white-space:nowrap!important}#movies th:nth-child(1),#sonarr th:nth-child(1),#missing th:nth-child(1){width:42%!important}#movies th:nth-child(2),#sonarr th:nth-child(2),#missing th:nth-child(2){width:19%!important}#movies th:nth-child(4),#movies th:nth-child(5),#sonarr th:nth-child(4),#sonarr th:nth-child(5),#missing th:nth-child(4),#missing th:nth-child(5){width:19.5%!important}#movies td,#sonarr td,#missing td{padding:7px 4px!important;font-size:10px!important}}';document.head.appendChild(style)});</script>
|
<symbol id="icon-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
|
||||||
<script>document.addEventListener('DOMContentLoaded',()=>{const source=document.querySelector('.nav'),brand=document.querySelector('.brand');if(!source||!brand)return;const drawer=document.createElement('aside');drawer.className='mobile-drawer-clone';drawer.innerHTML=source.innerHTML;drawer.querySelector('.mobile-nav-close')?.remove();const close=document.createElement('button');close.className='mobile-drawer-close';close.type='button';close.textContent='×';close.setAttribute('aria-label','Navigation schließen');drawer.prepend(close);const shade=document.createElement('div');shade.className='mobile-drawer-shade';document.body.append(shade,drawer);const closeDrawer=()=>{drawer.classList.remove('open');shade.classList.remove('open');document.body.classList.remove('mobile-nav-open')};const openDrawer=()=>{if(window.matchMedia('(max-width:700px)').matches){drawer.classList.add('open');shade.classList.add('open');document.body.classList.add('mobile-nav-open')}};brand.addEventListener('click',openDrawer);shade.addEventListener('click',closeDrawer);close.addEventListener('click',closeDrawer);drawer.addEventListener('click',event=>{const button=event.target.closest('button[data-view]');if(!button)return;source.querySelector(`button[data-view="${button.dataset.view}"]`)?.click();closeDrawer()})});</script>
|
<symbol id="icon-close" viewBox="0 0 24 24"><path d="m6 6 12 12M6 18 18 6"/></symbol>
|
||||||
<style>.mobile-drawer-clone,.mobile-drawer-shade{display:none}@media(max-width:700px){.mobile-drawer-shade{display:none;position:fixed!important;inset:0!important;z-index:1900!important;background:rgba(0,0,0,.62)!important;backdrop-filter:blur(3px)}.mobile-drawer-shade.open{display:block!important}.mobile-drawer-clone{display:flex!important;position:fixed!important;visibility:visible!important;opacity:1!important;top:0!important;left:0!important;bottom:0!important;width:min(84vw,320px)!important;z-index:2000!important;flex-direction:column!important;gap:5px!important;padding:62px 12px 18px!important;margin:0!important;overflow-y:auto!important;overflow-x:hidden!important;background:#1c2027!important;border-right:1px solid #465160!important;box-shadow:16px 0 45px rgba(0,0,0,.5)!important;transform:translate3d(-110%,0,0)!important;transition:transform .24s ease!important}.mobile-drawer-clone.open{transform:translate3d(0,0,0)!important}.mobile-drawer-clone button{display:block!important;flex:0 0 auto!important;width:100%!important;min-height:42px!important;padding:12px 13px!important;text-align:left!important;border:0!important;border-radius:8px!important;background:transparent!important;color:#c7d0dc!important;font-size:13px!important;white-space:nowrap!important}.mobile-drawer-clone button.active{background:#303b49!important;color:#fff!important}.mobile-drawer-clone .mobile-drawer-close{position:absolute!important;top:14px!important;right:12px!important;width:34px!important;min-height:34px!important;height:34px!important;padding:0!important;border:1px solid #4a5563!important;background:#292f38!important;color:#dce8f3!important;text-align:center!important;font-size:23px!important;line-height:1!important}.mobile-drawer-clone img{width:15px!important;height:15px!important;margin-right:6px!important;vertical-align:-3px!important;object-fit:contain!important}}</style>
|
<symbol id="icon-activity" viewBox="0 0 24 24"><path d="M2 12h5l3-8 4 16 3-8h5"/></symbol>
|
||||||
<script>document.addEventListener('DOMContentLoaded',()=>{const close=document.querySelector('.mobile-nav-close');if(close){close.style.fontSize='23px';close.style.padding='0'}});</script>
|
<symbol id="icon-server" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="7" rx="2"/><rect x="3" y="14" width="18" height="7" rx="2"/><path d="M7 6.5h.01M7 17.5h.01M12 6.5h5M12 17.5h5"/></symbol>
|
||||||
<script>document.addEventListener('DOMContentLoaded',()=>{const brand=document.querySelector('.brand'),nav=document.querySelector('.nav');if(!brand||!nav)return;const backdrop=document.createElement('div');backdrop.className='mobile-nav-backdrop';const close=document.createElement('button');close.className='mobile-nav-close';close.type='button';close.setAttribute('aria-label','Navigation schließen');close.textContent='×';nav.prepend(close);document.body.appendChild(backdrop);const drawerStyle=document.createElement('style');drawerStyle.textContent='@media(max-width:700px){.nav.mobile-drawer-open{position:fixed!important;display:flex!important;visibility:visible!important;opacity:1!important;top:0!important;left:0!important;bottom:0!important;width:min(82vw,310px)!important;max-width:none!important;overflow-y:auto!important;overflow-x:hidden!important;transform:translate3d(0,0,0)!important;z-index:1000!important}.nav:not(.mobile-drawer-open){transform:translate3d(-105%,0,0)!important}}';document.head.appendChild(drawerStyle);const closeNav=()=>{document.body.classList.remove('mobile-nav-open');nav.classList.remove('mobile-drawer-open')};const openNav=()=>{if(window.matchMedia('(max-width: 700px)').matches){document.body.classList.add('mobile-nav-open');nav.classList.add('mobile-drawer-open')}};brand.addEventListener('click',openNav);backdrop.addEventListener('click',closeNav);close.addEventListener('click',closeNav);nav.addEventListener('click',event=>{if(event.target.closest('button[data-view]'))closeNav()})});</script>
|
<symbol id="icon-check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></symbol>
|
||||||
<style>.mobile-nav-close,.mobile-nav-backdrop{display:none}@media(max-width:700px){.brand{cursor:pointer}.mobile-nav-backdrop{position:fixed;inset:0;z-index:130;background:rgba(0,0,0,.58);backdrop-filter:blur(2px)}body.mobile-nav-open .mobile-nav-backdrop{display:block}.nav{position:fixed;top:0;bottom:0;left:0;z-index:140;width:min(82vw,310px);margin:0!important;padding:62px 12px 18px!important;display:flex;flex-direction:column;align-items:stretch;gap:5px;overflow-y:auto;border:0!important;border-right:1px solid #3c4653!important;border-radius:0!important;background:rgba(28,32,39,.98)!important;box-shadow:14px 0 40px rgba(0,0,0,.35);transform:translateX(-105%);transition:transform .24s ease}.nav button{flex:0 0 auto!important;text-align:left;padding:12px 13px!important;border-radius:8px!important;font-size:13px!important}.nav button.active{background:#303b49}.mobile-nav-close{display:block;position:absolute;top:14px;right:12px;width:34px;height:34px;padding:0;border:1px solid #4a5563;border-radius:8px;background:#292f38;color:#dce8f3;font-size:23px;line-height:1}.mobile-nav-open .nav{transform:translateX(0)}body.mobile-nav-open{overflow:hidden}#movies,#sonarr table,#missing table{min-width:0;width:100%;table-layout:fixed}#movies th:nth-child(3),#movies td:nth-child(3),#movies th:nth-child(6),#movies td:nth-child(6),#movies th:nth-child(7),#movies td:nth-child(7),#sonarr th:nth-child(3),#sonarr td:nth-child(3),#sonarr th:nth-child(6),#sonarr td:nth-child(6),#sonarr th:nth-child(7),#sonarr td:nth-child(7),#missing th:nth-child(3),#missing td:nth-child(3),#missing th:nth-child(6),#missing td:nth-child(6),#missing th:nth-child(7),#missing td:nth-child(7){display:none}#movies th:nth-child(1),#sonarr th:nth-child(1),#missing th:nth-child(1){width:46%}#movies th:nth-child(2),#sonarr th:nth-child(2),#missing th:nth-child(2){width:18%}#movies th:nth-child(4),#movies th:nth-child(5),#sonarr th:nth-child(4),#sonarr th:nth-child(5),#missing th:nth-child(4),#missing th:nth-child(5){width:18%}#movies td,#sonarr td,#missing td{padding:8px 6px;font-size:11px}.badges{gap:3px}.badge{padding:2px 5px;font-size:10px}.title{max-width:100%}.tablewrap{overflow-x:hidden}.series-category>.series table{min-width:0!important}}</style>
|
<symbol id="icon-language" viewBox="0 0 24 24"><path d="M3 5h12M9 2v3M5 5c1 5 4 8 8 10M4 15c4-2 7-5 8-10m2 16 4-10 4 10m-6-4h4"/></symbol>
|
||||||
<script>document.addEventListener('DOMContentLoaded',()=>{const panel=document.querySelector('.system-panel'),diskList=document.querySelector('#diskList');if(!panel||!diskList)return;const extra=document.createElement('div');extra.id='systemDetails';extra.className='system-details';diskList.before(extra);const formatUptime=seconds=>{const days=Math.floor(seconds/86400),hours=Math.floor(seconds%86400/3600),minutes=Math.floor(seconds%3600/60);return `${days?days+' T ':''}${hours} Std. ${minutes} Min.`};const update=async()=>{try{const data=await (await fetch('/api/system')).json();const load=data.load||[];extra.innerHTML=`<div class="system-detail"><span class="system-detail-icon"><img src="https://api.iconify.design/mdi:chart-line.svg?color=%2360a9e6" alt="" aria-hidden="true"></span><div><small>Load Average</small><strong>${load.map(value=>Number(value).toFixed(2)).join(' · ')||'—'}</strong><em>1 / 5 / 15 Minuten</em></div></div><div class="system-detail"><span class="system-detail-icon"><img src="https://api.iconify.design/mdi:clock-outline.svg?color=%23d7adf3" alt="" aria-hidden="true"></span><div><small>Uptime</small><strong>${formatUptime(Number(data.uptime_seconds)||0)}</strong><em>Container-Laufzeit</em></div></div><div class="system-detail"><span class="system-detail-icon"><img src="https://api.iconify.design/mdi:application-cog-outline.svg?color=%23f4d267" alt="" aria-hidden="true"></span><div><small>Prozesse</small><strong>${Number(data.processes||0).toLocaleString('de-DE')}</strong><em>Aktive Prozesse</em></div></div>`}catch(error){extra.innerHTML='<div class="system-detail muted">Systemdetails momentan nicht verfügbar</div>'}};update();setInterval(update,5000)});</script>
|
</svg>
|
||||||
<style>.system-details{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:2px 0 17px}.system-detail{display:flex;align-items:center;gap:9px;padding:10px 11px;border:1px solid rgba(255,255,255,.07);border-radius:9px;background:rgba(40,43,49,.58)}.system-detail-icon{display:grid;place-items:center;flex:0 0 auto;width:28px;height:28px;border-radius:8px;background:rgba(96,169,230,.14)}.system-detail-icon img{width:17px;height:17px}.system-detail small,.system-detail em{display:block}.system-detail small{color:#9da5b2;font-size:10px}.system-detail strong{display:block;margin-top:2px;color:#f1f5fa;font-size:13px}.system-detail em{margin-top:2px;color:#7f8996;font-size:9px;font-style:normal}@media(max-width:700px){.system-details{grid-template-columns:1fr 1fr}.system-detail:last-child{grid-column:1 / -1}}</style>
|
<a class="skip-link" href="#main">Zum Inhalt</a>
|
||||||
<script>document.addEventListener('DOMContentLoaded',()=>{const icons={overview:['mdi:movie-open-outline','mdi:television-classic','mdi:alert-outline','mdi:download-box-outline'],insight:['mdi:movie-check-outline','mdi:playlist-check','mdi:database-check-outline','mdi:monitor-eye']};const apply=(selector,keys)=>{const colors=selector.includes('overview')?['%238bc9f2','%23d7adf3','%23ff9e9e','%236ae29a']:['%238bc9f2','%23d7adf3','%23f4d267','%236ae29a'];document.querySelectorAll(selector).forEach((node,index)=>{const key=keys[index%keys.length];if(!key||node.dataset.iconApplied===key)return;const image=document.createElement('img');image.src=`https://api.iconify.design/${key}.svg?color=${colors[index%colors.length]}`;image.alt='';image.setAttribute('aria-hidden','true');node.textContent='';node.appendChild(image);node.dataset.iconApplied=key})};const update=()=>{apply('.overview-icon',icons.overview);apply('.insight-icon',icons.insight)};update();new MutationObserver(update).observe(document.body,{childList:true,subtree:true})});</script>
|
<button id="sidebarShade" class="sidebar-shade" aria-label="Navigation schließen" tabindex="-1"></button>
|
||||||
<style>.overview-icon img{width:23px;height:23px;object-fit:contain}.insight-icon img{width:17px;height:17px;object-fit:contain}</style>
|
<aside class="sidebar" id="sidebar" aria-label="Hauptnavigation">
|
||||||
<script>document.addEventListener('DOMContentLoaded',()=>{const badge=document.querySelector('#raidStatusBadge');if(!badge)return;const side=document.createElement('div');side.className='raid-status-side';const countdown=document.createElement('small');countdown.id='raidRefreshCountdown';countdown.textContent='Update in 5s';badge.replaceWith(side);side.append(badge,countdown);const interval=5000;let next=Date.now()+interval;const reset=()=>{next=Date.now()+interval};window.resetRaidRefreshCountdown=reset;const update=()=>{const seconds=Math.max(0,Math.ceil((next-Date.now())/1000));countdown.textContent=`Update in ${seconds}s`;if(seconds===0)reset()};update();setInterval(update,250);const updated=document.querySelector('#datastoreUpdated');if(updated)new MutationObserver(reset).observe(updated,{childList:true,characterData:true,subtree:true})});</script>
|
<a class="brand" href="/">{{ icon('activity', 'brand-icon') }}<span>Media<span class="brand-accent"> Max</span></span></a>
|
||||||
<script>document.addEventListener('DOMContentLoaded',()=>{const icons={total:'mdi:harddisk',used:'mdi:database',free:'mdi:database-outline',percent:'mdi:chart-donut'};document.querySelectorAll('.storage-card .storage-icon').forEach((node,index)=>{const key=['total','used','free','percent'][index];if(!key)return;const image=document.createElement('img');image.src=`https://api.iconify.design/${icons[key]}.svg?color=${key==='used'?'%23f4d267':key==='free'?'%236ae29a':key==='percent'?'%23c59cff':'%2360a9e6'}`;image.alt='';image.setAttribute('aria-hidden','true');node.textContent='';node.appendChild(image)})});</script>
|
<div class="workspace">{{ icon('server') }}<div><strong>Media Stack</strong><small>{{ tr('Bibliothek & Monitoring', 'Library & monitoring') }}</small></div><span class="status-dot"></span></div>
|
||||||
<script>document.addEventListener('DOMContentLoaded',()=>{if(!document.querySelector('link[rel="shortcut icon"]')){const favicon=document.createElement('link');favicon.rel='shortcut icon';favicon.type='image/svg+xml';favicon.href="{{ url_for('static', filename='media-mark.svg') }}?v=media-max";document.head.appendChild(favicon)}});</script>
|
<nav class="nav">
|
||||||
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="theme-color" content="#1f252d"><meta name="mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-capable" content="yes"><link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}"><link rel="apple-touch-icon" href="{{ url_for('static', filename='media-mark.svg') }}"><link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='media-mark.svg') }}"><title>Media Max</title>
|
<div class="nav-label">{{ tr('Übersicht', 'Overview') }}</div>
|
||||||
<style>
|
{{ nav_button('dashboard', 'dashboard', 'Dashboard', 'Dashboard') }}
|
||||||
:root{color-scheme:dark;--bg:#181818;--panel:#222;--line:#3a3a3a;--text:#dedede;--muted:#929292;--yellow:#f4c430;--blue:#60a9e6}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:13px Inter,system-ui,sans-serif}header{height:58px;display:flex;align-items:center;gap:15px;padding:0 20px;background:#242424;border-bottom:1px solid #343434}.logo{color:var(--yellow);font-size:22px;font-weight:900;letter-spacing:1px}.head-title{font-weight:700}.head-sub{font-size:12px;color:var(--muted);margin-top:2px}.actions{margin-left:auto}button,input,select{font:inherit;color:var(--text);background:#292929;border:1px solid #444;border-radius:5px}button{padding:7px 10px;cursor:pointer}main{padding:14px 20px 30px}.nav{display:flex;gap:4px;margin-bottom:12px;border-bottom:1px solid var(--line)}.nav button{border:0;border-radius:5px 5px 0 0;background:transparent;color:#aaa}.nav button.active{background:#303030;color:#fff}.view{display:none}.view.active{display:block}.toolbar{display:grid;grid-template-columns:minmax(280px,1fr) 190px 190px 170px;gap:8px;margin-bottom:10px}input,select{padding:7px 10px;height:34px}.stats{display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap}.stat{background:#242424;border:1px solid #363636;padding:5px 9px;border-radius:5px;color:#bbb}.scan{color:#f4c430}.banner{padding:9px 11px;border:1px solid #693b3b;background:#372424;border-radius:5px;margin-bottom:10px;color:#ffd1d1}.tablewrap{border:1px solid #353535;border-radius:6px;overflow:hidden;background:var(--panel)}table{width:100%;border-collapse:collapse;table-layout:fixed}th{padding:8px 10px;background:#282828;border-bottom:1px solid #444;text-align:left;font-size:12px}td{padding:7px 10px;border-bottom:1px solid #343434;vertical-align:middle}.col-title{width:28%}.col-quality{width:12%}.col-state{width:10%}.col-audio{width:17%}.col-subs{width:17%}.col-video{width:8%}.col-size{width:8%}.title{color:var(--blue);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:inline-block;max-width:90%;vertical-align:bottom}.year{color:var(--muted);font-size:11px;margin-left:5px}.badges{display:flex;gap:4px;flex-wrap:wrap}.badge{display:inline-flex;gap:4px;align-items:center;padding:2px 6px;border-radius:999px;border:1px solid #484848;background:#303030;white-space:nowrap;font-size:11px}.quality{background:#0e7138;border-color:#19894b}.state{font-size:11px;font-weight:700;padding:3px 7px;border-radius:4px;display:inline-block}.state.ok{background:#126f39;color:#d9ffe8}.state.warn{background:#77561e;color:#ffebc4}.state.bad{background:#763030;color:#ffd9d9}.state.neutral{background:#3b3b3b;color:#ccc}.muted{color:var(--muted)}.hidden{display:none}.series{border-bottom:1px solid var(--line)}.series:last-child{border:0}.series summary{padding:12px;cursor:pointer;background:#282828;font-weight:700}.series summary:hover{background:#303030}.series table{border-radius:0}.series td:first-child{padding-left:28px}@media(max-width:1100px){.toolbar{grid-template-columns:1fr 1fr}.col-video,.col-size{display:none}}
|
<div class="nav-label">{{ tr('Bibliothek', 'Library') }}</div>
|
||||||
.brand-mark{width:34px;height:34px;display:grid;place-items:center;filter:drop-shadow(0 0 10px rgba(96,169,230,.24))}.brand-mark svg{width:34px;height:34px}.brand-name{color:var(--yellow);font-size:16px;font-weight:900;letter-spacing:1.5px}.brand{display:flex;align-items:center;gap:9px}
|
{{ nav_button('radarr', 'movie', 'Filme', 'Movies') }}
|
||||||
.dashboard-hero{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:14px;padding:26px;border:1px solid rgba(96,169,230,.2);border-radius:14px;background:radial-gradient(circle at 85% 15%,rgba(96,169,230,.2),transparent 34%),linear-gradient(135deg,rgba(39,48,61,.96),rgba(27,30,36,.96));box-shadow:0 16px 40px rgba(0,0,0,.22)}.dashboard-hero h1{margin:7px 0 4px;font-size:25px}.dashboard-hero p{margin:0;color:#9da5b2}.live-pill{display:flex;align-items:center;gap:8px;padding:9px 14px;border-radius:999px;background:rgba(21,148,71,.17);border:1px solid rgba(106,226,154,.25);color:#aaf2c5;font-weight:700}.live-dot{width:8px;height:8px;border-radius:50%;background:#6ae29a;box-shadow:0 0 12px #6ae29a}.overview-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:14px}.overview-card{display:flex;align-items:center;gap:12px;min-height:104px;padding:17px;border:1px solid rgba(255,255,255,.08);border-radius:11px;background:linear-gradient(145deg,rgba(43,47,55,.92),rgba(29,31,36,.92));box-shadow:0 8px 22px rgba(0,0,0,.16)}.overview-icon{display:grid;place-items:center;width:40px;height:40px;border-radius:11px;background:rgba(96,169,230,.17);color:#8bc9f2;font-size:23px;font-weight:700}.overview-icon.series-icon{background:rgba(146,87,194,.2);color:#d7adf3}.overview-icon.warning{background:rgba(188,70,70,.2);color:#ff9e9e}.overview-icon.download-icon{background:rgba(21,148,71,.2);color:#6ae29a}.overview-card div{min-width:0}.overview-card span:not(.overview-icon){display:block;color:#9da5b2;font-size:11px;text-transform:uppercase;letter-spacing:.06em}.overview-card strong{display:block;margin:3px 0;font-size:22px;color:#f1f5fa}.overview-card small{display:block;color:#7f8996;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dashboard-columns{display:grid;grid-template-columns:1.45fr 1fr;gap:14px}.system-panel,.service-panel{padding:19px;border:1px solid rgba(255,255,255,.08);border-radius:12px;background:rgba(31,33,38,.84);box-shadow:0 12px 30px rgba(0,0,0,.18)}.panel-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:15px;margin-bottom:17px}.panel-heading h2{margin:0 0 4px;font-size:16px}.panel-heading p{margin:0;color:#7f8996;font-size:12px}.system-meters{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px}.meter-top{display:flex;justify-content:space-between;margin-bottom:7px;color:#cbd1db}.meter-top strong{color:#fff}.meter-track{height:9px;background:#363b44;border-radius:99px;overflow:hidden}.meter-track span{display:block;width:0;height:100%;border-radius:99px;background:linear-gradient(90deg,#60a9e6,#159447);transition:width .45s ease}.disk-list{display:grid;gap:12px}.disk-row{display:grid;grid-template-columns:92px 1fr auto;align-items:center;gap:10px}.disk-name{color:#cbd1db;font-weight:700}.disk-path{display:block;color:#7f8996;font-size:11px;font-weight:400}.disk-bar{height:7px;background:#363b44;border-radius:99px;overflow:hidden}.disk-bar span{display:block;height:100%;border-radius:99px;background:linear-gradient(90deg,#60a9e6,#f4c430);transition:width .45s ease}.disk-values{text-align:right;color:#cbd1db;font-size:11px}.service-row{display:flex;align-items:center;gap:11px;padding:12px 0;border-bottom:1px solid rgba(255,255,255,.07)}.service-row:last-child{border-bottom:0}.service-logo{display:grid;place-items:center;width:34px;height:34px;border-radius:9px;color:#fff;font-weight:800}.radarr-dot{background:#1d7e45}.sonarr-dot{background:#4d65b1}.sab-dot{background:#9a6830}.service-row div{flex:1}.service-row strong,.service-row small{display:block}.service-row small{margin-top:2px;color:#7f8996;font-size:11px}.service-status{padding:4px 8px;border-radius:999px;font-size:11px;font-weight:700}.service-status.online{background:#126f39;color:#d9ffe8}.service-status.offline{background:#3b3b3b;color:#aaa}
|
{% if sonarr_enabled %}{{ nav_button('sonarr', 'series', 'Serien', 'Series') }}{% endif %}
|
||||||
.dashboard-view{flex-direction:column}.dashboard-view .dashboard-columns{align-items:stretch}.dashboard-view .system-panel,.dashboard-view .service-panel{min-height:0}.dashboard-view .disk-list{gap:18px}.dashboard-view .disk-row{min-height:38px}.dashboard-insights{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:14px}.insight-card{display:flex;align-items:center;gap:11px;padding:13px 15px;border:1px solid rgba(255,255,255,.08);border-radius:10px;background:rgba(31,33,38,.84)}.insight-icon{display:grid;place-items:center;width:32px;height:32px;border-radius:8px;background:rgba(96,169,230,.16);color:#8bc9f2;font-size:16px}.insight-card:nth-child(2) .insight-icon{background:rgba(146,87,194,.18);color:#d7adf3}.insight-card:nth-child(3) .insight-icon{background:rgba(244,196,48,.16);color:#f4c430}.insight-card:nth-child(4) .insight-icon{background:rgba(21,148,71,.18);color:#6ae29a}.insight-card strong,.insight-card small{display:block}.insight-card strong{font-size:14px;color:#f1f5fa}.insight-card small{margin-top:3px;color:#7f8996;font-size:10px}@media(max-width:1000px){.dashboard-insights{grid-template-columns:repeat(2,1fr)}}
|
{{ nav_button('missing', 'language', 'Ohne Deutsch', 'Without German', 'missingNavCount') }}
|
||||||
@media(max-width:1000px){.overview-grid{grid-template-columns:repeat(2,1fr)}.dashboard-columns{grid-template-columns:1fr}}@media(max-width:600px){.overview-grid,.system-meters{grid-template-columns:1fr}.dashboard-hero{align-items:flex-start;flex-direction:column;padding:20px}.live-pill{align-self:stretch;justify-content:center}.disk-row{grid-template-columns:78px 1fr auto}}
|
{% if sab_enabled %}{{ nav_button('downloads', 'download', 'Downloads', 'Downloads', 'downloadNavCount') }}{% endif %}
|
||||||
.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}
|
<div class="nav-label">{{ tr('System', 'System') }}</div>
|
||||||
@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}}
|
{{ nav_button('datastore', 'disk', 'Speicher & RAID', 'Storage & RAID') }}
|
||||||
@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}}
|
{{ nav_button('graphics', 'chip', 'Graphics', 'Graphics') }}
|
||||||
</style><style>.scan-control{display:flex;align-items:center;gap:5px}.scan-control select{height:36px;padding:0 8px;font-size:11px}.scan-control button{white-space:nowrap}.scan-running{position:relative;overflow:hidden;background:linear-gradient(90deg,rgba(244,196,48,.14) var(--scan-progress,0%),rgba(244,196,48,.04) var(--scan-progress,0%))!important}.scan-running::after{content:'';position:absolute;left:0;bottom:0;width:var(--scan-progress,0%);height:2px;background:#f4c430;transition:width .35s ease}@media(max-width:700px){.scan-control select{max-width:108px;font-size:10px}.scan-control button{font-size:0}.scan-control button::after{content:'↻';font-size:16px}}</style></head><body>
|
</nav>
|
||||||
<header><div class="brand"><div class="brand-mark" aria-label="Media Max"><svg viewBox="0 0 48 48" role="img"><path d="M10 8h28a4 4 0 0 1 4 4v24a4 4 0 0 1-4 4H10a4 4 0 0 1-4-4V12a4 4 0 0 1 4-4Z" fill="#20252d" stroke="#60a9e6" stroke-width="2"/><path d="M14 17h20M14 24h13M14 31h20" stroke="#f4c430" stroke-width="3" stroke-linecap="round"/><circle cx="34" cy="24" r="5" fill="#159447" stroke="#c9ffe0" stroke-width="1.5"/></svg></div><div class="brand-name">MEDIA</div></div><div><div class="head-title" data-de="Media Max" data-en="Media Max">Media Max</div><div class="head-sub" data-de="Audio- und Untertitelspuren aus den Mediendateien" data-en="Audio and subtitle tracks from media files">Audio- und Untertitelspuren aus den Mediendateien</div></div><div class="actions"><button class="lang-switch" id="languageToggle" title="Sprache / Language">DE</button><button class="settings-button" id="settingsOpen" title="Einstellungen">⚙</button><div class="scan-control"><select id="scanSource" aria-label="Scan-Quelle"><option value="all">Alles scannen</option><option value="radarr">Nur Radarr</option><option value="sonarr">Nur Sonarr</option></select><button id="rescan" data-de="↻ Neu scannen" data-en="↻ Rescan">↻ Neu scannen</button></div></div></header>
|
<div class="sidebar-bottom"><button id="settingsOpen">{{ icon('settings') }}{{ tr('Einstellungen', 'Settings') }}</button><div class="sidebar-footer"><span class="status-dot"></span> Media Max <span>Self-hosted</span></div></div>
|
||||||
<main>
|
</aside>
|
||||||
<nav class="nav"><button class="active" data-view="dashboard"><img class="nav-tab-icon" src="https://api.iconify.design/mdi:view-dashboard-outline.svg?color=%238bc9f2" alt="" aria-hidden="true">Dashboard</button><button data-view="radarr"><img class="nav-tab-icon" src="https://api.iconify.design/mdi:movie-open-outline.svg?color=%23cc7b19" alt="" aria-hidden="true">RADARR · Filme</button>{% if sonarr_enabled %}<button data-view="sonarr"><img class="nav-tab-icon" src="https://api.iconify.design/mdi:television-classic.svg?color=%2335c5f4" alt="" aria-hidden="true">SONARR · Serien</button>{% endif %}<button data-view="missing"><img class="nav-tab-icon" src="https://api.iconify.design/mdi:alert-outline.svg?color=%23ff9e9e" alt="" aria-hidden="true">Ohne Deutsch <span class="nav-count" id="missingNavCount">{{ missing_german_movies|length + (missing_german_series|map(attribute='episodes')|map('length')|sum) }}</span></button>{% if sab_enabled %}<button data-view="downloads"><img class="nav-tab-icon" src="https://api.iconify.design/mdi:download-box-outline.svg?color=%23f4c430" alt="" aria-hidden="true">SABNZBD · Downloads <span class="nav-count download-nav-count hidden" id="downloadNavCount">0</span></button>{% endif %}<button data-view="datastore"><img class="nav-tab-icon" src="https://api.iconify.design/mdi:harddisk.svg?color=%2360a9e6" alt="" aria-hidden="true">Datastore</button></nav>
|
<div class="app-shell">
|
||||||
<section id="dashboard" class="view active" data-source="dashboard"><div class="dashboard-hero"><div><div class="eyebrow">MEDIA MAX</div><h1>Dashboard</h1><p>Deine gesamte Medienbibliothek und Systemauslastung auf einen Blick.</p></div><div class="live-pill"><span class="live-dot"></span> Live</div></div><div class="overview-grid"><div class="overview-card"><span class="overview-icon movie">▣</span><div><span>Filme</span><strong>{{ rows|length }}</strong><small>Radarr Library</small></div></div><div class="overview-card"><span class="overview-icon series-icon">▤</span><div><span>Serien</span><strong>{{ series|length }}</strong><small>Sonarr Shows</small></div></div><div class="overview-card"><span class="overview-icon warning">⚠</span><div><span>Ohne Deutsch</span><strong id="dashboardMissing">{{ missing_german_movies|length + (missing_german_series|map(attribute='episodes')|map('length')|sum) }}</strong><small>Audio oder Untertitel prüfen</small></div></div><div class="overview-card"><span class="overview-icon download-icon">↓</span><div><span>Downloads</span><strong id="dashboardDownloads">—</strong><small id="dashboardRemaining">SABnzbd wird geladen …</small></div></div></div><div class="dashboard-columns"><section class="system-panel"><div class="panel-heading"><div><h2>Systemauslastung</h2><p>Aktuelle Werte aus dem Dashboard-Container</p></div><span id="systemUpdated" class="muted">Wird geladen …</span></div><div class="system-meters"><div class="meter"><div class="meter-top"><span>CPU</span><strong id="cpuValue">—</strong></div><div class="meter-track"><span id="cpuBar"></span></div></div><div class="meter"><div class="meter-top"><span>RAM</span><strong id="ramValue">—</strong></div><div class="meter-track"><span id="ramBar"></span></div></div></div><div id="diskList" class="disk-list"><div class="disk-row muted">Laufwerke werden geladen …</div></div></section><section class="service-panel"><div class="panel-heading"><div><h2>Dienste</h2><p>Verbindung und Bibliotheksstatus</p></div></div><div class="service-row"><span class="service-logo radarr-dot">R</span><div><strong>Radarr</strong><small>Filme & Qualität</small></div><span class="service-status online">Aktiv</span></div><div class="service-row"><span class="service-logo sonarr-dot">S</span><div><strong>Sonarr</strong><small>Serien & Episoden</small></div><span class="service-status {% if sonarr_enabled %}online{% else %}offline{% endif %}">{% if sonarr_enabled %}Aktiv{% else %}Nicht konfiguriert{% endif %}</span></div><div class="service-row"><span class="service-logo sab-dot">↓</span><div><strong>SABnzbd</strong><small>Downloads & Queue</small></div><span id="dashboardSabStatus" class="service-status {% if sab_enabled %}online{% else %}offline{% endif %}">{% if sab_enabled %}Wird geprüft …{% else %}Nicht konfiguriert{% endif %}</span></div></section></div></section>
|
<header class="topbar">
|
||||||
<section id="radarr" class="view" data-source="radarr">
|
<button id="menuToggle" class="icon-button menu-toggle" aria-label="Navigation öffnen" aria-controls="sidebar" aria-expanded="false">{{ icon('menu') }}</button>
|
||||||
<div class="toolbar"><input id="search" type="search" placeholder="Film suchen …"><select id="audioFilter"><option value="">Alle Audio-Sprachen</option><option>Deutsch</option><option>Englisch</option><option>Japanisch</option><option>Koreanisch</option></select><select id="subFilter"><option value="">Alle Untertitel</option><option>Deutsch</option><option>Englisch</option><option>Japanisch</option><option>Koreanisch</option></select><select id="stateFilter"><option value="">Alle Status</option><option>DE + EN</option><option>Deutsch</option><option>English only</option></select></div>
|
<div class="breadcrumb">Media Max <span>/</span> <strong id="pageTitle">Dashboard</strong></div>
|
||||||
<div class="stats"><span class="stat" id="visibleCount"></span><span class="stat scan" id="radarrStatus">Scan wird vorbereitet …</span><span class="stat">Radarr: {{ radarr_media_path }}</span></div>
|
<div class="actions">
|
||||||
<div class="tablewrap"><table id="movies"><thead><tr><th class="col-title">Film</th><th class="col-quality">Quality</th><th class="col-state">Status</th><th class="col-audio">Audio</th><th class="col-subs">Untertitel</th><th class="col-video">Video</th><th class="col-size">Größe</th></tr></thead><tbody>
|
<button id="languageToggle" class="icon-button" aria-label="Sprache wechseln">EN</button>
|
||||||
{% for r in rows %}<tr data-title="{{ (r.title ~ ' ' ~ (r.year or ''))|lower }}" data-audio="{{ r.audio_languages|map(attribute='name')|join(',') }}" data-subs="{{ r.subtitle_languages|map(attribute='name')|join(',') }}" data-state="{{ r.state.label }}"><td><span class="title">{{ r.title }}</span><span class="year">{{ r.year or '' }}</span>{% if r.error %} ⚠{% endif %}</td><td><span class="badge {% if not r.missing %}quality{% endif %}">{{ r.quality }}</span></td><td><span class="state {{ r.state.class }}">{{ r.state.label }}</span></td><td><div class="badges">{% for l in r.audio_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td><div class="badges">{% for l in r.subtitle_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td>{{ r.video_codec }}</td><td>{{ r.size }}</td></tr>{% endfor %}
|
<div class="scan-control">
|
||||||
</tbody></table></div></section>
|
<select id="scanSource" aria-label="Scan-Quelle">
|
||||||
{% if sonarr_enabled %}<section id="sonarr" class="view" data-source="sonarr"><div class="stats"><span class="stat" id="seriesCount">{{ series|length }} Serien</span><span class="stat scan" id="sonarrStatus">Scan wird vorbereitet …</span><span class="stat">Dashboard: {{ local_series_path }}</span></div><div class="tablewrap" id="seriesList">
|
<option value="all" data-de="Alle Quellen" data-en="All sources">Alle Quellen</option>
|
||||||
{% for category in series_categories %}{% if category.series %}<section class="series-category"><div class="series-category-heading"><h2>{{ category.label }}</h2><span>{{ category.series|length }} Serien</span></div>{% for group in category.series %}<details class="series" open><summary>{{ group.title }} <span class="muted">({{ group.year or '—' }}) · {{ group.episodes|length }} Episoden</span></summary><table><thead><tr><th class="col-title">Episode</th><th class="col-quality">Quality</th><th class="col-state">Status</th><th class="col-audio">Audio</th><th class="col-subs">Untertitel</th><th class="col-video">Video</th><th class="col-size">Größe</th></tr></thead><tbody>{% for r in group.episodes %}<tr><td><span class="title">{{ r.title }}</span></td><td><span class="badge {% if not r.missing %}quality{% endif %}">{{ r.quality }}</span></td><td><span class="state {{ r.state.class }}">{{ r.state.label }}</span></td><td><div class="badges">{% for l in r.audio_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td><div class="badges">{% for l in r.subtitle_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td>{{ r.video_codec }}</td><td>{{ r.size }}</td></tr>{% endfor %}</tbody></table></details>{% endfor %}</section>{% endif %}{% endfor %}
|
<option value="radarr">Radarr</option>
|
||||||
</div></section>{% endif %}
|
{% if sonarr_enabled %}<option value="sonarr">Sonarr</option>{% endif %}
|
||||||
<section id="missing" class="view" data-source="radarr"><div class="stats"><span class="stat" id="missingCount">{{ missing_german_movies|length + (missing_german_series|map(attribute='episodes')|map('length')|sum) }} Einträge ohne Deutsch</span><span class="stat scan" id="seasonZeroHint">Vorhanden oder wird gerade geladen · Staffel 0 ausgeblendet</span></div><div class="tablewrap missing-table">
|
<option value="missing" data-de="Ohne Deutsch" data-en="Without German">Ohne Deutsch</option>
|
||||||
<table id="missingMovies"><thead><tr><th class="col-title">Film</th><th class="col-quality">Qualität</th><th class="col-state">Status</th><th class="col-audio">Audio</th><th class="col-subs">Untertitel</th><th class="col-video">Video</th><th class="col-size">Größe</th></tr></thead><tbody>{% for r in missing_german_movies %}<tr><td><span class="title">{{ r.title }}</span><span class="year">{{ r.year or '' }}</span></td><td><span class="badge {% if not r.missing %}quality{% endif %}">{{ r.quality }}</span></td><td><span class="state bad">Ohne Deutsch</span></td><td><div class="badges">{% for l in r.audio_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td><div class="badges">{% for l in r.subtitle_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td>{{ r.video_codec }}</td><td>{{ r.size }}</td></tr>{% endfor %}</tbody></table>
|
</select>
|
||||||
{% for group in missing_german_series %}<details class="series missing-series"><summary>{{ group.title }} <span class="muted">({{ group.year or '—' }}) · {{ group.episodes|length }} Episoden ohne Deutsch</span></summary><table><thead><tr><th class="col-title">Episode</th><th class="col-quality">Qualität</th><th class="col-state">Status</th><th class="col-audio">Audio</th><th class="col-subs">Untertitel</th><th class="col-video">Video</th><th class="col-size">Größe</th></tr></thead><tbody>{% for r in group.episodes %}<tr><td><span class="title">{{ r.title }}</span></td><td><span class="badge {% if not r.missing %}quality{% endif %}">{{ r.quality }}</span></td><td><span class="state bad">Ohne Deutsch</span></td><td><div class="badges">{% for l in r.audio_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td><div class="badges">{% for l in r.subtitle_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td>{{ r.video_codec }}</td><td>{{ r.size }}</td></tr>{% endfor %}</tbody></table></details>{% endfor %}
|
<button id="rescan" class="primary-button">{{ icon('refresh') }}{{ tr('Neu scannen', 'Rescan') }}</button>
|
||||||
{% if not missing_german_movies and not missing_german_series %}<div class="empty-state">Alle vorhandenen Filme und Episoden haben deutsches Audio oder deutsche Untertitel.</div>{% endif %}</div></section>
|
</div>
|
||||||
{% if sab_enabled %}<section id="downloads" class="view" data-source="downloads"><div class="download-hero"><div><div class="eyebrow">SABNZBD · DOWNLOAD CENTER</div><h2>Download-Übersicht</h2><p>Queue, Fortschritt und verbleibender Speicherplatz auf einen Blick.</p></div><div class="queue-pill" id="sabQueueCard"><span class="queue-dot"></span><span>Verbinde mit SABnzbd …</span></div></div><div class="download-stats"><div class="download-card remaining-card"><span class="card-icon">↓</span><div><span class="card-label">Noch zu laden</span><strong id="sabRemaining">—</strong><small id="sabRemainingHint">Wird berechnet …</small></div></div><div class="download-card"><span class="card-icon blue">◉</span><div><span class="card-label">Aktive Downloads</span><strong id="sabActive">—</strong><small id="sabHistory">Verlauf: —</small></div></div><div class="download-card"><span class="card-icon purple">↯</span><div><span class="card-label">Geschwindigkeit</span><strong id="sabSpeedCard">—</strong><small>Aktuelle Queue</small></div></div><div class="download-card"><span class="card-icon orange">✓</span><div><span class="card-label">Status</span><strong id="sabStatusCard">—</strong><small id="downloadCount">—</small></div></div></div><div id="downloadError" class="banner hidden"></div><div class="tablewrap download-table"><table id="downloadsTable"><thead><tr><th class="col-title">Download</th><th class="col-state">Status</th><th class="col-progress">Fortschritt</th><th class="col-size">Größe / Rest</th><th class="col-time">Restzeit</th><th class="col-source">Quelle</th><th class="col-language">Sprache</th></tr></thead><tbody id="downloadsBody"><tr><td colspan="7" class="muted">Downloads werden geladen …</td></tr></tbody></table></div></section>{% endif %}
|
</div>
|
||||||
<section id="datastore" class="view" data-source="datastore"><div class="datastore-hero"><div><div class="eyebrow">MEDIA MAX · DATASTORE</div><h2>Datastore</h2><p>Speicherbelegung, Medienkategorien und RAID-Status von <code>/nesflix</code>.</p></div><div id="datastoreUpdated" class="datastore-live">Wird geladen …</div></div><div class="datastore-storage"><div class="storage-card"><span class="storage-icon">▣</span><div><small>Gesamtspeicher</small><strong id="datastoreTotal">—</strong></div></div><div class="storage-card"><span class="storage-icon used">◒</span><div><small>Belegt</small><strong id="datastoreUsed">—</strong></div></div><div class="storage-card"><span class="storage-icon free">◌</span><div><small>Frei</small><strong id="datastoreFree">—</strong></div></div><div class="storage-card"><span class="storage-icon percent">%</span><div><small>Auslastung</small><strong id="datastorePercent">—</strong></div></div></div><section class="datastore-panel datastore-capacity"><div class="datastore-panel-head"><div><h3>Gesamtauslastung</h3><p id="datastorePath">/nesflix</p></div><strong id="datastoreCapacityLabel">—</strong></div><div class="datastore-progress"><span id="datastoreCapacityBar"></span></div></section><div class="datastore-grid"><section class="datastore-panel"><div class="datastore-panel-head"><div><h3>Speicher nach Kategorie</h3><p>Anhand der tatsächlichen Verzeichnisse</p></div></div><div id="datastoreCategories" class="datastore-categories"><div class="datastore-empty">Wird geladen …</div></div></section><section class="datastore-panel raid-panel"><div class="datastore-panel-head"><div><h3>RAID-Status</h3><p id="raidDevice">/dev/md127</p></div><span id="raidStatusBadge" class="raid-status unknown">Wird geladen …</span></div><div id="raidSummary" class="raid-summary"><span>—</span><span>—</span><span>—</span></div><div id="raidRecovery" class="raid-recovery hidden"><div class="recovery-head"><strong>RAID Rebuild</strong><span id="raidRecoveryPercent">—</span></div><div class="datastore-progress recovery"><span id="raidRecoveryBar"></span></div><div class="recovery-meta"><span>Geschwindigkeit <strong id="raidRecoverySpeed">—</strong></span><span>Verbleibend <strong id="raidRecoveryFinish">—</strong></span></div></div><div id="raidDisks" class="raid-disks"><div class="datastore-empty">Wird geladen …</div></div></section></div></section>
|
</header>
|
||||||
|
<main id="main">
|
||||||
|
{% if error %}<div class="banner">{{ icon('alert') }}{{ error }}</div>{% endif %}
|
||||||
|
{% if not library_ready %}<div class="notice">{{ tr('Bibliothek wird synchronisiert. Diese Ansicht aktualisiert sich automatisch.', 'Library is syncing. This view will update automatically.') }}</div>{% endif %}
|
||||||
|
<div id="actionError" class="banner hidden" role="alert"></div>
|
||||||
|
<section id="dashboard" class="view active">
|
||||||
|
<div class="page-heading"><div><h1>{{ tr('Dashboard', 'Dashboard') }}</h1><p>{{ tr('Deine Medien. Deine Sprachen. Dein System.', 'Your media. Your languages. Your system.') }}</p></div><span class="live-label"><span class="status-dot"></span>{{ tr('Live-Monitoring', 'Live monitoring') }}</span></div>
|
||||||
|
<div class="summary-grid">
|
||||||
|
<button class="summary-card" data-go="radarr"><span class="summary-icon">{{ icon('movie') }}</span><div><strong>{{ rows|length }}</strong><span>{{ tr('Filme', 'Movies') }}</span><small>Radarr · {{ dashboard_stats.movie_files }} {{ tr('vorhanden', 'available') }}</small></div></button>
|
||||||
|
<button class="summary-card" {% if sonarr_enabled %}data-go="sonarr"{% endif %}><span class="summary-icon purple">{{ icon('series') }}</span><div><strong>{{ series|length }}</strong><span>{{ tr('Serien', 'Series') }}</span><small>{{ dashboard_stats.episode_files }} {{ tr('Episoden vorhanden', 'episodes available') }}</small></div></button>
|
||||||
|
<button class="summary-card" data-go="missing"><span class="summary-icon amber">{{ icon('language') }}</span><div><strong id="dashboardMissing">0</strong><span>{{ tr('Ohne Deutsch', 'Without German') }}</span><small>{{ tr('Audio & Untertitel prüfen', 'Review audio & subtitles') }}</small></div></button>
|
||||||
|
<button class="summary-card" {% if sab_enabled %}data-go="downloads"{% endif %}><span class="summary-icon green">{{ icon('download') }}</span><div><strong id="dashboardDownloads">{{ '—' if sab_enabled else '0' }}</strong><span>{{ tr('Downloads', 'Downloads') }}</span><small id="dashboardRemaining">{{ 'SABnzbd …' if sab_enabled else 'SABnzbd nicht konfiguriert' }}</small></div></button>
|
||||||
|
</div>
|
||||||
|
<div class="section-title">{{ icon('movie') }}<h2>{{ tr('Bibliotheksübersicht', 'Library overview') }}</h2></div>
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
<article class="panel library-panel"><div class="panel-heading"><h3>{{ tr('Medienbestand', 'Media collection') }}</h3><span class="muted">Radarr / Sonarr</span></div>
|
||||||
|
{% for de, en, files, total, missing in [('Filme', 'Movies', dashboard_stats.movie_files, dashboard_stats.movie_total, dashboard_stats.movie_missing), ('Episoden', 'Episodes', dashboard_stats.episode_files, dashboard_stats.episode_total, dashboard_stats.episode_missing)] %}
|
||||||
|
<div class="coverage-row"><div class="metric-line"><span>{{ tr(de, en) }}</span><strong>{{ files }} <span class="muted">/ {{ total }}</span></strong></div><div class="track"><span style="width:{{ files / total * 100 if total else 0 }}%"></span></div><small class="muted">{{ missing }} {{ tr('ohne Datei', 'without a file') }}</small></div>{% endfor %}
|
||||||
|
<div class="library-footer">{{ icon('refresh') }}<span>{{ tr('Automatischer Sync', 'Automatic sync') }} · {{ (sync_interval_seconds / 60)|round|int if sync_interval_seconds else '—' }} {{ tr('Minuten', 'minutes') }}</span></div>
|
||||||
|
</article>
|
||||||
|
<article class="panel"><div class="panel-heading"><h3>{{ tr('Spracherkennung', 'Language detection') }}</h3>{{ icon('language') }}</div><div class="language-metrics"><div><strong class="text-green">{{ dashboard_stats.german_tracks }}</strong><small>{{ tr('Mit Deutsch', 'With German') }}</small></div><div><strong class="text-amber">{{ dashboard_stats.unknown_audio }}</strong><small>{{ tr('Audio unbekannt', 'Unknown audio') }}</small></div><div><strong>{{ dashboard_stats.pending }}</strong><small>{{ tr('Scan ausstehend', 'Scan pending') }}</small></div><div><strong class="text-red">{{ dashboard_stats.scan_errors }}</strong><small>{{ tr('Scanfehler', 'Scan errors') }}</small></div></div>
|
||||||
|
<p class="panel-note">{{ tr('Deutsch im Audio oder in Untertiteln zählt als verfügbar. Nicht beschriftete Spuren werden nicht geraten.', 'German audio or subtitles count as available. Unlabelled tracks are not guessed.') }}</p>
|
||||||
|
{% if language_review %}<div class="review-list">{% for r in language_review %}<div><span class="title" title="{{ r.title }}">{{ r.title }}</span><span class="badge unknown">{{ 'Scanfehler' if r.error else 'Unbekannt' }}</span></div>{% endfor %}</div>{% endif %}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div class="section-title">{{ icon('activity') }}<h2>{{ tr('System & Dienste', 'System & services') }}</h2><span id="systemUpdated" class="muted">—</span></div>
|
||||||
|
<div class="dashboard-grid system-grid">
|
||||||
|
<article class="panel"><div class="panel-heading"><h3>{{ tr('Systemauslastung', 'System resources') }}</h3>{{ icon('chip') }}</div><div class="system-meters"><div><div class="metric-line"><span>CPU</span><strong id="cpuValue">—</strong></div><div class="track"><span id="cpuBar"></span></div></div><div><div class="metric-line"><span>RAM</span><strong id="ramValue">—</strong></div><div class="track purple-track"><span id="ramBar"></span></div><small id="ramDetail" class="muted">—</small></div></div><div id="systemDetails" class="system-details"></div><div id="diskList" class="disk-list"></div></article>
|
||||||
|
<article class="panel"><div class="panel-heading"><h3>{{ tr('Verbundene Dienste', 'Connected services') }}</h3><span id="serviceSummary" class="badge">—</span></div><div id="serviceList" class="service-list"><div class="empty-state">{{ tr('Dienste werden geprüft …', 'Checking services …') }}</div></div></article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="radarr" class="view"><div class="page-heading"><div><h1>{{ tr('Filme', 'Movies') }}</h1><p>{{ tr('Qualität, Audio und Untertitel deiner Radarr-Bibliothek.', 'Quality, audio and subtitles in your Radarr library.') }}</p></div><span class="source-label">Radarr</span></div>{{ filters('movie') }}<div class="stats"><span id="movieCount"></span><span id="radarrStatus" class="scan-status">—</span></div><div class="panel table-panel">{{ media_table(rows, 'movies') }}</div><p class="metadata-note">* {{ tr('Sprache aus Radarr/Sonarr-Dateimetadaten, nicht direkt in der Datei bestätigt.', 'Language from Radarr/Sonarr file metadata, not directly confirmed in the file.') }}</p><div id="movieNoResults" class="empty-state hidden">{{ tr('Keine Filme für diese Filter.', 'No movies match these filters.') }}</div></section>
|
||||||
|
{% if sonarr_enabled %}<section id="sonarr" class="view"><div class="page-heading"><div><h1>{{ tr('Serien', 'Series') }}</h1><p>{{ tr('Deine Serien, nach Kategorien und Staffeln geordnet.', 'Your shows, grouped by category and season.') }}</p></div><span class="source-label">Sonarr</span></div>{{ filters('series', true) }}<div class="stats"><span id="seriesCount"></span><span id="sonarrStatus" class="scan-status">—</span></div><div id="seriesList">{% for category in series_categories %}{% if category.series %}<section class="series-category"><div class="category-heading"><h2>{{ category.label }}</h2><span class="muted">{{ category.series|length }}</span></div>{% for group in category.series %}<details class="series" data-title="{{ group.title|lower }}"><summary>{{ icon('series') }}<strong>{{ group.title }}</strong><span class="muted">{{ group.year or '' }} · {{ group.episodes|length }} {{ tr('Episoden', 'episodes') }}</span></summary>{{ media_table(group.episodes, episode=true) }}</details>{% endfor %}</section>{% endif %}{% endfor %}</div><p class="metadata-note">* {{ tr('Sprache aus Radarr/Sonarr-Dateimetadaten, nicht direkt in der Datei bestätigt.', 'Language from Radarr/Sonarr file metadata, not directly confirmed in the file.') }}</p><div id="seriesNoResults" class="empty-state hidden">{{ tr('Keine Serien für diese Filter.', 'No series match these filters.') }}</div></section>{% endif %}
|
||||||
|
<section id="missing" class="view"><div class="page-heading"><div><h1>{{ tr('Ohne Deutsch', 'Without German') }}</h1><p>{{ tr('Keine deutsche Audio- oder Untertitelspur erkannt. Unbekannte Spuren bitte prüfen.', 'No German audio or subtitles detected. Please review unknown tracks.') }}</p></div>{{ icon('language') }}</div><div class="stats"><span id="missingCount">—</span><span id="missingStatus" class="scan-status">—</span><span id="seasonZeroHint" class="muted"></span></div><div id="missingList">{% if missing_german_movies %}<h2 class="list-heading">{{ tr('Filme', 'Movies') }}</h2><div class="panel table-panel">{{ media_table(missing_german_movies, 'missingMovies', review=true) }}</div>{% endif %}{% if missing_german_series %}<h2 class="list-heading">{{ tr('Serien', 'Series') }}</h2>{% for group in missing_german_series %}<details class="series missing-series"><summary>{{ icon('series') }}<strong>{{ group.title }}</strong><span class="muted">{{ group.episodes|length }} {{ tr('Episoden', 'episodes') }}</span></summary>{{ media_table(group.episodes, episode=true, review=true) }}</details>{% endfor %}{% endif %}</div><div id="missingEmpty" class="panel empty-state hidden">{{ icon('check') }}{{ tr('Keine Einträge ohne Deutsch in der aktuellen Ansicht.', 'No entries without German in the current view.') }}</div></section>
|
||||||
|
{% if sab_enabled %}<section id="downloads" class="view"><div class="page-heading"><div><h1>{{ tr('Downloads', 'Downloads') }}</h1><p>{{ tr('Aktuelle Queue und zuletzt abgeschlossene Downloads.', 'Current queue and recently completed downloads.') }}</p></div><span id="sabQueueStatus" class="source-label">SABnzbd</span></div><div class="summary-grid"><article class="summary-card"><span class="summary-icon">{{ icon('download') }}</span><div><strong id="sabRemaining">—</strong><span>{{ tr('Noch zu laden', 'Remaining') }}</span></div></article><article class="summary-card"><span class="summary-icon">{{ icon('movie') }}</span><div><strong id="sabActive">—</strong><span>{{ tr('Queue-Einträge', 'Queue entries') }}</span></div></article><article class="summary-card"><span class="summary-icon">{{ icon('activity') }}</span><div><strong id="sabSpeed">—</strong><span>{{ tr('Geschwindigkeit', 'Speed') }}</span></div></article><article class="summary-card"><span class="summary-icon green">{{ icon('check') }}</span><div><strong id="sabHistory">—</strong><span>{{ tr('Im Verlauf', 'In history') }}</span></div></article></div><div id="downloadError" class="banner hidden"></div>{% for id, de, en in [('activeDownloads', 'Aktive Downloads', 'Active downloads'), ('otherDownloads', 'Queue & Verlauf', 'Queue & history')] %}<div class="section-title">{{ icon('download') }}<h2>{{ tr(de, en) }}</h2><span id="{{ id }}Count" class="badge">0</span></div><div class="panel table-panel table-scroll"><table class="download-table"><thead><tr><th class="col-title">Download</th><th>Status</th><th>{{ tr('Fortschritt', 'Progress') }}</th><th>{{ tr('Größe / Rest', 'Size / remaining') }}</th><th>{{ tr('Restzeit', 'Time left') }}</th><th>{{ tr('Quelle', 'Source') }}</th><th>{{ tr('Sprache', 'Language') }}</th></tr></thead><tbody id="{{ id }}Body"></tbody></table></div>{% endfor %}</section>{% endif %}
|
||||||
|
<section id="datastore" class="view"><div class="page-heading"><div><h1>{{ tr('Speicher & RAID', 'Storage & RAID') }}</h1><p>{{ tr('Speicherbelegung, Medienkategorien und Zustand deiner Laufwerke.', 'Storage usage, media categories and drive health.') }}</p></div><span id="datastoreUpdated" class="muted">—</span></div><div id="storageWarning" class="banner hidden"></div><div class="summary-grid">{% for id, de, en in [('Total', 'Gesamtspeicher', 'Total storage'), ('Used', 'Belegt', 'Used'), ('Free', 'Verfügbar', 'Available'), ('Percent', 'Auslastung', 'Usage')] %}<article class="summary-card"><span class="summary-icon">{{ icon('disk') }}</span><div><strong id="datastore{{ id }}">—</strong><span>{{ tr(de, en) }}</span></div></article>{% endfor %}</div><div class="dashboard-grid"><article class="panel"><div class="panel-heading"><h3>{{ tr('Medienkategorien', 'Media categories') }}</h3><code id="datastorePath">—</code></div><div class="coverage-row"><div id="datastoreCapacityLabel" class="muted">—</div><div class="track"><span id="datastoreCapacityBar"></span></div></div><div id="datastoreCategories"></div></article><div class="panel-stack"><article class="panel"><div class="panel-heading"><h3>I/O Pressure</h3><strong id="ioPressureLabel">—</strong></div><p class="panel-note">{{ tr('Festplatten-Druck aus Linux PSI · letzte 10 Sekunden', 'Disk pressure from Linux PSI · last 10 seconds') }}</p>{% for name in ['Some', 'Full'] %}<div class="pressure-row"><span>{{ name }}</span><strong id="ioPressure{{ name }}">—</strong><div class="track"><span id="ioPressure{{ name }}Bar"></span></div></div>{% endfor %}</article><article class="panel raid-panel"><div class="panel-heading"><div><h3>RAID</h3><code id="raidDevice">—</code></div><span id="raidStatusBadge" class="badge">—</span></div><div id="raidWarning" class="banner hidden"></div><div id="raidSummary" class="system-details"></div><div id="raidRecovery" class="recovery-box hidden"><div class="metric-line"><strong>Rebuild / Resync</strong><strong id="raidRecoveryPercent">—</strong></div><div class="track"><span id="raidRecoveryBar"></span></div><div class="metric-line muted"><span id="raidRecoverySpeed">—</span><span id="raidRecoveryFinish">—</span></div></div><div id="raidDisks" class="raid-disks"></div></article></div></div></section>
|
||||||
|
<section id="graphics" class="view"><div class="page-heading"><div><h1>NVIDIA Graphics</h1><p>{{ tr('GPU-Auslastung, Temperatur, VRAM und Transcoding-Prozesse.', 'GPU usage, temperature, VRAM and transcoding processes.') }}</p></div><span id="graphicsStatus" class="source-label">—</span></div><div id="graphicsSummary" class="summary-grid"></div><div id="graphicsCards" class="graphics-cards"></div><div class="section-title">{{ icon('chip') }}<h2>{{ tr('GPU-Prozesse', 'GPU processes') }}</h2></div><div id="graphicsProcesses" class="panel"></div></section>
|
||||||
</main>
|
</main>
|
||||||
<div class="settings-backdrop hidden" id="settingsBackdrop"><section class="settings-modal" role="dialog" aria-modal="true" aria-labelledby="settingsTitle"><div class="settings-head"><div><h2 id="settingsTitle" data-de="Einstellungen" data-en="Settings">Einstellungen</h2><p data-de="Deine Anzeige- und Sprachpräferenzen" data-en="Your display and language preferences">Deine Anzeige- und Sprachpräferenzen</p></div><button class="settings-close" id="settingsClose" aria-label="Schließen">×</button></div><label for="preferredAudio" data-de="Bevorzugte Audio-Sprache" data-en="Preferred audio language">Bevorzugte Audio-Sprache</label><select id="preferredAudio"><option value="" data-de="Keine Präferenz" data-en="No preference">Keine Präferenz</option><option value="Deutsch" data-de="Deutsch" data-en="German">Deutsch</option><option value="Englisch" data-de="Englisch" data-en="English">Englisch</option><option value="Japanisch" data-de="Japanisch" data-en="Japanese">Japanisch</option><option value="Koreanisch" data-de="Koreanisch" data-en="Korean">Koreanisch</option></select><label for="preferredSubs" data-de="Bevorzugte Untertitel-Sprache" data-en="Preferred subtitle language">Bevorzugte Untertitel-Sprache</label><select id="preferredSubs"><option value="" data-de="Keine Präferenz" data-en="No preference">Keine Präferenz</option><option value="Deutsch" data-de="Deutsch" data-en="German">Deutsch</option><option value="Englisch" data-de="Englisch" data-en="English">Englisch</option><option value="Japanisch" data-de="Japanisch" data-en="Japanese">Japanisch</option><option value="Koreanisch" data-de="Koreanisch" data-en="Korean">Koreanisch</option></select><div class="settings-actions"><button id="settingsCancel" data-de="Abbrechen" data-en="Cancel">Abbrechen</button><button class="settings-save" id="settingsSave" data-de="Speichern" data-en="Save">Speichern</button></div></section></div>
|
</div>
|
||||||
<script>
|
<dialog id="settingsModal" class="settings-modal" aria-labelledby="settingsTitle">
|
||||||
const dashboardView=document.querySelector('#dashboard');if(dashboardView){dashboardView.classList.add('dashboard-view');const stats={{ dashboard_stats|tojson }};const insights=document.createElement('div');insights.className='dashboard-insights';insights.innerHTML=`<div class="insight-card"><span class="insight-icon">▣</span><div><strong>${stats.movie_files} von ${stats.movie_total} Filmen vorhanden</strong><small>${stats.movie_missing} fehlen · ${stats.movie_downloading} werden geladen</small></div></div><div class="insight-card"><span class="insight-icon">▤</span><div><strong>${stats.episode_files} von ${stats.episode_total} Episoden vorhanden</strong><small>${stats.episode_missing} fehlen in ${stats.series_total} Serien</small></div></div><div class="insight-card"><span class="insight-icon">◌</span><div><strong>Medienbestand geprüft</strong><small>Audio, Untertitel und Qualität werden überwacht</small></div></div><div class="insight-card"><span class="insight-icon">✓</span><div><strong>Live-Überwachung aktiv</strong><small>Systemwerte und Downloads aktualisieren sich automatisch</small></div></div>`;const columns=dashboardView.querySelector('.dashboard-columns');if(columns)dashboardView.insertBefore(insights,columns)}
|
<form method="dialog">
|
||||||
const servicePanel=document.querySelector('.service-panel');let serviceList=null;const serviceSummary=document.createElement('span');serviceSummary.className='service-summary';if(servicePanel){servicePanel.querySelectorAll('.service-row').forEach(row=>row.remove());serviceList=document.createElement('div');serviceList.className='service-list';servicePanel.appendChild(serviceList);const heading=servicePanel.querySelector('.panel-heading');if(heading)heading.appendChild(serviceSummary)}function renderServices(data){if(!serviceList)return;const values=data.services||[];serviceSummary.textContent=`${data.online||0} / ${data.total||values.length} online`;serviceList.innerHTML=values.map(service=>`<div class="service-row service-${service.status}"><span class="service-logo service-icon-${service.key}">${service.key==='dashboard'?'⌂':service.name.slice(0,1)}</span><div><strong>${html(service.name)}</strong><small>${html(service.detail||'Keine Antwort')}</small></div><span class="service-status ${service.status}">${html(service.label)}</span></div>`).join('')}async function fetchServices(){try{renderServices(await (await fetch('/api/services')).json())}catch(error){serviceSummary.textContent='Status nicht verfügbar'}}if(servicePanel){fetchServices();setInterval(fetchServices,30000)}
|
<div class="panel-heading">
|
||||||
const navigation=document.querySelector('.nav');if(navigation&&!navigation.querySelector('[data-view="datastore"]')){const datastoreButton=document.createElement('button');datastoreButton.dataset.view='datastore';datastoreButton.innerHTML='<img class="nav-icon" src="https://api.iconify.design/mdi:harddisk.svg?color=%2360a9e6" alt="" aria-hidden="true"><span data-de="Datastore" data-en="Datastore">Datastore</span>';navigation.appendChild(datastoreButton)}const rows=[...document.querySelectorAll('#movies tbody tr')], search=document.querySelector('#search'), audio=document.querySelector('#audioFilter'), subs=document.querySelector('#subFilter'), state=document.querySelector('#stateFilter'), count=document.querySelector('#visibleCount');
|
<div><h2 id="settingsTitle">{{ tr('Einstellungen', 'Settings') }}</h2><p class="muted">{{ tr('Anzeige- und Sprachpräferenzen', 'Display and language preferences') }}</p></div>
|
||||||
function apply(){const q=search.value.trim().toLowerCase();let n=0;rows.forEach(r=>{const ok=(!q||r.dataset.title.includes(q))&&(!audio.value||r.dataset.audio.includes(audio.value))&&(!subs.value||r.dataset.subs.includes(subs.value))&&(!state.value||r.dataset.state===state.value);r.classList.toggle('hidden',!ok);if(ok)n++});count.textContent=`${n} Filme`};[search,audio,subs,state].forEach(x=>x.addEventListener(x===search?'input':'change',apply));apply();
|
<button class="icon-button" aria-label="Schließen">{{ icon('close') }}</button>
|
||||||
document.querySelectorAll('.nav button').forEach(b=>b.onclick=()=>{document.querySelectorAll('.nav button').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.view').forEach(x=>x.classList.remove('active'));b.classList.add('active');document.querySelector('#'+b.dataset.view).classList.add('active');const rescan=document.querySelector('#rescan'),sourceSelect=document.querySelector('#scanSource'),canRescan=['radarr','sonarr'].includes(b.dataset.view);if(canRescan)sourceSelect.value=b.dataset.view;rescan.disabled=false;rescan.title='Bibliothek neu scannen';sessionStorage.setItem('dashboard-active-view',b.dataset.view)});const rememberedView=sessionStorage.getItem('dashboard-active-view');if(rememberedView)document.querySelector(`.nav button[data-view="${rememberedView}"]`)?.click();
|
</div>
|
||||||
function statusText(s){if(s.state==='running'){const pct=s.total?Math.round(s.done/s.total*100):0;return `Scan läuft · ${s.done}/${s.total||'?'} Dateien · ${pct}%`}if(s.state==='done')return 'Scan abgeschlossen ✓';if(s.state==='error')return 'Scanfehler: '+s.error;return 'Scan wird vorbereitet …'}
|
{% for id, de, en in [('preferredAudio', 'Bevorzugte Audio-Sprache', 'Preferred audio language'), ('preferredSubs', 'Bevorzugte Untertitel-Sprache', 'Preferred subtitle language')] %}
|
||||||
function updateScanStatus(selector,s){const el=document.querySelector(selector);if(!el)return;const pct=s.total?Math.min(100,Math.round(s.done/s.total*100)):0;el.textContent=statusText(s);el.style.setProperty('--scan-progress',`${pct}%`);el.classList.toggle('scan-running',s.state==='running')}
|
<label for="{{ id }}">{{ tr(de, en) }}</label>
|
||||||
const seenRunning={radarr:false,sonarr:false};async function poll(){try{const data=await (await fetch('/api/scan-status')).json();updateScanStatus('#radarrStatus',data.radarr);const sonarr=document.querySelector('#sonarrStatus');if(sonarr)updateScanStatus('#sonarrStatus',data.sonarr);['radarr','sonarr'].forEach(source=>{if(data[source].state==='running')seenRunning[source]=true;if(seenRunning[source]&&['done','error'].includes(data[source].state)){seenRunning[source]=false;setTimeout(()=>{sessionStorage.setItem('dashboard-active-view',document.querySelector('.nav button.active')?.dataset.view||'dashboard');location.reload()},700)}})}catch(e){} };setInterval(poll,1200);poll();
|
<select id="{{ id }}"><option value="" data-de="Keine Präferenz" data-en="No preference">Keine Präferenz</option>{% for name in language_names %}<option>{{ name }}</option>{% endfor %}</select>
|
||||||
document.querySelector('#rescan').onclick=async()=>{const b=document.querySelector('#rescan'),source=document.querySelector('#scanSource').value;sessionStorage.setItem('dashboard-active-view',document.querySelector('.nav button.active')?.dataset.view||'dashboard');b.disabled=true;b.textContent='Scan läuft …';await fetch('/api/rescan?source='+source,{method:'POST'});b.disabled=false;b.textContent='↻ Neu scannen';poll()};
|
{% endfor %}
|
||||||
</script>
|
<label class="setting-toggle"><input type="checkbox" id="showSeasonZero">{{ tr('Staffel 0 anzeigen', 'Show season 0') }}</label>
|
||||||
<script>
|
<p class="panel-note">{{ tr('Gilt für Serien und die Liste Ohne Deutsch. Automatisches Scanning läuft serverseitig.', 'Applies to series and the Without German list. Automatic scanning runs on the server.') }}</p>
|
||||||
const scanLabel=document.createElement('label');scanLabel.htmlFor='autoScan';scanLabel.textContent='Automatisches Scanning';const scanHelp=document.createElement('span');scanHelp.className='setting-help';scanHelp.textContent='Bibliotheken werden automatisch geprüft, solange das Dashboard geöffnet ist.';const scanSelect=document.createElement('select');scanSelect.id='autoScan';scanSelect.className='setting-select';[['0','Deaktiviert'],['1800000','Alle 30 Minuten'],['3600000','Jede Stunde'],['7200000','Alle 2 Stunden'],['10800000','Alle 3 Stunden'],['14400000','Alle 4 Stunden'],['18000000','Alle 5 Stunden']].forEach(([value,label])=>{const option=document.createElement('option');option.value=value;option.textContent=label;scanSelect.appendChild(option)});const settingsActions=document.querySelector('.settings-actions');if(settingsActions){settingsActions.before(scanLabel,scanHelp,scanSelect)}
|
<div class="settings-actions"><button value="cancel">{{ tr('Abbrechen', 'Cancel') }}</button><button id="settingsSave" type="button" class="primary-button">{{ tr('Speichern', 'Save') }}</button></div>
|
||||||
let autoScanTimer=null;function configureAutoScan(){if(autoScanTimer)clearInterval(autoScanTimer);const interval=Number(localStorage.getItem('auto-scan-interval')||0);if(!interval)return;autoScanTimer=setInterval(()=>{['radarr','sonarr'].forEach(source=>{if(document.querySelector(`#${source}`))fetch(`/api/rescan?source=${source}`,{method:'POST'})})},interval)}function saveAutoScan(){localStorage.setItem('auto-scan-interval',scanSelect.value);configureAutoScan()}scanSelect.value=localStorage.getItem('auto-scan-interval')||'0';configureAutoScan();document.querySelector('#settingsSave').addEventListener('click',saveAutoScan);
|
</form>
|
||||||
const seasonZeroLabel=document.createElement('label');seasonZeroLabel.className='setting-toggle';seasonZeroLabel.htmlFor='showSeasonZero';seasonZeroLabel.innerHTML='<input type="checkbox" id="showSeasonZero"><span>Staffel 0 anzeigen</span>';const seasonZeroHelp=document.createElement('span');seasonZeroHelp.className='setting-help';seasonZeroHelp.textContent='Gilt für Sonarr und die Liste Ohne Deutsch.';if(settingsActions){settingsActions.before(seasonZeroLabel,seasonZeroHelp)}
|
</dialog>
|
||||||
function applySeasonZeroPreference(){const show=localStorage.getItem('show-season-zero')==='true';const rows=[...document.querySelectorAll('#sonarr tbody tr:not(.season-divider),#missing .missing-series tbody tr:not(.season-divider)')];rows.forEach(row=>{const title=row.querySelector('.title')?.textContent.trim()||'';row.classList.toggle('season-zero-hidden',!show&&/^S00E/i.test(title))});document.querySelectorAll('#sonarr details.series,#missing details.missing-series').forEach(detail=>{const episodeRows=[...detail.querySelectorAll('tbody tr:not(.season-divider)')];detail.classList.toggle('season-zero-empty',episodeRows.length>0&&episodeRows.every(row=>row.classList.contains('season-zero-hidden')))});const missingCount=document.querySelector('#missingCount'),missingNavCount=document.querySelector('#missingNavCount');if(missingCount||missingNavCount){const movies=document.querySelectorAll('#missingMovies tbody tr:not(.hidden)').length,episodes=document.querySelectorAll('#missing .missing-series tbody tr:not(.season-divider):not(.season-zero-hidden)').length,total=movies+episodes;if(missingCount)missingCount.textContent=`${total} Einträge ohne Deutsch`;if(missingNavCount)missingNavCount.textContent=total}const hint=document.querySelector('#seasonZeroHint');if(hint)hint.textContent=show?'Vorhanden oder wird gerade geladen · Staffel 0 eingeblendet':'Vorhanden oder wird gerade geladen · Staffel 0 ausgeblendet'}const seasonZeroToggle=document.querySelector('#showSeasonZero');if(seasonZeroToggle){seasonZeroToggle.checked=localStorage.getItem('show-season-zero')==='true';seasonZeroToggle.addEventListener('change',()=>{localStorage.setItem('show-season-zero',seasonZeroToggle.checked?'true':'false');applySeasonZeroPreference()})}applySeasonZeroPreference();
|
<script id="dashboardConfig" type="application/json">{{ {'sabEnabled': sab_enabled, 'libraryUpdatedAt': library_updated_at, 'libraryReady': library_ready}|tojson }}</script>
|
||||||
function syncDashboardMissingCounter(){const visibleMissing=document.querySelectorAll('#missingMovies tbody tr:not(.hidden),#missing .missing-series tbody tr:not(.season-zero-hidden):not(.season-divider)').length;const dashboardMissing=document.querySelector('#dashboardMissing');if(dashboardMissing)dashboardMissing.textContent=visibleMissing}syncDashboardMissingCounter();document.querySelector('#showSeasonZero')?.addEventListener('change',()=>setTimeout(syncDashboardMissingCounter,0));
|
</body>
|
||||||
if('serviceWorker' in navigator)window.addEventListener('load',()=>navigator.serviceWorker.register('/static/sw.js?v=3',{scope:'/'}).catch(()=>{}));
|
</html>
|
||||||
function updateMissingHeading(){const heading=document.querySelector('#missingMovies thead th:first-child');if(heading)heading.textContent=document.documentElement.lang==='en'?'Media':'Medien'}updateMissingHeading();document.querySelector('#languageToggle')?.addEventListener('click',()=>setTimeout(updateMissingHeading,0));
|
|
||||||
function addMissingSections(){const view=document.querySelector('#missing'),movieTable=document.querySelector('#missingMovies'),seriesItems=[...document.querySelectorAll('#missing .missing-series')];if(!view)return;const makeHeading=(type,de,en)=>{const heading=document.createElement('div');heading.className=`missing-section-heading ${type}`;heading.dataset.de=de;heading.dataset.en=en;heading.textContent=document.documentElement.lang==='en'?en:de;return heading};if(movieTable&&movieTable.querySelector('tbody tr'))movieTable.before(makeHeading('movies','Filme ohne Deutsch','Movies without German'));if(seriesItems.length)seriesItems[0].before(makeHeading('series','Serien ohne Deutsch','Series without German'))}addMissingSections();
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
#dashboard.view.active{display:flex}.view.dashboard-view:not(.active){display:none}.meter:first-child .meter-top span::before{content:'◔';display:inline-grid;place-items:center;width:20px;height:20px;margin-right:7px;border-radius:6px;background:rgba(96,169,230,.18);color:#8bc9f2;font-size:13px}.meter:nth-child(2) .meter-top span::before{content:'▤';display:inline-grid;place-items:center;width:20px;height:20px;margin-right:7px;border-radius:6px;background:rgba(146,87,194,.2);color:#d7adf3;font-size:13px}.disk-name::before{content:'▰';display:grid;grid-column:1;grid-row:1 / span 2;place-items:center;width:20px;height:20px;margin:0;border-radius:6px;background:rgba(244,196,48,.16);color:#f4c430;font-size:11px}.meter-top span,.disk-name{display:flex;align-items:center}.disk-name{display:grid;grid-template-columns:20px minmax(0,1fr);grid-template-rows:auto auto;column-gap:7px;align-items:center;min-width:92px;line-height:1.1}.disk-name:not(:has(.disk-path)){grid-template-rows:auto}.disk-name .disk-path{grid-column:2;grid-row:2;white-space:nowrap}.disk-name .disk-path:empty{display:none}
|
|
||||||
</style><style>.nav-count.download-nav-count{background:#f4c430!important;border-color:#ffd95a!important;color:#17191d!important;box-shadow:0 0 10px rgba(244,196,48,.28)}.service-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:20px}.service-summary{margin-left:auto;color:#9da5b2;font-size:12px;font-weight:700}.service-row{min-width:0}.service-row strong,.service-row small{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.service-status.timeout,.service-status.error{background:#806b25;color:#fff3bd}.service-status.offline{background:#7a3030;color:#ffd9d9}.service-timeout .service-logo,.service-error .service-logo{background:#806b25!important}.service-offline .service-logo{background:#7a3030!important}@media(max-width:700px){.service-list{grid-template-columns:1fr}.service-summary{font-size:10px}}</style></body></html>
|
|
||||||
<style>
|
|
||||||
details.series{margin-bottom:8px;border:1px solid var(--line);border-radius:6px;overflow:hidden;background:#222}
|
|
||||||
details.series:last-child{margin-bottom:0}
|
|
||||||
.series-category{margin-bottom:16px}.series-category:last-child{margin-bottom:0}.series-category-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 14px;margin-bottom:8px;border:1px solid rgba(96,169,230,.2);border-radius:9px;background:linear-gradient(100deg,rgba(44,55,70,.9),rgba(34,34,34,.9))}.series-category-heading h2{margin:0;color:#e9f2fb;font-size:15px}.series-category-heading span{color:#9da5b2;font-size:11px}
|
|
||||||
.nav-count{display:inline-block;min-width:18px;padding:1px 5px;margin-left:4px;border-radius:999px;background:#713030;color:#ffd9d9;font-size:10px}.empty-state{padding:28px;text-align:center;color:var(--muted)}.missing-table>table{border-bottom:1px solid var(--line)}.missing-series{border-radius:0!important;border-left:0!important;border-right:0!important;border-bottom:1px solid var(--line)!important}.missing-series:last-child{border-bottom:0!important}
|
|
||||||
</style>
|
|
||||||
<style>
|
|
||||||
.view#radarr.active{row-gap:12px}.view#radarr>.toolbar{padding:0 10px;background:transparent;border-color:transparent;box-shadow:none}.view#radarr>.toolbar input,.view#radarr>.toolbar select{background:rgba(42,46,54,.82);border-color:#454d59}.view#radarr>.toolbar input:focus,.view#radarr>.toolbar select:focus{background:#303640}
|
|
||||||
.season-zero-hidden,.season-zero-empty{display:none!important}.setting-toggle{display:flex!important;align-items:center;gap:10px;margin:18px 0 6px!important;cursor:pointer}.setting-toggle input{appearance:none;width:42px;height:23px;margin:0;border:1px solid #505967;border-radius:999px;background:#30343b;position:relative;cursor:pointer;transition:.2s}.setting-toggle input::after{content:"";position:absolute;width:17px;height:17px;left:2px;top:2px;border-radius:50%;background:#9da5b2;transition:.2s}.setting-toggle input:checked{background:#176f40;border-color:#23965b}.setting-toggle input:checked::after{transform:translateX(19px);background:#e4ffef}.setting-toggle span{font-weight:700;color:#cbd1db}
|
|
||||||
.missing-table table{table-layout:fixed}.missing-table tbody tr{transform:none!important;animation:none!important}.missing-table .missing-series tbody tr:hover{transform:none!important}
|
|
||||||
.missing-table{padding:10px}.missing-table>table{width:100%;margin:0 0 8px;border:1px solid rgba(255,255,255,.09);border-radius:10px;overflow:hidden}.missing-table .missing-series{margin:8px 0 0!important}.missing-table thead th{height:38px;padding:0 12px;vertical-align:middle;background:#2d3540;border-right:1px solid rgba(255,255,255,.06)}.missing-table thead th:last-child{border-right:0}.missing-table .missing-series summary{min-height:42px;display:flex;align-items:center}
|
|
||||||
.missing-section-heading{display:flex;align-items:center;gap:10px;margin:14px 0 8px;padding:10px 13px;border-radius:9px;font-size:12px;font-weight:800;letter-spacing:.04em;text-transform:uppercase}.missing-section-heading:first-child{margin-top:0}.missing-section-heading::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 10px currentColor}.missing-section-heading.movies{color:#8fc9f2;background:linear-gradient(90deg,rgba(96,169,230,.16),rgba(96,169,230,.04));border:1px solid rgba(96,169,230,.2)}.missing-section-heading.series{color:#e2a9aa;background:linear-gradient(90deg,rgba(190,83,83,.16),rgba(190,83,83,.04));border:1px solid rgba(190,83,83,.2)}
|
|
||||||
</style>
|
|
||||||
<script>document.querySelectorAll('details.series').forEach(series => { series.open = false; });</script>
|
|
||||||
<style>
|
|
||||||
header{z-index:100!important}
|
|
||||||
#seriesList{overflow:visible!important}
|
|
||||||
#seriesList details.series{overflow:visible!important}
|
|
||||||
#seriesList details.series>summary{position:sticky!important;top:64px!important;z-index:30!important}
|
|
||||||
#seriesList .season-divider td{top:110px!important;z-index:29!important}
|
|
||||||
</style>
|
|
||||||
<style>
|
|
||||||
.subtitle-de{background:#126f39!important;border-color:#19894b!important;color:#d9ffe8!important}
|
|
||||||
.audio-preferred{background:#2673a8!important;border-color:#64b5ed!important;color:#eef8ff!important;box-shadow:0 0 12px rgba(96,169,230,.3)}
|
|
||||||
.col-progress{width:16%}.col-time{width:10%}.col-source{width:10%}.col-language{width:12%}
|
|
||||||
.progress-wrap{display:flex;align-items:center;gap:8px}.progress-bar{height:7px;min-width:70px;flex:1;background:#3a3a3a;border-radius:99px;overflow:hidden}.progress-bar span{display:block;height:100%;background:#159447;border-radius:99px}.download-title{color:var(--blue);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block}
|
|
||||||
.download-hero{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:14px;padding:24px 26px;border:1px solid rgba(96,169,230,.2);border-radius:14px;background:radial-gradient(circle at 88% 20%,rgba(21,148,71,.18),transparent 32%),linear-gradient(135deg,rgba(39,48,61,.96),rgba(27,30,36,.96));box-shadow:0 16px 40px rgba(0,0,0,.22)}.eyebrow{color:#79b9e9;font-size:10px;font-weight:800;letter-spacing:.16em}.download-hero h2{margin:7px 0 4px;font-size:22px}.download-hero p{margin:0;color:#9da5b2}.queue-pill{display:flex;align-items:center;gap:9px;padding:10px 14px;border:1px solid rgba(255,255,255,.12);border-radius:999px;background:rgba(13,16,20,.35);white-space:nowrap;color:#d9dee7}.queue-dot{width:9px;height:9px;border-radius:50%;background:#f4c430;box-shadow:0 0 12px currentColor}.download-stats{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:14px}.download-card{display:flex;align-items:center;gap:12px;min-height:92px;padding:16px;border:1px solid rgba(255,255,255,.08);border-radius:11px;background:linear-gradient(145deg,rgba(43,47,55,.92),rgba(29,31,36,.92));box-shadow:0 8px 22px rgba(0,0,0,.16)}.card-icon{display:grid;place-items:center;width:38px;height:38px;border-radius:10px;background:rgba(21,148,71,.2);color:#6ae29a;font-size:21px;font-weight:700}.card-icon.blue{background:rgba(96,169,230,.18);color:#8bc9f2}.card-icon.purple{background:rgba(146,87,194,.2);color:#d7adf3}.card-icon.orange{background:rgba(244,166,48,.18);color:#ffd383}.download-card div{min-width:0}.card-label{display:block;color:#9da5b2;font-size:11px;text-transform:uppercase;letter-spacing:.06em}.download-card strong{display:block;margin:3px 0;font-size:20px;color:#f1f5fa}.download-card small{display:block;color:#7f8996;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.download-table{box-shadow:0 16px 34px rgba(0,0,0,.24)}
|
|
||||||
@media(max-width:900px){.download-stats{grid-template-columns:repeat(2,1fr)}.download-hero{align-items:flex-start;flex-direction:column}.queue-pill{align-self:stretch;justify-content:center}}@media(max-width:520px){.download-stats{grid-template-columns:1fr}.download-hero{padding:18px}.download-hero h2{font-size:19px}}
|
|
||||||
</style>
|
|
||||||
<style>
|
|
||||||
.missing-table{padding:10px}.missing-table>table{width:100%;margin:0 0 8px;border:1px solid rgba(255,255,255,.09);border-radius:10px;overflow:hidden}.missing-table .missing-series{margin:8px 0 0!important}.missing-table thead th{height:38px;padding:0 12px;vertical-align:middle;background:#2d3540;border-right:1px solid rgba(255,255,255,.06)}.missing-table thead th:last-child{border-right:0}.missing-table .missing-series summary{min-height:42px;display:flex;align-items:center}
|
|
||||||
</style>
|
|
||||||
<style>
|
|
||||||
.datastore-hero{display:flex;justify-content:space-between;align-items:center;gap:18px;margin-bottom:14px;padding:22px 26px;border:1px solid rgba(96,169,230,.2);border-radius:14px;background:radial-gradient(circle at 88% 20%,rgba(96,169,230,.14),transparent 32%),linear-gradient(135deg,rgba(39,48,61,.96),rgba(27,30,36,.96));box-shadow:0 14px 34px rgba(0,0,0,.2)}.datastore-hero h2{margin:7px 0 4px;font-size:22px}.datastore-hero p{margin:0;color:#9da5b2}.datastore-hero code{color:#b9d9f1}.datastore-live{padding:8px 12px;border:1px solid rgba(106,226,154,.22);border-radius:999px;color:#aaf2c5;background:rgba(21,148,71,.12);font-size:11px}.datastore-storage{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:12px}.storage-card{display:flex;align-items:center;gap:11px;min-height:82px;padding:14px;border:1px solid rgba(255,255,255,.08);border-radius:11px;background:linear-gradient(145deg,rgba(43,47,55,.92),rgba(29,31,36,.92));box-shadow:0 8px 22px rgba(0,0,0,.16)}.storage-icon{display:grid;place-items:center;width:35px;height:35px;border-radius:10px;background:rgba(96,169,230,.16);color:#8bc9f2;font-weight:800}.storage-icon.used{background:rgba(244,196,48,.16);color:#f4d267}.storage-icon.free{background:rgba(21,148,71,.16);color:#6ae29a}.storage-icon.percent{background:rgba(146,87,194,.18);color:#d7adf3}.storage-card small{display:block;color:#9da5b2;font-size:10px;text-transform:uppercase;letter-spacing:.06em}.storage-card strong{display:block;margin-top:3px;color:#f1f5fa;font-size:18px}.datastore-panel{padding:18px;border:1px solid rgba(255,255,255,.08);border-radius:12px;background:rgba(31,33,38,.84);box-shadow:0 12px 30px rgba(0,0,0,.18)}.datastore-capacity{margin-bottom:12px}.datastore-panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:14px}.datastore-panel-head h3{margin:0 0 4px;font-size:15px}.datastore-panel-head p{margin:0;color:#7f8996;font-size:11px}.datastore-panel-head>strong{color:#dce9f4;font-size:12px}.datastore-progress{height:10px;border-radius:99px;overflow:hidden;background:#363b44}.datastore-progress span{display:block;height:100%;width:0;border-radius:99px;background:linear-gradient(90deg,#60a9e6,#f4c430);transition:width .5s ease}.datastore-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.datastore-categories{display:grid;gap:13px}.category-row-head{display:flex;justify-content:space-between;gap:10px;color:#cbd1db;font-size:12px}.category-row-head strong{color:#f1f5fa}.category-track{height:7px;margin:6px 0 4px;border-radius:99px;overflow:hidden;background:#363b44}.category-track span{display:block;height:100%;border-radius:99px;background:linear-gradient(90deg,#60a9e6,#159447);transition:width .5s ease}.category-row small{color:#7f8996;font-size:10px}.raid-status{padding:5px 9px;border-radius:999px;font-size:11px;font-weight:800}.raid-status.ok{background:#126f39;color:#d9ffe8}.raid-status.rebuilding{background:#245a91;color:#d9efff}.raid-status.degraded{background:#806b25;color:#fff3bd}.raid-status.failed{background:#7a3030;color:#ffd9d9}.raid-status.unknown{background:#3b3b3b;color:#aaa}.raid-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:14px}.raid-summary span{padding:9px;border:1px solid rgba(255,255,255,.07);border-radius:8px;background:rgba(10,14,20,.2)}.raid-summary small,.raid-summary strong{display:block}.raid-summary small{color:#7f8996;font-size:10px}.raid-summary strong{margin-top:3px;color:#e8eef5;font-size:14px}.raid-disks{display:grid;gap:7px}.raid-disk{display:flex;align-items:center;gap:9px;padding:9px 10px;border:1px solid rgba(255,255,255,.07);border-radius:8px;background:rgba(40,43,49,.65)}.raid-disk>div{min-width:0;flex:1}.raid-disk strong,.raid-disk small{display:block}.raid-disk strong{color:#dce9f4;font-size:12px}.raid-disk small{margin-top:2px;color:#7f8996;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.raid-disk b{font-size:10px;text-transform:uppercase;color:#b8c2cd}.disk-status{width:9px;height:9px;flex:0 0 auto;border-radius:50%;background:#929292}.disk-status.active{background:#6ae29a;box-shadow:0 0 9px #6ae29a}.disk-status.failed{background:#ff8585;box-shadow:0 0 9px #ff8585}.disk-status.spare{background:#b9b9b9}.disk-status.rebuilding{background:#60a9e6;box-shadow:0 0 9px #60a9e6}.disk-status.missing{background:#f4c430}.raid-recovery{margin-bottom:14px;padding:12px;border:1px solid rgba(96,169,230,.2);border-radius:9px;background:rgba(48,69,92,.22)}.recovery-head,.recovery-meta{display:flex;justify-content:space-between;gap:12px}.recovery-head{margin-bottom:8px;color:#ddecfa;font-size:12px}.recovery-meta{margin-top:8px;color:#9da5b2;font-size:10px}.recovery-meta strong{color:#e8eef5}.datastore-empty{padding:16px;text-align:center;color:#7f8996;font-size:11px}@media(max-width:900px){.datastore-storage{grid-template-columns:repeat(2,1fr)}.datastore-grid{grid-template-columns:1fr}}@media(max-width:600px){.datastore-hero{alignLine truncated
|
|
||||||
</style>
|
|
||||||
<script>
|
|
||||||
document.querySelectorAll('#movies tbody tr td:nth-child(5) .badge,#sonarr tbody tr td:nth-child(5) .badge').forEach(badge=>{if(badge.textContent.includes('Deutsch'))badge.classList.add('subtitle-de')});
|
|
||||||
document.querySelectorAll('#movies tbody tr,#sonarr tbody tr').forEach(row=>{if(row.querySelector('.state.downloading'))row.children[1].querySelector('.badge')?.classList.add('downloading-badge')});
|
|
||||||
const downloadTable=document.querySelector('.download-table');if(downloadTable){downloadTable.innerHTML='<section class="download-section"><div class="download-section-head"><h3>Aktive Downloads</h3><span id="activeDownloadSectionCount">0</span></div><table id="downloadsTable"><thead><tr><th class="col-title">Download</th><th class="col-state">Status</th><th class="col-progress">Fortschritt</th><th class="col-size">Größe / Rest</th><th class="col-time">Restzeit</th><th class="col-source">Quelle</th><th class="col-language">Sprache</th></tr></thead><tbody id="activeDownloadsBody"></tbody></table></section><section class="download-section download-secondary"><div class="download-section-head"><h3>Weitere Queue-Einträge</h3><span id="otherDownloadSectionCount">0</span></div><table id="otherDownloadsTable"><thead><tr><th class="col-title">Download</th><th class="col-state">Status</th><th class="col-progress">Fortschritt</th><th class="col-size">Größe / Rest</th><th class="col-time">Restzeit</th><th class="col-source">Quelle</th><th class="col-language">Sprache</th></tr></thead><tbody id="otherDownloadsBody"></tbody></table></section>'}const downloadBody=document.querySelector('#activeDownloadsBody'),otherDownloadBody=document.querySelector('#otherDownloadsBody');
|
|
||||||
function html(value){return String(value??'—').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
|
||||||
function renderDownloads(data){if(!downloadBody)return;const items=data.items||[], active=Number(data.active_count)||0, history=Number(data.history_count)||0, paused=!!data.paused, status=data.error?'Fehler':(paused?'Pausiert':(data.queue_status||'Idle'));document.querySelector('#downloadCount').textContent=`${items.length} Einträge`;const dashboardDownloads=document.querySelector('#dashboardDownloads'),dashboardRemaining=document.querySelector('#dashboardRemaining'),dashboardSabStatus=document.querySelector('#dashboardSabStatus');if(dashboardDownloads)dashboardDownloads.textContent=active;if(dashboardRemaining)dashboardRemaining.textContent=data.remaining?`Noch ${data.remaining}`:'Queue ist leer';if(dashboardSabStatus)dashboardSabStatus.textContent=data.error?'Fehler':(paused?'Pausiert':'Aktiv');document.querySelector('#sabRemaining').textContent=data.remaining||'—';document.querySelector('#sabRemainingHint').textContent=active?`${active} Einträge in der Queue`:'Queue ist leer';document.querySelector('#sabActive').textContent=active;document.querySelector('#sabHistory').textContent=`Verlauf: ${history} Einträge`;document.querySelector('#sabSpeedCard').textContent=data.speed||'—';document.querySelector('#sabStatusCard').textContent=status;document.querySelector('#sabQueueCard').innerHTML=`<span class="queue-dot ${paused?'paused':''}"></span><span>${html(paused?'Pausiert':(data.error?'Nicht erreichbar':'SABnzbd '+status))}</span>`;const error=document.querySelector('#downloadError');error.classList.toggle('hidden',!data.error);error.innerHTML=data.error?`<strong>SABnzbd:</strong> ${html(data.error)}`:'';downloadBody.innerHTML=items.length?items.map(item=>{const pct=Math.max(0,Math.min(100,Number(item.percentage)||0));const languages=(item.languages||[]).map(l=>`<span class="badge">${html(l.flag)} ${html(l.name)}</span>`).join('')||'<span class="muted">Nicht erkannt</span>';return `<tr><td><span class="download-title" title="${html(item.title)}">${html(item.title)}</span><span class="muted">${html(item.category)}</span></td><td><span class="state ${item.completed?'ok':'neutral'}">${html(item.status)}</span></td><td><div class="progress-wrap"><div class="progress-bar"><span style="width:${pct}%"></span></div><span>${pct.toFixed(1)}%</span></div></td><td>${html(item.size)}<br><span class="muted">Rest: ${html(item.remaining)}</span></td><td>${html(item.completed?'—':item.timeleft)}</td><td>${html(item.source)}</td><td><div class="badges">${languages}</div></td></tr>`}).join(''):'<tr><td colspan="7" class="muted">Keine aktuellen Downloads</td></tr>'}
|
|
||||||
async function fetchDownloads(){if(!downloadBody)return;try{const response=await fetch('/api/downloads');const data=await response.json();renderDownloads(data);if(typeof markDownloading==='function')markDownloading(data.items)}catch(error){renderDownloads({error:'Dashboard konnte SABnzbd nicht erreichen.',items:[]})}};
|
|
||||||
if(downloadBody){fetchDownloads();setInterval(fetchDownloads,4000)}
|
|
||||||
const diskList=document.querySelector('#diskList');
|
|
||||||
function renderSystem(data){const cpu=Number(data.cpu_percent)||0,ram=data.memory||{};const cpuValue=document.querySelector('#cpuValue'),ramValue=document.querySelector('#ramValue'),cpuBar=document.querySelector('#cpuBar'),ramBar=document.querySelector('#ramBar'),updated=document.querySelector('#systemUpdated');if(cpuValue)cpuValue.textContent=`${cpu.toFixed(1)}%`;if(ramValue)ramValue.textContent=`${ram.used||'—'} / ${ram.total||'—'} (${Number(ram.percent||0).toFixed(1)}%)`;if(cpuBar)cpuBar.style.width=`${Math.min(100,cpu)}%`;if(ramBar)ramBar.style.width=`${Math.min(100,Number(ram.percent)||0)}%`;if(updated)updated.textContent='Gerade aktualisiert';if(!diskList)return;diskList.innerHTML=(data.disks||[]).map(d=>`<div class="disk-row"><div class="disk-name">${html(d.label)}<span class="disk-path">${html(d.path)}</span></div><div class="disk-bar"><span style="width:${Math.min(100,Number(d.percent)||0)}%"></span></div><div class="disk-values">${html(d.used)} / ${html(d.total)}<br><span class="muted">${Number(d.percent||0).toFixed(1)}% belegt</span></div></div>`).join('')||'<div class="disk-row muted">Keine Laufwerke gefunden</div>'}
|
|
||||||
async function fetchSystem(){try{const response=await fetch('/api/system');renderSystem(await response.json())}catch(error){const updated=document.querySelector('#systemUpdated');if(updated)updated.textContent='Nicht verfügbar'}}
|
|
||||||
if(document.querySelector('#dashboard')){fetchSystem();setInterval(fetchSystem,5000)}
|
|
||||||
const datastoreView=document.querySelector('#datastore');function renderDatastore(data){const storage=data.storage||{},raid=data.raid||{};const set=(id,value)=>{const node=document.querySelector(id);if(node)node.textContent=value};set('#datastoreTotal',storage.total||'—');set('#datastoreUsed',storage.used||'—');set('#datastoreFree',storage.free||'—');set('#datastorePercent',storage.percent!=null?`${storage.percent}%`:'—');set('#datastorePath',storage.path||'/nesflix');set('#datastoreCapacityLabel',storage.percent!=null?`${storage.used||'—'} / ${storage.total||'—'} · ${storage.percent}%`:storage.error||'Nicht verfügbar');set('#datastoreUpdated',storage.scanned_at?'Gerade aktualisiert':'Nicht verfügbar');const capacityBar=document.querySelector('#datastoreCapacityBar');if(capacityBar)capacityBar.style.width=`${Math.min(100,Number(storage.percent)||0)}%`;const categories=document.querySelector('#datastoreCategories');if(categories){const values=(storage.categories||[]).filter(category=>category.bytes>0||['movies','series','anime','kdrama','downloads'].includes(category.key));categories.innerHTML=values.length?values.map(category=>`<div class="category-row"><div class="category-row-head"><span>${html(category.label)}</span><strong>${html(category.size||'0 B')}</strong></div><div class="category-track"><span style="width:${Math.min(100,Number(category.percent)||0)}%"></span></div><small>${Number(category.percent||0).toFixed(1)}% vom belegten Speicher · ${Number(category.files||0).toLocaleString('de-DE')} Dateien</small></div>`).join(''):'<div class="datastore-empty">Keine Kategorien gefunden</div>'}const badge=document.querySelector('#raidStatusBadge');if(badge){badge.textContent=raid.label||'UNKNOWN';badge.className=`raid-status ${raid.status||'unknown'}`}set('#raidDevice',raid.device||'/dev/md127');const summary=document.querySelector('#raidSummary');if(summary)summary.innerHTML=`<span><small>Level</small><strong>${html(raid.level||'—')}</strong></span><span><small>Aktiv</small><strong>${html(raid.active_devices??'—')} / ${html(raid.raid_devices??'—')}</strong></span><span><small>Fehlend</small><strong>${html(raid.failed_devices??0)}</strong></span>`;const recovery=raid.recovery||{},recoveryBox=document.querySelector('#raidRecovery');if(recoveryBox){recoveryBox.classList.toggle('hidden',!recovery.active);set('#raidRecoveryPercent',`${Number(recovery.percent||0).toFixed(1)}%`);set('#raidRecoverySpeed',recovery.speed||'—');set('#raidRecoveryFinish',recovery.finish||'—');const recoveryBar=document.querySelector('#raidRecoveryBar');if(recoveryBar)recoveryBar.style.width=`${Math.min(100,Number(recovery.percent)||0)}%`}const disks=document.querySelector('#raidDisks');if(disks){disks.innerHTML=(raid.devices||[]).length?(raid.devices||[]).map(disk=>`<div class="raid-disk"><span class="disk-status ${html(disk.status||'unknown')}"></span><div><strong>${html(disk.device)}</strong><small>Slot ${html(disk.slot||'—')} · ${html(disk.model||'—')}</small><small>${html(disk.size||'—')} · Seriennummer: ${html(disk.serial||'—')}</small><small class="raid-smart">SMART: ${html(disk.smart||'Nicht aktiviert')}</small><small class="raid-temperature">Temperatur: ${html(disk.temperature||'Nicht verfügbar')}</small></div><b>${html(disk.status||'unknown')}</b></div>`).join(''):'<div class="datastore-empty">Keine RAID-Mitglieder erkannt</div>'}if(raid.error){const node=document.querySelector('#raidDisks .datastore-empty');if(node)node.textContent=raid.error}}async function fetchDatastore(){if(!datastoreView)return;try{renderDatastore(await (await fetch('/api/datastore')).json())}catch(error){renderDatastore({storage:{error:'Datastore nicht erreichbar'},raid:{status:'failed',label:'FAILED',error:'RAID-Status nicht verfügbar'}})}}if(datastoreView){fetchDatastore();setInterval(fetchDatastore,5000)}
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
.season-divider td{padding:6px 10px;background:#1b1b1b;border-top:1px solid #444;border-bottom:1px solid #303030;color:#aaa;font-size:11px;font-weight:700;letter-spacing:.3px}
|
|
||||||
#seriesList{overflow:visible}.series{overflow:visible!important}.series summary{position:sticky!important;top:64px!important;z-index:30!important}.season-divider td{top:110px!important;z-index:29!important}
|
|
||||||
.series[open] table{animation:seriesExpand .38s cubic-bezier(.2,.75,.25,1) both;transform-origin:top center}.series summary::after{content:'⌄';margin-left:auto;color:#929292;font-size:16px;line-height:1;transition:transform .3s ease,color .3s ease}.series[open] summary::after{transform:rotate(180deg);color:#60a9e6}@keyframes seriesExpand{from{opacity:0;transform:translateY(-8px) scaleY(.98)}to{opacity:1;transform:translateY(0) scaleY(1)}}
|
|
||||||
.series{position:relative;margin-bottom:14px;border:1px solid #353b46;border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.14);transition:border-color .25s ease,box-shadow .25s ease,transform .25s ease}.series:hover{border-color:#4a5565;box-shadow:0 12px 30px rgba(0,0,0,.24);transform:translateY(-1px)}.series-current{border-color:#557ba5;box-shadow:0 0 0 1px rgba(96,169,230,.18),0 14px 34px rgba(0,0,0,.25)}.series summary{position:sticky;top:0;z-index:5;display:flex;align-items:center;min-height:46px;padding:12px 14px;background:rgba(40,40,40,.96);backdrop-filter:blur(12px);border-bottom:1px solid transparent;transition:background .25s ease,border-color .25s ease,color .25s ease}.series-current summary{background:rgba(47,61,78,.98)}.series summary::marker{color:#60a9e6}.series[open] summary{border-bottom-color:#3d444f;box-shadow:0 4px 14px rgba(0,0,0,.16)}.series summary:hover{background:rgba(54,60,70,.97);color:#fff}.series summary .muted{margin-left:7px}.series table tbody tr:not(.season-divider){animation:rowIn .35s ease both;transition:background .2s ease,transform .2s ease}.series table tbody tr:not(.season-divider):hover{background:#292e36;transform:translateX(2px)}.season-divider td{position:sticky;top:46px;z-index:4;background:rgba(27,27,27,.96);backdrop-filter:blur(8px)}
|
|
||||||
.state.downloading,.downloading-badge{animation:downloadPulse 2s ease-in-out infinite}.progress-bar span{position:relative;overflow:hidden}.progress-bar span::after{content:"";position:absolute;inset:0;background:linear-gradient(110deg,transparent 20%,rgba(255,255,255,.25) 48%,transparent 72%);animation:progressShimmer 1.8s linear infinite}.nav button{transition:color .2s ease,background .2s ease,transform .2s ease}.nav button:hover{transform:translateY(-1px)}.badge{transition:transform .18s ease,filter .18s ease}.badge:hover{transform:translateY(-2px);filter:brightness(1.15)}
|
|
||||||
@keyframes rowIn{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}}@keyframes downloadPulse{0%,100%{box-shadow:0 0 0 rgba(113,60,158,0)}50%{box-shadow:0 0 14px rgba(146,87,194,.35)}}@keyframes progressShimmer{from{transform:translateX(-100%)}to{transform:translateX(100%)}}
|
|
||||||
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-duration:.01ms!important}}
|
|
||||||
.view#sonarr{padding-bottom:4px}.view#sonarr #seriesList{border:0;border-radius:0;background:transparent;overflow:visible}.series-category{margin:0 0 18px;padding:0 0 2px;border:1px solid rgba(96,169,230,.16);border-radius:13px;background:linear-gradient(145deg,rgba(34,39,47,.82),rgba(25,27,31,.78));box-shadow:0 10px 28px rgba(0,0,0,.14);overflow:hidden}.series-category:last-child{margin-bottom:0}.series-category-heading{margin:0;padding:13px 16px;border:0;border-bottom:1px solid rgba(96,169,230,.16);border-radius:0;background:linear-gradient(100deg,rgba(54,74,98,.72),rgba(36,42,50,.7));box-shadow:inset 3px 0 0 #60a9e6}.series-category-heading h2{font-size:14px;letter-spacing:.02em}.series-category-heading span{padding:3px 8px;border:1px solid rgba(255,255,255,.1);border-radius:999px;background:rgba(10,14,20,.2)}.series-category>.series{margin:8px 10px 0;border-color:rgba(255,255,255,.08);border-radius:9px;background:rgba(31,33,38,.78);box-shadow:0 5px 15px rgba(0,0,0,.13)}.series-category>.series:last-child{margin-bottom:10px}.series-category>.series:hover{transform:none;border-color:rgba(96,169,230,.4);box-shadow:0 8px 22px rgba(0,0,0,.2)}.series-category>.series summary{position:relative!important;top:auto!important;z-index:1;min-height:42px;padding:10px 13px;background:rgba(42,45,51,.8);font-size:12px}.series-category>.series[open] summary{background:rgba(51,65,82,.88);border-bottom-color:rgba(96,169,230,.24)}.series-category>.series table{background:rgba(22,24,28,.42)}.series-category>.series th{padding:7px 10px;background:rgba(40,45,53,.86);font-size:10px;color:#9eb8ce;text-transform:uppercase;letter-spacing:.05em}.series-category>.series td{padding:8px 10px}.series-category .season-divider td{position:static!important;top:auto!important;padding:7px 13px;background:rgba(12,15,19,.72);color:#9da5b2;border-top:0}.missing-table{border:1px solid rgba(255,255,255,.1);border-radius:13px;background:rgba(28,30,35,.82);box-shadow:0 10px 30px rgba(0,0,0,.18);overflow:hidden}.missing-table>table{background:transparent}.missing-table>table th{padding:10px 12px;background:linear-gradient(100deg,#303741,#272b32);color:#b5c7d8;text-transform:uppercase;font-size:10px;letter-spacing:.06em}.missing-table>table td{padding:9px 12px}.missing-table>table tbody tr:hover{background:rgba(96,169,230,.08)}.missing-table .missing-series{margin:8px 10px!important;border:1px solid rgba(255,255,255,.09)!important;border-radius:10px!important;background:rgba(36,38,43,.78);box-shadow:0 5px 18px rgba(0,0,0,.14)}.missing-table .missing-series summary{position:relative!important;top:auto!important;z-index:1;min-height:44px;padding:11px 13px;background:rgba(47,45,45,.78);border-bottom:0}.missing-table .missing-series[open] summary{background:rgba(91,55,58,.78);border-bottom-color:rgba(255,130,130,.2)}.missing-table .missing-series table{background:rgba(22,24,28,.42)}.missing-table .missing-series th{padding:7px 10px;background:rgba(56,47,49,.84);font-size:10px;color:#d6afb2;text-transform:uppercase;letter-spacing:.05em}.missing-table .missing-series td{padding:8px 10px}.missing-table .missing-series tbody tr:hover{background:rgba(210,100,100,.08)}
|
|
||||||
</style>
|
|
||||||
<style>
|
|
||||||
:root{--accent:#f4c430;--accent-blue:#60a9e6;--surface:rgba(35,37,42,.82);--surface-strong:#282b31;--border-soft:rgba(255,255,255,.09)}
|
|
||||||
html,body{min-height:100%;background:#151619}body{min-height:100vh;background:radial-gradient(circle at 10% -15%,rgba(96,169,230,.12),transparent 33%),radial-gradient(circle at 90% 0%,rgba(244,196,48,.07),transparent 28%),#151619;background-attachment:fixed;letter-spacing:.01em}
|
|
||||||
header{height:64px;background:rgba(31,33,38,.9);backdrop-filter:blur(18px);border-bottom:1px solid var(--border-soft);box-shadow:0 8px 30px rgba(0,0,0,.2);position:sticky;top:0;z-index:20}.logo{font-size:23px;text-shadow:0 0 18px rgba(244,196,48,.24)}.head-title{font-size:14px;letter-spacing:.01em}.head-sub{color:#9da5b2}
|
|
||||||
main{max-width:1800px;margin:0 auto;padding:18px 20px 42px}.nav{gap:6px;margin:0 0 16px;padding:4px;border:1px solid var(--border-soft);border-radius:9px;background:rgba(31,33,38,.72);box-shadow:0 8px 24px rgba(0,0,0,.12)}.nav button{padding:9px 14px;border-radius:6px;color:#aeb5c1}.nav button.active{background:linear-gradient(135deg,#3a3f48,#2d3139);box-shadow:inset 0 1px rgba(255,255,255,.08),0 4px 12px rgba(0,0,0,.18);color:#fff}.nav button.active::after{content:"";display:block;height:2px;margin:6px 2px -4px;border-radius:2px;background:var(--accent-blue)}
|
|
||||||
.toolbar{padding:1px 10px;border:1px solid var(--border-soft);border-radius:8px;background:rgba(35,37,42,.72);box-shadow:0 8px 20px rgba(0,0,0,.12);gap:8px}.toolbar input,.toolbar select{height:30px;padding:5px 9px;background:#2b2e34;border-color:#474d57;transition:border-color .2s,box-shadow .2s,background .2s}.toolbar input:focus,.toolbar select:focus{outline:none;background:#30343b;border-color:var(--accent-blue);box-shadow:0 0 0 3px rgba(96,169,230,.14)}
|
|
||||||
.view#radarr{display:none}.view#radarr.active{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:10px;align-items:start}.view#radarr>.toolbar{grid-column:1;grid-row:1;height:34px;margin-bottom:0}.view#radarr>.stats{grid-column:2;grid-row:1;height:34px;margin:0;align-self:stretch;align-items:stretch;flex-wrap:nowrap;white-space:nowrap}.view#radarr>.stats>.stat{height:34px;display:flex;align-items:center}.view#radarr>.tablewrap{grid-column:1/-1;grid-row:2}
|
|
||||||
.stats{gap:9px;margin:12px 0}.stat{background:linear-gradient(135deg,rgba(48,52,60,.9),rgba(34,36,41,.9));border:1px solid var(--border-soft);border-radius:8px;padding:7px 11px;box-shadow:0 5px 15px rgba(0,0,0,.12)}.stat:first-child{color:#fff;font-weight:700}.scan{color:#f4c430!important}
|
|
||||||
.tablewrap{border:1px solid var(--border-soft);border-radius:10px;background:rgba(31,33,38,.84);box-shadow:0 14px 34px rgba(0,0,0,.2)}table{border-spacing:0}thead th{height:38px;background:linear-gradient(180deg,#30343b,#292c32);color:#bfc6d1;text-transform:uppercase;letter-spacing:.07em;font-size:10px}tbody td{height:42px;border-bottom-color:rgba(255,255,255,.075)}tbody tr{transition:background .2s,transform .2s}tbody tr:hover{background:rgba(96,169,230,.065);transform:translateX(1px)}.title{font-size:13px}.quality{box-shadow:0 0 12px rgba(21,148,71,.18)}
|
|
||||||
#rescan{padding:9px 13px;background:linear-gradient(135deg,#343943,#292c32);border-color:#515865;box-shadow:0 5px 16px rgba(0,0,0,.18);transition:transform .2s,box-shadow .2s,border-color .2s}#rescan:hover{transform:translateY(-2px);border-color:var(--accent-blue);box-shadow:0 8px 20px rgba(0,0,0,.28)}
|
|
||||||
.actions{display:flex;align-items:center;gap:7px}.lang-switch,.settings-button{height:36px;min-width:36px;padding:0 9px;background:rgba(48,52,60,.9);border-color:#515865;color:#d9dee7;font-weight:700}.settings-button{font-size:17px;padding:0}.lang-switch:hover,.settings-button:hover{border-color:var(--accent-blue);background:#363c47}.settings-backdrop{position:fixed;inset:0;z-index:200;display:grid;place-items:center;background:rgba(5,7,10,.68);backdrop-filter:blur(8px);animation:fadeIn .2s ease}.settings-backdrop.hidden{display:none}.settings-modal{width:min(440px,calc(100vw - 28px));padding:22px;border:1px solid #4a5362;border-radius:14px;background:linear-gradient(145deg,#292e36,#1f2228);box-shadow:0 24px 80px rgba(0,0,0,.55);animation:modalIn .28s cubic-bezier(.2,.8,.2,1)}.settings-head{display:flex;justify-content:space-between;gap:20px;margin-bottom:22px}.settings-head h2{margin:0 0 5px;font-size:19px}.settings-head p{margin:0;color:var(--muted);font-size:12px}.settings-close{padding:0;width:30px;height:30px;border:0;background:transparent;font-size:25px;color:#aeb5c1}.settings-modal label{display:block;margin:15px 0 7px;color:#cbd1db;font-size:12px;font-weight:700}.settings-modal select{width:100%;height:38px;background:#20242a;border-color:#505967}.settings-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:24px}.settings-actions button{padding:9px 14px}.settings-save{background:#176f40;border-color:#23965b;color:#e4ffef}.settings-save:hover{background:#1c8850}@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes modalIn{from{opacity:0;transform:translateY(12px) scale(.97)}to{opacity:1;transform:none}}
|
|
||||||
@media(max-width:760px){header{padding:0 12px}.head-sub{display:none}main{padding:12px}.view#radarr.active{display:block}.view#radarr>.toolbar{height:auto;margin-bottom:10px}.view#radarr>.stats{height:auto;align-items:initial;flex-wrap:wrap}.view#radarr>.stats>.stat{height:auto}.toolbar{grid-template-columns:1fr}.actions{margin-left:auto}.nav{overflow-x:auto}.nav button{white-space:nowrap}.tablewrap{overflow-x:auto}table{min-width:900px}}
|
|
||||||
main{min-height:calc(100vh - 64px)}
|
|
||||||
.download-table{display:grid;gap:14px;border:0;background:transparent;box-shadow:none;overflow:visible}.download-section{border:1px solid rgba(255,255,255,.09);border-radius:12px;overflow:hidden;background:rgba(31,33,38,.84);box-shadow:0 12px 28px rgba(0,0,0,.18)}.download-section-head{display:flex;align-items:center;justify-content:space-between;padding:13px 15px;border-bottom:1px solid rgba(255,255,255,.08);background:linear-gradient(100deg,rgba(46,56,69,.9),rgba(36,39,45,.9))}.download-section-head h3{margin:0;color:#e8eef5;font-size:14px}.download-section-head span{min-width:24px;padding:3px 8px;border-radius:999px;background:rgba(96,169,230,.16);color:#9ed2f5;font-size:11px;text-align:center}.download-secondary .download-section-head{background:linear-gradient(100deg,rgba(53,47,47,.9),rgba(38,37,41,.9))}.download-secondary .download-section-head span{background:rgba(244,196,48,.15);color:#f4d267}.download-section table{background:transparent}.download-section tbody tr:hover{background:rgba(96,169,230,.07)}
|
|
||||||
</style>
|
|
||||||
<script>
|
|
||||||
document.querySelectorAll('details.series tbody').forEach(tbody=>{let currentSeason=null;[...tbody.querySelectorAll('tr')].forEach(row=>{if(row.classList.contains('season-divider'))return;const match=row.querySelector('.title')?.textContent.match(/^S(\d{2})E\d{2}/i);if(!match||match[1]===currentSeason)return;currentSeason=match[1];const divider=document.createElement('tr');divider.className='season-divider';divider.innerHTML=`<td colspan="7">Staffel ${Number(currentSeason)}</td>`;tbody.insertBefore(divider,row)})});
|
|
||||||
document.querySelectorAll('details.series').forEach(series=>{const summary=series.querySelector('summary');if(!summary)return;summary.title='Klicken zum Auf- oder Zuklappen';const observer=new IntersectionObserver(([entry])=>series.classList.toggle('series-current',!entry.isIntersecting&&series.open),{threshold:0,rootMargin:'-58px 0px 0px 0px'});observer.observe(summary)});
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
.downloading-badge{background:#713c9e!important;border-color:#9257c2!important;color:#f2e5ff!important}.state.downloading{background:#713c9e;color:#f2e5ff}
|
|
||||||
header{z-index:100!important}
|
|
||||||
.downloading-state{background:#713c9e!important;color:#f2e5ff!important}
|
|
||||||
</style>
|
|
||||||
<script>
|
|
||||||
function normalizeDownloadText(value){return String(value||'').toLowerCase().replace(/[._()[\]{}'\-·]/g,' ').replace(/\s+/g,' ').trim()}
|
|
||||||
function markDownloading(items){
|
|
||||||
const downloads=(items||[]).filter(item=>!item.completed).map(item=>normalizeDownloadText(item.title));
|
|
||||||
document.querySelectorAll('#movies tbody tr,#sonarr tbody tr').forEach(row=>{
|
|
||||||
if(row.dataset.downloadMarked==='1'){
|
|
||||||
row.children[1].innerHTML='<span class="badge">Keine Datei</span>';
|
|
||||||
row.children[2].innerHTML='<span class="state bad">Fehler</span>';
|
|
||||||
row.dataset.downloadMarked='0';
|
|
||||||
}
|
|
||||||
const details=row.closest('details.series');
|
|
||||||
const seriesName=details?normalizeDownloadText(details.querySelector('summary')?.textContent.split('(')[0]):'';
|
|
||||||
const rowName=normalizeDownloadText(row.querySelector('.title')?.textContent);
|
|
||||||
const rowTokens=rowName.split(' ').filter(token=>token.length>2);
|
|
||||||
const isMatch=downloads.some(download=>download.includes(rowName)||(seriesName&&download.includes(seriesName)&&download.includes(rowName.match(/s\d{2}e\d{2}/)?.[0]||''))||(rowTokens.length>=3&&rowTokens.filter(token=>download.includes(token)).length>=Math.ceil(rowTokens.length*.75)));
|
|
||||||
if(!rowName||!isMatch)return;
|
|
||||||
const quality=row.children[1], state=row.children[2];
|
|
||||||
if(quality.textContent.includes('Keine Datei')||row.dataset.downloadMarked==='1'){
|
|
||||||
quality.innerHTML='<span class="badge downloading-badge">Downloading</span>';
|
|
||||||
state.innerHTML='<span class="state downloading-state">Downloading</span>';
|
|
||||||
row.dataset.downloadMarked='1';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const originalRenderDownloads=renderDownloads;
|
|
||||||
renderDownloads=function(data){originalRenderDownloads(data);const rows=[...downloadBody.querySelectorAll('tr')],activeRows=[],otherRows=[];rows.forEach(row=>{const status=(row.querySelector('.state')?.textContent||'').trim().toLowerCase();if(status==='downloading')activeRows.push(row);else if(row.querySelector('.download-title'))otherRows.push(row)});downloadBody.innerHTML='';otherDownloadBody.innerHTML='';activeRows.forEach(row=>downloadBody.appendChild(row));otherRows.forEach(row=>otherDownloadBody.appendChild(row));if(!activeRows.length)downloadBody.innerHTML='<tr><td colspan="7" class="muted">Keine aktiven Downloads</td></tr>';if(!otherRows.length)otherDownloadBody.innerHTML='<tr><td colspan="7" class="muted">Keine weiteren Queue-Einträge</td></tr>';document.querySelector('#activeDownloadSectionCount').textContent=activeRows.length;document.querySelector('#otherDownloadSectionCount').textContent=otherRows.length;markDownloading(data.items);const navCount=document.querySelector('#downloadNavCount'),active=Number(data.active_count)||0;if(navCount){navCount.textContent=active;navCount.classList.toggle('hidden',active===0)}};
|
|
||||||
if(downloadBody)fetchDownloads();
|
|
||||||
</script>
|
|
||||||
<script>
|
|
||||||
const uiTranslations={de:{film:'Film',quality:'Qualität',status:'Status',audio:'Audio',subs:'Untertitel',video:'Video',size:'Größe',episode:'Episode',allAudio:'Alle Audio-Sprachen',allSubs:'Alle Untertitel',allStatus:'Alle Status',searchMovie:'Film suchen …',searchSeries:'Serie suchen …'},en:{film:'Movie',quality:'Quality',status:'Status',audio:'Audio',subs:'Subtitles',video:'Video',size:'Size',episode:'Episode',allAudio:'All audio languages',allSubs:'All subtitles',allStatus:'All statuses',searchMovie:'Search movies …',searchSeries:'Search series …'}};
|
|
||||||
let currentUiLanguage=localStorage.getItem('dashboard-language')||'de';
|
|
||||||
function translateLayout(language){
|
|
||||||
currentUiLanguage=language;localStorage.setItem('dashboard-language',language);document.documentElement.lang=language;const t=uiTranslations[language];
|
|
||||||
document.querySelectorAll('[data-de][data-en]').forEach(node=>{node.textContent=node.dataset[language]});
|
|
||||||
document.querySelector('#languageToggle').textContent=language==='de'?'EN':'DE';
|
|
||||||
const navLabels={dashboard:['▦ Dashboard','▦ Dashboard'],radarr:['RADARR · Filme','RADARR · Movies'],sonarr:['SONARR · Serien','SONARR · Series'],missing:['⚠ Ohne Deutsch','⚠ Without German'],downloads:['SABNZBD · Downloads','SABNZBD · Downloads']};document.querySelectorAll('.nav button').forEach(button=>{const labels=navLabels[button.dataset.view];if(labels)button.childNodes[0].textContent=labels[language==='de'?0:1]});
|
|
||||||
const searchInput=document.querySelector('#search');if(searchInput)searchInput.placeholder=t.searchMovie;
|
|
||||||
const filterOptions=[['#audioFilter',t.allAudio],['#subFilter',t.allSubs],['#stateFilter',t.allStatus]];filterOptions.forEach(([selector,label])=>{const first=document.querySelector(`${selector} option:first-child`);if(first)first.textContent=label});
|
|
||||||
document.querySelectorAll('#movies thead th').forEach((th,i)=>{th.textContent=[t.film,t.quality,t.status,t.audio,t.subs,t.video,t.size][i]||th.textContent});
|
|
||||||
document.querySelectorAll('#seriesList thead th').forEach((th,i)=>{th.textContent=[t.episode,t.quality,t.status,t.audio,t.subs,t.video,t.size][i]||th.textContent});
|
|
||||||
document.querySelectorAll('#downloadsTable thead th').forEach((th,i)=>{th.textContent=(language==='de'?['Download','Status','Fortschritt','Größe / Rest','Restzeit','Quelle','Sprache']:['Download','Status','Progress','Size / Remaining','Time left','Source','Language'])[i]||th.textContent});
|
|
||||||
}
|
|
||||||
function highlightPreferences(){const audioPreference=localStorage.getItem('preferred-audio')||'';document.querySelectorAll('#movies tbody tr td:nth-child(4) .badge,#sonarr tbody tr td:nth-child(4) .badge').forEach(badge=>badge.classList.toggle('audio-preferred',!!audioPreference&&badge.textContent.includes(audioPreference)))}
|
|
||||||
function loadPreferences(){const audioPreference=localStorage.getItem('preferred-audio')||'';const subsPreference=localStorage.getItem('preferred-subs')||'';const audioSelect=document.querySelector('#preferredAudio'),subsSelect=document.querySelector('#preferredSubs');if(audioSelect)audioSelect.value=audioPreference;if(subsSelect)subsSelect.value=subsPreference;const audioFilter=document.querySelector('#audioFilter'),subFilter=document.querySelector('#subFilter');if(audioFilter)audioFilter.value=audioPreference;if(subFilter)subFilter.value=subsPreference;if(typeof apply==='function')apply();highlightPreferences()}
|
|
||||||
translateLayout(currentUiLanguage);loadPreferences();
|
|
||||||
const serviceIconSvg={radarr:'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="7"/><circle cx="12" cy="12" r="2" fill="currentColor"/><path d="M12 5a7 7 0 0 1 6 3M12 12l6-5"/></svg>',sonarr:'<svg viewBox="0 0 24 24"><path d="M4 12h3M17 12h3M7 8a6 6 0 0 1 10 0M7 16a6 6 0 0 0 10 0M10 12h4"/><circle cx="12" cy="12" r="2" fill="currentColor"/></svg>',sabnzbd:'<svg viewBox="0 0 24 24"><path d="M12 4v11M8 11l4 4 4-4M5 19h14"/></svg>',jellyfin:'<svg viewBox="0 0 24 24"><path d="M7 4h10l4 8-4 8H7l-4-8 4-8Z"/><path d="m10 8 6 4-6 4V8Z" fill="currentColor"/></svg>',seerr:'<svg viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6"/><path d="m15 15 5 5M8 10h5"/></svg>',prowlarr:'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/><path d="m12 6 2 6-2 6-2-6 2-6ZM6 12h12"/></svg>',easynews:'<svg viewBox="0 0 24 24"><path d="M6 18h12a3 3 0 0 0 .5-6A6.5 6.5 0 0 0 6 10a4 4 0 0 0 0 8Z"/></svg>',qbittorrent:'<svg viewBox="0 0 24 24"><path d="m5 7 5-3 5 3-5 3-5-3ZM9 14l5-3 5 3-5 3-5-3ZM5 17l5-3 5 3-5 3-5-3Z"/></svg>',bazarr:'<svg viewBox="0 0 24 24"><path d="M5 6h14M5 12h9M5 18h14"/><path d="m17 10 2 2-2 2"/></svg>',lingarr:'<svg viewBox="0 0 24 24"><path d="M5 6h14v10H9l-4 3V6Z"/><path d="M9 10h6M9 13h4"/></svg>',libretranslate:'<svg viewBox="0 0 24 24"><path d="M5 6h8M9 4v2M6 6c1 5 4 8 8 10M6 16c3-1 5-3 7-6M14 6h5M16 4v2M14 18h7M17 10l-3 8M18 15h4"/></svg>',sonarr_prowlarr_proxy:'<svg viewBox="0 0 24 24"><path d="M5 8h8M11 5l3 3-3 3M19 16h-8M13 13l-3 3 3 3"/><circle cx="5" cy="8" r="2"/><circle cx="19" cy="16" r="2"/></svg>',radarr_prowlarr_proxy:'<svg viewBox="0 0 24 24"><path d="M5 8h8M11 5l3 3-3 3M19 16h-8M13 13l-3 3 3 3"/><circle cx="5" cy="8" r="2"/><circle cx="19" cy="16" r="2"/></svg>',dashboard:'<svg viewBox="0 0 24 24"><path d="m4 11 8-7 8 7v8a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-8Z"/><path d="M9 20v-6h6v6"/></svg>'};function upgradeServiceIcons(){document.querySelectorAll('.service-logo[class*="service-icon-"]').forEach(icon=>{const key=[...icon.classList].find(name=>name.startsWith('service-icon-'))?.slice(13);if(serviceIconSvg[key]&&icon.dataset.iconKey!==key){icon.innerHTML=serviceIconSvg[key];icon.dataset.iconKey=key}})}const serviceIconObserver=new MutationObserver(upgradeServiceIcons);const serviceIconList=document.querySelector('.service-list');if(serviceIconList)serviceIconObserver.observe(serviceIconList,{childList:true,subtree:true});upgradeServiceIcons();
|
|
||||||
document.querySelector('#languageToggle').addEventListener('click',()=>translateLayout(currentUiLanguage==='de'?'en':'de'));
|
|
||||||
const header=document.querySelector('header'),headerActions=document.querySelector('.actions'),mainNav=document.querySelector('.nav');if(header&&headerActions&&mainNav){mainNav.classList.add('header-nav');header.insertBefore(mainNav,headerActions);const headerStyle=document.createElement('style');headerStyle.textContent='.head-title,.head-sub{display:none}.header-nav{display:flex;align-items:center;gap:4px;margin:0 0 0 22px!important;padding:3px!important;border:0!important;background:transparent!important;box-shadow:none!important;overflow:visible}.header-nav button{padding:8px 11px;font-size:12px}.header-nav .nav-count{font-size:10px}header{gap:0}.brand{flex:0 0 auto}@media(max-width:850px){.header-nav{margin-left:10px!important;overflow-x:auto;max-width:calc(100vw - 250px);scrollbar-width:none}.header-nav::-webkit-scrollbar{display:none}.header-nav button{white-space:nowrap;padding:7px 9px;font-size:11px}}@media(max-width:560px){.header-nav{max-width:calc(100vw - 135px)}.header-nav button{font-size:0;padding:8px}.header-nav button[data-view="dashboard"]{font-size:0}.header-nav button[data-view="dashboard"]::before{content:"▦";font-size:16px}.header-nav button[data-view="radarr"]::before{content:"R";font-size:14px;font-weight:800}.header-nav button[data-view="sonarr"]::before{content:"S";font-size:14px;font-weight:800}.header-nav button[data-view="missing"]::before{content:"⚠";font-size:14px}.header-nav button[data-view="downloads"]::before{content:"↓";font-size:16px}}';document.head.appendChild(headerStyle)}
|
|
||||||
const officialServiceIcons={radarr:'https://cdn.simpleicons.org/radarr/CC7B19',sonarr:'https://cdn.simpleicons.org/sonarr/35C5F4',sabnzbd:'https://api.iconify.design/mdi:download-box.svg?color=%23F5A623',jellyfin:'https://cdn.simpleicons.org/jellyfin/AA5CC3',seerr:'https://api.iconify.design/mdi:movie-search.svg?color=%2373CFF8',prowlarr:'https://api.iconify.design/mdi:radar.svg?color=%235B6BBF',easynews:'https://api.iconify.design/mdi:cloud-download.svg?color=%23F28C28',qbittorrent:'https://cdn.simpleicons.org/qbittorrent/2F81C7',bazarr:'https://api.iconify.design/mdi:subtitles.svg?color=%2344A5B5',lingarr:'https://api.iconify.design/mdi:forum.svg?color=%238B5CF6',libretranslate:'https://cdn.simpleicons.org/libretranslate/22C55E'};Object.entries(officialServiceIcons).forEach(([key,url])=>{serviceIconSvg[key]=`<img src="${url}" alt="" loading="lazy" referrerpolicy="no-referrer">`});upgradeServiceIcons();
|
|
||||||
document.querySelectorAll('.service-logo svg').forEach(svg=>{svg.style.width='19px';svg.style.height='19px';svg.style.fill='none';svg.style.stroke='currentColor';svg.style.strokeWidth='1.8';svg.style.strokeLinecap='round';svg.style.strokeLinejoin='round'});
|
|
||||||
document.querySelectorAll('.service-logo img').forEach(img=>{img.style.width='20px';img.style.height='20px';img.style.objectFit='contain'});
|
|
||||||
setInterval(()=>{document.querySelectorAll('.service-logo svg').forEach(svg=>{svg.style.width='19px';svg.style.height='19px';svg.style.fill='none';svg.style.stroke='currentColor';svg.style.strokeWidth='1.8';svg.style.strokeLinecap='round';svg.style.strokeLinejoin='round'});document.querySelectorAll('.service-logo img').forEach(img=>{img.style.width='20px';img.style.height='20px';img.style.objectFit='contain'})},500);
|
|
||||||
const settingsBackdrop=document.querySelector('#settingsBackdrop');const closeSettings=()=>settingsBackdrop.classList.add('hidden');document.querySelector('#settingsOpen').addEventListener('click',()=>{loadPreferences();settingsBackdrop.classList.remove('hidden')});document.querySelector('#settingsClose').addEventListener('click',closeSettings);document.querySelector('#settingsCancel').addEventListener('click',closeSettings);settingsBackdrop.addEventListener('click',event=>{if(event.target===settingsBackdrop)closeSettings()});document.querySelector('#settingsSave').addEventListener('click',()=>{localStorage.setItem('preferred-audio',document.querySelector('#preferredAudio').value);localStorage.setItem('preferred-subs',document.querySelector('#preferredSubs').value);loadPreferences();closeSettings()});
|
|
||||||
</script>
|
|
||||||
<style>.nav-icon{width:15px;height:15px;margin-right:5px;vertical-align:-3px;object-fit:contain}.nav-tab-icon{width:15px;height:15px;margin-right:6px;vertical-align:-3px;object-fit:contain}.storage-icon img{width:20px;height:20px;object-fit:contain}.raid-status-side{display:flex;flex-direction:column;align-items:flex-end;gap:4px}.raid-status-side small{color:#8d9aaa;font-size:10px;white-space:nowrap}.raid-disks{grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}.raid-disk{padding:7px 9px}.raid-disk small{margin-top:1px}.raid-smart{opacity:.85}.series-category,.missing-table{overflow:visible!important}.series-category>.series summary,.missing-table .missing-series summary{position:sticky!important;top:64px!important;z-index:30!important}.series-category>.series[open] summary,.missing-table .missing-series[open] summary{box-shadow:0 4px 14px rgba(0,0,0,.25)}@media(max-width:700px){.raid-disks{grid-template-columns:1fr}}</style>
|
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
# Import without contacting the user's services or starting hardware probes.
|
||||||
|
with patch('threading.Thread.start'):
|
||||||
|
import app as dashboard
|
||||||
|
|
||||||
|
|
||||||
|
class LanguageTests(unittest.TestCase):
|
||||||
|
def probe(self, streams):
|
||||||
|
result = SimpleNamespace(returncode=0, stdout=json.dumps({'streams': streams, 'format': {'size': '1024'}}))
|
||||||
|
with patch.object(dashboard.subprocess, 'run', return_value=result), patch.object(dashboard, 'external_subtitle_files', return_value=[]):
|
||||||
|
return dashboard.run_ffprobe('/media/example.mkv')
|
||||||
|
|
||||||
|
def test_codes_names_and_regions(self):
|
||||||
|
for value, expected in [('GER', 'de'), ('de-DE', 'de'), ('eng_US', 'en'), ('German', 'de'), ('Portuguese', 'pt'), ('dut', 'nl'), ('pol', 'pl'), ('zho-Hans', 'zh'), ('unknown', 'und'), (None, 'und')]:
|
||||||
|
with self.subTest(value=value):
|
||||||
|
self.assertEqual(dashboard.norm_lang(value)['code'], expected)
|
||||||
|
|
||||||
|
def test_audio_title_fallback(self):
|
||||||
|
info = self.probe([{'codec_type': 'audio', 'tags': {'language': 'und', 'title': 'Deutsch / DTS-HD MA 5.1'}}])
|
||||||
|
self.assertEqual(info['audio_languages'][0]['name'], 'Deutsch')
|
||||||
|
self.assertEqual(info['audio_details'][0]['language_source'], 'title')
|
||||||
|
|
||||||
|
def test_uppercase_tags_and_subtitle_titles(self):
|
||||||
|
info = self.probe([{'codec_type': 'audio', 'tags': {'LANGUAGE': 'eng'}}, {'codec_type': 'subtitle', 'tags': {'TITLE': 'Português (forced)'}}])
|
||||||
|
self.assertEqual(info['audio_languages'][0]['code'], 'en')
|
||||||
|
self.assertEqual(info['subtitle_languages'][0]['code'], 'pt')
|
||||||
|
|
||||||
|
def test_explicit_tag_wins_over_title(self):
|
||||||
|
info = self.probe([{'codec_type': 'audio', 'tags': {'language': 'jpn', 'title': 'German commentary'}}])
|
||||||
|
self.assertEqual(info['audio_languages'][0]['code'], 'ja')
|
||||||
|
|
||||||
|
def test_ambiguous_titles_stay_unknown(self):
|
||||||
|
for title in ['DTS 5.1', 'German / English', 'Original', 'Commentary']:
|
||||||
|
with self.subTest(title=title):
|
||||||
|
info = self.probe([{'codec_type': 'audio', 'tags': {'title': title}}])
|
||||||
|
self.assertEqual(info['audio_languages'][0]['code'], 'und')
|
||||||
|
|
||||||
|
def test_languages_are_deduplicated(self):
|
||||||
|
info = self.probe([{'codec_type': 'audio', 'tags': {'language': code}} for code in ['de', 'deu', 'ger', 'eng']])
|
||||||
|
self.assertEqual([lang['code'] for lang in info['audio_languages']], ['de', 'en'])
|
||||||
|
|
||||||
|
def test_external_subtitles_only_use_suffix(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
media = Path(tmp) / 'English [2024].mkv'
|
||||||
|
media.touch()
|
||||||
|
for suffix in ['.pt-BR.forced.srt', '.ger.ass', '.forced.srt']:
|
||||||
|
media.with_name(media.stem + suffix).touch()
|
||||||
|
self.assertEqual({lang['code'] for lang in dashboard.external_subtitle_languages(media)}, {'pt', 'de'})
|
||||||
|
|
||||||
|
def test_file_metadata_fallback(self):
|
||||||
|
file = {'path': '/remote/movie.mkv', 'mediaInfo': {'audioLanguages': 'German / English'}}
|
||||||
|
with patch.object(dashboard, 'cached', return_value={'audio_languages': [dashboard.norm_lang('und')]}), patch.object(dashboard, 'external_subtitle_languages', return_value=[]):
|
||||||
|
row = dashboard.media_row('Movie', 2024, file, '/remote', '/local')
|
||||||
|
self.assertEqual({lang['code'] for lang in row['audio_languages']}, {'de', 'en'})
|
||||||
|
self.assertTrue(all(lang['source'] == 'arr' for lang in row['audio_languages']))
|
||||||
|
self.assertEqual(row['state']['label'], 'DE + EN')
|
||||||
|
|
||||||
|
def test_partial_known_tracks_not_replaced(self):
|
||||||
|
file = {'path': '/movie.mkv', 'languages': [{'name': 'German'}]}
|
||||||
|
tracks = [dashboard.norm_lang('en'), dashboard.norm_lang('und')]
|
||||||
|
with patch.object(dashboard, 'cached', return_value={'audio_languages': tracks}), patch.object(dashboard, 'external_subtitle_languages', return_value=[]):
|
||||||
|
row = dashboard.media_row('Movie', 2024, file, '', '')
|
||||||
|
self.assertEqual(row['audio_languages'], tracks)
|
||||||
|
self.assertEqual(row['state']['label'], 'Sprache prüfen')
|
||||||
|
|
||||||
|
def test_missing_german_ignores_failed_and_pending_scans(self):
|
||||||
|
base = {'missing': False, 'audio_languages': [dashboard.norm_lang('en')]}
|
||||||
|
self.assertTrue(dashboard.needs_german_review(base))
|
||||||
|
for extra in [{'pending': True}, {'error': 'ffprobe failed'}, {'missing': True}, {'subtitle_languages': [dashboard.norm_lang('de')]}]:
|
||||||
|
self.assertFalse(dashboard.needs_german_review({**base, **extra}))
|
||||||
|
|
||||||
|
def test_english_only_is_not_multilingual(self):
|
||||||
|
self.assertEqual(dashboard.language_state([dashboard.norm_lang('en'), dashboard.norm_lang('fr')])['label'], 'Andere')
|
||||||
|
|
||||||
|
def test_release_separators_and_dl_not_guessed(self):
|
||||||
|
self.assertEqual({lang['code'] for lang in dashboard.release_languages('Movie_[GER]_ENG_1080p')}, {'de', 'en'})
|
||||||
|
self.assertEqual([lang['code'] for lang in dashboard.release_languages('Movie.German.DL.1080p')], ['de'])
|
||||||
|
|
||||||
|
def test_original_movie_language_is_not_file_metadata(self):
|
||||||
|
self.assertEqual(dashboard.file_metadata_languages({'originalLanguage': {'name': 'German'}}), [])
|
||||||
|
|
||||||
|
def test_path_mapping_respects_directory_boundary(self):
|
||||||
|
self.assertEqual(dashboard.map_path('/data/films/a.mkv', '/data/films', '/local'), '/local/a.mkv')
|
||||||
|
self.assertEqual(dashboard.map_path('/data/films-other/a.mkv', '/data/films', '/local'), '/data/films-other/a.mkv')
|
||||||
|
|
||||||
|
|
||||||
|
class CacheTests(unittest.TestCase):
|
||||||
|
def test_old_scanner_and_transient_errors_are_retried(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp, patch.object(dashboard, 'DB_PATH', str(Path(tmp) / 'cache.db')):
|
||||||
|
media = Path(tmp) / 'film.mkv'
|
||||||
|
media.touch()
|
||||||
|
con = dashboard.db()
|
||||||
|
def store(version, error=None, age=0):
|
||||||
|
data = {'scanner_version': version, 'external_subtitle_files': [], 'audio_languages': []}
|
||||||
|
if error:
|
||||||
|
data['error'] = error
|
||||||
|
con.execute('INSERT OR REPLACE INTO media_cache VALUES(?,?,?,?)', (str(media), media.stat().st_mtime, json.dumps(data), (datetime.now(timezone.utc) - timedelta(seconds=age)).isoformat()))
|
||||||
|
con.commit()
|
||||||
|
store(dashboard.SCANNER_VERSION - 1)
|
||||||
|
self.assertIsNone(dashboard.cached(media))
|
||||||
|
store(dashboard.SCANNER_VERSION)
|
||||||
|
self.assertIsNotNone(dashboard.cached(media))
|
||||||
|
store(dashboard.SCANNER_VERSION, error='timeout')
|
||||||
|
self.assertIsNotNone(dashboard.cached(media))
|
||||||
|
store(dashboard.SCANNER_VERSION, error='timeout', age=301)
|
||||||
|
self.assertIsNone(dashboard.cached(media))
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
def test_missing_file_does_not_loop_scans(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
result = dashboard.cached(Path(tmp) / 'missing.mkv')
|
||||||
|
self.assertFalse(result['pending'])
|
||||||
|
self.assertIn('Datei nicht gefunden', result['error'])
|
||||||
|
|
||||||
|
|
||||||
|
class PageTests(unittest.TestCase):
|
||||||
|
def test_empty_and_enabled_views_render(self):
|
||||||
|
for enabled in [False, True]:
|
||||||
|
with self.subTest(enabled=enabled), patch.object(dashboard, 'SONARR_URL', 'http://sonarr' if enabled else ''), patch.object(dashboard, 'SONARR_API_KEY', 'test' if enabled else ''), patch.object(dashboard, 'SAB_URL', 'http://sab' if enabled else ''), patch.object(dashboard, 'SAB_API_KEY', 'test' if enabled else ''):
|
||||||
|
response = dashboard.app.test_client().get('/')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
page = response.get_data(as_text=True)
|
||||||
|
self.assertIn('id="sidebar"', page)
|
||||||
|
self.assertEqual('id="sonarr"' in page, enabled)
|
||||||
|
self.assertEqual('id="downloads"' in page, enabled)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const {readFileSync} = require('node:fs');
|
||||||
|
const {test} = require('node:test');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
|
||||||
|
// Load the actual helpers without bootstrapping API polling or page controls.
|
||||||
|
const source = readFileSync(`${__dirname}/../static/dashboard.js`, 'utf8');
|
||||||
|
const helpers = source.slice(0, source.indexOf('\nfunction closeSidebar()'));
|
||||||
|
|
||||||
|
function node(overrides = {}) {
|
||||||
|
return {
|
||||||
|
textContent: '', style: {}, calls: [],
|
||||||
|
getClientRects: () => [1], matches: () => false,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
animate(frames, options) {
|
||||||
|
const animation = {cancelled: false, cancel() { this.cancelled = true; }};
|
||||||
|
this.calls.push({frames, options, animation});
|
||||||
|
return animation;
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function harness() {
|
||||||
|
const nodes = new Map([['#dashboardConfig', {textContent: '{}'}]]);
|
||||||
|
const preference = {matches: false, addEventListener(type, listener) { this.listener = listener; }};
|
||||||
|
const document = {
|
||||||
|
hidden: false,
|
||||||
|
querySelector: selector => nodes.get(selector), querySelectorAll: () => [],
|
||||||
|
getAnimations: () => [],
|
||||||
|
};
|
||||||
|
const context = vm.createContext({document, window: {matchMedia: () => preference}, localStorage: {getItem: () => null}});
|
||||||
|
vm.runInContext(helpers, context);
|
||||||
|
return {nodes, preference, document, context};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('animations respect reduced motion, background tabs, hidden nodes and older browsers', () => {
|
||||||
|
const h = harness();
|
||||||
|
const target = node();
|
||||||
|
h.context.animate(target, []);
|
||||||
|
assert.equal(target.calls.length, 1);
|
||||||
|
h.preference.matches = true;
|
||||||
|
h.context.animate(target, []);
|
||||||
|
h.preference.matches = false;
|
||||||
|
h.document.hidden = true;
|
||||||
|
h.context.animate(target, []);
|
||||||
|
h.document.hidden = false;
|
||||||
|
target.getClientRects = () => [];
|
||||||
|
h.context.animate(target, []);
|
||||||
|
assert.equal(target.calls.length, 1);
|
||||||
|
assert.doesNotThrow(() => h.context.animate(node({animate: undefined}), []));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('new animations cancel older ones and delayed entrances stay hidden until they start', () => {
|
||||||
|
const h = harness();
|
||||||
|
const target = node();
|
||||||
|
h.context.animate(target, []);
|
||||||
|
h.context.animate(target, [], {delay: 35});
|
||||||
|
assert.equal(target.calls[0].animation.cancelled, true);
|
||||||
|
assert.equal(target.calls[1].options.fill, 'backwards');
|
||||||
|
assert.equal(target.calls[1].options.delay, 35);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('entrance work and stagger delays are bounded for large libraries', () => {
|
||||||
|
const h = harness();
|
||||||
|
const targets = Array.from({length: 1000}, () => node());
|
||||||
|
h.context.animateEntrance(node({querySelectorAll: () => targets}));
|
||||||
|
assert.equal(targets.filter(target => target.calls.length).length, 24);
|
||||||
|
assert.equal(targets[23].calls[0].options.delay, 210);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unchanged metrics do not animate again', () => {
|
||||||
|
const h = harness();
|
||||||
|
const target = node({matches: () => true});
|
||||||
|
h.nodes.set('#cpuValue', target);
|
||||||
|
vm.runInContext("set('cpuValue', '12.0%'); set('cpuValue', '12.0%');", h.context);
|
||||||
|
assert.equal(target.textContent, '12.0%');
|
||||||
|
assert.equal(target.calls.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('polling preserves progress and does not replay container entrances', () => {
|
||||||
|
const h = harness();
|
||||||
|
let markup = '', bars = [], writes = 0;
|
||||||
|
const card = node();
|
||||||
|
const root = node({querySelectorAll: selector => selector === '.track > span' ? bars : [card]});
|
||||||
|
Object.defineProperty(root, 'innerHTML', {
|
||||||
|
get: () => markup,
|
||||||
|
set(value) { markup = value; writes++; bars = [node({style: {width: value}})]; },
|
||||||
|
});
|
||||||
|
h.nodes.set('#progress', root);
|
||||||
|
h.context.renderContent('progress', '25%');
|
||||||
|
assert.equal(bars[0].calls[0].frames[0].width, '0%');
|
||||||
|
h.context.renderContent('progress', '40%');
|
||||||
|
assert.equal(bars[0].calls[0].frames[0].width, '25%');
|
||||||
|
assert.equal(bars[0].calls[0].frames[1].width, '40%');
|
||||||
|
assert.equal(card.calls.length, 1);
|
||||||
|
h.context.renderContent('progress', '40%');
|
||||||
|
assert.equal(writes, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enabling reduced motion cancels animations already in flight', () => {
|
||||||
|
const h = harness();
|
||||||
|
const animation = {cancelled: false, cancel() { this.cancelled = true; }};
|
||||||
|
h.document.getAnimations = () => [animation];
|
||||||
|
h.preference.listener({matches: true});
|
||||||
|
assert.equal(animation.cancelled, true);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user