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:
@@ -10,7 +10,7 @@ from ..models.user import User
|
||||
from ..models.media_item import LibraryItem, BookFile, Chapter
|
||||
from ..models.session import PlaybackSession
|
||||
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
|
||||
|
||||
router = APIRouter(tags=["stream"])
|
||||
@@ -34,14 +34,13 @@ async def start_playback(
|
||||
)
|
||||
files = files_result.scalars().all()
|
||||
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(
|
||||
select(Chapter).where(Chapter.library_item_id == item_id).order_by(Chapter.chapter_index)
|
||||
)
|
||||
chapters = chapters_result.scalars().all()
|
||||
|
||||
# Fortschritt ermitteln
|
||||
progress_result = await db.execute(
|
||||
select(MediaProgress).where(
|
||||
MediaProgress.user_id == current_user.id,
|
||||
@@ -54,10 +53,14 @@ async def start_playback(
|
||||
current_time = float(body["startTime"])
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
|
||||
# HLS-Session asynchron starten
|
||||
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(
|
||||
id=session_id,
|
||||
@@ -75,35 +78,9 @@ async def start_playback(
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
|
||||
settings = get_settings()
|
||||
# URL-Basis relativ — wird durch nginx weitergeleitet
|
||||
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 = [
|
||||
{
|
||||
"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
|
||||
]
|
||||
|
||||
@@ -119,7 +96,7 @@ async def start_playback(
|
||||
"displayAuthor": item.author,
|
||||
"coverPath": f"/api/items/{item_id}/cover" if item.cover_path else None,
|
||||
"duration": item.duration_seconds or 0.0,
|
||||
"playMethod": 0, # 0 = HLS Transcode
|
||||
"playMethod": 0,
|
||||
"mediaPlayer": body.get("mediaPlayer", ""),
|
||||
"deviceInfo": body.get("deviceInfo", {}),
|
||||
"serverVersion": "2.4.0",
|
||||
@@ -130,7 +107,15 @@ async def start_playback(
|
||||
"currentTime": current_time,
|
||||
"startedAt": 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,
|
||||
}
|
||||
|
||||
@@ -138,15 +123,13 @@ async def start_playback(
|
||||
@router.post("/api/playback-session/{session_id}/sync")
|
||||
async def sync_session(
|
||||
session_id: str,
|
||||
body: dict = {},
|
||||
body: dict | None = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
body = body or {}
|
||||
result = await db.execute(
|
||||
select(PlaybackSession).where(
|
||||
PlaybackSession.id == session_id,
|
||||
PlaybackSession.user_id == current_user.id,
|
||||
)
|
||||
select(PlaybackSession).where(PlaybackSession.id == session_id, PlaybackSession.user_id == current_user.id)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if not session:
|
||||
@@ -154,13 +137,10 @@ async def sync_session(
|
||||
|
||||
current_time = float(body.get("currentTime", session.current_time))
|
||||
duration = float(body.get("duration", session.duration))
|
||||
time_listening = float(body.get("timeListening", 0))
|
||||
|
||||
session.current_time = current_time
|
||||
session.duration = duration
|
||||
session.updated_at = datetime.utcnow()
|
||||
|
||||
# Fortschritt persistieren
|
||||
progress_result = await db.execute(
|
||||
select(MediaProgress).where(
|
||||
MediaProgress.user_id == current_user.id,
|
||||
@@ -200,39 +180,28 @@ async def close_session(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(PlaybackSession).where(
|
||||
PlaybackSession.id == session_id,
|
||||
PlaybackSession.user_id == current_user.id,
|
||||
)
|
||||
select(PlaybackSession).where(PlaybackSession.id == session_id, PlaybackSession.user_id == current_user.id)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
session.is_active = False
|
||||
await db.commit()
|
||||
|
||||
# HLS-Temp-Dateien bereinigen
|
||||
cleanup_hls_session(session_id)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/hls/{session_id}/{filename}")
|
||||
@router.get("/hls/{session_id}/{filename:path}")
|
||||
async def serve_hls(
|
||||
session_id: str,
|
||||
filename: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# Session prüfen
|
||||
result = await db.execute(
|
||||
select(PlaybackSession).where(
|
||||
PlaybackSession.id == session_id,
|
||||
PlaybackSession.user_id == current_user.id,
|
||||
)
|
||||
select(PlaybackSession).where(PlaybackSession.id == session_id, PlaybackSession.user_id == current_user.id)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if not session:
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
settings = get_settings()
|
||||
@@ -242,7 +211,4 @@ async def serve_hls(
|
||||
|
||||
if filename.endswith(".m3u8"):
|
||||
return FileResponse(file_path, media_type="application/x-mpegURL")
|
||||
elif filename.endswith(".ts"):
|
||||
return FileResponse(file_path, media_type="video/MP2T")
|
||||
else:
|
||||
return FileResponse(file_path)
|
||||
return FileResponse(file_path, media_type="video/MP2T")
|
||||
|
||||
Reference in New Issue
Block a user