Dashboard-Disc-Karte: Auto-Pre-Scan beim Einlegen — man SIEHT was drinliegt
Ampel / ampel (push) Successful in 41s

Commander-Befund: Disc wurde erkannt, aber das UI verriet nicht WAS.
- Watcher stoesst beim Einlegen (und beim Start mit bereits eingelegter
  Disc) automatisch den Pre-Scan an, Ergebnis landet im DISC_CACHE und
  im Log ("Disc erkannt: Titel (Jahr) [typ, Confidence]")
- GET /devices liefert das disc-Feld mit Titel/Jahr/Typ/Confidence/Poster
- UI: prominente Karte "Im Laufwerk erkannt" mit Poster (TMDB relativ
  oder OMDb absolut), Beschreibung und Rippen-Start (Ziel-Dialog)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-23 19:55:18 +02:00
parent dfef585ec8
commit e350523f7e
2 changed files with 103 additions and 2 deletions
+38 -2
View File
@@ -63,8 +63,34 @@ async def startup_event():
asyncio.create_task(disc_watcher()) asyncio.create_task(disc_watcher())
# Auto-Pre-Scan-Ergebnisse je Laufwerk: das Dashboard zeigt damit sofort,
# WAS im Laufwerk liegt (Titel/Jahr/Poster), ohne dass jemand klicken muss.
DISC_CACHE: Dict[str, Dict] = {}
async def _auto_prescan(pfad: str):
"""Identifiziert die eingelegte Disc im Hintergrund und cached das Ergebnis."""
if DISC_CACHE.get(pfad, {}).get("_laeuft"):
return
DISC_CACHE[pfad] = {"_laeuft": True, "title": "Wird erkannt…"}
try:
prescan = PreScan()
ergebnis = await asyncio.to_thread(prescan.scan, pfad)
DISC_CACHE[pfad] = ergebnis.to_dict()
db.add_log(
"info", "watcher",
f"Disc erkannt: {ergebnis.title}"
+ (f" ({ergebnis.year})" if ergebnis.year else "")
+ f" [{ergebnis.disc_type}, Confidence {ergebnis.confidence:.0%}] auf {pfad}",
)
except Exception as e:
DISC_CACHE.pop(pfad, None)
print(f"Auto-Pre-Scan {pfad}: {e}")
async def disc_watcher(): async def disc_watcher():
"""Disc-Wache: pollt die Laufwerke und protokolliert Einwurf/Auswurf. """Disc-Wache: pollt die Laufwerke, protokolliert Einwurf/Auswurf und
stößt beim Einlegen automatisch den Pre-Scan an (Dashboard-Disc-Karte).
Ersetzt den nie gebauten udev-Daemon aus dem KONZEPT: udev funktioniert im Ersetzt den nie gebauten udev-Daemon aus dem KONZEPT: udev funktioniert im
Container nicht sinnvoll (kein udevd) — ein 3-Sekunden-Poll per ioctl ist Container nicht sinnvoll (kein udevd) — ein 3-Sekunden-Poll per ioctl ist
@@ -79,11 +105,17 @@ async def disc_watcher():
except OSError: except OSError:
continue continue
vorher = bekannt.get(pfad) vorher = bekannt.get(pfad)
if vorher is not None and status != vorher: if vorher is None:
# Erststart: liegt schon eine Disc drin, direkt erkennen
if status == CDS_DISC_OK:
asyncio.create_task(_auto_prescan(pfad))
elif status != vorher:
if status == CDS_DISC_OK: if status == CDS_DISC_OK:
db.add_log("info", "watcher", f"Disc eingelegt: {pfad}") db.add_log("info", "watcher", f"Disc eingelegt: {pfad}")
asyncio.create_task(_auto_prescan(pfad))
elif status in (CDS_NO_DISC, CDS_TRAY_OPEN) and vorher == CDS_DISC_OK: elif status in (CDS_NO_DISC, CDS_TRAY_OPEN) and vorher == CDS_DISC_OK:
db.add_log("info", "watcher", f"Disc entfernt: {pfad}") db.add_log("info", "watcher", f"Disc entfernt: {pfad}")
DISC_CACHE.pop(pfad, None)
bekannt[pfad] = status bekannt[pfad] = status
except Exception as e: except Exception as e:
print(f"Disc-Wache: {e}") print(f"Disc-Wache: {e}")
@@ -147,6 +179,7 @@ class Device(BaseModel):
status: str status: str
model: Optional[str] = None model: Optional[str] = None
serial: Optional[str] = None serial: Optional[str] = None
disc: Optional[Dict] = None # Auto-Pre-Scan-Ergebnis (Titel/Jahr/Poster)
def _job_row_to_model(zeile: dict) -> Job: def _job_row_to_model(zeile: dict) -> Job:
@@ -323,6 +356,9 @@ async def get_devices():
geraete = [] geraete = []
for pfad in device_discovery.list_optical_devices(): for pfad in device_discovery.list_optical_devices():
info = await asyncio.to_thread(device_discovery.device_info, pfad) info = await asyncio.to_thread(device_discovery.device_info, pfad)
disc = DISC_CACHE.get(pfad)
if disc and not disc.get("_laeuft"):
info["disc"] = disc
geraete.append(Device(**info)) geraete.append(Device(**info))
return geraete return geraete
@@ -4,6 +4,18 @@ import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext' import { useDarkMode } from '../context/ThemeContext'
import RipTargetModal from './RipTargetModal' import RipTargetModal from './RipTargetModal'
interface DiscInfo {
title: string
year?: number
disc_type: string
confidence: number
metadata?: {
poster_path?: string
overview?: string
type?: string
}
}
interface Device { interface Device {
id: string id: string
name: string name: string
@@ -12,6 +24,14 @@ interface Device {
status: 'empty' | 'ready' | 'ripping' status: 'empty' | 'ready' | 'ripping'
serial?: string serial?: string
model?: string model?: string
disc?: DiscInfo
}
// TMDB liefert relative Poster-Pfade, OMDb absolute URLs — beides abdecken
function posterUrl(disc?: DiscInfo): string | null {
const p = disc?.metadata?.poster_path
if (!p) return null
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
} }
export default function DeviceDiscovery() { export default function DeviceDiscovery() {
@@ -122,6 +142,51 @@ export default function DeviceDiscovery() {
</div> </div>
<div className="p-6 transition-colors duration-300"> <div className="p-6 transition-colors duration-300">
{/* Disc-Karte: WAS liegt gerade im Laufwerk (Auto-Pre-Scan) */}
{devices.filter(d => d.status === 'ready' && d.disc).map(device => (
<div
key={`disc-${device.id}`}
className={`mb-6 rounded-xl overflow-hidden border-2 flex ${theme === 'dark' ? 'border-indigo-500/40 bg-gradient-to-r from-indigo-950/60 to-slate-900' : 'border-indigo-200 bg-gradient-to-r from-indigo-50 to-white'}`}
>
{posterUrl(device.disc) && (
<img
src={posterUrl(device.disc)!}
alt={device.disc!.title}
className="w-28 object-cover flex-shrink-0"
/>
)}
<div className="p-5 flex-1 min-w-0">
<p className={`text-xs font-semibold uppercase tracking-wider mb-1 ${theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'}`}>
Im Laufwerk erkannt
</p>
<h3 className={`text-xl font-bold truncate ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
{device.disc!.title}
{device.disc!.year ? <span className={`font-normal ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> ({device.disc!.year})</span> : null}
</h3>
<div className="flex items-center gap-2 mt-2">
<span className={`text-xs font-medium px-2 py-0.5 rounded ${getTypeColor(device.type)}`}>
{device.disc!.disc_type?.toUpperCase()}
</span>
<span className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Übereinstimmung {Math.round((device.disc!.confidence || 0) * 100)} %
</span>
</div>
{device.disc!.metadata?.overview && (
<p className={`text-sm mt-2 line-clamp-2 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-600'}`}>
{device.disc!.metadata.overview}
</p>
)}
<button
onClick={() => setModalDevice(device)}
disabled={actionBusy}
className="mt-3 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
>
Rippen starten
</button>
</div>
</div>
))}
{devices.length === 0 ? ( {devices.length === 0 ? (
<div className="text-center py-12 transition-colors duration-300"> <div className="text-center py-12 transition-colors duration-300">
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-4 ${theme === 'dark' ? 'bg-slate-700' : 'bg-slate-100'}`}> <div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-4 ${theme === 'dark' ? 'bg-slate-700' : 'bg-slate-100'}`}>