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,262 @@
import React, { useEffect, useState } from 'react'
import { Users, Library, Settings, Trash2, Plus, RefreshCw, Loader2, Check, X } from 'lucide-react'
import { getUsers, createUser, deleteUser, getSettings, updateSettings } from '../api/me'
import { getLibraries, scanLibrary, createLibrary, deleteLibrary } from '../api/libraries'
type Tab = 'users' | 'libraries' | 'settings'
export default function Admin() {
const [tab, setTab] = useState<Tab>('users')
return (
<div className="p-6">
<h1 className="text-2xl font-bold text-white mb-6">Administration</h1>
<div className="flex gap-1 mb-6 bg-surface rounded-xl p-1 w-fit">
{(['users', 'libraries', 'settings'] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2 rounded-lg text-sm transition-colors ${
tab === t ? 'bg-primary text-black font-medium' : 'text-gray-400 hover:text-white'
}`}
>
{t === 'users' ? 'Benutzer' : t === 'libraries' ? 'Bibliotheken' : 'Einstellungen'}
</button>
))}
</div>
{tab === 'users' && <UsersPanel />}
{tab === 'libraries' && <LibrariesPanel />}
{tab === 'settings' && <SettingsPanel />}
</div>
)
}
function UsersPanel() {
const [users, setUsers] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [form, setForm] = useState({ username: '', password: '', isAdmin: false })
useEffect(() => { getUsers().then(setUsers).finally(() => setLoading(false)) }, [])
const handleCreate = async () => {
await createUser(form)
const updated = await getUsers()
setUsers(updated)
setShowCreate(false)
setForm({ username: '', password: '', isAdmin: false })
}
const handleDelete = async (id: string) => {
if (!confirm('Benutzer wirklich löschen?')) return
await deleteUser(id)
setUsers(users.filter((u) => u.id !== id))
}
return (
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-gray-400 text-sm">{users.length} Benutzer</p>
<button
onClick={() => setShowCreate(!showCreate)}
className="flex items-center gap-2 bg-primary text-black px-3 py-2 rounded-lg text-sm font-medium"
>
<Plus size={14} /> Neu
</button>
</div>
{showCreate && (
<div className="bg-surface border border-white/10 rounded-xl p-4 mb-4 space-y-3">
<h3 className="text-sm font-semibold text-white">Neuer Benutzer</h3>
<input
type="text" placeholder="Benutzername"
value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })}
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-primary"
/>
<input
type="password" placeholder="Passwort"
value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })}
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-primary"
/>
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="checkbox" checked={form.isAdmin} onChange={(e) => setForm({ ...form, isAdmin: e.target.checked })} />
Admin-Rechte
</label>
<div className="flex gap-2">
<button onClick={handleCreate} disabled={!form.username || !form.password}
className="bg-primary text-black px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
Anlegen
</button>
<button onClick={() => setShowCreate(false)} className="text-gray-400 px-4 py-2 rounded-lg text-sm hover:text-white">
Abbrechen
</button>
</div>
</div>
)}
{loading ? <Loader2 className="text-primary animate-spin" size={24} /> : (
<div className="space-y-1">
{users.map((u: any) => (
<div key={u.id} className="flex items-center gap-4 bg-surface px-4 py-3 rounded-lg">
<div className="flex-1">
<p className="text-sm text-white">{u.username}</p>
<p className="text-xs text-gray-500">{u.email || 'Keine E-Mail'} · {u.isAdmin ? 'Admin' : 'Benutzer'}</p>
</div>
<button onClick={() => handleDelete(u.id)} className="text-gray-500 hover:text-red-400 p-1">
<Trash2 size={14} />
</button>
</div>
))}
</div>
)}
</div>
)
}
function LibrariesPanel() {
const [libraries, setLibraries] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [scanning, setScanning] = useState<string | null>(null)
const [showCreate, setShowCreate] = useState(false)
const [form, setForm] = useState({ name: '', path: '', mediaType: 'book' })
useEffect(() => { getLibraries().then(setLibraries).finally(() => setLoading(false)) }, [])
const handleScan = async (id: string) => {
setScanning(id)
await scanLibrary(id).catch(() => {})
setTimeout(() => setScanning(null), 5000)
}
const handleCreate = async () => {
await createLibrary({ name: form.name, folders: [{ fullPath: form.path }], media_type: form.mediaType })
const libs = await getLibraries()
setLibraries(libs)
setShowCreate(false)
setForm({ name: '', path: '', mediaType: 'book' })
}
const handleDelete = async (id: string) => {
if (!confirm('Bibliothek wirklich löschen?')) return
await deleteLibrary(id)
setLibraries(libs => libs.filter((l) => l.id !== id))
}
return (
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-gray-400 text-sm">{libraries.length} Bibliotheken</p>
<button onClick={() => setShowCreate(!showCreate)}
className="flex items-center gap-2 bg-primary text-black px-3 py-2 rounded-lg text-sm font-medium">
<Plus size={14} /> Neu
</button>
</div>
{showCreate && (
<div className="bg-surface border border-white/10 rounded-xl p-4 mb-4 space-y-3">
<h3 className="text-sm font-semibold text-white">Neue Bibliothek</h3>
<input type="text" placeholder="Name (z.B. Hörbücher)"
value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-primary"
/>
<input type="text" placeholder="Pfad (z.B. /audiofiles/hörbucher)"
value={form.path} onChange={(e) => setForm({ ...form, path: e.target.value })}
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-primary"
/>
<select value={form.mediaType} onChange={(e) => setForm({ ...form, mediaType: e.target.value })}
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-gray-300 focus:outline-none">
<option value="book">Hörbücher</option>
<option value="podcast">Podcasts</option>
</select>
<div className="flex gap-2">
<button onClick={handleCreate} disabled={!form.name || !form.path}
className="bg-primary text-black px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
Anlegen
</button>
<button onClick={() => setShowCreate(false)} className="text-gray-400 px-4 py-2 rounded-lg text-sm hover:text-white">
Abbrechen
</button>
</div>
</div>
)}
{loading ? <Loader2 className="text-primary animate-spin" size={24} /> : (
<div className="space-y-2">
{libraries.map((lib: any) => (
<div key={lib.id} className="flex items-center gap-4 bg-surface px-4 py-3 rounded-lg">
<div className="flex-1">
<p className="text-sm text-white">{lib.name}</p>
<p className="text-xs text-gray-500">
{lib.folders?.[0]?.fullPath || ''} · {lib.mediaType}
</p>
</div>
<button onClick={() => handleScan(lib.id)} disabled={scanning === lib.id}
className="flex items-center gap-1 text-xs text-gray-400 hover:text-white bg-white/5 px-3 py-1.5 rounded-lg disabled:opacity-50">
<RefreshCw size={12} className={scanning === lib.id ? 'animate-spin' : ''} />
Scan
</button>
<button onClick={() => handleDelete(lib.id)} className="text-gray-500 hover:text-red-400 p-1">
<Trash2 size={14} />
</button>
</div>
))}
</div>
)}
</div>
)
}
function SettingsPanel() {
const [settings, setSettings] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [saved, setSaved] = useState(false)
useEffect(() => { getSettings().then(setSettings).finally(() => setLoading(false)) }, [])
const save = async (key: string, value: any) => {
await updateSettings({ [key]: value })
setSettings((s: any) => ({ ...s, [key]: value }))
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
if (loading) return <Loader2 className="text-primary animate-spin" size={24} />
const toggle = (key: string) => (
<button
onClick={() => save(key, !settings[key])}
className={`relative w-10 h-5 rounded-full transition-colors ${settings[key] ? 'bg-primary' : 'bg-white/20'}`}
>
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full transition-transform ${settings[key] ? 'translate-x-5' : 'translate-x-0.5'}`} />
</button>
)
return (
<div className="space-y-4 max-w-lg">
{saved && (
<div className="flex items-center gap-2 text-primary text-sm">
<Check size={14} /> Gespeichert
</div>
)}
{[
{ key: 'autoMatchBooks', label: 'Auto-Match Hörbücher' },
{ key: 'autoMatchPodcasts', label: 'Auto-Match Podcasts' },
].map(({ key, label }) => (
<div key={key} className="flex items-center justify-between bg-surface px-4 py-3 rounded-lg">
<p className="text-sm text-gray-300">{label}</p>
{toggle(key)}
</div>
))}
<div className="flex items-center justify-between bg-surface px-4 py-3 rounded-lg">
<p className="text-sm text-gray-300">Feed-Update Intervall (Stunden)</p>
<input
type="number" min={1} max={168}
value={settings.podcastUpdateIntervalHours || 24}
onChange={(e) => save('podcastUpdateIntervalHours', parseInt(e.target.value))}
className="w-16 bg-white/5 border border-white/10 rounded-lg px-2 py-1 text-sm text-white text-center focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
</div>
)
}