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 \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
nfs-common \
|
nfs-common \
|
||||||
cifs-utils \
|
cifs-utils \
|
||||||
|
smbclient \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY docker/api/requirements.txt .
|
COPY docker/api/requirements.txt .
|
||||||
|
|||||||
@@ -33,6 +33,32 @@ class JikanClient:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.session = requests.Session()
|
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]:
|
def lookup(self, title: str) -> Optional[Dict]:
|
||||||
"""Sucht per Titel; nimmt den ÄHNLICHSTEN Treffer, nicht blind den ersten."""
|
"""Sucht per Titel; nimmt den ÄHNLICHSTEN Treffer, nicht blind den ersten."""
|
||||||
cache_key = f"jikan:{title.lower()}"
|
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.api_key = get_settings().get("omdbApiKey") or settings.omdb_api_key
|
||||||
self.session = requests.Session()
|
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]:
|
def lookup(self, title: str, year: Optional[int] = None) -> Optional[Dict]:
|
||||||
"""Sucht per Titel (t=), liefert normalisierte Metadaten oder None."""
|
"""Sucht per Titel (t=), liefert normalisierte Metadaten oder None."""
|
||||||
if not self.api_key:
|
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")
|
raise HTTPException(status_code=409, detail="Name bereits vergeben")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(
|
schreibbar = await asyncio.to_thread(
|
||||||
mount_verwaltung.mounten,
|
mount_verwaltung.mounten,
|
||||||
request.name, request.type, request.source,
|
request.name, request.type, request.source,
|
||||||
request.options or "", request.username or "", request.password or "",
|
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 "",
|
request.options or "", request.username or "", request.password or "",
|
||||||
)
|
)
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
db.add_log, "success", "mounts",
|
db.add_log,
|
||||||
f"Speicherziel '{request.name}' ({request.type}) eingehängt: {request.source}",
|
"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}")
|
@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}
|
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
|
# Pre-Scan Endpoint
|
||||||
class PreScanRequest(BaseModel):
|
class PreScanRequest(BaseModel):
|
||||||
device_path: str
|
device_path: str
|
||||||
|
|||||||
+45
-3
@@ -33,13 +33,54 @@ def ist_gemountet(name: str) -> bool:
|
|||||||
return os.path.ismount(_mountpoint(name))
|
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 = "",
|
def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
||||||
username: str = "", passwort: str = "") -> None:
|
username: str = "", passwort: str = "") -> bool:
|
||||||
"""Hängt ein NFS/CIFS-Ziel unter /app/media/<name> ein. Wirft RuntimeError."""
|
"""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)
|
ziel = _mountpoint(name)
|
||||||
os.makedirs(ziel, exist_ok=True)
|
os.makedirs(ziel, exist_ok=True)
|
||||||
if os.path.ismount(ziel):
|
if os.path.ismount(ziel):
|
||||||
return
|
return schreibtest(ziel)
|
||||||
|
|
||||||
creds_datei = None
|
creds_datei = None
|
||||||
try:
|
try:
|
||||||
@@ -69,6 +110,7 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
|||||||
if ergebnis.returncode != 0:
|
if ergebnis.returncode != 0:
|
||||||
fehler = (ergebnis.stderr or ergebnis.stdout or "").strip()
|
fehler = (ergebnis.stderr or ergebnis.stdout or "").strip()
|
||||||
raise RuntimeError(f"mount schlug fehl: {fehler[:300]}")
|
raise RuntimeError(f"mount schlug fehl: {fehler[:300]}")
|
||||||
|
return schreibtest(ziel)
|
||||||
finally:
|
finally:
|
||||||
if creds_datei:
|
if creds_datei:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -150,6 +150,20 @@ def titel_kandidaten(titel: str) -> List[str]:
|
|||||||
return kandidaten
|
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]:
|
def read_udf_volume_label(device_path: str) -> Optional[str]:
|
||||||
"""Liest das Volume-Label aus dem UDF Primary Volume Descriptor.
|
"""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
|
# Cache-Key braucht den Fingerabdruck (sonst erbt die nächste Disc
|
||||||
# die Metadaten der vorherigen).
|
# die Metadaten der vorherigen).
|
||||||
toc["disc_type"] = disc_type
|
toc["disc_type"] = disc_type
|
||||||
try:
|
toc["fingerprint"] = disc_fingerprint(device_path)
|
||||||
groesse = detection.disc_size_bytes(device_path)
|
|
||||||
except OSError:
|
|
||||||
groesse = 0
|
|
||||||
toc["fingerprint"] = f"{toc.get('title') or 'unbekannt'}|{groesse}"
|
|
||||||
|
|
||||||
if disc_type == "CD":
|
if disc_type == "CD":
|
||||||
return self._scan_audio(device_path, toc)
|
return self._scan_audio(device_path, toc)
|
||||||
|
|||||||
@@ -25,8 +25,12 @@ export default function StorageMounts() {
|
|||||||
const [mounts, setMounts] = useState<Mount[]>([])
|
const [mounts, setMounts] = useState<Mount[]>([])
|
||||||
const [targets, setTargets] = useState<Target[]>([])
|
const [targets, setTargets] = useState<Target[]>([])
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [typ, setTyp] = useState<'nfs' | 'cifs'>('nfs')
|
const [typ, setTyp] = useState<'nfs' | 'cifs'>('cifs')
|
||||||
const [source, setSource] = useState('')
|
const [source, setSource] = useState('')
|
||||||
|
const [host, setHost] = useState('')
|
||||||
|
const [shares, setShares] = useState<string[]>([])
|
||||||
|
const [selectedShare, setSelectedShare] = useState('')
|
||||||
|
const [sharesBusy, setSharesBusy] = useState(false)
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
@@ -75,16 +79,43 @@ export default function StorageMounts() {
|
|||||||
|
|
||||||
useEffect(() => { laden(); browsen('/app/media') }, [])
|
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 () => {
|
const hinzufuegen = async () => {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setFeedback(null)
|
setFeedback(null)
|
||||||
|
const quelle = typ === 'cifs' && host && selectedShare ? `//${host}/${selectedShare}` : source
|
||||||
try {
|
try {
|
||||||
await api.post('/storage-mounts', {
|
const r = await api.post('/storage-mounts', {
|
||||||
name, type: typ, source,
|
name, type: typ, source: quelle,
|
||||||
...(username ? { username, password } : {}),
|
...(username ? { username, password } : {}),
|
||||||
})
|
})
|
||||||
setFeedback(`✓ „${name}" eingehängt — ab sofort als Rip-Ziel wählbar`)
|
setFeedback(
|
||||||
setName(''); setSource(''); setUsername(''); setPassword('')
|
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()
|
laden()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setFeedback(`✗ ${e?.response?.data?.detail || 'Einhängen fehlgeschlagen'}`)
|
setFeedback(`✗ ${e?.response?.data?.detail || 'Einhängen fehlgeschlagen'}`)
|
||||||
@@ -205,14 +236,38 @@ export default function StorageMounts() {
|
|||||||
<option value="cifs">SMB/CIFS (Windows-Freigabe, NAS)</option>
|
<option value="cifs">SMB/CIFS (Windows-Freigabe, NAS)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
{typ === 'nfs' ? (
|
||||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<div className="md:col-span-2">
|
||||||
Quelle {typ === 'nfs' ? '(host:/export/pfad)' : '(//host/freigabe)'}
|
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||||
</label>
|
Quelle (host:/export/pfad)
|
||||||
<input value={source} onChange={e => setSource(e.target.value)} className={feld} placeholder={typ === 'nfs' ? '192.168.1.10:/volume1/filme' : '//192.168.1.10/filme'} />
|
</label>
|
||||||
</div>
|
<input value={source} onChange={e => setSource(e.target.value)} className={feld} placeholder="192.168.1.10:/volume1/filme" />
|
||||||
{typ === 'cifs' && (
|
</div>
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<div>
|
||||||
|
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||||
|
Rechner (dein PC oder NAS — Name oder IP)
|
||||||
|
</label>
|
||||||
|
<input value={host} onChange={e => setHost(e.target.value)} className={feld} placeholder="192.168.178.20 oder TOBIS-PC" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<button
|
||||||
|
onClick={freigabenAuflisten}
|
||||||
|
disabled={!host || sharesBusy}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-40 ${theme === 'dark' ? 'bg-slate-700 text-indigo-300 hover:bg-slate-600' : 'bg-slate-200 text-indigo-700 hover:bg-slate-300'}`}
|
||||||
|
>
|
||||||
|
{sharesBusy ? 'Suche…' : 'Freigaben auflisten'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{shares.length > 0 && (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Freigabe wählen</label>
|
||||||
|
<select value={selectedShare} onChange={e => setSelectedShare(e.target.value)} className={feld}>
|
||||||
|
{shares.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Benutzer (leer = Gast)</label>
|
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Benutzer (leer = Gast)</label>
|
||||||
<input value={username} onChange={e => setUsername(e.target.value)} className={feld} autoComplete="off" />
|
<input value={username} onChange={e => setUsername(e.target.value)} className={feld} autoComplete="off" />
|
||||||
@@ -226,7 +281,7 @@ export default function StorageMounts() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={hinzufuegen}
|
onClick={hinzufuegen}
|
||||||
disabled={busy || !name || !source}
|
disabled={busy || !name || (typ === 'nfs' ? !source : !(host && selectedShare))}
|
||||||
className="mt-4 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
|
className="mt-4 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{busy ? 'Hänge ein…' : 'Einhängen & speichern'}
|
{busy ? 'Hänge ein…' : 'Einhängen & speichern'}
|
||||||
|
|||||||
@@ -10,6 +10,21 @@ interface DeviceOption {
|
|||||||
status: string
|
status: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SuchKandidat {
|
||||||
|
title: string
|
||||||
|
year?: number
|
||||||
|
poster?: string
|
||||||
|
overview?: string
|
||||||
|
type: string
|
||||||
|
source: string
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function posterAbsolut(p?: string): string | null {
|
||||||
|
if (!p) return null
|
||||||
|
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
|
||||||
|
}
|
||||||
|
|
||||||
interface Metadata {
|
interface Metadata {
|
||||||
type: 'movie' | 'tv' | 'music' | 'unknown'
|
type: 'movie' | 'tv' | 'music' | 'unknown'
|
||||||
id: number
|
id: number
|
||||||
@@ -44,8 +59,44 @@ export default function MetadataPreview() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [confirming, setConfirming] = useState(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
|
const [suchbegriff, setSuchbegriff] = useState('')
|
||||||
|
const [kandidaten, setKandidaten] = useState<SuchKandidat[]>([])
|
||||||
|
const [sucheLaeuft, setSucheLaeuft] = useState(false)
|
||||||
const { theme } = useDarkMode()
|
const { theme } = useDarkMode()
|
||||||
|
|
||||||
|
const titelSuchen = async () => {
|
||||||
|
if (!suchbegriff) return
|
||||||
|
setSucheLaeuft(true)
|
||||||
|
try {
|
||||||
|
const r = await api.get('/metadata/search', { params: { q: suchbegriff } })
|
||||||
|
setKandidaten(r.data)
|
||||||
|
} catch {
|
||||||
|
setKandidaten([])
|
||||||
|
} finally {
|
||||||
|
setSucheLaeuft(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kandidatWaehlen = async (k: SuchKandidat) => {
|
||||||
|
try {
|
||||||
|
const r = await api.post('/metadata/override', {
|
||||||
|
device_path: device,
|
||||||
|
title: k.title,
|
||||||
|
year: k.year,
|
||||||
|
poster: k.poster,
|
||||||
|
overview: k.overview,
|
||||||
|
type: k.type,
|
||||||
|
source: k.source,
|
||||||
|
id: k.id,
|
||||||
|
})
|
||||||
|
setResult(r.data) // Karte + Dashboard zeigen ab jetzt DEINE Wahl
|
||||||
|
setKandidaten([])
|
||||||
|
setSuchbegriff('')
|
||||||
|
} catch {
|
||||||
|
setError('Übernehmen fehlgeschlagen.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Echte Laufwerke laden — vorher standen hier erfundene Festwerte
|
// Echte Laufwerke laden — vorher standen hier erfundene Festwerte
|
||||||
// (/dev/dvd, /dev/bluray), die es im Container nie gab.
|
// (/dev/dvd, /dev/bluray), die es im Container nie gab.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -184,24 +235,85 @@ export default function MetadataPreview() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Manuelle Korrektur: wenn die Automatik danebenliegt, entscheidest DU */}
|
||||||
|
{device && (
|
||||||
|
<div className={`p-4 rounded-lg shadow mb-6 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
|
||||||
|
<p className={`text-sm font-medium mb-2 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
||||||
|
Erkennung falsch? Richtigen Titel suchen und übernehmen:
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<input
|
||||||
|
value={suchbegriff}
|
||||||
|
onChange={e => setSuchbegriff(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && titelSuchen()}
|
||||||
|
placeholder="z. B. Evangelion 2.22"
|
||||||
|
className={`flex-1 px-4 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-700 border-slate-600 text-slate-100' : 'bg-white border-gray-300 text-slate-900'}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={titelSuchen}
|
||||||
|
disabled={!suchbegriff || sucheLaeuft}
|
||||||
|
className="flex items-center gap-2 px-5 py-2 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{sucheLaeuft ? <Loader2 className="animate-spin" size={18} /> : <Search size={18} />}
|
||||||
|
Suchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{kandidaten.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-4">
|
||||||
|
{kandidaten.map((k, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => kandidatWaehlen(k)}
|
||||||
|
className={`text-left rounded-lg border overflow-hidden transition-all hover:border-indigo-500 hover:shadow-lg ${theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-slate-50 border-slate-200'}`}
|
||||||
|
>
|
||||||
|
{k.poster ? (
|
||||||
|
<img src={k.poster} alt={k.title} className="w-full h-40 object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className={`w-full h-40 flex items-center justify-center ${theme === 'dark' ? 'bg-slate-800 text-slate-600' : 'bg-slate-100 text-slate-400'}`}>
|
||||||
|
<Film size={32} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-2">
|
||||||
|
<p className={`text-xs font-semibold leading-tight line-clamp-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{k.title}</p>
|
||||||
|
<p className={`text-xs mt-0.5 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||||
|
{k.year || '—'} · {k.type === 'tv' ? 'Serie' : 'Film'} · {k.source}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
{result && (
|
{result && (
|
||||||
<div className={`rounded-lg shadow overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
|
<div className={`rounded-lg shadow overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
|
||||||
{/* Header */}
|
{/* Header — mit Poster, wenn eine Quelle eins geliefert hat */}
|
||||||
<div className="bg-gradient-to-r from-blue-600 to-indigo-700 p-6 text-white">
|
<div className="bg-gradient-to-r from-blue-600 to-indigo-700 p-6 text-white flex gap-5">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
{posterAbsolut(result.metadata?.poster_path) && (
|
||||||
{/* Fix 23.07.: Lucide-Icons sind forwardRef-Komponenten — ein
|
<img
|
||||||
Direkt-Aufruf getIcon(...)({size}) crasht die ganze Seite
|
src={posterAbsolut(result.metadata?.poster_path)!}
|
||||||
(weißes Fenster). Als JSX rendern. */}
|
alt={result.title}
|
||||||
{(() => { const TypIcon = getIcon(result.disc_type); return <TypIcon size={32} /> })()}
|
className="w-24 rounded-lg shadow-lg object-cover flex-shrink-0"
|
||||||
<span className="text-sm font-medium opacity-90 uppercase tracking-wide">
|
/>
|
||||||
{result.disc_type.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-3xl font-bold">{result.title}</h2>
|
|
||||||
{result.year && (
|
|
||||||
<p className="text-lg opacity-90 mt-1">{result.year}</p>
|
|
||||||
)}
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
{/* 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 <TypIcon size={28} /> })()}
|
||||||
|
<span className="text-sm font-medium opacity-90 uppercase tracking-wide">
|
||||||
|
{result.disc_type.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-bold">{result.title}</h2>
|
||||||
|
{result.year && (
|
||||||
|
<p className="text-lg opacity-90 mt-1">{result.year}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Confidence Score */}
|
{/* Confidence Score */}
|
||||||
|
|||||||
@@ -265,14 +265,17 @@ export default function SettingsPage() {
|
|||||||
Speicherziele
|
Speicherziele
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Standard-Unterordner (ehemals eigener Tab "Verzeichnisse" —
|
{/* Schnellwahl-Ordner: die drei Knöpfe im "Rippen starten"-Dialog */}
|
||||||
gehört inhaltlich hierher) */}
|
|
||||||
<div className={`rounded-lg p-4 mb-6 border ${theme === 'dark' ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50'}`}>
|
<div className={`rounded-lg p-4 mb-6 border ${theme === 'dark' ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50'}`}>
|
||||||
<h3 className={`font-medium mb-3 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<h3 className={`font-medium mb-1 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||||
<Database size={16} className="text-indigo-500" /> Standard-Unterordner
|
<Database size={16} className="text-indigo-500" /> Schnellwahl beim Rippen
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<p className={`text-xs mb-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||||
{([['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.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{([['movieDir', '🎥 Filme'], ['seriesDir', '📺 Serien'], ['musicDir', '🎵 Musik']] as const).map(([feld, label]) => (
|
||||||
<div key={feld}>
|
<div key={feld}>
|
||||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{label}</label>
|
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{label}</label>
|
||||||
<input
|
<input
|
||||||
@@ -284,9 +287,6 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
||||||
Diese Ordner bietet die Schnellwahl beim Rippen an. Speichern nicht vergessen (Knopf unten).
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StorageMounts />
|
<StorageMounts />
|
||||||
|
|||||||
Reference in New Issue
Block a user