Dashboard-Disc-Karte: Auto-Pre-Scan beim Einlegen — man SIEHT was drinliegt
Ampel / ampel (push) Successful in 41s
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:
+38
-2
@@ -63,8 +63,34 @@ async def startup_event():
|
||||
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():
|
||||
"""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
|
||||
Container nicht sinnvoll (kein udevd) — ein 3-Sekunden-Poll per ioctl ist
|
||||
@@ -79,11 +105,17 @@ async def disc_watcher():
|
||||
except OSError:
|
||||
continue
|
||||
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:
|
||||
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:
|
||||
db.add_log("info", "watcher", f"Disc entfernt: {pfad}")
|
||||
DISC_CACHE.pop(pfad, None)
|
||||
bekannt[pfad] = status
|
||||
except Exception as e:
|
||||
print(f"Disc-Wache: {e}")
|
||||
@@ -147,6 +179,7 @@ class Device(BaseModel):
|
||||
status: str
|
||||
model: 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:
|
||||
@@ -323,6 +356,9 @@ async def get_devices():
|
||||
geraete = []
|
||||
for pfad in device_discovery.list_optical_devices():
|
||||
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))
|
||||
return geraete
|
||||
|
||||
|
||||
@@ -4,6 +4,18 @@ import { api } from '../lib/api'
|
||||
import { useDarkMode } from '../context/ThemeContext'
|
||||
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 {
|
||||
id: string
|
||||
name: string
|
||||
@@ -12,6 +24,14 @@ interface Device {
|
||||
status: 'empty' | 'ready' | 'ripping'
|
||||
serial?: 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() {
|
||||
@@ -122,6 +142,51 @@ export default function DeviceDiscovery() {
|
||||
</div>
|
||||
|
||||
<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 ? (
|
||||
<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'}`}>
|
||||
|
||||
Reference in New Issue
Block a user