From 6b7d3917370eeafd728e461738a5931429ef1a23 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Thu, 23 Jul 2026 21:34:34 +0200 Subject: [PATCH] Korrektur-Flow + Freigaben-Auswahl + Schreibtest + Schnellwahl-Klartext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commander-Befunde (23.07. spaet): - MANUELLE KORREKTUR (KONZEPT Schritt 5 endlich komplett): Metadaten-Seite hat jetzt Titelsuche ueber ALLE Quellen (GET /metadata/search) mit Poster-Kacheln; ein Klick uebernimmt den Treffer (POST /metadata/override) fuer Karte, Jobs UND merkt ihn sich 30 Tage per Disc-Fingerabdruck — dieselbe Disc wird beim naechsten Einlegen sofort richtig erkannt - PC/NAS per Klick: SMB-Freigaben eines Rechners auflisten (GET /storage-mounts/shares via smbclient) statt //host/share raten - Berechtigungs-PRUEFUNG beim Einhaengen: Schreibtest, Warnung bei nur-lesbaren Zielen (API + UI + Log) - "Standard-Unterordner" verstaendlich gemacht: heisst jetzt "Schnellwahl beim Rippen" mit Erklaertext; kryptisches Basis-Feld raus - Preview zeigt Poster; Fingerprint fuer Cache/Override vereinheitlicht (billiges Label+Groesse statt Mount-Titel) Co-Authored-By: Claude Fable 5 --- docker/api/Dockerfile | 1 + docker/api/clients/jikan.py | 26 ++++ docker/api/clients/omdb.py | 28 +++++ docker/api/main.py | 126 ++++++++++++++++++- docker/api/mounts.py | 48 ++++++- docker/api/prescan/prescan.py | 20 ++- docker/ui/src/components/StorageMounts.tsx | 81 ++++++++++-- docker/ui/src/pages/MetadataPreview.tsx | 140 ++++++++++++++++++--- docker/ui/src/pages/Settings.tsx | 18 +-- 9 files changed, 440 insertions(+), 48 deletions(-) diff --git a/docker/api/Dockerfile b/docker/api/Dockerfile index 701deea..937fcaa 100644 --- a/docker/api/Dockerfile +++ b/docker/api/Dockerfile @@ -9,6 +9,7 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ nfs-common \ cifs-utils \ + smbclient \ && rm -rf /var/lib/apt/lists/* COPY docker/api/requirements.txt . diff --git a/docker/api/clients/jikan.py b/docker/api/clients/jikan.py index 42ef7f1..f1c7512 100644 --- a/docker/api/clients/jikan.py +++ b/docker/api/clients/jikan.py @@ -33,6 +33,32 @@ class JikanClient: def __init__(self): self.session = requests.Session() + def suche(self, title: str) -> list: + """Roh-Kandidaten für die manuelle Korrektur (ohne Ähnlichkeits-Gate).""" + try: + response = self.session.get( + f"{JIKAN_BASE_URL}/anime", + params={"q": title, "limit": 5, "sfw": "true"}, + timeout=10, + ) + response.raise_for_status() + eintraege = response.json().get("data") or [] + except Exception: + return [] + ergebnisse = [] + for e in eintraege: + bilder = (e.get("images") or {}).get("jpg") or {} + ergebnisse.append({ + "title": e.get("title_english") or e.get("title") or "", + "year": e.get("year"), + "poster": bilder.get("large_image_url") or bilder.get("image_url") or "", + "overview": (e.get("synopsis") or "")[:200], + "type": "movie" if e.get("type") == "Movie" else "tv", + "source": "jikan", + "id": f"mal-{e.get('mal_id', '')}", + }) + return ergebnisse + def lookup(self, title: str) -> Optional[Dict]: """Sucht per Titel; nimmt den ÄHNLICHSTEN Treffer, nicht blind den ersten.""" cache_key = f"jikan:{title.lower()}" diff --git a/docker/api/clients/omdb.py b/docker/api/clients/omdb.py index baefda2..9050f23 100644 --- a/docker/api/clients/omdb.py +++ b/docker/api/clients/omdb.py @@ -37,6 +37,34 @@ class OMDbClient: self.api_key = get_settings().get("omdbApiKey") or settings.omdb_api_key self.session = requests.Session() + def suche(self, title: str) -> list: + """Roh-Kandidaten für die manuelle Korrektur (unscharfe s=-Suche).""" + if not self.api_key: + return [] + try: + antwort = self.session.get( + OMDB_BASE_URL, + params={"apikey": self.api_key, "s": title, "r": "json"}, + timeout=10, + ).json() + except Exception: + return [] + if antwort.get("Response") != "True": + return [] + ergebnisse = [] + for e in (antwort.get("Search") or [])[:5]: + poster = e.get("Poster", "") + ergebnisse.append({ + "title": e.get("Title", ""), + "year": parse_year(e.get("Year", "")), + "poster": poster if poster != "N/A" else "", + "overview": "", + "type": "tv" if e.get("Type") == "series" else "movie", + "source": "omdb", + "id": e.get("imdbID", ""), + }) + return ergebnisse + def lookup(self, title: str, year: Optional[int] = None) -> Optional[Dict]: """Sucht per Titel (t=), liefert normalisierte Metadaten oder None.""" if not self.api_key: diff --git a/docker/api/main.py b/docker/api/main.py index d976138..3f1c017 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -421,7 +421,7 @@ async def create_storage_mount(request: MountRequest): raise HTTPException(status_code=409, detail="Name bereits vergeben") try: - await asyncio.to_thread( + schreibbar = await asyncio.to_thread( mount_verwaltung.mounten, request.name, request.type, request.source, request.options or "", request.username or "", request.password or "", @@ -435,10 +435,24 @@ async def create_storage_mount(request: MountRequest): request.options or "", request.username or "", request.password or "", ) await asyncio.to_thread( - db.add_log, "success", "mounts", - f"Speicherziel '{request.name}' ({request.type}) eingehängt: {request.source}", + db.add_log, + "success" if schreibbar else "warning", "mounts", + f"Speicherziel '{request.name}' ({request.type}) eingehängt: {request.source}" + + ("" if schreibbar else " — ACHTUNG: NUR LESBAR (Schreibtest fehlgeschlagen)"), ) - return {"name": request.name, "mounted": True} + return {"name": request.name, "mounted": True, "writable": schreibbar} + + +@app.get("/storage-mounts/shares") +async def list_shares(host: str, username: str = "", password: str = ""): + """SMB-Freigaben eines Rechners auflisten (PC/NAS per Klick wählen).""" + try: + freigaben = await asyncio.to_thread( + mount_verwaltung.liste_smb_freigaben, host, username, password + ) + except RuntimeError as e: + raise HTTPException(status_code=400, detail=str(e)) + return {"host": host, "shares": freigaben} @app.delete("/storage-mounts/{name}") @@ -615,6 +629,110 @@ async def confirm_metadata(title: str, year: Optional[int] = None, metadata: Dic return {"status": "confirmed", "key": cache_key} +@app.get("/metadata/search") +async def metadata_search(q: str): + """Manuelle Korrektur: Titel-Kandidaten aus ALLEN Quellen (TMDB/Jikan/OMDb). + + KONZEPT Schritt 5: „Commander bestätigt oder korrigiert manuell" — das + hier ist der Korrektur-Teil, wenn die Automatik danebenliegt. + """ + def sammle(): + prescan = PreScan() + ergebnisse = [] + for movie in (prescan.tmdb.search_movie(q) or [])[:4]: + ergebnisse.append({ + "title": movie.get("title", ""), + "year": int(movie["release_date"][:4]) if movie.get("release_date") else None, + "poster": f"https://image.tmdb.org/t/p/w342{movie['poster_path']}" if movie.get("poster_path") else "", + "overview": (movie.get("overview") or "")[:200], + "type": "movie", "source": "tmdb", "id": str(movie.get("id", "")), + }) + for show in (prescan.tmdb.search_tv(q) or [])[:3]: + ergebnisse.append({ + "title": show.get("name", ""), + "year": int(show["first_air_date"][:4]) if show.get("first_air_date") else None, + "poster": f"https://image.tmdb.org/t/p/w342{show['poster_path']}" if show.get("poster_path") else "", + "overview": (show.get("overview") or "")[:200], + "type": "tv", "source": "tmdb", "id": str(show.get("id", "")), + }) + ergebnisse += prescan.jikan.suche(q) + ergebnisse += prescan.omdb.suche(q) + + # Mit Poster zuerst, Duplikate (Titel+Jahr) raus + gesehen, dedup = set(), [] + for e in sorted(ergebnisse, key=lambda x: 0 if x.get("poster") else 1): + schluessel = ((e.get("title") or "").lower(), e.get("year")) + if schluessel in gesehen: + continue + gesehen.add(schluessel) + dedup.append(e) + return dedup[:12] + + return await asyncio.to_thread(sammle) + + +class MetadataOverride(BaseModel): + device_path: str + title: str + year: Optional[int] = None + poster: Optional[str] = None + overview: Optional[str] = None + type: Optional[str] = "movie" + source: Optional[str] = None + id: Optional[str] = None + + +@app.post("/metadata/override") +async def metadata_override(request: MetadataOverride): + """Nutzer-Wahl für DIESE Disc merken: Karte, Jobs und Cache (30 Tage). + + Der Disc-Fingerabdruck (Label+Größe) macht die Korrektur wiedererkennbar — + dieselbe Disc wird beim nächsten Einlegen sofort richtig angezeigt. + """ + if request.device_path not in device_discovery.list_optical_devices(): + raise HTTPException(status_code=404, detail="Laufwerk nicht gefunden") + + def speichere(): + from cache import set as cache_setter + from cache.keys import generate_prescan_key + from prescan.prescan import disc_fingerprint + + ergebnis = { + "disc_type": (DISC_CACHE.get(request.device_path) or {}).get("disc_type", "Blu-ray"), + "title": request.title, + "year": request.year, + "confidence": 0.99, + "metadata": { + "type": request.type or "movie", + "id": request.id or "", + "title": request.title, + "year": request.year, + "overview": request.overview or "", + "poster_path": request.poster or "", + "backdrop_path": "", + "runtime": 0, + "genres": [], + "source": request.source or "manuell", + }, + "tracks": [], + } + abdruck = disc_fingerprint(request.device_path) + cache_setter( + generate_prescan_key(request.device_path, False, abdruck), + ergebnis, expire=30 * 86400, + ) + DISC_CACHE[request.device_path] = ergebnis + return ergebnis + + ergebnis = await asyncio.to_thread(speichere) + await asyncio.to_thread( + db.add_log, "success", "api", + f"Metadaten manuell festgelegt: {request.title}" + + (f" ({request.year})" if request.year else ""), + ) + return ergebnis + + # Pre-Scan Endpoint class PreScanRequest(BaseModel): device_path: str diff --git a/docker/api/mounts.py b/docker/api/mounts.py index b74e007..24e7ec1 100644 --- a/docker/api/mounts.py +++ b/docker/api/mounts.py @@ -33,13 +33,54 @@ def ist_gemountet(name: str) -> bool: return os.path.ismount(_mountpoint(name)) +def schreibtest(pfad: str) -> bool: + """Berechtigungs-Prüfung: können wir im Ziel wirklich schreiben? + + Commander-Anforderung 23.07.: beim Hinzufügen muss geprüft werden, ob + die Rechte stimmen — ein nur-lesbares Ziel fällt sonst erst beim Rip auf. + """ + testdatei = os.path.join(pfad, ".rippy-schreibtest") + try: + with open(testdatei, "w") as f: + f.write("ok") + os.unlink(testdatei) + return True + except OSError: + return False + + +def liste_smb_freigaben(host: str, username: str = "", passwort: str = "") -> list: + """Listet die SMB-Freigaben eines Rechners (smbclient -L) — damit man + seinen PC/NAS per Klick wählt statt //host/share zu raten.""" + cmd = ["smbclient", "-L", f"//{host}", "-g"] + if username: + cmd += ["-U", f"{username}%{passwort or ''}"] + else: + cmd += ["-N"] + ergebnis = subprocess.run(cmd, capture_output=True, text=True, timeout=20) + freigaben = [] + for zeile in (ergebnis.stdout or "").splitlines(): + # -g-Format: Disk|Freigabename|Kommentar + teile = zeile.split("|") + if len(teile) >= 2 and teile[0] == "Disk" and not teile[1].endswith("$"): + freigaben.append(teile[1]) + if not freigaben and ergebnis.returncode != 0: + fehler = (ergebnis.stderr or ergebnis.stdout or "").strip() + raise RuntimeError(f"Freigaben-Abfrage fehlgeschlagen: {fehler[:200]}") + return freigaben + + def mounten(name: str, typ: str, quelle: str, optionen: str = "", - username: str = "", passwort: str = "") -> None: - """Hängt ein NFS/CIFS-Ziel unter /app/media/ ein. Wirft RuntimeError.""" + username: str = "", passwort: str = "") -> bool: + """Hängt ein NFS/CIFS-Ziel unter /app/media/ ein. + + Rückgabe: True wenn das Ziel SCHREIBBAR ist. Wirft RuntimeError bei + Mount-Fehlern. + """ ziel = _mountpoint(name) os.makedirs(ziel, exist_ok=True) if os.path.ismount(ziel): - return + return schreibtest(ziel) creds_datei = None try: @@ -69,6 +110,7 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "", if ergebnis.returncode != 0: fehler = (ergebnis.stderr or ergebnis.stdout or "").strip() raise RuntimeError(f"mount schlug fehl: {fehler[:300]}") + return schreibtest(ziel) finally: if creds_datei: try: diff --git a/docker/api/prescan/prescan.py b/docker/api/prescan/prescan.py index 864c25d..96c3340 100644 --- a/docker/api/prescan/prescan.py +++ b/docker/api/prescan/prescan.py @@ -150,6 +150,20 @@ def titel_kandidaten(titel: str) -> List[str]: return kandidaten +def disc_fingerprint(device_path: str) -> str: + """Billiger, stabiler Disc-Fingerabdruck (Volume-Label + Größe). + + Für Cache-Keys und das Merken manueller Korrekturen — bewusst OHNE den + teuren Mount-Titel, damit ihn jeder Endpunkt schnell berechnen kann. + """ + label = read_iso_volume_label(device_path) or read_udf_volume_label(device_path) or "unbekannt" + try: + groesse = detection.disc_size_bytes(device_path) + except OSError: + groesse = 0 + return f"{label}|{groesse}" + + def read_udf_volume_label(device_path: str) -> Optional[str]: """Liest das Volume-Label aus dem UDF Primary Volume Descriptor. @@ -226,11 +240,7 @@ class PreScan: # Cache-Key braucht den Fingerabdruck (sonst erbt die nächste Disc # die Metadaten der vorherigen). toc["disc_type"] = disc_type - try: - groesse = detection.disc_size_bytes(device_path) - except OSError: - groesse = 0 - toc["fingerprint"] = f"{toc.get('title') or 'unbekannt'}|{groesse}" + toc["fingerprint"] = disc_fingerprint(device_path) if disc_type == "CD": return self._scan_audio(device_path, toc) diff --git a/docker/ui/src/components/StorageMounts.tsx b/docker/ui/src/components/StorageMounts.tsx index 33e4ed2..3b4a92d 100644 --- a/docker/ui/src/components/StorageMounts.tsx +++ b/docker/ui/src/components/StorageMounts.tsx @@ -25,8 +25,12 @@ export default function StorageMounts() { const [mounts, setMounts] = useState([]) const [targets, setTargets] = useState([]) const [name, setName] = useState('') - const [typ, setTyp] = useState<'nfs' | 'cifs'>('nfs') + const [typ, setTyp] = useState<'nfs' | 'cifs'>('cifs') const [source, setSource] = useState('') + const [host, setHost] = useState('') + const [shares, setShares] = useState([]) + const [selectedShare, setSelectedShare] = useState('') + const [sharesBusy, setSharesBusy] = useState(false) const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [busy, setBusy] = useState(false) @@ -75,16 +79,43 @@ export default function StorageMounts() { useEffect(() => { laden(); browsen('/app/media') }, []) + const freigabenAuflisten = async () => { + if (!host) return + setSharesBusy(true) + setFeedback(null) + try { + const r = await api.get('/storage-mounts/shares', { + params: { host, ...(username ? { username, password } : {}) }, + }) + setShares(r.data.shares) + if (r.data.shares.length === 0) { + setFeedback('✗ Keine Freigaben gefunden — Rechner erreichbar und Freigaben aktiv?') + } else { + setSelectedShare(r.data.shares[0]) + } + } catch (e: any) { + setFeedback(`✗ ${e?.response?.data?.detail || 'Freigaben-Abfrage fehlgeschlagen'}`) + setShares([]) + } finally { + setSharesBusy(false) + } + } + const hinzufuegen = async () => { setBusy(true) setFeedback(null) + const quelle = typ === 'cifs' && host && selectedShare ? `//${host}/${selectedShare}` : source try { - await api.post('/storage-mounts', { - name, type: typ, source, + const r = await api.post('/storage-mounts', { + name, type: typ, source: quelle, ...(username ? { username, password } : {}), }) - setFeedback(`✓ „${name}" eingehängt — ab sofort als Rip-Ziel wählbar`) - setName(''); setSource(''); setUsername(''); setPassword('') + setFeedback( + r.data.writable + ? `✓ „${name}" eingehängt — Schreibtest OK, ab sofort als Rip-Ziel wählbar` + : `⚠️ „${name}" eingehängt, aber NUR LESBAR — Freigabe-Rechte prüfen!` + ) + setName(''); setSource(''); setHost(''); setShares([]); setSelectedShare(''); setUsername(''); setPassword('') laden() } catch (e: any) { setFeedback(`✗ ${e?.response?.data?.detail || 'Einhängen fehlgeschlagen'}`) @@ -205,14 +236,38 @@ export default function StorageMounts() { -
- - setSource(e.target.value)} className={feld} placeholder={typ === 'nfs' ? '192.168.1.10:/volume1/filme' : '//192.168.1.10/filme'} /> -
- {typ === 'cifs' && ( + {typ === 'nfs' ? ( +
+ + setSource(e.target.value)} className={feld} placeholder="192.168.1.10:/volume1/filme" /> +
+ ) : ( <> +
+ + setHost(e.target.value)} className={feld} placeholder="192.168.178.20 oder TOBIS-PC" /> +
+
+ +
+ {shares.length > 0 && ( +
+ + +
+ )}
setUsername(e.target.value)} className={feld} autoComplete="off" /> @@ -226,7 +281,7 @@ export default function StorageMounts() {
+ + + {kandidaten.length > 0 && ( +
+ {kandidaten.map((k, i) => ( + + ))} +
+ )} + + )} + {/* Results */} {result && (
- {/* Header */} -
-
- {/* Fix 23.07.: Lucide-Icons sind forwardRef-Komponenten — ein - Direkt-Aufruf getIcon(...)({size}) crasht die ganze Seite - (weißes Fenster). Als JSX rendern. */} - {(() => { const TypIcon = getIcon(result.disc_type); return })()} - - {result.disc_type.toUpperCase()} - -
-

{result.title}

- {result.year && ( -

{result.year}

+ {/* Header — mit Poster, wenn eine Quelle eins geliefert hat */} +
+ {posterAbsolut(result.metadata?.poster_path) && ( + {result.title} )} +
+
+ {/* Fix 23.07.: Lucide-Icons sind forwardRef-Komponenten — ein + Direkt-Aufruf getIcon(...)({size}) crasht die ganze Seite + (weißes Fenster). Als JSX rendern. */} + {(() => { const TypIcon = getIcon(result.disc_type); return })()} + + {result.disc_type.toUpperCase()} + +
+

{result.title}

+ {result.year && ( +

{result.year}

+ )} +
{/* Confidence Score */} diff --git a/docker/ui/src/pages/Settings.tsx b/docker/ui/src/pages/Settings.tsx index 62f81e0..0cf0f4f 100644 --- a/docker/ui/src/pages/Settings.tsx +++ b/docker/ui/src/pages/Settings.tsx @@ -265,14 +265,17 @@ export default function SettingsPage() { Speicherziele - {/* Standard-Unterordner (ehemals eigener Tab "Verzeichnisse" — - gehört inhaltlich hierher) */} + {/* Schnellwahl-Ordner: die drei Knöpfe im "Rippen starten"-Dialog */}
-

- Standard-Unterordner +

+ Schnellwahl beim Rippen

-
- {([['outputDir', 'Basis'], ['movieDir', 'Filme'], ['seriesDir', 'Serien'], ['musicDir', 'Musik']] as const).map(([feld, label]) => ( +

+ Beim Start eines Rips fragt Rippy „Wohin?" — diese drei Ordner erscheinen + dort als 🎥/📺/🎵-Schnellwahl. Hier legst du nur fest, wie sie heißen. +

+
+ {([['movieDir', '🎥 Filme'], ['seriesDir', '📺 Serien'], ['musicDir', '🎵 Musik']] as const).map(([feld, label]) => (
))}
-

- Diese Ordner bietet die Schnellwahl beim Rippen an. Speichern nicht vergessen (Knopf unten). -