Universal-Sprint: Wizard, UI-Mounts, Encoder-Erkennung, Task-Split, README
Ampel / ampel (push) Successful in 29s
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:
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user