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())
|
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'}`}>
|
||||||
|
|||||||
Reference in New Issue
Block a user