Etappe 3: Metadaten-Lookup + Pre-Scan
- SQLite-Cache für API-Rate-Limits (LRU, 10k Einträge) - TMDB/MusicBrainz/TheTVDB Clients - Pre-Scan-Modul für TOC-Lesung ohne Ripping - Metadaten-Preview UI - Jellyfin-Formatierung (NFO + Images) - API Endpoints für Lookup, Confirm, Format
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
import { BookOpen, Music, Film, AlertCircle, CheckCircle, Search, Loader2 } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface Metadata {
|
||||
type: 'movie' | 'tv' | 'music' | 'unknown'
|
||||
id: number
|
||||
title: string
|
||||
year?: number
|
||||
overview?: string
|
||||
poster_path?: string
|
||||
backdrop_path?: string
|
||||
runtime?: number
|
||||
genres?: string[]
|
||||
}
|
||||
|
||||
interface Track {
|
||||
track_number: number
|
||||
title: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
interface PrescanResult {
|
||||
disc_type: 'cd' | 'dvd' | 'bluray'
|
||||
title: string
|
||||
year?: number
|
||||
confidence: number
|
||||
metadata?: Metadata
|
||||
tracks?: Track[]
|
||||
}
|
||||
|
||||
const API_URL = 'http://localhost:8000'
|
||||
|
||||
export default function MetadataPreview() {
|
||||
const [device, setDevice] = useState<string>('')
|
||||
const [result, setResult] = useState<PrescanResult | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
|
||||
const handleScan = async () => {
|
||||
if (!device) {
|
||||
setError('Bitte wählen Sie ein Gerät aus.')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${API_URL}/prescan`, { device_path: device })
|
||||
setResult(response.data)
|
||||
} catch (err) {
|
||||
setError('Pre-Scan fehlgeschlagen.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!result) return
|
||||
|
||||
setConfirming(true)
|
||||
|
||||
try {
|
||||
await axios.post(`${API_URL}/metadata/confirm`, {
|
||||
title: result.title,
|
||||
year: result.year,
|
||||
metadata: result.metadata
|
||||
})
|
||||
|
||||
// Job erstellen
|
||||
await axios.post(`${API_URL}/jobs`, {
|
||||
device: device,
|
||||
type: result.disc_type
|
||||
})
|
||||
} catch (err) {
|
||||
setError('Bestätigung fehlgeschlagen.')
|
||||
} finally {
|
||||
setConfirming(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getIcon = (type: string): LucideIcon => {
|
||||
switch (type) {
|
||||
case 'cd':
|
||||
return Music
|
||||
case 'dvd':
|
||||
case 'bluray':
|
||||
return Film
|
||||
default:
|
||||
return BookOpen
|
||||
}
|
||||
}
|
||||
|
||||
const getConfidenceColor = (confidence: number): string => {
|
||||
if (confidence >= 0.9) return 'text-green-600 bg-green-100'
|
||||
if (confidence >= 0.7) return 'text-yellow-600 bg-yellow-100'
|
||||
return 'text-red-600 bg-red-100'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Metadaten-Preview</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Wählen Sie ein Gerät aus, um die Metadaten vor dem Ripping zu lookupen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Device Selection */}
|
||||
<div className="bg-white p-4 rounded-lg shadow mb-6">
|
||||
<div className="flex gap-4">
|
||||
<select
|
||||
value={device}
|
||||
onChange={(e) => setDevice(e.target.value)}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Gerät auswählen...</option>
|
||||
<option value="/dev/cdrom">CD-ROM (/dev/cdrom)</option>
|
||||
<option value="/dev/dvd">DVD (/dev/dvd)</option>
|
||||
<option value="/dev/bluray">Blu-ray (/dev/bluray)</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={handleScan}
|
||||
disabled={!device || loading}
|
||||
className={`flex items-center gap-2 px-6 py-2 rounded-lg text-white ${
|
||||
!device || loading
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={20} />
|
||||
Scannen...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search size={20} />
|
||||
Scan starten
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-center gap-3 text-red-700">
|
||||
<AlertCircle size={24} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{/* 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">
|
||||
{getIcon(result.disc_type)({ 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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confidence Score */}
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">Übereinstimmung:</span>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getConfidenceColor(result.confidence)}`}>
|
||||
{Math.round(result.confidence * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="p-6">
|
||||
{result.metadata && (
|
||||
<div className="space-y-4">
|
||||
{result.metadata.overview && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 uppercase mb-2">Beschreibung</h3>
|
||||
<p className="text-gray-700 leading-relaxed">{result.metadata.overview}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.metadata.genres && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 uppercase mb-2">Genre</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{result.metadata.genres.map((genre, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm"
|
||||
>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.metadata.runtime && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 uppercase mb-2">Laufzeit</h3>
|
||||
<p className="text-gray-700">{Math.floor(result.metadata.runtime / 60)}h {result.metadata.runtime % 60}m</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tracks */}
|
||||
{result.tracks && result.tracks.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 uppercase mb-3">Tracks</h3>
|
||||
<div className="space-y-2">
|
||||
{result.tracks.map((track, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 font-medium text-gray-500">{track.track_number}</span>
|
||||
<span className="text-gray-900">{track.title}</span>
|
||||
</div>
|
||||
{track.duration && (
|
||||
<span className="text-sm text-gray-500">
|
||||
{Math.floor(track.duration / 60)}:{String(track.duration % 60).padStart(2, '0')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<div className="p-6 bg-gray-50 border-t border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 text-sm text-gray-600">
|
||||
<CheckCircle className="text-green-600" size={20} />
|
||||
<span>Bereit zum Ripping</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={confirming}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
{confirming ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={20} />
|
||||
Bestätigen...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle size={20} />
|
||||
Rippen starten
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user