Compare commits

...
44 Commits
Author SHA1 Message Date
nessi 00b841fa67 feat: add pending_only parameter to start_scan to limit missing German scan to newly added pending items only
Add pending_only parameter to start_scan() function with default False. Update missing scan path collection to filter for pending items when pending_only=True using (not pending_only or row.get("pending")) check for both movies and series episodes. Update needs_missing_scan check to only trigger when pending items exist without German tracks by adding row.get("pending") condition. Pass pending_only=
2026-08-26 13:54:14 +02:00
nessi 3fb2b34587 feat: cache failed ffprobe scans with scanner_version and exclude downloading items from scan queue to prevent incomplete metadata
Store failed ffprobe attempts in media_cache with scanner_version/audio_languages/subtitle_languages/external_subtitle_files/video_codec/size/error fields. Add comment explaining mtime-based invalidation triggers automatic retry. Filter out downloading items from radarr/sonarr/missing scan paths using not row.get("downloading") check to prevent scanning incomplete files
2026-08-26 13:52:15 +02:00
nessi de0d360a3c feat: add immediate cache refresh after scan completion and exclude pending items from missing German detection to prevent showing unscanned files
Call refresh_library_cache() after scan completion to immediately publish scanned metadata. Add needs_missing_scan check that only triggers missing scan when German review candidates exist without German tracks. Filter out pending items from missing_german_movies/missing_german_series to prevent showing unscanned files as missing German while background scanner processes them
2026-08-26 13:47:24 +02:00
nessi 3f418bb2a3 feat: add library sync status polling with automatic page reload when background sync completes
Add /api/library-status endpoint returning ready/updated_at/error from library_cache with thread-safe lock. Add pollLibrarySync() function to check library status every 5 seconds and reload page when updated_at timestamp changes. Track lastLibrarySync timestamp to detect background sync completion. Save active view to sessionStorage before reload to restore navigation state
2026-08-25 20:16:50 +02:00
nessi 83dc8c08ae feat: add independent background monitors for RAID/IO/GPU with configurable refresh intervals to eliminate blocking queries
Add RAID_REFRESH_SECONDS/IO_REFRESH_SECONDS/GPU_REFRESH_SECONDS environment variables with default 5 seconds and minimum 2 seconds. Add graphics_cache_lock and graphics_cache global for thread-safe GPU data access. Add io/io_at fields to datastore_cache. Extract refresh_io_status() to update I/O pressure cache. Add refresh_gpu_status() to update graphics_cache. Implement hardware
2026-08-25 20:15:01 +02:00
nessi 952797005f feat: add background library sync thread with configurable interval to eliminate browser dependency for automatic scanning
Add SYNC_INTERVAL_SECONDS environment variable with default 1800 (30 minutes). Add library_cache global dict with rows/series/updated_at/error/ready fields and library_cache_lock for thread-safe access. Extract refresh_library_cache() function to update Radarr/Sonarr data with error handling that preserves last good snapshot during outages. Add background_library_sync() daemon
2026-08-25 20:11:38 +02:00
nessi 7313eb96c3 feat: add nvidia-smi pmon integration to detect NVENC/NVDEC processes and display GPU process activity with auto-refresh status
Add graphics_process_log_state global to track GPU process changes. Query nvidia-smi pmon with 1-count sample to detect video encode/decode processes missed by compute-apps query. Parse pmon output for SM/ENC/DEC utilization percentages and process types. Merge pmon results with compute-apps by PID, preferring pmon data when available. Log GPU process detection with PID/name
2026-08-21 21:26:09 +02:00
nessi 8c8e1be0db feat: add GPU status logging with state tracking to prevent duplicate log messages and configure NVIDIA runtime in docker-compose
Add graphics_log_state global to track GPU query status and prevent duplicate log messages. Log warning with error_summary() when nvidia-smi is unavailable or returns non-zero exit code. Log info message when GPU status becomes available with GPU count and binary path. Track signature tuples for unavailable/failed/ok states including GPU index/name/temperature/utilization.
2026-08-21 21:12:27 +02:00
nessi 7d1987c4f4 feat: add NVIDIA Graphics tab with GPU monitoring via nvidia-smi and configurable binary path
Add graphics_snapshot() function to query GPU details using nvidia-smi with 5s timeout. Parse GPU metrics including model, driver, UUID, serial, temperature, utilization, VRAM, power, clocks, and compute processes. Add NVIDIA_SMI_BIN environment variable with default "nvidia-smi". Add /api/graphics endpoint returning GPU data with error handling. Add Graphics navigation tab with purple expansion-card icon
2026-08-21 21:09:15 +02:00
nessi 6aa4032134 feat: add non-blocking RAID status refresh with dedicated lock and remove offline navigation fallback from service worker
Add raid_refresh_lock to prevent concurrent RAID refresh operations. Extract refresh_raid_status() function with non-blocking lock acquisition using acquire(blocking=False). Update datastore_snapshot() to spawn background thread for RAID refresh when cache is stale but return immediately with cached data. Remove offline navigation fallback from service worker to prevent masking backend errors. Bump service worker cache version to v7
2026-08-21 21:02:27 +02:00
nessi 5d7e452aaf feat: add non-blocking datastore refresh with dedicated lock and improve service worker offline handling
Add datastore_refresh_lock to prevent concurrent refresh operations. Extract refresh_datastore_storage() function with non-blocking lock acquisition using acquire(blocking=False). Update datastore_snapshot() to spawn background thread for refresh when cache is stale but return immediately with cached data. Add offline fallback HTML page to service worker for navigate requests with 503 status for
2026-08-21 20:55:19 +02:00
nessi 1882d4e137 feat: add data-title attribute to series details elements and optimize series filtering with display:none for better performance
Add data-title="{{ group.title|lower }}" to details.series elements. Update applySeries() to read series title from detail.dataset.title instead of parsing summary textContent. Add explicit display:none style to hidden rows and details alongside hidden class for improved rendering performance
2026-08-21 20:49:47 +02:00
nessi a2f411c578 feat: extend Radarr toolbar styling to Sonarr series-toolbar with matching background/border/focus states
Apply row-gap:12px to .view#sonarr.active alongside #radarr. Extend toolbar padding/background/border/box-shadow rules to .view#sonarr>.series-toolbar. Add .series-toolbar input/select to background:rgba(42,46,54,.82) and border-color:#454d59 rules. Include .series-toolbar input:focus/select:focus in background:#303640 focus state
2026-08-21 20:08:37 +02:00
nessi 8ec43481c7 feat: add series filtering toolbar with search/audio/subtitle/state filters and data attributes for episode rows
Add toolbar with seriesSearch input and seriesAudioFilter/seriesSubFilter/seriesStateFilter selects to #sonarr section. Add data-title/data-audio/data-subs/data-state attributes to episode table rows including series title in data-title. Implement applySeries() function to filter episodes by search query, audio language, subtitle language, and state with series/category hiding when no
2026-08-21 20:01:44 +02:00
nessi 34af0a4ad9 feat: add language detection from subtitle track titles and scanner versioning to invalidate stale cache entries
Add SCANNER_VERSION constant set to 2 and include in run_ffprobe() output. Create language_from_text() helper to infer language from track titles using token matching with German/English/Japanese/Korean/French/Spanish/Italian aliases. Apply language_from_text() to subtitle streams when ffprobe reports "und" language tag by checking title field. Update cached() to validate scanner_version matches
2026-08-21 19:29:25 +02:00
nessi 67e588bfc8 feat: add scan status indicator to missing-German view and update poll() to track missing source status
Add #missingStatus span to missing section stats with "Scan wird vorbereitet …" text. Change missing section data-source from "radarr" to "missing". Update poll() function to select #missingStatus element and call updateScanStatus() for data.missing when element exists
2026-08-21 15:42:29 +02:00
nessi dfbeec4dc0 feat: add "Nur Ohne Deutsch" scan option to rescan only current missing-German entries without clearing cache
Add "missing" job state to jobs dict with idle/total/done/error tracking. Update start_scan() to handle "missing" source by filtering radarr_rows() and sonarr_groups() for is_german_review_candidate() entries without has_german_track(). Skip cache deletion for missing-German scan in api_rescan() with comment explaining cache preservation. Add "Nur Ohne Deutsch" option to scanSource select
2026-08-21 15:36:27 +02:00
nessi 58c8ad3bcc feat: add mobile performance optimizations with scroll-behavior and animation disabling for screens below 700px
Add media query to disable smooth scrolling with scroll-behavior:auto on html. Remove backdrop-filter from header/dashboard/cards/panels/tables and box-shadow from panels. Disable transform/animation on table rows and series items with transform:none/animation:none. Add contain:layout paint to tablewrap with overscroll-behavior-x:contain and contain:layout style to view for performance
2026-08-17 19:42:54 +02:00
nessi f5d8b4189c chore: add static directory to Docker image build with COPY instruction
Add COPY static ./static to Dockerfile to include static assets (CSS/JS/images) in container image alongside templates directory
2026-08-17 19:42:10 +02:00
nessi 7469ec7b9f feat: add mobile navigation hide rules for screens below 700px with pointer-events and visibility overrides
Add media query to hide legacy-mobile-nav and mobile-drawer-open with display:none/visibility:hidden/pointer-events:none. Set mobile-nav-backdrop display:none and mobile-drawer-clone/mobile-drawer-shade pointer-events:none when not open
2026-08-17 19:40:14 +02:00
nessi be68853a74 refactor: replace SVG icons with PNG format in manifest and update apple-touch-icon reference
Replace media-mark-192.svg/media-mark-512.svg with .png equivalents in manifest.json icon entries and update image/svg+xml to image/png MIME types. Add media-mark-192.png and media-mark-512.png binary assets. Update sw.js SHELL array to cache .png files instead of .svg. Change apple-touch-icon href from media-mark.svg to media-mark-192.png in index.html head
2026-08-17 19:38:38 +02:00
nessi 2096fc5d11 chore: bump service worker cache version to v5 and add manifest cache-busting parameter
Increment CACHE_NAME from media-max-shell-v4 to v5 in sw.js. Update service worker registration query parameter from v=4 to v=5 in index.html. Add v=5 cache-busting parameter to manifest.json link tag. Remove duplicate service worker registration script block from template footer
2026-08-17 19:36:04 +02:00
nessi 14a4c6ce7b docs: convert README to German with comprehensive feature documentation and deployment guide
Replace English README with German documentation covering all features: Radarr/Sonarr media tracking with quality/audio/subtitle analysis, SABnzbd download monitoring with language detection, dashboard with service status/CPU/RAM/storage/I/O pressure/library stats, datastore with categories/capacity/RAID status/rebuild progress/SMART/temperature/serial numbers. Add detailed configuration sections for path mapping between Radarr/Sonarr and container volumes
2026-08-17 19:35:21 +02:00
nessi 806886da46 feat: expand language detection patterns with 8 additional languages and enhanced token matching
Add Italian/Portuguese/Korean/Chinese/Dutch/Russian to patterns list with standard/ISO 639-2 codes (ita/por/pt br/kor/chi/zho/nld/ned/rus). Enhance German patterns with " ger "/" deu " tokens and French with " français "/" fre " variants. Add corresponding language codes to codes dict (it/pt/ko/zh/nl/ru). Format patterns list with multi-line structure for readability
2026-08-17 19:33:39 +02:00
nessi ffd995e50b refactor: wrap RAID and I/O pressure panels in flex column container with stretch alignment and remove bottom margin from I/O panel
Add .datastore-grid align-items:stretch and .datastore-right-stack flex column with gap:12px/min-height:100%. Set .datastore-right-stack .raid-panel flex:1/margin:0 and #ioPressurePanel flex:0 0 auto. Remove margin:0 0 12px from #ioPressurePanel base rule. Add DOMContentLoaded script to create datastore-right-stack div, replaceChild raid panel with stack in grid, and append pressure/
2026-08-17 19:31:08 +02:00
nessi 75f6de91b6 refactor: move I/O pressure panel into RAID section and capacity panel into categories section with DOM restructuring script
Remove max-width:calc(50% - 6px) from #ioPressurePanel and set max-width:none. Add .datastore-grid>.datastore-panel>.datastore-capacity and .datastore-grid>.raid-panel>#ioPressurePanel margin/padding rules. Add DOMContentLoaded script to select datastore root/grid/capacity/pressure elements, identify categories and raid sections from grid.children[0]/[1], and prepend capacity to
2026-08-17 19:28:14 +02:00
nessi d4c24f02d4 feat: add smart_serial() function to extract manufacturer serial numbers from SMART data with fallback chain and parallel execution
Add smart_serial() function with smartctl -i command execution, fallback to -d sat mode, and Serial Number: field regex extraction with WWN/0x prefix filtering to prefer physical serial over logical identifiers. Add serial_futures dict with service_check_executor.submit() calls for parallel serial reads alongside smart_futures/temperature_futures. Update raid_status() disk
2026-08-17 19:27:21 +02:00
nessi 02449a80de refactor: replace dynamic temperature injection with static rendering in RAID disk template and add I/O pressure panel with PSI metrics
Remove DOMContentLoaded script for raid-temperature DOM manipulation with MutationObserver and replace with inline disk.temperature rendering in renderDatastore() RAID devices map. Add #ioPressurePanel CSS with 50% max-width/12px margin/14px padding and io-pressure-bars grid with 42px/48px/1fr columns. Add DOMContentLoaded script to create ioPressurePanel section
2026-08-17 19:25:30 +02:00
nessi b275164be9 feat: add Linux PSI I/O pressure monitoring with real-time dashboard panel and auto-refresh on DOM mutations
Add io_pressure() function to parse /proc/pressure/io with line splitting, key-value extraction from some/full metrics, and OSError/ValueError exception handling returning available:False on failure. Add io_pressure() call to datastore_snapshot() return dict. Add DOMContentLoaded script to create #ioPressurePanel section with datastore-panel/io-pressure-panel classes, innerHTML with I/O-
2026-08-17 19:21:49 +02:00
nessi c6c2437732 refactor: prioritize Temperature_Celsius raw value extraction before unit-based parsing in SMART temperature detection
Swap Temperature_Celsius to first position in ordered list before Current Drive Temperature/Drive Temperature/Temperature: patterns. Add Temperature_Celsius.*-\s*(-?\d{1,3}) regex to extract raw value from attribute line with 0-150 range validation before falling back to explicit unit pattern matching to improve accuracy when both formatted and raw temperature values are present
2026-08-16 20:13:34 +02:00
nessi 01b6a7fd4e refactor: prioritize Current Drive Temperature and Temperature: field patterns over Temperature_Celsius in SMART parsing and reorder -x before -A in sat mode commands
Update temperature line matching to first search for Current Drive Temperature/Drive Temperature/Temperature: patterns, then append Temperature_Celsius lines, before falling back to generic temperature/airflow keywords. Swap smartctl -x -d sat command before -A -d sat in fallback chain to improve attribute detection on SATA drives behind
2026-08-16 20:12:28 +02:00
nessi c2e2f7552f feat: prioritize Temperature_Celsius over Airflow_Temperature in SMART parsing to prefer physical drive temperature
Reorder temperature line matching to first search for Temperature_Celsius/Drive_Temperature/Current Drive Temperature attributes before falling back to generic temperature/airflow keywords. Add ordered list construction with preferred patterns first, then append remaining temperature lines, to ensure physical drive temperature is selected over airflow sensor when both are present in
2026-08-16 20:11:34 +02:00
nessi f820a99c7c refactor: reorder smartctl command fallback chain to prioritize -d sat mode before standard commands
Move -d sat variants of -A and -x commands to front of fallback chain before non-sat equivalents in smart_temperature() function to improve compatibility with SATA drives behind USB/RAID controllers
2026-08-16 20:09:53 +02:00
nessi 9373e93e7f feat: improve SMART temperature parsing with multi-command fallback and enhanced line-based extraction logic
Replace regex pattern matching with extract() helper that iterates through output lines, filters for temperature/airflow keywords, and extracts values using explicit unit patterns (°C/Celsius/degrees C) with 0-150 range validation or fallback to last plausible number in line. Add smartctl -x/-x -d sat commands to fallback chain after -A/-A -d sat attempts with early return on first successful
2026-08-16 20:08:29 +02:00
nessi 310b36317e feat: add drive temperature monitoring with async updates and multi-pattern SMART parsing
Add smart_temperature() function with smartctl -A command execution, fallback to -d sat mode, and regex patterns for Temperature_Celsius/Airflow_Temperature_Cel/Drive_Temperature/Current Drive Temperature attributes plus Temperature: field parsing with 1-3 digit capture and °C formatting. Add temperature_futures dict with service_check_executor.submit() calls for parallel temperature reads alongside smart_futures. Add disk["
2026-08-16 20:07:19 +02:00
nessi 71752483f6 feat: add error_summary() helper to replace verbose exception tracebacks with concise German error messages
Add error_summary() function to format exceptions as single-line log messages with 240-char limit and special handling for requests.RequestException as "Dienst nicht erreichbar". Replace log.exception() calls with log.error() using error_summary() across service_monitor, scan_datastore_storage, sab_downloads, scan_one, start_scan, index, downloads, system, and datastore endpoints. Update user-facing error messages to German "momentan nicht verfügbar/
2026-08-16 17:31:21 +02:00
nessi 5723a5d2a0 feat: add service worker route with cache-control headers and update PWA manifest icons for proper maskable support
Add /sw.js route with send_from_directory to serve service worker from root scope with Service-Worker-Allowed:/ and Cache-Control:no-cache headers. Add navigator.serviceWorker.register() script to index.html with load event listener and scope:/ parameter. Update service worker cache to v4 with media-mark-192.svg/media-mark-512.svg additions to SHELL array.

Update manifest.json icons
2026-08-16 17:29:21 +02:00
nessi cef785361f feat: add preferred audio/subtitle badge highlighting with localStorage persistence and mobile badge visibility controls
Add DOMContentLoaded script to apply preferred-audio/preferred-subs classes from localStorage to badges in movies/sonarr/missing tables. Add applyPreferred() function with querySelectorAll iteration over tbody rows, forEach loop through column 4/5 badges, classList.remove/add for className assignment, and parentElement.prepend() to reorder preferred badge first. Add setInterval(
2026-08-16 13:11:18 +02:00
nessi d9033cbb6e fix: add whitespace after opening brace in mobile responsive table styles media query 2026-08-16 13:08:51 +02:00
nessi 2f71f92165 feat: add legacy mobile navigation cleanup with auto-close on outside click, escape key, and window resize
Add DOMContentLoaded script to mark .nav with legacy-mobile-nav class and hide via @media(max-width:700px) display:none!important. Add closeAll() function to remove mobile-nav-open/open/mobile-drawer-open classes across body/drawer/shade/backdrop elements. Add document click handler to close drawer when clicking outside .mobile-drawer-clone/.mobile-nav/.brand on mobile. Add Escape keydown listener
2026-08-16 13:06:58 +02:00
nessi 62d19c26a1 feat: enhance PWA manifest with multi-size icons, window controls overlay, and German locale settings
Add id "/" and lang "de" fields to manifest.json. Add display_override with window-controls-overlay/standalone options and prefer_related_applications:false. Replace single media-mark.svg icon with 192x192 and 512x512 sized variants (media-mark-192.svg, media-mark-512.svg) containing gradient background, yellow strokes, and green circle with glow filter. Update service worker cache to v3 with scope "/" parameter
2026-08-16 13:05:48 +02:00
nessi c2df06dda8 feat: replace mobile navigation drawer implementation with cloned drawer approach and enhanced styling
Replace mobile-nav-backdrop/mobile-nav-close drawer with mobile-drawer-clone/mobile-drawer-shade implementation using cloned navigation content. Add DOMContentLoaded script to create aside.mobile-drawer-clone with cloned .nav innerHTML, prepended close button (×), and mobile-drawer-shade backdrop. Add @media(max-width:700px) styles for drawer with fixed positioning, min(84vw,320px) width, translate
2026-08-16 13:00:05 +02:00
nessi e84bc9c695 feat: add mobile drawer styling injection and close button inline styles for navigation drawer positioning and appearance
Add DOMContentLoaded script to inject inline fontSize/padding styles on .mobile-nav-close button. Add drawerStyle createElement with @media(max-width:700px) rules for .nav.mobile-drawer-open class with fixed positioning, 82vw/310px width, translate3d transform, and z-index:1000. Add mobile-drawer-open class toggle to closeNav/openNav functions alongside mobile-nav-open body class
2026-08-16 12:58:10 +02:00
nessi 1f658adf71 feat: add mobile navigation drawer with backdrop, close button, and responsive table layout optimizations
Add mobile-nav-backdrop/mobile-nav-close elements with DOMContentLoaded script for hamburger navigation. Add .brand click handler to open navigation on mobile (<700px) with body.mobile-nav-open class toggle. Add backdrop/close button click handlers and nav button delegation to close drawer. Style .nav as fixed left-side drawer with 310px max-width, translateX(-105%) default, and translateX(
2026-08-16 12:55:11 +02:00
11 changed files with 660 additions and 146 deletions
+1
View File
@@ -7,5 +7,6 @@ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY app.py . COPY app.py .
COPY templates ./templates COPY templates ./templates
COPY static ./static
EXPOSE 8099 EXPOSE 8099
CMD ["gunicorn", "--bind", "0.0.0.0:8099", "--workers", "1", "--threads", "4", "--timeout", "120", "app:app"] CMD ["gunicorn", "--bind", "0.0.0.0:8099", "--workers", "1", "--threads", "4", "--timeout", "120", "app:app"]
+150 -67
View File
@@ -1,43 +1,75 @@
# Media Max # Media Max
## Datastore Media Max ist ein kompaktes Media-Control-Dashboard für Radarr, Sonarr und SABnzbd. Es zeigt Medienqualität, Audio- und Untertitelspuren, fehlende deutsche Spuren, laufende Downloads sowie System-, Speicher- und RAID-Status.
Der Datastore-Tab zeigt den read-only Speicherstatus von `/nesflix`, Medienkategorien ## Funktionen
und den Linux-Software-RAID-Status. Größen-Scans werden standardmäßig 15 Minuten
gecacht; RAID-/Recovery-Werte aktualisieren sich live. Konfigurierbar sind
`DATASTORE_PATH`, `DATASTORE_HOST_PATH`, `RAID_DEVICE`, `STORAGE_CACHE_SECONDS` und
optional `SMART_ENABLED=true` für SMART-Healthwerte.
## Wichtig: Path-Mapping - Radarr-Filme mit Qualität, Status, Audio, Untertiteln, Codec und Größe
- Sonarr-Serien mit Episodenansicht und Kategorien über Tags:
- `anime` → Anime
- `kdrama` → K-Dramen
- alle anderen → Standard
- Separate Liste „Ohne Deutsch“ für vorhandene oder aktuell geladene Medien
- Staffel 0 kann in den Einstellungen ein- oder ausgeblendet werden
- Externe Untertitel (`.srt`, `.ass`, `.ssa`, `.vtt`, `.sub`, `.idx`, `.sup`) werden berücksichtigt
- SABnzbd-Downloadübersicht mit aktiven Downloads und separater Queue-/Verifying-Tabelle
- Download-Sprachen aus üblichen Release-Kürzeln wie `Eng`, `Ger`, `Fre`, `Ita` und `Spa`
- Dashboard mit Diensten, CPU, RAM, Speicher, I/O Pressure und Bibliotheksstatistiken
- Datastore mit Kategorien, Gesamtauslastung, I/O Pressure und RAID-Status
- Graphics-Tab mit NVIDIA-GPU-Modell, Treiber, UUID, Seriennummer, Temperatur, Auslastung, VRAM, Leistung, Clocks und GPU-Prozessen
- RAID-Rebuild-Fortschritt, Geschwindigkeit, Restzeit, Plattenmodell, SMART, Temperatur und Hersteller-Seriennummer
- Parallele Hintergrundscans mit konfigurierbarer Worker-Anzahl
- PWA-Unterstützung für HTTPS-fähige Installationen auf Desktop und Android
Radarr kann seine Filme z.B. unter `/data/filme` sehen, während der Host sie unter ## Konfiguration
`/nesflix/filme` hat. Das Dashboard unterstützt diese Abbildung explizit:
```yaml Persönliche Werte gehören in `.env`. Die Datei ist in `.gitignore` eingetragen. Als Vorlage dient `.env.example`:
environment:
RADARR_MEDIA_PATH: "/data/filme"
LOCAL_MEDIA_PATH: "/media/filme"
volumes: ```bash
- /nesflix/filme:/media/filme:ro cp .env.example .env
``` ```
### Radarr und Medienpfade
Radarr kann beispielsweise `/data/filme` verwenden, während Media Max die Dateien im Container unter `/media/filme` sieht:
```env
RADARR_URL=http://radarr:7878
RADARR_API_KEY=...
RADARR_MEDIA_PATH=/data/filme
LOCAL_MEDIA_PATH=/media/filme
FILM_MEDIA_VOLUME=/nesflix/filme
```
### Sonarr
```env
SONARR_URL=http://sonarr:8989
SONARR_API_KEY=...
SONARR_MEDIA_PATH=/data/serien
LOCAL_SERIES_PATH=/media/serien
SERIES_MEDIA_VOLUME=/nesflix/serien
```
### SABnzbd
```env
SAB_URL=http://sabnzbd:8080
SAB_API_KEY=...
```
Der Download-Tab verwendet Queue und History aus SABnzbd. Für laufende Releases werden Sprachen aus dem Dateinamen abgeleitet; die Medienanalyse nach dem Import erfolgt über `ffprobe`.
## Dienststatus ## Dienststatus
Der Dashboard-Tab prüft zusätzlich Jellyfin, Seerr, Prowlarr, Easynews as Indexer, Der Dashboard-Tab prüft die Dienste parallel mit kurzen Timeouts und aktualisiert sie regelmäßig:
qBittorrent, Bazarr, Lingarr, LibreTranslate sowie die Sonarr-/Radarr-Prowlarr-Proxies.
Die Prüfungen laufen parallel im Hintergrund und blockieren die Seite nicht.
Die Standardnamen funktionieren, wenn die Container im selben Docker-Netzwerk liegen.
Für qBittorrent muss `QBITTORRENT_URL` auf eine aus dem Dashboard-Container erreichbare
URL zeigen, zum Beispiel `http://host.docker.internal:8080` oder die Host-IP.
```env ```env
JELLYFIN_URL=http://jellyfin:8096 JELLYFIN_URL=http://jellyfin:8096
SEERR_URL=http://seerr:5055 SEERR_URL=http://seerr:5055
PROWLARR_URL=http://prowlarr:9696 PROWLARR_URL=http://prowlarr:9696
EASYNEWS_INDEXER_URL=http://easynews-as-indexer:8081 EASYNEWS_INDEXER_URL=http://easynews-as-indexer:8081
QBITTORRENT_URL=http://HOST_ODER_IP:8080 QBITTORRENT_URL=http://host.docker.internal:8080
BAZARR_URL=http://bazarr:6767 BAZARR_URL=http://bazarr:6767
LINGARR_URL=http://lingarr:9876 LINGARR_URL=http://lingarr:9876
LIBRETRANSLATE_URL=http://libretranslate:5000 LIBRETRANSLATE_URL=http://libretranslate:5000
@@ -47,73 +79,124 @@ SERVICE_CHECK_TIMEOUT=3
SERVICE_REFRESH_SECONDS=30 SERVICE_REFRESH_SECONDS=30
``` ```
Für die bereits vorhandenen Container sind keine zusätzlichen Compose-Services nötig; Die Container müssen im gemeinsamen Docker-Netzwerk `media-stack_default` erreichbar sein. qBittorrent läuft außerhalb des Compose-Stacks und benötigt deshalb eine erreichbare URL. Bei Bedarf stellt `extra_hosts` den Namen `host.docker.internal` bereit.
sie müssen im gemeinsamen `media-stack_default`-Netzwerk erreichbar sein. Falls
qBittorrent nicht über `host.docker.internal` erreichbar ist, kann unter Linux je nach ## Scans und Performance
Docker-Konfiguration dieser Eintrag ergänzt werden:
Der Bibliotheks-Sync läuft als serverseitiger Hintergrund-Thread und benötigt keinen geöffneten Browser. Beim Start und anschließend nach `SYNC_INTERVAL_SECONDS` werden Radarr, Sonarr und die Sprachprüfung aktualisiert. Über den Button „Neu scannen“ kann weiterhin manuell zwischen Radarr, Sonarr, allen Quellen und ausschließlich den aktuellen Einträgen der Liste „Ohne Deutsch“ gewählt werden.
```env
SCAN_WORKERS=4
REQUEST_TIMEOUT=20
DB_PATH=/data/cache.db
SYNC_INTERVAL_SECONDS=1800
RAID_REFRESH_SECONDS=5
IO_REFRESH_SECONDS=5
GPU_REFRESH_SECONDS=5
```
`SCAN_WORKERS` steuert die Anzahl paralleler `ffprobe`-Jobs. Höhere Werte beschleunigen große Bibliotheken, erhöhen aber die I/O-Last.
`SYNC_INTERVAL_SECONDS` steuert den serverseitigen Sync-Takt; `1800` entspricht 30 Minuten, `18000` fünf Stunden und `0` deaktiviert die periodische Wiederholung (der Start-Sync bleibt aktiv).
RAID, I/O-Pressure und GPU werden unabhängig vom Browser durch eigene Hintergrund-Monitoren aktualisiert. Die Werte werden aus dem Server-Cache geliefert; die drei Intervalle können separat angepasst werden.
## Datastore, RAID und SMART
Der Datastore ist standardmäßig `/nesflix` und wird read-only eingebunden:
```env
DATASTORE_PATH=/nesflix
DATASTORE_HOST_PATH=/nesflix
RAID_DEVICE=/dev/md127
STORAGE_CACHE_SECONDS=900
SMART_ENABLED=true
HOST_SYS_PATH=/host-sys
```
Für SMART, Temperaturen und echte Hersteller-Seriennummern benötigt der Container Zugriff auf `/dev`, `/sys`, `/run/udev`, `SYS_RAWIO` sowie `smartctl` im Image. Die App liest keine RAID-Daten schreibend und führt keinen Rebuild aus.
Die I/O Pressure basiert auf Linux PSI (`/proc/pressure/io`). Wenn der Kernel PSI nicht unterstützt, wird die Anzeige als nicht verfügbar markiert.
### NVIDIA Graphics
Der Graphics-Tab verwendet `nvidia-smi` im Container. Optional kann der Pfad über `NVIDIA_SMI_BIN` gesetzt werden:
```env
NVIDIA_SMI_BIN=nvidia-smi
```
Für NVIDIA Container Toolkit kann der Service zusätzlich mit GPU-Zugriff gestartet werden:
```yaml ```yaml
extra_hosts: deploy:
- "host.docker.internal:host-gateway" resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
environment:
NVIDIA_VISIBLE_DEVICES: all
NVIDIA_DRIVER_CAPABILITIES: compute,utility
``` ```
Ein Radarr-Pfad wie: Wenn `nvidia-smi` nicht erreichbar ist, bleibt der Tab verfügbar und zeigt den Grund an.
`/data/filme/Avatar (2009)/Avatar.mkv` ## Logging
wird dadurch für ffprobe zu: ```env
LOG_LEVEL=INFO
`/media/filme/Avatar (2009)/Avatar.mkv` LOG_FILE=/data/media-max.log
LOG_RETENTION_HOURS=6
## Konfiguration
Die persönlichen URLs, Pfade und API-Keys liegen in `.env`. Diese Datei ist in `.gitignore` eingetragen und bleibt bei `git pull` erhalten. Als Vorlage dient `.env.example`.
Beim ersten Start:
```bash
cp .env.example .env
``` ```
Danach die Werte in `.env` eintragen. Sonarr und SABnzbd werden dort durch Auskommentieren der jeweiligen Variablen aktiviert. Wenn Sonarr aktiv ist, muss `SERIES_MEDIA_VOLUME` auf den echten Host-Pfad deiner Serien-Library zeigen; dieser Ordner wird in den Dashboard-Container nach `/media/serien` gemountet. Erwartbare Verbindungsfehler werden als kurze einzeilige Meldungen protokolliert. Die Logdatei wird standardmäßig alle sechs Stunden rotiert.
## Start ## Start mit Docker Compose
API-Keys und Pfade in `.env` anpassen:
```bash ```bash
docker compose down
docker compose up -d --build docker compose up -d --build
docker compose logs -f docker compose logs -f media-max
``` ```
Die Seite lädt zuerst die Library-Metadaten. Die ffprobe-Scans laufen danach im Hintergrund; oben wird der Fortschritt angezeigt. Für Sonarr die optionalen Variablen und den Serien-Volume-Mount in `docker-compose.yml` aktivieren. Die Navigation trennt Filme und Serien, Serien sind pro Show aufklappbar. Der Compose-Stack bindet standardmäßig ein:
Sonarr benötigt:
```yaml ```yaml
SONARR_URL: "http://sonarr:8989" volumes:
SONARR_API_KEY: "..." - ${FILM_MEDIA_VOLUME}:/media/filme:ro
SONARR_MEDIA_PATH: "/data/serien" - ${SERIES_MEDIA_VOLUME:-/nesflix/serien}:/media/serien:ro
LOCAL_SERIES_PATH: "/media/serien" - ${DATASTORE_HOST_PATH:-/nesflix}:/nesflix:ro
- /dev:/host-dev:ro
- /sys:/host-sys:ro
- /run/udev:/host-run-udev:ro
- ./data:/data
``` ```
SABnzbd benötigt für den Download-Tab: Für Radarr, Sonarr und die weiteren Container wird das externe Netzwerk `media-stack_default` verwendet.
```yaml ## Fehlersuche
SAB_URL: "http://sabnzbd:8080"
SAB_API_KEY: "..."
```
Der API-Key steht in SABnzbd unter `Config > General`. Die laufenden Jobs kommen aus der SABnzbd-Queue, abgeschlossene und fehlgeschlagene Jobs aus der History. Die Sprache eines laufenden Releases wird aus dessen Namen abgeleitet; nach dem Import ist die Sprache im Radarr-/Sonarr-Scan verlässlich. Prüfe zuerst, ob Media Max die Medien sieht:
## Bei weiterem Scanfehler
Prüfe einen Pfad im Container:
```bash ```bash
docker exec -it media-max sh docker exec -it media-max sh
ls -lah /media/filme ls -lah /media/filme
ls -lah /media/serien
find /media/filme -type f | head find /media/filme -type f | head
ffprobe -v error -show_entries stream=codec_type,codec_name:stream_tags=language -of json "/media/filme/DEIN/FILM.mkv"
``` ```
Teste anschließend `ffprobe` direkt im Container:
```bash
ffprobe -v error \
-show_entries stream=codec_type,codec_name:stream_tags=language \
-of json "/media/filme/DEIN/FILM.mkv"
```
Für SMART:
```bash
smartctl -i -d sat /host-dev/sda
smartctl -A -d sat /host-dev/sda
```
Wenn eine PWA-Installation in Chrome nicht angeboten wird, muss Media Max über gültiges HTTPS aufgerufen werden. Nach Änderungen an Manifest oder Service Worker sollten alte Website-Daten bzw. eine alte Verknüpfung entfernt und die Seite neu geladen werden.
+410 -54
View File
@@ -13,7 +13,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
import requests import requests
from flask import Flask, jsonify, render_template, request from flask import Flask, jsonify, render_template, request, send_from_directory
RADARR_URL = os.environ.get("RADARR_URL", "http://radarr:7878").rstrip("/") RADARR_URL = os.environ.get("RADARR_URL", "http://radarr:7878").rstrip("/")
RADARR_API_KEY = os.environ.get("RADARR_API_KEY", "") RADARR_API_KEY = os.environ.get("RADARR_API_KEY", "")
@@ -43,13 +43,25 @@ DATASTORE_PATH = os.environ.get("DATASTORE_PATH", "/nesflix").rstrip("/") or "/"
RAID_DEVICE = os.environ.get("RAID_DEVICE", "/dev/md127") RAID_DEVICE = os.environ.get("RAID_DEVICE", "/dev/md127")
HOST_DEVICE_PATH = os.environ.get("HOST_DEVICE_PATH", "/host-dev") HOST_DEVICE_PATH = os.environ.get("HOST_DEVICE_PATH", "/host-dev")
HOST_SYS_PATH = os.environ.get("HOST_SYS_PATH", "/host-sys") HOST_SYS_PATH = os.environ.get("HOST_SYS_PATH", "/host-sys")
NVIDIA_SMI_BIN = os.environ.get("NVIDIA_SMI_BIN", "nvidia-smi")
STORAGE_CACHE_SECONDS = int(os.environ.get("STORAGE_CACHE_SECONDS", "900")) STORAGE_CACHE_SECONDS = int(os.environ.get("STORAGE_CACHE_SECONDS", "900"))
SMART_ENABLED = os.environ.get("SMART_ENABLED", "false").lower() in {"1", "true", "yes", "on"} SMART_ENABLED = os.environ.get("SMART_ENABLED", "false").lower() in {"1", "true", "yes", "on"}
LOG_FILE = os.environ.get("LOG_FILE", "/data/media-max.log") LOG_FILE = os.environ.get("LOG_FILE", "/data/media-max.log")
LOG_RETENTION_HOURS = max(1, int(os.environ.get("LOG_RETENTION_HOURS", "6"))) LOG_RETENTION_HOURS = max(1, int(os.environ.get("LOG_RETENTION_HOURS", "6")))
SYNC_INTERVAL_SECONDS = max(0, int(os.environ.get("SYNC_INTERVAL_SECONDS", "1800")))
RAID_REFRESH_SECONDS = max(2, int(os.environ.get("RAID_REFRESH_SECONDS", "5")))
IO_REFRESH_SECONDS = max(2, int(os.environ.get("IO_REFRESH_SECONDS", "5")))
GPU_REFRESH_SECONDS = max(2, int(os.environ.get("GPU_REFRESH_SECONDS", "5")))
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)s %(message)s") logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("media-max") log = logging.getLogger("media-max")
def error_summary(exc):
"""Return one short, useful log line instead of a connection traceback."""
if isinstance(exc, requests.RequestException):
return f"{exc.__class__.__name__}: Dienst nicht erreichbar"
return " ".join(str(exc).split())[:240] or exc.__class__.__name__
try: try:
file_handler = TimedRotatingFileHandler(LOG_FILE, when="H", interval=LOG_RETENTION_HOURS, backupCount=1, encoding="utf-8", delay=True, utc=True) file_handler = TimedRotatingFileHandler(LOG_FILE, when="H", interval=LOG_RETENTION_HOURS, backupCount=1, encoding="utf-8", delay=True, utc=True)
file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
@@ -57,18 +69,35 @@ try:
except OSError as exc: except OSError as exc:
log.warning("Datei-Logging nicht verfügbar (%s): %s", LOG_FILE, exc) log.warning("Datei-Logging nicht verfügbar (%s): %s", LOG_FILE, exc)
app = Flask(__name__) app = Flask(__name__)
@app.get("/sw.js")
def service_worker():
"""Serve the worker from the origin root so it can control the whole app."""
response = send_from_directory(app.static_folder, "sw.js", mimetype="application/javascript")
response.headers["Service-Worker-Allowed"] = "/"
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return response
executor = ThreadPoolExecutor(max_workers=int(os.environ.get("SCAN_WORKERS", "2"))) executor = ThreadPoolExecutor(max_workers=int(os.environ.get("SCAN_WORKERS", "2")))
scan_executor = ThreadPoolExecutor(max_workers=int(os.environ.get("SCAN_WORKERS", "4"))) scan_executor = ThreadPoolExecutor(max_workers=int(os.environ.get("SCAN_WORKERS", "4")))
jobs = {"radarr": {"state": "idle", "total": 0, "done": 0, "error": None}, "sonarr": {"state": "idle", "total": 0, "done": 0, "error": None}} jobs = {"radarr": {"state": "idle", "total": 0, "done": 0, "error": None}, "sonarr": {"state": "idle", "total": 0, "done": 0, "error": None}, "missing": {"state": "idle", "total": 0, "done": 0, "error": None}}
job_lock = threading.Lock() job_lock = threading.Lock()
system_lock = threading.Lock() system_lock = threading.Lock()
previous_cpu = None previous_cpu = None
service_status_lock = threading.Lock() service_status_lock = threading.Lock()
service_check_executor = ThreadPoolExecutor(max_workers=16) service_check_executor = ThreadPoolExecutor(max_workers=16)
datastore_cache_lock = threading.Lock() datastore_cache_lock = threading.Lock()
datastore_cache = {"stored_at": 0.0, "snapshot": None, "raid": None, "raid_at": 0.0} datastore_refresh_lock = threading.Lock()
raid_refresh_lock = threading.Lock()
datastore_cache = {"stored_at": 0.0, "snapshot": None, "raid": None, "raid_at": 0.0, "io": None, "io_at": 0.0}
raid_log_state = None raid_log_state = None
graphics_log_state = None
graphics_process_log_state = None
graphics_cache_lock = threading.Lock()
graphics_cache = None
hardware_log_once = set() hardware_log_once = set()
library_cache_lock = threading.Lock()
library_cache = {"rows": [], "series": [], "updated_at": 0.0, "error": None, "ready": False}
LANG_NAMES = {"de":"Deutsch", "deu":"Deutsch", "ger":"Deutsch", "en":"Englisch", "eng":"Englisch", "ja":"Japanisch", "jpn":"Japanisch", "ko":"Koreanisch", "kor":"Koreanisch", "fr":"Französisch", "fra":"Französisch", "fre":"Französisch", "es":"Spanisch", "spa":"Spanisch", "it":"Italienisch", "ita":"Italienisch", "ru":"Russisch", "rus":"Russisch", "zh":"Chinesisch", "zho":"Chinesisch", "chi":"Chinesisch", "und":"Unbekannt", "":"Unbekannt"} LANG_NAMES = {"de":"Deutsch", "deu":"Deutsch", "ger":"Deutsch", "en":"Englisch", "eng":"Englisch", "ja":"Japanisch", "jpn":"Japanisch", "ko":"Koreanisch", "kor":"Koreanisch", "fr":"Französisch", "fra":"Französisch", "fre":"Französisch", "es":"Spanisch", "spa":"Spanisch", "it":"Italienisch", "ita":"Italienisch", "ru":"Russisch", "rus":"Russisch", "zh":"Chinesisch", "zho":"Chinesisch", "chi":"Chinesisch", "und":"Unbekannt", "":"Unbekannt"}
FLAGS = {"Deutsch":"🇩🇪", "Englisch":"🇬🇧", "Japanisch":"🇯🇵", "Koreanisch":"🇰🇷", "Französisch":"🇫🇷", "Spanisch":"🇪🇸", "Italienisch":"🇮🇹", "Russisch":"🇷🇺", "Chinesisch":"🇨🇳", "Unbekannt":""} FLAGS = {"Deutsch":"🇩🇪", "Englisch":"🇬🇧", "Japanisch":"🇯🇵", "Koreanisch":"🇰🇷", "Französisch":"🇫🇷", "Spanisch":"🇪🇸", "Italienisch":"🇮🇹", "Russisch":"🇷🇺", "Chinesisch":"🇨🇳", "Unbekannt":""}
@@ -138,8 +167,8 @@ def service_monitor():
while True: while True:
try: try:
refresh_services() refresh_services()
except Exception: except Exception as exc:
log.exception("Dienststatus konnte nicht aktualisiert werden") log.error("Dienststatus konnte nicht aktualisiert werden: %s", error_summary(exc))
time.sleep(max(5, SERVICE_REFRESH_SECONDS)) time.sleep(max(5, SERVICE_REFRESH_SECONDS))
threading.Thread(target=service_monitor, name="service-monitor", daemon=True).start() threading.Thread(target=service_monitor, name="service-monitor", daemon=True).start()
@@ -228,8 +257,8 @@ def scan_datastore_storage():
category["files"] += 1 category["files"] += 1
usage = shutil.disk_usage(root) usage = shutil.disk_usage(root)
except OSError as exc: except OSError as exc:
log.exception("Datastore konnte nicht gelesen werden: %s", DATASTORE_PATH) log.error("Datastore konnte nicht gelesen werden (%s): %s", DATASTORE_PATH, error_summary(exc))
return {"error": f"Datastore konnte nicht gelesen werden: {exc}", "path": DATASTORE_PATH, "categories": list(categories.values())} return {"error": "Datastore momentan nicht verfügbar", "path": DATASTORE_PATH, "categories": list(categories.values())}
used = usage.used used = usage.used
values = [] values = []
for category in categories.values(): for category in categories.values():
@@ -322,6 +351,101 @@ def format_remaining_time(value):
parts.append(f"{minutes} Min.") parts.append(f"{minutes} Min.")
return " ".join(parts) or "< 1 Min." return " ".join(parts) or "< 1 Min."
def io_pressure():
"""Read Linux PSI I/O pressure, if the kernel exposes it."""
try:
values = {}
for line in Path("/proc/pressure/io").read_text().splitlines():
parts = line.split()
if not parts:
continue
values[parts[0]] = {key: float(value) for key, value in (item.split("=", 1) for item in parts[1:] if "=" in item)}
return {"some": values.get("some", {}), "full": values.get("full", {}), "available": bool(values)}
except (OSError, ValueError):
return {"some": {}, "full": {}, "available": False}
def graphics_snapshot():
"""Read NVIDIA GPU details with short, non-blocking subprocess timeouts."""
global graphics_log_state, graphics_process_log_state
query = "index,name,uuid,serial,pci.bus_id,driver_version,pstate,temperature.gpu,utilization.gpu,utilization.memory,memory.total,memory.used,memory.free,power.draw,power.limit,fan.speed,clocks.current.graphics,clocks.current.memory,clocks.max.graphics,clocks.max.memory,compute_mode"
try:
result = subprocess.run([NVIDIA_SMI_BIN, f"--query-gpu={query}", "--format=csv,noheader,nounits"], capture_output=True, text=True, timeout=5, check=False)
except (OSError, subprocess.SubprocessError) as exc:
signature = ("unavailable", str(exc))
if signature != graphics_log_state:
log.warning("GPU-Abfrage fehlgeschlagen: %s (%s)", NVIDIA_SMI_BIN, error_summary(exc))
graphics_log_state = signature
return {"available": False, "gpus": [], "processes": [], "error": "NVIDIA SMI nicht verfügbar"}
if result.returncode != 0 or not result.stdout.strip():
detail = " ".join((result.stderr or result.stdout or "keine Ausgabe").split())[:240]
signature = ("failed", result.returncode, detail)
if signature != graphics_log_state:
log.warning("GPU-Abfrage fehlgeschlagen: %s (Code %s): %s", NVIDIA_SMI_BIN, result.returncode, detail)
graphics_log_state = signature
return {"available": False, "gpus": [], "processes": [], "error": "Keine NVIDIA-GPU erkannt"}
fields = query.split(",")
gpus = []
for line in result.stdout.splitlines():
values = [value.strip() for value in line.split(",")]
if len(values) < len(fields):
continue
gpu = dict(zip(fields, values))
for key in ("index", "temperature.gpu", "utilization.gpu", "utilization.memory", "memory.total", "memory.used", "memory.free", "clocks.current.graphics", "clocks.current.memory", "clocks.max.graphics", "clocks.max.memory"):
try: gpu[key] = int(float(gpu[key]))
except (TypeError, ValueError): gpu[key] = None
for key in ("power.draw", "power.limit"):
try: gpu[key] = round(float(gpu[key]), 1)
except (TypeError, ValueError): gpu[key] = None
gpus.append(gpu)
processes_by_pid = {}
try:
proc = subprocess.run([NVIDIA_SMI_BIN, "--query-compute-apps=pid,process_name,used_gpu_memory", "--format=csv,noheader,nounits"], capture_output=True, text=True, timeout=5, check=False)
if proc.returncode == 0:
for line in proc.stdout.splitlines():
values = [value.strip() for value in line.split(",")]
if len(values) >= 3 and values[0].isdigit():
processes_by_pid[values[0]] = {"pid": values[0], "name": values[1], "memory": values[2], "type": "compute"}
except (OSError, subprocess.SubprocessError):
pass
pmon_error = ""
try:
# Compute-apps misses NVENC/NVDEC. pmon also reports video/graphics
# processes such as Jellyfin while a hardware transcode is running.
pmon = subprocess.run([NVIDIA_SMI_BIN, "pmon", "-c", "1", "-s", "um"], capture_output=True, text=True, timeout=5, check=False)
if pmon.returncode == 0:
for line in pmon.stdout.splitlines():
values = line.split()
if not values or values[0].startswith("#") or len(values) < 8 or not values[1].isdigit():
continue
pid, process_type = values[1], values[2]
command = " ".join(values[7:]) or "GPU-Prozess"
processes_by_pid[pid] = {
"pid": pid,
"name": command,
"memory": f"SM {values[3]}% · ENC {values[5]}% · DEC {values[6]}%",
"type": process_type,
"sm": values[3], "enc": values[5], "dec": values[6],
}
else:
pmon_error = " ".join((pmon.stderr or "pmon ohne Ausgabe").split())[:180]
except (OSError, subprocess.SubprocessError) as exc:
pmon_error = error_summary(exc)
processes = list(processes_by_pid.values())
process_signature = tuple((item["pid"], item["name"], item.get("type")) for item in processes)
if process_signature != graphics_process_log_state:
if processes:
log.info("GPU-Prozesse erkannt: %s", ", ".join(f"{item['name']} (PID {item['pid']})" for item in processes))
elif pmon_error:
log.warning("GPU-Prozessabfrage fehlgeschlagen: %s", pmon_error)
else:
log.info("Keine GPU-Prozesse erkannt")
graphics_process_log_state = process_signature
signature = ("ok", tuple((gpu.get("index"), gpu.get("name"), gpu.get("temperature.gpu"), gpu.get("utilization.gpu")) for gpu in gpus))
if signature != graphics_log_state:
log.info("GPU-Status verfügbar: %s NVIDIA-GPU(s) via %s", len(gpus), NVIDIA_SMI_BIN)
graphics_log_state = signature
return {"available": bool(gpus), "gpus": gpus, "processes": processes, "error": None if gpus else "Keine NVIDIA-GPU erkannt", "updated_at": time.time()}
def smart_status(device): def smart_status(device):
if not SMART_ENABLED: if not SMART_ENABLED:
return "Nicht aktiviert" return "Nicht aktiviert"
@@ -336,6 +460,56 @@ def smart_status(device):
health = re.search(r"(?:SMART overall-health self-assessment test result|SMART Health Status):\s*(.+)", smart_output, re.IGNORECASE) health = re.search(r"(?:SMART overall-health self-assessment test result|SMART Health Status):\s*(.+)", smart_output, re.IGNORECASE)
return health.group(1).strip() if health else ("Fehler: " + smart_error.strip()[:80] if smart_error.strip() else "Nicht unterstützt") return health.group(1).strip() if health else ("Fehler: " + smart_error.strip()[:80] if smart_error.strip() else "Nicht unterstützt")
def smart_serial(device):
"""Read the manufacturer serial, not a WWN or SCSI by-id alias."""
if not shutil.which("smartctl"):
return ""
smart_device = device.replace("/dev/", f"{HOST_DEVICE_PATH.rstrip('/')}/", 1)
for command in (["smartctl", "-i", "-d", "sat", smart_device], ["smartctl", "-i", smart_device]):
output, _ = _read_command(command, timeout=6, warn_on_nonzero=False)
match = re.search(r"^\s*Serial Number:\s*(\S+)\s*$", output, re.IGNORECASE | re.MULTILINE)
if match and not match.group(1).lower().startswith(("wwn-", "0x")):
return match.group(1).strip()
return ""
def smart_temperature(device):
"""Read the drive temperature in a controller-friendly way."""
if not SMART_ENABLED or not shutil.which("smartctl"):
return "Nicht verfügbar"
smart_device = device.replace("/dev/", f"{HOST_DEVICE_PATH.rstrip('/')}/", 1)
def extract(output):
lines = output.splitlines()
# Seagate and similar drives often expose both Airflow_Temperature
# and Temperature_Celsius. Prefer the latter because it is the
# physical drive temperature shown by the usual terminal command.
ordered = [line for line in lines if re.search(r"Temperature_Celsius", line, re.IGNORECASE)]
ordered += [line for line in lines if line not in ordered and re.search(r"Current Drive Temperature|Drive Temperature|^\s*Temperature:\s*", line, re.IGNORECASE)]
ordered += [line for line in lines if line not in ordered and re.search(r"temperature|airflow", line, re.IGNORECASE)]
for line in ordered:
raw_temperature = re.search(r"Temperature_Celsius.*-\s*(-?\d{1,3})\s*(?:\([^)]*\))?\s*$", line, re.IGNORECASE)
if raw_temperature and 0 < int(raw_temperature.group(1)) < 150:
return f"{int(raw_temperature.group(1))} °C"
explicit = re.search(r"(\d{1,3})\s*(?:°\s*C|Celsius|degrees?\s*C)\b", line, re.IGNORECASE)
if explicit and 0 < int(explicit.group(1)) < 150:
return f"{int(explicit.group(1))} °C"
numbers = [int(value) for value in re.findall(r"\b\d{1,3}\b", line)]
plausible = [value for value in numbers if 0 < value < 150]
if plausible:
return f"{plausible[-1]} °C"
return None
for command in (
["smartctl", "-x", "-d", "sat", smart_device],
["smartctl", "-A", "-d", "sat", smart_device],
["smartctl", "-A", smart_device],
["smartctl", "-x", smart_device],
):
output, _ = _read_command(command, timeout=6, warn_on_nonzero=False)
temperature = extract(output)
if temperature:
return temperature
return "Nicht verfügbar"
def raid_status(): def raid_status():
global raid_log_state global raid_log_state
mdstat, mdstat_error = "", "" mdstat, mdstat_error = "", ""
@@ -390,8 +564,12 @@ def raid_status():
except (ValueError, AttributeError): except (ValueError, AttributeError):
block_devices = {} block_devices = {}
smart_futures = {} smart_futures = {}
if SMART_ENABLED and shutil.which("smartctl"): serial_futures = {}
temperature_futures = {}
if shutil.which("smartctl"):
smart_futures = {disk["device"]: service_check_executor.submit(smart_status, disk["device"]) for disk in devices} smart_futures = {disk["device"]: service_check_executor.submit(smart_status, disk["device"]) for disk in devices}
serial_futures = {disk["device"]: service_check_executor.submit(smart_serial, disk["device"]) for disk in devices}
temperature_futures = {disk["device"]: service_check_executor.submit(smart_temperature, disk["device"]) for disk in devices}
for disk in devices: for disk in devices:
info = block_devices.get(disk["device"], {}) info = block_devices.get(disk["device"], {})
parent = re.sub(r"(p)?\d+$", "", disk["device"]) parent = re.sub(r"(p)?\d+$", "", disk["device"])
@@ -404,7 +582,7 @@ def raid_status():
info = host_items[0] if host_items else {} info = host_items[0] if host_items else {}
except (ValueError, AttributeError): except (ValueError, AttributeError):
info = {} info = {}
serial = info.get("serial") or _device_serial(disk["device"]) serial = (serial_futures[disk["device"]].result() if disk["device"] in serial_futures else "") or info.get("serial") or _device_serial(disk["device"])
disk.update({"size": format_bytes(info.get("size") or 0) if info.get("size") else "", "model": info.get("model") or "", "serial": serial or ""}) disk.update({"size": format_bytes(info.get("size") or 0) if info.get("size") else "", "model": info.get("model") or "", "serial": serial or ""})
serial_log_key = "serial:" + disk["device"] serial_log_key = "serial:" + disk["device"]
if not serial and serial_log_key not in hardware_log_once: if not serial and serial_log_key not in hardware_log_once:
@@ -419,6 +597,7 @@ def raid_status():
elif disk["smart"] != "PASSED" and smart_log_key not in hardware_log_once: elif disk["smart"] != "PASSED" and smart_log_key not in hardware_log_once:
log.warning("SMART-Status für %s nicht verfügbar: %s", disk["device"], disk["smart"]) log.warning("SMART-Status für %s nicht verfügbar: %s", disk["device"], disk["smart"])
hardware_log_once.add(smart_log_key) hardware_log_once.add(smart_log_key)
disk["temperature"] = temperature_futures[disk["device"]].result() if disk["device"] in temperature_futures else smart_temperature(disk["device"])
missing = members_match and members_match.group(1) != members_match.group(2) missing = members_match and members_match.group(1) != members_match.group(2)
failed = detail.get("failed_devices", 0) > 0 or any(disk["status"] == "failed" for disk in devices) failed = detail.get("failed_devices", 0) > 0 or any(disk["status"] == "failed" for disk in devices)
rebuilding = bool(progress_match) rebuilding = bool(progress_match)
@@ -448,29 +627,89 @@ def raid_status():
"finish": format_remaining_time(finish_match.group(1)) if finish_match else "", "speed": format_transfer_rate(speed_match.group(1)) if speed_match else ""}, "finish": format_remaining_time(finish_match.group(1)) if finish_match else "", "speed": format_transfer_rate(speed_match.group(1)) if speed_match else ""},
"error": None if array_line or mdadm_output else (mdadm_error or mdstat_error or "RAID-Status nicht verfügbar"), "updated_at": time.time()} "error": None if array_line or mdadm_output else (mdadm_error or mdstat_error or "RAID-Status nicht verfügbar"), "updated_at": time.time()}
def datastore_snapshot(): def refresh_datastore_storage():
now = time.time() if not datastore_refresh_lock.acquire(blocking=False):
with datastore_cache_lock: return
if datastore_cache["snapshot"] is None or now - datastore_cache["stored_at"] >= max(30, STORAGE_CACHE_SECONDS): try:
fresh_storage = scan_datastore_storage() fresh_storage = scan_datastore_storage()
with datastore_cache_lock:
if fresh_storage.get("error") and datastore_cache["snapshot"]: if fresh_storage.get("error") and datastore_cache["snapshot"]:
datastore_cache["snapshot"] = dict(datastore_cache["snapshot"]) datastore_cache["snapshot"] = dict(datastore_cache["snapshot"])
datastore_cache["snapshot"]["stale"] = True datastore_cache["snapshot"]["stale"] = True
datastore_cache["snapshot"]["warning"] = fresh_storage["error"] datastore_cache["snapshot"]["warning"] = fresh_storage["error"]
else: else:
datastore_cache["snapshot"] = fresh_storage datastore_cache["snapshot"] = fresh_storage
datastore_cache["stored_at"] = now datastore_cache["stored_at"] = time.time()
storage = datastore_cache["snapshot"] finally:
current_raid = raid_status() datastore_refresh_lock.release()
def refresh_raid_status():
if not raid_refresh_lock.acquire(blocking=False):
return
try:
current = raid_status()
with datastore_cache_lock: with datastore_cache_lock:
if current_raid.get("status") == "failed" and current_raid.get("error") and datastore_cache.get("raid") and now - datastore_cache.get("raid_at", 0) < 60: if current.get("status") == "failed" and current.get("error") and datastore_cache.get("raid"):
current_raid = dict(datastore_cache["raid"]) cached = dict(datastore_cache["raid"])
current_raid["stale"] = True cached["stale"] = True
current_raid["warning"] = "Vorübergehend keine aktuelle RAID-Antwort; letzter gültiger Stand wird angezeigt." cached["warning"] = "Vorübergehend keine aktuelle RAID-Antwort."
elif current_raid.get("status") != "failed": datastore_cache["raid"] = cached
datastore_cache["raid"] = dict(current_raid) else:
datastore_cache["raid_at"] = now datastore_cache["raid"] = current
return {"storage": storage, "raid": current_raid, "cache_seconds": STORAGE_CACHE_SECONDS} datastore_cache["raid_at"] = time.time()
except Exception as exc:
log.error("RAID-Refresh fehlgeschlagen: %s", error_summary(exc))
finally:
raid_refresh_lock.release()
def refresh_io_status():
current = io_pressure()
with datastore_cache_lock:
datastore_cache["io"] = current
datastore_cache["io_at"] = time.time()
def _hardware_loop(name, interval, refresh):
log.info("%s-Monitor gestartet (Intervall: %ss)", name, interval)
while True:
try:
refresh()
except Exception as exc:
log.error("%s-Monitor fehlgeschlagen: %s", name, error_summary(exc))
time.sleep(interval)
def refresh_gpu_status():
global graphics_cache
current = graphics_snapshot()
with graphics_cache_lock:
graphics_cache = current
def hardware_status_monitor():
"""Run slow hardware probes independently and in parallel."""
for name, interval, refresh in (
("RAID", RAID_REFRESH_SECONDS, refresh_raid_status),
("I/O", IO_REFRESH_SECONDS, refresh_io_status),
("GPU", GPU_REFRESH_SECONDS, refresh_gpu_status),
):
threading.Thread(target=_hardware_loop, args=(name, interval, refresh), name=f"{name.lower()}-monitor", daemon=True).start()
def datastore_snapshot():
now = time.time()
with datastore_cache_lock:
storage = datastore_cache["snapshot"]
refresh_due = storage is None or now - datastore_cache["stored_at"] >= max(30, STORAGE_CACHE_SECONDS)
if storage is None:
refresh_datastore_storage()
elif refresh_due:
threading.Thread(target=refresh_datastore_storage, name="datastore-refresh", daemon=True).start()
with datastore_cache_lock:
storage = datastore_cache["snapshot"]
with datastore_cache_lock:
current_raid = datastore_cache.get("raid")
if current_raid is None:
current_raid = {"device": RAID_DEVICE, "status": "unknown", "label": "WIRD GELADEN", "error": "RAID-Status wird geladen"}
with datastore_cache_lock:
current_io = datastore_cache.get("io") or {"available": False, "some": {}, "full": {}}
return {"storage": storage, "raid": current_raid, "io_pressure": current_io, "cache_seconds": STORAGE_CACHE_SECONDS}
def cpu_usage_percent(): def cpu_usage_percent():
global previous_cpu global previous_cpu
@@ -530,8 +769,20 @@ def system_metrics():
def release_languages(name): def release_languages(name):
value = f" {name.lower().replace('.', ' ').replace('-', ' ')} " value = f" {name.lower().replace('.', ' ').replace('-', ' ')} "
found = [] found = []
patterns = [("Deutsch", (" german ", " deutsch ", " german dubbed ", " ger dub ", " german dl ")), ("Englisch", (" english ", " eng ")), ("Japanisch", (" japanese ", " japan ", " jpn ")), ("Französisch", (" french ", " francais ", " fra ")), ("Spanisch", (" spanish ", " esp "))] patterns = [
codes = {"Deutsch": "de", "Englisch": "en", "Japanisch": "ja", "Französisch": "fr", "Spanisch": "es"} ("Deutsch", (" german ", " german dubbed ", " deutsch ", " ger ", " deu ", " german dl ")),
("Englisch", (" english ", " eng ")),
("Japanisch", (" japanese ", " japan ", " jpn ")),
("Französisch", (" french ", " francais ", " français ", " fre ", " fra ")),
("Spanisch", (" spanish ", " esp ", " spa ")),
("Italienisch", (" italian ", " ita ")),
("Portugiesisch", (" portuguese ", " por ", " pt br ")),
("Koreanisch", (" korean ", " kor ")),
("Chinesisch", (" chinese ", " chi ", " zho ")),
("Niederländisch", (" dutch ", " nld ", " ned ")),
("Russisch", (" russian ", " rus ")),
]
codes = {"Deutsch": "de", "Englisch": "en", "Japanisch": "ja", "Französisch": "fr", "Spanisch": "es", "Italienisch": "it", "Portugiesisch": "pt", "Koreanisch": "ko", "Chinesisch": "zh", "Niederländisch": "nl", "Russisch": "ru"}
for language, tokens in patterns: for language, tokens in patterns:
if any(token in value for token in tokens): found.append(norm_lang(codes[language])) if any(token in value for token in tokens): found.append(norm_lang(codes[language]))
return unique_langs(found) return unique_langs(found)
@@ -543,8 +794,8 @@ def sab_downloads():
try: try:
if RADARR_API_KEY: wanted.extend({"title": x.get("movie", {}).get("title") or x.get("title", ""), "source": "Radarr"} for x in api_get(RADARR_URL, RADARR_API_KEY, "queue?includeUnknownMovieItems=true").get("records", [])) if RADARR_API_KEY: wanted.extend({"title": x.get("movie", {}).get("title") or x.get("title", ""), "source": "Radarr"} for x in api_get(RADARR_URL, RADARR_API_KEY, "queue?includeUnknownMovieItems=true").get("records", []))
if SONARR_URL and SONARR_API_KEY: wanted.extend({"title": x.get("title", ""), "source": "Sonarr"} for x in api_get(SONARR_URL, SONARR_API_KEY, "queue?includeUnknownSeriesItems=true").get("records", [])) if SONARR_URL and SONARR_API_KEY: wanted.extend({"title": x.get("title", ""), "source": "Sonarr"} for x in api_get(SONARR_URL, SONARR_API_KEY, "queue?includeUnknownSeriesItems=true").get("records", []))
except Exception: except Exception as exc:
log.warning("Radarr/Sonarr-Queue konnte für SAB-Zuordnung nicht geladen werden", exc_info=True) log.warning("Radarr/Sonarr-Queue konnte für SAB-Zuordnung nicht geladen werden: %s", error_summary(exc))
def make_item(item, completed=False): def make_item(item, completed=False):
title = item.get("name") or item.get("filename") or item.get("nzb_name") or "" title = item.get("name") or item.get("filename") or item.get("nzb_name") or ""
@@ -583,6 +834,7 @@ def map_path(path, remote, local):
return path return path
EXTERNAL_SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".vtt", ".sub", ".idx", ".sup"} EXTERNAL_SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".vtt", ".sub", ".idx", ".sup"}
SCANNER_VERSION = 2
def external_subtitle_files(path): def external_subtitle_files(path):
media = Path(path) media = Path(path)
@@ -611,18 +863,40 @@ def external_subtitle_languages(path):
languages.append(norm_lang("it")) languages.append(norm_lang("it"))
return unique_langs(languages) return unique_langs(languages)
def language_from_text(value):
"""Infer a language from a subtitle/audio track title when the codec tag is `und`."""
tokens = {token for token in re.split(r"[. _()\[\]-]+", str(value or "").lower()) if token}
aliases = {
"de": "de", "deu": "de", "ger": "de", "german": "de", "deutsch": "de",
"en": "en", "eng": "en", "english": "en",
"ja": "ja", "jpn": "ja", "japanese": "ja", "japanisch": "ja",
"ko": "ko", "kor": "ko", "korean": "ko", "koreanisch": "ko",
"fr": "fr", "fra": "fr", "fre": "fr", "french": "fr", "französisch": "fr",
"es": "es", "spa": "es", "spanish": "es", "spanisch": "es",
"it": "it", "ita": "it", "italian": "it", "italienisch": "it",
}
for token in tokens:
if token in aliases:
return norm_lang(aliases[token])
return None
def run_ffprobe(path): def run_ffprobe(path):
proc = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=size:stream=index,codec_type,codec_name,channels,channel_layout:stream_tags=language,title", "-of", "json", path], capture_output=True, text=True, timeout=90) proc = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=size:stream=index,codec_type,codec_name,channels,channel_layout:stream_tags=language,title", "-of", "json", path], capture_output=True, text=True, timeout=90)
if proc.returncode != 0: raise RuntimeError(proc.stderr.strip() or "ffprobe fehlgeschlagen") if proc.returncode != 0: raise RuntimeError(proc.stderr.strip() or "ffprobe fehlgeschlagen")
raw, audios, subs, details, video = json.loads(proc.stdout or "{}"), [], [], [], None raw, audios, subs, details, video = json.loads(proc.stdout or "{}"), [], [], [], None
for stream in raw.get("streams", []): for stream in raw.get("streams", []):
tags = stream.get("tags") or {}; lang = norm_lang(tags.get("language")); kind = stream.get("codec_type") tags = stream.get("tags") or {}; kind = stream.get("codec_type")
lang = norm_lang(tags.get("language"))
# Viele ASS-Spuren werden von ffprobe nur als `und` gemeldet, obwohl
# der Tracktitel z. B. „Deutsch - Undefined - ASS“ enthält.
if kind == "subtitle" and lang["name"] == "Unbekannt":
lang = language_from_text(tags.get("title")) or lang
if kind == "video" and not video: video = stream.get("codec_name") if kind == "video" and not video: video = stream.get("codec_name")
elif kind == "audio": elif kind == "audio":
audios.append(lang); details.append({"language": lang, "codec": stream.get("codec_name"), "channels": stream.get("channels"), "layout": stream.get("channel_layout"), "title": tags.get("title")}) audios.append(lang); details.append({"language": lang, "codec": stream.get("codec_name"), "channels": stream.get("channels"), "layout": stream.get("channel_layout"), "title": tags.get("title")})
elif kind == "subtitle": subs.append(lang) elif kind == "subtitle": subs.append(lang)
subtitles = unique_langs(subs + external_subtitle_languages(path)) subtitles = unique_langs(subs + external_subtitle_languages(path))
return {"audio_languages": unique_langs(audios), "subtitle_languages": subtitles, "external_subtitle_files": [str(file) for file in external_subtitle_files(path)], "audio_details": details, "video_codec": video or "", "size": int((raw.get("format") or {}).get("size") or 0)} return {"scanner_version": SCANNER_VERSION, "audio_languages": unique_langs(audios), "subtitle_languages": subtitles, "external_subtitle_files": [str(file) for file in external_subtitle_files(path)], "audio_details": details, "video_codec": video or "", "size": int((raw.get("format") or {}).get("size") or 0)}
def cached(path): def cached(path):
p = Path(path) p = Path(path)
@@ -631,7 +905,7 @@ def cached(path):
if row and float(row["mtime"]) == float(mtime): if row and float(row["mtime"]) == float(mtime):
data = json.loads(row["data"]) data = json.loads(row["data"])
current_subtitles = [str(file) for file in external_subtitle_files(path)] current_subtitles = [str(file) for file in external_subtitle_files(path)]
if "external_subtitle_files" in data and data["external_subtitle_files"] == current_subtitles: if data.get("scanner_version") == SCANNER_VERSION and "external_subtitle_files" in data and data["external_subtitle_files"] == current_subtitles:
return data return data
return None return None
@@ -639,8 +913,18 @@ def scan_one(path):
p = Path(path) p = Path(path)
if not p.exists(): data = {"error": f"Datei nicht gefunden: {p}"} if not p.exists(): data = {"error": f"Datei nicht gefunden: {p}"}
else: else:
try: data = run_ffprobe(str(p)); log.info("Gescannt: %s", p) try:
except Exception as exc: data = {"error": str(exc)}; log.exception("Scanfehler für %s", p) data = run_ffprobe(str(p)); log.info("Gescannt: %s", p)
except Exception as exc:
# Cache failed probes as completed attempts. A changed mtime will
# invalidate this entry automatically and trigger a retry later.
data = {
"scanner_version": SCANNER_VERSION,
"audio_languages": [], "subtitle_languages": [],
"external_subtitle_files": [str(file) for file in external_subtitle_files(str(p))],
"video_codec": "", "size": 0, "error": error_summary(exc),
}
log.error("Scanfehler für %s: %s", p, error_summary(exc))
mtime = p.stat().st_mtime mtime = p.stat().st_mtime
con = db(); con.execute("INSERT INTO media_cache(path,mtime,data,scanned_at) VALUES(?,?,?,?) ON CONFLICT(path) DO UPDATE SET mtime=excluded.mtime,data=excluded.data,scanned_at=excluded.scanned_at", (str(p), mtime, json.dumps(data), datetime.utcnow().isoformat())); con.commit(); con.close() con = db(); con.execute("INSERT INTO media_cache(path,mtime,data,scanned_at) VALUES(?,?,?,?) ON CONFLICT(path) DO UPDATE SET mtime=excluded.mtime,data=excluded.data,scanned_at=excluded.scanned_at", (str(p), mtime, json.dumps(data), datetime.utcnow().isoformat())); con.commit(); con.close()
return data return data
@@ -722,7 +1006,7 @@ def sonarr_groups():
"category":category_key, "category_label":category_label}) "category":category_key, "category_label":category_label})
return sorted(groups, key=lambda x: x["title"].lower()) return sorted(groups, key=lambda x: x["title"].lower())
def start_scan(source, force=False): def start_scan(source, force=False, rows_snapshot=None, series_snapshot=None, pending_only=False):
with job_lock: with job_lock:
if jobs[source]["state"] == "running" or (jobs[source]["state"] in ("done", "error") and not force): return if jobs[source]["state"] == "running" or (jobs[source]["state"] in ("done", "error") and not force): return
jobs[source] = {"state":"running", "total":0, "done":0, "error":None} jobs[source] = {"state":"running", "total":0, "done":0, "error":None}
@@ -730,37 +1014,96 @@ def start_scan(source, force=False):
try: try:
paths = [] paths = []
if source == "radarr": if source == "radarr":
for row in radarr_rows(): for row in rows_snapshot if rows_snapshot is not None else radarr_rows():
if row["local_path"] != "" and row["pending"]: paths.append(row["local_path"]) if row["local_path"] != "" and row["pending"] and not row.get("downloading"): paths.append(row["local_path"])
elif source == "sonarr":
for group in series_snapshot if series_snapshot is not None else sonarr_groups():
paths.extend(r["local_path"] for r in group["episodes"] if r["local_path"] != "" and r["pending"] and not r.get("downloading"))
else: else:
for group in sonarr_groups(): for row in rows_snapshot if rows_snapshot is not None else radarr_rows():
paths.extend(r["local_path"] for r in group["episodes"] if r["local_path"] != "" and r["pending"]) if row["local_path"] != "" and is_german_review_candidate(row) and not row.get("downloading") and not has_german_track(row) and (not pending_only or row.get("pending")): paths.append(row["local_path"])
if SONARR_URL and SONARR_API_KEY:
for group in series_snapshot if series_snapshot is not None else sonarr_groups():
paths.extend(r["local_path"] for r in group["episodes"] if r["local_path"] != "" and is_german_review_candidate(r) and not r.get("downloading") and not has_german_track(r) and (not pending_only or r.get("pending")))
with job_lock: jobs[source]["total"] = len(paths) with job_lock: jobs[source]["total"] = len(paths)
futures = [scan_executor.submit(scan_one, path) for path in paths] futures = [scan_executor.submit(scan_one, path) for path in paths]
for future in as_completed(futures): for future in as_completed(futures):
future.result() future.result()
with job_lock: jobs[source]["done"] += 1 with job_lock: jobs[source]["done"] += 1
with job_lock: jobs[source]["state"] = "done" with job_lock: jobs[source]["state"] = "done"
# Publish the newly scanned metadata immediately to the cache.
refresh_library_cache()
except Exception as exc: except Exception as exc:
log.exception("%s-Scan fehlgeschlagen", source); jobs[source]["error"] = str(exc); jobs[source]["state"] = "error" log.error("%s-Scan fehlgeschlagen: %s", source, error_summary(exc)); jobs[source]["error"] = str(exc); jobs[source]["state"] = "error"
executor.submit(work) executor.submit(work)
@app.route("/") def refresh_library_cache():
def index(): """Refresh Radarr/Sonarr data outside the request thread."""
error = None; rows = []; series = [] rows = []
series = []
errors = []
radarr_ok = False
sonarr_ok = not (SONARR_URL and SONARR_API_KEY)
try: try:
rows = radarr_rows() rows = radarr_rows()
if any(row["pending"] for row in rows): start_scan("radarr") radarr_ok = True
except Exception as exc: error = str(exc) except Exception as exc:
errors.append(f"Radarr nicht erreichbar: {error_summary(exc)}")
if SONARR_URL and SONARR_API_KEY: if SONARR_URL and SONARR_API_KEY:
try: try:
series = sonarr_groups() series = sonarr_groups()
if any(row["pending"] for group in series for row in group["episodes"]): start_scan("sonarr") sonarr_ok = True
except Exception as exc: log.exception("Sonarr nicht erreichbar"); error = f"{error + ' | ' if error else ''}Sonarr: {exc}" except Exception as exc:
missing_german_movies = [row for row in rows if is_german_review_candidate(row) and not has_german_track(row)] errors.append(f"Sonarr nicht erreichbar: {error_summary(exc)}")
with library_cache_lock:
# Keep the last good snapshot during a short service/DNS outage.
if not radarr_ok:
rows = library_cache["rows"]
if not sonarr_ok:
series = library_cache["series"]
library_cache.update({"rows": rows, "series": series, "updated_at": time.time(), "error": " | ".join(errors) or None, "ready": True})
if rows and any(row.get("pending") for row in rows):
start_scan("radarr", force=True, rows_snapshot=rows)
if series and any(row.get("pending") for group in series for row in group["episodes"]):
start_scan("sonarr", force=True, series_snapshot=series)
needs_missing_scan = any(
row.get("local_path") != "" and is_german_review_candidate(row) and row.get("pending") and not has_german_track(row)
for row in rows
) or any(
episode.get("local_path") != "" and is_german_review_candidate(episode) and episode.get("pending") and not has_german_track(episode)
for group in series for episode in group["episodes"]
)
if needs_missing_scan:
start_scan("missing", force=True, rows_snapshot=rows, series_snapshot=series, pending_only=True)
log.info("Bibliotheks-Sync abgeschlossen: %s Filme, %s Serien", len(rows), len(series))
def background_library_sync():
"""Keep library data and media metadata current without a browser tab."""
log.info("Hintergrund-Sync gestartet (Intervall: %ss)", SYNC_INTERVAL_SECONDS or "deaktiviert")
while True:
try:
refresh_library_cache()
except Exception as exc:
log.error("Hintergrund-Sync fehlgeschlagen: %s", error_summary(exc))
if not SYNC_INTERVAL_SECONDS:
return
time.sleep(SYNC_INTERVAL_SECONDS)
threading.Thread(target=background_library_sync, name="library-sync", daemon=True).start()
threading.Thread(target=hardware_status_monitor, name="hardware-monitor", daemon=True).start()
@app.route("/")
def index():
with library_cache_lock:
rows = list(library_cache["rows"])
series = list(library_cache["series"])
error = library_cache["error"]
# Unscanned files have no language information yet and must not be shown
# as missing German while the background scanner is still processing them.
missing_german_movies = [row for row in rows if is_german_review_candidate(row) and not row.get("pending") and not has_german_track(row)]
missing_german_series = [] missing_german_series = []
for group in series: for group in series:
episodes = [episode for episode in group["episodes"] if is_german_review_candidate(episode) and not has_german_track(episode)] episodes = [episode for episode in group["episodes"] if is_german_review_candidate(episode) and not episode.get("pending") and not has_german_track(episode)]
if episodes: if episodes:
missing_german_series.append({**group, "episodes": episodes}) missing_german_series.append({**group, "episodes": episodes})
series_categories = [ series_categories = [
@@ -792,6 +1135,8 @@ def api_rescan():
con = db() con = db()
if source in ("radarr", "all"): con.execute("DELETE FROM media_cache WHERE path NOT LIKE ?", (LOCAL_SERIES_PATH.rstrip("/") + "/%",)) if source in ("radarr", "all"): con.execute("DELETE FROM media_cache WHERE path NOT LIKE ?", (LOCAL_SERIES_PATH.rstrip("/") + "/%",))
if source in ("sonarr", "all"): con.execute("DELETE FROM media_cache WHERE path LIKE ?", (LOCAL_SERIES_PATH.rstrip("/") + "/%",)) if source in ("sonarr", "all"): con.execute("DELETE FROM media_cache WHERE path LIKE ?", (LOCAL_SERIES_PATH.rstrip("/") + "/%",))
# The missing-German scan deliberately keeps the cache: it re-processes
# only current candidates and leaves the rest of the library untouched.
con.commit(); con.close() con.commit(); con.close()
if source == "all": if source == "all":
start_scan("radarr", force=True) start_scan("radarr", force=True)
@@ -805,6 +1150,11 @@ def api_rescan():
def scan_status(): def scan_status():
with job_lock: return jsonify(jobs) with job_lock: return jsonify(jobs)
@app.get("/api/library-status")
def library_status():
with library_cache_lock:
return jsonify({"ready": library_cache["ready"], "updated_at": library_cache["updated_at"], "error": library_cache["error"]})
@app.get("/api/downloads") @app.get("/api/downloads")
def downloads(): def downloads():
if not SAB_URL or not SAB_API_KEY: if not SAB_URL or not SAB_API_KEY:
@@ -812,24 +1162,30 @@ def downloads():
try: try:
return jsonify(sab_downloads()) return jsonify(sab_downloads())
except Exception as exc: except Exception as exc:
log.exception("SABnzbd konnte nicht abgefragt werden") log.error("SABnzbd konnte nicht abgefragt werden: %s", error_summary(exc))
return jsonify({"items": [], "queue_status": "Fehler", "speed": "", "paused": False, "active_count": 0, "history_count": 0, "remaining": "", "error": str(exc)}), 502 return jsonify({"items": [], "queue_status": "Nicht erreichbar", "speed": "", "paused": False, "active_count": 0, "history_count": 0, "remaining": "", "error": "SABnzbd ist momentan nicht erreichbar."}), 502
@app.get("/api/system") @app.get("/api/system")
def system(): def system():
try: try:
return jsonify(system_metrics()) return jsonify(system_metrics())
except Exception as exc: except Exception as exc:
log.exception("Systemmetriken konnten nicht gelesen werden") log.error("Systemmetriken konnten nicht gelesen werden: %s", error_summary(exc))
return jsonify({"error": str(exc), "cpu_percent": 0, "memory": {}, "disks": []}), 500 return jsonify({"error": "Systemmetriken momentan nicht verfügbar", "cpu_percent": 0, "memory": {}, "disks": []}), 500
@app.get("/api/datastore") @app.get("/api/datastore")
def datastore(): def datastore():
try: try:
return jsonify(datastore_snapshot()) return jsonify(datastore_snapshot())
except Exception as exc: except Exception as exc:
log.exception("Datastore konnte nicht abgefragt werden") log.error("Datastore konnte nicht abgefragt werden: %s", error_summary(exc))
return jsonify({"storage": {"error": str(exc), "path": DATASTORE_PATH, "categories": []}, "raid": {"status": "failed", "label": "FAILED", "error": str(exc)}}), 500 return jsonify({"storage": {"error": "Datastore momentan nicht verfügbar", "path": DATASTORE_PATH, "categories": []}, "raid": {"status": "failed", "label": "FAILED", "error": "RAID-Status momentan nicht verfügbar"}}), 500
@app.get("/api/graphics")
def graphics():
with graphics_cache_lock:
snapshot = graphics_cache
return jsonify(snapshot or {"available": False, "gpus": [], "processes": [], "error": "GPU-Status wird geladen"})
@app.get("/health") @app.get("/health")
def health(): return jsonify({"ok":True, "radarr_url":RADARR_URL, "sonarr_enabled":bool(SONARR_URL and SONARR_API_KEY), "sab_enabled":bool(SAB_URL and SAB_API_KEY)}) def health(): return jsonify({"ok":True, "radarr_url":RADARR_URL, "sonarr_enabled":bool(SONARR_URL and SONARR_API_KEY), "sab_enabled":bool(SAB_URL and SAB_API_KEY)})
+9
View File
@@ -3,6 +3,7 @@ services:
build: . build: .
container_name: media-max container_name: media-max
restart: unless-stopped restart: unless-stopped
gpus: all
ports: ports:
- "8099:8099" - "8099:8099"
devices: devices:
@@ -16,6 +17,14 @@ services:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
env_file: env_file:
- .env - .env
environment:
NVIDIA_VISIBLE_DEVICES: ${NVIDIA_VISIBLE_DEVICES:-all}
NVIDIA_DRIVER_CAPABILITIES: ${NVIDIA_DRIVER_CAPABILITIES:-compute,utility}
# Serverseitiger Bibliotheks-Sync; 1800 = alle 30 Minuten, 0 = nur beim Start.
SYNC_INTERVAL_SECONDS: ${SYNC_INTERVAL_SECONDS:-1800}
RAID_REFRESH_SECONDS: ${RAID_REFRESH_SECONDS:-5}
IO_REFRESH_SECONDS: ${IO_REFRESH_SECONDS:-5}
GPU_REFRESH_SECONDS: ${GPU_REFRESH_SECONDS:-5}
volumes: volumes:
# Host-Pfad deiner Film-Library -> interner Dashboard-Pfad # Host-Pfad deiner Film-Library -> interner Dashboard-Pfad
- ${FILM_MEDIA_VOLUME}:/media/filme:ro - ${FILM_MEDIA_VOLUME}:/media/filme:ro
+7 -1
View File
@@ -1,14 +1,20 @@
{ {
"name": "Media Max", "name": "Media Max",
"short_name": "Media Max", "short_name": "Media Max",
"id": "/",
"lang": "de",
"description": "Media Max: Radarr, Sonarr und SABnzbd im Überblick", "description": "Media Max: Radarr, Sonarr und SABnzbd im Überblick",
"start_url": "/", "start_url": "/",
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
"display_override": ["window-controls-overlay", "standalone"],
"prefer_related_applications": false,
"background_color": "#151619", "background_color": "#151619",
"theme_color": "#1f252d", "theme_color": "#1f252d",
"orientation": "any", "orientation": "any",
"icons": [ "icons": [
{"src": "/static/media-mark.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable"} {"src": "/static/media-mark-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"},
{"src": "/static/media-mark-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any"},
{"src": "/static/media-mark-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"}
] ]
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 803 B

+1
View File
@@ -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

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="512" height="512"><defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#293542"/><stop offset="1" stop-color="#15191f"/></linearGradient><filter id="glow"><feGaussianBlur stdDeviation="2.2" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><rect x="5" y="5" width="54" height="54" rx="15" fill="url(#bg)" stroke="#60a9e6" stroke-width="2.5"/><path d="M17 21h30M17 31h19M17 41h30" fill="none" stroke="#f4c430" stroke-width="4" stroke-linecap="round"/><circle cx="46" cy="31" r="7" fill="#159447" stroke="#c9ffe0" stroke-width="2" filter="url(#glow)"/></svg>

After

Width:  |  Height:  |  Size: 709 B

+9 -3
View File
@@ -1,5 +1,5 @@
const CACHE_NAME = 'media-max-shell-v2'; const CACHE_NAME = 'media-max-shell-v7';
const SHELL = ['/static/media-mark.svg', '/static/manifest.json']; const SHELL = ['/static/media-mark.svg', '/static/media-mark-192.png', '/static/media-mark-512.png', '/static/manifest.json'];
self.addEventListener('install', event => { self.addEventListener('install', event => {
event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(SHELL))); event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(SHELL)));
@@ -16,5 +16,11 @@ self.addEventListener('fetch', event => {
if (request.method !== 'GET') return; if (request.method !== 'GET') return;
const url = new URL(request.url); const url = new URL(request.url);
if (url.pathname.startsWith('/api/')) return; if (url.pathname.startsWith('/api/')) return;
event.respondWith(fetch(request).catch(() => caches.match(request).then(response => response || caches.match('/static/manifest.json')))); // Navigation requests must stay real network requests. Returning an
// offline document here can mask backend errors and make the app appear
// disconnected after a scan/reload.
if (request.mode === 'navigate') return;
event.respondWith(fetch(request).catch(() => {
return caches.match(request).then(response => response || new Response('', {status: 503}));
}));
}); });
+71 -20
View File
File diff suppressed because one or more lines are too long