Korrektur-Flow + Freigaben-Auswahl + Schreibtest + Schnellwahl-Klartext
Ampel / ampel (push) Failing after 52s
Ampel / ampel (push) Failing after 52s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 .
|
||||
|
||||
@@ -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()}"
|
||||
|
||||
@@ -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:
|
||||
|
||||
+122
-4
@@ -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
|
||||
|
||||
+45
-3
@@ -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/<name> ein. Wirft RuntimeError."""
|
||||
username: str = "", passwort: str = "") -> bool:
|
||||
"""Hängt ein NFS/CIFS-Ziel unter /app/media/<name> 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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user