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/
This commit is contained in:
1 parent
6f0c222e93
commit
5ddf506f20
7 files changed
+201
-2
No files matched your search
@@ -31,4 +31,10 @@ 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
|
||||
LOG_LEVEL=INFO
|
||||
@@ -2,3 +2,5 @@
|
||||
data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
AGENT.md
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
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 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Media Max
|
||||
|
||||
## Datastore
|
||||
|
||||
Der Datastore-Tab zeigt den read-only Speicherstatus von `/nesflix`, Medienkategorien
|
||||
und den Linux-Software-RAID-Status. Größen-Scans werden standardmäßig 15 Minuten
|
||||
gecacht; RAID-/Recovery-Werte aktualisieren sich live. Konfigurierbar sind
|
||||
`DATASTORE_PATH`, `DATASTORE_HOST_PATH`, `RAID_DEVICE`, `STORAGE_CACHE_SECONDS` und
|
||||
optional `SMART_ENABLED=true` für SMART-Healthwerte.
|
||||
|
||||
## Wichtig: Path-Mapping
|
||||
|
||||
Radarr kann seine Filme z.B. unter `/data/filme` sehen, während der Host sie unter
|
||||
|
||||
@@ -38,6 +38,11 @@ SERVICE_CHECK_TIMEOUT = float(os.environ.get("SERVICE_CHECK_TIMEOUT", "3"))
|
||||
SERVICE_REFRESH_SECONDS = int(os.environ.get("SERVICE_REFRESH_SECONDS", "30"))
|
||||
DB_PATH = os.environ.get("DB_PATH", "/data/cache.db")
|
||||
REQUEST_TIMEOUT = int(os.environ.get("REQUEST_TIMEOUT", "20"))
|
||||
DATASTORE_PATH = os.environ.get("DATASTORE_PATH", "/nesflix").rstrip("/") or "/"
|
||||
RAID_DEVICE = os.environ.get("RAID_DEVICE", "/dev/md127")
|
||||
HOST_DEVICE_PATH = os.environ.get("HOST_DEVICE_PATH", "/host-dev")
|
||||
STORAGE_CACHE_SECONDS = int(os.environ.get("STORAGE_CACHE_SECONDS", "900"))
|
||||
SMART_ENABLED = os.environ.get("SMART_ENABLED", "false").lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger("media-max")
|
||||
@@ -50,6 +55,8 @@ system_lock = threading.Lock()
|
||||
previous_cpu = None
|
||||
service_status_lock = threading.Lock()
|
||||
service_check_executor = ThreadPoolExecutor(max_workers=16)
|
||||
datastore_cache_lock = threading.Lock()
|
||||
datastore_cache = {"stored_at": 0.0, "snapshot": None}
|
||||
|
||||
LANG_NAMES = {"de":"Deutsch", "deu":"Deutsch", "ger":"Deutsch", "en":"Englisch", "eng":"Englisch", "ja":"Japanisch", "jpn":"Japanisch", "ko":"Koreanisch", "kor":"Koreanisch", "fr":"Französisch", "fra":"Französisch", "fre":"Französisch", "es":"Spanisch", "spa":"Spanisch", "it":"Italienisch", "ita":"Italienisch", "ru":"Russisch", "rus":"Russisch", "zh":"Chinesisch", "zho":"Chinesisch", "chi":"Chinesisch", "und":"Unbekannt", "":"Unbekannt"}
|
||||
FLAGS = {"Deutsch":"🇩🇪", "Englisch":"🇬🇧", "Japanisch":"🇯🇵", "Koreanisch":"🇰🇷", "Französisch":"🇫🇷", "Spanisch":"🇪🇸", "Italienisch":"🇮🇹", "Russisch":"🇷🇺", "Chinesisch":"🇨🇳", "Unbekannt":"❓"}
|
||||
@@ -160,6 +167,163 @@ def format_bytes(size):
|
||||
return f"{value:.1f} {unit}"
|
||||
value /= 1024
|
||||
|
||||
def _normalized_path_part(value):
|
||||
return re.sub(r"[^a-z0-9]+", "", str(value or "").casefold())
|
||||
|
||||
def _datastore_category(relative_path, category_roots):
|
||||
parts = [part for part in Path(relative_path).parts if part not in (".", "")]
|
||||
normalized = {_normalized_path_part(part) for part in parts}
|
||||
if normalized & {"download", "downloads", "sabnzbd", "nzb"}:
|
||||
return "downloads"
|
||||
if normalized & {"anime"}:
|
||||
return "anime"
|
||||
if normalized & {"kdrama", "kdramas", "kdramen", "korean", "koreanischeserien"}:
|
||||
return "kdrama"
|
||||
if parts and _normalized_path_part(parts[0]) in category_roots["movies"]:
|
||||
return "movies"
|
||||
if parts and _normalized_path_part(parts[0]) in category_roots["series"]:
|
||||
return "series"
|
||||
return "other"
|
||||
|
||||
def scan_datastore_storage():
|
||||
root = Path(DATASTORE_PATH)
|
||||
categories = {key: {"key": key, "label": label, "bytes": 0, "files": 0} for key, label in (
|
||||
("movies", "Filme"), ("series", "Serien"), ("anime", "Anime"),
|
||||
("kdrama", "K-Dramen"), ("downloads", "Downloads"), ("other", "Sonstiges")
|
||||
)}
|
||||
if not root.exists() or not root.is_dir():
|
||||
return {"error": f"Datastore {DATASTORE_PATH} ist nicht verfügbar.", "path": DATASTORE_PATH, "categories": list(categories.values())}
|
||||
category_roots = {
|
||||
"movies": {"filme", "film", "movies", "movie"},
|
||||
"series": {"serien", "serie", "series", "tv", "shows"},
|
||||
}
|
||||
try:
|
||||
for current, directories, files in os.walk(root, followlinks=False):
|
||||
directories[:] = [directory for directory in directories if not os.path.islink(os.path.join(current, directory))]
|
||||
relative = os.path.relpath(current, root)
|
||||
for filename in files:
|
||||
full_path = os.path.join(current, filename)
|
||||
if os.path.islink(full_path):
|
||||
continue
|
||||
try:
|
||||
size = os.path.getsize(full_path)
|
||||
except OSError:
|
||||
continue
|
||||
category = categories[_datastore_category(relative, category_roots)]
|
||||
category["bytes"] += size
|
||||
category["files"] += 1
|
||||
usage = shutil.disk_usage(root)
|
||||
except OSError as exc:
|
||||
return {"error": f"Datastore konnte nicht gelesen werden: {exc}", "path": DATASTORE_PATH, "categories": list(categories.values())}
|
||||
used = usage.used
|
||||
values = []
|
||||
for category in categories.values():
|
||||
category["size"] = format_bytes(category["bytes"])
|
||||
category["percent"] = round(category["bytes"] / used * 100, 1) if used else 0
|
||||
values.append(category)
|
||||
return {"error": None, "path": DATASTORE_PATH, "total_bytes": usage.total, "used_bytes": usage.used,
|
||||
"free_bytes": usage.free, "total": format_bytes(usage.total), "used": format_bytes(usage.used),
|
||||
"free": format_bytes(usage.free), "percent": round(usage.used / usage.total * 100, 1) if usage.total else 0,
|
||||
"categories": values, "scanned_at": time.time()}
|
||||
|
||||
def _read_command(command, timeout=4):
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=False)
|
||||
return result.stdout, result.stderr
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return "", str(exc)
|
||||
|
||||
def _flatten_lsblk(nodes):
|
||||
result = []
|
||||
for node in nodes or []:
|
||||
result.append(node)
|
||||
result.extend(_flatten_lsblk(node.get("children")))
|
||||
return result
|
||||
|
||||
def raid_status():
|
||||
mdstat, mdstat_error = "", ""
|
||||
try:
|
||||
mdstat = Path("/proc/mdstat").read_text()
|
||||
except OSError as exc:
|
||||
mdstat_error = str(exc)
|
||||
array_name = Path(RAID_DEVICE).name
|
||||
array_line = next((line for line in mdstat.splitlines() if line.startswith(f"{array_name} :")), "")
|
||||
profile_match = re.search(r"(raid\d+)", array_line)
|
||||
members_match = re.search(r"\[(\d+)/(\d+)\]\s+\[([^\]]+)\]", mdstat)
|
||||
progress_match = re.search(r"(?:recovery|resync|reshape|check)\s*=\s*([\d.]+)%", mdstat, re.IGNORECASE)
|
||||
finish_match = re.search(r"finish=([^\s]+)", mdstat)
|
||||
speed_match = re.search(r"speed=([^\s]+)", mdstat)
|
||||
mdadm_output, mdadm_error = _read_command(["mdadm", "--detail", RAID_DEVICE])
|
||||
detail = {}
|
||||
for key, pattern in (("raid_devices", r"Raid Device\s*:\s*(\d+)"), ("active_devices", r"Active Devices\s*:\s*(\d+)"),
|
||||
("working_devices", r"Working Devices\s*:\s*(\d+)"), ("failed_devices", r"Failed Devices\s*:\s*(\d+)"),
|
||||
("spare_devices", r"Spare Devices\s*:\s*(\d+)")):
|
||||
match = re.search(pattern, mdadm_output, re.IGNORECASE)
|
||||
if match:
|
||||
detail[key] = int(match.group(1))
|
||||
if profile_match:
|
||||
detail["level"] = profile_match.group(1).upper()
|
||||
elif mdadm_output:
|
||||
level = re.search(r"Raid Level\s*:\s*(\S+)", mdadm_output, re.IGNORECASE)
|
||||
detail["level"] = level.group(1).upper() if level else "RAID"
|
||||
devices = []
|
||||
for line in mdadm_output.splitlines():
|
||||
match = re.match(r"\s*\d+\s+\d+\s+\d+\s+(\d+|-)\s+(.+?)\s+(/dev/\S+)\s*$", line)
|
||||
if match:
|
||||
state_text, device = match.group(2), match.group(3)
|
||||
devices.append({"device": device, "slot": match.group(1), "state": state_text, "status": "active" if "active" in state_text.lower() else ("spare" if "spare" in state_text.lower() else "failed")})
|
||||
lsblk_output, _ = _read_command(["lsblk", "-J", "-b", "-o", "NAME,SIZE,MODEL,SERIAL,TYPE,PATH"])
|
||||
try:
|
||||
block_devices = {item.get("path") or f"/dev/{item.get('name')}": item for item in _flatten_lsblk(json.loads(lsblk_output).get("blockdevices", []))}
|
||||
except (ValueError, AttributeError):
|
||||
block_devices = {}
|
||||
for disk in devices:
|
||||
info = block_devices.get(disk["device"], {})
|
||||
parent = re.sub(r"(p)?\d+$", "", disk["device"])
|
||||
info = info or block_devices.get(parent, {})
|
||||
if not info:
|
||||
host_device = disk["device"].replace("/dev/", f"{HOST_DEVICE_PATH.rstrip('/')}/", 1)
|
||||
host_lsblk, _ = _read_command(["lsblk", "-J", "-b", "-o", "NAME,SIZE,MODEL,SERIAL,TYPE,PATH", host_device])
|
||||
try:
|
||||
host_items = _flatten_lsblk(json.loads(host_lsblk).get("blockdevices", []))
|
||||
info = host_items[0] if host_items else {}
|
||||
except (ValueError, AttributeError):
|
||||
info = {}
|
||||
disk.update({"size": format_bytes(info.get("size") or 0) if info.get("size") else "—", "model": info.get("model") or "—", "serial": info.get("serial") or "—"})
|
||||
if SMART_ENABLED and shutil.which("smartctl"):
|
||||
smart_device = disk["device"].replace("/dev/", f"{HOST_DEVICE_PATH.rstrip('/')}/", 1)
|
||||
smart_output, _ = _read_command(["smartctl", "-H", smart_device], timeout=6)
|
||||
health = re.search(r"SMART overall-health self-assessment test result:\s*(.+)", smart_output)
|
||||
disk["smart"] = health.group(1).strip() if health else "Nicht verfügbar"
|
||||
missing = members_match and members_match.group(1) != members_match.group(2)
|
||||
failed = detail.get("failed_devices", 0) > 0 or any(disk["status"] == "failed" for disk in devices)
|
||||
rebuilding = bool(progress_match)
|
||||
if failed and not array_line:
|
||||
status = "failed"
|
||||
elif rebuilding:
|
||||
status = "rebuilding"
|
||||
elif failed or missing:
|
||||
status = "degraded"
|
||||
elif array_line or mdadm_output:
|
||||
status = "ok"
|
||||
else:
|
||||
status = "failed"
|
||||
return {"device": RAID_DEVICE, "status": status, "label": {"ok": "OK", "degraded": "DEGRADED", "failed": "FAILED", "rebuilding": "REBUILDING"}.get(status, "UNKNOWN"),
|
||||
"level": detail.get("level", "—"), "raid_devices": detail.get("raid_devices") or (int(members_match.group(2)) if members_match else 0),
|
||||
"active_devices": detail.get("active_devices") or (int(members_match.group(1)) if members_match else 0), "failed_devices": detail.get("failed_devices", 0),
|
||||
"devices": devices, "recovery": {"active": rebuilding, "percent": float(progress_match.group(1)) if progress_match else 0,
|
||||
"finish": finish_match.group(1) if finish_match else "—", "speed": speed_match.group(1) if speed_match else "—"},
|
||||
"error": None if array_line or mdadm_output else (mdadm_error or mdstat_error or "RAID-Status nicht verfügbar"), "updated_at": time.time()}
|
||||
|
||||
def datastore_snapshot():
|
||||
now = time.time()
|
||||
with datastore_cache_lock:
|
||||
if datastore_cache["snapshot"] is None or now - datastore_cache["stored_at"] >= max(30, STORAGE_CACHE_SECONDS):
|
||||
datastore_cache["snapshot"] = scan_datastore_storage()
|
||||
datastore_cache["stored_at"] = now
|
||||
storage = datastore_cache["snapshot"]
|
||||
return {"storage": storage, "raid": raid_status(), "cache_seconds": STORAGE_CACHE_SECONDS}
|
||||
|
||||
def cpu_usage_percent():
|
||||
global previous_cpu
|
||||
try:
|
||||
@@ -499,6 +663,14 @@ def system():
|
||||
log.exception("Systemmetriken konnten nicht gelesen werden")
|
||||
return jsonify({"error": str(exc), "cpu_percent": 0, "memory": {}, "disks": []}), 500
|
||||
|
||||
@app.get("/api/datastore")
|
||||
def datastore():
|
||||
try:
|
||||
return jsonify(datastore_snapshot())
|
||||
except Exception as exc:
|
||||
log.exception("Datastore konnte nicht abgefragt werden")
|
||||
return jsonify({"storage": {"error": str(exc), "path": DATASTORE_PATH, "categories": []}, "raid": {"status": "failed", "label": "FAILED", "error": str(exc)}}), 500
|
||||
|
||||
@app.get("/health")
|
||||
def health(): return jsonify({"ok":True, "radarr_url":RADARR_URL, "sonarr_enabled":bool(SONARR_URL and SONARR_API_KEY), "sab_enabled":bool(SAB_URL and SAB_API_KEY)})
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8099:8099"
|
||||
devices:
|
||||
- "${RAID_DEVICE:-/dev/md127}:${RAID_DEVICE:-/dev/md127}"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
env_file:
|
||||
@@ -14,6 +16,10 @@ services:
|
||||
- ${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
|
||||
- ./data:/data
|
||||
networks:
|
||||
- media
|
||||
|
||||
@@ -30,12 +30,13 @@
|
||||
{% 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 %}
|
||||
<section id="datastore" class="view" data-source="datastore"><div class="datastore-hero"><div><div class="eyebrow">MEDIA MAX · DATASTORE</div><h2>Datastore</h2><p>Speicherbelegung, Medienkategorien und RAID-Status von <code>/nesflix</code>.</p></div><div id="datastoreUpdated" class="datastore-live">Wird geladen …</div></div><div class="datastore-storage"><div class="storage-card"><span class="storage-icon">▣</span><div><small>Gesamtspeicher</small><strong id="datastoreTotal">—</strong></div></div><div class="storage-card"><span class="storage-icon used">◒</span><div><small>Belegt</small><strong id="datastoreUsed">—</strong></div></div><div class="storage-card"><span class="storage-icon free">◌</span><div><small>Frei</small><strong id="datastoreFree">—</strong></div></div><div class="storage-card"><span class="storage-icon percent">%</span><div><small>Auslastung</small><strong id="datastorePercent">—</strong></div></div></div><section class="datastore-panel datastore-capacity"><div class="datastore-panel-head"><div><h3>Gesamtauslastung</h3><p id="datastorePath">/nesflix</p></div><strong id="datastoreCapacityLabel">—</strong></div><div class="datastore-progress"><span id="datastoreCapacityBar"></span></div></section><div class="datastore-grid"><section class="datastore-panel"><div class="datastore-panel-head"><div><h3>Speicher nach Kategorie</h3><p>Anhand der tatsächlichen Verzeichnisse</p></div></div><div id="datastoreCategories" class="datastore-categories"><div class="datastore-empty">Wird geladen …</div></div></section><section class="datastore-panel raid-panel"><div class="datastore-panel-head"><div><h3>RAID-Status</h3><p id="raidDevice">/dev/md127</p></div><span id="raidStatusBadge" class="raid-status unknown">Wird geladen …</span></div><div id="raidSummary" class="raid-summary"><span>—</span><span>—</span><span>—</span></div><div id="raidRecovery" class="raid-recovery hidden"><div class="recovery-head"><strong>RAID Rebuild</strong><span id="raidRecoveryPercent">—</span></div><div class="datastore-progress recovery"><span id="raidRecoveryBar"></span></div><div class="recovery-meta"><span>Geschwindigkeit <strong id="raidRecoverySpeed">—</strong></span><span>Verbleibend <strong id="raidRecoveryFinish">—</strong></span></div></div><div id="raidDisks" class="raid-disks"><div class="datastore-empty">Wird geladen …</div></div></section></div></section>
|
||||
</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');
|
||||
const navigation=document.querySelector('.nav');if(navigation&&!navigation.querySelector('[data-view="datastore"]')){const datastoreButton=document.createElement('button');datastoreButton.dataset.view='datastore';datastoreButton.dataset.de='▣ Datastore';datastoreButton.dataset.en='▣ Datastore';datastoreButton.textContent='▣ Datastore';navigation.appendChild(datastoreButton)}const rows=[...document.querySelectorAll('#movies tbody tr')], search=document.querySelector('#search'), audio=document.querySelector('#audioFilter'), subs=document.querySelector('#subFilter'), state=document.querySelector('#stateFilter'), count=document.querySelector('#visibleCount');
|
||||
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 …'}
|
||||
@@ -88,6 +89,9 @@ header{z-index:100!important}
|
||||
<style>
|
||||
.missing-table{padding:10px}.missing-table>table{width:100%;margin:0 0 8px;border:1px solid rgba(255,255,255,.09);border-radius:10px;overflow:hidden}.missing-table .missing-series{margin:8px 0 0!important}.missing-table thead th{height:38px;padding:0 12px;vertical-align:middle;background:#2d3540;border-right:1px solid rgba(255,255,255,.06)}.missing-table thead th:last-child{border-right:0}.missing-table .missing-series summary{min-height:42px;display:flex;align-items:center}
|
||||
</style>
|
||||
<style>
|
||||
.datastore-hero{display:flex;justify-content:space-between;align-items:center;gap:18px;margin-bottom:14px;padding:22px 26px;border:1px solid rgba(96,169,230,.2);border-radius:14px;background:radial-gradient(circle at 88% 20%,rgba(96,169,230,.14),transparent 32%),linear-gradient(135deg,rgba(39,48,61,.96),rgba(27,30,36,.96));box-shadow:0 14px 34px rgba(0,0,0,.2)}.datastore-hero h2{margin:7px 0 4px;font-size:22px}.datastore-hero p{margin:0;color:#9da5b2}.datastore-hero code{color:#b9d9f1}.datastore-live{padding:8px 12px;border:1px solid rgba(106,226,154,.22);border-radius:999px;color:#aaf2c5;background:rgba(21,148,71,.12);font-size:11px}.datastore-storage{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:12px}.storage-card{display:flex;align-items:center;gap:11px;min-height:82px;padding:14px;border:1px solid rgba(255,255,255,.08);border-radius:11px;background:linear-gradient(145deg,rgba(43,47,55,.92),rgba(29,31,36,.92));box-shadow:0 8px 22px rgba(0,0,0,.16)}.storage-icon{display:grid;place-items:center;width:35px;height:35px;border-radius:10px;background:rgba(96,169,230,.16);color:#8bc9f2;font-weight:800}.storage-icon.used{background:rgba(244,196,48,.16);color:#f4d267}.storage-icon.free{background:rgba(21,148,71,.16);color:#6ae29a}.storage-icon.percent{background:rgba(146,87,194,.18);color:#d7adf3}.storage-card small{display:block;color:#9da5b2;font-size:10px;text-transform:uppercase;letter-spacing:.06em}.storage-card strong{display:block;margin-top:3px;color:#f1f5fa;font-size:18px}.datastore-panel{padding:18px;border:1px solid rgba(255,255,255,.08);border-radius:12px;background:rgba(31,33,38,.84);box-shadow:0 12px 30px rgba(0,0,0,.18)}.datastore-capacity{margin-bottom:12px}.datastore-panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:14px}.datastore-panel-head h3{margin:0 0 4px;font-size:15px}.datastore-panel-head p{margin:0;color:#7f8996;font-size:11px}.datastore-panel-head>strong{color:#dce9f4;font-size:12px}.datastore-progress{height:10px;border-radius:99px;overflow:hidden;background:#363b44}.datastore-progress span{display:block;height:100%;width:0;border-radius:99px;background:linear-gradient(90deg,#60a9e6,#f4c430);transition:width .5s ease}.datastore-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.datastore-categories{display:grid;gap:13px}.category-row-head{display:flex;justify-content:space-between;gap:10px;color:#cbd1db;font-size:12px}.category-row-head strong{color:#f1f5fa}.category-track{height:7px;margin:6px 0 4px;border-radius:99px;overflow:hidden;background:#363b44}.category-track span{display:block;height:100%;border-radius:99px;background:linear-gradient(90deg,#60a9e6,#159447);transition:width .5s ease}.category-row small{color:#7f8996;font-size:10px}.raid-status{padding:5px 9px;border-radius:999px;font-size:11px;font-weight:800}.raid-status.ok{background:#126f39;color:#d9ffe8}.raid-status.rebuilding{background:#245a91;color:#d9efff}.raid-status.degraded{background:#806b25;color:#fff3bd}.raid-status.failed{background:#7a3030;color:#ffd9d9}.raid-status.unknown{background:#3b3b3b;color:#aaa}.raid-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:14px}.raid-summary span{padding:9px;border:1px solid rgba(255,255,255,.07);border-radius:8px;background:rgba(10,14,20,.2)}.raid-summary small,.raid-summary strong{display:block}.raid-summary small{color:#7f8996;font-size:10px}.raid-summary strong{margin-top:3px;color:#e8eef5;font-size:14px}.raid-disks{display:grid;gap:7px}.raid-disk{display:flex;align-items:center;gap:9px;padding:9px 10px;border:1px solid rgba(255,255,255,.07);border-radius:8px;background:rgba(40,43,49,.65)}.raid-disk>div{min-width:0;flex:1}.raid-disk strong,.raid-disk small{display:block}.raid-disk strong{color:#dce9f4;font-size:12px}.raid-disk small{margin-top:2px;color:#7f8996;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.raid-disk b{font-size:10px;text-transform:uppercase;color:#b8c2cd}.disk-status{width:9px;height:9px;flex:0 0 auto;border-radius:50%;background:#929292}.disk-status.active{background:#6ae29a;box-shadow:0 0 9px #6ae29a}.disk-status.failed{background:#ff8585;box-shadow:0 0 9px #ff8585}.disk-status.spare{background:#b9b9b9}.disk-status.rebuilding{background:#60a9e6;box-shadow:0 0 9px #60a9e6}.disk-status.missing{background:#f4c430}.raid-recovery{margin-bottom:14px;padding:12px;border:1px solid rgba(96,169,230,.2);border-radius:9px;background:rgba(48,69,92,.22)}.recovery-head,.recovery-meta{display:flex;justify-content:space-between;gap:12px}.recovery-head{margin-bottom:8px;color:#ddecfa;font-size:12px}.recovery-meta{margin-top:8px;color:#9da5b2;font-size:10px}.recovery-meta strong{color:#e8eef5}.datastore-empty{padding:16px;text-align:center;color:#7f8996;font-size:11px}@media(max-width:900px){.datastore-storage{grid-template-columns:repeat(2,1fr)}.datastore-grid{grid-template-columns:1fr}}@media(max-width:600px){.datastore-hero{alignLine truncated
|
||||
</style>
|
||||
<script>
|
||||
document.querySelectorAll('#movies tbody tr td:nth-child(5) .badge,#sonarr tbody tr td:nth-child(5) .badge').forEach(badge=>{if(badge.textContent.includes('Deutsch'))badge.classList.add('subtitle-de')});
|
||||
document.querySelectorAll('#movies tbody tr,#sonarr tbody tr').forEach(row=>{if(row.querySelector('.state.downloading'))row.children[1].querySelector('.badge')?.classList.add('downloading-badge')});
|
||||
@@ -100,6 +104,7 @@ const diskList=document.querySelector('#diskList');
|
||||
function renderSystem(data){const cpu=Number(data.cpu_percent)||0,ram=data.memory||{};const cpuValue=document.querySelector('#cpuValue'),ramValue=document.querySelector('#ramValue'),cpuBar=document.querySelector('#cpuBar'),ramBar=document.querySelector('#ramBar'),updated=document.querySelector('#systemUpdated');if(cpuValue)cpuValue.textContent=`${cpu.toFixed(1)}%`;if(ramValue)ramValue.textContent=`${ram.used||'—'} / ${ram.total||'—'} (${Number(ram.percent||0).toFixed(1)}%)`;if(cpuBar)cpuBar.style.width=`${Math.min(100,cpu)}%`;if(ramBar)ramBar.style.width=`${Math.min(100,Number(ram.percent)||0)}%`;if(updated)updated.textContent='Gerade aktualisiert';if(!diskList)return;diskList.innerHTML=(data.disks||[]).map(d=>`<div class="disk-row"><div class="disk-name">${html(d.label)}<span class="disk-path">${html(d.path)}</span></div><div class="disk-bar"><span style="width:${Math.min(100,Number(d.percent)||0)}%"></span></div><div class="disk-values">${html(d.used)} / ${html(d.total)}<br><span class="muted">${Number(d.percent||0).toFixed(1)}% belegt</span></div></div>`).join('')||'<div class="disk-row muted">Keine Laufwerke gefunden</div>'}
|
||||
async function fetchSystem(){try{const response=await fetch('/api/system');renderSystem(await response.json())}catch(error){const updated=document.querySelector('#systemUpdated');if(updated)updated.textContent='Nicht verfügbar'}}
|
||||
if(document.querySelector('#dashboard')){fetchSystem();setInterval(fetchSystem,5000)}
|
||||
const datastoreView=document.querySelector('#datastore');function renderDatastore(data){const storage=data.storage||{},raid=data.raid||{};const set=(id,value)=>{const node=document.querySelector(id);if(node)node.textContent=value};set('#datastoreTotal',storage.total||'—');set('#datastoreUsed',storage.used||'—');set('#datastoreFree',storage.free||'—');set('#datastorePercent',storage.percent!=null?`${storage.percent}%`:'—');set('#datastorePath',storage.path||'/nesflix');set('#datastoreCapacityLabel',storage.percent!=null?`${storage.used||'—'} / ${storage.total||'—'} · ${storage.percent}%`:storage.error||'Nicht verfügbar');set('#datastoreUpdated',storage.scanned_at?'Gerade aktualisiert':'Nicht verfügbar');const capacityBar=document.querySelector('#datastoreCapacityBar');if(capacityBar)capacityBar.style.width=`${Math.min(100,Number(storage.percent)||0)}%`;const categories=document.querySelector('#datastoreCategories');if(categories){const values=(storage.categories||[]).filter(category=>category.bytes>0||['movies','series','anime','kdrama','downloads'].includes(category.key));categories.innerHTML=values.length?values.map(category=>`<div class="category-row"><div class="category-row-head"><span>${html(category.label)}</span><strong>${html(category.size||'0 B')}</strong></div><div class="category-track"><span style="width:${Math.min(100,Number(category.percent)||0)}%"></span></div><small>${Number(category.percent||0).toFixed(1)}% vom belegten Speicher · ${Number(category.files||0).toLocaleString('de-DE')} Dateien</small></div>`).join(''):'<div class="datastore-empty">Keine Kategorien gefunden</div>'}const badge=document.querySelector('#raidStatusBadge');if(badge){badge.textContent=raid.label||'UNKNOWN';badge.className=`raid-status ${raid.status||'unknown'}`}set('#raidDevice',raid.device||'/dev/md127');const summary=document.querySelector('#raidSummary');if(summary)summary.innerHTML=`<span><small>Level</small><strong>${html(raid.level||'—')}</strong></span><span><small>Aktiv</small><strong>${html(raid.active_devices??'—')} / ${html(raid.raid_devices??'—')}</strong></span><span><small>Fehlend</small><strong>${html(raid.failed_devices??0)}</strong></span>`;const recovery=raid.recovery||{},recoveryBox=document.querySelector('#raidRecovery');if(recoveryBox){recoveryBox.classList.toggle('hidden',!recovery.active);set('#raidRecoveryPercent',`${Number(recovery.percent||0).toFixed(1)}%`);set('#raidRecoverySpeed',recovery.speed||'—');set('#raidRecoveryFinish',recovery.finish||'—');const recoveryBar=document.querySelector('#raidRecoveryBar');if(recoveryBar)recoveryBar.style.width=`${Math.min(100,Number(recovery.percent)||0)}%`}const disks=document.querySelector('#raidDisks');if(disks){disks.innerHTML=(raid.devices||[]).length?(raid.devices||[]).map(disk=>`<div class="raid-disk"><span class="disk-status ${html(disk.status||'unknown')}"></span><div><strong>${html(disk.device)}</strong><small>Slot ${html(disk.slot||'—')} · ${html(disk.model||'—')}</small><small>${html(disk.size||'—')} · Serial: ${html(disk.serial||'—')}</small></div><b>${html(disk.status||'unknown')}</b></div>`).join(''):'<div class="datastore-empty">Keine RAID-Mitglieder erkannt</div>'}if(raid.error){const node=document.querySelector('#raidDisks .datastore-empty');if(node)node.textContent=raid.error}}async function fetchDatastore(){if(!datastoreView)return;try{renderDatastore(await (await fetch('/api/datastore')).json())}catch(error){renderDatastore({storage:{error:'Datastore nicht erreichbar'},raid:{status:'failed',label:'FAILED',error:'RAID-Status nicht verfügbar'}})}}if(datastoreView){fetchDatastore();setInterval(fetchDatastore,5000)}
|
||||
</script>
|
||||
<style>
|
||||
.season-divider td{padding:6px 10px;background:#1b1b1b;border-top:1px solid #444;border-bottom:1px solid #303030;color:#aaa;font-size:11px;font-weight:700;letter-spacing:.3px}
|
||||
|
||||
Reference in new issue
Block a user