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
|
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
@@ -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
@@ -1,6 +1,49 @@
|
|||||||
# SAVEPOINT — Rippy
|
# 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`
|
**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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,14 +27,19 @@ 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)
|
||||||
@@ -35,7 +49,7 @@ class TMDBClient:
|
|||||||
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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -217,6 +247,39 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 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'}`}>
|
||||||
@@ -409,6 +472,38 @@ export default function SettingsPage() {
|
|||||||
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
@@ -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"):
|
||||||
|
|||||||
@@ -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(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user