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:
Audiolib
2026-05-26 13:11:04 +02:00
parent dfbb397e46
commit 52c10a7518
32 changed files with 2987 additions and 223 deletions

View 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>
)
}