Praxis-Feedback-Runde: CIFS-Cap-Fix, TMDB v3-Keys, 4K-UHD-Typ, Vollautomatik, Job-Verwaltung, Worker-Namen
Ampel / ampel (push) Successful in 29s
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:
@@ -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 |
|
||||
|
||||
+27
@@ -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.
|
||||
|
||||
+44
-1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,14 +27,19 @@ 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)
|
||||
@@ -35,7 +49,7 @@ class TMDBClient:
|
||||
try:
|
||||
response = self.session.get(
|
||||
f"{TMDB_BASE_URL}/{endpoint}",
|
||||
params=params,
|
||||
params={**(params or {}), **self._key_params},
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
+145
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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<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) => {
|
||||
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()}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
{device.disc!.metadata?.overview && (
|
||||
@@ -266,7 +276,7 @@ export default function DeviceDiscovery() {
|
||||
'Rippt'}
|
||||
</span>
|
||||
<span className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
Typ: {device.type.toUpperCase()}
|
||||
Typ: {typLabel(device.type)}
|
||||
</span>
|
||||
</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={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Disc</span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -331,6 +341,7 @@ export default function DeviceDiscovery() {
|
||||
Pfad — dort eingehängte Shares sind direkt wählbar) */}
|
||||
<RipTargetModal
|
||||
isOpen={modalDevice !== null}
|
||||
initialType={modalDevice?.type === 'cd' ? 'music' : 'movies'}
|
||||
onClose={() => setModalDevice(null)}
|
||||
onSave={(target) => {
|
||||
if (modalDevice) {
|
||||
|
||||
@@ -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 && (
|
||||
<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">
|
||||
<p className={`text-sm font-medium flex items-center gap-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>
|
||||
<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">
|
||||
{dateien.map(f => (
|
||||
<a
|
||||
|
||||
@@ -13,6 +13,7 @@ interface TargetConfig {
|
||||
|
||||
interface RipTargetModalProps {
|
||||
isOpen: boolean
|
||||
initialType?: TargetConfig['type'] // CD → Musik vorwählen (Medium-gerecht)
|
||||
onClose: () => 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<TargetConfig['type']>('movies')
|
||||
const [targets, setTargets] = useState<TargetConfig[]>([
|
||||
{ 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'
|
||||
|
||||
@@ -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() {
|
||||
)}
|
||||
</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={`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
|
||||
onClick={() => browseParent && browsen(browseParent)}
|
||||
disabled={!browseParent}
|
||||
@@ -249,6 +271,7 @@ export default function StorageMounts() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
{/* Neues Netzwerk-Ziel */}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
@@ -55,7 +61,8 @@ export default function WorkerVerwaltung() {
|
||||
const rippyHost = window.location.hostname
|
||||
const installBefehl = [
|
||||
`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')
|
||||
|
||||
const kopieren = () => {
|
||||
@@ -88,12 +95,15 @@ export default function WorkerVerwaltung() {
|
||||
/>
|
||||
<Server size={18} className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'} />
|
||||
<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'}`}>
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap gap-1 justify-end">
|
||||
{w.encoders.map(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'}`}>
|
||||
@@ -101,6 +111,20 @@ export default function WorkerVerwaltung() {
|
||||
</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>
|
||||
@@ -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>
|
||||
<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
|
||||
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.
|
||||
</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'}`}>
|
||||
{installBefehl}
|
||||
@@ -127,7 +152,10 @@ export default function WorkerVerwaltung() {
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<Job[]> {
|
||||
@@ -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<string, string> = 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',
|
||||
}
|
||||
const Icon = icons[type as keyof typeof icons] || Disc
|
||||
: {
|
||||
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 fallback = theme === 'dark' ? 'bg-slate-800 text-slate-300' : 'bg-slate-100 text-slate-700'
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium ${colors}`}>
|
||||
<Icon size={14} />
|
||||
{type.toUpperCase()}
|
||||
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium ${colors[type] || fallback}`}>
|
||||
<Disc size={14} />
|
||||
{labels[type] || type.toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -141,7 +153,30 @@ export default function Dashboard() {
|
||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [detailJobId, setDetailJobId] = useState<string | null>(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() {
|
||||
<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`}>
|
||||
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Neueste Jobs</h2>
|
||||
<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 className="overflow-x-auto transition-colors duration-300">
|
||||
<table className="w-full transition-colors duration-300">
|
||||
@@ -314,15 +361,30 @@ export default function Dashboard() {
|
||||
Download
|
||||
</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
|
||||
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)"
|
||||
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
|
||||
</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') && (
|
||||
<button
|
||||
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 */}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ interface SettingsState {
|
||||
ripAllTracks: boolean
|
||||
mainFeatureOnly: boolean
|
||||
autoEject: boolean
|
||||
autoRipStart: boolean
|
||||
notificationWebhook: string
|
||||
transcodeEnabled: boolean
|
||||
transcodePreset: string
|
||||
@@ -37,6 +38,7 @@ const defaultSettings: SettingsState = {
|
||||
ripAllTracks: true,
|
||||
mainFeatureOnly: false,
|
||||
autoEject: true,
|
||||
autoRipStart: false,
|
||||
notificationWebhook: '',
|
||||
transcodeEnabled: true,
|
||||
transcodePreset: 'H.265 MKV 1080p30',
|
||||
@@ -80,9 +82,29 @@ export default function SettingsPage() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = 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 { 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(() => {
|
||||
api.get('/capabilities')
|
||||
.then(r => setWorkers(r.data.workers || []))
|
||||
@@ -182,20 +204,28 @@ export default function SettingsPage() {
|
||||
Ripping-Einstellungen
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4 transition-colors duration-300">
|
||||
<div className="flex items-center justify-between p-4 transition-colors duration-300">
|
||||
<div className="space-y-6 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={`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} />
|
||||
</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 (nicht nur Main Feature)</p>
|
||||
<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'}`}>
|
||||
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>
|
||||
<Toggle
|
||||
checked={settings.ripAllTracks}
|
||||
onChange={(v) => handleChange('ripAllTracks', v)}
|
||||
checked={settings.autoRipStart}
|
||||
onChange={(v) => handleChange('autoRipStart', v)}
|
||||
darkMode={theme === 'dark'}
|
||||
/>
|
||||
</div>
|
||||
@@ -217,6 +247,39 @@ export default function SettingsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className={`text-xs px-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
🌐 Sprachen & 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 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'}`}>
|
||||
@@ -409,6 +472,38 @@ export default function SettingsPage() {
|
||||
API-Schlüssel
|
||||
</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-2 transition-colors duration-300">
|
||||
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
||||
|
||||
+27
-1
@@ -26,6 +26,26 @@ def erkenne_encoder() -> list:
|
||||
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:
|
||||
"""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)
|
||||
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"):
|
||||
info["makemkv"] = os.getenv("MAKEMKV_VERSION") or "installiert"
|
||||
if shutil.which("HandBrakeCLI"):
|
||||
|
||||
@@ -39,12 +39,17 @@ def melde_faehigkeiten(**kwargs):
|
||||
import threading
|
||||
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():
|
||||
while True:
|
||||
try:
|
||||
db.init_db()
|
||||
db.save_worker(
|
||||
socket.gethostname(),
|
||||
anzeigename,
|
||||
caps.erkenne_encoder(),
|
||||
caps.werkzeug_versionen(),
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
+20
-7
@@ -142,7 +142,7 @@ def _job_abschliessen(job_id: str, ergebnis: dict) -> None:
|
||||
return
|
||||
if ergebnis.get("status") == "success":
|
||||
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)
|
||||
einstellungen = db.get_settings()
|
||||
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)
|
||||
|
||||
einstellungen = db.get_settings()
|
||||
transcode_an = (
|
||||
disc_type in ("dvd", "bluray")
|
||||
and einstellungen.get("transcodeEnabled", True)
|
||||
)
|
||||
ist_video = disc_type in ("dvd", "bluray", "uhd")
|
||||
transcode_an = ist_video 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
|
||||
_makemkv_key_anwenden(einstellungen)
|
||||
|
||||
@@ -251,7 +249,7 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
||||
except OSError:
|
||||
disc_bytes = 0
|
||||
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
|
||||
fehler = _platz_pruefen(rip_ziel, disc_bytes + marge, "den Roh-Rip")
|
||||
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,
|
||||
)
|
||||
|
||||
# 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:
|
||||
# Kompression als eigener Task auf der transcode-Queue — kann vom
|
||||
# lokalen Worker ODER einem Remote-GPU-Worker übernommen werden.
|
||||
|
||||
@@ -11,6 +11,7 @@ from detection import (
|
||||
CDS_AUDIO,
|
||||
CDS_DATA_1,
|
||||
CDS_MIXED,
|
||||
UHD_MIN_BYTES,
|
||||
classify,
|
||||
)
|
||||
|
||||
@@ -34,6 +35,18 @@ def test_bluray_ab_schwelle():
|
||||
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():
|
||||
assert classify(999, 5 * 1024**3) == "unknown"
|
||||
assert classify(0, 0) == "unknown"
|
||||
|
||||
Reference in New Issue
Block a user