Compare commits

..
64 Commits
Author SHA1 Message Date
nessi f646339781 Add rich dashboard animations with reduced-motion support 2026-10-01 07:28:27 +02:00
nessi 25abce19e8 style: change dashboard accents from turquoise to warm yellow 2026-10-01 07:22:15 +02:00
nessi 15310c5e61 feat: redesign dashboard with Tracearr-inspired layout and improve language detection 2026-09-30 16:07:54 +02:00
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
nessi e3dae93d33 feat: add sticky positioning to series/missing table summaries with shadow on expand and overflow visibility
Add overflow:visible to .series-category and .missing-table containers. Add position:sticky with top:64px and z-index:30 to series/missing-series summary elements for persistent header visibility during scroll. Add box-shadow to [open] summary state for visual depth indication when expanded.
2026-08-16 12:36:25 +02:00
nessi 44fbfaded4 feat: add RAID/storage error resilience with stale data fallback and parallel SMART health checks
Add datastore_cache raid/raid_at fields for RAID status caching with 60-second fallback window. Extract smart_status() function from raid_status() to enable parallel execution. Add ThreadPoolExecutor submission for SMART checks across all RAID disks with concurrent futures. Update SMART logging to check "PASSED" status explicitly instead of regex match presence. Add stale/warning fields to datastore_snapshot() when fresh
2026-08-16 12:34:26 +02:00
nessi cc7d719465 feat: add system load average, uptime, and process count to dashboard with responsive detail cards and iconify indicators
Add os.getloadavg() to system_metrics() with (0,0,0) fallback for AttributeError/OSError. Add /proc/uptime parsing for uptime_seconds with float conversion and 0 fallback. Add /proc process counting by iterating numeric entries with OSError exception handling. Add load/uptime_seconds/processes fields to /api/system response with rounded values.

Add DOMContentLoaded script to inject #
2026-08-16 12:32:08 +02:00
nessi c15cc17142 feat: add overview and insight card icons with MutationObserver and refactor RAID countdown with reset callback
Add overview-icon/insight-icon script with iconify API integration for movie/TV/alert/download icons (overview) and check/playlist/database/monitor icons (insight). Add color-coded URL encoding (%238bc9f2 blue, %23d7adf3 purple, %23ff9e9e red, %236ae29a green for overview; blue/purple/yellow/green for insight). Add MutationObserver to reapply icons on DOM changes. Add .overview-icon img
2026-08-16 12:29:34 +02:00
nessi 87898fb2b2 feat: add live RAID status countdown timer with 5-second auto-refresh indicator
Add DOMContentLoaded script to inject #raidRefreshCountdown element next to #raidStatusBadge showing "Update in Xs" countdown. Add .raid-status-side flexbox styling with column layout, flex-end alignment, and 4px gap. Style countdown with #8d9aaa color, 10px font-size, and nowrap. Update countdown every 250ms with 5-second interval reset.
2026-08-16 12:26:11 +02:00
nessi 022c628b23 feat: add iconify icons to navigation tabs with color-coded indicators for each section
Replace text-only navigation buttons with iconify SVG icons: dashboard (view-dashboard-outline, blue), Radarr (movie-open-outline, orange), Sonarr (television-classic, cyan), missing (alert-outline, red), downloads (download-box-outline, yellow), and datastore (harddisk, blue). Add .nav-tab-icon styling with 15px dimensions, 6px right margin, and -3px vertical alignment.
2026-08-16 12:24:12 +02:00
nessi 24b2a3a12f feat: add file-based logging with hourly rotation and configurable retention period
Add LOG_FILE and LOG_RETENTION_HOURS environment variables to .env.example with defaults /data/media-max.log and 6 hours. Install TimedRotatingFileHandler with hourly rotation, 1 backup, UTF-8 encoding, and UTC timestamps. Add OSError exception handling with warning log when file logging unavailable. Add warn_on_nonzero parameter to _read_command() with default True and disable for smartctl -H calls to suppress expected
2026-08-16 12:22:06 +02:00
nessi 02a5db61be feat: add udev serial detection, comprehensive logging, and SAT fallback for SMART health checks on RAID disks
Install udev package and mount /run/udev:/host-run-udev:ro for udev by-id symlink access. Add raid_log_state global to track RAID status changes and hardware_log_once set to prevent duplicate hardware warnings. Update scan_datastore_storage() with info/error logging for scan lifecycle and disk usage. Add _read_command() debug/warning/error logging for all subprocess calls with command text
2026-08-16 12:18:40 +02:00
nessi 10eb3a4191 feat: add /host-sys volume mount and HOST_SYS_PATH environment variable for sysfs serial fallback and SMART health detection
Add HOST_SYS_PATH=/host-sys to .env.example and app.py config with /host-sys default. Update _device_serial() to check HOST_SYS_PATH/class/block and HOST_SYS_PATH/block before fallback /sys paths for serial reads. Update raid_status() SMART regex to match both "SMART overall-health self-assessment test result" and "SMART Health Status" with case-insensitive flag for NVMe/SSD compatibility. Add /
2026-08-16 12:14:10 +02:00
nessi f7638dd5d5 feat: add sysfs serial fallback for RAID disks and responsive disk grid layout with improved styling
Add _device_serial() to read disk serials from /sys/class/block/{name}/device/serial and /sys/block/{name}/device/serial when lsblk cannot expose them in Docker containers. Update raid_status() to use sysfs fallback before defaulting to "—" for missing serials. Add .raid-disks 2-column grid with 6px gap, .raid-disk 7px/9px padding, .raid-disk small 1px top margin, and .raid-smart 85% opacity. Add mobile
2026-08-16 12:11:47 +02:00
nessi 0c922d4f5d feat: add human-readable RAID recovery formatting with German time units and improved member detection from mdstat
Add format_transfer_rate() to convert mdstat rates (e.g., 133628K/sec) into readable values like "130.5 MB/s" using 1024-based units. Add format_remaining_time() to convert mdstat durations (e.g., 450.1min) into compact German format like "7 Std. 30 Min." with day/hour/minute components. Update raid_status() to parse member devices directly from array_line in /proc/mdstat to avoid cross
2026-08-16 12:10:17 +02:00
nessi 5ddf506f20 feat: add datastore view with storage categories, RAID status monitoring, and live recovery tracking
Add scan_datastore_storage() to walk DATASTORE_PATH and categorize media by directory structure (movies/series/anime/kdrama/downloads/other) with size/file counts. Add raid_status() to parse /proc/mdstat and mdadm --detail for RAID level, active/failed devices, and recovery progress. Add /api/datastore endpoint with 15-minute cache for storage scans and live RAID updates. Install mdadm/util-linux/
2026-08-16 11:55:45 +02:00
nessi 6f0c222e93 feat: add section headings for movies and series in missing view with language-aware labels and styled indicators
Add addMissingSections() function to inject "Filme ohne Deutsch"/"Movies without German" heading before #missingMovies table and "Serien ohne Deutsch"/"Series without German" before first .missing-series element. Style .missing-section-heading with 9px border-radius, uppercase 12px/800 text, 10px/13px padding, and glowing dot indicator. Apply blue gradient/border for .movies section
2026-08-15 13:39:06 +02:00
nessi 6b010c8ade feat: split downloads view into active and queued sections with separate tables and dynamic row distribution
Replace single downloads table with two-section layout: "Aktive Downloads" for downloading items and "Weitere Queue-Einträge" for queued/paused items. Inject dual-table HTML structure with section headers showing item counts. Update renderDownloads() to distribute rows by status into activeDownloadsBody/otherDownloadsBody with fallback empty states. Add .download-table grid layout, .download-section styling
2026-08-15 13:36:42 +02:00
nessi c8d2b9f454 feat: add dynamic favicon injection script and update dashboard eyebrow from "MEDIA CONTROL CENTER" to "MEDIA MAX"
Add DOMContentLoaded script to dynamically inject shortcut icon link element with media-mark.svg if not already present. Update dashboard hero eyebrow text from "MEDIA CONTROL CENTER" to "MEDIA MAX" for consistent branding.
2026-08-15 13:11:31 +02:00
nessi 42ed29ac51 refactor: rebrand application from "Radarr Language Dashboard" to "Media Max" across all files
Update project name from "Radarr Language Dashboard v2" to "Media Max" in README.md, docker-compose.yml service/container names, app.py logger name and service definition, manifest.json name/short_name/description, and templates/index.html title and header elements. Update docker exec command example in README to use new container name.
2026-08-15 13:06:54 +02:00
nessi ceb4dfe120 feat: add missing table styling with bordered layout, header background, and consistent padding for improved visual structure
Add .missing-table wrapper with 10px padding and style nested table with 100% width, 8px bottom margin, border, 10px border-radius, and overflow:hidden. Set thead th with 38px height, 12px horizontal padding, #2d3540 background, and right borders (except last-child). Set .missing-series summary min-height to 42px with flex center alignment. Add duplicate style block at end
2026-08-15 13:01:44 +02:00
17 changed files with 2035 additions and 316 deletions

No files matched your search

+9
View File
@@ -31,4 +31,13 @@ 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
+2
View File
@@ -2,3 +2,5 @@
data/
__pycache__/
*.pyc
AGENT.md
+2 -1
View File
@@ -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"]
+170 -62
View File
@@ -1,35 +1,79 @@
# 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
```yaml
environment:
RADARR_MEDIA_PATH: "/data/filme"
LOCAL_MEDIA_PATH: "/media/filme"
- 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
volumes:
- /nesflix/filme:/media/filme:ro
## 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 zusätzlich Jellyfin, Seerr, Prowlarr, Easynews as Indexer,
qBittorrent, Bazarr, Lingarr, LibreTranslate sowie die Sonarr-/Radarr-Prowlarr-Proxies.
Die Prüfungen laufen parallel im Hintergrund und blockieren die Seite nicht.
Die Standardnamen funktionieren, wenn die Container im selben Docker-Netzwerk liegen.
Für qBittorrent muss `QBITTORRENT_URL` auf eine aus dem Dashboard-Container erreichbare
URL zeigen, zum Beispiel `http://host.docker.internal:8080` oder die Host-IP.
Der Dashboard-Tab prüft die Dienste parallel mit kurzen Timeouts und aktualisiert sie regelmäßig:
```env
JELLYFIN_URL=http://jellyfin:8096
SEERR_URL=http://seerr:5055
PROWLARR_URL=http://prowlarr:9696
EASYNEWS_INDEXER_URL=http://easynews-as-indexer:8081
QBITTORRENT_URL=http://HOST_ODER_IP:8080
QBITTORRENT_URL=http://host.docker.internal:8080
BAZARR_URL=http://bazarr:6767
LINGARR_URL=http://lingarr:9876
LIBRETRANSLATE_URL=http://libretranslate:5000
@@ -39,73 +83,137 @@ SERVICE_CHECK_TIMEOUT=3
SERVICE_REFRESH_SECONDS=30
```
Für die bereits vorhandenen Container sind keine zusätzlichen Compose-Services nötig;
sie müssen im gemeinsamen `media-stack_default`-Netzwerk erreichbar sein. Falls
qBittorrent nicht über `host.docker.internal` erreichbar ist, kann unter Linux je nach
Docker-Konfiguration dieser Eintrag ergänzt werden:
Die Container müssen im gemeinsamen Docker-Netzwerk `media-stack_default` erreichbar sein. qBittorrent läuft außerhalb des Compose-Stacks und benötigt deshalb eine erreichbare URL. Bei Bedarf stellt `extra_hosts` den Namen `host.docker.internal` bereit.
## Scans und Performance
Der Bibliotheks-Sync läuft als serverseitiger Hintergrund-Thread und benötigt keinen geöffneten Browser. Beim Start und anschließend nach `SYNC_INTERVAL_SECONDS` werden Radarr, Sonarr und die Sprachprüfung aktualisiert. Über den Button „Neu scannen“ kann weiterhin manuell zwischen Radarr, Sonarr, allen Quellen und ausschließlich den aktuellen Einträgen der Liste „Ohne Deutsch“ gewählt werden.
```env
SCAN_WORKERS=4
REQUEST_TIMEOUT=20
DB_PATH=/data/cache.db
SYNC_INTERVAL_SECONDS=1800
RAID_REFRESH_SECONDS=5
IO_REFRESH_SECONDS=5
GPU_REFRESH_SECONDS=5
```
`SCAN_WORKERS` steuert die Anzahl paralleler `ffprobe`-Jobs. Höhere Werte beschleunigen große Bibliotheken, erhöhen aber die I/O-Last.
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
extra_hosts:
- "host.docker.internal:host-gateway"
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
environment:
NVIDIA_VISIBLE_DEVICES: all
NVIDIA_DRIVER_CAPABILITIES: compute,utility
```
Ein Radarr-Pfad wie:
Wenn `nvidia-smi` nicht erreichbar ist, bleibt der Tab verfügbar und zeigt den Grund an.
`/data/filme/Avatar (2009)/Avatar.mkv`
## Logging
wird dadurch für ffprobe zu:
`/media/filme/Avatar (2009)/Avatar.mkv`
## Konfiguration
Die persönlichen URLs, Pfade und API-Keys liegen in `.env`. Diese Datei ist in `.gitignore` eingetragen und bleibt bei `git pull` erhalten. Als Vorlage dient `.env.example`.
Beim ersten Start:
```bash
cp .env.example .env
```env
LOG_LEVEL=INFO
LOG_FILE=/data/media-max.log
LOG_RETENTION_HOURS=6
```
Danach die Werte in `.env` eintragen. Sonarr und SABnzbd werden dort durch Auskommentieren der jeweiligen Variablen aktiviert. Wenn Sonarr aktiv ist, muss `SERIES_MEDIA_VOLUME` auf den echten Host-Pfad deiner Serien-Library zeigen; dieser Ordner wird in den Dashboard-Container nach `/media/serien` gemountet.
Erwartbare Verbindungsfehler werden als kurze einzeilige Meldungen protokolliert. Die Logdatei wird standardmäßig alle sechs Stunden rotiert.
## Start
API-Keys und Pfade in `.env` anpassen:
## Start mit Docker Compose
```bash
docker compose down
docker compose up -d --build
docker compose logs -f
docker compose logs -f media-max
```
Die Seite lädt zuerst die Library-Metadaten. Die ffprobe-Scans laufen danach im Hintergrund; oben wird der Fortschritt angezeigt. Für Sonarr die optionalen Variablen und den Serien-Volume-Mount in `docker-compose.yml` aktivieren. Die Navigation trennt Filme und Serien, Serien sind pro Show aufklappbar.
Sonarr benötigt:
Der Compose-Stack bindet standardmäßig ein:
```yaml
SONARR_URL: "http://sonarr:8989"
SONARR_API_KEY: "..."
SONARR_MEDIA_PATH: "/data/serien"
LOCAL_SERIES_PATH: "/media/serien"
volumes:
- ${FILM_MEDIA_VOLUME}:/media/filme:ro
- ${SERIES_MEDIA_VOLUME:-/nesflix/serien}:/media/serien:ro
- ${DATASTORE_HOST_PATH:-/nesflix}:/nesflix:ro
- /dev:/host-dev:ro
- /sys:/host-sys:ro
- /run/udev:/host-run-udev:ro
- ./data:/data
```
SABnzbd benötigt für den Download-Tab:
Für Radarr, Sonarr und die weiteren Container wird das externe Netzwerk `media-stack_default` verwendet.
```yaml
SAB_URL: "http://sabnzbd:8080"
SAB_API_KEY: "..."
```
## Fehlersuche
Der API-Key steht in SABnzbd unter `Config > General`. Die laufenden Jobs kommen aus der SABnzbd-Queue, abgeschlossene und fehlgeschlagene Jobs aus der History. Die Sprache eines laufenden Releases wird aus dessen Namen abgeleitet; nach dem Import ist die Sprache im Radarr-/Sonarr-Scan verlässlich.
„Unbekannt“ bedeutet, dass eine Spur weder einen verwertbaren Sprach-Tag noch einen eindeutigen Spurtitel besitzt. Wenn auch Radarr/Sonarr keine Dateisprachen liefern, kann die App die Sprache nicht zuverlässig bestimmen. Die Originalsprache eines Films und Release-Kürzel wie `DL` sind kein Nachweis für die tatsächlichen Audiospuren.
## Bei weiterem Scanfehler
Prüfe einen Pfad im Container:
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.
+810 -60
View File
File diff suppressed because it is too large. Load diff
+25 -2
View File
@@ -1,19 +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:
NVIDIA_VISIBLE_DEVICES: ${NVIDIA_VISIBLE_DEVICES:-all}
NVIDIA_DRIVER_CAPABILITIES: ${NVIDIA_DRIVER_CAPABILITIES:-compute,utility}
# Serverseitiger Bibliotheks-Sync; 1800 = alle 30 Minuten, 0 = nur beim Start.
SYNC_INTERVAL_SECONDS: ${SYNC_INTERVAL_SECONDS:-1800}
RAID_REFRESH_SECONDS: ${RAID_REFRESH_SECONDS:-5}
IO_REFRESH_SECONDS: ${IO_REFRESH_SECONDS:-5}
GPU_REFRESH_SECONDS: ${GPU_REFRESH_SECONDS:-5}
volumes:
# Host-Pfad deiner Film-Library -> interner Dashboard-Pfad
- ${FILM_MEDIA_VOLUME}:/media/filme:ro
# 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
+174
View File
@@ -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; }
}
+426
View File
@@ -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 => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[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(() => {}));
+12 -6
View File
@@ -1,14 +1,20 @@
{
"name": "Media Language Dashboard",
"short_name": "Media Dashboard",
"description": "Radarr, Sonarr und SABnzbd im Überblick",
"name": "Media Max",
"short_name": "Media Max",
"id": "/",
"lang": "de",
"description": "Media Max: Radarr, Sonarr und SABnzbd im Überblick",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#151619",
"theme_color": "#1f252d",
"display_override": ["window-controls-overlay", "standalone"],
"prefer_related_applications": false,
"background_color": "#0c0d0f",
"theme_color": "#101113",
"orientation": "any",
"icons": [
{"src": "/static/media-mark.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable"}
{"src": "/static/media-mark-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"},
{"src": "/static/media-mark-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any"},
{"src": "/static/media-mark-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 803 B

+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-dashboard-shell-v1';
const SHELL = ['/static/media-mark.svg', '/static/manifest.json'];
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)));
@@ -16,5 +16,11 @@ self.addEventListener('fetch', event => {
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.pathname.startsWith('/api/')) return;
event.respondWith(fetch(request).catch(() => caches.match(request).then(response => response || caches.match('/static/manifest.json'))));
// Navigation requests must stay real network requests. Returning an
// offline document here can mask backend errors and make the app appear
// disconnected after a scan/reload.
if (request.mode === 'navigate') return;
event.respondWith(fetch(request).catch(() => {
return caches.match(request).then(response => response || new Response('', {status: 503}));
}));
});
+149 -182
View File
@@ -1,183 +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, viewport-fit=cover"><meta name="theme-color" content="#1f252d"><meta name="mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-capable" content="yes"><link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}"><link rel="apple-touch-icon" href="{{ url_for('static', filename='media-mark.svg') }}"><link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='media-mark.svg') }}"><title>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}}
.brand-mark{width:34px;height:34px;display:grid;place-items:center;filter:drop-shadow(0 0 10px rgba(96,169,230,.24))}.brand-mark svg{width:34px;height:34px}.brand-name{color:var(--yellow);font-size:16px;font-weight:900;letter-spacing:1.5px}.brand{display:flex;align-items:center;gap:9px}
.dashboard-hero{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:14px;padding:26px;border:1px solid rgba(96,169,230,.2);border-radius:14px;background:radial-gradient(circle at 85% 15%,rgba(96,169,230,.2),transparent 34%),linear-gradient(135deg,rgba(39,48,61,.96),rgba(27,30,36,.96));box-shadow:0 16px 40px rgba(0,0,0,.22)}.dashboard-hero h1{margin:7px 0 4px;font-size:25px}.dashboard-hero p{margin:0;color:#9da5b2}.live-pill{display:flex;align-items:center;gap:8px;padding:9px 14px;border-radius:999px;background:rgba(21,148,71,.17);border:1px solid rgba(106,226,154,.25);color:#aaf2c5;font-weight:700}.live-dot{width:8px;height:8px;border-radius:50%;background:#6ae29a;box-shadow:0 0 12px #6ae29a}.overview-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:14px}.overview-card{display:flex;align-items:center;gap:12px;min-height:104px;padding:17px;border:1px solid rgba(255,255,255,.08);border-radius:11px;background:linear-gradient(145deg,rgba(43,47,55,.92),rgba(29,31,36,.92));box-shadow:0 8px 22px rgba(0,0,0,.16)}.overview-icon{display:grid;place-items:center;width:40px;height:40px;border-radius:11px;background:rgba(96,169,230,.17);color:#8bc9f2;font-size:23px;font-weight:700}.overview-icon.series-icon{background:rgba(146,87,194,.2);color:#d7adf3}.overview-icon.warning{background:rgba(188,70,70,.2);color:#ff9e9e}.overview-icon.download-icon{background:rgba(21,148,71,.2);color:#6ae29a}.overview-card div{min-width:0}.overview-card span:not(.overview-icon){display:block;color:#9da5b2;font-size:11px;text-transform:uppercase;letter-spacing:.06em}.overview-card strong{display:block;margin:3px 0;font-size:22px;color:#f1f5fa}.overview-card small{display:block;color:#7f8996;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dashboard-columns{display:grid;grid-template-columns:1.45fr 1fr;gap:14px}.system-panel,.service-panel{padding:19px;border:1px solid rgba(255,255,255,.08);border-radius:12px;background:rgba(31,33,38,.84);box-shadow:0 12px 30px rgba(0,0,0,.18)}.panel-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:15px;margin-bottom:17px}.panel-heading h2{margin:0 0 4px;font-size:16px}.panel-heading p{margin:0;color:#7f8996;font-size:12px}.system-meters{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px}.meter-top{display:flex;justify-content:space-between;margin-bottom:7px;color:#cbd1db}.meter-top strong{color:#fff}.meter-track{height:9px;background:#363b44;border-radius:99px;overflow:hidden}.meter-track span{display:block;width:0;height:100%;border-radius:99px;background:linear-gradient(90deg,#60a9e6,#159447);transition:width .45s ease}.disk-list{display:grid;gap:12px}.disk-row{display:grid;grid-template-columns:92px 1fr auto;align-items:center;gap:10px}.disk-name{color:#cbd1db;font-weight:700}.disk-path{display:block;color:#7f8996;font-size:11px;font-weight:400}.disk-bar{height:7px;background:#363b44;border-radius:99px;overflow:hidden}.disk-bar span{display:block;height:100%;border-radius:99px;background:linear-gradient(90deg,#60a9e6,#f4c430);transition:width .45s ease}.disk-values{text-align:right;color:#cbd1db;font-size:11px}.service-row{display:flex;align-items:center;gap:11px;padding:12px 0;border-bottom:1px solid rgba(255,255,255,.07)}.service-row:last-child{border-bottom:0}.service-logo{display:grid;place-items:center;width:34px;height:34px;border-radius:9px;color:#fff;font-weight:800}.radarr-dot{background:#1d7e45}.sonarr-dot{background:#4d65b1}.sab-dot{background:#9a6830}.service-row div{flex:1}.service-row strong,.service-row small{display:block}.service-row small{margin-top:2px;color:#7f8996;font-size:11px}.service-status{padding:4px 8px;border-radius:999px;font-size:11px;font-weight:700}.service-status.online{background:#126f39;color:#d9ffe8}.service-status.offline{background:#3b3b3b;color:#aaa}
.dashboard-view{flex-direction:column}.dashboard-view .dashboard-columns{align-items:stretch}.dashboard-view .system-panel,.dashboard-view .service-panel{min-height:0}.dashboard-view .disk-list{gap:18px}.dashboard-view .disk-row{min-height:38px}.dashboard-insights{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:14px}.insight-card{display:flex;align-items:center;gap:11px;padding:13px 15px;border:1px solid rgba(255,255,255,.08);border-radius:10px;background:rgba(31,33,38,.84)}.insight-icon{display:grid;place-items:center;width:32px;height:32px;border-radius:8px;background:rgba(96,169,230,.16);color:#8bc9f2;font-size:16px}.insight-card:nth-child(2) .insight-icon{background:rgba(146,87,194,.18);color:#d7adf3}.insight-card:nth-child(3) .insight-icon{background:rgba(244,196,48,.16);color:#f4c430}.insight-card:nth-child(4) .insight-icon{background:rgba(21,148,71,.18);color:#6ae29a}.insight-card strong,.insight-card small{display:block}.insight-card strong{font-size:14px;color:#f1f5fa}.insight-card small{margin-top:3px;color:#7f8996;font-size:10px}@media(max-width:1000px){.dashboard-insights{grid-template-columns:repeat(2,1fr)}}
@media(max-width:1000px){.overview-grid{grid-template-columns:repeat(2,1fr)}.dashboard-columns{grid-template-columns:1fr}}@media(max-width:600px){.overview-grid,.system-meters{grid-template-columns:1fr}.dashboard-hero{align-items:flex-start;flex-direction:column;padding:20px}.live-pill{align-self:stretch;justify-content:center}.disk-row{grid-template-columns:78px 1fr auto}}
.settings-modal .setting-help{display:block;margin:-3px 0 12px;color:var(--muted);font-size:11px}.settings-modal .setting-select{width:100%;height:38px}.pwa-install{display:none}
@media(max-width:700px){body{font-size:12px;padding-bottom:env(safe-area-inset-bottom)}header{height:auto;min-height:62px;padding:9px 12px;gap:9px;align-items:center}.brand-mark,.brand-mark svg{width:30px;height:30px}.brand-name{font-size:14px}.head-title{font-size:12px}.head-sub{font-size:10px}.actions{gap:4px}.actions button{height:34px;padding:0 8px}.settings-button{font-size:15px}.actions #rescan{font-size:0}.actions #rescan::after{content:'↻';font-size:16px}main{padding:10px 10px 24px}.nav{margin-bottom:10px;padding:4px;overflow-x:auto;scrollbar-width:none}.nav::-webkit-scrollbar{display:none}.nav button{flex:0 0 auto;white-space:nowrap;padding:8px 10px;font-size:11px}.toolbar{grid-template-columns:1fr;gap:7px;padding:8px}.toolbar input,.toolbar select{width:100%;height:38px}.stats{gap:6px}.stat{padding:6px 8px}.tablewrap{overflow-x:auto;border-radius:8px}table{min-width:860px}.dashboard-hero{margin-bottom:10px;padding:18px;border-radius:11px}.dashboard-hero h1{font-size:21px}.dashboard-hero p{font-size:11px;line-height:1.4}.overview-grid{grid-template-columns:1fr 1fr;gap:7px}.overview-card{min-height:84px;padding:12px 10px;gap:8px}.overview-icon{width:32px;height:32px;font-size:18px}.overview-card strong{font-size:18px}.overview-card span:not(.overview-icon){font-size:9px}.overview-card small{font-size:9px}.dashboard-columns{gap:8px}.system-panel,.service-panel{padding:14px;border-radius:10px}.system-meters{gap:12px}.panel-heading{margin-bottom:13px}.panel-heading h2{font-size:14px}.panel-heading p{font-size:10px}.disk-row{grid-template-columns:82px 1fr auto;gap:7px}.disk-values{font-size:10px}.download-hero{padding:18px;margin-bottom:10px}.download-hero h2{font-size:19px}.download-hero p{font-size:11px}.download-stats{grid-template-columns:1fr 1fr;gap:7px}.download-card{min-height:78px;padding:10px;gap:8px}.card-icon{width:30px;height:30px;font-size:17px}.card-label{font-size:9px}.download-card strong{font-size:17px}.download-card small{font-size:9px}.settings-modal{max-height:calc(100dvh - 24px);overflow:auto;padding:18px}.settings-head{margin-bottom:15px}.settings-modal label{margin-top:13px}}
@media(max-width:390px){.brand-name{display:none}.head-sub{display:none}.overview-grid{grid-template-columns:1fr}.dashboard-hero{padding:15px}.download-stats{grid-template-columns:1fr}}
</style><style>.scan-control{display:flex;align-items:center;gap:5px}.scan-control select{height:36px;padding:0 8px;font-size:11px}.scan-control button{white-space:nowrap}.scan-running{position:relative;overflow:hidden;background:linear-gradient(90deg,rgba(244,196,48,.14) var(--scan-progress,0%),rgba(244,196,48,.04) var(--scan-progress,0%))!important}.scan-running::after{content:'';position:absolute;left:0;bottom:0;width:var(--scan-progress,0%);height:2px;background:#f4c430;transition:width .35s ease}@media(max-width:700px){.scan-control select{max-width:108px;font-size:10px}.scan-control button{font-size:0}.scan-control button::after{content:'↻';font-size:16px}}</style></head><body>
<header><div class="brand"><div class="brand-mark" aria-label="Media Dashboard"><svg viewBox="0 0 48 48" role="img"><path d="M10 8h28a4 4 0 0 1 4 4v24a4 4 0 0 1-4 4H10a4 4 0 0 1-4-4V12a4 4 0 0 1 4-4Z" fill="#20252d" stroke="#60a9e6" stroke-width="2"/><path d="M14 17h20M14 24h13M14 31h20" stroke="#f4c430" stroke-width="3" stroke-linecap="round"/><circle cx="34" cy="24" r="5" fill="#159447" stroke="#c9ffe0" stroke-width="1.5"/></svg></div><div class="brand-name">MEDIA</div></div><div><div class="head-title" data-de="Language Dashboard" data-en="Language Dashboard">Language Dashboard</div><div class="head-sub" data-de="Audio- und Untertitelspuren aus den Mediendateien" data-en="Audio and subtitle tracks from media files">Audio- und Untertitelspuren aus den Mediendateien</div></div><div class="actions"><button class="lang-switch" id="languageToggle" title="Sprache / Language">DE</button><button class="settings-button" id="settingsOpen" title="Einstellungen">⚙</button><div class="scan-control"><select id="scanSource" aria-label="Scan-Quelle"><option value="all">Alles scannen</option><option value="radarr">Nur Radarr</option><option value="sonarr">Nur Sonarr</option></select><button id="rescan" data-de="↻ Neu scannen" data-en="↻ Rescan">↻ Neu scannen</button></div></div></header>
<main>
{% if error %}<div class="banner"><strong>Fehler:</strong> {{ error }}</div>{% endif %}
<nav class="nav"><button class="active" data-view="dashboard">▦ Dashboard</button><button data-view="radarr">RADARR · Filme</button>{% if sonarr_enabled %}<button data-view="sonarr">SONARR · Serien</button>{% endif %}<button data-view="missing">⚠ Ohne Deutsch <span class="nav-count" id="missingNavCount">{{ missing_german_movies|length + (missing_german_series|map(attribute='episodes')|map('length')|sum) }}</span></button>{% if sab_enabled %}<button data-view="downloads">SABNZBD · Downloads <span class="nav-count download-nav-count hidden" id="downloadNavCount">0</span></button>{% endif %}</nav>
<section id="dashboard" class="view active" data-source="dashboard"><div class="dashboard-hero"><div><div class="eyebrow">MEDIA CONTROL CENTER</div><h1>Dashboard</h1><p>Deine gesamte Medienbibliothek und Systemauslastung auf einen Blick.</p></div><div class="live-pill"><span class="live-dot"></span> Live</div></div><div class="overview-grid"><div class="overview-card"><span class="overview-icon movie">▣</span><div><span>Filme</span><strong>{{ rows|length }}</strong><small>Radarr Library</small></div></div><div class="overview-card"><span class="overview-icon series-icon">▤</span><div><span>Serien</span><strong>{{ series|length }}</strong><small>Sonarr Shows</small></div></div><div class="overview-card"><span class="overview-icon warning">⚠</span><div><span>Ohne Deutsch</span><strong id="dashboardMissing">{{ missing_german_movies|length + (missing_german_series|map(attribute='episodes')|map('length')|sum) }}</strong><small>Audio oder Untertitel prüfen</small></div></div><div class="overview-card"><span class="overview-icon download-icon">↓</span><div><span>Downloads</span><strong id="dashboardDownloads">—</strong><small id="dashboardRemaining">SABnzbd wird geladen …</small></div></div></div><div class="dashboard-columns"><section class="system-panel"><div class="panel-heading"><div><h2>Systemauslastung</h2><p>Aktuelle Werte aus dem Dashboard-Container</p></div><span id="systemUpdated" class="muted">Wird geladen …</span></div><div class="system-meters"><div class="meter"><div class="meter-top"><span>CPU</span><strong id="cpuValue">—</strong></div><div class="meter-track"><span id="cpuBar"></span></div></div><div class="meter"><div class="meter-top"><span>RAM</span><strong id="ramValue">—</strong></div><div class="meter-track"><span id="ramBar"></span></div></div></div><div id="diskList" class="disk-list"><div class="disk-row muted">Laufwerke werden geladen …</div></div></section><section class="service-panel"><div class="panel-heading"><div><h2>Dienste</h2><p>Verbindung und Bibliotheksstatus</p></div></div><div class="service-row"><span class="service-logo radarr-dot">R</span><div><strong>Radarr</strong><small>Filme & Qualität</small></div><span class="service-status online">Aktiv</span></div><div class="service-row"><span class="service-logo sonarr-dot">S</span><div><strong>Sonarr</strong><small>Serien & Episoden</small></div><span class="service-status {% if sonarr_enabled %}online{% else %}offline{% endif %}">{% if sonarr_enabled %}Aktiv{% else %}Nicht konfiguriert{% endif %}</span></div><div class="service-row"><span class="service-logo sab-dot">↓</span><div><strong>SABnzbd</strong><small>Downloads & Queue</small></div><span id="dashboardSabStatus" class="service-status {% if sab_enabled %}online{% else %}offline{% endif %}">{% if sab_enabled %}Wird geprüft …{% else %}Nicht konfiguriert{% endif %}</span></div></section></div></section>
<section id="radarr" class="view" 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 category in series_categories %}{% if category.series %}<section class="series-category"><div class="series-category-heading"><h2>{{ category.label }}</h2><span>{{ category.series|length }} Serien</span></div>{% for group in category.series %}<details class="series" open><summary>{{ group.title }} <span class="muted">({{ group.year or '—' }}) · {{ group.episodes|length }} Episoden</span></summary><table><thead><tr><th class="col-title">Episode</th><th class="col-quality">Quality</th><th class="col-state">Status</th><th class="col-audio">Audio</th><th class="col-subs">Untertitel</th><th class="col-video">Video</th><th class="col-size">Größe</th></tr></thead><tbody>{% for r in group.episodes %}<tr><td><span class="title">{{ r.title }}</span></td><td><span class="badge {% if not r.missing %}quality{% endif %}">{{ r.quality }}</span></td><td><span class="state {{ r.state.class }}">{{ r.state.label }}</span></td><td><div class="badges">{% for l in r.audio_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td><div class="badges">{% for l in r.subtitle_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td>{{ r.video_codec }}</td><td>{{ r.size }}</td></tr>{% endfor %}</tbody></table></details>{% endfor %}</section>{% endif %}{% endfor %}
</div></section>{% endif %}
<section id="missing" class="view" data-source="radarr"><div class="stats"><span class="stat" id="missingCount">{{ missing_german_movies|length + (missing_german_series|map(attribute='episodes')|map('length')|sum) }} Einträge ohne Deutsch</span><span class="stat scan" id="seasonZeroHint">Vorhanden oder wird gerade geladen · Staffel 0 ausgeblendet</span></div><div class="tablewrap missing-table">
<table id="missingMovies"><thead><tr><th class="col-title">Film</th><th class="col-quality">Qualität</th><th class="col-state">Status</th><th class="col-audio">Audio</th><th class="col-subs">Untertitel</th><th class="col-video">Video</th><th class="col-size">Größe</th></tr></thead><tbody>{% for r in missing_german_movies %}<tr><td><span class="title">{{ r.title }}</span><span class="year">{{ r.year or '' }}</span></td><td><span class="badge {% if not r.missing %}quality{% endif %}">{{ r.quality }}</span></td><td><span class="state bad">Ohne Deutsch</span></td><td><div class="badges">{% for l in r.audio_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td><div class="badges">{% for l in r.subtitle_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td>{{ r.video_codec }}</td><td>{{ r.size }}</td></tr>{% endfor %}</tbody></table>
{% for group in missing_german_series %}<details class="series missing-series"><summary>{{ group.title }} <span class="muted">({{ group.year or '—' }}) · {{ group.episodes|length }} Episoden ohne Deutsch</span></summary><table><thead><tr><th class="col-title">Episode</th><th class="col-quality">Qualität</th><th class="col-state">Status</th><th class="col-audio">Audio</th><th class="col-subs">Untertitel</th><th class="col-video">Video</th><th class="col-size">Größe</th></tr></thead><tbody>{% for r in group.episodes %}<tr><td><span class="title">{{ r.title }}</span></td><td><span class="badge {% if not r.missing %}quality{% endif %}">{{ r.quality }}</span></td><td><span class="state bad">Ohne Deutsch</span></td><td><div class="badges">{% for l in r.audio_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td><div class="badges">{% for l in r.subtitle_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td>{{ r.video_codec }}</td><td>{{ r.size }}</td></tr>{% endfor %}</tbody></table></details>{% endfor %}
{% if not missing_german_movies and not missing_german_series %}<div class="empty-state">Alle vorhandenen Filme und Episoden haben deutsches Audio oder deutsche Untertitel.</div>{% endif %}</div></section>
{% if sab_enabled %}<section id="downloads" class="view" data-source="downloads"><div class="download-hero"><div><div class="eyebrow">SABNZBD · DOWNLOAD CENTER</div><h2>Download-Übersicht</h2><p>Queue, Fortschritt und verbleibender Speicherplatz auf einen Blick.</p></div><div class="queue-pill" id="sabQueueCard"><span class="queue-dot"></span><span>Verbinde mit SABnzbd …</span></div></div><div class="download-stats"><div class="download-card remaining-card"><span class="card-icon">↓</span><div><span class="card-label">Noch zu laden</span><strong id="sabRemaining">—</strong><small id="sabRemainingHint">Wird berechnet …</small></div></div><div class="download-card"><span class="card-icon blue">◉</span><div><span class="card-label">Aktive Downloads</span><strong id="sabActive">—</strong><small id="sabHistory">Verlauf: —</small></div></div><div class="download-card"><span class="card-icon purple">↯</span><div><span class="card-label">Geschwindigkeit</span><strong id="sabSpeedCard">—</strong><small>Aktuelle Queue</small></div></div><div class="download-card"><span class="card-icon orange">✓</span><div><span class="card-label">Status</span><strong id="sabStatusCard">—</strong><small id="downloadCount">—</small></div></div></div><div id="downloadError" class="banner hidden"></div><div class="tablewrap download-table"><table id="downloadsTable"><thead><tr><th class="col-title">Download</th><th class="col-state">Status</th><th class="col-progress">Fortschritt</th><th class="col-size">Größe / Rest</th><th class="col-time">Restzeit</th><th class="col-source">Quelle</th><th class="col-language">Sprache</th></tr></thead><tbody id="downloadsBody"><tr><td colspan="7" class="muted">Downloads werden geladen …</td></tr></tbody></table></div></section>{% endif %}
</main>
<div class="settings-backdrop hidden" id="settingsBackdrop"><section class="settings-modal" role="dialog" aria-modal="true" aria-labelledby="settingsTitle"><div class="settings-head"><div><h2 id="settingsTitle" data-de="Einstellungen" data-en="Settings">Einstellungen</h2><p data-de="Deine Anzeige- und Sprachpräferenzen" data-en="Your display and language preferences">Deine Anzeige- und Sprachpräferenzen</p></div><button class="settings-close" id="settingsClose" aria-label="Schließen">×</button></div><label for="preferredAudio" data-de="Bevorzugte Audio-Sprache" data-en="Preferred audio language">Bevorzugte Audio-Sprache</label><select id="preferredAudio"><option value="" data-de="Keine Präferenz" data-en="No preference">Keine Präferenz</option><option value="Deutsch" data-de="Deutsch" data-en="German">Deutsch</option><option value="Englisch" data-de="Englisch" data-en="English">Englisch</option><option value="Japanisch" data-de="Japanisch" data-en="Japanese">Japanisch</option><option value="Koreanisch" data-de="Koreanisch" data-en="Korean">Koreanisch</option></select><label for="preferredSubs" data-de="Bevorzugte Untertitel-Sprache" data-en="Preferred subtitle language">Bevorzugte Untertitel-Sprache</label><select id="preferredSubs"><option value="" data-de="Keine Präferenz" data-en="No preference">Keine Präferenz</option><option value="Deutsch" data-de="Deutsch" data-en="German">Deutsch</option><option value="Englisch" data-de="Englisch" data-en="English">Englisch</option><option value="Japanisch" data-de="Japanisch" data-en="Japanese">Japanisch</option><option value="Koreanisch" data-de="Koreanisch" data-en="Korean">Koreanisch</option></select><div class="settings-actions"><button id="settingsCancel" data-de="Abbrechen" data-en="Cancel">Abbrechen</button><button class="settings-save" id="settingsSave" data-de="Speichern" data-en="Save">Speichern</button></div></section></div>
<script>
const dashboardView=document.querySelector('#dashboard');if(dashboardView){dashboardView.classList.add('dashboard-view');const stats={{ dashboard_stats|tojson }};const insights=document.createElement('div');insights.className='dashboard-insights';insights.innerHTML=`<div class="insight-card"><span class="insight-icon">▣</span><div><strong>${stats.movie_files} von ${stats.movie_total} Filmen vorhanden</strong><small>${stats.movie_missing} fehlen · ${stats.movie_downloading} werden geladen</small></div></div><div class="insight-card"><span class="insight-icon">▤</span><div><strong>${stats.episode_files} von ${stats.episode_total} Episoden vorhanden</strong><small>${stats.episode_missing} fehlen in ${stats.series_total} Serien</small></div></div><div class="insight-card"><span class="insight-icon">◌</span><div><strong>Medienbestand geprüft</strong><small>Audio, Untertitel und Qualität werden überwacht</small></div></div><div class="insight-card"><span class="insight-icon">✓</span><div><strong>Live-Überwachung aktiv</strong><small>Systemwerte und Downloads aktualisieren sich automatisch</small></div></div>`;const columns=dashboardView.querySelector('.dashboard-columns');if(columns)dashboardView.insertBefore(insights,columns)}
const servicePanel=document.querySelector('.service-panel');let serviceList=null;const serviceSummary=document.createElement('span');serviceSummary.className='service-summary';if(servicePanel){servicePanel.querySelectorAll('.service-row').forEach(row=>row.remove());serviceList=document.createElement('div');serviceList.className='service-list';servicePanel.appendChild(serviceList);const heading=servicePanel.querySelector('.panel-heading');if(heading)heading.appendChild(serviceSummary)}function renderServices(data){if(!serviceList)return;const values=data.services||[];serviceSummary.textContent=`${data.online||0} / ${data.total||values.length} online`;serviceList.innerHTML=values.map(service=>`<div class="service-row service-${service.status}"><span class="service-logo service-icon-${service.key}">${service.key==='dashboard'?'⌂':service.name.slice(0,1)}</span><div><strong>${html(service.name)}</strong><small>${html(service.detail||'Keine Antwort')}</small></div><span class="service-status ${service.status}">${html(service.label)}</span></div>`).join('')}async function fetchServices(){try{renderServices(await (await fetch('/api/services')).json())}catch(error){serviceSummary.textContent='Status nicht verfügbar'}}if(servicePanel){fetchServices();setInterval(fetchServices,30000)}
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');const rescan=document.querySelector('#rescan'),sourceSelect=document.querySelector('#scanSource'),canRescan=['radarr','sonarr'].includes(b.dataset.view);if(canRescan)sourceSelect.value=b.dataset.view;rescan.disabled=false;rescan.title='Bibliothek neu scannen';sessionStorage.setItem('dashboard-active-view',b.dataset.view)});const rememberedView=sessionStorage.getItem('dashboard-active-view');if(rememberedView)document.querySelector(`.nav button[data-view="${rememberedView}"]`)?.click();
function statusText(s){if(s.state==='running'){const pct=s.total?Math.round(s.done/s.total*100):0;return `Scan läuft · ${s.done}/${s.total||'?'} Dateien · ${pct}%`}if(s.state==='done')return 'Scan abgeschlossen ✓';if(s.state==='error')return 'Scanfehler: '+s.error;return 'Scan wird vorbereitet …'}
function updateScanStatus(selector,s){const el=document.querySelector(selector);if(!el)return;const pct=s.total?Math.min(100,Math.round(s.done/s.total*100)):0;el.textContent=statusText(s);el.style.setProperty('--scan-progress',`${pct}%`);el.classList.toggle('scan-running',s.state==='running')}
const seenRunning={radarr:false,sonarr:false};async function poll(){try{const data=await (await fetch('/api/scan-status')).json();updateScanStatus('#radarrStatus',data.radarr);const sonarr=document.querySelector('#sonarrStatus');if(sonarr)updateScanStatus('#sonarrStatus',data.sonarr);['radarr','sonarr'].forEach(source=>{if(data[source].state==='running')seenRunning[source]=true;if(seenRunning[source]&&['done','error'].includes(data[source].state)){seenRunning[source]=false;setTimeout(()=>{sessionStorage.setItem('dashboard-active-view',document.querySelector('.nav button.active')?.dataset.view||'dashboard');location.reload()},700)}})}catch(e){} };setInterval(poll,1200);poll();
document.querySelector('#rescan').onclick=async()=>{const b=document.querySelector('#rescan'),source=document.querySelector('#scanSource').value;sessionStorage.setItem('dashboard-active-view',document.querySelector('.nav button.active')?.dataset.view||'dashboard');b.disabled=true;b.textContent='Scan läuft …';await fetch('/api/rescan?source='+source,{method:'POST'});b.disabled=false;b.textContent='↻ Neu scannen';poll()};
</script>
<script>
const scanLabel=document.createElement('label');scanLabel.htmlFor='autoScan';scanLabel.textContent='Automatisches Scanning';const scanHelp=document.createElement('span');scanHelp.className='setting-help';scanHelp.textContent='Bibliotheken werden automatisch geprüft, solange das Dashboard geöffnet ist.';const scanSelect=document.createElement('select');scanSelect.id='autoScan';scanSelect.className='setting-select';[['0','Deaktiviert'],['1800000','Alle 30 Minuten'],['3600000','Jede Stunde'],['7200000','Alle 2 Stunden'],['10800000','Alle 3 Stunden'],['14400000','Alle 4 Stunden'],['18000000','Alle 5 Stunden']].forEach(([value,label])=>{const option=document.createElement('option');option.value=value;option.textContent=label;scanSelect.appendChild(option)});const settingsActions=document.querySelector('.settings-actions');if(settingsActions){settingsActions.before(scanLabel,scanHelp,scanSelect)}
let autoScanTimer=null;function configureAutoScan(){if(autoScanTimer)clearInterval(autoScanTimer);const interval=Number(localStorage.getItem('auto-scan-interval')||0);if(!interval)return;autoScanTimer=setInterval(()=>{['radarr','sonarr'].forEach(source=>{if(document.querySelector(`#${source}`))fetch(`/api/rescan?source=${source}`,{method:'POST'})})},interval)}function saveAutoScan(){localStorage.setItem('auto-scan-interval',scanSelect.value);configureAutoScan()}scanSelect.value=localStorage.getItem('auto-scan-interval')||'0';configureAutoScan();document.querySelector('#settingsSave').addEventListener('click',saveAutoScan);
const seasonZeroLabel=document.createElement('label');seasonZeroLabel.className='setting-toggle';seasonZeroLabel.htmlFor='showSeasonZero';seasonZeroLabel.innerHTML='<input type="checkbox" id="showSeasonZero"><span>Staffel 0 anzeigen</span>';const seasonZeroHelp=document.createElement('span');seasonZeroHelp.className='setting-help';seasonZeroHelp.textContent='Gilt für Sonarr und die Liste Ohne Deutsch.';if(settingsActions){settingsActions.before(seasonZeroLabel,seasonZeroHelp)}
function applySeasonZeroPreference(){const show=localStorage.getItem('show-season-zero')==='true';const rows=[...document.querySelectorAll('#sonarr tbody tr:not(.season-divider),#missing .missing-series tbody tr:not(.season-divider)')];rows.forEach(row=>{const title=row.querySelector('.title')?.textContent.trim()||'';row.classList.toggle('season-zero-hidden',!show&&/^S00E/i.test(title))});document.querySelectorAll('#sonarr details.series,#missing details.missing-series').forEach(detail=>{const episodeRows=[...detail.querySelectorAll('tbody tr:not(.season-divider)')];detail.classList.toggle('season-zero-empty',episodeRows.length>0&&episodeRows.every(row=>row.classList.contains('season-zero-hidden')))});const missingCount=document.querySelector('#missingCount'),missingNavCount=document.querySelector('#missingNavCount');if(missingCount||missingNavCount){const movies=document.querySelectorAll('#missingMovies tbody tr:not(.hidden)').length,episodes=document.querySelectorAll('#missing .missing-series tbody tr:not(.season-divider):not(.season-zero-hidden)').length,total=movies+episodes;if(missingCount)missingCount.textContent=`${total} Einträge ohne Deutsch`;if(missingNavCount)missingNavCount.textContent=total}const hint=document.querySelector('#seasonZeroHint');if(hint)hint.textContent=show?'Vorhanden oder wird gerade geladen · Staffel 0 eingeblendet':'Vorhanden oder wird gerade geladen · Staffel 0 ausgeblendet'}const seasonZeroToggle=document.querySelector('#showSeasonZero');if(seasonZeroToggle){seasonZeroToggle.checked=localStorage.getItem('show-season-zero')==='true';seasonZeroToggle.addEventListener('change',()=>{localStorage.setItem('show-season-zero',seasonZeroToggle.checked?'true':'false');applySeasonZeroPreference()})}applySeasonZeroPreference();
function syncDashboardMissingCounter(){const visibleMissing=document.querySelectorAll('#missingMovies tbody tr:not(.hidden),#missing .missing-series tbody tr:not(.season-zero-hidden):not(.season-divider)').length;const dashboardMissing=document.querySelector('#dashboardMissing');if(dashboardMissing)dashboardMissing.textContent=visibleMissing}syncDashboardMissingCounter();document.querySelector('#showSeasonZero')?.addEventListener('change',()=>setTimeout(syncDashboardMissingCounter,0));
if('serviceWorker' in navigator)window.addEventListener('load',()=>navigator.serviceWorker.register('/static/sw.js').catch(()=>{}));
function updateMissingHeading(){const heading=document.querySelector('#missingMovies thead th:first-child');if(heading)heading.textContent=document.documentElement.lang==='en'?'Media':'Medien'}updateMissingHeading();document.querySelector('#languageToggle')?.addEventListener('click',()=>setTimeout(updateMissingHeading,0));
</script>
<style>
#dashboard.view.active{display:flex}.view.dashboard-view:not(.active){display:none}.meter:first-child .meter-top span::before{content:'◔';display:inline-grid;place-items:center;width:20px;height:20px;margin-right:7px;border-radius:6px;background:rgba(96,169,230,.18);color:#8bc9f2;font-size:13px}.meter:nth-child(2) .meter-top span::before{content:'▤';display:inline-grid;place-items:center;width:20px;height:20px;margin-right:7px;border-radius:6px;background:rgba(146,87,194,.2);color:#d7adf3;font-size:13px}.disk-name::before{content:'▰';display:grid;grid-column:1;grid-row:1 / span 2;place-items:center;width:20px;height:20px;margin:0;border-radius:6px;background:rgba(244,196,48,.16);color:#f4c430;font-size:11px}.meter-top span,.disk-name{display:flex;align-items:center}.disk-name{display:grid;grid-template-columns:20px minmax(0,1fr);grid-template-rows:auto auto;column-gap:7px;align-items:center;min-width:92px;line-height:1.1}.disk-name:not(:has(.disk-path)){grid-template-rows:auto}.disk-name .disk-path{grid-column:2;grid-row:2;white-space:nowrap}.disk-name .disk-path:empty{display:none}
</style><style>.nav-count.download-nav-count{background:#f4c430!important;border-color:#ffd95a!important;color:#17191d!important;box-shadow:0 0 10px rgba(244,196,48,.28)}.service-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:20px}.service-summary{margin-left:auto;color:#9da5b2;font-size:12px;font-weight:700}.service-row{min-width:0}.service-row strong,.service-row small{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.service-status.timeout,.service-status.error{background:#806b25;color:#fff3bd}.service-status.offline{background:#7a3030;color:#ffd9d9}.service-timeout .service-logo,.service-error .service-logo{background:#806b25!important}.service-offline .service-logo{background:#7a3030!important}@media(max-width:700px){.service-list{grid-template-columns:1fr}.service-summary{font-size:10px}}</style></body></html>
<style>
details.series{margin-bottom:8px;border:1px solid var(--line);border-radius:6px;overflow:hidden;background:#222}
details.series:last-child{margin-bottom:0}
.series-category{margin-bottom:16px}.series-category:last-child{margin-bottom:0}.series-category-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 14px;margin-bottom:8px;border:1px solid rgba(96,169,230,.2);border-radius:9px;background:linear-gradient(100deg,rgba(44,55,70,.9),rgba(34,34,34,.9))}.series-category-heading h2{margin:0;color:#e9f2fb;font-size:15px}.series-category-heading span{color:#9da5b2;font-size:11px}
.nav-count{display:inline-block;min-width:18px;padding:1px 5px;margin-left:4px;border-radius:999px;background:#713030;color:#ffd9d9;font-size:10px}.empty-state{padding:28px;text-align:center;color:var(--muted)}.missing-table>table{border-bottom:1px solid var(--line)}.missing-series{border-radius:0!important;border-left:0!important;border-right:0!important;border-bottom:1px solid var(--line)!important}.missing-series:last-child{border-bottom:0!important}
</style>
<style>
.view#radarr.active{row-gap:12px}.view#radarr>.toolbar{padding:0 10px;background:transparent;border-color:transparent;box-shadow:none}.view#radarr>.toolbar input,.view#radarr>.toolbar select{background:rgba(42,46,54,.82);border-color:#454d59}.view#radarr>.toolbar input:focus,.view#radarr>.toolbar select:focus{background:#303640}
.season-zero-hidden,.season-zero-empty{display:none!important}.setting-toggle{display:flex!important;align-items:center;gap:10px;margin:18px 0 6px!important;cursor:pointer}.setting-toggle input{appearance:none;width:42px;height:23px;margin:0;border:1px solid #505967;border-radius:999px;background:#30343b;position:relative;cursor:pointer;transition:.2s}.setting-toggle input::after{content:"";position:absolute;width:17px;height:17px;left:2px;top:2px;border-radius:50%;background:#9da5b2;transition:.2s}.setting-toggle input:checked{background:#176f40;border-color:#23965b}.setting-toggle input:checked::after{transform:translateX(19px);background:#e4ffef}.setting-toggle span{font-weight:700;color:#cbd1db}
.missing-table table{table-layout:fixed}.missing-table tbody tr{transform:none!important;animation:none!important}.missing-table .missing-series tbody tr:hover{transform:none!important}
</style>
<script>document.querySelectorAll('details.series').forEach(series => { series.open = false; });</script>
<style>
header{z-index:100!important}
#seriesList{overflow:visible!important}
#seriesList details.series{overflow:visible!important}
#seriesList details.series>summary{position:sticky!important;top:64px!important;z-index:30!important}
#seriesList .season-divider td{top:110px!important;z-index:29!important}
</style>
<style>
.subtitle-de{background:#126f39!important;border-color:#19894b!important;color:#d9ffe8!important}
.audio-preferred{background:#2673a8!important;border-color:#64b5ed!important;color:#eef8ff!important;box-shadow:0 0 12px rgba(96,169,230,.3)}
.col-progress{width:16%}.col-time{width:10%}.col-source{width:10%}.col-language{width:12%}
.progress-wrap{display:flex;align-items:center;gap:8px}.progress-bar{height:7px;min-width:70px;flex:1;background:#3a3a3a;border-radius:99px;overflow:hidden}.progress-bar span{display:block;height:100%;background:#159447;border-radius:99px}.download-title{color:var(--blue);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block}
.download-hero{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:14px;padding:24px 26px;border:1px solid rgba(96,169,230,.2);border-radius:14px;background:radial-gradient(circle at 88% 20%,rgba(21,148,71,.18),transparent 32%),linear-gradient(135deg,rgba(39,48,61,.96),rgba(27,30,36,.96));box-shadow:0 16px 40px rgba(0,0,0,.22)}.eyebrow{color:#79b9e9;font-size:10px;font-weight:800;letter-spacing:.16em}.download-hero h2{margin:7px 0 4px;font-size:22px}.download-hero p{margin:0;color:#9da5b2}.queue-pill{display:flex;align-items:center;gap:9px;padding:10px 14px;border:1px solid rgba(255,255,255,.12);border-radius:999px;background:rgba(13,16,20,.35);white-space:nowrap;color:#d9dee7}.queue-dot{width:9px;height:9px;border-radius:50%;background:#f4c430;box-shadow:0 0 12px currentColor}.download-stats{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:14px}.download-card{display:flex;align-items:center;gap:12px;min-height:92px;padding:16px;border:1px solid rgba(255,255,255,.08);border-radius:11px;background:linear-gradient(145deg,rgba(43,47,55,.92),rgba(29,31,36,.92));box-shadow:0 8px 22px rgba(0,0,0,.16)}.card-icon{display:grid;place-items:center;width:38px;height:38px;border-radius:10px;background:rgba(21,148,71,.2);color:#6ae29a;font-size:21px;font-weight:700}.card-icon.blue{background:rgba(96,169,230,.18);color:#8bc9f2}.card-icon.purple{background:rgba(146,87,194,.2);color:#d7adf3}.card-icon.orange{background:rgba(244,166,48,.18);color:#ffd383}.download-card div{min-width:0}.card-label{display:block;color:#9da5b2;font-size:11px;text-transform:uppercase;letter-spacing:.06em}.download-card strong{display:block;margin:3px 0;font-size:20px;color:#f1f5fa}.download-card small{display:block;color:#7f8996;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.download-table{box-shadow:0 16px 34px rgba(0,0,0,.24)}
@media(max-width:900px){.download-stats{grid-template-columns:repeat(2,1fr)}.download-hero{align-items:flex-start;flex-direction:column}.queue-pill{align-self:stretch;justify-content:center}}@media(max-width:520px){.download-stats{grid-template-columns:1fr}.download-hero{padding:18px}.download-hero h2{font-size:19px}}
</style>
<script>
document.querySelectorAll('#movies tbody tr td:nth-child(5) .badge,#sonarr tbody tr td:nth-child(5) .badge').forEach(badge=>{if(badge.textContent.includes('Deutsch'))badge.classList.add('subtitle-de')});
document.querySelectorAll('#movies tbody tr,#sonarr tbody tr').forEach(row=>{if(row.querySelector('.state.downloading'))row.children[1].querySelector('.badge')?.classList.add('downloading-badge')});
const downloadBody=document.querySelector('#downloadsBody');
function html(value){return String(value??'—').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
function renderDownloads(data){if(!downloadBody)return;const items=data.items||[], active=Number(data.active_count)||0, history=Number(data.history_count)||0, paused=!!data.paused, status=data.error?'Fehler':(paused?'Pausiert':(data.queue_status||'Idle'));document.querySelector('#downloadCount').textContent=`${items.length} Einträge`;const dashboardDownloads=document.querySelector('#dashboardDownloads'),dashboardRemaining=document.querySelector('#dashboardRemaining'),dashboardSabStatus=document.querySelector('#dashboardSabStatus');if(dashboardDownloads)dashboardDownloads.textContent=active;if(dashboardRemaining)dashboardRemaining.textContent=data.remaining?`Noch ${data.remaining}`:'Queue ist leer';if(dashboardSabStatus)dashboardSabStatus.textContent=data.error?'Fehler':(paused?'Pausiert':'Aktiv');document.querySelector('#sabRemaining').textContent=data.remaining||'—';document.querySelector('#sabRemainingHint').textContent=active?`${active} Einträge in der Queue`:'Queue ist leer';document.querySelector('#sabActive').textContent=active;document.querySelector('#sabHistory').textContent=`Verlauf: ${history} Einträge`;document.querySelector('#sabSpeedCard').textContent=data.speed||'—';document.querySelector('#sabStatusCard').textContent=status;document.querySelector('#sabQueueCard').innerHTML=`<span class="queue-dot ${paused?'paused':''}"></span><span>${html(paused?'Pausiert':(data.error?'Nicht erreichbar':'SABnzbd '+status))}</span>`;const error=document.querySelector('#downloadError');error.classList.toggle('hidden',!data.error);error.innerHTML=data.error?`<strong>SABnzbd:</strong> ${html(data.error)}`:'';downloadBody.innerHTML=items.length?items.map(item=>{const pct=Math.max(0,Math.min(100,Number(item.percentage)||0));const languages=(item.languages||[]).map(l=>`<span class="badge">${html(l.flag)} ${html(l.name)}</span>`).join('')||'<span class="muted">Nicht erkannt</span>';return `<tr><td><span class="download-title" title="${html(item.title)}">${html(item.title)}</span><span class="muted">${html(item.category)}</span></td><td><span class="state ${item.completed?'ok':'neutral'}">${html(item.status)}</span></td><td><div class="progress-wrap"><div class="progress-bar"><span style="width:${pct}%"></span></div><span>${pct.toFixed(1)}%</span></div></td><td>${html(item.size)}<br><span class="muted">Rest: ${html(item.remaining)}</span></td><td>${html(item.completed?'—':item.timeleft)}</td><td>${html(item.source)}</td><td><div class="badges">${languages}</div></td></tr>`}).join(''):'<tr><td colspan="7" class="muted">Keine aktuellen Downloads</td></tr>'}
async function fetchDownloads(){if(!downloadBody)return;try{const response=await fetch('/api/downloads');const data=await response.json();renderDownloads(data);if(typeof markDownloading==='function')markDownloading(data.items)}catch(error){renderDownloads({error:'Dashboard konnte SABnzbd nicht erreichen.',items:[]})}};
if(downloadBody){fetchDownloads();setInterval(fetchDownloads,4000)}
const diskList=document.querySelector('#diskList');
function renderSystem(data){const cpu=Number(data.cpu_percent)||0,ram=data.memory||{};const cpuValue=document.querySelector('#cpuValue'),ramValue=document.querySelector('#ramValue'),cpuBar=document.querySelector('#cpuBar'),ramBar=document.querySelector('#ramBar'),updated=document.querySelector('#systemUpdated');if(cpuValue)cpuValue.textContent=`${cpu.toFixed(1)}%`;if(ramValue)ramValue.textContent=`${ram.used||'—'} / ${ram.total||'—'} (${Number(ram.percent||0).toFixed(1)}%)`;if(cpuBar)cpuBar.style.width=`${Math.min(100,cpu)}%`;if(ramBar)ramBar.style.width=`${Math.min(100,Number(ram.percent)||0)}%`;if(updated)updated.textContent='Gerade aktualisiert';if(!diskList)return;diskList.innerHTML=(data.disks||[]).map(d=>`<div class="disk-row"><div class="disk-name">${html(d.label)}<span class="disk-path">${html(d.path)}</span></div><div class="disk-bar"><span style="width:${Math.min(100,Number(d.percent)||0)}%"></span></div><div class="disk-values">${html(d.used)} / ${html(d.total)}<br><span class="muted">${Number(d.percent||0).toFixed(1)}% belegt</span></div></div>`).join('')||'<div class="disk-row muted">Keine Laufwerke gefunden</div>'}
async function fetchSystem(){try{const response=await fetch('/api/system');renderSystem(await response.json())}catch(error){const updated=document.querySelector('#systemUpdated');if(updated)updated.textContent='Nicht verfügbar'}}
if(document.querySelector('#dashboard')){fetchSystem();setInterval(fetchSystem,5000)}
</script>
<style>
.season-divider td{padding:6px 10px;background:#1b1b1b;border-top:1px solid #444;border-bottom:1px solid #303030;color:#aaa;font-size:11px;font-weight:700;letter-spacing:.3px}
#seriesList{overflow:visible}.series{overflow:visible!important}.series summary{position:sticky!important;top:64px!important;z-index:30!important}.season-divider td{top:110px!important;z-index:29!important}
.series[open] table{animation:seriesExpand .38s cubic-bezier(.2,.75,.25,1) both;transform-origin:top center}.series summary::after{content:'⌄';margin-left:auto;color:#929292;font-size:16px;line-height:1;transition:transform .3s ease,color .3s ease}.series[open] summary::after{transform:rotate(180deg);color:#60a9e6}@keyframes seriesExpand{from{opacity:0;transform:translateY(-8px) scaleY(.98)}to{opacity:1;transform:translateY(0) scaleY(1)}}
.series{position:relative;margin-bottom:14px;border:1px solid #353b46;border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.14);transition:border-color .25s ease,box-shadow .25s ease,transform .25s ease}.series:hover{border-color:#4a5565;box-shadow:0 12px 30px rgba(0,0,0,.24);transform:translateY(-1px)}.series-current{border-color:#557ba5;box-shadow:0 0 0 1px rgba(96,169,230,.18),0 14px 34px rgba(0,0,0,.25)}.series summary{position:sticky;top:0;z-index:5;display:flex;align-items:center;min-height:46px;padding:12px 14px;background:rgba(40,40,40,.96);backdrop-filter:blur(12px);border-bottom:1px solid transparent;transition:background .25s ease,border-color .25s ease,color .25s ease}.series-current summary{background:rgba(47,61,78,.98)}.series summary::marker{color:#60a9e6}.series[open] summary{border-bottom-color:#3d444f;box-shadow:0 4px 14px rgba(0,0,0,.16)}.series summary:hover{background:rgba(54,60,70,.97);color:#fff}.series summary .muted{margin-left:7px}.series table tbody tr:not(.season-divider){animation:rowIn .35s ease both;transition:background .2s ease,transform .2s ease}.series table tbody tr:not(.season-divider):hover{background:#292e36;transform:translateX(2px)}.season-divider td{position:sticky;top:46px;z-index:4;background:rgba(27,27,27,.96);backdrop-filter:blur(8px)}
.state.downloading,.downloading-badge{animation:downloadPulse 2s ease-in-out infinite}.progress-bar span{position:relative;overflow:hidden}.progress-bar span::after{content:"";position:absolute;inset:0;background:linear-gradient(110deg,transparent 20%,rgba(255,255,255,.25) 48%,transparent 72%);animation:progressShimmer 1.8s linear infinite}.nav button{transition:color .2s ease,background .2s ease,transform .2s ease}.nav button:hover{transform:translateY(-1px)}.badge{transition:transform .18s ease,filter .18s ease}.badge:hover{transform:translateY(-2px);filter:brightness(1.15)}
@keyframes rowIn{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}}@keyframes downloadPulse{0%,100%{box-shadow:0 0 0 rgba(113,60,158,0)}50%{box-shadow:0 0 14px rgba(146,87,194,.35)}}@keyframes progressShimmer{from{transform:translateX(-100%)}to{transform:translateX(100%)}}
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-duration:.01ms!important}}
.view#sonarr{padding-bottom:4px}.view#sonarr #seriesList{border:0;border-radius:0;background:transparent;overflow:visible}.series-category{margin:0 0 18px;padding:0 0 2px;border:1px solid rgba(96,169,230,.16);border-radius:13px;background:linear-gradient(145deg,rgba(34,39,47,.82),rgba(25,27,31,.78));box-shadow:0 10px 28px rgba(0,0,0,.14);overflow:hidden}.series-category:last-child{margin-bottom:0}.series-category-heading{margin:0;padding:13px 16px;border:0;border-bottom:1px solid rgba(96,169,230,.16);border-radius:0;background:linear-gradient(100deg,rgba(54,74,98,.72),rgba(36,42,50,.7));box-shadow:inset 3px 0 0 #60a9e6}.series-category-heading h2{font-size:14px;letter-spacing:.02em}.series-category-heading span{padding:3px 8px;border:1px solid rgba(255,255,255,.1);border-radius:999px;background:rgba(10,14,20,.2)}.series-category>.series{margin:8px 10px 0;border-color:rgba(255,255,255,.08);border-radius:9px;background:rgba(31,33,38,.78);box-shadow:0 5px 15px rgba(0,0,0,.13)}.series-category>.series:last-child{margin-bottom:10px}.series-category>.series:hover{transform:none;border-color:rgba(96,169,230,.4);box-shadow:0 8px 22px rgba(0,0,0,.2)}.series-category>.series summary{position:relative!important;top:auto!important;z-index:1;min-height:42px;padding:10px 13px;background:rgba(42,45,51,.8);font-size:12px}.series-category>.series[open] summary{background:rgba(51,65,82,.88);border-bottom-color:rgba(96,169,230,.24)}.series-category>.series table{background:rgba(22,24,28,.42)}.series-category>.series th{padding:7px 10px;background:rgba(40,45,53,.86);font-size:10px;color:#9eb8ce;text-transform:uppercase;letter-spacing:.05em}.series-category>.series td{padding:8px 10px}.series-category .season-divider td{position:static!important;top:auto!important;padding:7px 13px;background:rgba(12,15,19,.72);color:#9da5b2;border-top:0}.missing-table{border:1px solid rgba(255,255,255,.1);border-radius:13px;background:rgba(28,30,35,.82);box-shadow:0 10px 30px rgba(0,0,0,.18);overflow:hidden}.missing-table>table{background:transparent}.missing-table>table th{padding:10px 12px;background:linear-gradient(100deg,#303741,#272b32);color:#b5c7d8;text-transform:uppercase;font-size:10px;letter-spacing:.06em}.missing-table>table td{padding:9px 12px}.missing-table>table tbody tr:hover{background:rgba(96,169,230,.08)}.missing-table .missing-series{margin:8px 10px!important;border:1px solid rgba(255,255,255,.09)!important;border-radius:10px!important;background:rgba(36,38,43,.78);box-shadow:0 5px 18px rgba(0,0,0,.14)}.missing-table .missing-series summary{position:relative!important;top:auto!important;z-index:1;min-height:44px;padding:11px 13px;background:rgba(47,45,45,.78);border-bottom:0}.missing-table .missing-series[open] summary{background:rgba(91,55,58,.78);border-bottom-color:rgba(255,130,130,.2)}.missing-table .missing-series table{background:rgba(22,24,28,.42)}.missing-table .missing-series th{padding:7px 10px;background:rgba(56,47,49,.84);font-size:10px;color:#d6afb2;text-transform:uppercase;letter-spacing:.05em}.missing-table .missing-series td{padding:8px 10px}.missing-table .missing-series tbody tr:hover{background:rgba(210,100,100,.08)}
</style>
<style>
:root{--accent:#f4c430;--accent-blue:#60a9e6;--surface:rgba(35,37,42,.82);--surface-strong:#282b31;--border-soft:rgba(255,255,255,.09)}
html,body{min-height:100%;background:#151619}body{min-height:100vh;background:radial-gradient(circle at 10% -15%,rgba(96,169,230,.12),transparent 33%),radial-gradient(circle at 90% 0%,rgba(244,196,48,.07),transparent 28%),#151619;background-attachment:fixed;letter-spacing:.01em}
header{height:64px;background:rgba(31,33,38,.9);backdrop-filter:blur(18px);border-bottom:1px solid var(--border-soft);box-shadow:0 8px 30px rgba(0,0,0,.2);position:sticky;top:0;z-index:20}.logo{font-size:23px;text-shadow:0 0 18px rgba(244,196,48,.24)}.head-title{font-size:14px;letter-spacing:.01em}.head-sub{color:#9da5b2}
main{max-width:1800px;margin:0 auto;padding:18px 20px 42px}.nav{gap:6px;margin:0 0 16px;padding:4px;border:1px solid var(--border-soft);border-radius:9px;background:rgba(31,33,38,.72);box-shadow:0 8px 24px rgba(0,0,0,.12)}.nav button{padding:9px 14px;border-radius:6px;color:#aeb5c1}.nav button.active{background:linear-gradient(135deg,#3a3f48,#2d3139);box-shadow:inset 0 1px rgba(255,255,255,.08),0 4px 12px rgba(0,0,0,.18);color:#fff}.nav button.active::after{content:"";display:block;height:2px;margin:6px 2px -4px;border-radius:2px;background:var(--accent-blue)}
.toolbar{padding:1px 10px;border:1px solid var(--border-soft);border-radius:8px;background:rgba(35,37,42,.72);box-shadow:0 8px 20px rgba(0,0,0,.12);gap:8px}.toolbar input,.toolbar select{height:30px;padding:5px 9px;background:#2b2e34;border-color:#474d57;transition:border-color .2s,box-shadow .2s,background .2s}.toolbar input:focus,.toolbar select:focus{outline:none;background:#30343b;border-color:var(--accent-blue);box-shadow:0 0 0 3px rgba(96,169,230,.14)}
.view#radarr{display:none}.view#radarr.active{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:10px;align-items:start}.view#radarr>.toolbar{grid-column:1;grid-row:1;height:34px;margin-bottom:0}.view#radarr>.stats{grid-column:2;grid-row:1;height:34px;margin:0;align-self:stretch;align-items:stretch;flex-wrap:nowrap;white-space:nowrap}.view#radarr>.stats>.stat{height:34px;display:flex;align-items:center}.view#radarr>.tablewrap{grid-column:1/-1;grid-row:2}
.stats{gap:9px;margin:12px 0}.stat{background:linear-gradient(135deg,rgba(48,52,60,.9),rgba(34,36,41,.9));border:1px solid var(--border-soft);border-radius:8px;padding:7px 11px;box-shadow:0 5px 15px rgba(0,0,0,.12)}.stat:first-child{color:#fff;font-weight:700}.scan{color:#f4c430!important}
.tablewrap{border:1px solid var(--border-soft);border-radius:10px;background:rgba(31,33,38,.84);box-shadow:0 14px 34px rgba(0,0,0,.2)}table{border-spacing:0}thead th{height:38px;background:linear-gradient(180deg,#30343b,#292c32);color:#bfc6d1;text-transform:uppercase;letter-spacing:.07em;font-size:10px}tbody td{height:42px;border-bottom-color:rgba(255,255,255,.075)}tbody tr{transition:background .2s,transform .2s}tbody tr:hover{background:rgba(96,169,230,.065);transform:translateX(1px)}.title{font-size:13px}.quality{box-shadow:0 0 12px rgba(21,148,71,.18)}
#rescan{padding:9px 13px;background:linear-gradient(135deg,#343943,#292c32);border-color:#515865;box-shadow:0 5px 16px rgba(0,0,0,.18);transition:transform .2s,box-shadow .2s,border-color .2s}#rescan:hover{transform:translateY(-2px);border-color:var(--accent-blue);box-shadow:0 8px 20px rgba(0,0,0,.28)}
.actions{display:flex;align-items:center;gap:7px}.lang-switch,.settings-button{height:36px;min-width:36px;padding:0 9px;background:rgba(48,52,60,.9);border-color:#515865;color:#d9dee7;font-weight:700}.settings-button{font-size:17px;padding:0}.lang-switch:hover,.settings-button:hover{border-color:var(--accent-blue);background:#363c47}.settings-backdrop{position:fixed;inset:0;z-index:200;display:grid;place-items:center;background:rgba(5,7,10,.68);backdrop-filter:blur(8px);animation:fadeIn .2s ease}.settings-backdrop.hidden{display:none}.settings-modal{width:min(440px,calc(100vw - 28px));padding:22px;border:1px solid #4a5362;border-radius:14px;background:linear-gradient(145deg,#292e36,#1f2228);box-shadow:0 24px 80px rgba(0,0,0,.55);animation:modalIn .28s cubic-bezier(.2,.8,.2,1)}.settings-head{display:flex;justify-content:space-between;gap:20px;margin-bottom:22px}.settings-head h2{margin:0 0 5px;font-size:19px}.settings-head p{margin:0;color:var(--muted);font-size:12px}.settings-close{padding:0;width:30px;height:30px;border:0;background:transparent;font-size:25px;color:#aeb5c1}.settings-modal label{display:block;margin:15px 0 7px;color:#cbd1db;font-size:12px;font-weight:700}.settings-modal select{width:100%;height:38px;background:#20242a;border-color:#505967}.settings-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:24px}.settings-actions button{padding:9px 14px}.settings-save{background:#176f40;border-color:#23965b;color:#e4ffef}.settings-save:hover{background:#1c8850}@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes modalIn{from{opacity:0;transform:translateY(12px) scale(.97)}to{opacity:1;transform:none}}
@media(max-width:760px){header{padding:0 12px}.head-sub{display:none}main{padding:12px}.view#radarr.active{display:block}.view#radarr>.toolbar{height:auto;margin-bottom:10px}.view#radarr>.stats{height:auto;align-items:initial;flex-wrap:wrap}.view#radarr>.stats>.stat{height:auto}.toolbar{grid-template-columns:1fr}.actions{margin-left:auto}.nav{overflow-x:auto}.nav button{white-space:nowrap}.tablewrap{overflow-x:auto}table{min-width:900px}}
main{min-height:calc(100vh - 64px)}
</style>
<script>
document.querySelectorAll('details.series tbody').forEach(tbody=>{let currentSeason=null;[...tbody.querySelectorAll('tr')].forEach(row=>{if(row.classList.contains('season-divider'))return;const match=row.querySelector('.title')?.textContent.match(/^S(\d{2})E\d{2}/i);if(!match||match[1]===currentSeason)return;currentSeason=match[1];const divider=document.createElement('tr');divider.className='season-divider';divider.innerHTML=`<td colspan="7">Staffel ${Number(currentSeason)}</td>`;tbody.insertBefore(divider,row)})});
document.querySelectorAll('details.series').forEach(series=>{const summary=series.querySelector('summary');if(!summary)return;summary.title='Klicken zum Auf- oder Zuklappen';const observer=new IntersectionObserver(([entry])=>series.classList.toggle('series-current',!entry.isIntersecting&&series.open),{threshold:0,rootMargin:'-58px 0px 0px 0px'});observer.observe(summary)});
</script>
<style>
.downloading-badge{background:#713c9e!important;border-color:#9257c2!important;color:#f2e5ff!important}.state.downloading{background:#713c9e;color:#f2e5ff}
header{z-index:100!important}
.downloading-state{background:#713c9e!important;color:#f2e5ff!important}
</style>
<script>
function normalizeDownloadText(value){return String(value||'').toLowerCase().replace(/[._()[\]{}'\-·]/g,' ').replace(/\s+/g,' ').trim()}
function markDownloading(items){
const downloads=(items||[]).filter(item=>!item.completed).map(item=>normalizeDownloadText(item.title));
document.querySelectorAll('#movies tbody tr,#sonarr tbody tr').forEach(row=>{
if(row.dataset.downloadMarked==='1'){
row.children[1].innerHTML='<span class="badge">Keine Datei</span>';
row.children[2].innerHTML='<span class="state bad">Fehler</span>';
row.dataset.downloadMarked='0';
}
const details=row.closest('details.series');
const seriesName=details?normalizeDownloadText(details.querySelector('summary')?.textContent.split('(')[0]):'';
const rowName=normalizeDownloadText(row.querySelector('.title')?.textContent);
const rowTokens=rowName.split(' ').filter(token=>token.length>2);
const isMatch=downloads.some(download=>download.includes(rowName)||(seriesName&&download.includes(seriesName)&&download.includes(rowName.match(/s\d{2}e\d{2}/)?.[0]||''))||(rowTokens.length>=3&&rowTokens.filter(token=>download.includes(token)).length>=Math.ceil(rowTokens.length*.75)));
if(!rowName||!isMatch)return;
const quality=row.children[1], state=row.children[2];
if(quality.textContent.includes('Keine Datei')||row.dataset.downloadMarked==='1'){
quality.innerHTML='<span class="badge downloading-badge">Downloading</span>';
state.innerHTML='<span class="state downloading-state">Downloading</span>';
row.dataset.downloadMarked='1';
}
});
}
const originalRenderDownloads=renderDownloads;
renderDownloads=function(data){originalRenderDownloads(data);markDownloading(data.items);const navCount=document.querySelector('#downloadNavCount'),active=Number(data.active_count)||0;if(navCount){navCount.textContent=active;navCount.classList.toggle('hidden',active===0)}};
if(downloadBody)fetchDownloads();
</script>
<script>
const uiTranslations={de:{film:'Film',quality:'Qualität',status:'Status',audio:'Audio',subs:'Untertitel',video:'Video',size:'Größe',episode:'Episode',allAudio:'Alle Audio-Sprachen',allSubs:'Alle Untertitel',allStatus:'Alle Status',searchMovie:'Film suchen …',searchSeries:'Serie suchen …'},en:{film:'Movie',quality:'Quality',status:'Status',audio:'Audio',subs:'Subtitles',video:'Video',size:'Size',episode:'Episode',allAudio:'All audio languages',allSubs:'All subtitles',allStatus:'All statuses',searchMovie:'Search movies …',searchSeries:'Search series …'}};
let currentUiLanguage=localStorage.getItem('dashboard-language')||'de';
function translateLayout(language){
currentUiLanguage=language;localStorage.setItem('dashboard-language',language);document.documentElement.lang=language;const t=uiTranslations[language];
document.querySelectorAll('[data-de][data-en]').forEach(node=>{node.textContent=node.dataset[language]});
document.querySelector('#languageToggle').textContent=language==='de'?'EN':'DE';
const navLabels={dashboard:['▦ Dashboard','▦ Dashboard'],radarr:['RADARR · Filme','RADARR · Movies'],sonarr:['SONARR · Serien','SONARR · Series'],missing:['⚠ Ohne Deutsch','⚠ Without German'],downloads:['SABNZBD · Downloads','SABNZBD · Downloads']};document.querySelectorAll('.nav button').forEach(button=>{const labels=navLabels[button.dataset.view];if(labels)button.childNodes[0].textContent=labels[language==='de'?0:1]});
const searchInput=document.querySelector('#search');if(searchInput)searchInput.placeholder=t.searchMovie;
const filterOptions=[['#audioFilter',t.allAudio],['#subFilter',t.allSubs],['#stateFilter',t.allStatus]];filterOptions.forEach(([selector,label])=>{const first=document.querySelector(`${selector} option:first-child`);if(first)first.textContent=label});
document.querySelectorAll('#movies thead th').forEach((th,i)=>{th.textContent=[t.film,t.quality,t.status,t.audio,t.subs,t.video,t.size][i]||th.textContent});
document.querySelectorAll('#seriesList thead th').forEach((th,i)=>{th.textContent=[t.episode,t.quality,t.status,t.audio,t.subs,t.video,t.size][i]||th.textContent});
document.querySelectorAll('#downloadsTable thead th').forEach((th,i)=>{th.textContent=(language==='de'?['Download','Status','Fortschritt','Größe / Rest','Restzeit','Quelle','Sprache']:['Download','Status','Progress','Size / Remaining','Time left','Source','Language'])[i]||th.textContent});
}
function highlightPreferences(){const audioPreference=localStorage.getItem('preferred-audio')||'';document.querySelectorAll('#movies tbody tr td:nth-child(4) .badge,#sonarr tbody tr td:nth-child(4) .badge').forEach(badge=>badge.classList.toggle('audio-preferred',!!audioPreference&&badge.textContent.includes(audioPreference)))}
function loadPreferences(){const audioPreference=localStorage.getItem('preferred-audio')||'';const subsPreference=localStorage.getItem('preferred-subs')||'';const audioSelect=document.querySelector('#preferredAudio'),subsSelect=document.querySelector('#preferredSubs');if(audioSelect)audioSelect.value=audioPreference;if(subsSelect)subsSelect.value=subsPreference;const audioFilter=document.querySelector('#audioFilter'),subFilter=document.querySelector('#subFilter');if(audioFilter)audioFilter.value=audioPreference;if(subFilter)subFilter.value=subsPreference;if(typeof apply==='function')apply();highlightPreferences()}
translateLayout(currentUiLanguage);loadPreferences();
const serviceIconSvg={radarr:'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="7"/><circle cx="12" cy="12" r="2" fill="currentColor"/><path d="M12 5a7 7 0 0 1 6 3M12 12l6-5"/></svg>',sonarr:'<svg viewBox="0 0 24 24"><path d="M4 12h3M17 12h3M7 8a6 6 0 0 1 10 0M7 16a6 6 0 0 0 10 0M10 12h4"/><circle cx="12" cy="12" r="2" fill="currentColor"/></svg>',sabnzbd:'<svg viewBox="0 0 24 24"><path d="M12 4v11M8 11l4 4 4-4M5 19h14"/></svg>',jellyfin:'<svg viewBox="0 0 24 24"><path d="M7 4h10l4 8-4 8H7l-4-8 4-8Z"/><path d="m10 8 6 4-6 4V8Z" fill="currentColor"/></svg>',seerr:'<svg viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6"/><path d="m15 15 5 5M8 10h5"/></svg>',prowlarr:'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/><path d="m12 6 2 6-2 6-2-6 2-6ZM6 12h12"/></svg>',easynews:'<svg viewBox="0 0 24 24"><path d="M6 18h12a3 3 0 0 0 .5-6A6.5 6.5 0 0 0 6 10a4 4 0 0 0 0 8Z"/></svg>',qbittorrent:'<svg viewBox="0 0 24 24"><path d="m5 7 5-3 5 3-5 3-5-3ZM9 14l5-3 5 3-5 3-5-3ZM5 17l5-3 5 3-5 3-5-3Z"/></svg>',bazarr:'<svg viewBox="0 0 24 24"><path d="M5 6h14M5 12h9M5 18h14"/><path d="m17 10 2 2-2 2"/></svg>',lingarr:'<svg viewBox="0 0 24 24"><path d="M5 6h14v10H9l-4 3V6Z"/><path d="M9 10h6M9 13h4"/></svg>',libretranslate:'<svg viewBox="0 0 24 24"><path d="M5 6h8M9 4v2M6 6c1 5 4 8 8 10M6 16c3-1 5-3 7-6M14 6h5M16 4v2M14 18h7M17 10l-3 8M18 15h4"/></svg>',sonarr_prowlarr_proxy:'<svg viewBox="0 0 24 24"><path d="M5 8h8M11 5l3 3-3 3M19 16h-8M13 13l-3 3 3 3"/><circle cx="5" cy="8" r="2"/><circle cx="19" cy="16" r="2"/></svg>',radarr_prowlarr_proxy:'<svg viewBox="0 0 24 24"><path d="M5 8h8M11 5l3 3-3 3M19 16h-8M13 13l-3 3 3 3"/><circle cx="5" cy="8" r="2"/><circle cx="19" cy="16" r="2"/></svg>',dashboard:'<svg viewBox="0 0 24 24"><path d="m4 11 8-7 8 7v8a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-8Z"/><path d="M9 20v-6h6v6"/></svg>'};function upgradeServiceIcons(){document.querySelectorAll('.service-logo[class*="service-icon-"]').forEach(icon=>{const key=[...icon.classList].find(name=>name.startsWith('service-icon-'))?.slice(13);if(serviceIconSvg[key]&&icon.dataset.iconKey!==key){icon.innerHTML=serviceIconSvg[key];icon.dataset.iconKey=key}})}const serviceIconObserver=new MutationObserver(upgradeServiceIcons);const serviceIconList=document.querySelector('.service-list');if(serviceIconList)serviceIconObserver.observe(serviceIconList,{childList:true,subtree:true});upgradeServiceIcons();
document.querySelector('#languageToggle').addEventListener('click',()=>translateLayout(currentUiLanguage==='de'?'en':'de'));
const header=document.querySelector('header'),headerActions=document.querySelector('.actions'),mainNav=document.querySelector('.nav');if(header&&headerActions&&mainNav){mainNav.classList.add('header-nav');header.insertBefore(mainNav,headerActions);const headerStyle=document.createElement('style');headerStyle.textContent='.head-title,.head-sub{display:none}.header-nav{display:flex;align-items:center;gap:4px;margin:0 0 0 22px!important;padding:3px!important;border:0!important;background:transparent!important;box-shadow:none!important;overflow:visible}.header-nav button{padding:8px 11px;font-size:12px}.header-nav .nav-count{font-size:10px}header{gap:0}.brand{flex:0 0 auto}@media(max-width:850px){.header-nav{margin-left:10px!important;overflow-x:auto;max-width:calc(100vw - 250px);scrollbar-width:none}.header-nav::-webkit-scrollbar{display:none}.header-nav button{white-space:nowrap;padding:7px 9px;font-size:11px}}@media(max-width:560px){.header-nav{max-width:calc(100vw - 135px)}.header-nav button{font-size:0;padding:8px}.header-nav button[data-view="dashboard"]{font-size:0}.header-nav button[data-view="dashboard"]::before{content:"▦";font-size:16px}.header-nav button[data-view="radarr"]::before{content:"R";font-size:14px;font-weight:800}.header-nav button[data-view="sonarr"]::before{content:"S";font-size:14px;font-weight:800}.header-nav button[data-view="missing"]::before{content:"⚠";font-size:14px}.header-nav button[data-view="downloads"]::before{content:"↓";font-size:16px}}';document.head.appendChild(headerStyle)}
const officialServiceIcons={radarr:'https://cdn.simpleicons.org/radarr/CC7B19',sonarr:'https://cdn.simpleicons.org/sonarr/35C5F4',sabnzbd:'https://api.iconify.design/mdi:download-box.svg?color=%23F5A623',jellyfin:'https://cdn.simpleicons.org/jellyfin/AA5CC3',seerr:'https://api.iconify.design/mdi:movie-search.svg?color=%2373CFF8',prowlarr:'https://api.iconify.design/mdi:radar.svg?color=%235B6BBF',easynews:'https://api.iconify.design/mdi:cloud-download.svg?color=%23F28C28',qbittorrent:'https://cdn.simpleicons.org/qbittorrent/2F81C7',bazarr:'https://api.iconify.design/mdi:subtitles.svg?color=%2344A5B5',lingarr:'https://api.iconify.design/mdi:forum.svg?color=%238B5CF6',libretranslate:'https://cdn.simpleicons.org/libretranslate/22C55E'};Object.entries(officialServiceIcons).forEach(([key,url])=>{serviceIconSvg[key]=`<img src="${url}" alt="" loading="lazy" referrerpolicy="no-referrer">`});upgradeServiceIcons();
document.querySelectorAll('.service-logo svg').forEach(svg=>{svg.style.width='19px';svg.style.height='19px';svg.style.fill='none';svg.style.stroke='currentColor';svg.style.strokeWidth='1.8';svg.style.strokeLinecap='round';svg.style.strokeLinejoin='round'});
document.querySelectorAll('.service-logo img').forEach(img=>{img.style.width='20px';img.style.height='20px';img.style.objectFit='contain'});
setInterval(()=>{document.querySelectorAll('.service-logo svg').forEach(svg=>{svg.style.width='19px';svg.style.height='19px';svg.style.fill='none';svg.style.stroke='currentColor';svg.style.strokeWidth='1.8';svg.style.strokeLinecap='round';svg.style.strokeLinejoin='round'});document.querySelectorAll('.service-logo img').forEach(img=>{img.style.width='20px';img.style.height='20px';img.style.objectFit='contain'})},500);
const settingsBackdrop=document.querySelector('#settingsBackdrop');const closeSettings=()=>settingsBackdrop.classList.add('hidden');document.querySelector('#settingsOpen').addEventListener('click',()=>{loadPreferences();settingsBackdrop.classList.remove('hidden')});document.querySelector('#settingsClose').addEventListener('click',closeSettings);document.querySelector('#settingsCancel').addEventListener('click',closeSettings);settingsBackdrop.addEventListener('click',event=>{if(event.target===settingsBackdrop)closeSettings()});document.querySelector('#settingsSave').addEventListener('click',()=>{localStorage.setItem('preferred-audio',document.querySelector('#preferredAudio').value);localStorage.setItem('preferred-subs',document.querySelector('#preferredSubs').value);loadPreferences();closeSettings()});
</script>
<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>
+136
View File
@@ -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()
+109
View File
@@ -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);
});