Universal-Sprint: Wizard, UI-Mounts, Encoder-Erkennung, Task-Split, README
Ampel / ampel (push) Successful in 29s

Commander-Ziel: All-in-one, universell, weitergebbar.

- First-Run-Wizard: startet automatisch bei neuer Installation (Keys,
  Verarbeitung, erkannte Hardware); /setup + /setup/complete
- Data-Mounts via UI: Einstellungen -> Speicherziele haengt NFS/SMB direkt
  ein (mounts.py, CAP_SYS_ADMIN + rshared-Propagation, Auto-Remount beim
  Start, CIFS-Creds via Datei statt Kommandozeile); nfs-common/cifs-utils
  im api-Image
- Encoder-Erkennung: jeder Worker meldet beim Start ehrlich seine
  Faehigkeiten (caps.py -> workers-Tabelle), GET /capabilities, Anzeige
  in Wizard + Verarbeitung-Tab
- Task-Split: transcode_files als eigener Task auf Queue "transcode"
  (Basis fuer optionale Remote-GPU-Worker, deploy/remote-transcode-worker.yml
  EXPERIMENTELL) + POST /jobs/{id}/retry-transcode + UI-Knopf
  "Neu komprimieren" bei fehlgeschlagenen Jobs
- API-Keys aus der DB: Settings-UI/Wizard ueberstimmen Env — vorher waren
  die Key-Felder im UI reine Dekoration (Clients lasen nur Env)
- README komplett neu: generischer Schnellstart, Laufwerk-Override via
  docker-compose.override.yml, Architektur, Env-Tabelle

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-23 20:49:08 +02:00
parent e350523f7e
commit b584cc29ad
20 changed files with 984 additions and 154 deletions
+7 -1
View File
@@ -3,7 +3,13 @@ FROM python:3.12-slim-bookworm
WORKDIR /app
# udev ist raus (23.07.): udevadm lieferte im Container nie Daten (kein udevd) —
# die Geräte-Erkennung läuft jetzt über /sys + ioctls, ganz ohne Systempakete.
# die Geräte-Erkennung läuft jetzt über /sys + ioctls.
# nfs-common/cifs-utils: Netzwerk-Speicherziele werden aus dem UI heraus
# eingehängt (mounts.py, braucht CAP_SYS_ADMIN aus dem Compose).
RUN apt-get update && apt-get install -y --no-install-recommends \
nfs-common \
cifs-utils \
&& rm -rf /var/lib/apt/lists/*
COPY docker/api/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
+3 -1
View File
@@ -32,7 +32,9 @@ def parse_year(year: str) -> Optional[int]:
class OMDbClient:
def __init__(self):
self.api_key = settings.omdb_api_key
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable
from db import get_settings
self.api_key = get_settings().get("omdbApiKey") or settings.omdb_api_key
self.session = requests.Session()
def lookup(self, title: str, year: Optional[int] = None) -> Optional[Dict]:
+3 -1
View File
@@ -12,7 +12,9 @@ THETVDB_BASE_URL = "https://api.thetvdb.com"
class TheTVDBClient:
def __init__(self):
self.api_key = settings.thetvdb_api_key
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable
from db import get_settings
self.api_key = get_settings().get("tvdbApiKey") or settings.thetvdb_api_key
self.base_url = THETVDB_BASE_URL
self.session = requests.Session()
self.session.headers.update({
+4 -1
View File
@@ -13,7 +13,10 @@ TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p"
class TMDBClient:
def __init__(self):
self.api_key = settings.tmdb_api_key
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable —
# vorher war das Settings-Feld reine Dekoration (Fix 23.07.).
from db import get_settings
self.api_key = get_settings().get("tmdbApiKey") or settings.tmdb_api_key
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"Bearer {self.api_key}",
+65
View File
@@ -64,6 +64,63 @@ settings_table = Table(
Column("value", Text),
)
workers = Table(
"workers",
metadata,
Column("name", String(128), primary_key=True),
Column("encoders", Text),
Column("last_seen", DateTime(timezone=True)),
)
storage_mounts = Table(
"storage_mounts",
metadata,
Column("name", String(64), primary_key=True),
Column("typ", String(8)), # nfs | cifs
Column("quelle", String(255)), # host:/export bzw. //host/share
Column("optionen", String(255)),
Column("username", String(128)),
Column("passwort", String(255)), # Klartext — Heimnetz-Kompromiss, siehe README
)
def list_workers() -> list:
import json
with engine.connect() as conn:
zeilen = conn.execute(select(workers)).mappings().all()
ergebnis = []
for z in zeilen:
eintrag = dict(z)
try:
eintrag["encoders"] = json.loads(eintrag.get("encoders") or "[]")
except ValueError:
eintrag["encoders"] = []
if eintrag.get("last_seen"):
eintrag["last_seen"] = eintrag["last_seen"].isoformat()
ergebnis.append(eintrag)
return ergebnis
def list_mounts() -> list:
with engine.connect() as conn:
return [dict(z) for z in conn.execute(select(storage_mounts)).mappings().all()]
def save_mount(name: str, typ: str, quelle: str, optionen: str, username: str, passwort: str) -> None:
with engine.begin() as conn:
conn.execute(
storage_mounts.insert().values(
name=name, typ=typ, quelle=quelle,
optionen=optionen, username=username, passwort=passwort,
)
)
def delete_mount(name: str) -> None:
with engine.begin() as conn:
conn.execute(storage_mounts.delete().where(storage_mounts.c.name == name))
def utcnow() -> datetime:
return datetime.now(timezone.utc)
@@ -97,6 +154,14 @@ def insert_job(
)
def get_job(job_id: str) -> dict:
with engine.connect() as conn:
zeile = conn.execute(
select(jobs).where(jobs.c.id == job_id)
).mappings().first()
return dict(zeile) if zeile else None
def list_jobs(limit: int = 100) -> list:
with engine.connect() as conn:
zeilen = conn.execute(
+127 -1
View File
@@ -12,7 +12,8 @@ import uuid
import db
import devices as device_discovery
from celery_client import start_rip
import mounts as mount_verwaltung
from celery_client import celery_client, start_rip
from detection import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN, drive_status
from fastapi.security import OAuth2PasswordBearer
@@ -60,6 +61,12 @@ async def startup_event():
except ConfigValidationError as e:
print(f"⚠️ Konfigurations-Warnung: {e}")
# Gespeicherte Netzwerk-Speicherziele wiederherstellen
def remount():
for meldung in mount_verwaltung.alle_remounten():
db.add_log("info", "mounts", meldung)
await asyncio.to_thread(remount)
asyncio.create_task(disc_watcher())
@@ -316,6 +323,125 @@ async def eject_device(name: str):
return {"status": "ejected", "device": device_path}
@app.post("/jobs/{job_id}/retry-transcode")
async def retry_transcode(job_id: str):
"""Stößt die Kompression eines Jobs neu an — OHNE die Disc neu zu rippen.
Voraussetzung: die Rohdateien liegen noch in /app/temp/raw/<job_id>
(bei Kompressions-Fehlschlägen bleiben sie dort absichtlich erhalten).
"""
job = await asyncio.to_thread(db.get_job, job_id)
if not job:
raise HTTPException(status_code=404, detail="Job nicht gefunden")
if job["status"] in ("running", "pending"):
raise HTTPException(status_code=409, detail="Job rippt noch")
raw_dir = f"/app/temp/raw/{job_id}"
basis = job.get("target_dir") or f"{MEDIA_ROOT}/{job.get('disc_type') or 'bluray'}"
final_dir = f"{basis}/{job_id}"
celery_client.send_task(
"worker.tasks.transcode_files",
args=[job_id, raw_dir, final_dir],
queue="transcode",
)
await asyncio.to_thread(db.update_job, job_id, status="transcoding", progress=0, error=None)
await asyncio.to_thread(db.add_log, "info", "api", f"Job {job_id}: Kompression neu eingereiht")
return {"id": job_id, "status": "transcoding"}
@app.get("/capabilities")
async def capabilities():
"""Welche Encoder sind auf welchen Workern WIRKLICH verfügbar?
Jeder Worker meldet sich beim Start selbst (caps.py) — auch optionale
Remote-GPU-Worker tauchen hier automatisch auf.
"""
return {"workers": await asyncio.to_thread(db.list_workers)}
class MountRequest(BaseModel):
name: str
type: str # nfs | cifs
source: str # host:/export bzw. //host/share
options: Optional[str] = None
username: Optional[str] = None
password: Optional[str] = None
@app.get("/storage-mounts")
async def get_storage_mounts():
"""Konfigurierte Netzwerk-Speicherziele inkl. Live-Mount-Status."""
eintraege = await asyncio.to_thread(db.list_mounts)
return [
{
"name": e["name"],
"type": e["typ"],
"source": e["quelle"],
"mounted": mount_verwaltung.ist_gemountet(e["name"]),
"has_credentials": bool(e.get("username")),
}
for e in eintraege
]
@app.post("/storage-mounts", status_code=201)
async def create_storage_mount(request: MountRequest):
"""Hängt ein NFS/SMB-Ziel ein und speichert es für den nächsten Start."""
if not mount_verwaltung.validiere_name(request.name):
raise HTTPException(status_code=422, detail="Name: nur a-z, 0-9, Bindestrich (2-31 Zeichen)")
if request.type not in ("nfs", "cifs"):
raise HTTPException(status_code=422, detail="Typ muss nfs oder cifs sein")
if any(e["name"] == request.name for e in await asyncio.to_thread(db.list_mounts)):
raise HTTPException(status_code=409, detail="Name bereits vergeben")
try:
await asyncio.to_thread(
mount_verwaltung.mounten,
request.name, request.type, request.source,
request.options or "", request.username or "", request.password or "",
)
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e))
await asyncio.to_thread(
db.save_mount,
request.name, request.type, request.source,
request.options or "", request.username or "", request.password or "",
)
await asyncio.to_thread(
db.add_log, "success", "mounts",
f"Speicherziel '{request.name}' ({request.type}) eingehängt: {request.source}",
)
return {"name": request.name, "mounted": True}
@app.delete("/storage-mounts/{name}")
async def delete_storage_mount(name: str):
"""Hängt ein Netzwerk-Speicherziel aus und entfernt es aus der Konfiguration."""
try:
await asyncio.to_thread(mount_verwaltung.aushaengen, name)
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e))
await asyncio.to_thread(db.delete_mount, name)
await asyncio.to_thread(db.add_log, "info", "mounts", f"Speicherziel '{name}' entfernt")
return {"status": "removed"}
@app.get("/setup")
async def setup_status():
"""First-Run-Erkennung: wurde der Einrichtungs-Assistent abgeschlossen?"""
einstellungen = await asyncio.to_thread(db.get_settings, "setup")
return {"done": bool(einstellungen.get("done"))}
@app.post("/setup/complete")
async def setup_complete():
await asyncio.to_thread(db.save_settings, {"done": True}, "setup")
await asyncio.to_thread(db.add_log, "success", "setup", "Einrichtungs-Assistent abgeschlossen")
return {"done": True}
@app.get("/logs")
async def get_logs(limit: int = 200):
"""Echte Ereignisse aus der Datenbank (Watcher, API, Worker)."""
+109
View File
@@ -0,0 +1,109 @@
"""Netzwerk-Speicherziele (NFS/SMB) direkt aus Rippy heraus einhängen.
Commander-Anforderung 23.07.: Data-Mounts über das UI, universell für jede
Installation. Der api-Container läuft dafür mit CAP_SYS_ADMIN und einem
rshared-Bind auf /app/media — ein Mount hier propagiert über den Host in
alle anderen Container (Worker sieht das Ziel sofort).
Zugangsdaten liegen in Postgres (Klartext — bewusster Heimnetz-Kompromiss,
im README dokumentiert; SMB-Credentials wandern NIE in die Kommandozeile,
sondern über eine temporäre credentials-Datei).
"""
import os
import re
import subprocess
import tempfile
import db
MEDIA_ROOT = "/app/media"
NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$")
def _mountpoint(name: str) -> str:
return os.path.join(MEDIA_ROOT, name)
def validiere_name(name: str) -> bool:
return bool(NAME_MUSTER.match(name))
def ist_gemountet(name: str) -> bool:
return os.path.ismount(_mountpoint(name))
def mounten(name: str, typ: str, quelle: str, optionen: str = "",
username: str = "", passwort: str = "") -> None:
"""Hängt ein NFS/CIFS-Ziel unter /app/media/<name> ein. Wirft RuntimeError."""
ziel = _mountpoint(name)
os.makedirs(ziel, exist_ok=True)
if os.path.ismount(ziel):
return
creds_datei = None
try:
if typ == "nfs":
opts = optionen or "vers=4,soft,timeo=100"
cmd = ["mount", "-t", "nfs", "-o", opts, quelle, ziel]
elif typ == "cifs":
teile = [optionen] if optionen else []
if username:
# Credentials über Datei statt Kommandozeile (ps-sichtbar!)
creds = tempfile.NamedTemporaryFile(
"w", delete=False, prefix="cifs-", suffix=".cred"
)
creds.write(f"username={username}\npassword={passwort or ''}\n")
creds.close()
os.chmod(creds.name, 0o600)
creds_datei = creds.name
teile.append(f"credentials={creds.name}")
else:
teile.append("guest")
teile.append("iocharset=utf8")
cmd = ["mount", "-t", "cifs", "-o", ",".join(teile), quelle, ziel]
else:
raise RuntimeError(f"Unbekannter Typ: {typ} (nfs oder cifs)")
ergebnis = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if ergebnis.returncode != 0:
fehler = (ergebnis.stderr or ergebnis.stdout or "").strip()
raise RuntimeError(f"mount schlug fehl: {fehler[:300]}")
finally:
if creds_datei:
try:
os.unlink(creds_datei)
except OSError:
pass
def aushaengen(name: str) -> None:
ziel = _mountpoint(name)
if os.path.ismount(ziel):
ergebnis = subprocess.run(
["umount", ziel], capture_output=True, text=True, timeout=30
)
if ergebnis.returncode != 0:
raise RuntimeError(
f"umount schlug fehl: {(ergebnis.stderr or '').strip()[:300]}"
)
try:
os.rmdir(ziel)
except OSError:
pass # nicht leer oder weg — egal
def alle_remounten() -> list:
"""Beim API-Start: alle gespeicherten Mounts wiederherstellen."""
meldungen = []
for eintrag in db.list_mounts():
try:
mounten(
eintrag["name"], eintrag["typ"], eintrag["quelle"],
eintrag.get("optionen") or "", eintrag.get("username") or "",
eintrag.get("passwort") or "",
)
meldungen.append(f"{eintrag['name']}: eingehängt")
except Exception as e:
meldungen.append(f"{eintrag['name']}: FEHLER — {e}")
return meldungen