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:
@@ -25,8 +25,12 @@ export default function StorageMounts() {
|
||||
const [mounts, setMounts] = useState<Mount[]>([])
|
||||
const [targets, setTargets] = useState<Target[]>([])
|
||||
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<string[]>([])
|
||||
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() {
|
||||
<option value="cifs">SMB/CIFS (Windows-Freigabe, NAS)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||
Quelle {typ === 'nfs' ? '(host:/export/pfad)' : '(//host/freigabe)'}
|
||||
</label>
|
||||
<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'} />
|
||||
</div>
|
||||
{typ === 'cifs' && (
|
||||
{typ === 'nfs' ? (
|
||||
<div className="md:col-span-2">
|
||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||
Quelle (host:/export/pfad)
|
||||
</label>
|
||||
<input value={source} onChange={e => setSource(e.target.value)} className={feld} placeholder="192.168.1.10:/volume1/filme" />
|
||||
</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>
|
||||
<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" />
|
||||
@@ -226,7 +281,7 @@ export default function StorageMounts() {
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{busy ? 'Hänge ein…' : 'Einhängen & speichern'}
|
||||
|
||||
@@ -10,6 +10,21 @@ interface DeviceOption {
|
||||
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 {
|
||||
type: 'movie' | 'tv' | 'music' | 'unknown'
|
||||
id: number
|
||||
@@ -44,8 +59,44 @@ export default function MetadataPreview() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [suchbegriff, setSuchbegriff] = useState('')
|
||||
const [kandidaten, setKandidaten] = useState<SuchKandidat[]>([])
|
||||
const [sucheLaeuft, setSucheLaeuft] = useState(false)
|
||||
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
|
||||
// (/dev/dvd, /dev/bluray), die es im Container nie gab.
|
||||
useEffect(() => {
|
||||
@@ -184,24 +235,85 @@ export default function MetadataPreview() {
|
||||
</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 */}
|
||||
{result && (
|
||||
<div className={`rounded-lg shadow overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-blue-600 to-indigo-700 p-6 text-white">
|
||||
<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={32} /> })()}
|
||||
<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>
|
||||
{/* 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 flex gap-5">
|
||||
{posterAbsolut(result.metadata?.poster_path) && (
|
||||
<img
|
||||
src={posterAbsolut(result.metadata?.poster_path)!}
|
||||
alt={result.title}
|
||||
className="w-24 rounded-lg shadow-lg object-cover flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{/* Confidence Score */}
|
||||
|
||||
@@ -265,14 +265,17 @@ export default function SettingsPage() {
|
||||
Speicherziele
|
||||
</h2>
|
||||
|
||||
{/* Standard-Unterordner (ehemals eigener Tab "Verzeichnisse" —
|
||||
gehört inhaltlich hierher) */}
|
||||
{/* Schnellwahl-Ordner: die drei Knöpfe im "Rippen starten"-Dialog */}
|
||||
<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'}`}>
|
||||
<Database size={16} className="text-indigo-500" /> Standard-Unterordner
|
||||
<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" /> Schnellwahl beim Rippen
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{([['outputDir', 'Basis'], ['movieDir', 'Filme'], ['seriesDir', 'Serien'], ['musicDir', 'Musik']] as const).map(([feld, label]) => (
|
||||
<p className={`text-xs mb-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
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}>
|
||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{label}</label>
|
||||
<input
|
||||
@@ -284,9 +287,6 @@ export default function SettingsPage() {
|
||||
</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>
|
||||
|
||||
<StorageMounts />
|
||||
|
||||
Reference in New Issue
Block a user