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

@@ -1,32 +1,32 @@
import uuid import uuid
import logging import logging
import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
import os from apscheduler.schedulers.asyncio import AsyncIOScheduler
from .database import init_db, AsyncSessionLocal from .database import init_db, AsyncSessionLocal
from .config import get_settings from .config import get_settings
from .models import User, Library from .models import User, Library
from .services.auth import hash_password, create_token from .services.auth import hash_password, create_token
from .services.file_watcher import start_file_watcher, stop_file_watcher from .services.file_watcher import start_file_watcher, stop_file_watcher
from .services.podcast_feed import update_all_feeds
from sqlalchemy import select from sqlalchemy import select
logging.basicConfig( logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_scheduler = AsyncIOScheduler()
async def _seed_admin(): async def _seed_admin():
settings = get_settings() settings = get_settings()
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
result = await db.execute(select(User).where(User.is_admin == True)) result = await db.execute(select(User).where(User.is_admin == True))
if result.scalar_one_or_none(): if result.scalar_one_or_none():
return # Admin existiert bereits return
logger.info(f"Lege Admin-User an: {settings.admin_username}") logger.info(f"Lege Admin-User an: {settings.admin_username}")
admin = User( admin = User(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
@@ -37,7 +37,6 @@ async def _seed_admin():
) )
db.add(admin) db.add(admin)
await db.flush() await db.flush()
# Token mit echter ID erstellen
admin.token = create_token(admin.id) admin.token = create_token(admin.id)
await db.commit() await db.commit()
logger.info("Admin-User angelegt.") logger.info("Admin-User angelegt.")
@@ -48,8 +47,7 @@ async def _seed_default_library():
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
result = await db.execute(select(Library)) result = await db.execute(select(Library))
if result.scalar_one_or_none(): if result.scalar_one_or_none():
return # Bereits eine Library vorhanden return
folder_id = str(uuid.uuid4()) folder_id = str(uuid.uuid4())
lib = Library( lib = Library(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
@@ -66,29 +64,28 @@ async def _seed_default_library():
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Startup
settings = get_settings() settings = get_settings()
os.makedirs(settings.hls_cache_dir, exist_ok=True) for d in [settings.hls_cache_dir, settings.covers_dir, settings.log_dir]:
os.makedirs(settings.covers_dir, exist_ok=True) os.makedirs(d, exist_ok=True)
os.makedirs(settings.log_dir, exist_ok=True)
await init_db() await init_db()
await _seed_admin() await _seed_admin()
await _seed_default_library() await _seed_default_library()
await start_file_watcher() await start_file_watcher()
# Podcast-Feed-Scheduler
_scheduler.add_job(update_all_feeds, "interval", hours=settings.podcast_update_interval_hours, id="feed_update")
_scheduler.start()
logger.info("Audiolib gestartet.") logger.info("Audiolib gestartet.")
yield yield
# Shutdown
stop_file_watcher() stop_file_watcher()
_scheduler.shutdown(wait=False)
logger.info("Audiolib gestoppt.") logger.info("Audiolib gestoppt.")
app = FastAPI( app = FastAPI(title="Audiolib", version="2.4.0", lifespan=lifespan)
title="Audiolib",
version="2.4.0",
description="Selbst gehosteter Audiobook-Server, API-kompatibel mit Audiobookshelf",
lifespan=lifespan,
)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -98,13 +95,12 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# Cover-Dateien direkt ausliefern
settings = get_settings() settings = get_settings()
if os.path.exists(settings.covers_dir): if os.path.exists(settings.covers_dir):
app.mount("/covers", StaticFiles(directory=settings.covers_dir), name="covers") app.mount("/covers", StaticFiles(directory=settings.covers_dir), name="covers")
# Router registrieren
from .routers import auth, libraries, items, stream, me, users, settings as settings_router from .routers import auth, libraries, items, stream, me, users, settings as settings_router
from .routers import matching, podcasts
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(libraries.router) app.include_router(libraries.router)
@@ -113,3 +109,5 @@ app.include_router(stream.router)
app.include_router(me.router) app.include_router(me.router)
app.include_router(users.router) app.include_router(users.router)
app.include_router(settings_router.router) app.include_router(settings_router.router)
app.include_router(matching.router)
app.include_router(podcasts.router)

View File

@@ -0,0 +1,124 @@
import asyncio
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from ..dependencies import get_db, get_current_user, require_admin
from ..models.user import User
from ..models.media_item import LibraryItem
from ..services.matcher import match_audiobook, search_for_item, _apply_match, _score_result
from ..services.matching.musicbrainz import get_release_details
from ..services.matching.open_library import get_work_details
from ..services.matching.google_books import search_google_books
from ..services.matching.base import MatchResult
from datetime import datetime
router = APIRouter(prefix="/api/items", tags=["matching"])
@router.post("/{item_id}/match")
async def trigger_match(
item_id: str,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
background_tasks.add_task(match_audiobook, item_id)
return {"message": "Matching gestartet", "itemId": item_id}
@router.get("/{item_id}/match/search")
async def search_match(
item_id: str,
q: str | None = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
query = q or item.title or ""
author = item.author if not q else None
results = await search_for_item(query, author)
return {"results": results}
@router.post("/{item_id}/match/apply")
async def apply_match(
item_id: str,
body: dict,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Wendet einen manuell gewählten Match-Treffer an.
body: { source, id, title, author, ... }
"""
result = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
source = body.get("source", "manual")
source_id = body.get("id", "")
# Versuche Details zu laden wenn MusicBrainz/OpenLibrary
match_result = None
if source == "musicbrainz":
match_result = await get_release_details(source_id)
elif source == "open_library":
from ..services.matching.open_library import get_work_details
match_result = await get_work_details(source_id)
if not match_result:
# Fallback: nur die übergebenen Daten verwenden
match_result = MatchResult(
source=source,
source_id=source_id,
title=body.get("title", item.title or ""),
author=body.get("author"),
publish_year=body.get("publishYear"),
cover_url=body.get("cover"),
confidence=1.0,
)
match_result.confidence = 1.0 # Manuell → immer akzeptieren
await _apply_match(db, item, match_result, confidence=1.0)
item.match_locked = True
item.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(item)
from ..routers.items import _enrich_item_with_files
return await _enrich_item_with_files(item, db)
@router.delete("/{item_id}/match")
async def clear_match(
item_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
item.matched_source = "none"
item.matched_id = None
item.match_confidence = 0.0
item.match_locked = False
tags = item.tags or []
if "zu_prüfen" not in tags:
tags.append("zu_prüfen")
item.tags = tags
item.updated_at = datetime.utcnow()
await db.commit()
return {"success": True}

View File

@@ -0,0 +1,178 @@
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from ..dependencies import get_db, get_current_user, require_admin
from ..models.user import User
from ..models.library import Library
from ..models.media_item import LibraryItem
from ..models.podcast import Podcast, PodcastEpisode
from ..services.podcast_feed import fetch_and_update_feed, search_podcast_feeds
router = APIRouter(prefix="/api/podcasts", tags=["podcasts"])
def _episode_out(ep: PodcastEpisode) -> dict:
return {
"id": ep.id,
"podcastId": ep.podcast_id,
"title": ep.title,
"description": ep.description,
"episode": ep.episode_number,
"season": ep.season_number,
"pubDate": ep.pub_date.isoformat() if ep.pub_date else None,
"duration": ep.duration_seconds,
"size": ep.size_bytes,
"path": ep.path,
"feedEpisodeId": ep.feed_episode_id,
"feedEpisodeUrl": ep.feed_episode_url,
"explicit": ep.explicit,
"addedAt": int(ep.created_at.timestamp() * 1000) if ep.created_at else 0,
}
def _podcast_out(podcast: Podcast, item: LibraryItem, episodes: list[PodcastEpisode]) -> dict:
return {
"id": item.id,
"libraryId": item.library_id,
"title": item.title,
"author": item.author or podcast.author,
"description": item.description,
"cover": f"/api/items/{item.id}/cover" if item.cover_path else None,
"feedUrl": podcast.feed_url,
"feedLastChecked": podcast.feed_last_checked.isoformat() if podcast.feed_last_checked else None,
"updateIntervalHours": podcast.update_interval_hours,
"tags": item.tags or [],
"episodes": [_episode_out(ep) for ep in episodes],
"numEpisodes": len(episodes),
"matchedSource": item.matched_source,
}
@router.get("")
async def list_podcasts(
library_id: str | None = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(LibraryItem).where(LibraryItem.media_type == "podcast")
if library_id:
query = query.where(LibraryItem.library_id == library_id)
items_result = await db.execute(query)
items = items_result.scalars().all()
result = []
for item in items:
podcast_result = await db.execute(select(Podcast).where(Podcast.library_item_id == item.id))
podcast = podcast_result.scalar_one_or_none()
if not podcast:
continue
ep_result = await db.execute(
select(PodcastEpisode).where(PodcastEpisode.podcast_id == podcast.id)
.order_by(PodcastEpisode.pub_date.desc())
)
episodes = ep_result.scalars().all()
result.append(_podcast_out(podcast, item, episodes))
return {"podcasts": result}
@router.get("/search")
async def search_feeds(
q: str,
current_user: User = Depends(get_current_user),
):
results = await search_podcast_feeds(q)
return {"results": results}
@router.get("/{item_id}")
async def get_podcast(
item_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
item_result = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
item = item_result.scalar_one_or_none()
if not item or item.media_type != "podcast":
raise HTTPException(status_code=404, detail="Podcast not found")
podcast_result = await db.execute(select(Podcast).where(Podcast.library_item_id == item_id))
podcast = podcast_result.scalar_one_or_none()
if not podcast:
raise HTTPException(status_code=404, detail="Podcast data not found")
ep_result = await db.execute(
select(PodcastEpisode).where(PodcastEpisode.podcast_id == podcast.id)
.order_by(PodcastEpisode.pub_date.desc())
)
episodes = ep_result.scalars().all()
return _podcast_out(podcast, item, episodes)
@router.patch("/{item_id}/feed")
async def set_feed_url(
item_id: str,
body: dict,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
item_result = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
item = item_result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
podcast_result = await db.execute(select(Podcast).where(Podcast.library_item_id == item_id))
podcast = podcast_result.scalar_one_or_none()
if not podcast:
podcast = Podcast(library_item_id=item_id)
db.add(podcast)
feed_url = body.get("feedUrl", "")
if not feed_url:
raise HTTPException(status_code=400, detail="feedUrl required")
podcast.feed_url = feed_url
if body.get("updateIntervalHours"):
podcast.update_interval_hours = int(body["updateIntervalHours"])
await db.commit()
background_tasks.add_task(fetch_and_update_feed, item_id)
return {"success": True, "message": "Feed wird aktualisiert..."}
@router.post("/{item_id}/update-feed")
async def update_feed(
item_id: str,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
podcast_result = await db.execute(select(Podcast).where(Podcast.library_item_id == item_id))
podcast = podcast_result.scalar_one_or_none()
if not podcast or not podcast.feed_url:
raise HTTPException(status_code=400, detail="Kein Feed konfiguriert")
background_tasks.add_task(fetch_and_update_feed, item_id)
return {"success": True}
@router.get("/{item_id}/episodes")
async def get_episodes(
item_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
podcast_result = await db.execute(select(Podcast).where(Podcast.library_item_id == item_id))
podcast = podcast_result.scalar_one_or_none()
if not podcast:
raise HTTPException(status_code=404, detail="Podcast not found")
ep_result = await db.execute(
select(PodcastEpisode).where(PodcastEpisode.podcast_id == podcast.id)
.order_by(PodcastEpisode.pub_date.desc())
)
return {"episodes": [_episode_out(ep) for ep in ep_result.scalars().all()]}

View File

@@ -10,7 +10,7 @@ from ..models.user import User
from ..models.media_item import LibraryItem, BookFile, Chapter from ..models.media_item import LibraryItem, BookFile, Chapter
from ..models.session import PlaybackSession from ..models.session import PlaybackSession
from ..models.progress import MediaProgress from ..models.progress import MediaProgress
from ..services.hls import create_hls_session, cleanup_hls_session, get_hls_session_path from ..services.hls import start_hls_session, wait_for_playlist, cleanup_hls_session
from ..config import get_settings from ..config import get_settings
router = APIRouter(tags=["stream"]) router = APIRouter(tags=["stream"])
@@ -34,14 +34,13 @@ async def start_playback(
) )
files = files_result.scalars().all() files = files_result.scalars().all()
if not files: if not files:
raise HTTPException(status_code=400, detail="No audio files for this item") raise HTTPException(status_code=400, detail="Keine Audio-Dateien vorhanden")
chapters_result = await db.execute( chapters_result = await db.execute(
select(Chapter).where(Chapter.library_item_id == item_id).order_by(Chapter.chapter_index) select(Chapter).where(Chapter.library_item_id == item_id).order_by(Chapter.chapter_index)
) )
chapters = chapters_result.scalars().all() chapters = chapters_result.scalars().all()
# Fortschritt ermitteln
progress_result = await db.execute( progress_result = await db.execute(
select(MediaProgress).where( select(MediaProgress).where(
MediaProgress.user_id == current_user.id, MediaProgress.user_id == current_user.id,
@@ -54,10 +53,14 @@ async def start_playback(
current_time = float(body["startTime"]) current_time = float(body["startTime"])
session_id = str(uuid.uuid4()) session_id = str(uuid.uuid4())
# HLS-Session asynchron starten
audio_paths = [f.path for f in files] audio_paths = [f.path for f in files]
hls_dir = await create_hls_session(session_id, audio_paths, start_time=0.0) hls_dir = start_hls_session(session_id, audio_paths, start_time=0.0)
# Warten bis erste Segmente da sind (max. 60s)
ready = await wait_for_playlist(session_id, timeout=60.0)
if not ready:
cleanup_hls_session(session_id)
raise HTTPException(status_code=500, detail="HLS-Konvertierung fehlgeschlagen")
session = PlaybackSession( session = PlaybackSession(
id=session_id, id=session_id,
@@ -75,35 +78,9 @@ async def start_playback(
db.add(session) db.add(session)
await db.commit() await db.commit()
settings = get_settings()
# URL-Basis relativ — wird durch nginx weitergeleitet
hls_url = f"/hls/{session_id}/output.m3u8" hls_url = f"/hls/{session_id}/output.m3u8"
audio_tracks = [
{
"index": 0,
"startOffset": 0.0,
"duration": item.duration_seconds or 0.0,
"title": "Part 1",
"contentUrl": hls_url,
"mimeType": "application/x-mpegURL",
"metadata": {
"filename": "output.m3u8",
"ext": ".m3u8",
"path": hls_url,
"relPath": "output.m3u8",
"size": 0,
},
}
]
chapters_out = [ chapters_out = [
{ {"id": c.chapter_index, "start": c.start_seconds, "end": c.end_seconds, "title": c.title}
"id": c.chapter_index,
"start": c.start_seconds,
"end": c.end_seconds,
"title": c.title,
}
for c in chapters for c in chapters
] ]
@@ -119,7 +96,7 @@ async def start_playback(
"displayAuthor": item.author, "displayAuthor": item.author,
"coverPath": f"/api/items/{item_id}/cover" if item.cover_path else None, "coverPath": f"/api/items/{item_id}/cover" if item.cover_path else None,
"duration": item.duration_seconds or 0.0, "duration": item.duration_seconds or 0.0,
"playMethod": 0, # 0 = HLS Transcode "playMethod": 0,
"mediaPlayer": body.get("mediaPlayer", ""), "mediaPlayer": body.get("mediaPlayer", ""),
"deviceInfo": body.get("deviceInfo", {}), "deviceInfo": body.get("deviceInfo", {}),
"serverVersion": "2.4.0", "serverVersion": "2.4.0",
@@ -130,7 +107,15 @@ async def start_playback(
"currentTime": current_time, "currentTime": current_time,
"startedAt": int(datetime.utcnow().timestamp() * 1000), "startedAt": int(datetime.utcnow().timestamp() * 1000),
"updatedAt": int(datetime.utcnow().timestamp() * 1000), "updatedAt": int(datetime.utcnow().timestamp() * 1000),
"audioTracks": audio_tracks, "audioTracks": [{
"index": 0,
"startOffset": 0.0,
"duration": item.duration_seconds or 0.0,
"title": "Part 1",
"contentUrl": hls_url,
"mimeType": "application/x-mpegURL",
"metadata": {"filename": "output.m3u8", "ext": ".m3u8", "path": hls_url, "relPath": "output.m3u8", "size": 0},
}],
"videoTrack": None, "videoTrack": None,
} }
@@ -138,15 +123,13 @@ async def start_playback(
@router.post("/api/playback-session/{session_id}/sync") @router.post("/api/playback-session/{session_id}/sync")
async def sync_session( async def sync_session(
session_id: str, session_id: str,
body: dict = {}, body: dict | None = None,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
body = body or {}
result = await db.execute( result = await db.execute(
select(PlaybackSession).where( select(PlaybackSession).where(PlaybackSession.id == session_id, PlaybackSession.user_id == current_user.id)
PlaybackSession.id == session_id,
PlaybackSession.user_id == current_user.id,
)
) )
session = result.scalar_one_or_none() session = result.scalar_one_or_none()
if not session: if not session:
@@ -154,13 +137,10 @@ async def sync_session(
current_time = float(body.get("currentTime", session.current_time)) current_time = float(body.get("currentTime", session.current_time))
duration = float(body.get("duration", session.duration)) duration = float(body.get("duration", session.duration))
time_listening = float(body.get("timeListening", 0))
session.current_time = current_time session.current_time = current_time
session.duration = duration session.duration = duration
session.updated_at = datetime.utcnow() session.updated_at = datetime.utcnow()
# Fortschritt persistieren
progress_result = await db.execute( progress_result = await db.execute(
select(MediaProgress).where( select(MediaProgress).where(
MediaProgress.user_id == current_user.id, MediaProgress.user_id == current_user.id,
@@ -200,39 +180,28 @@ async def close_session(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
result = await db.execute( result = await db.execute(
select(PlaybackSession).where( select(PlaybackSession).where(PlaybackSession.id == session_id, PlaybackSession.user_id == current_user.id)
PlaybackSession.id == session_id,
PlaybackSession.user_id == current_user.id,
)
) )
session = result.scalar_one_or_none() session = result.scalar_one_or_none()
if not session: if not session:
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")
session.is_active = False session.is_active = False
await db.commit() await db.commit()
# HLS-Temp-Dateien bereinigen
cleanup_hls_session(session_id) cleanup_hls_session(session_id)
return {"success": True} return {"success": True}
@router.get("/hls/{session_id}/{filename}") @router.get("/hls/{session_id}/{filename:path}")
async def serve_hls( async def serve_hls(
session_id: str, session_id: str,
filename: str, filename: str,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
# Session prüfen
result = await db.execute( result = await db.execute(
select(PlaybackSession).where( select(PlaybackSession).where(PlaybackSession.id == session_id, PlaybackSession.user_id == current_user.id)
PlaybackSession.id == session_id,
PlaybackSession.user_id == current_user.id,
) )
) if not result.scalar_one_or_none():
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")
settings = get_settings() settings = get_settings()
@@ -242,7 +211,4 @@ async def serve_hls(
if filename.endswith(".m3u8"): if filename.endswith(".m3u8"):
return FileResponse(file_path, media_type="application/x-mpegURL") return FileResponse(file_path, media_type="application/x-mpegURL")
elif filename.endswith(".ts"):
return FileResponse(file_path, media_type="video/MP2T") return FileResponse(file_path, media_type="video/MP2T")
else:
return FileResponse(file_path)

View File

@@ -1,64 +1,32 @@
import os import os
import asyncio import asyncio
import uuid
import shutil import shutil
from pathlib import Path
from typing import Optional from typing import Optional
from ..config import get_settings from ..config import get_settings
HLS_SEGMENT_DURATION = 10
HLS_SEGMENT_DURATION = 10 # Sekunden pro Segment _running_sessions: dict[str, asyncio.Task] = {}
async def create_hls_session( async def _run_ffmpeg(session_id: str, audio_files: list[str], start_time: float = 0.0):
session_id: str,
audio_files: list[str],
start_time: float = 0.0,
) -> str:
"""
Erstellt HLS-Segmente via FFmpeg für die gegebenen Audio-Dateien.
Gibt den Pfad zum HLS-Verzeichnis zurück.
"""
settings = get_settings() settings = get_settings()
session_dir = os.path.join(settings.hls_cache_dir, session_id) session_dir = os.path.join(settings.hls_cache_dir, session_id)
os.makedirs(session_dir, exist_ok=True) os.makedirs(session_dir, exist_ok=True)
playlist_path = os.path.join(session_dir, "output.m3u8") playlist_path = os.path.join(session_dir, "output.m3u8")
if len(audio_files) == 1: if len(audio_files) == 1:
input_path = audio_files[0] input_args = ["-ss", str(start_time), "-i", audio_files[0]]
else: else:
# Mehrere Dateien: Concat-Liste erstellen
concat_file = os.path.join(session_dir, "concat.txt") concat_file = os.path.join(session_dir, "concat.txt")
with open(concat_file, "w", encoding="utf-8") as f: with open(concat_file, "w", encoding="utf-8") as f:
for af in audio_files: for af in audio_files:
safe_path = af.replace("\\", "/") f.write(f"file '{af.replace(chr(92), '/')}'\n")
f.write(f"file '{safe_path}'\n") input_args = ["-f", "concat", "-safe", "0", "-i", concat_file, "-ss", str(start_time)]
input_path = concat_file
if len(audio_files) == 1:
cmd = [ cmd = [
"ffmpeg", "-y", "ffmpeg", "-y",
"-ss", str(start_time), *input_args,
"-i", input_path, "-c:a", "aac", "-b:a", "128k", "-ac", "2",
"-c:a", "aac",
"-b:a", "192k",
"-ac", "2",
"-hls_time", str(HLS_SEGMENT_DURATION),
"-hls_list_size", "0",
"-hls_segment_filename", os.path.join(session_dir, "seg%05d.ts"),
"-hls_flags", "independent_segments",
playlist_path,
]
else:
cmd = [
"ffmpeg", "-y",
"-f", "concat", "-safe", "0",
"-i", input_path,
"-ss", str(start_time),
"-c:a", "aac",
"-b:a", "192k",
"-ac", "2",
"-hls_time", str(HLS_SEGMENT_DURATION), "-hls_time", str(HLS_SEGMENT_DURATION),
"-hls_list_size", "0", "-hls_list_size", "0",
"-hls_segment_filename", os.path.join(session_dir, "seg%05d.ts"), "-hls_segment_filename", os.path.join(session_dir, "seg%05d.ts"),
@@ -72,17 +40,49 @@ async def create_hls_session(
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
_, stderr = await proc.communicate() _, stderr = await proc.communicate()
if proc.returncode != 0 and session_id in _running_sessions:
err = stderr.decode(errors="replace") if stderr else "unknown"
# Fehler-Datei schreiben damit der Client es merkt
with open(os.path.join(session_dir, "error.txt"), "w") as f:
f.write(err)
if proc.returncode != 0:
error_msg = stderr.decode(errors="replace") if stderr else "unknown error"
raise RuntimeError(f"FFmpeg fehler: {error_msg}")
def start_hls_session(session_id: str, audio_files: list[str], start_time: float = 0.0) -> str:
"""Startet FFmpeg als Background-Task. Gibt den Session-Pfad zurück."""
settings = get_settings()
session_dir = os.path.join(settings.hls_cache_dir, session_id)
os.makedirs(session_dir, exist_ok=True)
task = asyncio.create_task(_run_ffmpeg(session_id, audio_files, start_time))
_running_sessions[session_id] = task
return session_dir return session_dir
async def wait_for_playlist(session_id: str, timeout: float = 60.0) -> bool:
"""Wartet bis das erste Segment fertig ist (max. timeout Sekunden)."""
settings = get_settings()
playlist = os.path.join(settings.hls_cache_dir, session_id, "output.m3u8")
error_file = os.path.join(settings.hls_cache_dir, session_id, "error.txt")
waited = 0.0
while waited < timeout:
if os.path.exists(error_file):
return False
if os.path.exists(playlist) and os.path.getsize(playlist) > 0:
# Warte auf mindestens 1 Segment
seg0 = os.path.join(settings.hls_cache_dir, session_id, "seg00000.ts")
if os.path.exists(seg0):
return True
await asyncio.sleep(0.5)
waited += 0.5
return False
def cleanup_hls_session(session_id: str): def cleanup_hls_session(session_id: str):
settings = get_settings() settings = get_settings()
session_dir = os.path.join(settings.hls_cache_dir, session_id) session_dir = os.path.join(settings.hls_cache_dir, session_id)
task = _running_sessions.pop(session_id, None)
if task and not task.done():
task.cancel()
if os.path.exists(session_dir): if os.path.exists(session_dir):
shutil.rmtree(session_dir, ignore_errors=True) shutil.rmtree(session_dir, ignore_errors=True)
@@ -90,19 +90,4 @@ def cleanup_hls_session(session_id: str):
def get_hls_session_path(session_id: str) -> Optional[str]: def get_hls_session_path(session_id: str) -> Optional[str]:
settings = get_settings() settings = get_settings()
session_dir = os.path.join(settings.hls_cache_dir, session_id) session_dir = os.path.join(settings.hls_cache_dir, session_id)
playlist = os.path.join(session_dir, "output.m3u8") return session_dir if os.path.isdir(session_dir) else None
return session_dir if os.path.exists(playlist) else None
def parse_m3u8_duration(playlist_path: str) -> float:
"""Berechnet Gesamtdauer aus M3U8-Playlist."""
total = 0.0
try:
with open(playlist_path, "r") as f:
for line in f:
if line.startswith("#EXTINF:"):
duration_str = line.split(":")[1].split(",")[0]
total += float(duration_str)
except Exception:
pass
return total

View File

@@ -0,0 +1,279 @@
"""
Matching-Orchestrator:
- Erkennt deutsche Hörbuch-Serien (die drei ???, TKKG, ...)
- Versucht MusicBrainz → OpenLibrary → Google Books
- Lädt Cover herunter
- Bewertet Konfidenz und entscheidet über Auto-Accept
"""
import re
import os
import logging
import httpx
import asyncio
from pathlib import Path
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from ..config import get_settings
from ..models.media_item import LibraryItem, BookFile, Chapter
from ..models.session import ServerSetting
from ..database import AsyncSessionLocal
from .matching.base import MatchResult
from .matching.musicbrainz import search_musicbrainz, get_release_details
from .matching.open_library import search_open_library, get_work_details
from .matching.google_books import search_google_books
logger = logging.getLogger(__name__)
AUTO_ACCEPT_THRESHOLD = 0.75
UNCERTAIN_THRESHOLD = 0.50
# Bekannte deutsche Hörbuch-Serien: (regex, kanonischer Name)
SERIES_PATTERNS = [
(r"(?i)^(die drei \?\?\?|die drei fragezeichen|drei fragezeichen)\s*[-]?\s*(?:folge\s*)?(\d+)", "Die drei ???"),
(r"(?i)^(tkkg)\s*[-]?\s*(?:folge\s*)?(\d+)", "TKKG"),
(r"(?i)^(fünf freunde|funf freunde)\s*[-]?\s*(?:band\s*)?(\d+)", "Fünf Freunde"),
(r"(?i)^(bibi blocksberg)\s*[-]?\s*(?:folge\s*)?(\d+)", "Bibi Blocksberg"),
(r"(?i)^(benjamin blümchen|benjamin blumchen)\s*[-]?\s*(?:folge\s*)?(\d+)", "Benjamin Blümchen"),
(r"(?i)^(bibi und tina)\s*[-]?\s*(?:folge\s*)?(\d+)", "Bibi und Tina"),
(r"(?i)^(der kleine vampir)\s*[-]?\s*(?:band\s*)?(\d+)", "Der kleine Vampir"),
# Generisch: "Serie - Folge/Band/Teil N - Titel"
(r"(?i)^(.+?)\s*[-]\s*(?:folge|band|teil|nr\.?|#)\s*(\d+)", None),
# Generisch: "Serie (Folge N)"
(r"(?i)^(.+?)\s*\((?:folge|band|teil|nr\.?|#|episode)\s*(\d+)\)", None),
]
def detect_series(title: str) -> tuple[str | None, str | None]:
"""Gibt (Serienname, Folgennummer) zurück oder (None, None)."""
for pattern, canonical_name in SERIES_PATTERNS:
m = re.match(pattern, title.strip())
if m:
series = canonical_name or m.group(1).strip()
episode = m.group(2)
return series, episode
return None, None
def _title_similarity(a: str, b: str) -> float:
"""Einfache Ähnlichkeit: Wort-Überlapp."""
if not a or not b:
return 0.0
wa = set(re.findall(r'\w+', a.lower()))
wb = set(re.findall(r'\w+', b.lower()))
if not wa or not wb:
return 0.0
return len(wa & wb) / max(len(wa), len(wb))
def _score_result(result: MatchResult, query_title: str, query_author: str | None) -> float:
score = result.confidence
title_sim = _title_similarity(result.title, query_title)
score = score * 0.4 + title_sim * 0.6
if query_author and result.author:
author_sim = _title_similarity(result.author, query_author)
score = score * 0.7 + author_sim * 0.3
return min(score, 1.0)
async def _download_cover(url: str, item_id: str) -> str | None:
"""Lädt Cover herunter und speichert es lokal."""
settings = get_settings()
ext = ".jpg"
if ".png" in url:
ext = ".png"
dest = os.path.join(settings.covers_dir, f"{item_id}{ext}")
try:
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
r = await client.get(url)
if r.status_code == 200:
os.makedirs(settings.covers_dir, exist_ok=True)
with open(dest, "wb") as f:
f.write(r.content)
return dest
except Exception as e:
logger.warning(f"Cover-Download fehlgeschlagen ({url}): {e}")
return None
async def _apply_match(db: AsyncSession, item: LibraryItem, result: MatchResult, confidence: float):
"""Schreibt Metadaten aus MatchResult in die DB."""
if result.title:
item.title = result.title
if result.subtitle and not item.subtitle:
item.subtitle = result.subtitle
if result.author:
item.author = result.author
if result.narrator:
item.narrator = result.narrator
if result.description:
item.description = result.description
if result.publisher:
item.publisher = result.publisher
if result.publish_year:
item.publish_year = result.publish_year
if result.language:
item.language = result.language
if result.genres:
item.genres = result.genres
if result.series:
item.series = result.series
if result.series_sequence:
item.series_sequence = result.series_sequence
item.matched_source = result.source
item.matched_id = result.source_id
item.match_confidence = confidence
item.updated_at = datetime.utcnow()
# Cover herunterladen
if result.cover_url and not item.cover_path:
cover_path = await _download_cover(result.cover_url, item.id)
if cover_path:
item.cover_path = cover_path
# Kapitel aus MusicBrainz-Tracklisting
if result.chapters:
from sqlalchemy import delete
from ..models.media_item import Chapter
await db.execute(delete(Chapter).where(Chapter.library_item_id == item.id))
for idx, ch in enumerate(result.chapters):
chapter = Chapter(
library_item_id=item.id,
chapter_index=idx,
title=ch.get("title", f"Kapitel {idx + 1}"),
start_seconds=ch.get("start", 0.0),
end_seconds=ch.get("end", 0.0),
)
db.add(chapter)
# zu_prüfen entfernen wenn Konfidenz hoch genug
if confidence >= AUTO_ACCEPT_THRESHOLD:
tags = item.tags or []
item.tags = [t for t in tags if t != "zu_prüfen"]
async def match_audiobook(item_id: str):
"""
Haupt-Matching-Funktion. Wird nach dem Scan als Hintergrund-Task gestartet.
"""
async with AsyncSessionLocal() as db:
result_row = await db.execute(select(LibraryItem).where(LibraryItem.id == item_id))
item = result_row.scalar_one_or_none()
if not item or item.match_locked:
return
# Einstellung prüfen
setting = await db.execute(
select(ServerSetting).where(ServerSetting.key == "autoMatchBooks")
)
s = setting.scalar_one_or_none()
if s and s.value is False:
return
title = item.title or ""
author = item.author
# Serien-Erkennung verbessert den Suchbegriff
series, episode = detect_series(title)
search_title = title
if series:
search_title = f"{series} {episode}" if episode else series
if not item.series:
item.series = series
if not item.series_sequence and episode:
item.series_sequence = episode
logger.info(f"Matche: '{title}' (Serie: {series}, Folge: {episode})")
best: MatchResult | None = None
best_score = 0.0
# 1. MusicBrainz
try:
mb_results = await search_musicbrainz(search_title, author)
for r in mb_results:
score = _score_result(r, title, author)
if score > best_score:
best_score = score
best = r
except Exception as e:
logger.warning(f"MusicBrainz Fehler: {e}")
# Wenn guter MB-Treffer → Details holen (Tracklist + Cover)
if best and best_score >= UNCERTAIN_THRESHOLD and best.source == "musicbrainz":
try:
details = await get_release_details(best.source_id)
if details:
details.confidence = best_score
best = details
except Exception as e:
logger.warning(f"MusicBrainz Details Fehler: {e}")
# 2. OpenLibrary als Fallback
if best_score < UNCERTAIN_THRESHOLD:
try:
ol_results = await search_open_library(search_title, author)
for r in ol_results:
score = _score_result(r, title, author)
if score > best_score:
best_score = score
best = r
if best and best.source == "open_library" and best_score >= UNCERTAIN_THRESHOLD:
details = await get_work_details(best.source_id)
if details and details.description:
best.description = details.description
except Exception as e:
logger.warning(f"OpenLibrary Fehler: {e}")
# 3. Google Books als letzter Fallback
if best_score < UNCERTAIN_THRESHOLD:
try:
gb_results = await search_google_books(search_title, author)
for r in gb_results:
score = _score_result(r, title, author)
if score > best_score:
best_score = score
best = r
except Exception as e:
logger.warning(f"Google Books Fehler: {e}")
if best and best_score >= UNCERTAIN_THRESHOLD:
await _apply_match(db, item, best, best_score)
logger.info(f"Match angewendet: '{item.title}'{best.source} (Konfidenz: {best_score:.2f})")
else:
logger.info(f"Kein Match gefunden für '{title}' (beste Konfidenz: {best_score:.2f})")
await db.commit()
async def search_for_item(title: str, author: str | None = None) -> list[dict]:
"""Suche über alle Quellen für manuelles Matching."""
results = []
async def _search_source(coro):
try:
return await coro
except Exception:
return []
mb, ol, gb = await asyncio.gather(
_search_source(search_musicbrainz(title, author)),
_search_source(search_open_library(title, author)),
_search_source(search_google_books(title, author)),
)
for r in mb + ol + gb:
results.append({
"source": r.source,
"id": r.source_id,
"title": r.title,
"author": r.author,
"publishYear": r.publish_year,
"cover": r.cover_url,
"confidence": r.confidence,
})
results.sort(key=lambda x: x["confidence"], reverse=True)
return results

View File

@@ -1,4 +1,3 @@
"""Google Books-Matching — Phase 5."""
import httpx import httpx
from .base import MatchResult from .base import MatchResult
@@ -10,26 +9,52 @@ async def search_google_books(title: str, author: str | None = None) -> list[Mat
if author: if author:
q += f' inauthor:"{author}"' q += f' inauthor:"{author}"'
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=12) as client:
resp = await client.get(f"{GB_BASE}/volumes", params={"q": q, "maxResults": 5, "langRestrict": "de"}) try:
resp.raise_for_status() r = await client.get(
data = resp.json() f"{GB_BASE}/volumes",
params={"q": q, "maxResults": 5, "langRestrict": "de", "printType": "books"},
)
r.raise_for_status()
data = r.json()
except Exception:
return []
results = [] results = []
for item in data.get("items", []): for item in data.get("items", []):
vol = item.get("volumeInfo", {}) vol = item.get("volumeInfo", {})
authors = vol.get("authors", []) authors = vol.get("authors", [])
results.append(
MatchResult( cover_url = None
image_links = vol.get("imageLinks", {})
if image_links:
cover_url = (
image_links.get("extraLarge")
or image_links.get("large")
or image_links.get("medium")
or image_links.get("thumbnail", "").replace("zoom=1", "zoom=3")
)
year = None
pub_date = vol.get("publishedDate", "")
if pub_date and len(pub_date) >= 4:
try:
year = int(pub_date[:4])
except ValueError:
pass
results.append(MatchResult(
source="google_books", source="google_books",
source_id=item.get("id", ""), source_id=item.get("id", ""),
title=vol.get("title", title), title=vol.get("title", title),
subtitle=vol.get("subtitle"),
author=authors[0] if authors else None, author=authors[0] if authors else None,
description=vol.get("description"), description=vol.get("description"),
publisher=vol.get("publisher"), publisher=vol.get("publisher"),
publish_year=int(vol.get("publishedDate", "0")[:4]) if vol.get("publishedDate") else None, publish_year=year,
language=vol.get("language"), language=vol.get("language"),
genres=vol.get("categories", []),
cover_url=cover_url,
confidence=0.5, confidence=0.5,
) ))
)
return results return results

View File

@@ -1,40 +1,115 @@
"""MusicBrainz-Matching — Phase 5."""
import httpx import httpx
import asyncio
from .base import MatchResult from .base import MatchResult
MB_BASE = "https://musicbrainz.org/ws/2" MB_BASE = "https://musicbrainz.org/ws/2"
HEADERS = {"User-Agent": "audiolib/1.0 (https://github.com/audiolib)"} CAA_BASE = "https://coverartarchive.org"
HEADERS = {"User-Agent": "audiolib/1.0 (contact@audiolib.local)"}
_semaphore = asyncio.Semaphore(2) # MusicBrainz Rate-Limit: max 1 req/s
async def _get(client: httpx.AsyncClient, url: str, **params) -> dict:
async with _semaphore:
await asyncio.sleep(1.1) # MusicBrainz erlaubt 1 req/s
r = await client.get(url, params={"fmt": "json", **params}, timeout=15)
r.raise_for_status()
return r.json()
async def search_musicbrainz(title: str, artist: str | None = None) -> list[MatchResult]: async def search_musicbrainz(title: str, artist: str | None = None) -> list[MatchResult]:
query = f'release:"{title}"' query = f'release:"{title}"'
if artist: if artist:
query += f' AND artist:"{artist}"' query += f' AND artist:"{artist}"'
query += " AND format:Digital"
async with httpx.AsyncClient(headers=HEADERS, timeout=10) as client: async with httpx.AsyncClient(headers=HEADERS) as client:
resp = await client.get( try:
f"{MB_BASE}/release", data = await _get(client, f"{MB_BASE}/release", query=query, limit=5)
params={"query": query, "fmt": "json", "limit": 5}, except Exception:
) return []
resp.raise_for_status()
data = resp.json()
results = [] results = []
for release in data.get("releases", []): for rel in data.get("releases", []):
confidence = release.get("score", 0) / 100.0 confidence = rel.get("score", 0) / 100.0
artist_name = None artist_name = _first_artist(rel)
release_id = rel.get("id", "")
results.append(MatchResult(
source="musicbrainz",
source_id=release_id,
title=rel.get("title", title),
author=artist_name,
publish_year=_parse_year(rel.get("date", "")),
confidence=confidence,
))
return results
async def get_release_details(release_id: str) -> MatchResult | None:
"""Lädt vollständige Release-Details inkl. Tracklist (= Kapitel) und Cover."""
async with httpx.AsyncClient(headers=HEADERS) as client:
try:
data = await _get(
client, f"{MB_BASE}/release/{release_id}",
inc="recordings+artists+release-groups"
)
except Exception:
return None
artist_name = _first_artist(data)
rg = data.get("release-group", {})
series = rg.get("title") if rg.get("primary-type") == "Album" else None
# Tracklist → Kapitel
chapters = []
offset = 0.0
for medium in data.get("media", []):
for track in medium.get("tracks", []):
duration_ms = track.get("length") or track.get("recording", {}).get("length") or 0
duration_s = duration_ms / 1000.0
chapters.append({
"title": track.get("title", f"Track {track.get('position', '')}"),
"start": offset,
"end": offset + duration_s,
})
offset += duration_s
# Cover Art
cover_url = None
try:
caa = await client.get(f"{CAA_BASE}/release/{release_id}", timeout=10)
if caa.status_code == 200:
caa_data = caa.json()
images = caa_data.get("images", [])
front = next((i for i in images if i.get("front")), images[0] if images else None)
if front:
cover_url = front.get("thumbnails", {}).get("large") or front.get("image")
except Exception:
pass
return MatchResult(
source="musicbrainz",
source_id=release_id,
title=data.get("title", ""),
author=artist_name,
publish_year=_parse_year(data.get("date", "")),
cover_url=cover_url,
chapters=chapters,
confidence=1.0,
)
def _first_artist(release: dict) -> str | None:
credits = release.get("artist-credit", []) credits = release.get("artist-credit", [])
if credits: if credits:
artist_name = credits[0].get("name") or credits[0].get("artist", {}).get("name") c = credits[0]
return c.get("name") or c.get("artist", {}).get("name")
return None
results.append(
MatchResult( def _parse_year(date_str: str) -> int | None:
source="musicbrainz", if date_str and len(date_str) >= 4:
source_id=release.get("id", ""), try:
title=release.get("title", title), return int(date_str[:4])
author=artist_name, except ValueError:
confidence=confidence, pass
) return None
)
return results

View File

@@ -1,4 +1,3 @@
"""OpenLibrary-Matching — Phase 5."""
import httpx import httpx
from .base import MatchResult from .base import MatchResult
@@ -6,25 +5,55 @@ OL_BASE = "https://openlibrary.org"
async def search_open_library(title: str, author: str | None = None) -> list[MatchResult]: async def search_open_library(title: str, author: str | None = None) -> list[MatchResult]:
params: dict = {"title": title, "limit": 5} params: dict = {"title": title, "limit": 5, "fields": "key,title,author_name,first_publish_year,cover_i,subject"}
if author: if author:
params["author"] = author params["author"] = author
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=12) as client:
resp = await client.get(f"{OL_BASE}/search.json", params=params) try:
resp.raise_for_status() r = await client.get(f"{OL_BASE}/search.json", params=params)
data = resp.json() r.raise_for_status()
data = r.json()
except Exception:
return []
results = [] results = []
for doc in data.get("docs", []): for doc in data.get("docs", []):
results.append( cover_url = None
MatchResult( if doc.get("cover_i"):
cover_url = f"https://covers.openlibrary.org/b/id/{doc['cover_i']}-L.jpg"
results.append(MatchResult(
source="open_library", source="open_library",
source_id=doc.get("key", ""), source_id=doc.get("key", ""),
title=doc.get("title", title), title=doc.get("title", title),
author=doc.get("author_name", [None])[0] if doc.get("author_name") else None, author=doc.get("author_name", [None])[0] if doc.get("author_name") else None,
publish_year=doc.get("first_publish_year"), publish_year=doc.get("first_publish_year"),
confidence=0.6, cover_url=cover_url,
) genres=doc.get("subject", [])[:5],
) confidence=0.55,
))
return results return results
async def get_work_details(work_key: str) -> MatchResult | None:
"""Lädt Beschreibung und Genres nach."""
async with httpx.AsyncClient(timeout=12) as client:
try:
r = await client.get(f"{OL_BASE}{work_key}.json")
r.raise_for_status()
data = r.json()
except Exception:
return None
desc = data.get("description")
if isinstance(desc, dict):
desc = desc.get("value")
return MatchResult(
source="open_library",
source_id=work_key,
title=data.get("title", ""),
description=desc,
confidence=1.0,
)

View File

@@ -0,0 +1,186 @@
"""
Podcast-Feed-Manager:
- RSS-Feed parsen
- Episoden mit lokalen Dateien abgleichen
- Periodisches Update
"""
import os
import re
import logging
import httpx
import feedparser
from datetime import datetime
from difflib import SequenceMatcher
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from ..database import AsyncSessionLocal
from ..models.library import Library
from ..models.media_item import LibraryItem
from ..models.podcast import Podcast, PodcastEpisode
from ..services.matcher import _download_cover
logger = logging.getLogger(__name__)
def _similarity(a: str, b: str) -> float:
if not a or not b:
return 0.0
return SequenceMatcher(None, a.lower(), b.lower()).ratio()
def _parse_duration(s: str | None) -> float:
"""Parst "HH:MM:SS" oder "MM:SS" oder reine Sekunden."""
if not s:
return 0.0
s = s.strip()
try:
if ":" in s:
parts = s.split(":")
if len(parts) == 3:
return int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2])
elif len(parts) == 2:
return int(parts[0]) * 60 + float(parts[1])
return float(s)
except (ValueError, IndexError):
return 0.0
async def search_podcast_feeds(query: str) -> list[dict]:
"""Sucht Podcast-Feeds über iTunes Search API."""
results = []
try:
async with httpx.AsyncClient(timeout=12) as client:
r = await client.get(
"https://itunes.apple.com/search",
params={"term": query, "media": "podcast", "limit": 10, "country": "de"},
)
r.raise_for_status()
data = r.json()
for item in data.get("results", []):
results.append({
"title": item.get("collectionName", ""),
"author": item.get("artistName", ""),
"feedUrl": item.get("feedUrl", ""),
"artworkUrl": item.get("artworkUrl600") or item.get("artworkUrl100", ""),
"trackCount": item.get("trackCount", 0),
"itunesId": item.get("collectionId"),
})
except Exception as e:
logger.warning(f"iTunes-Suche fehlgeschlagen: {e}")
return results
async def fetch_and_update_feed(library_item_id: str):
"""
Holt RSS-Feed und aktualisiert Metadaten + Episoden in der DB.
"""
async with AsyncSessionLocal() as db:
item_result = await db.execute(select(LibraryItem).where(LibraryItem.id == library_item_id))
item = item_result.scalar_one_or_none()
if not item:
return
podcast_result = await db.execute(select(Podcast).where(Podcast.library_item_id == library_item_id))
podcast = podcast_result.scalar_one_or_none()
if not podcast or not podcast.feed_url:
logger.warning(f"Kein Feed für Item {library_item_id}")
return
try:
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
r = await client.get(podcast.feed_url)
r.raise_for_status()
raw = r.text
except Exception as e:
logger.error(f"Feed-Abruf fehlgeschlagen ({podcast.feed_url}): {e}")
return
feed = feedparser.parse(raw)
channel = feed.feed
# Podcast-Metadaten aktualisieren
if channel.get("title") and not item.title:
item.title = channel.get("title")
if channel.get("author") and not item.author:
item.author = channel.get("author")
if channel.get("summary") and not item.description:
item.description = channel.get("summary")
if channel.get("language") and not item.language:
item.language = channel.get("language")
# Cover
cover_url = None
if channel.get("image"):
cover_url = channel.image.get("href") or channel.image.get("url")
if cover_url and not item.cover_path:
cover_path = await _download_cover(cover_url, item.id)
if cover_path:
item.cover_path = cover_path
podcast.feed_last_checked = datetime.utcnow()
# Lokale Episode-Dateien holen
episodes_result = await db.execute(
select(PodcastEpisode).where(PodcastEpisode.podcast_id == podcast.id)
)
existing_episodes = {ep.feed_episode_id: ep for ep in episodes_result.scalars().all()}
# Feed-Einträge verarbeiten
for entry in feed.entries:
feed_ep_id = entry.get("id") or entry.get("link", "")
title = entry.get("title", "")
description = entry.get("summary") or entry.get("content", [{}])[0].get("value", "") if entry.get("content") else ""
pub_date = None
if entry.get("published_parsed"):
import time
pub_date = datetime(*entry.published_parsed[:6])
enclosure_url = None
duration_s = 0.0
for enc in entry.get("enclosures", []):
if enc.get("type", "").startswith("audio/"):
enclosure_url = enc.get("href") or enc.get("url")
break
duration_s = _parse_duration(entry.get("itunes_duration"))
ep_num = entry.get("itunes_episode")
season_num = entry.get("itunes_season")
if feed_ep_id in existing_episodes:
# Vorhandene Episode aktualisieren
ep = existing_episodes[feed_ep_id]
ep.title = title
ep.description = description
ep.feed_episode_url = enclosure_url
ep.duration_seconds = duration_s or ep.duration_seconds
else:
# Neue Episode anlegen
ep = PodcastEpisode(
podcast_id=podcast.id,
title=title,
description=description,
pub_date=pub_date,
duration_seconds=duration_s,
feed_episode_id=feed_ep_id,
feed_episode_url=enclosure_url,
episode_number=str(ep_num) if ep_num else None,
season_number=str(season_num) if season_num else None,
)
db.add(ep)
item.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"Feed aktualisiert: {item.title} ({len(feed.entries)} Einträge)")
async def update_all_feeds():
"""Aktualisiert alle Podcast-Feeds (wird vom Scheduler aufgerufen)."""
async with AsyncSessionLocal() as db:
result = await db.execute(select(Podcast).where(Podcast.feed_url.isnot(None)))
podcasts = result.scalars().all()
for podcast in podcasts:
try:
await fetch_and_update_feed(podcast.library_item_id)
except Exception as e:
logger.error(f"Feed-Update fehlgeschlagen für {podcast.id}: {e}")

View File

@@ -13,3 +13,4 @@ mutagen==1.47.0
aiofiles==24.1.0 aiofiles==24.1.0
pillow==10.4.0 pillow==10.4.0
feedparser==6.0.11 feedparser==6.0.11
apscheduler==3.10.4

View File

@@ -12,7 +12,9 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.27.0", "react-router-dom": "^6.27.0",
"zustand": "^5.0.0", "zustand": "^5.0.0",
"axios": "^1.7.7" "axios": "^1.7.7",
"hls.js": "^1.5.15",
"lucide-react": "^0.447.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.11", "@types/react": "^18.3.11",

View File

@@ -1,17 +1,107 @@
import React from 'react' import React, { useEffect } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from './store/authStore'
import { usePlayerStore } from './store/playerStore'
import Layout from './components/common/Layout'
import AudioPlayer from './components/player/AudioPlayer'
import Login from './pages/Login'
import Library from './pages/Library'
import BookDetail from './pages/BookDetail'
import PodcastDetail from './pages/PodcastDetail'
import Admin from './pages/Admin'
function PrivateRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token)
if (!token) return <Navigate to="/login" replace />
return <>{children}</>
}
function AppRoutes() {
const { user, loadAuth } = useAuthStore()
const { libraries } = useAuthStore()
const { expanded, setExpanded } = usePlayerStore()
useEffect(() => { loadAuth() }, [])
const defaultLib = libraries?.[0]?.id
export default function App() {
return ( return (
<div className="min-h-screen bg-background flex items-center justify-center"> <>
<div className="text-center"> <Routes>
<h1 className="text-4xl font-bold text-primary mb-4">Audiolib</h1> <Route path="/login" element={<Login />} />
<p className="text-gray-400 text-lg">Web-Interface wird in Phase 8 implementiert.</p> <Route
<p className="text-gray-500 mt-2 text-sm"> path="/*"
Die Swift-App kann bereits über{' '} element={
<code className="bg-surface px-2 py-1 rounded text-primary">localhost:3000</code>{' '} <PrivateRoute>
verbunden werden. <Layout>
</p> <Routes>
<Route path="/" element={
defaultLib
? <Navigate to={`/library/${defaultLib}`} replace />
: <div className="p-6 text-gray-400">Keine Bibliothek. Im Admin-Bereich anlegen.</div>
} />
<Route path="/library/:libraryId" element={<Library />} />
<Route path="/book/:id" element={<BookDetail />} />
<Route path="/podcasts" element={<PodcastList />} />
<Route path="/podcast/:id" element={<PodcastDetail />} />
<Route path="/admin" element={<Admin />} />
</Routes>
</Layout>
</PrivateRoute>
}
/>
</Routes>
{/* Vollbild-Player als Overlay */}
{expanded && (
<div className="fixed inset-0 bg-background z-50 overflow-y-auto">
<AudioPlayer />
</div> </div>
)}
</>
)
}
function PodcastList() {
const { libraries } = useAuthStore()
const [podcasts, setPodcasts] = React.useState<any[]>([])
const podcastLib = libraries.find((l: any) => l.mediaType === 'podcast')
React.useEffect(() => {
import('./api/client').then(({ default: api }) => {
api.get('/api/podcasts').then((r) => setPodcasts(r.data.podcasts || []))
})
}, [])
return (
<div className="p-6">
<h1 className="text-2xl font-bold text-white mb-6">Podcasts</h1>
{podcasts.length === 0 ? (
<p className="text-gray-500">Noch keine Podcasts. Library vom Typ Podcast" scannen.</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{podcasts.map((p: any) => (
<a key={p.id} href={`/podcast/${p.id}`} className="group block">
<div className="aspect-square bg-surface rounded-lg overflow-hidden mb-2">
{p.cover
? <img src={p.cover} alt={p.title} className="w-full h-full object-cover" />
: <div className="w-full h-full flex items-center justify-center text-gray-600 text-4xl">🎙</div>
}
</div>
<p className="text-sm font-medium text-white truncate">{p.title}</p>
<p className="text-xs text-gray-400">{p.numEpisodes} Episoden</p>
</a>
))}
</div>
)}
</div> </div>
) )
} }
export default function App() {
return (
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
)
}

10
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,10 @@
import api from './client'
export const login = (username: string, password: string) =>
api.post('/login', { username, password }).then((r) => r.data)
export const authorize = () =>
api.get('/api/authorize').then((r) => r.data)
export const logout = () =>
api.post('/logout').then((r) => r.data)

View File

@@ -0,0 +1,22 @@
import axios from 'axios'
const api = axios.create({ baseURL: '/' })
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
api.interceptors.response.use(
(r) => r,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(err)
}
)
export default api

30
frontend/src/api/items.ts Normal file
View File

@@ -0,0 +1,30 @@
import api from './client'
export const getItem = (id: string) =>
api.get(`/api/items/${id}`).then((r) => r.data)
export const updateItem = (id: string, data: object) =>
api.patch(`/api/items/${id}`, data).then((r) => r.data)
export const deleteItem = (id: string) =>
api.delete(`/api/items/${id}`).then((r) => r.data)
export const startPlayback = (id: string, body?: object) =>
api.post(`/api/items/${id}/play`, body || {}).then((r) => r.data)
export const syncSession = (sessionId: string, data: object) =>
api.post(`/api/playback-session/${sessionId}/sync`, data).then((r) => r.data)
export const closeSession = (sessionId: string) =>
api.delete(`/api/playback-session/${sessionId}`).then((r) => r.data)
export const searchMatch = (id: string, q?: string) =>
api.get(`/api/items/${id}/match/search`, { params: q ? { q } : {} }).then((r) => r.data)
export const applyMatch = (id: string, match: object) =>
api.post(`/api/items/${id}/match/apply`, match).then((r) => r.data)
export const triggerMatch = (id: string) =>
api.post(`/api/items/${id}/match`).then((r) => r.data)
export const coverUrl = (id: string) => `/api/items/${id}/cover`

View File

@@ -0,0 +1,21 @@
import api from './client'
export const getLibraries = () =>
api.get('/api/libraries').then((r) => r.data.libraries)
export const getLibraryItems = (
libraryId: string,
params?: { page?: number; limit?: number; search?: string; sort?: string }
) => api.get(`/api/libraries/${libraryId}/items`, { params }).then((r) => r.data)
export const searchLibrary = (libraryId: string, q: string) =>
api.get(`/api/libraries/${libraryId}/search`, { params: { q } }).then((r) => r.data)
export const scanLibrary = (libraryId: string) =>
api.post(`/api/libraries/${libraryId}/scan`).then((r) => r.data)
export const createLibrary = (data: object) =>
api.post('/api/libraries', data).then((r) => r.data)
export const deleteLibrary = (id: string) =>
api.delete(`/api/libraries/${id}`).then((r) => r.data)

34
frontend/src/api/me.ts Normal file
View File

@@ -0,0 +1,34 @@
import api from './client'
export const getMe = () =>
api.get('/api/me').then((r) => r.data)
export const updateProgress = (libraryItemId: string, data: object) =>
api.patch(`/api/me/progress/${libraryItemId}`, data).then((r) => r.data)
export const syncLocalProgress = (data: object) =>
api.post('/api/me/sync-local-progress', data).then((r) => r.data)
export const createBookmark = (libraryItemId: string, time: number, title: string) =>
api.post(`/api/me/bookmark/${libraryItemId}`, { time, title }).then((r) => r.data)
export const deleteBookmark = (libraryItemId: string, time: number) =>
api.delete(`/api/me/bookmark/${libraryItemId}/${time}`).then((r) => r.data)
export const getUsers = () =>
api.get('/api/users').then((r) => r.data)
export const createUser = (data: object) =>
api.post('/api/users', data).then((r) => r.data)
export const updateUser = (id: string, data: object) =>
api.patch(`/api/users/${id}`, data).then((r) => r.data)
export const deleteUser = (id: string) =>
api.delete(`/api/users/${id}`).then((r) => r.data)
export const getSettings = () =>
api.get('/api/settings').then((r) => r.data)
export const updateSettings = (data: object) =>
api.patch('/api/settings', data).then((r) => r.data)

View File

@@ -0,0 +1,29 @@
import React, { useState } from 'react'
import { BookOpen } from 'lucide-react'
interface Props {
src?: string | null
alt?: string
className?: string
}
export default function CoverImage({ src, alt, className = '' }: Props) {
const [error, setError] = useState(false)
if (!src || error) {
return (
<div className={`bg-surface flex items-center justify-center ${className}`}>
<BookOpen className="text-gray-600" size={32} />
</div>
)
}
return (
<img
src={src}
alt={alt || ''}
className={`object-cover ${className}`}
onError={() => setError(true)}
/>
)
}

View File

@@ -0,0 +1,20 @@
import React from 'react'
import Sidebar from './Sidebar'
import MiniPlayer from '../player/MiniPlayer'
import { usePlayerStore } from '../../store/playerStore'
interface Props { children: React.ReactNode }
export default function Layout({ children }: Props) {
const item = usePlayerStore((s) => s.item)
return (
<div className="flex h-screen bg-background overflow-hidden">
<Sidebar />
<main className={`flex-1 overflow-y-auto ${item ? 'pb-24' : ''}`}>
{children}
</main>
{item && <MiniPlayer />}
</div>
)
}

View File

@@ -0,0 +1,76 @@
import React from 'react'
import { NavLink } from 'react-router-dom'
import { Library, Mic2, Settings, LogOut, BookOpen } from 'lucide-react'
import { useAuthStore } from '../../store/authStore'
export default function Sidebar() {
const { libraries, user, logout } = useAuthStore()
return (
<aside className="w-56 bg-surface flex-shrink-0 flex flex-col border-r border-white/5">
{/* Logo */}
<div className="p-4 flex items-center gap-2 border-b border-white/5">
<BookOpen className="text-primary" size={22} />
<span className="font-bold text-white text-lg">Audiolib</span>
</div>
{/* Libraries */}
<nav className="flex-1 overflow-y-auto p-2">
<p className="text-xs text-gray-500 uppercase tracking-wider px-2 py-2">Bibliotheken</p>
{libraries.map((lib: any) => (
<NavLink
key={lib.id}
to={`/library/${lib.id}`}
className={({ isActive }) =>
`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
isActive ? 'bg-primary/20 text-primary' : 'text-gray-400 hover:text-white hover:bg-white/5'
}`
}
>
<Library size={16} />
{lib.name}
</NavLink>
))}
<div className="mt-4 border-t border-white/5 pt-2">
<p className="text-xs text-gray-500 uppercase tracking-wider px-2 py-2">Navigation</p>
<NavLink
to="/podcasts"
className={({ isActive }) =>
`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
isActive ? 'bg-primary/20 text-primary' : 'text-gray-400 hover:text-white hover:bg-white/5'
}`
}
>
<Mic2 size={16} />
Podcasts
</NavLink>
</div>
</nav>
{/* Footer */}
<div className="p-2 border-t border-white/5">
{user?.isAdmin && (
<NavLink
to="/admin"
className={({ isActive }) =>
`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
isActive ? 'bg-primary/20 text-primary' : 'text-gray-400 hover:text-white hover:bg-white/5'
}`
}
>
<Settings size={16} />
Admin
</NavLink>
)}
<button
onClick={logout}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-400 hover:text-white hover:bg-white/5 transition-colors"
>
<LogOut size={16} />
Abmelden
</button>
</div>
</aside>
)
}

View File

@@ -0,0 +1,75 @@
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { Play, AlertCircle } from 'lucide-react'
import CoverImage from '../common/CoverImage'
import { coverUrl } from '../../api/items'
import { usePlayerStore } from '../../store/playerStore'
interface Props { item: any }
export default function BookCard({ item }: Props) {
const navigate = useNavigate()
const play = usePlayerStore((s) => s.play)
const currentItem = usePlayerStore((s) => s.item)
const meta = item.media?.metadata || {}
const title = meta.title || item.relPath || 'Unbekannt'
const author = meta.authors?.[0]?.name || ''
const series = meta.series?.[0]
const progress = item._progress
const tags: string[] = item.media?.tags || []
const isPlaying = currentItem?.id === item.id
const hasCover = item.media?.coverPath
return (
<div
className="group relative bg-surface rounded-lg overflow-hidden cursor-pointer hover:ring-1 hover:ring-primary/50 transition-all"
onClick={() => navigate(`/book/${item.id}`)}
>
<div className="relative aspect-square">
<CoverImage
src={hasCover ? coverUrl(item.id) : null}
alt={title}
className="w-full h-full"
/>
{isPlaying && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<div className="w-3 h-3 bg-primary rounded-full animate-pulse" />
</div>
)}
<button
className="absolute bottom-2 right-2 bg-primary text-black rounded-full p-2 opacity-0 group-hover:opacity-100 transition-opacity shadow-lg"
onClick={(e) => { e.stopPropagation(); play(item) }}
>
<Play size={14} fill="currentColor" />
</button>
</div>
<div className="p-3">
{series && (
<p className="text-xs text-primary truncate mb-0.5">
{series.name}{series.sequence ? ` #${series.sequence}` : ''}
</p>
)}
<p className="text-sm font-medium text-white truncate">{title}</p>
{author && <p className="text-xs text-gray-400 truncate mt-0.5">{author}</p>}
{progress && !progress.isFinished && (
<div className="mt-2 h-0.5 bg-white/10 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: `${Math.min((progress.currentTime / progress.duration) * 100, 100)}%` }}
/>
</div>
)}
{tags.includes('zu_prüfen') && (
<div className="mt-1.5 flex items-center gap-1">
<AlertCircle size={10} className="text-yellow-400 flex-shrink-0" />
<span className="text-xs text-yellow-400">zu prüfen</span>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,304 @@
import React, { useEffect, useRef, useState } from 'react'
import Hls from 'hls.js'
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX,
List, BookmarkPlus, Moon, X, ChevronLeft
} from 'lucide-react'
import { usePlayerStore } from '../../store/playerStore'
import { createBookmark } from '../../api/me'
import CoverImage from '../common/CoverImage'
import { coverUrl } from '../../api/items'
import ChapterList from './ChapterList'
export default function AudioPlayer() {
const {
item, session, currentTime, duration, isPlaying, playbackRate, volume, chapters,
sleepTimerActive, sleepTimer,
setPlaying, setCurrentTime, setDuration, setPlaybackRate, setVolume,
seek, stop, setExpanded, setSleepTimer, cancelSleepTimer, syncProgress,
} = usePlayerStore()
const audioRef = useRef<HTMLAudioElement>(null)
const hlsRef = useRef<Hls | null>(null)
const [showChapters, setShowChapters] = useState(false)
const [showSpeedMenu, setShowSpeedMenu] = useState(false)
const [showSleepMenu, setShowSleepMenu] = useState(false)
const [muted, setMuted] = useState(false)
const meta = item?.media?.metadata || {}
const title = meta.title || item?.relPath || ''
const author = meta.authors?.[0]?.name || ''
// HLS laden sobald sich die Session ändert
useEffect(() => {
if (!session || !audioRef.current) return
const hlsUrl = session.audioTracks?.[0]?.contentUrl
if (!hlsUrl) return
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null }
const audio = audioRef.current
if (Hls.isSupported()) {
const hls = new Hls({ startPosition: session.currentTime || 0 })
hls.loadSource(hlsUrl)
hls.attachMedia(audio)
hlsRef.current = hls
} else if (audio.canPlayType('application/vnd.apple.mpegurl')) {
audio.src = hlsUrl
audio.currentTime = session.currentTime || 0
}
audio.playbackRate = playbackRate
audio.volume = volume
audio.play().catch(() => {})
return () => {
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null }
}
}, [session?.id])
// isPlaying <-> audio
useEffect(() => {
if (!audioRef.current) return
if (isPlaying) audioRef.current.play().catch(() => {})
else audioRef.current.pause()
}, [isPlaying])
useEffect(() => {
if (audioRef.current) audioRef.current.playbackRate = playbackRate
}, [playbackRate])
useEffect(() => {
if (audioRef.current) audioRef.current.volume = muted ? 0 : volume
}, [volume, muted])
// Keyboard shortcuts
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement) return
if (e.code === 'Space') { e.preventDefault(); setPlaying(!isPlaying) }
if (e.code === 'ArrowRight') audioRef.current && (audioRef.current.currentTime += 30)
if (e.code === 'ArrowLeft') audioRef.current && (audioRef.current.currentTime -= 10)
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [isPlaying])
const handleTimeUpdate = () => {
if (!audioRef.current) return
setCurrentTime(audioRef.current.currentTime)
}
const handleLoadedMetadata = () => {
if (!audioRef.current) return
setDuration(audioRef.current.duration)
}
const handleEnded = () => {
setPlaying(false)
syncProgress()
}
const handleSeekBar = (e: React.ChangeEvent<HTMLInputElement>) => {
const t = parseFloat(e.target.value)
if (audioRef.current) audioRef.current.currentTime = t
seek(t)
}
const fmtTime = (s: number) => {
if (!isFinite(s)) return '0:00'
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const sec = Math.floor(s % 60)
return h > 0
? `${h}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
: `${m}:${sec.toString().padStart(2, '0')}`
}
const currentChapter = chapters.findLast?.((c: any) => currentTime >= c.start) || chapters[0]
const addBookmark = async () => {
if (!item) return
const label = currentChapter?.title || fmtTime(currentTime)
await createBookmark(item.id, currentTime, label)
}
const SPEEDS = [0.75, 1, 1.25, 1.5, 1.75, 2]
const SLEEP_OPTIONS = [15 * 60, 30 * 60, 45 * 60, 60 * 60]
// Chapter progress markers
const chapterMarkers = chapters.map((c: any) => ({
pct: duration > 0 ? (c.start / duration) * 100 : 0,
}))
return (
<div className="flex flex-col h-full bg-background p-6 overflow-y-auto">
<audio
ref={audioRef}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={handleEnded}
/>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<button onClick={() => setExpanded(false)} className="text-gray-400 hover:text-white">
<ChevronLeft size={24} />
</button>
<span className="text-sm text-gray-400">Jetzt läuft</span>
<button onClick={stop} className="text-gray-400 hover:text-white">
<X size={20} />
</button>
</div>
{/* Cover */}
<div className="flex justify-center mb-6">
<CoverImage
src={item ? coverUrl(item.id) : null}
alt={title}
className="w-64 h-64 rounded-xl shadow-2xl"
/>
</div>
{/* Title */}
<div className="text-center mb-6">
{currentChapter && (
<p className="text-xs text-primary mb-1 truncate">{currentChapter.title}</p>
)}
<h2 className="text-xl font-bold text-white truncate">{title}</h2>
{author && <p className="text-gray-400 mt-1 truncate">{author}</p>}
</div>
{/* Progress bar with chapter markers */}
<div className="mb-4 relative">
<div className="relative h-1.5 bg-white/10 rounded-full mb-1">
{chapterMarkers.map((m: any, i: number) => (
<div
key={i}
className="absolute top-0 w-0.5 h-full bg-white/20"
style={{ left: `${m.pct}%` }}
/>
))}
<div
className="absolute top-0 left-0 h-full bg-primary rounded-full pointer-events-none"
style={{ width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%` }}
/>
<input
type="range"
min={0}
max={duration || 0}
step={1}
value={currentTime}
onChange={handleSeekBar}
className="absolute inset-0 w-full opacity-0 cursor-pointer h-full"
/>
</div>
<div className="flex justify-between text-xs text-gray-500">
<span>{fmtTime(currentTime)}</span>
<span>-{fmtTime(duration - currentTime)}</span>
</div>
</div>
{/* Controls */}
<div className="flex items-center justify-center gap-6 mb-6">
<button
className="text-gray-400 hover:text-white"
onClick={() => audioRef.current && (audioRef.current.currentTime -= 30)}
>
<SkipBack size={28} />
</button>
<button
className="w-14 h-14 rounded-full bg-primary text-black flex items-center justify-center hover:bg-primary/80 transition-colors"
onClick={() => setPlaying(!isPlaying)}
>
{isPlaying ? <Pause size={24} fill="currentColor" /> : <Play size={24} fill="currentColor" />}
</button>
<button
className="text-gray-400 hover:text-white"
onClick={() => audioRef.current && (audioRef.current.currentTime += 30)}
>
<SkipForward size={28} />
</button>
</div>
{/* Secondary controls */}
<div className="flex items-center justify-between">
{/* Speed */}
<div className="relative">
<button
className="text-sm text-gray-400 hover:text-white px-2 py-1 rounded"
onClick={() => setShowSpeedMenu(!showSpeedMenu)}
>
{playbackRate}×
</button>
{showSpeedMenu && (
<div className="absolute bottom-8 left-0 bg-surface border border-white/10 rounded-lg shadow-xl overflow-hidden z-10">
{SPEEDS.map((s) => (
<button
key={s}
className={`block w-full px-4 py-2 text-sm text-left hover:bg-white/5 ${playbackRate === s ? 'text-primary' : 'text-gray-300'}`}
onClick={() => { setPlaybackRate(s); setShowSpeedMenu(false) }}
>
{s}×
</button>
))}
</div>
)}
</div>
{/* Bookmark */}
<button className="text-gray-400 hover:text-white" onClick={addBookmark}>
<BookmarkPlus size={20} />
</button>
{/* Sleep Timer */}
<div className="relative">
<button
className={`hover:text-white ${sleepTimerActive ? 'text-primary' : 'text-gray-400'}`}
onClick={() => sleepTimerActive ? cancelSleepTimer() : setShowSleepMenu(!showSleepMenu)}
>
<Moon size={20} />
</button>
{sleepTimerActive && sleepTimer !== null && (
<span className="text-xs text-primary ml-1">{Math.floor(sleepTimer / 60)}m</span>
)}
{showSleepMenu && (
<div className="absolute bottom-8 right-0 bg-surface border border-white/10 rounded-lg shadow-xl overflow-hidden z-10">
{SLEEP_OPTIONS.map((s) => (
<button
key={s}
className="block w-full px-4 py-2 text-sm text-left text-gray-300 hover:bg-white/5"
onClick={() => { setSleepTimer(s); setShowSleepMenu(false) }}
>
{s / 60} Min
</button>
))}
</div>
)}
</div>
{/* Chapter List */}
<button
className={`hover:text-white ${showChapters ? 'text-primary' : 'text-gray-400'}`}
onClick={() => setShowChapters(!showChapters)}
>
<List size={20} />
</button>
{/* Volume */}
<button className="text-gray-400 hover:text-white" onClick={() => setMuted(!muted)}>
{muted ? <VolumeX size={20} /> : <Volume2 size={20} />}
</button>
</div>
{/* Chapter List Panel */}
{showChapters && chapters.length > 0 && (
<ChapterList
chapters={chapters}
currentTime={currentTime}
onSeek={(t) => { if (audioRef.current) audioRef.current.currentTime = t; seek(t) }}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,39 @@
import React from 'react'
interface Chapter { id: number; start: number; end: number; title: string }
interface Props {
chapters: Chapter[]
currentTime: number
onSeek: (time: number) => void
}
export default function ChapterList({ chapters, currentTime, onSeek }: Props) {
const fmt = (s: number) => {
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const sec = Math.floor(s % 60)
return h > 0
? `${h}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
: `${m}:${sec.toString().padStart(2, '0')}`
}
const active = chapters.findLastIndex?.((c) => currentTime >= c.start) ?? -1
return (
<div className="mt-4 border-t border-white/10 pt-4 max-h-64 overflow-y-auto">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Kapitel</p>
{chapters.map((ch, i) => (
<button
key={ch.id}
className={`w-full flex items-center gap-3 px-2 py-2 rounded-lg text-left transition-colors ${
i === active ? 'bg-primary/10 text-primary' : 'text-gray-400 hover:text-white hover:bg-white/5'
}`}
onClick={() => onSeek(ch.start)}
>
<span className="text-xs font-mono w-10 flex-shrink-0">{fmt(ch.start)}</span>
<span className="text-sm truncate">{ch.title}</span>
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,53 @@
import React from 'react'
import { Play, Pause, X, ChevronUp } from 'lucide-react'
import { usePlayerStore } from '../../store/playerStore'
import CoverImage from '../common/CoverImage'
import { coverUrl } from '../../api/items'
export default function MiniPlayer() {
const { item, currentTime, duration, isPlaying, setPlaying, stop, setExpanded } = usePlayerStore()
if (!item) return null
const meta = item.media?.metadata || {}
const title = meta.title || item.relPath || ''
const author = meta.authors?.[0]?.name || ''
const pct = duration > 0 ? (currentTime / duration) * 100 : 0
return (
<div className="fixed bottom-0 left-0 right-0 bg-surface border-t border-white/10 z-50">
{/* Progress bar */}
<div className="h-0.5 bg-white/10">
<div className="h-full bg-primary transition-all" style={{ width: `${pct}%` }} />
</div>
<div className="flex items-center gap-3 px-4 py-3">
<div
className="cursor-pointer flex items-center gap-3 flex-1 min-w-0"
onClick={() => setExpanded(true)}
>
<CoverImage
src={item.media?.coverPath ? coverUrl(item.id) : null}
alt={title}
className="w-10 h-10 rounded flex-shrink-0"
/>
<div className="min-w-0">
<p className="text-sm font-medium text-white truncate">{title}</p>
{author && <p className="text-xs text-gray-400 truncate">{author}</p>}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
className="w-9 h-9 rounded-full bg-primary text-black flex items-center justify-center"
onClick={() => setPlaying(!isPlaying)}
>
{isPlaying ? <Pause size={16} fill="currentColor" /> : <Play size={16} fill="currentColor" />}
</button>
<button className="text-gray-400 hover:text-white p-1" onClick={stop}>
<X size={18} />
</button>
</div>
</div>
</div>
)
}

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

View File

@@ -0,0 +1,274 @@
import React, { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
Play, ArrowLeft, Tag, RefreshCw, Search, Check,
Loader2, Trash2, X, ExternalLink, BookmarkPlus
} from 'lucide-react'
import { getItem, updateItem, triggerMatch, searchMatch, applyMatch, coverUrl } from '../api/items'
import { getMe, createBookmark, deleteBookmark } from '../api/me'
import { usePlayerStore } from '../store/playerStore'
import CoverImage from '../components/common/CoverImage'
import ChapterList from '../components/player/ChapterList'
export default function BookDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [item, setItem] = useState<any>(null)
const [progress, setProgress] = useState<any>(null)
const [bookmarks, setBookmarks] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [matchResults, setMatchResults] = useState<any[]>([])
const [matchQuery, setMatchQuery] = useState('')
const [matchLoading, setMatchLoading] = useState(false)
const [showMatchPanel, setShowMatchPanel] = useState(false)
const { play, item: currentItem, currentTime } = usePlayerStore()
useEffect(() => {
if (!id) return
Promise.all([getItem(id), getMe()]).then(([itemData, me]) => {
setItem(itemData)
setProgress(me.mediaProgress?.find((p: any) => p.libraryItemId === id) || null)
setBookmarks(me.bookmarks?.filter((b: any) => b.libraryItemId === id) || [])
setLoading(false)
})
}, [id])
if (loading) return (
<div className="flex justify-center py-16"><Loader2 className="text-primary animate-spin" size={32} /></div>
)
if (!item) return <div className="p-6 text-gray-400">Nicht gefunden</div>
const meta = item.media?.metadata || {}
const title = meta.title || item.relPath || 'Unbekannt'
const author = meta.authors?.map((a: any) => a.name).join(', ') || ''
const series = meta.series?.[0]
const chapters = item.media?.chapters || []
const tags: string[] = item.media?.tags || []
const isCurrentItem = currentItem?.id === id
const handleRemoveTag = async (tag: string) => {
const newTags = tags.filter((t) => t !== tag)
const updated = await updateItem(id!, { tags: newTags })
setItem(updated)
}
const handleSearchMatch = async () => {
if (!id) return
setMatchLoading(true)
const res = await searchMatch(id, matchQuery || undefined)
setMatchResults(res.results || [])
setMatchLoading(false)
}
const handleApplyMatch = async (match: any) => {
if (!id) return
const updated = await applyMatch(id, match)
setItem(updated)
setShowMatchPanel(false)
setMatchResults([])
}
const handleAutoMatch = async () => {
if (!id) return
setMatchLoading(true)
await triggerMatch(id)
setTimeout(async () => {
const updated = await getItem(id)
setItem(updated)
setMatchLoading(false)
}, 3000)
}
const fmtTime = (s: number) => {
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
return (
<div className="p-6 max-w-4xl">
<button onClick={() => navigate(-1)} className="flex items-center gap-2 text-gray-400 hover:text-white mb-6 text-sm">
<ArrowLeft size={16} /> Zurück
</button>
<div className="flex gap-6 mb-8 flex-wrap">
{/* Cover */}
<CoverImage
src={item.media?.coverPath ? coverUrl(id!) : null}
alt={title}
className="w-48 h-48 rounded-xl flex-shrink-0 shadow-2xl"
/>
{/* Info */}
<div className="flex-1 min-w-0">
{series && (
<p className="text-primary text-sm mb-1">
{series.name}{series.sequence ? ` #${series.sequence}` : ''}
</p>
)}
<h1 className="text-2xl font-bold text-white mb-1">{title}</h1>
{author && <p className="text-gray-400 mb-2">{author}</p>}
{meta.narrator && <p className="text-gray-500 text-sm mb-2">Sprecher: {meta.narrator}</p>}
{meta.publisher && <p className="text-gray-500 text-sm">Verlag: {meta.publisher} {meta.publishedYear ? `(${meta.publishedYear})` : ''}</p>}
{item.media?.duration > 0 && (
<p className="text-gray-500 text-sm mt-1">{fmtTime(item.media.duration)}</p>
)}
{/* Progress */}
{progress && !progress.isFinished && (
<div className="mt-3">
<div className="h-1 bg-white/10 rounded-full w-48">
<div className="h-full bg-primary rounded-full" style={{ width: `${Math.min((progress.currentTime / progress.duration) * 100, 100)}%` }} />
</div>
<p className="text-xs text-gray-500 mt-1">{fmtTime(progress.currentTime)} von {fmtTime(progress.duration)}</p>
</div>
)}
{progress?.isFinished && <p className="text-primary text-sm mt-2"> Abgeschlossen</p>}
{/* Tags */}
<div className="flex flex-wrap gap-2 mt-3">
{tags.map((tag) => (
<span key={tag} className={`flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ${
tag === 'zu_prüfen' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-white/10 text-gray-300'
}`}>
{tag}
<button onClick={() => handleRemoveTag(tag)} className="hover:text-white">
<X size={10} />
</button>
</span>
))}
</div>
{/* Actions */}
<div className="flex gap-3 mt-4 flex-wrap">
<button
onClick={() => play(item)}
className="flex items-center gap-2 bg-primary text-black font-semibold px-4 py-2 rounded-lg hover:bg-primary/80"
>
<Play size={16} fill="currentColor" />
{isCurrentItem ? 'Läuft...' : progress ? 'Weiter hören' : 'Abspielen'}
</button>
<button
onClick={() => setShowMatchPanel(!showMatchPanel)}
className="flex items-center gap-2 bg-surface border border-white/10 px-4 py-2 rounded-lg text-sm text-gray-300 hover:text-white hover:bg-white/5"
>
<Search size={14} />
Match
</button>
<button
onClick={handleAutoMatch}
disabled={matchLoading}
className="flex items-center gap-2 bg-surface border border-white/10 px-4 py-2 rounded-lg text-sm text-gray-300 hover:text-white hover:bg-white/5 disabled:opacity-50"
>
<RefreshCw size={14} className={matchLoading ? 'animate-spin' : ''} />
Auto-Match
</button>
</div>
</div>
</div>
{/* Description */}
{meta.description && (
<div className="mb-6">
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">Beschreibung</h3>
<p className="text-gray-300 text-sm leading-relaxed whitespace-pre-line">{meta.description}</p>
</div>
)}
{/* Match info */}
{item.matchedSource && item.matchedSource !== 'none' && (
<div className="mb-4 flex items-center gap-2 text-xs text-gray-500">
<Check size={12} className="text-primary" />
Metadaten via {item.matchedSource}
{item.matchConfidence && ` (${Math.round(item.matchConfidence * 100)}%)`}
</div>
)}
{/* Manual Match Panel */}
{showMatchPanel && (
<div className="mb-6 bg-surface border border-white/10 rounded-xl p-4">
<h3 className="text-sm font-semibold text-white mb-3">Manuelles Matching</h3>
<div className="flex gap-2 mb-4">
<input
type="text"
value={matchQuery}
onChange={(e) => setMatchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearchMatch()}
placeholder={`${meta.title || 'Suche...'}`}
className="flex-1 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"
/>
<button
onClick={handleSearchMatch}
disabled={matchLoading}
className="bg-primary text-black px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
{matchLoading ? <Loader2 size={14} className="animate-spin" /> : 'Suchen'}
</button>
</div>
{matchResults.map((r, i) => (
<div key={i} className="flex items-center gap-3 py-2 border-t border-white/5 hover:bg-white/3">
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{r.title}</p>
<p className="text-xs text-gray-400">{r.author} · {r.source} · {Math.round(r.confidence * 100)}%</p>
</div>
<button
onClick={() => handleApplyMatch(r)}
className="text-xs bg-primary/20 text-primary px-3 py-1 rounded-lg hover:bg-primary/30"
>
Anwenden
</button>
</div>
))}
</div>
)}
{/* Chapters */}
{chapters.length > 0 && (
<div className="mb-6">
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">
Kapitel ({chapters.length})
</h3>
<ChapterList
chapters={chapters}
currentTime={isCurrentItem ? currentTime : 0}
onSeek={(t) => {
if (isCurrentItem) usePlayerStore.getState().seek(t)
else play(item).then(() => usePlayerStore.getState().seek(t))
}}
/>
</div>
)}
{/* Bookmarks */}
{bookmarks.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">Lesezeichen</h3>
<div className="space-y-1">
{bookmarks.map((b, i) => {
const fmt = (s: number) => {
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = Math.floor(s % 60)
return h > 0 ? `${h}:${m.toString().padStart(2,'0')}:${sec.toString().padStart(2,'0')}` : `${m}:${sec.toString().padStart(2,'0')}`
}
return (
<div key={i} className="flex items-center gap-3 py-2 border-t border-white/5">
<span className="text-xs font-mono text-gray-500 w-16">{fmt(b.time)}</span>
<span className="text-sm text-gray-300 flex-1">{b.title}</span>
<button
onClick={async () => {
await deleteBookmark(id!, b.time)
setBookmarks(bks => bks.filter((_, j) => j !== i))
}}
className="text-gray-500 hover:text-red-400"
>
<Trash2 size={12} />
</button>
</div>
)
})}
</div>
</div>
)}
</div>
)
}

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

View File

@@ -0,0 +1,67 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { BookOpen, Loader2 } from 'lucide-react'
import { useAuthStore } from '../store/authStore'
export default function Login() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const { login, loading } = useAuthStore()
const navigate = useNavigate()
const submit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
try {
await login(username, password)
navigate('/', { replace: true })
} catch {
setError('Ungültige Anmeldedaten')
}
}
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="flex items-center justify-center gap-3 mb-8">
<BookOpen className="text-primary" size={32} />
<h1 className="text-3xl font-bold text-white">Audiolib</h1>
</div>
<form onSubmit={submit} className="bg-surface rounded-xl p-6 space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Benutzername</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-primary"
placeholder="admin"
autoFocus
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Passwort</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-primary"
placeholder="••••••••"
/>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<button
type="submit"
disabled={loading || !username || !password}
className="w-full bg-primary text-black font-semibold py-2.5 rounded-lg hover:bg-primary/80 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{loading && <Loader2 size={16} className="animate-spin" />}
Anmelden
</button>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,154 @@
import React, { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, RefreshCw, Loader2, ExternalLink } from 'lucide-react'
import api from '../api/client'
import CoverImage from '../components/common/CoverImage'
import { coverUrl } from '../api/items'
export default function PodcastDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [podcast, setPodcast] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [feedInput, setFeedInput] = useState('')
const [saving, setSaving] = useState(false)
const [searchQ, setSearchQ] = useState('')
const [searchResults, setSearchResults] = useState<any[]>([])
useEffect(() => {
if (!id) return
api.get(`/api/podcasts/${id}`).then((r) => {
setPodcast(r.data)
setFeedInput(r.data.feedUrl || '')
setLoading(false)
}).catch(() => setLoading(false))
}, [id])
const handleSetFeed = async () => {
if (!id || !feedInput) return
setSaving(true)
await api.patch(`/api/podcasts/${id}/feed`, { feedUrl: feedInput })
const updated = await api.get(`/api/podcasts/${id}`)
setPodcast(updated.data)
setSaving(false)
}
const handleUpdate = async () => {
if (!id) return
await api.post(`/api/podcasts/${id}/update-feed`)
}
const handleSearchFeed = async () => {
const r = await api.get('/api/podcasts/search', { params: { q: searchQ } })
setSearchResults(r.data.results || [])
}
const fmtDate = (s: string | null) => s ? new Date(s).toLocaleDateString('de-DE') : ''
const fmtDur = (s: number) => {
const m = Math.floor(s / 60)
return m > 0 ? `${m} Min` : ''
}
if (loading) return <div className="flex justify-center py-16"><Loader2 className="text-primary animate-spin" size={32} /></div>
if (!podcast) return <div className="p-6 text-gray-400">Nicht gefunden</div>
return (
<div className="p-6 max-w-3xl">
<button onClick={() => navigate(-1)} className="flex items-center gap-2 text-gray-400 hover:text-white mb-6 text-sm">
<ArrowLeft size={16} /> Zurück
</button>
<div className="flex gap-6 mb-8">
<CoverImage src={podcast.cover} alt={podcast.title} className="w-40 h-40 rounded-xl flex-shrink-0 shadow-xl" />
<div className="flex-1">
<h1 className="text-2xl font-bold text-white mb-1">{podcast.title}</h1>
{podcast.author && <p className="text-gray-400 mb-2">{podcast.author}</p>}
<p className="text-sm text-gray-500">{podcast.numEpisodes} Episoden</p>
{podcast.feedLastChecked && (
<p className="text-xs text-gray-600 mt-1">
Zuletzt aktualisiert: {fmtDate(podcast.feedLastChecked)}
</p>
)}
<button onClick={handleUpdate} className="mt-3 flex items-center gap-2 bg-surface border border-white/10 px-3 py-1.5 rounded-lg text-sm text-gray-300 hover:text-white hover:bg-white/5">
<RefreshCw size={13} /> Feed aktualisieren
</button>
</div>
</div>
{podcast.description && (
<p className="text-gray-400 text-sm leading-relaxed mb-6">{podcast.description}</p>
)}
{/* Feed URL */}
<div className="bg-surface border border-white/10 rounded-xl p-4 mb-6">
<h3 className="text-sm font-semibold text-white mb-3">RSS-Feed</h3>
<div className="flex gap-2">
<input
type="url"
value={feedInput}
onChange={(e) => setFeedInput(e.target.value)}
placeholder="https://..."
className="flex-1 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"
/>
<button
onClick={handleSetFeed}
disabled={saving || !feedInput}
className="bg-primary text-black px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
{saving ? <Loader2 size={14} className="animate-spin" /> : 'Speichern'}
</button>
</div>
{/* Feed-Suche */}
<div className="mt-3 flex gap-2">
<input
type="text"
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearchFeed()}
placeholder="Podcast suchen..."
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none"
/>
<button onClick={handleSearchFeed} className="bg-white/10 px-3 py-2 rounded-lg text-sm text-gray-300 hover:bg-white/20">
Suchen
</button>
</div>
{searchResults.map((r, i) => (
<div key={i} className="flex items-center gap-3 mt-2 py-2 border-t border-white/5">
<div className="flex-1">
<p className="text-sm text-white">{r.title}</p>
<p className="text-xs text-gray-500">{r.author} · {r.trackCount} Episoden</p>
</div>
<button
onClick={() => { setFeedInput(r.feedUrl); setSearchResults([]) }}
className="text-xs bg-primary/20 text-primary px-3 py-1 rounded-lg hover:bg-primary/30"
>
Verwenden
</button>
</div>
))}
</div>
{/* Episodes */}
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Episoden</h3>
<div className="space-y-1">
{podcast.episodes?.map((ep: any) => (
<div key={ep.id} className="flex items-start gap-3 py-3 border-t border-white/5">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{ep.title || 'Unbekannte Episode'}</p>
<div className="flex items-center gap-3 mt-0.5">
{ep.pubDate && <span className="text-xs text-gray-500">{fmtDate(ep.pubDate)}</span>}
{ep.duration > 0 && <span className="text-xs text-gray-500">{fmtDur(ep.duration)}</span>}
</div>
</div>
</div>
))}
{(!podcast.episodes || podcast.episodes.length === 0) && (
<p className="text-gray-500 text-sm py-4">Keine Episoden. Feed konfigurieren und aktualisieren.</p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,52 @@
import { create } from 'zustand'
import { login as apiLogin, authorize, logout as apiLogout } from '../api/auth'
interface AuthState {
user: any | null
libraries: any[]
token: string | null
loading: boolean
login: (username: string, password: string) => Promise<void>
loadAuth: () => Promise<void>
logout: () => Promise<void>
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
libraries: [],
token: localStorage.getItem('token'),
loading: false,
login: async (username, password) => {
set({ loading: true })
try {
const data = await apiLogin(username, password)
const token = data.user?.token
if (token) localStorage.setItem('token', token)
set({ user: data.user, libraries: [], token, loading: false })
const auth = await authorize()
set({ user: auth.user, libraries: auth.libraries || [] })
} catch (e) {
set({ loading: false })
throw e
}
},
loadAuth: async () => {
const token = localStorage.getItem('token')
if (!token) return
try {
const auth = await authorize()
set({ user: auth.user, libraries: auth.libraries || [], token })
} catch {
localStorage.removeItem('token')
set({ user: null, token: null, libraries: [] })
}
},
logout: async () => {
try { await apiLogout() } catch { }
localStorage.removeItem('token')
set({ user: null, token: null, libraries: [] })
},
}))

View File

@@ -0,0 +1,121 @@
import { create } from 'zustand'
import { startPlayback, syncSession, closeSession } from '../api/items'
import { updateProgress } from '../api/me'
interface Chapter { id: number; start: number; end: number; title: string }
interface PlayerState {
item: any | null
session: any | null
currentTime: number
duration: number
isPlaying: boolean
playbackRate: number
volume: number
sleepTimer: number | null // Sekunden verbleibend
sleepTimerActive: boolean
chapters: Chapter[]
expanded: boolean
play: (item: any) => Promise<void>
stop: () => Promise<void>
seek: (time: number) => void
setPlaying: (v: boolean) => void
setCurrentTime: (t: number) => void
setDuration: (d: number) => void
setPlaybackRate: (r: number) => void
setVolume: (v: number) => void
setSleepTimer: (seconds: number) => void
cancelSleepTimer: () => void
setExpanded: (v: boolean) => void
syncProgress: () => Promise<void>
}
let syncInterval: ReturnType<typeof setInterval> | null = null
let sleepInterval: ReturnType<typeof setInterval> | null = null
export const usePlayerStore = create<PlayerState>((set, get) => ({
item: null,
session: null,
currentTime: 0,
duration: 0,
isPlaying: false,
playbackRate: 1,
volume: 1,
sleepTimer: null,
sleepTimerActive: false,
chapters: [],
expanded: false,
play: async (item: any) => {
const { session: oldSession, stop } = get()
if (oldSession) await stop()
const session = await startPlayback(item.id, { mediaPlayer: 'audiolib-web' })
set({
item,
session,
currentTime: session.currentTime || 0,
duration: session.duration || 0,
chapters: session.chapters || [],
isPlaying: true,
expanded: false,
})
// Sync alle 15 Sekunden
if (syncInterval) clearInterval(syncInterval)
syncInterval = setInterval(() => get().syncProgress(), 15000)
},
stop: async () => {
const { session } = get()
if (syncInterval) { clearInterval(syncInterval); syncInterval = null }
if (sleepInterval) { clearInterval(sleepInterval); sleepInterval = null }
if (session) {
try { await closeSession(session.id) } catch { }
}
set({ item: null, session: null, isPlaying: false, currentTime: 0, sleepTimerActive: false })
},
seek: (time) => set({ currentTime: time }),
setPlaying: (v) => set({ isPlaying: v }),
setCurrentTime: (t) => set({ currentTime: t }),
setDuration: (d) => set({ duration: d }),
setPlaybackRate: (r) => set({ playbackRate: r }),
setVolume: (v) => set({ volume: v }),
setSleepTimer: (seconds) => {
if (sleepInterval) clearInterval(sleepInterval)
set({ sleepTimer: seconds, sleepTimerActive: true })
sleepInterval = setInterval(() => {
const { sleepTimer } = get()
if (sleepTimer === null || sleepTimer <= 0) {
clearInterval(sleepInterval!)
set({ isPlaying: false, sleepTimerActive: false, sleepTimer: null })
return
}
set({ sleepTimer: sleepTimer - 1 })
}, 1000)
},
cancelSleepTimer: () => {
if (sleepInterval) { clearInterval(sleepInterval); sleepInterval = null }
set({ sleepTimer: null, sleepTimerActive: false })
},
setExpanded: (v) => set({ expanded: v }),
syncProgress: async () => {
const { session, currentTime, duration, item } = get()
if (!session || !item) return
try {
await syncSession(session.id, { currentTime, duration, timeListening: 15 })
await updateProgress(item.id, { currentTime, duration })
} catch { }
},
}))