Praxis-Feedback-Runde: CIFS-Cap-Fix, TMDB v3-Keys, 4K-UHD-Typ, Vollautomatik, Job-Verwaltung, Worker-Namen
Ampel / ampel (push) Successful in 29s

Zwei bewiesene Bug-Fixes:
- SMB-Mount 'Unable to apply new capability set': mount.cifs hebt
  CAP_DAC_READ_SEARCH an, die in Dockers Default-Caps fehlt (auf der VM
  reproduziert: Bounding-Set a82425fb ohne Bit 2; mit der Capability
  verschwindet der Fehler). Fix: cap_add DAC_READ_SEARCH fuer den
  api-Container.
- TMDB fiel still aus: Client konnte nur v4-Bearer-Tokens, der uebliche
  32-Hex-v3-Key bekam 401 und die Suche lieferte nur OMDb. Jetzt beide
  Key-Arten (ist_v4_token + api_key-Query-Param lt.
  developer.themoviedb.org, mit Tests) — damit kommen auch die deutschen
  Texte an (language=de-DE war ueberall schon gesetzt). Neu:
  GET /metadata/status + 'Verbindung pruefen' in Einstellungen -> APIs
  (Live-Check am Cache vorbei).

Features aus dem Commander-Feedback:
- 4K UHD als eigener Disc-Typ: classify >= 55 GiB (BD-66/BD-100; BD-50
  bleibt bluray), beide detection.py + Tests, eigene Badge-Farbe in
  Dashboard/Laufwerken, Prescan-Label '4K UHD'. UHD-Rip-Fehler 'Failed to
  open disc' (Code 11) bekommt Klartext: LibreDrive-Firmware noetig.
- Vollautomatik (Setting autoRipStart): Disc erkannt -> Rip startet ohne
  Popup in den Schnellwahl-Ordner (Serie->Serien, sonst Filme, CD->Musik).
- Job-Verwaltung: 'Neu komprimieren' nur noch mit can_retry (Rohdaten
  liegen wirklich da), DELETE /jobs/{id} + 'Erledigte aufraeumen'
  (Dateien bleiben immer), 'Alle herunterladen' im Job-Detail
  (gestaffelte Einzel-Downloads statt Server-Zip von 40-GB-Dateien).
- Worker zuordenbar: WORKER_NAME-Env als stabiler Anzeigename (fixt auch
  die Offline-Leichen nach Rebuilds), info.hostname/ip gemeldet,
  Online-Abgleich ueber hostname, DELETE /workers/{name} + Papierkorb im
  UI, Quelle-Anzeige an der Disc-Karte ('Quelle: TMDB - 99 % sicher').
- Ripping-Tab nach Medium gegliedert (Video/Audio-CD/Allgemein) +
  Untertitel-Klartext (--all-audio/--all-subtitles bleiben komplett),
  CD -> Musik-Vorauswahl im Ziel-Dialog, Ordner-Verwaltung beschriftet
  und standardmaessig eingeklappt.
- Docs: SAVEPOINT v3.3, ROADMAP Etappe 14 + Ideen (nativer
  Windows-Worker, deutsche OMDb-Texte via TMDB-Find), README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-24 13:29:10 +02:00
parent ab75134931
commit 5778ac4645
23 changed files with 691 additions and 81 deletions
+7 -3
View File
@@ -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 sie mit Poster auf dem Dashboard. Klick auf einen Job-Titel öffnet die
Detail-Ansicht (Poster, Jahr, Beschreibung, Ablagepfad). Detail-Ansicht (Poster, Jahr, Beschreibung, Ablagepfad).
2. **Rip** (MakeMKV, verlustfrei — der einzige Weg durch AACS): Ziel wählst 2. **Rip** (MakeMKV, verlustfrei — der einzige Weg durch AACS): Ziel wählst
du beim Start (Filme/Serien/Musik/eigener Pfad, inkl. Netzwerk-Ziele). 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. 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. Audio-CDs laufen über abcde → FLAC + MusicBrainz.
3. **Kompression** (HandBrake, eigener Job auf eigener Queue): x265/x264, 3. **Kompression** (HandBrake, eigener Job auf eigener Queue): x265/x264,
Preset im UI wählbar; Rohdatei wird erst nach Erfolg gelöscht 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 | | Variable | Pflicht | Zweck |
|---|---|---| |---|---|---|
| `JWT_SECRET_KEY` | ✔ | Signierschlüssel (openssl rand -hex 32) | | `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) | | `OMDB_API_KEY` | optional | zweite Metadaten-Quelle (Fallback) |
| `THETVDB_API_KEY` | optional | Serien-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 | | `MAKEMKV_APP_KEY` | optional | MakeMKV-Beta-Key (Forum); DVDs gehen ohne — bequemer: im UI unter Einstellungen → System pflegen |
+27
View File
@@ -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 ## Ideen-Katalog (24.07.2026, priorisiert) — Lücken, die noch offen sind
1. **Serien-Staffel-Flow**: Beim Rippen einer Serien-Disc Staffel/Disc-Nr 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. 7. **Rip-Historie exportieren** (CSV/JSON) für die Sammlung-Übersicht.
8. **Design 2.0**: Design-Tokens statt Theme-Ternaries in jeder Zeile — 8. **Design 2.0**: Design-Tokens statt Theme-Ternaries in jeder Zeile —
die `theme === 'dark' ? … : …`-Kaskaden sind der größte UI-Schuldenberg. 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.
+44 -1
View File
@@ -1,6 +1,49 @@
# SAVEPOINT — Rippy # SAVEPOINT — Rippy
## Aktueller Stand: v3.2Universal-Komfort-Runde + Ampel entrostet (24.07.2026, Claude) ## Aktueller Stand: v3.3Praxis-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` **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. hing 10 Commits hinter `main`** — deshalb kam nichts Neues mehr auf die VM.
+3
View File
@@ -25,6 +25,9 @@ services:
- DATABASE_URL=postgresql://rippy:rippy@${RIPPY_HOST}:5432/rippy - DATABASE_URL=postgresql://rippy:rippy@${RIPPY_HOST}:5432/rippy
- RIP_OUTPUT_DIR=/app/media - RIP_OUTPUT_DIR=/app/media
- RAW_DIR=/app/temp/raw - 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: volumes:
# Freigabe der Rippy-Maschine (siehe Kopf-Kommentar) # Freigabe der Rippy-Maschine (siehe Kopf-Kommentar)
- /mnt/rippy/media:/app/media - /mnt/rippy/media:/app/media
+8
View File
@@ -29,8 +29,13 @@ services:
# SYS_ADMIN: die API hängt Netzwerk-Speicherziele (NFS/SMB) selbst ein # SYS_ADMIN: die API hängt Netzwerk-Speicherziele (NFS/SMB) selbst ein
# (mounts.py) — dank rshared-Propagation unten sehen Host UND Worker # (mounts.py) — dank rshared-Propagation unten sehen Host UND Worker
# jeden Mount sofort. # 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: cap_add:
- SYS_ADMIN - SYS_ADMIN
- DAC_READ_SEARCH
security_opt: security_opt:
- apparmor:unconfined - apparmor:unconfined
volumes: volumes:
@@ -68,6 +73,9 @@ services:
- REDIS_URL=redis://redis:6379/0 - REDIS_URL=redis://redis:6379/0
- RIP_OUTPUT_DIR=/app/media - RIP_OUTPUT_DIR=/app/media
- MAKEMKV_APP_KEY=${MAKEMKV_APP_KEY} - 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 - LOG_LEVEL=INFO
volumes: volumes:
- type: bind - type: bind
+22 -8
View File
@@ -11,6 +11,15 @@ TMDB_BASE_URL = "https://api.themoviedb.org/3"
TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p" 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: class TMDBClient:
def __init__(self): def __init__(self):
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable — # DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable —
@@ -18,24 +27,29 @@ class TMDBClient:
from db import get_settings from db import get_settings
self.api_key = get_settings().get("tmdbApiKey") or settings.tmdb_api_key self.api_key = get_settings().get("tmdbApiKey") or settings.tmdb_api_key
self.session = requests.Session() self.session = requests.Session()
self.session.headers.update({ self.session.headers.update({"Content-Type": "application/json"})
"Authorization": f"Bearer {self.api_key}", # Beide Key-Arten unterstützen (developer.themoviedb.org: v3 als
"Content-Type": "application/json" # 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]: def _request(self, endpoint: str, params: Dict = None) -> Optional[Dict]:
"""Mache API-Request mit Caching.""" """Mache API-Request mit Caching."""
from cache.keys import generate_tmdb_key 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) cache_key = generate_tmdb_key(endpoint, params)
cached = get(cache_key) cached = get(cache_key)
if cached: if cached:
return cached return cached
try: try:
response = self.session.get( response = self.session.get(
f"{TMDB_BASE_URL}/{endpoint}", f"{TMDB_BASE_URL}/{endpoint}",
params=params, params={**(params or {}), **self._key_params},
timeout=10 timeout=10
) )
response.raise_for_status() response.raise_for_status()
+21
View File
@@ -189,6 +189,27 @@ def list_jobs(limit: int = 100) -> list:
return [dict(z) for z in zeilen] 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: def has_active_job(device: str) -> bool:
"""True, wenn auf dem Gerät ein Job läuft oder wartet (Eject-Schutz).""" """True, wenn auf dem Gerät ein Job läuft oder wartet (Eject-Schutz)."""
with engine.connect() as conn: with engine.connect() as conn:
+7 -1
View File
@@ -36,6 +36,10 @@ BLKGETSIZE64 = 0x80081272
# Eine DVD9 fasst ~8,5 GB; Blu-ray beginnt bei 25 GB (Single Layer). # Eine DVD9 fasst ~8,5 GB; Blu-ray beginnt bei 25 GB (Single Layer).
# Alles ab 10 GB ist also sicher eine Blu-ray. # Alles ab 10 GB ist also sicher eine Blu-ray.
BLURAY_MIN_BYTES = 10 * 1024**3 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: 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: 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): if disc_status_code in (CDS_AUDIO, CDS_MIXED):
return "cd" return "cd"
if disc_status_code in (CDS_DATA_1, CDS_DATA_2, CDS_XA_2_1, CDS_XA_2_2): 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 "bluray" if size_bytes >= BLURAY_MIN_BYTES else "dvd"
return "unknown" return "unknown"
+145 -3
View File
@@ -91,11 +91,56 @@ async def _auto_prescan(pfad: str):
+ (f" ({ergebnis.year})" if ergebnis.year else "") + (f" ({ergebnis.year})" if ergebnis.year else "")
+ f" [{ergebnis.disc_type}, Confidence {ergebnis.confidence:.0%}] auf {pfad}", + f" [{ergebnis.disc_type}, Confidence {ergebnis.confidence:.0%}] auf {pfad}",
) )
await _auto_rip_wenn_aktiviert(pfad)
except Exception as e: except Exception as e:
DISC_CACHE.pop(pfad, None) DISC_CACHE.pop(pfad, None)
print(f"Auto-Pre-Scan {pfad}: {e}") 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(): async def disc_watcher():
"""Disc-Wache: pollt die Laufwerke, protokolliert Einwurf/Auswurf und """Disc-Wache: pollt die Laufwerke, protokolliert Einwurf/Auswurf und
stößt beim Einlegen automatisch den Pre-Scan an (Dashboard-Disc-Karte). stößt beim Einlegen automatisch den Pre-Scan an (Dashboard-Disc-Karte).
@@ -178,6 +223,7 @@ class Job(BaseModel):
progress: int = 0 progress: int = 0
title: Optional[str] = None title: Optional[str] = None
error: Optional[str] = None error: Optional[str] = None
can_retry: bool = False # Rohdaten vorhanden → „Neu komprimieren" sinnvoll
class Device(BaseModel): class Device(BaseModel):
id: str 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]) @app.get("/jobs", response_model=List[Job])
async def get_jobs(): async def get_jobs():
"""Holt alle Jobs aus der Datenbank (neueste zuerst).""" """Holt alle Jobs aus der Datenbank (neueste zuerst)."""
zeilen = await asyncio.to_thread(db.list_jobs) def sammle():
return [_job_row_to_model(z) for z in zeilen] 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): class JobCreateRequest(BaseModel):
@@ -500,12 +587,67 @@ async def capabilities():
except Exception: except Exception:
online_namen = set() online_namen = set()
for zeile in zeilen: 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 zeilen
return {"workers": await asyncio.to_thread(sammle)} 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): class MountRequest(BaseModel):
name: str name: str
type: str # nfs | cifs type: str # nfs | cifs
+7 -4
View File
@@ -253,7 +253,7 @@ class PreScan:
installiert, die Erkennung fiel still immer auf "DVD" zurück. installiert, die Erkennung fiel still immer auf "DVD" zurück.
""" """
typ = detection.detect_disc_type(device_path) 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: def _read_toc(self, device_path: str, disc_type: str) -> Dict:
"""Lese TOC (Table of Contents).""" """Lese TOC (Table of Contents)."""
@@ -403,7 +403,8 @@ class PreScan:
"poster_path": movie_details.get("poster_path", ""), "poster_path": movie_details.get("poster_path", ""),
"backdrop_path": movie_details.get("backdrop_path", ""), "backdrop_path": movie_details.get("backdrop_path", ""),
"runtime": movie_details.get("runtime", 0), "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 matched = True
break break
@@ -426,7 +427,8 @@ class PreScan:
"overview": tv_details.get("overview", ""), "overview": tv_details.get("overview", ""),
"poster_path": tv_details.get("poster_path", ""), "poster_path": tv_details.get("poster_path", ""),
"backdrop_path": tv_details.get("backdrop_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 matched = True
break break
@@ -471,7 +473,8 @@ class PreScan:
"poster_path": vorschlag.get("poster_path", ""), "poster_path": vorschlag.get("poster_path", ""),
"backdrop_path": vorschlag.get("backdrop_path", ""), "backdrop_path": vorschlag.get("backdrop_path", ""),
"runtime": vorschlag.get("runtime", 0), "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 matched = True
+20
View File
@@ -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
+15 -4
View File
@@ -15,13 +15,14 @@ interface DiscInfo {
poster_path?: string poster_path?: string
overview?: string overview?: string
type?: string type?: string
source?: string
} }
} }
interface Device { interface Device {
id: string id: string
name: string name: string
type: 'cd' | 'dvd' | 'bluray' | 'unknown' type: 'cd' | 'dvd' | 'bluray' | 'uhd' | 'unknown'
path: string path: string
status: 'empty' | 'ready' | 'ripping' status: 'empty' | 'ready' | 'ripping'
serial?: string 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 '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 '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 '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' 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<string, string>)[type] || type.toUpperCase()
const QUELLEN_LABEL: Record<string, string> = {
tmdb: 'TMDB', omdb: 'OMDb', jikan: 'MyAnimeList', manuell: 'manuell gewählt',
}
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
case 'empty': return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600' 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()} {device.disc!.disc_type?.toUpperCase()}
</span> </span>
<span className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <span className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Ü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
</span> </span>
</div> </div>
{device.disc!.metadata?.overview && ( {device.disc!.metadata?.overview && (
@@ -266,7 +276,7 @@ export default function DeviceDiscovery() {
'Rippt'} 'Rippt'}
</span> </span>
<span className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <span className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Typ: {device.type.toUpperCase()} Typ: {typLabel(device.type)}
</span> </span>
</div> </div>
</div> </div>
@@ -293,7 +303,7 @@ export default function DeviceDiscovery() {
<span className={`font-mono text-xs ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>{device.serial || '—'}</span> <span className={`font-mono text-xs ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>{device.serial || '—'}</span>
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Disc</span> <span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Disc</span>
<span className={theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}> <span className={theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}>
{device.status === 'ready' ? `eingelegt (${device.type.toUpperCase()})` : 'keine'} {device.status === 'ready' ? `eingelegt (${typLabel(device.type)})` : 'keine'}
</span> </span>
</div> </div>
@@ -331,6 +341,7 @@ export default function DeviceDiscovery() {
Pfad — dort eingehängte Shares sind direkt wählbar) */} Pfad — dort eingehängte Shares sind direkt wählbar) */}
<RipTargetModal <RipTargetModal
isOpen={modalDevice !== null} isOpen={modalDevice !== null}
initialType={modalDevice?.type === 'cd' ? 'music' : 'movies'}
onClose={() => setModalDevice(null)} onClose={() => setModalDevice(null)}
onSave={(target) => { onSave={(target) => {
if (modalDevice) { if (modalDevice) {
+27 -3
View File
@@ -177,9 +177,33 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul
{/* Download der fertigen Dateien — vorher kam man nur per scp dran */} {/* Download der fertigen Dateien — vorher kam man nur per scp dran */}
{detail.status === 'completed' && dateien && dateien.length > 0 && ( {detail.status === 'completed' && dateien && dateien.length > 0 && (
<div className={`rounded-lg p-4 ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}> <div className={`rounded-lg p-4 ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}>
<p className={`text-sm font-medium mb-2 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}> <div className="flex items-center justify-between mb-2">
<Download size={15} /> Dateien herunterladen <p className={`text-sm font-medium flex items-center gap-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>
</p> <Download size={15} /> Dateien herunterladen
</p>
{dateien.length > 1 && (
<button
onClick={() => {
// Gestaffelte Einzel-Downloads statt Server-seitigem
// 40-GB-Zip — der Browser fragt ggf. einmal nach
// Erlaubnis für mehrere Downloads.
dateien.forEach((f, i) => {
setTimeout(() => {
const a = document.createElement('a')
a.href = `/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`
a.download = f.name
document.body.appendChild(a)
a.click()
a.remove()
}, i * 400)
})
}}
className="text-xs font-medium px-3 py-1.5 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors"
>
Alle herunterladen ({dateien.length})
</button>
)}
</div>
<div className="space-y-1"> <div className="space-y-1">
{dateien.map(f => ( {dateien.map(f => (
<a <a
+4 -1
View File
@@ -13,6 +13,7 @@ interface TargetConfig {
interface RipTargetModalProps { interface RipTargetModalProps {
isOpen: boolean isOpen: boolean
initialType?: TargetConfig['type'] // CD → Musik vorwählen (Medium-gerecht)
onClose: () => void onClose: () => void
onSave: (target: TargetConfig) => void onSave: (target: TargetConfig) => void
} }
@@ -24,7 +25,7 @@ interface BrowseDir {
// Ziel-Auswahl vor dem Rip: Schnellwahl (Filme/Serien/Musik) ODER frei per // Ziel-Auswahl vor dem Rip: Schnellwahl (Filme/Serien/Musik) ODER frei per
// Ordner-Browser — auch in eingehängte Netzwerk-Ziele (NAS, PC-Freigabe). // 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<TargetConfig['type']>('movies') const [selectedType, setSelectedType] = useState<TargetConfig['type']>('movies')
const [targets, setTargets] = useState<TargetConfig[]>([ const [targets, setTargets] = useState<TargetConfig[]>([
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true }, { 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) // Standard-Unterordner aus den Einstellungen ziehen (nicht hartkodiert)
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return
setSelectedType(initialType || 'movies')
setCustomPath('')
api.get('/settings').then(r => { api.get('/settings').then(r => {
const s = r.data || {} const s = r.data || {}
const basis = s.outputDir || '/app/media' const basis = s.outputDir || '/app/media'
+25 -2
View File
@@ -41,6 +41,7 @@ export default function StorageMounts() {
const [browseDirs, setBrowseDirs] = useState<{ name: string, path: string }[]>([]) const [browseDirs, setBrowseDirs] = useState<{ name: string, path: string }[]>([])
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([]) const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
const [neuerOrdner, setNeuerOrdner] = useState('') const [neuerOrdner, setNeuerOrdner] = useState('')
const [browserOffen, setBrowserOffen] = useState(false)
const { theme } = useDarkMode() const { theme } = useDarkMode()
const { toast } = useToast() const { toast } = useToast()
@@ -188,9 +189,30 @@ export default function StorageMounts() {
)} )}
</div> </div>
{/* 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.). */}
<div className={`rounded-lg border overflow-hidden ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}> <div className={`rounded-lg border overflow-hidden ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
<div className={`flex items-center gap-2 px-3 py-2 border-b ${theme === 'dark' ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-slate-50'}`}> <button
onClick={() => setBrowserOffen(!browserOffen)}
className={`w-full flex items-center justify-between px-4 py-3 text-left transition-colors ${theme === 'dark' ? 'hover:bg-slate-800' : 'hover:bg-slate-50'}`}
>
<div>
<h3 className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
Ordner-Verwaltung (optional)
</h3>
<p className={`text-xs mt-0.5 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Unterordner in der Ablage anschauen und neue anlegen z. B. anime" oder
„doku", die danach im Rippen starten"-Dialog wählbar sind. Fürs normale
Rippen brauchst du das hier nicht.
</p>
</div>
<span className={`text-xs flex-shrink-0 ml-3 ${theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'}`}>
{browserOffen ? 'zuklappen ▲' : 'aufklappen ▼'}
</span>
</button>
{browserOffen && (<>
<div className={`flex items-center gap-2 px-3 py-2 border-y ${theme === 'dark' ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-slate-50'}`}>
<button <button
onClick={() => browseParent && browsen(browseParent)} onClick={() => browseParent && browsen(browseParent)}
disabled={!browseParent} disabled={!browseParent}
@@ -249,6 +271,7 @@ export default function StorageMounts() {
</> </>
)} )}
</div> </div>
</>)}
</div> </div>
{/* Neues Netzwerk-Ziel */} {/* Neues Netzwerk-Ziel */}
+41 -13
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' 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 { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext' import { useDarkMode } from '../context/ThemeContext'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
@@ -14,6 +14,12 @@ interface WorkerInfo {
encoders: string[] encoders: string[]
last_seen?: string last_seen?: string
online?: boolean online?: boolean
info?: {
hostname?: string
ip?: string
makemkv?: string
handbrake?: string
}
} }
const ENCODER_LABELS: Record<string, string> = { const ENCODER_LABELS: Record<string, string> = {
@@ -55,7 +61,8 @@ export default function WorkerVerwaltung() {
const rippyHost = window.location.hostname const rippyHost = window.location.hostname
const installBefehl = [ const installBefehl = [
`git clone <rippy-repo-url> rippy && cd rippy`, `git clone <rippy-repo-url> 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') ].join('\n')
const kopieren = () => { const kopieren = () => {
@@ -88,18 +95,35 @@ export default function WorkerVerwaltung() {
/> />
<Server size={18} className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'} /> <Server size={18} className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'} />
<div className="min-w-0"> <div className="min-w-0">
<p className={`text-sm font-mono truncate ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{w.name}</p> <p className={`text-sm font-medium truncate ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{w.name}</p>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
{w.online ? 'online' : 'offline'} · Lebenszeichen {relativeZeit(w.last_seen)} {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}` : ''}
</p> </p>
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-1 justify-end"> <div className="flex items-center gap-2">
{w.encoders.map(e => ( <div className="flex flex-wrap gap-1 justify-end">
<span key={e} className={`text-xs px-2 py-0.5 rounded ${e.startsWith('cpu') ? (theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-200 text-slate-600') : 'bg-emerald-600/20 text-emerald-500 font-medium'}`}> {w.encoders.map(e => (
{ENCODER_LABELS[e] || e} <span key={e} className={`text-xs px-2 py-0.5 rounded ${e.startsWith('cpu') ? (theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-200 text-slate-600') : 'bg-emerald-600/20 text-emerald-500 font-medium'}`}>
</span> {ENCODER_LABELS[e] || e}
))} </span>
))}
</div>
{!w.online && (
<button
onClick={() => {
api.delete(`/workers/${encodeURIComponent(w.name)}`)
.then(() => { toast('success', `${w.name}" entfernt — ein aktiver Worker meldet sich binnen 1 min zurück`); laden() })
.catch(() => toast('error', 'Entfernen fehlgeschlagen'))
}}
title="Verwaisten Eintrag entfernen (alte Container-ID nach Rebuild) — aktive Worker melden sich automatisch wieder an"
className="p-1.5 rounded text-rose-500 hover:bg-rose-500/10"
>
<Trash2 size={14} />
</button>
)}
</div> </div>
</div> </div>
))} ))}
@@ -112,9 +136,10 @@ export default function WorkerVerwaltung() {
<h3 className={`font-medium mb-1 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Maschine als Encoding-Worker anbinden</h3> <h3 className={`font-medium mb-1 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Maschine als Encoding-Worker anbinden</h3>
<p className={`text-xs mb-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <p className={`text-xs mb-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Auf der Zielmaschine (Linux mit Docker, oder Windows mit Docker Desktop) diesen 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 Befehl ausführen WORKER_NAME frei wählen, unter dem Namen taucht die Maschine
übernimmt ab dann automatisch Kompressions-Jobs. GPU vorhanden? Wird erkannt oben auf und übernimmt ab dann automatisch Kompressions-Jobs. GPU vorhanden?
und angezeigt. Voraussetzung: die Maschine erreicht diesen Rippy-Host im Netz. Wird erkannt und angezeigt. Voraussetzung: die Maschine erreicht diesen
Rippy-Host im Netz.
</p> </p>
<div className={`relative rounded-lg p-3 font-mono text-xs whitespace-pre overflow-x-auto ${theme === 'dark' ? 'bg-slate-950 text-slate-300' : 'bg-slate-900 text-slate-200'}`}> <div className={`relative rounded-lg p-3 font-mono text-xs whitespace-pre overflow-x-auto ${theme === 'dark' ? 'bg-slate-950 text-slate-300' : 'bg-slate-900 text-slate-200'}`}>
{installBefehl} {installBefehl}
@@ -127,7 +152,10 @@ export default function WorkerVerwaltung() {
</button> </button>
</div> </div>
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
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.
</p> </p>
</div> </div>
</div> </div>
+89 -17
View File
@@ -1,20 +1,23 @@
import { useEffect, useState } from 'react' 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 { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext' import { useDarkMode } from '../context/ThemeContext'
import { useToast } from '../context/ToastContext'
import ConfirmDialog from '../components/ConfirmDialog'
import DeviceDiscovery from '../components/DeviceDiscovery' import DeviceDiscovery from '../components/DeviceDiscovery'
import JobDetailModal from '../components/JobDetailModal' import JobDetailModal from '../components/JobDetailModal'
import LiveLogSection from '../components/LiveLogSection' import LiveLogSection from '../components/LiveLogSection'
interface Job { interface Job {
id: string id: string
type: 'cd' | 'dvd' | 'bluray' type: 'cd' | 'dvd' | 'bluray' | 'uhd'
status: 'pending' | 'processing' | 'transcoding' | 'canceling' | 'completed' | 'failed' status: 'pending' | 'processing' | 'transcoding' | 'canceling' | 'completed' | 'failed'
device: string device: string
startTime: string startTime: string
endTime?: string endTime?: string
progress: number progress: number
title?: string title?: string
can_retry?: boolean
} }
async function fetchJobs(): Promise<Job[]> { async function fetchJobs(): Promise<Job[]> {
@@ -64,20 +67,29 @@ function StatusBadge({ status }: { status: string }) {
function TypeBadge({ type }: { type: string }) { function TypeBadge({ type }: { type: string }) {
const { theme } = useDarkMode() const { theme } = useDarkMode()
const colors = theme === 'dark' // 4K UHD sichtbar abheben (Commander-Wunsch 24.07.) — es IST eine Blu-ray,
? 'bg-slate-800 text-slate-300' // aber der Unterschied (Laufwerk! Größe!) muss auf einen Blick erkennbar sein.
: 'bg-slate-100 text-slate-700' const colors: Record<string, string> = theme === 'dark'
? {
const icons = { cd: 'bg-blue-900/30 text-blue-400',
cd: Disc, dvd: 'bg-purple-900/30 text-purple-400',
dvd: Disc, bluray: 'bg-pink-900/30 text-pink-400',
bluray: Disc, 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<string, string> = {
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 ( return (
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium ${colors}`}> <span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium ${colors[type] || fallback}`}>
<Icon size={14} /> <Disc size={14} />
{type.toUpperCase()} {labels[type] || type.toUpperCase()}
</span> </span>
) )
} }
@@ -141,7 +153,30 @@ export default function Dashboard() {
const [workers, setWorkers] = useState<WorkerInfo[]>([]) const [workers, setWorkers] = useState<WorkerInfo[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [detailJobId, setDetailJobId] = useState<string | null>(null) const [detailJobId, setDetailJobId] = useState<string | null>(null)
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
const { theme } = useDarkMode() 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(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
@@ -253,7 +288,19 @@ export default function Dashboard() {
<div className={`rounded-xl shadow-sm border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}> <div className={`rounded-xl shadow-sm border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex items-center justify-between`}> <div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex items-center justify-between`}>
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Neueste Jobs</h2> <h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Neueste Jobs</h2>
<span className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{jobs.length} Jobs insgesamt</span> <div className="flex items-center gap-3">
<span className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{jobs.length} Jobs insgesamt</span>
{jobs.some(j => j.status === 'completed' || j.status === 'failed') && (
<button
onClick={() => setAufraeumenOffen(true)}
title="Alle fertigen und fehlgeschlagenen Jobs aus der Liste entfernen (Dateien bleiben)"
className={`inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
>
<Trash2 size={13} />
Erledigte aufräumen
</button>
)}
</div>
</div> </div>
<div className="overflow-x-auto transition-colors duration-300"> <div className="overflow-x-auto transition-colors duration-300">
<table className="w-full transition-colors duration-300"> <table className="w-full transition-colors duration-300">
@@ -314,15 +361,30 @@ export default function Dashboard() {
Download Download
</button> </button>
)} )}
{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 && (
<button <button
onClick={() => api.post(`/jobs/${job.id}/retry-transcode`).catch(() => {})} onClick={() => {
api.post(`/jobs/${job.id}/retry-transcode`)
.then(() => toast('success', 'Kompression neu eingereiht'))
.catch((e: any) => toast('error', e?.response?.data?.detail || 'Neu anstoßen fehlgeschlagen'))
}}
title="Kompression neu anstoßen (Rohdatei bleibt nach Fehlschlägen erhalten)" title="Kompression neu anstoßen (Rohdatei bleibt nach Fehlschlägen erhalten)"
className={`text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-indigo-300 hover:bg-slate-600' : 'bg-slate-100 text-indigo-700 hover:bg-slate-200'}`} className={`text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-indigo-300 hover:bg-slate-600' : 'bg-slate-100 text-indigo-700 hover:bg-slate-200'}`}
> >
Neu komprimieren Neu komprimieren
</button> </button>
)} )}
{(job.status === 'completed' || job.status === 'failed') && (
<button
onClick={() => jobLoeschen(job)}
title="Job aus der Liste entfernen — fertige Dateien bleiben liegen"
className={`ml-2 p-1.5 rounded-lg transition-colors text-rose-500 ${theme === 'dark' ? 'hover:bg-slate-700' : 'hover:bg-slate-100'}`}
>
<Trash2 size={14} />
</button>
)}
{(job.status === 'pending' || job.status === 'processing' || job.status === 'transcoding') && ( {(job.status === 'pending' || job.status === 'processing' || job.status === 'transcoding') && (
<button <button
onClick={() => api.post(`/jobs/${job.id}/cancel`).catch(() => {})} onClick={() => api.post(`/jobs/${job.id}/cancel`).catch(() => {})}
@@ -346,6 +408,16 @@ export default function Dashboard() {
{/* Klick auf einen Job-Titel: Film-/Serien-Info + Job-Fakten */} {/* Klick auf einen Job-Titel: Film-/Serien-Info + Job-Fakten */}
<JobDetailModal jobId={detailJobId} onClose={() => setDetailJobId(null)} /> <JobDetailModal jobId={detailJobId} onClose={() => setDetailJobId(null)} />
<ConfirmDialog
isOpen={aufraeumenOffen}
onClose={() => setAufraeumenOffen(false)}
onConfirm={erledigteAufraeumen}
title="Job-Liste aufräumen?"
message="Alle fertigen und fehlgeschlagenen Jobs verschwinden aus der Liste. Die gerippten Dateien bleiben selbstverständlich liegen."
confirmText="Aufräumen"
type="warning"
/>
</div> </div>
) )
} }
+106 -11
View File
@@ -17,6 +17,7 @@ interface SettingsState {
ripAllTracks: boolean ripAllTracks: boolean
mainFeatureOnly: boolean mainFeatureOnly: boolean
autoEject: boolean autoEject: boolean
autoRipStart: boolean
notificationWebhook: string notificationWebhook: string
transcodeEnabled: boolean transcodeEnabled: boolean
transcodePreset: string transcodePreset: string
@@ -37,6 +38,7 @@ const defaultSettings: SettingsState = {
ripAllTracks: true, ripAllTracks: true,
mainFeatureOnly: false, mainFeatureOnly: false,
autoEject: true, autoEject: true,
autoRipStart: false,
notificationWebhook: '', notificationWebhook: '',
transcodeEnabled: true, transcodeEnabled: true,
transcodePreset: 'H.265 MKV 1080p30', transcodePreset: 'H.265 MKV 1080p30',
@@ -80,9 +82,29 @@ export default function SettingsPage() {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [testBusy, setTestBusy] = useState(false) const [testBusy, setTestBusy] = useState(false)
const [quellenStatus, setQuellenStatus] = useState<Record<string, string> | null>(null)
const [quellenBusy, setQuellenBusy] = useState(false)
const { theme } = useDarkMode() const { theme } = useDarkMode()
const { toast } = useToast() const { toast } = useToast()
const quellenPruefen = async () => {
setQuellenBusy(true)
try {
const r = await api.get('/metadata/status')
setQuellenStatus(r.data)
const kaputt = Object.entries(r.data).filter(([, v]) => v === 'fehler')
if (kaputt.length === 0) {
toast('success', 'Metadaten-Quellen geprüft — alles in Ordnung')
} else {
toast('error', `Quelle mit Problem: ${kaputt.map(([k]) => k.toUpperCase()).join(', ')}`)
}
} catch {
toast('error', 'Prüfung fehlgeschlagen — läuft die API?')
} finally {
setQuellenBusy(false)
}
}
useEffect(() => { useEffect(() => {
api.get('/capabilities') api.get('/capabilities')
.then(r => setWorkers(r.data.workers || [])) .then(r => setWorkers(r.data.workers || []))
@@ -182,20 +204,28 @@ export default function SettingsPage() {
Ripping-Einstellungen Ripping-Einstellungen
</h2> </h2>
<div className="space-y-4 transition-colors duration-300"> <div className="space-y-6 transition-colors duration-300">
<div className="flex items-center justify-between p-4 transition-colors duration-300"> {/* ── Video: DVD · Blu-ray · 4K UHD ─────────────────────── */}
<p className={`text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
📀 Video DVD · Blu-ray · 4K UHD
</p>
<div className={`flex items-center justify-between p-4 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700/50 rounded-lg' : 'bg-slate-50 rounded-lg'}`}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={`p-2 rounded-lg transition-colors duration-300 ${theme === 'dark' ? 'bg-indigo-900/30 text-indigo-400' : 'bg-indigo-100 text-indigo-600'}`}> <div className={`p-2 rounded-lg transition-colors duration-300 ${theme === 'dark' ? 'bg-indigo-900/30 text-indigo-400' : 'bg-indigo-100 text-indigo-600'}`}>
<Disc size={20} /> <Disc size={20} />
</div> </div>
<div> <div>
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Alle Tracks rippen</h3> <h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Vollautomatik: sofort rippen</h3>
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Rippe alle Tracks einer CD (nicht nur Main Feature)</p> <p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
Disc erkannt Rip startet ohne Nachfrage in den passenden Schnellwahl-Ordner
(Serie Serien, sonst Filme; CD Musik). Aus = wie bisher per Rippen starten"-Dialog.
</p>
</div> </div>
</div> </div>
<Toggle <Toggle
checked={settings.ripAllTracks} checked={settings.autoRipStart}
onChange={(v) => handleChange('ripAllTracks', v)} onChange={(v) => handleChange('autoRipStart', v)}
darkMode={theme === 'dark'} darkMode={theme === 'dark'}
/> />
</div> </div>
@@ -210,13 +240,46 @@ export default function SettingsPage() {
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Rippe bei DVDs/Blu-rays nur das Hauptfeature</p> <p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Rippe bei DVDs/Blu-rays nur das Hauptfeature</p>
</div> </div>
</div> </div>
<Toggle <Toggle
checked={settings.mainFeatureOnly} checked={settings.mainFeatureOnly}
onChange={(v) => handleChange('mainFeatureOnly', v)} onChange={(v) => handleChange('mainFeatureOnly', v)}
darkMode={theme === 'dark'} darkMode={theme === 'dark'}
/> />
</div> </div>
<p className={`text-xs px-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
🌐 Sprachen &amp; Untertitel: MakeMKV sichert grundsätzlich ALLE Ton-Spuren und
Untertitel verlustfrei, und auch die Kompression behält alle (--all-audio /
--all-subtitles) — gerade bei Animes gehen also weder O-Ton noch Subs verloren.
</p>
{/* ── Audio-CD ──────────────────────────────────────────── */}
<p className={`text-xs font-semibold uppercase tracking-wider pt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
🎵 Audio-CD
</p>
<div className={`flex items-center justify-between p-4 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700/50 rounded-lg' : 'bg-slate-50 rounded-lg'}`}>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg transition-colors duration-300 ${theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-600'}`}>
<Disc size={20} />
</div>
<div>
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Alle Tracks rippen</h3>
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Rippe alle Tracks einer CD (FLAC + MusicBrainz-Tags)</p>
</div>
</div>
<Toggle
checked={settings.ripAllTracks}
onChange={(v) => handleChange('ripAllTracks', v)}
darkMode={theme === 'dark'}
/>
</div>
{/* ── Allgemein ─────────────────────────────────────────── */}
<p className={`text-xs font-semibold uppercase tracking-wider pt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Allgemein
</p>
<div className={`flex items-center justify-between p-4 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700/50 rounded-lg' : 'bg-slate-50 rounded-lg'}`}> <div className={`flex items-center justify-between p-4 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700/50 rounded-lg' : 'bg-slate-50 rounded-lg'}`}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={`p-2 rounded-lg transition-colors duration-300 ${theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-600'}`}> <div className={`p-2 rounded-lg transition-colors duration-300 ${theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-600'}`}>
@@ -408,7 +471,39 @@ export default function SettingsPage() {
<Globe size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} /> <Globe size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
API-Schlüssel API-Schlüssel
</h2> </h2>
{/* Live-Prüfung: funktioniert der eingetragene Key WIRKLICH? */}
<div className={`rounded-lg p-4 mb-4 flex flex-wrap items-center gap-3 ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
<button
onClick={quellenPruefen}
disabled={quellenBusy}
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
>
{quellenBusy ? 'Prüfe…' : 'Verbindung prüfen'}
</button>
{quellenStatus ? (
<div className="flex flex-wrap gap-2">
{Object.entries(quellenStatus).map(([quelle, status]) => (
<span
key={quelle}
className={`text-xs px-2.5 py-1 rounded-full font-medium ${
status === 'ok' ? 'bg-emerald-600/20 text-emerald-500'
: status === 'kein_key' ? (theme === 'dark' ? 'bg-slate-800 text-slate-400' : 'bg-slate-200 text-slate-500')
: 'bg-rose-600/20 text-rose-500'
}`}
>
{quelle.toUpperCase()}: {status === 'ok' ? '✓ funktioniert' : status === 'kein_key' ? 'kein Key' : '✗ Fehler'}
</span>
))}
</div>
) : (
<span className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
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).
</span>
)}
</div>
<div className="space-y-4 transition-colors duration-300"> <div className="space-y-4 transition-colors duration-300">
<div className="space-y-2 transition-colors duration-300"> <div className="space-y-2 transition-colors duration-300">
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}> <label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
+27 -1
View File
@@ -26,6 +26,26 @@ def erkenne_encoder() -> list:
return gefunden return gefunden
def erkenne_ip() -> str:
"""Beste erratbare eigene IP (UDP-Route-Trick, KEIN echter Traffic).
Ehrlicher Vorbehalt: im Docker-Bridge-Netz ist das die Container-IP —
für die Maschinen-Zuordnung ist deshalb WORKER_NAME (Anzeigename in
compose/.env) die verlässliche Größe; die IP ist Zusatz-Indiz.
"""
import socket
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("192.0.2.1", 9)) # TEST-NET — nur Routing, kein Paket nötig
return s.getsockname()[0]
finally:
s.close()
except OSError:
return ""
def werkzeug_versionen() -> dict: def werkzeug_versionen() -> dict:
"""Kern-Werkzeuge dieses Workers — fürs UI (Einstellungen → System). """Kern-Werkzeuge dieses Workers — fürs UI (Einstellungen → System).
@@ -34,7 +54,13 @@ def werkzeug_versionen() -> dict:
HandBrakeCLI --version ist dokumentiert (handbrake.fr/docs, CLI Options) HandBrakeCLI --version ist dokumentiert (handbrake.fr/docs, CLI Options)
und gibt z.B. "HandBrake 1.6.1" aus. und gibt z.B. "HandBrake 1.6.1" aus.
""" """
info = {} import socket
info = {
# Zuordnung im UI: hostname (für den Celery-Online-Abgleich) + IP
"hostname": socket.gethostname(),
"ip": erkenne_ip(),
}
if shutil.which("makemkvcon"): if shutil.which("makemkvcon"):
info["makemkv"] = os.getenv("MAKEMKV_VERSION") or "installiert" info["makemkv"] = os.getenv("MAKEMKV_VERSION") or "installiert"
if shutil.which("HandBrakeCLI"): if shutil.which("HandBrakeCLI"):
+6 -1
View File
@@ -39,12 +39,17 @@ def melde_faehigkeiten(**kwargs):
import threading import threading
import time import time
# Anzeigename: WORKER_NAME (compose/.env) macht die Maschine im UI
# zuordenbar UND stabil — der Container-Hostname wechselt bei jedem
# Rebuild und hinterließ Offline-Leichen in der Worker-Liste.
anzeigename = os.getenv("WORKER_NAME") or socket.gethostname()
def herzschlag(): def herzschlag():
while True: while True:
try: try:
db.init_db() db.init_db()
db.save_worker( db.save_worker(
socket.gethostname(), anzeigename,
caps.erkenne_encoder(), caps.erkenne_encoder(),
caps.werkzeug_versionen(), caps.werkzeug_versionen(),
) )
+7 -1
View File
@@ -36,6 +36,10 @@ BLKGETSIZE64 = 0x80081272
# Eine DVD9 fasst ~8,5 GB; Blu-ray beginnt bei 25 GB (Single Layer). # Eine DVD9 fasst ~8,5 GB; Blu-ray beginnt bei 25 GB (Single Layer).
# Alles ab 10 GB ist also sicher eine Blu-ray. # Alles ab 10 GB ist also sicher eine Blu-ray.
BLURAY_MIN_BYTES = 10 * 1024**3 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: 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: 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): if disc_status_code in (CDS_AUDIO, CDS_MIXED):
return "cd" return "cd"
if disc_status_code in (CDS_DATA_1, CDS_DATA_2, CDS_XA_2_1, CDS_XA_2_2): 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 "bluray" if size_bytes >= BLURAY_MIN_BYTES else "dvd"
return "unknown" return "unknown"
+20 -7
View File
@@ -142,7 +142,7 @@ def _job_abschliessen(job_id: str, ergebnis: dict) -> None:
return return
if ergebnis.get("status") == "success": if ergebnis.get("status") == "success":
ausgabe = ergebnis.get("output_dir") ausgabe = ergebnis.get("output_dir")
if ausgabe and (job.get("disc_type") in ("dvd", "bluray")): if ausgabe and (job.get("disc_type") in ("dvd", "bluray", "uhd")):
# Media-Server-Aufbereitung (Jellyfin/Emby/Kodi: NFO + Poster) # Media-Server-Aufbereitung (Jellyfin/Emby/Kodi: NFO + Poster)
einstellungen = db.get_settings() einstellungen = db.get_settings()
try: try:
@@ -219,12 +219,10 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
db.update_job(job_id, progress=progress) db.update_job(job_id, progress=progress)
einstellungen = db.get_settings() einstellungen = db.get_settings()
transcode_an = ( ist_video = disc_type in ("dvd", "bluray", "uhd")
disc_type in ("dvd", "bluray") transcode_an = ist_video and einstellungen.get("transcodeEnabled", True)
and einstellungen.get("transcodeEnabled", True)
)
if disc_type in ("dvd", "bluray"): if ist_video:
# UI-Key schlägt Env-Key — Monats-Key ohne Rebuild aktualisierbar # UI-Key schlägt Env-Key — Monats-Key ohne Rebuild aktualisierbar
_makemkv_key_anwenden(einstellungen) _makemkv_key_anwenden(einstellungen)
@@ -251,7 +249,7 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
except OSError: except OSError:
disc_bytes = 0 disc_bytes = 0
marge = 1024**3 # 1 GB Sicherheitsabstand marge = 1024**3 # 1 GB Sicherheitsabstand
if disc_type in ("dvd", "bluray"): if ist_video:
rip_ziel = raw_dir if transcode_an else final_dir rip_ziel = raw_dir if transcode_an else final_dir
fehler = _platz_pruefen(rip_ziel, disc_bytes + marge, "den Roh-Rip") fehler = _platz_pruefen(rip_ziel, disc_bytes + marge, "den Roh-Rip")
if not fehler and transcode_an and einstellungen.get("keepOriginal", False): if not fehler and transcode_an and einstellungen.get("keepOriginal", False):
@@ -276,6 +274,21 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
progress_cb=fortschritt, output_dir=final_dir, progress_cb=fortschritt, output_dir=final_dir,
) )
# 4K-UHD scheitert mit Nicht-LibreDrive-Laufwerken IMMER an der
# Verschlüsselung — "Failed to open disc" heißt dann nicht "Disc kaputt",
# sondern "Laufwerk kann kein UHD" (Firmware-Faden, siehe SAVEPOINT).
if (
ergebnis.get("status") == "error"
and disc_type == "uhd"
and "Failed to open disc" in (ergebnis.get("error") or "")
):
ergebnis["error"] += (
" — 4K-UHD erkannt: Das Laufwerk kann UHD-Discs vermutlich nicht "
"entschlüsseln. Dafür ist eine LibreDrive-Firmware nötig (BU40N: "
"Crossflash auf 1.03-MK, MakeMKV-Forum 'Ultimate UHD Drives "
"Flashing Guide'). Normale BD/DVD gehen weiterhin."
)
if ergebnis.get("status") == "success" and transcode_an: if ergebnis.get("status") == "success" and transcode_an:
# Kompression als eigener Task auf der transcode-Queue — kann vom # Kompression als eigener Task auf der transcode-Queue — kann vom
# lokalen Worker ODER einem Remote-GPU-Worker übernommen werden. # lokalen Worker ODER einem Remote-GPU-Worker übernommen werden.
+13
View File
@@ -11,6 +11,7 @@ from detection import (
CDS_AUDIO, CDS_AUDIO,
CDS_DATA_1, CDS_DATA_1,
CDS_MIXED, CDS_MIXED,
UHD_MIN_BYTES,
classify, classify,
) )
@@ -34,6 +35,18 @@ def test_bluray_ab_schwelle():
assert classify(CDS_DATA_1, BLURAY_MIN_BYTES) == "bluray" assert classify(CDS_DATA_1, BLURAY_MIN_BYTES) == "bluray"
def test_bd50_bleibt_bluray():
# BD-50 = 50 * 10^9 Bytes ≈ 46,6 GiB — knapp unter der UHD-Schwelle
assert classify(CDS_DATA_1, 50 * 1000**3) == "bluray"
def test_uhd_ab_schwelle():
# BD-66 (66 GB) und BD-100 sind 4K-UHD-Medien
assert classify(CDS_DATA_1, 66 * 1000**3) == "uhd"
assert classify(CDS_DATA_1, 100 * 1000**3) == "uhd"
assert classify(CDS_DATA_1, UHD_MIN_BYTES) == "uhd"
def test_unbekannter_status(): def test_unbekannter_status():
assert classify(999, 5 * 1024**3) == "unknown" assert classify(999, 5 * 1024**3) == "unknown"
assert classify(0, 0) == "unknown" assert classify(0, 0) == "unknown"