Compare commits
111
Commits
c43f938afe
..
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 | ||
|
|
75f6de91b6 | ||
|
|
d4c24f02d4 | ||
|
|
02449a80de | ||
|
|
b275164be9 | ||
|
|
c6c2437732 | ||
|
|
01b6a7fd4e | ||
|
|
c2e2f7552f | ||
|
|
f820a99c7c | ||
|
|
9373e93e7f | ||
|
|
310b36317e | ||
|
|
71752483f6 | ||
|
|
5723a5d2a0 | ||
|
|
cef785361f | ||
|
|
d9033cbb6e | ||
|
|
2f71f92165 | ||
|
|
62d19c26a1 | ||
|
|
c2df06dda8 | ||
|
|
e84bc9c695 | ||
|
|
1f658adf71 | ||
|
|
e3dae93d33 | ||
|
|
44fbfaded4 | ||
|
|
cc7d719465 | ||
|
|
c15cc17142 | ||
|
|
87898fb2b2 | ||
|
|
022c628b23 | ||
|
|
24b2a3a12f | ||
|
|
02a5db61be | ||
|
|
10eb3a4191 | ||
|
|
f7638dd5d5 | ||
|
|
0c922d4f5d | ||
|
|
5ddf506f20 | ||
|
|
6f0c222e93 | ||
|
|
6b010c8ade | ||
|
|
c8d2b9f454 | ||
|
|
42ed29ac51 | ||
|
|
ceb4dfe120 | ||
|
|
3874415dc3 | ||
|
|
844227ba67 | ||
|
|
d0c29b7a38 | ||
|
|
77a36cae55 | ||
|
|
4762952e11 | ||
|
|
8312294a1d | ||
|
|
c090431f01 | ||
|
|
5b0289a383 | ||
|
|
f449e2d2f0 | ||
|
|
0905f4fa75 | ||
|
|
cdfa29d25f | ||
|
|
e49216dca5 | ||
|
|
2b8b0cd5e9 | ||
|
|
e508035474 | ||
|
|
d69f82d995 | ||
|
|
a90b516f49 | ||
|
|
04ca437054 | ||
|
|
ba906d4748 | ||
|
|
10bf5c32d3 | ||
|
|
61d65d5988 | ||
|
|
b5365170fb | ||
|
|
644c54a6f6 | ||
|
|
bcefa02c32 | ||
|
|
f63ba5381b | ||
|
|
0d48c9eef2 | ||
|
|
cce2f9fbb0 | ||
|
|
7daee41f65 | ||
|
|
5912b7078c | ||
|
|
3d4fb52fa3 | ||
|
|
94ab044a0a | ||
|
|
05e32fb5af | ||
|
|
7b788f7dc8 | ||
|
|
1558b081dd | ||
|
|
2f395e245a | ||
|
|
81f8d9ced4 | ||
|
|
e290c17f36 | ||
|
|
d202499cdd | ||
|
|
a276911ca7 | ||
|
|
7458b921be | ||
|
|
8327ed567d | ||
|
|
3fcc2bf0ea | ||
|
|
6da8204855 | ||
|
|
d252024dbc | ||
|
|
6ab78ff9ad | ||
|
|
7e5dbd8b14 | ||
|
|
f52fdf747c | ||
|
|
f78944795c |
No files matched your search
@@ -0,0 +1,43 @@
|
||||
RADARR_URL=http://radarr:7878
|
||||
RADARR_API_KEY=CHANGE_ME
|
||||
RADARR_MEDIA_PATH=/data/filme
|
||||
LOCAL_MEDIA_PATH=/media/filme
|
||||
FILM_MEDIA_VOLUME=/nesflix/filme
|
||||
|
||||
# Optional: Sonarr aktivieren
|
||||
# SONARR_URL=http://sonarr:8989
|
||||
# SONARR_API_KEY=CHANGE_ME
|
||||
# SONARR_MEDIA_PATH=/data/serien
|
||||
# LOCAL_SERIES_PATH=/media/serien
|
||||
# SERIES_MEDIA_VOLUME=/nesflix/serien
|
||||
|
||||
# Optional: SABnzbd aktivieren
|
||||
# SAB_URL=http://sabnzbd:8080
|
||||
# SAB_API_KEY=CHANGE_ME
|
||||
|
||||
# Dienststatus im Dashboard
|
||||
JELLYFIN_URL=http://jellyfin:8096
|
||||
SEERR_URL=http://seerr:5055
|
||||
PROWLARR_URL=http://prowlarr:9696
|
||||
EASYNEWS_INDEXER_URL=http://easynews-as-indexer:8081
|
||||
# qBittorrent läuft außerhalb des Compose-Stacks: URL hier anpassen
|
||||
# QBITTORRENT_URL=http://host.docker.internal:8080
|
||||
BAZARR_URL=http://bazarr:6767
|
||||
LINGARR_URL=http://lingarr:9876
|
||||
LIBRETRANSLATE_URL=http://libretranslate:5000
|
||||
SONARR_PROWLARR_PROXY_URL=http://sonarr-prowlarr-proxy:8080
|
||||
RADARR_PROWLARR_PROXY_URL=http://radarr-prowlarr-proxy:8080
|
||||
SERVICE_CHECK_TIMEOUT=3
|
||||
SERVICE_REFRESH_SECONDS=30
|
||||
|
||||
DB_PATH=/data/cache.db
|
||||
DATASTORE_PATH=/nesflix
|
||||
DATASTORE_HOST_PATH=/nesflix
|
||||
RAID_DEVICE=/dev/md127
|
||||
STORAGE_CACHE_SECONDS=900
|
||||
# Optional: SMART-Abfragen aktivieren (benötigt Zugriff auf Host-Devices)
|
||||
SMART_ENABLED=false
|
||||
HOST_SYS_PATH=/host-sys
|
||||
LOG_FILE=/data/media-max.log
|
||||
LOG_RETENTION_HOURS=6
|
||||
LOG_LEVEL=INFO
|
||||
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
AGENT.md
|
||||
|
||||
+2
-1
@@ -1,11 +1,12 @@
|
||||
FROM python:3.13-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg mdadm util-linux smartmontools udev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app.py .
|
||||
COPY templates ./templates
|
||||
COPY static ./static
|
||||
EXPOSE 8099
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:8099", "--workers", "1", "--threads", "4", "--timeout", "120", "app:app"]
|
||||
@@ -1,55 +1,219 @@
|
||||
# Radarr Language Dashboard v2
|
||||
# Media Max
|
||||
|
||||
## Wichtig: Path-Mapping
|
||||
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.
|
||||
|
||||
Radarr kann seine Filme z.B. unter `/data/filme` sehen, während der Host sie unter
|
||||
`/nesflix/filme` hat. Das Dashboard unterstützt diese Abbildung explizit:
|
||||
## Funktionen
|
||||
|
||||
- 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
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Persönliche Werte gehören in `.env`. Die Datei ist in `.gitignore` eingetragen. Als Vorlage dient `.env.example`:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### Radarr und Medienpfade
|
||||
|
||||
Radarr kann beispielsweise `/data/filme` verwenden, während Media Max die Dateien im Container unter `/media/filme` sieht:
|
||||
|
||||
```env
|
||||
RADARR_URL=http://radarr:7878
|
||||
RADARR_API_KEY=...
|
||||
RADARR_MEDIA_PATH=/data/filme
|
||||
LOCAL_MEDIA_PATH=/media/filme
|
||||
FILM_MEDIA_VOLUME=/nesflix/filme
|
||||
```
|
||||
|
||||
### Sonarr
|
||||
|
||||
```env
|
||||
SONARR_URL=http://sonarr:8989
|
||||
SONARR_API_KEY=...
|
||||
SONARR_MEDIA_PATH=/data/serien
|
||||
LOCAL_SERIES_PATH=/media/serien
|
||||
SERIES_MEDIA_VOLUME=/nesflix/serien
|
||||
```
|
||||
|
||||
### SABnzbd
|
||||
|
||||
```env
|
||||
SAB_URL=http://sabnzbd:8080
|
||||
SAB_API_KEY=...
|
||||
```
|
||||
|
||||
Der Download-Tab verwendet Queue und History aus SABnzbd. Für laufende Releases werden Sprachen aus dem Dateinamen abgeleitet; die Medienanalyse nach dem Import erfolgt über `ffprobe`.
|
||||
|
||||
## Dienststatus
|
||||
|
||||
Der Dashboard-Tab prüft die Dienste parallel mit kurzen Timeouts und aktualisiert sie regelmäßig:
|
||||
|
||||
```env
|
||||
JELLYFIN_URL=http://jellyfin:8096
|
||||
SEERR_URL=http://seerr:5055
|
||||
PROWLARR_URL=http://prowlarr:9696
|
||||
EASYNEWS_INDEXER_URL=http://easynews-as-indexer:8081
|
||||
QBITTORRENT_URL=http://host.docker.internal:8080
|
||||
BAZARR_URL=http://bazarr:6767
|
||||
LINGARR_URL=http://lingarr:9876
|
||||
LIBRETRANSLATE_URL=http://libretranslate:5000
|
||||
SONARR_PROWLARR_PROXY_URL=http://sonarr-prowlarr-proxy:8080
|
||||
RADARR_PROWLARR_PROXY_URL=http://radarr-prowlarr-proxy:8080
|
||||
SERVICE_CHECK_TIMEOUT=3
|
||||
SERVICE_REFRESH_SECONDS=30
|
||||
```
|
||||
|
||||
Die Container müssen im gemeinsamen Docker-Netzwerk `media-stack_default` erreichbar sein. qBittorrent läuft außerhalb des Compose-Stacks und benötigt deshalb eine erreichbare URL. Bei Bedarf stellt `extra_hosts` den Namen `host.docker.internal` bereit.
|
||||
|
||||
## Scans und Performance
|
||||
|
||||
Der Bibliotheks-Sync läuft als serverseitiger Hintergrund-Thread und benötigt keinen geöffneten Browser. Beim Start und anschließend nach `SYNC_INTERVAL_SECONDS` werden Radarr, Sonarr und die Sprachprüfung aktualisiert. Über den Button „Neu scannen“ kann weiterhin manuell zwischen Radarr, Sonarr, allen Quellen und ausschließlich den aktuellen Einträgen der Liste „Ohne Deutsch“ gewählt werden.
|
||||
|
||||
```env
|
||||
SCAN_WORKERS=4
|
||||
REQUEST_TIMEOUT=20
|
||||
DB_PATH=/data/cache.db
|
||||
SYNC_INTERVAL_SECONDS=1800
|
||||
RAID_REFRESH_SECONDS=5
|
||||
IO_REFRESH_SECONDS=5
|
||||
GPU_REFRESH_SECONDS=5
|
||||
```
|
||||
|
||||
`SCAN_WORKERS` steuert die Anzahl paralleler `ffprobe`-Jobs. Höhere Werte beschleunigen große Bibliotheken, erhöhen aber die I/O-Last.
|
||||
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
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
environment:
|
||||
RADARR_MEDIA_PATH: "/data/filme"
|
||||
LOCAL_MEDIA_PATH: "/media/filme"
|
||||
|
||||
volumes:
|
||||
- /nesflix/filme:/media/filme:ro
|
||||
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
|
||||
LOG_FILE=/data/media-max.log
|
||||
LOG_RETENTION_HOURS=6
|
||||
```
|
||||
|
||||
`/media/filme/Avatar (2009)/Avatar.mkv`
|
||||
Erwartbare Verbindungsfehler werden als kurze einzeilige Meldungen protokolliert. Die Logdatei wird standardmäßig alle sechs Stunden rotiert.
|
||||
|
||||
## Start
|
||||
|
||||
API-Key und ggf. Docker-Netz in `docker-compose.yml` anpassen:
|
||||
## Start mit Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d --build
|
||||
docker compose logs -f
|
||||
docker compose logs -f media-max
|
||||
```
|
||||
|
||||
Die Seite lädt zuerst die Library-Metadaten. Die ffprobe-Scans laufen danach im Hintergrund; oben wird der Fortschritt angezeigt. Für Sonarr die optionalen Variablen und den Serien-Volume-Mount in `docker-compose.yml` aktivieren. Die Navigation trennt Filme und Serien, Serien sind pro Show aufklappbar.
|
||||
|
||||
Sonarr benötigt:
|
||||
Der Compose-Stack bindet standardmäßig ein:
|
||||
|
||||
```yaml
|
||||
SONARR_URL: "http://sonarr:8989"
|
||||
SONARR_API_KEY: "..."
|
||||
SONARR_MEDIA_PATH: "/data/serien"
|
||||
LOCAL_SERIES_PATH: "/media/serien"
|
||||
volumes:
|
||||
- ${FILM_MEDIA_VOLUME}:/media/filme:ro
|
||||
- ${SERIES_MEDIA_VOLUME:-/nesflix/serien}:/media/serien:ro
|
||||
- ${DATASTORE_HOST_PATH:-/nesflix}:/nesflix:ro
|
||||
- /dev:/host-dev:ro
|
||||
- /sys:/host-sys:ro
|
||||
- /run/udev:/host-run-udev:ro
|
||||
- ./data:/data
|
||||
```
|
||||
|
||||
## Bei weiterem Scanfehler
|
||||
Für Radarr, Sonarr und die weiteren Container wird das externe Netzwerk `media-stack_default` verwendet.
|
||||
|
||||
Prüfe einen Pfad im Container:
|
||||
## Fehlersuche
|
||||
|
||||
„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.
|
||||
|
||||
Prüfe zuerst, ob Media Max die Medien sieht:
|
||||
|
||||
```bash
|
||||
docker exec -it radarr-language-dashboard sh
|
||||
docker exec -it media-max sh
|
||||
ls -lah /media/filme
|
||||
ls -lah /media/serien
|
||||
find /media/filme -type f | head
|
||||
ffprobe -v error -show_entries stream=codec_type,codec_name:stream_tags=language -of json "/media/filme/DEIN/FILM.mkv"
|
||||
```
|
||||
|
||||
Teste anschließend `ffprobe` direkt im Container:
|
||||
|
||||
```bash
|
||||
ffprobe -v error \
|
||||
-show_entries stream=codec_type,codec_name:stream_tags=language \
|
||||
-of json "/media/filme/DEIN/FILM.mkv"
|
||||
```
|
||||
|
||||
Für SMART:
|
||||
|
||||
```bash
|
||||
smartctl -i -d sat /host-dev/sda
|
||||
smartctl -A -d sat /host-dev/sda
|
||||
```
|
||||
|
||||
Wenn eine PWA-Installation in Chrome nicht angeboten wird, muss Media Max über gültiges HTTPS aufgerufen werden. Nach Änderungen an Manifest oder Service Worker sollten alte Website-Daten bzw. eine alte Verknüpfung entfernt und die Seite neu geladen werden.
|
||||
|
||||
## 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.
|
||||
+31
-21
@@ -1,32 +1,42 @@
|
||||
services:
|
||||
radarr-language-dashboard:
|
||||
media-max:
|
||||
build: .
|
||||
container_name: radarr-language-dashboard
|
||||
container_name: media-max
|
||||
restart: unless-stopped
|
||||
gpus: all
|
||||
ports:
|
||||
- "8099:8099"
|
||||
devices:
|
||||
- "${RAID_DEVICE:-/dev/md127}:${RAID_DEVICE:-/dev/md127}"
|
||||
cap_add:
|
||||
- SYS_RAWIO
|
||||
device_cgroup_rules:
|
||||
- "b 8:* rwm"
|
||||
- "b 9:* rwm"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
RADARR_URL: "http://radarr:7878"
|
||||
RADARR_API_KEY: "CHANGE_ME"
|
||||
|
||||
# Optional: Sonarr aktivieren (auskommentieren und Werte anpassen)
|
||||
# SONARR_URL: "http://sonarr:8989"
|
||||
# SONARR_API_KEY: "CHANGE_ME"
|
||||
|
||||
# Pfad, den Radarr in seiner API meldet:
|
||||
RADARR_MEDIA_PATH: "/data/filme"
|
||||
|
||||
# Pfad derselben Dateien INNERHALB dieses Containers:
|
||||
LOCAL_MEDIA_PATH: "/media/filme"
|
||||
# SONARR_MEDIA_PATH: "/data/serien"
|
||||
# LOCAL_SERIES_PATH: "/media/serien"
|
||||
|
||||
DB_PATH: "/data/cache.db"
|
||||
LOG_LEVEL: "INFO"
|
||||
NVIDIA_VISIBLE_DEVICES: ${NVIDIA_VISIBLE_DEVICES:-all}
|
||||
NVIDIA_DRIVER_CAPABILITIES: ${NVIDIA_DRIVER_CAPABILITIES:-compute,utility}
|
||||
# Serverseitiger Bibliotheks-Sync; 1800 = alle 30 Minuten, 0 = nur beim Start.
|
||||
SYNC_INTERVAL_SECONDS: ${SYNC_INTERVAL_SECONDS:-1800}
|
||||
RAID_REFRESH_SECONDS: ${RAID_REFRESH_SECONDS:-5}
|
||||
IO_REFRESH_SECONDS: ${IO_REFRESH_SECONDS:-5}
|
||||
GPU_REFRESH_SECONDS: ${GPU_REFRESH_SECONDS:-5}
|
||||
volumes:
|
||||
# Host-Pfad deiner Film-Library -> interner Dashboard-Pfad
|
||||
- /nesflix/filme:/media/filme:ro
|
||||
# - /nesflix/serien:/media/serien:ro
|
||||
- ${FILM_MEDIA_VOLUME}:/media/filme:ro
|
||||
# Falls Sonarr verwendet wird, muss SERIES_MEDIA_VOLUME auf die Serien-Library zeigen.
|
||||
- ${SERIES_MEDIA_VOLUME:-/nesflix/serien}:/media/serien:ro
|
||||
# Read-only Datastore für Größen-/Kategorie-Scans.
|
||||
- ${DATASTORE_HOST_PATH:-/nesflix}:/nesflix:ro
|
||||
# Read-only Host-Devices für lsblk/smartctl; Media Max führt keine RAID-Schreiboperationen aus.
|
||||
- /dev:/host-dev:ro
|
||||
# Read-only Hardware-Metadaten für Seriennummern und SMART-Zuordnung.
|
||||
- /sys:/host-sys:ro
|
||||
- /run/udev:/host-run-udev:ro
|
||||
- ./data:/data
|
||||
networks:
|
||||
- media
|
||||
|
||||
@@ -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(() => {}));
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "Media Max",
|
||||
"short_name": "Media Max",
|
||||
"id": "/",
|
||||
"lang": "de",
|
||||
"description": "Media Max: Radarr, Sonarr und SABnzbd im Überblick",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone"],
|
||||
"prefer_related_applications": false,
|
||||
"background_color": "#0c0d0f",
|
||||
"theme_color": "#101113",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{"src": "/static/media-mark-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"},
|
||||
{"src": "/static/media-mark-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any"},
|
||||
{"src": "/static/media-mark-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 803 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="192" height="192"><defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#293542"/><stop offset="1" stop-color="#15191f"/></linearGradient><filter id="glow"><feGaussianBlur stdDeviation="2.2" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><rect x="5" y="5" width="54" height="54" rx="15" fill="url(#bg)" stroke="#60a9e6" stroke-width="2.5"/><path d="M17 21h30M17 31h19M17 41h30" fill="none" stroke="#f4c430" stroke-width="4" stroke-linecap="round"/><circle cx="46" cy="31" r="7" fill="#159447" stroke="#c9ffe0" stroke-width="2" filter="url(#glow)"/></svg>
|
||||
|
After Width: | Height: | Size: 709 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="512" height="512"><defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#293542"/><stop offset="1" stop-color="#15191f"/></linearGradient><filter id="glow"><feGaussianBlur stdDeviation="2.2" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><rect x="5" y="5" width="54" height="54" rx="15" fill="url(#bg)" stroke="#60a9e6" stroke-width="2.5"/><path d="M17 21h30M17 31h19M17 41h30" fill="none" stroke="#f4c430" stroke-width="4" stroke-linecap="round"/><circle cx="46" cy="31" r="7" fill="#159447" stroke="#c9ffe0" stroke-width="2" filter="url(#glow)"/></svg>
|
||||
|
After Width: | Height: | Size: 709 B |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#293542"/>
|
||||
<stop offset="1" stop-color="#15191f"/>
|
||||
</linearGradient>
|
||||
<filter id="glow"><feGaussianBlur stdDeviation="2.2" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
||||
</defs>
|
||||
<rect x="5" y="5" width="54" height="54" rx="15" fill="url(#bg)" stroke="#60a9e6" stroke-width="2.5"/>
|
||||
<path d="M17 21h30M17 31h19M17 41h30" fill="none" stroke="#f4c430" stroke-width="4" stroke-linecap="round"/>
|
||||
<circle cx="46" cy="31" r="7" fill="#159447" stroke="#c9ffe0" stroke-width="2" filter="url(#glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 729 B |
@@ -0,0 +1,26 @@
|
||||
const CACHE_NAME = 'media-max-shell-v10';
|
||||
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 => {
|
||||
event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(SHELL)));
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key)))));
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const request = event.request;
|
||||
if (request.method !== 'GET') return;
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname.startsWith('/api/')) return;
|
||||
// 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}));
|
||||
}));
|
||||
});
|
||||
+149
-31
@@ -1,32 +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>
|
||||
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Language Dashboard</title>
|
||||
<style>
|
||||
: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}}
|
||||
</style></head><body>
|
||||
<header><div class="logo">MEDIA</div><div><div class="head-title">Language Dashboard</div><div class="head-sub">Audio- und Untertitelspuren aus den Mediendateien</div></div><div class="actions"><button id="rescan">↻ Neu scannen</button></div></header>
|
||||
<main>
|
||||
{% if error %}<div class="banner"><strong>Fehler:</strong> {{ error }}</div>{% endif %}
|
||||
<nav class="nav"><button class="active" data-view="radarr">RADARR · Filme</button>{% if sonarr_enabled %}<button data-view="sonarr">SONARR · Serien</button>{% endif %}</nav>
|
||||
<section id="radarr" class="view active" data-source="radarr">
|
||||
<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="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="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>
|
||||
{% 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 %}
|
||||
</tbody></table></div></section>
|
||||
{% 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">
|
||||
{% for group in 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 %}
|
||||
</div></section>{% endif %}
|
||||
</main>
|
||||
<script>
|
||||
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');
|
||||
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();
|
||||
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');document.querySelector('#rescan').dataset.source=b.dataset.view});
|
||||
function statusText(s){if(s.state==='running')return `Scan: ${s.done}/${s.total||'?'} Dateien`;if(s.state==='done')return 'Scan abgeschlossen';if(s.state==='error')return 'Scanfehler: '+s.error;return 'Scan wird vorbereitet …'}
|
||||
const seenRunning={radarr:false,sonarr:false};async function poll(){try{const data=await (await fetch('/api/scan-status')).json();document.querySelector('#radarrStatus').textContent=statusText(data.radarr);const sonarr=document.querySelector('#sonarrStatus');if(sonarr)sonarr.textContent=statusText(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(()=>location.reload(),700)}})}catch(e){} };setInterval(poll,1200);poll();
|
||||
document.querySelector('#rescan').onclick=async()=>{const b=document.querySelector('#rescan');b.disabled=true;b.textContent='Scanne …';await fetch('/api/rescan?source='+(b.dataset.source||'radarr'),{method:'POST'});b.disabled=false;b.textContent='↻ Neu scannen';poll()};
|
||||
</script></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}
|
||||
</style>
|
||||
<script>document.querySelectorAll('details.series').forEach(series => { series.open = false; });</script>
|
||||
<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="#101113"><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-192.png') }}"><link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='media-mark.svg') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='dashboard.css') }}?v=10"><title>Media Max</title>
|
||||
<script src="{{ url_for('static', filename='dashboard.js') }}?v=10" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<svg class="svg-definitions" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<symbol id="icon-search" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6"/><path d="m15 15 6 6"/></symbol>
|
||||
<symbol id="icon-refresh" viewBox="0 0 24 24"><path d="M20 8a8 8 0 1 0 0 8M20 3v5h-5"/></symbol>
|
||||
<symbol id="icon-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
|
||||
<symbol id="icon-close" viewBox="0 0 24 24"><path d="m6 6 12 12M6 18 18 6"/></symbol>
|
||||
<symbol id="icon-activity" viewBox="0 0 24 24"><path d="M2 12h5l3-8 4 16 3-8h5"/></symbol>
|
||||
<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>
|
||||
<symbol id="icon-check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></symbol>
|
||||
<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>
|
||||
</svg>
|
||||
<a class="skip-link" href="#main">Zum Inhalt</a>
|
||||
<button id="sidebarShade" class="sidebar-shade" aria-label="Navigation schließen" tabindex="-1"></button>
|
||||
<aside class="sidebar" id="sidebar" aria-label="Hauptnavigation">
|
||||
<a class="brand" href="/">{{ icon('activity', 'brand-icon') }}<span>Media<span class="brand-accent"> Max</span></span></a>
|
||||
<div class="workspace">{{ icon('server') }}<div><strong>Media Stack</strong><small>{{ tr('Bibliothek & Monitoring', 'Library & monitoring') }}</small></div><span class="status-dot"></span></div>
|
||||
<nav class="nav">
|
||||
<div class="nav-label">{{ tr('Übersicht', 'Overview') }}</div>
|
||||
{{ nav_button('dashboard', 'dashboard', 'Dashboard', 'Dashboard') }}
|
||||
<div class="nav-label">{{ tr('Bibliothek', 'Library') }}</div>
|
||||
{{ nav_button('radarr', 'movie', 'Filme', 'Movies') }}
|
||||
{% if sonarr_enabled %}{{ nav_button('sonarr', 'series', 'Serien', 'Series') }}{% endif %}
|
||||
{{ nav_button('missing', 'language', 'Ohne Deutsch', 'Without German', 'missingNavCount') }}
|
||||
{% if sab_enabled %}{{ nav_button('downloads', 'download', 'Downloads', 'Downloads', 'downloadNavCount') }}{% endif %}
|
||||
<div class="nav-label">{{ tr('System', 'System') }}</div>
|
||||
{{ nav_button('datastore', 'disk', 'Speicher & RAID', 'Storage & RAID') }}
|
||||
{{ nav_button('graphics', 'chip', 'Graphics', 'Graphics') }}
|
||||
</nav>
|
||||
<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>
|
||||
</aside>
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<button id="menuToggle" class="icon-button menu-toggle" aria-label="Navigation öffnen" aria-controls="sidebar" aria-expanded="false">{{ icon('menu') }}</button>
|
||||
<div class="breadcrumb">Media Max <span>/</span> <strong id="pageTitle">Dashboard</strong></div>
|
||||
<div class="actions">
|
||||
<button id="languageToggle" class="icon-button" aria-label="Sprache wechseln">EN</button>
|
||||
<div class="scan-control">
|
||||
<select id="scanSource" aria-label="Scan-Quelle">
|
||||
<option value="all" data-de="Alle Quellen" data-en="All sources">Alle Quellen</option>
|
||||
<option value="radarr">Radarr</option>
|
||||
{% if sonarr_enabled %}<option value="sonarr">Sonarr</option>{% endif %}
|
||||
<option value="missing" data-de="Ohne Deutsch" data-en="Without German">Ohne Deutsch</option>
|
||||
</select>
|
||||
<button id="rescan" class="primary-button">{{ icon('refresh') }}{{ tr('Neu scannen', 'Rescan') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
<dialog id="settingsModal" class="settings-modal" aria-labelledby="settingsTitle">
|
||||
<form method="dialog">
|
||||
<div class="panel-heading">
|
||||
<div><h2 id="settingsTitle">{{ tr('Einstellungen', 'Settings') }}</h2><p class="muted">{{ tr('Anzeige- und Sprachpräferenzen', 'Display and language preferences') }}</p></div>
|
||||
<button class="icon-button" aria-label="Schließen">{{ icon('close') }}</button>
|
||||
</div>
|
||||
{% for id, de, en in [('preferredAudio', 'Bevorzugte Audio-Sprache', 'Preferred audio language'), ('preferredSubs', 'Bevorzugte Untertitel-Sprache', 'Preferred subtitle language')] %}
|
||||
<label for="{{ id }}">{{ tr(de, en) }}</label>
|
||||
<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>
|
||||
{% endfor %}
|
||||
<label class="setting-toggle"><input type="checkbox" id="showSeasonZero">{{ tr('Staffel 0 anzeigen', 'Show season 0') }}</label>
|
||||
<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>
|
||||
<div class="settings-actions"><button value="cancel">{{ tr('Abbrechen', 'Cancel') }}</button><button id="settingsSave" type="button" class="primary-button">{{ tr('Speichern', 'Save') }}</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<script id="dashboardConfig" type="application/json">{{ {'sabEnabled': sab_enabled, 'libraryUpdatedAt': library_updated_at, 'libraryReady': library_ready}|tojson }}</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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