Phase 5-9: Matching-Engine, Podcast-Support, Web-Interface + Player
Backend: - Matching-Orchestrator mit deutschen Serien-Patterns (drei ???, TKKG, ...) - Vollständige MusicBrainz-Integration (Tracklist → Kapitel, Cover Art Archive) - OpenLibrary + Google Books als Fallback-Quellen - Auto-Accept (≥0.75) vs zu_prüfen (0.5-0.75) vs kein Match - Manuelles Matching: GET /api/items/:id/match/search, POST apply - RSS-Feed-Manager: feedparser, iTunes Search, periodisches Update - APScheduler für Podcast-Feed-Updates (konfigurierbares Intervall) - Podcast-Router: Feed-URL setzen, Episoden, Feed-Suche - HLS: FFmpeg läuft als Background-Task, wartet auf ersten Segment - main.py: APScheduler + neue Router eingebunden Frontend (React + Vite + Tailwind + HLS.js): - Login-Seite mit Fehlerbehandlung - Library-Seite: Grid/Listen-Ansicht, Suche, Tag-Filter, Pagination, Scan - BookCard: Cover, Fortschrittsbalken, zu_prüfen Badge, Quick-Play - BookDetail: Metadaten, Matching-Panel, Kapitel-Liste, Lesezeichen - AudioPlayer: HLS.js, Kapitel-Marker auf Fortschrittsbalken, Speed, Sleep-Timer, Lesezeichen, Keyboard-Shortcuts (Space/Arrows) - MiniPlayer: persistent an Fußzeile, expandierbar - PodcastDetail: Feed-URL, iTunes-Suche, Episoden-Liste - Admin-Panel: Benutzer/Bibliotheken/Einstellungen verwalten - App.tsx: React Router, Auth-Guard, Player-Overlay Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
183
frontend/src/pages/Library.tsx
Normal file
183
frontend/src/pages/Library.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Search, RefreshCw, Grid, List, Loader2 } from 'lucide-react'
|
||||
import { getLibraryItems, scanLibrary } from '../api/libraries'
|
||||
import { getMe } from '../api/me'
|
||||
import BookCard from '../components/library/BookCard'
|
||||
|
||||
const PAGE_SIZE = 48
|
||||
|
||||
export default function Library() {
|
||||
const { libraryId } = useParams<{ libraryId: string }>()
|
||||
const [items, setItems] = useState<any[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(0)
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [scanning, setScanning] = useState(false)
|
||||
const [progressMap, setProgressMap] = useState<Record<string, any>>({})
|
||||
const [view, setView] = useState<'grid' | 'list'>('grid')
|
||||
const [filterTag, setFilterTag] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!libraryId) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await getLibraryItems(libraryId, { page, limit: PAGE_SIZE, search: search || undefined })
|
||||
setItems(data.results || [])
|
||||
setTotal(data.total || 0)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [libraryId, page, search])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
useEffect(() => {
|
||||
getMe().then((me) => {
|
||||
const map: Record<string, any> = {}
|
||||
for (const p of me.mediaProgress || []) map[p.libraryItemId] = p
|
||||
setProgressMap(map)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleScan = async () => {
|
||||
if (!libraryId) return
|
||||
setScanning(true)
|
||||
await scanLibrary(libraryId).catch(() => {})
|
||||
setTimeout(() => { setScanning(false); load() }, 3000)
|
||||
}
|
||||
|
||||
const searchDebounce = useCallback(
|
||||
(() => {
|
||||
let t: ReturnType<typeof setTimeout>
|
||||
return (v: string) => { clearTimeout(t); setSearch(v); setPage(0) }
|
||||
})(),
|
||||
[]
|
||||
)
|
||||
|
||||
const displayed = filterTag
|
||||
? items.filter((i) => (i.media?.tags || []).includes(filterTag))
|
||||
: items
|
||||
|
||||
const allTags = [...new Set(items.flatMap((i) => i.media?.tags || []))]
|
||||
|
||||
// Inject progress into items
|
||||
const enriched = displayed.map((i) => ({ ...i, _progress: progressMap[i.id] }))
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-3 mb-6 flex-wrap">
|
||||
<div className="relative flex-1 min-w-48">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen..."
|
||||
className="w-full bg-surface border border-white/10 rounded-lg pl-9 pr-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
onChange={(e) => searchDebounce(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{allTags.length > 0 && (
|
||||
<select
|
||||
value={filterTag}
|
||||
onChange={(e) => setFilterTag(e.target.value)}
|
||||
className="bg-surface border border-white/10 rounded-lg px-3 py-2 text-sm text-gray-300 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="">Alle Tags</option>
|
||||
{allTags.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleScan}
|
||||
disabled={scanning}
|
||||
className="flex items-center gap-2 bg-surface border border-white/10 px-3 py-2 rounded-lg text-sm text-gray-300 hover:text-white hover:bg-white/5 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={14} className={scanning ? 'animate-spin' : ''} />
|
||||
Scan
|
||||
</button>
|
||||
|
||||
<div className="flex bg-surface border border-white/10 rounded-lg overflow-hidden">
|
||||
<button
|
||||
className={`p-2 ${view === 'grid' ? 'bg-white/10 text-white' : 'text-gray-400'}`}
|
||||
onClick={() => setView('grid')}
|
||||
><Grid size={16} /></button>
|
||||
<button
|
||||
className={`p-2 ${view === 'list' ? 'bg-white/10 text-white' : 'text-gray-400'}`}
|
||||
onClick={() => setView('list')}
|
||||
><List size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
{total} {total === 1 ? 'Eintrag' : 'Einträge'}
|
||||
{filterTag && ` · Filter: ${filterTag}`}
|
||||
</p>
|
||||
|
||||
{/* Grid */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Loader2 className="text-primary animate-spin" size={32} />
|
||||
</div>
|
||||
) : enriched.length === 0 ? (
|
||||
<div className="text-center py-16 text-gray-500">
|
||||
<p className="text-lg mb-2">Keine Einträge gefunden</p>
|
||||
<p className="text-sm">Klicke auf „Scan" um die Bibliothek zu durchsuchen.</p>
|
||||
</div>
|
||||
) : view === 'grid' ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{enriched.map((item) => <BookCard key={item.id} item={item} />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{enriched.map((item) => {
|
||||
const meta = item.media?.metadata || {}
|
||||
const title = meta.title || item.relPath || 'Unbekannt'
|
||||
const author = meta.authors?.[0]?.name || ''
|
||||
const p = item._progress
|
||||
return (
|
||||
<div key={item.id} className="flex items-center gap-4 bg-surface hover:bg-white/5 px-4 py-3 rounded-lg cursor-pointer">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">{title}</p>
|
||||
{author && <p className="text-xs text-gray-400">{author}</p>}
|
||||
</div>
|
||||
{p && !p.isFinished && (
|
||||
<div className="w-24 h-1 bg-white/10 rounded-full overflow-hidden flex-shrink-0">
|
||||
<div className="h-full bg-primary" style={{ width: `${Math.min((p.currentTime / p.duration) * 100, 100)}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{p?.isFinished && <span className="text-xs text-primary flex-shrink-0">Fertig</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="flex justify-center gap-2 mt-8">
|
||||
<button
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
className="px-4 py-2 bg-surface border border-white/10 rounded-lg text-sm text-gray-300 disabled:opacity-50 hover:bg-white/5"
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
<span className="px-4 py-2 text-sm text-gray-400">
|
||||
{page + 1} / {Math.ceil(total / PAGE_SIZE)}
|
||||
</span>
|
||||
<button
|
||||
disabled={(page + 1) * PAGE_SIZE >= total}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
className="px-4 py-2 bg-surface border border-white/10 rounded-lg text-sm text-gray-300 disabled:opacity-50 hover:bg-white/5"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user