diff --git a/README.md b/README.md index c56eef0..ffd3a3f 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,11 @@ NICHT als emuliertes CD-ROM (`media=cdrom`), das kann keine SCSI-Kommandos. sie mit Poster auf dem Dashboard. Klick auf einen Job-Titel öffnet die Detail-Ansicht (Poster, Jahr, Beschreibung, Ablagepfad). 2. **Rip** (MakeMKV, verlustfrei — der einzige Weg durch AACS): Ziel wählst - du beim Start (Filme/Serien/Musik/eigener Pfad, inkl. Netzwerk-Ziele). - Vor dem Start prüft Rippy den freien Platz gegen die Disc-Größe. + du beim Start (Filme/Serien/Musik/eigener Pfad, inkl. Netzwerk-Ziele) — + oder du stellst die **Vollautomatik** an (Einstellungen → Ripping) und + der Rip startet ohne Nachfrage in den passenden Schnellwahl-Ordner. + Vor dem Start prüft Rippy den freien Platz gegen die Disc-Größe. Alle + Tonspuren und Untertitel bleiben erhalten (wichtig für Anime/O-Ton). Audio-CDs laufen über abcde → FLAC + MusicBrainz. 3. **Kompression** (HandBrake, eigener Job auf eigener Queue): x265/x264, Preset im UI wählbar; Rohdatei wird erst nach Erfolg gelöscht @@ -119,7 +122,8 @@ eingebaute CPU-Worker alles selbst — Rippy bleibt All-in-one. | Variable | Pflicht | Zweck | |---|---|---| | `JWT_SECRET_KEY` | ✔ | Signierschlüssel (openssl rand -hex 32) | -| `TMDB_API_KEY` | empfohlen | Metadaten — alternativ im UI/Wizard eintragbar | +| `TMDB_API_KEY` | empfohlen | Metadaten (deutsche Texte) — alternativ im UI/Wizard; beide Key-Arten gehen (v3-Schlüssel und v4-Token) | +| `WORKER_NAME` | optional | Anzeigename des eingebauten Workers (Standard: rippy-hauptworker) | | `OMDB_API_KEY` | optional | zweite Metadaten-Quelle (Fallback) | | `THETVDB_API_KEY` | optional | Serien-Fallback | | `MAKEMKV_APP_KEY` | optional | MakeMKV-Beta-Key (Forum); DVDs gehen ohne — bequemer: im UI unter Einstellungen → System pflegen | diff --git a/ROADMAP.md b/ROADMAP.md index 24f6cbd..3920765 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -342,6 +342,25 @@ auffiel, in einem Rutsch. Alles gebaut, Tests dabei, Ampel-Blocker behoben. --- +## Etappe 14 (24.07.2026): Praxis-Feedback-Runde — erste echte Nutzung + +**Quelle:** Commander-Feedback nach dem ersten richtigen Arbeiten mit v3.2. + +- [x] SMB-Mount-Blocker: CAP_DAC_READ_SEARCH für mount.cifs (auf der VM + reproduziert + bewiesen), Compose-Fix. +- [x] TMDB-Key-Falle: v3-Schlüssel wurden still 401 (Client konnte nur + v4-Bearer) — beide Arten unterstützt, „Verbindung prüfen" im UI. +- [x] 4K UHD als eigener Disc-Typ (≥ 55 GiB) mit eigener Farbe + Klartext- + Fehler bei Nicht-LibreDrive-Laufwerken. +- [x] Vollautomatik-Setting (Disc rein → Rip startet ohne Popup). +- [x] Job-Verwaltung: can_retry (Knopf nur bei vorhandenen Rohdaten), + Einzel-Löschen, „Erledigte aufräumen", „Alle herunterladen". +- [x] Worker: WORKER_NAME-Anzeigename + IP/ID, verwaiste Einträge löschbar. +- [x] Ripping-Tab nach Medium (Video/Audio/Allgemein), Untertitel-Klartext, + CD → Musik-Vorauswahl im Ziel-Dialog, Ordner-Verwaltung eingeklappt. + +--- + ## Ideen-Katalog (24.07.2026, priorisiert) — Lücken, die noch offen sind 1. **Serien-Staffel-Flow**: Beim Rippen einer Serien-Disc Staffel/Disc-Nr @@ -359,3 +378,11 @@ auffiel, in einem Rutsch. Alles gebaut, Tests dabei, Ampel-Blocker behoben. 7. **Rip-Historie exportieren** (CSV/JSON) für die Sammlung-Übersicht. 8. **Design 2.0**: Design-Tokens statt Theme-Ternaries in jeder Zeile — die `theme === 'dark' ? … : …`-Kaskaden sind der größte UI-Schuldenberg. +9. **Nativer Windows-Worker (ohne Docker)**: Endnutzer installieren kein + Docker Desktop nur für den Transcode-Worker. Realistischer Weg: ein + PyInstaller-Paket (Celery solo-Pool + HandBrakeCLI.exe gebündelt) als + Windows-Dienst, Anbindung wie gehabt über Redis. Aufwand: mittel; + Celery unter Windows nur im solo/threads-Pool verlässlich. +10. **Deutsche Texte auch für OMDb-Treffer**: OMDb liefert nur Englisch. + Wenn OMDb/Jikan die Quelle war, per TMDB-Find (imdb_id → TMDB-Eintrag, + language=de-DE) die deutsche Beschreibung nachladen. diff --git a/SAVEPOINT.md b/SAVEPOINT.md index 0fab9f6..cf83106 100644 --- a/SAVEPOINT.md +++ b/SAVEPOINT.md @@ -1,6 +1,49 @@ # SAVEPOINT — Rippy -## Aktueller Stand: v3.2 — Universal-Komfort-Runde + Ampel entrostet (24.07.2026, Claude) +## Aktueller Stand: v3.3 — Praxis-Feedback-Runde (24.07.2026, Claude) + +**Zwei echte Bugs mit Beweis gefixt:** +1. **SMB-Mount „Unable to apply new capability set"**: mount.cifs hebt + CAP_DAC_READ_SEARCH an — die fehlt in Dockers Default-Caps. Auf der VM + reproduziert (Bounding-Set a82425fb, Bit 2 fehlt) und mit + `cap_add: DAC_READ_SEARCH` bewiesen behoben (docker-compose.yml). +2. **TMDB fiel still aus**: Der Client konnte nur v4-Bearer-Tokens — der + eingetragene übliche v3-Key (32 Hex) bekam still 401, Suche lieferte nur + OMDb. Jetzt beide Key-Arten (ist_v4_token, mit Tests); Einstellungen → + APIs hat „Verbindung prüfen" mit Live-Status je Quelle (am Cache vorbei). + +**Neu in v3.3:** +- **4K UHD als eigener Disc-Typ** (classify ≥ 55 GiB, beide detection.py, + Tests): eigene Badge-Farbe überall, Prescan-Label „4K UHD". Der + UHD-Rip-Fehler „Failed to open disc" (Code 11) bekommt Klartext: + Laufwerk kann UHD nicht entschlüsseln → LibreDrive-Firmware-Hinweis + (BU40N-Crossflash-Faden bleibt Hardware-Aufgabe des Commanders). +- **Vollautomatik** (Einstellungen → Ripping): Disc erkannt → Rip startet + ohne Popup in den passenden Schnellwahl-Ordner (Serie/Film/Musik). +- **Job-Verwaltung**: „Neu komprimieren" nur noch, wenn Rohdaten wirklich + daliegen (can_retry); Jobs einzeln löschbar (Papierkorb) + „Erledigte + aufräumen" mit Bestätigungs-Dialog — Dateien bleiben immer liegen. +- **„Alle herunterladen"** im Job-Detail (gestaffelte Einzel-Downloads — + bewusst kein Server-seitiges 40-GB-Zip). +- **Worker zuordenbar**: WORKER_NAME-Env als stabiler Anzeigename (compose: + rippy-hauptworker; Remote-Worker: frei wählbar) — fixt zugleich die + Offline-Leichen nach Rebuilds; IP + Container-ID werden mit angezeigt, + verwaiste Einträge sind löschbar (DELETE /workers/{name}). Online-Abgleich + läuft jetzt über info.hostname. +- **Disc-Karte**: „Quelle: TMDB/OMDb/MyAnimeList · xx % sicher" statt des + nackten „Übereinstimmung xx %"; TMDB-Metadaten sind Deutsch (language= + de-DE war schon überall dran — sie kamen nur nie an, siehe Key-Bug). +- **Ripping-Tab nach Medium** gegliedert (Video / Audio-CD / Allgemein) + + Klartext: alle Tonspuren & Untertitel bleiben erhalten (--all-audio/ + --all-subtitles) — wichtig für Anime. +- **Ordner-Verwaltung** (Ex-„Dateibrowser") ist jetzt beschriftet, erklärt + und standardmäßig eingeklappt. +- **Docker-Pflicht beim Worker**: ehrlich im UI beantwortet; nativer + Windows-Dienst steht als Ausbaustufe in der ROADMAP (Ideen-Katalog). + +--- + +## Vorheriger Stand: v3.2 — Universal-Komfort-Runde + Ampel entrostet (24.07.2026, Claude) **Wichtigster Befund zuerst: die Ampel war seit dem 23.07. ROT und `stable` hing 10 Commits hinter `main`** — deshalb kam nichts Neues mehr auf die VM. diff --git a/deploy/remote-transcode-worker.yml b/deploy/remote-transcode-worker.yml index 91083ec..9a0ba10 100644 --- a/deploy/remote-transcode-worker.yml +++ b/deploy/remote-transcode-worker.yml @@ -25,6 +25,9 @@ services: - DATABASE_URL=postgresql://rippy:rippy@${RIPPY_HOST}:5432/rippy - RIP_OUTPUT_DIR=/app/media - RAW_DIR=/app/temp/raw + # Anzeigename in Einstellungen → Worker — z. B. den Maschinennamen + # setzen: WORKER_NAME=gaming-pc docker compose -f … up -d + - WORKER_NAME=${WORKER_NAME:-transcode-worker} volumes: # Freigabe der Rippy-Maschine (siehe Kopf-Kommentar) - /mnt/rippy/media:/app/media diff --git a/docker-compose.yml b/docker-compose.yml index 9fe23cd..fda5dfa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,8 +29,13 @@ services: # SYS_ADMIN: die API hängt Netzwerk-Speicherziele (NFS/SMB) selbst ein # (mounts.py) — dank rshared-Propagation unten sehen Host UND Worker # jeden Mount sofort. + # DAC_READ_SEARCH: mount.cifs hebt diese Capability an (toggle_dac_ + # capability) — sie fehlt in Dockers Default-Set, ohne sie stirbt JEDER + # SMB-Mount mit "Unable to apply new capability set" (Befund 24.07., + # auf der VM reproduziert und mit dieser Capability bewiesen behoben). cap_add: - SYS_ADMIN + - DAC_READ_SEARCH security_opt: - apparmor:unconfined volumes: @@ -68,6 +73,9 @@ services: - REDIS_URL=redis://redis:6379/0 - RIP_OUTPUT_DIR=/app/media - MAKEMKV_APP_KEY=${MAKEMKV_APP_KEY} + # Anzeigename in Einstellungen → Worker (stabil über Rebuilds hinweg; + # via .env übersteuerbar, z. B. WORKER_NAME=wohnzimmer-vm) + - WORKER_NAME=${WORKER_NAME:-rippy-hauptworker} - LOG_LEVEL=INFO volumes: - type: bind diff --git a/docker/api/clients/tmdb.py b/docker/api/clients/tmdb.py index 9f3ee8b..2d7a5af 100644 --- a/docker/api/clients/tmdb.py +++ b/docker/api/clients/tmdb.py @@ -11,6 +11,15 @@ TMDB_BASE_URL = "https://api.themoviedb.org/3" TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p" +def ist_v4_token(key: str) -> bool: + """Pure Funktion (testbar): TMDB hat ZWEI Key-Arten — der v4 Read Access + Token ist ein langes JWT ('eyJ…', Bearer-Header), der klassische v3-Key + 32 Hex-Zeichen (api_key-Query-Parameter). Befund 24.07.: der Client konnte + NUR v4 — mit dem üblichen v3-Key aus dem TMDB-Konto war jede Anfrage 401 + und die Suche lieferte still nur OMDb-Treffer.""" + return bool(key) and key.startswith("eyJ") + + class TMDBClient: def __init__(self): # DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable — @@ -18,24 +27,29 @@ class TMDBClient: 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}", - "Content-Type": "application/json" - }) - + self.session.headers.update({"Content-Type": "application/json"}) + # Beide Key-Arten unterstützen (developer.themoviedb.org: v3 als + # api_key-Parameter, v4-Token als Bearer-Header) + self._key_params = {} + if ist_v4_token(self.api_key): + self.session.headers.update({"Authorization": f"Bearer {self.api_key}"}) + elif self.api_key: + self._key_params = {"api_key": self.api_key} + def _request(self, endpoint: str, params: Dict = None) -> Optional[Dict]: """Mache API-Request mit Caching.""" from cache.keys import generate_tmdb_key + # Cache-Key OHNE den api_key — der gehört nicht in den Cache cache_key = generate_tmdb_key(endpoint, params) - + cached = get(cache_key) if cached: return cached - + try: response = self.session.get( f"{TMDB_BASE_URL}/{endpoint}", - params=params, + params={**(params or {}), **self._key_params}, timeout=10 ) response.raise_for_status() diff --git a/docker/api/db.py b/docker/api/db.py index 73fa035..1407722 100644 --- a/docker/api/db.py +++ b/docker/api/db.py @@ -189,6 +189,27 @@ def list_jobs(limit: int = 100) -> list: return [dict(z) for z in zeilen] +def delete_job(job_id: str) -> None: + """Entfernt EINEN Job-Eintrag (nur die DB-Zeile — Dateien bleiben).""" + with engine.begin() as conn: + conn.execute(jobs.delete().where(jobs.c.id == job_id)) + + +def delete_finished_jobs() -> int: + """Räumt alle erledigten Jobs (completed/failed) aus der Liste. Dateien bleiben.""" + with engine.begin() as conn: + ergebnis = conn.execute( + jobs.delete().where(jobs.c.status.in_(("completed", "failed"))) + ) + return ergebnis.rowcount or 0 + + +def delete_worker(name: str) -> None: + """Entfernt einen (verwaisten) Worker-Eintrag aus der Liste.""" + with engine.begin() as conn: + conn.execute(workers.delete().where(workers.c.name == name)) + + def has_active_job(device: str) -> bool: """True, wenn auf dem Gerät ein Job läuft oder wartet (Eject-Schutz).""" with engine.connect() as conn: diff --git a/docker/api/detection.py b/docker/api/detection.py index 287d37e..687bde5 100644 --- a/docker/api/detection.py +++ b/docker/api/detection.py @@ -36,6 +36,10 @@ BLKGETSIZE64 = 0x80081272 # Eine DVD9 fasst ~8,5 GB; Blu-ray beginnt bei 25 GB (Single Layer). # Alles ab 10 GB ist also sicher eine Blu-ray. BLURAY_MIN_BYTES = 10 * 1024**3 +# 4K-UHD-Discs sind BD-66 (66 GB) oder BD-100 — eine normale BD-50 bleibt +# unter ~47 GiB. Ab 55 GiB ist es also sicher eine UHD. (Seltene 50-GB-UHDs +# laufen als "bluray" — der Rip-Weg ist ohnehin identisch.) +UHD_MIN_BYTES = 55 * 1024**3 def _open_nonblock(device_path: str) -> int: @@ -73,10 +77,12 @@ def disc_size_bytes(device_path: str) -> int: def classify(disc_status_code: int, size_bytes: int) -> str: - """Pure Zuordnung (testbar): Disc-Status + Größe → cd | dvd | bluray | unknown.""" + """Pure Zuordnung (testbar): Disc-Status + Größe → cd | dvd | bluray | uhd | unknown.""" if disc_status_code in (CDS_AUDIO, CDS_MIXED): return "cd" if disc_status_code in (CDS_DATA_1, CDS_DATA_2, CDS_XA_2_1, CDS_XA_2_2): + if size_bytes >= UHD_MIN_BYTES: + return "uhd" return "bluray" if size_bytes >= BLURAY_MIN_BYTES else "dvd" return "unknown" diff --git a/docker/api/main.py b/docker/api/main.py index ffc9336..2138e55 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -91,11 +91,56 @@ async def _auto_prescan(pfad: str): + (f" ({ergebnis.year})" if ergebnis.year else "") + f" [{ergebnis.disc_type}, Confidence {ergebnis.confidence:.0%}] auf {pfad}", ) + await _auto_rip_wenn_aktiviert(pfad) except Exception as e: DISC_CACHE.pop(pfad, None) print(f"Auto-Pre-Scan {pfad}: {e}") +async def _auto_rip_wenn_aktiviert(pfad: str): + """Vollautomatik (Setting autoRipStart): Disc erkannt → Rip startet sofort. + + Commander-Wunsch 24.07.: wahlweise Popup ODER Automatik. Ziel-Ordner + kommt aus den Schnellwahl-Einstellungen (Serie → seriesDir, sonst + movieDir; CD → musicDir) — genau wie ein Klick im Dialog. + """ + einstellungen = await asyncio.to_thread(db.get_settings) + if not einstellungen.get("autoRipStart"): + return + if await asyncio.to_thread(db.has_active_job, pfad): + return + disc = DISC_CACHE.get(pfad) or {} + if disc.get("_laeuft"): + return + + basis = einstellungen.get("outputDir") or MEDIA_ROOT + meta = disc.get("metadata") or {} + if disc.get("disc_type") == "CD": + unterordner = einstellungen.get("musicDir") or "music" + elif meta.get("type") == "tv": + unterordner = einstellungen.get("seriesDir") or "series" + else: + unterordner = einstellungen.get("movieDir") or "movies" + ziel = os.path.normpath(os.path.join(basis, unterordner)) + if not ziel.startswith(MEDIA_ROOT): + ziel = None + + job_id = str(uuid.uuid4()) + meta_json = json.dumps({ + "year": disc.get("year"), + "confidence": disc.get("confidence"), + **meta, + }) + await asyncio.to_thread( + db.insert_job, job_id, pfad, None, disc.get("title"), ziel, meta_json + ) + await asyncio.to_thread( + db.add_log, "info", "api", + f'Automatik: Rip für „{disc.get("title")}" gestartet ({pfad} → {ziel})', + ) + start_rip(pfad, job_id, ziel) + + async def disc_watcher(): """Disc-Wache: pollt die Laufwerke, protokolliert Einwurf/Auswurf und stößt beim Einlegen automatisch den Pre-Scan an (Dashboard-Disc-Karte). @@ -178,6 +223,7 @@ class Job(BaseModel): progress: int = 0 title: Optional[str] = None error: Optional[str] = None + can_retry: bool = False # Rohdaten vorhanden → „Neu komprimieren" sinnvoll class Device(BaseModel): id: str @@ -219,11 +265,52 @@ async def root(): } +def _kann_neu_komprimieren(job: dict, work_dir: str) -> bool: + """Nur wenn Rohdaten wirklich noch daliegen — der „Neu komprimieren"-Knopf + an einem Job, der nie gerippt hat, war Unsinn (Befund 24.07.).""" + if job.get("status") != "failed": + return False + if os.path.isdir(os.path.join("/app/temp/raw", job["id"])): + return True + return work_dir.startswith(MEDIA_ROOT) and os.path.isdir(os.path.join(work_dir, job["id"])) + + @app.get("/jobs", response_model=List[Job]) async def get_jobs(): """Holt alle Jobs aus der Datenbank (neueste zuerst).""" - zeilen = await asyncio.to_thread(db.list_jobs) - return [_job_row_to_model(z) for z in zeilen] + def sammle(): + work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/") + modelle = [] + for z in db.list_jobs(): + modell = _job_row_to_model(z) + modell.can_retry = _kann_neu_komprimieren(z, work_dir) + modelle.append(modell) + return modelle + + return await asyncio.to_thread(sammle) + + +@app.delete("/jobs/{job_id}") +async def delete_job(job_id: str): + """Entfernt einen erledigten Job aus der Liste (Dateien bleiben liegen).""" + 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"] not in ("completed", "failed"): + raise HTTPException(status_code=409, detail="Job läuft noch — erst abbrechen") + await asyncio.to_thread(db.delete_job, job_id) + await asyncio.to_thread(db.add_log, "info", "api", f"Job {job_id} aus der Liste entfernt") + return {"status": "deleted"} + + +@app.delete("/jobs") +async def delete_finished_jobs(): + """Räumt ALLE erledigten Jobs (fertig + fehlgeschlagen) aus der Liste.""" + anzahl = await asyncio.to_thread(db.delete_finished_jobs) + await asyncio.to_thread( + db.add_log, "info", "api", f"Job-Liste aufgeräumt ({anzahl} erledigte Einträge entfernt)" + ) + return {"deleted": anzahl} class JobCreateRequest(BaseModel): @@ -500,12 +587,67 @@ async def capabilities(): except Exception: online_namen = set() for zeile in zeilen: - zeile["online"] = zeile["name"] in online_namen + # Celery-Ping meldet den HOSTNAME des Knotens — bei gesetztem + # WORKER_NAME (Anzeigename) steckt der echte Hostname in info. + hostname = (zeile.get("info") or {}).get("hostname") or zeile["name"] + zeile["online"] = hostname in online_namen return zeilen return {"workers": await asyncio.to_thread(sammle)} +@app.delete("/workers/{name}") +async def delete_worker(name: str): + """Verwaisten Worker-Eintrag entfernen (alte Container-IDs nach Rebuilds). + + Ein AKTIVER Worker meldet sich binnen einer Minute einfach wieder an — + löschen ist also immer gefahrlos.""" + await asyncio.to_thread(db.delete_worker, name) + await asyncio.to_thread(db.add_log, "info", "api", f"Worker-Eintrag '{name}' entfernt") + return {"status": "deleted"} + + +@app.get("/metadata/status") +async def metadata_status(): + """Live-Prüfung der Metadaten-Quellen — beantwortet „funktioniert mein + Key?" sofort statt durch stilles Wegfallen einer Quelle.""" + def pruefe(): + from clients.omdb import OMDB_BASE_URL + from clients.tmdb import TMDB_BASE_URL + + status = {} + prescan = PreScan() + # Bewusst am Cache VORBEI — ein alter Treffer soll keinen kaputten + # Key als "ok" tarnen. /configuration ist der kleinste Auth-Aufruf. + if not prescan.tmdb.api_key: + status["tmdb"] = "kein_key" + else: + try: + antwort = prescan.tmdb.session.get( + f"{TMDB_BASE_URL}/configuration", + params=prescan.tmdb._key_params, timeout=10, + ) + status["tmdb"] = "ok" if antwort.status_code == 200 else "fehler" + except Exception: + status["tmdb"] = "fehler" + if not prescan.omdb.api_key: + status["omdb"] = "kein_key" + else: + try: + antwort = prescan.omdb.session.get( + OMDB_BASE_URL, + params={"apikey": prescan.omdb.api_key, "t": "Inception"}, + timeout=10, + ).json() + status["omdb"] = "ok" if antwort.get("Response") == "True" else "fehler" + except Exception: + status["omdb"] = "fehler" + status["jikan"] = "ok" # keyless — fällt nur bei Netzproblemen aus + return status + + return await asyncio.to_thread(pruefe) + + class MountRequest(BaseModel): name: str type: str # nfs | cifs diff --git a/docker/api/prescan/prescan.py b/docker/api/prescan/prescan.py index 96c3340..9ab2992 100644 --- a/docker/api/prescan/prescan.py +++ b/docker/api/prescan/prescan.py @@ -253,7 +253,7 @@ class PreScan: installiert, die Erkennung fiel still immer auf "DVD" zurück. """ typ = detection.detect_disc_type(device_path) - return {"cd": "CD", "dvd": "DVD", "bluray": "Blu-ray"}.get(typ, "DVD") + return {"cd": "CD", "dvd": "DVD", "bluray": "Blu-ray", "uhd": "4K UHD"}.get(typ, "DVD") def _read_toc(self, device_path: str, disc_type: str) -> Dict: """Lese TOC (Table of Contents).""" @@ -403,7 +403,8 @@ class PreScan: "poster_path": movie_details.get("poster_path", ""), "backdrop_path": movie_details.get("backdrop_path", ""), "runtime": movie_details.get("runtime", 0), - "genres": [g["name"] for g in movie_details.get("genres", [])] + "genres": [g["name"] for g in movie_details.get("genres", [])], + "source": "tmdb", } matched = True break @@ -426,7 +427,8 @@ class PreScan: "overview": tv_details.get("overview", ""), "poster_path": tv_details.get("poster_path", ""), "backdrop_path": tv_details.get("backdrop_path", ""), - "genres": [g["name"] for g in tv_details.get("genres", [])] + "genres": [g["name"] for g in tv_details.get("genres", [])], + "source": "tmdb", } matched = True break @@ -471,7 +473,8 @@ class PreScan: "poster_path": vorschlag.get("poster_path", ""), "backdrop_path": vorschlag.get("backdrop_path", ""), "runtime": vorschlag.get("runtime", 0), - "genres": [g["name"] for g in vorschlag.get("genres", [])] + "genres": [g["name"] for g in vorschlag.get("genres", [])], + "source": "tmdb", } matched = True diff --git a/docker/api/test_tmdb_helpers.py b/docker/api/test_tmdb_helpers.py new file mode 100644 index 0000000..d53984b --- /dev/null +++ b/docker/api/test_tmdb_helpers.py @@ -0,0 +1,20 @@ +"""Tests für die TMDB-Key-Erkennung (v3-Schlüssel vs. v4-Token). + +Befund 24.07.: Der Client konnte nur v4-Bearer — mit dem üblichen 32-Hex- +v3-Key war jede Anfrage 401 und TMDB fiel still aus (nur OMDb-Treffer). +""" + +from clients.tmdb import ist_v4_token + + +def test_v4_token_wird_erkannt(): + assert ist_v4_token("eyJhbGciOiJIUzI1NiJ9.irgendwas.signatur") is True + + +def test_v3_hex_key_ist_kein_v4_token(): + assert ist_v4_token("842abc0123456789842abc0123456789") is False + + +def test_leer_ist_kein_token(): + assert ist_v4_token("") is False + assert ist_v4_token(None) is False diff --git a/docker/ui/src/components/DeviceDiscovery.tsx b/docker/ui/src/components/DeviceDiscovery.tsx index 5212134..d364b21 100644 --- a/docker/ui/src/components/DeviceDiscovery.tsx +++ b/docker/ui/src/components/DeviceDiscovery.tsx @@ -15,13 +15,14 @@ interface DiscInfo { poster_path?: string overview?: string type?: string + source?: string } } interface Device { id: string name: string - type: 'cd' | 'dvd' | 'bluray' | 'unknown' + type: 'cd' | 'dvd' | 'bluray' | 'uhd' | 'unknown' path: string status: 'empty' | 'ready' | 'ripping' serial?: string @@ -112,10 +113,18 @@ export default function DeviceDiscovery() { case 'cd': return theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-600' case 'dvd': return theme === 'dark' ? 'bg-purple-900/30 text-purple-400' : 'bg-purple-100 text-purple-600' case 'bluray': return theme === 'dark' ? 'bg-pink-900/30 text-pink-400' : 'bg-pink-100 text-pink-600' + case 'uhd': return theme === 'dark' ? 'bg-amber-900/30 text-amber-400' : 'bg-amber-100 text-amber-700' default: return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600' } } + const typLabel = (type: string) => + ({ cd: 'CD', dvd: 'DVD', bluray: 'BLU-RAY', uhd: '4K UHD' } as Record)[type] || type.toUpperCase() + + const QUELLEN_LABEL: Record = { + tmdb: 'TMDB', omdb: 'OMDb', jikan: 'MyAnimeList', manuell: 'manuell gewählt', + } + const getStatusColor = (status: string) => { switch (status) { case 'empty': return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600' @@ -185,7 +194,8 @@ export default function DeviceDiscovery() { {device.disc!.disc_type?.toUpperCase()} - Übereinstimmung {Math.round((device.disc!.confidence || 0) * 100)} % + Quelle: {QUELLEN_LABEL[device.disc!.metadata?.source || ''] || device.disc!.metadata?.source || 'Disc-Label'} + {' · '}{Math.round((device.disc!.confidence || 0) * 100)} % sicher {device.disc!.metadata?.overview && ( @@ -266,7 +276,7 @@ export default function DeviceDiscovery() { 'Rippt'} - Typ: {device.type.toUpperCase()} + Typ: {typLabel(device.type)} @@ -293,7 +303,7 @@ export default function DeviceDiscovery() { {device.serial || '—'} Disc - {device.status === 'ready' ? `eingelegt (${device.type.toUpperCase()})` : 'keine'} + {device.status === 'ready' ? `eingelegt (${typLabel(device.type)})` : 'keine'} @@ -331,6 +341,7 @@ export default function DeviceDiscovery() { Pfad — dort eingehängte Shares sind direkt wählbar) */} setModalDevice(null)} onSave={(target) => { if (modalDevice) { diff --git a/docker/ui/src/components/JobDetailModal.tsx b/docker/ui/src/components/JobDetailModal.tsx index 198beb1..af50361 100644 --- a/docker/ui/src/components/JobDetailModal.tsx +++ b/docker/ui/src/components/JobDetailModal.tsx @@ -177,9 +177,33 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul {/* Download der fertigen Dateien — vorher kam man nur per scp dran */} {detail.status === 'completed' && dateien && dateien.length > 0 && (
-

- Dateien herunterladen -

+
+

+ Dateien herunterladen +

+ {dateien.length > 1 && ( + + )} +
{dateien.map(f => ( void onSave: (target: TargetConfig) => void } @@ -24,7 +25,7 @@ interface BrowseDir { // Ziel-Auswahl vor dem Rip: Schnellwahl (Filme/Serien/Musik) ODER frei per // Ordner-Browser — auch in eingehängte Netzwerk-Ziele (NAS, PC-Freigabe). -export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetModalProps) { +export default function RipTargetModal({ isOpen, initialType, onClose, onSave }: RipTargetModalProps) { const [selectedType, setSelectedType] = useState('movies') const [targets, setTargets] = useState([ { id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true }, @@ -41,6 +42,8 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod // Standard-Unterordner aus den Einstellungen ziehen (nicht hartkodiert) useEffect(() => { if (!isOpen) return + setSelectedType(initialType || 'movies') + setCustomPath('') api.get('/settings').then(r => { const s = r.data || {} const basis = s.outputDir || '/app/media' diff --git a/docker/ui/src/components/StorageMounts.tsx b/docker/ui/src/components/StorageMounts.tsx index 0f65c04..582b9ee 100644 --- a/docker/ui/src/components/StorageMounts.tsx +++ b/docker/ui/src/components/StorageMounts.tsx @@ -41,6 +41,7 @@ export default function StorageMounts() { const [browseDirs, setBrowseDirs] = useState<{ name: string, path: string }[]>([]) const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([]) const [neuerOrdner, setNeuerOrdner] = useState('') + const [browserOffen, setBrowserOffen] = useState(false) const { theme } = useDarkMode() const { toast } = useToast() @@ -188,9 +189,30 @@ export default function StorageMounts() { )}
- {/* Lokale Ordner durchsuchen + anlegen */} + {/* Ordner-Verwaltung — bewusst eingeklappt: fürs tägliche Rippen nicht + nötig (Ziel wählt man im „Rippen starten"-Dialog), war unbeschriftet + und verwirrte mehr als sie half (Commander-Feedback 24.07.). */}
-
+ + {browserOffen && (<> +
+ )}
{/* Neues Netzwerk-Ziel */} diff --git a/docker/ui/src/components/WorkerVerwaltung.tsx b/docker/ui/src/components/WorkerVerwaltung.tsx index 9b8086f..2c8e123 100644 --- a/docker/ui/src/components/WorkerVerwaltung.tsx +++ b/docker/ui/src/components/WorkerVerwaltung.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Server, RefreshCw, Copy, CheckCircle } from 'lucide-react' +import { Server, RefreshCw, Copy, CheckCircle, Trash2 } from 'lucide-react' import { api } from '../lib/api' import { useDarkMode } from '../context/ThemeContext' import { useToast } from '../context/ToastContext' @@ -14,6 +14,12 @@ interface WorkerInfo { encoders: string[] last_seen?: string online?: boolean + info?: { + hostname?: string + ip?: string + makemkv?: string + handbrake?: string + } } const ENCODER_LABELS: Record = { @@ -55,7 +61,8 @@ export default function WorkerVerwaltung() { const rippyHost = window.location.hostname const installBefehl = [ `git clone rippy && cd rippy`, - `RIPPY_HOST=${rippyHost} docker compose -f deploy/remote-transcode-worker.yml up -d --build`, + `RIPPY_HOST=${rippyHost} WORKER_NAME=mein-pc \\`, + ` docker compose -f deploy/remote-transcode-worker.yml up -d --build`, ].join('\n') const kopieren = () => { @@ -88,18 +95,35 @@ export default function WorkerVerwaltung() { />
-

{w.name}

+

{w.name}

{w.online ? 'online' : 'offline'} · Lebenszeichen {relativeZeit(w.last_seen)} + {w.info?.ip ? ` · IP ${w.info.ip}` : ''} + {w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}

-
- {w.encoders.map(e => ( - - {ENCODER_LABELS[e] || e} - - ))} +
+
+ {w.encoders.map(e => ( + + {ENCODER_LABELS[e] || e} + + ))} +
+ {!w.online && ( + + )}
))} @@ -112,9 +136,10 @@ export default function WorkerVerwaltung() {

Maschine als Encoding-Worker anbinden

Auf der Zielmaschine (Linux mit Docker, oder Windows mit Docker Desktop) diesen - Befehl ausführen — der Worker verbindet sich, taucht oben in der Liste auf und - übernimmt ab dann automatisch Kompressions-Jobs. GPU vorhanden? Wird erkannt - und angezeigt. Voraussetzung: die Maschine erreicht diesen Rippy-Host im Netz. + Befehl ausführen — WORKER_NAME frei wählen, unter dem Namen taucht die Maschine + oben auf und übernimmt ab dann automatisch Kompressions-Jobs. GPU vorhanden? + Wird erkannt und angezeigt. Voraussetzung: die Maschine erreicht diesen + Rippy-Host im Netz.

{installBefehl} @@ -127,7 +152,10 @@ export default function WorkerVerwaltung() {

- Eine geführte Ein-Klick-Installation (SSH) ist als Ausbaustufe geplant. + Ehrliche Antwort zu „muss das Docker sein?": aktuell ja — der Worker braucht + HandBrake + Celery, und Docker ist der einzige Weg, das auf jeder Maschine + identisch und ohne Installations-Roulette auszuliefern. Ein nativer + Windows-Dienst (Installer, ohne Docker) steht als Ausbaustufe in der ROADMAP.

diff --git a/docker/ui/src/pages/Dashboard.tsx b/docker/ui/src/pages/Dashboard.tsx index 3108aba..efb37e3 100644 --- a/docker/ui/src/pages/Dashboard.tsx +++ b/docker/ui/src/pages/Dashboard.tsx @@ -1,20 +1,23 @@ import { useEffect, useState } from 'react' -import { Clock, Activity, AlertCircle, CheckCircle, Disc, Download } from 'lucide-react' +import { Clock, Activity, AlertCircle, CheckCircle, Disc, Download, Trash2 } from 'lucide-react' import { api } from '../lib/api' import { useDarkMode } from '../context/ThemeContext' +import { useToast } from '../context/ToastContext' +import ConfirmDialog from '../components/ConfirmDialog' import DeviceDiscovery from '../components/DeviceDiscovery' import JobDetailModal from '../components/JobDetailModal' import LiveLogSection from '../components/LiveLogSection' interface Job { id: string - type: 'cd' | 'dvd' | 'bluray' + type: 'cd' | 'dvd' | 'bluray' | 'uhd' status: 'pending' | 'processing' | 'transcoding' | 'canceling' | 'completed' | 'failed' device: string startTime: string endTime?: string progress: number title?: string + can_retry?: boolean } async function fetchJobs(): Promise { @@ -64,20 +67,29 @@ function StatusBadge({ status }: { status: string }) { function TypeBadge({ type }: { type: string }) { const { theme } = useDarkMode() - const colors = theme === 'dark' - ? 'bg-slate-800 text-slate-300' - : 'bg-slate-100 text-slate-700' - - const icons = { - cd: Disc, - dvd: Disc, - bluray: Disc, + // 4K UHD sichtbar abheben (Commander-Wunsch 24.07.) — es IST eine Blu-ray, + // aber der Unterschied (Laufwerk! Größe!) muss auf einen Blick erkennbar sein. + const colors: Record = theme === 'dark' + ? { + cd: 'bg-blue-900/30 text-blue-400', + dvd: 'bg-purple-900/30 text-purple-400', + bluray: 'bg-pink-900/30 text-pink-400', + uhd: 'bg-amber-900/30 text-amber-400', + } + : { + cd: 'bg-blue-100 text-blue-600', + dvd: 'bg-purple-100 text-purple-600', + bluray: 'bg-pink-100 text-pink-600', + uhd: 'bg-amber-100 text-amber-700', + } + const labels: Record = { + cd: 'CD', dvd: 'DVD', bluray: 'BLU-RAY', uhd: '4K UHD', } - const Icon = icons[type as keyof typeof icons] || Disc + const fallback = theme === 'dark' ? 'bg-slate-800 text-slate-300' : 'bg-slate-100 text-slate-700' return ( - - - {type.toUpperCase()} + + + {labels[type] || type.toUpperCase()} ) } @@ -141,7 +153,30 @@ export default function Dashboard() { const [workers, setWorkers] = useState([]) const [loading, setLoading] = useState(true) const [detailJobId, setDetailJobId] = useState(null) + const [aufraeumenOffen, setAufraeumenOffen] = useState(false) const { theme } = useDarkMode() + const { toast } = useToast() + + const jobLoeschen = async (job: Job) => { + try { + await api.delete(`/jobs/${job.id}`) + toast('success', `„${job.title || job.id.slice(0, 8)}" aus der Liste entfernt`) + setJobs(await fetchJobs()) + } catch (e: any) { + toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen') + } + } + + const erledigteAufraeumen = async () => { + setAufraeumenOffen(false) + try { + const r = await api.delete('/jobs') + toast('success', `${r.data.deleted} erledigte Jobs entfernt — Dateien bleiben liegen`) + setJobs(await fetchJobs()) + } catch { + toast('error', 'Aufräumen fehlgeschlagen') + } + } useEffect(() => { const loadData = async () => { @@ -253,7 +288,19 @@ export default function Dashboard() {

Neueste Jobs

- {jobs.length} Jobs insgesamt +
+ {jobs.length} Jobs insgesamt + {jobs.some(j => j.status === 'completed' || j.status === 'failed') && ( + + )} +
@@ -314,15 +361,30 @@ export default function Dashboard() { Download )} - {job.status === 'failed' && ( + {/* Nur wenn Rohdaten wirklich daliegen — an einem Job, + der nie gerippt hat, war der Knopf Unsinn */} + {job.status === 'failed' && job.can_retry && ( )} + {(job.status === 'completed' || job.status === 'failed') && ( + + )} {(job.status === 'pending' || job.status === 'processing' || job.status === 'transcoding') && ( + {quellenStatus ? ( +
+ {Object.entries(quellenStatus).map(([quelle, status]) => ( + + {quelle.toUpperCase()}: {status === 'ok' ? '✓ funktioniert' : status === 'kein_key' ? 'kein Key' : '✗ Fehler'} + + ))} +
+ ) : ( + + Prüft live gegen TMDB/OMDb — nach dem Speichern eines neuen Keys hier klicken. + Beide TMDB-Key-Arten funktionieren (v3-Schlüssel und v4-Token). + + )} + +