- FastAPI-Backend mit vollständiger ABS v2.x API-Kompatibilität - SQLAlchemy-Models: User, Library, LibraryItem, BookFile, Chapter, Podcast, PodcastEpisode, MediaProgress, Bookmark, PlaybackSession - Auth: JWT-Login (/login, /logout, /api/authorize) - Library + Items Endpoints inkl. camelCase ABS-Response-Format - HLS-Streaming via FFmpeg (POST /api/items/:id/play, Session-Sync) - Me/Progress Endpoints + Lesezeichen - User-Management + Server-Settings (Admin) - Library-Scanner (MP3/WAV Discovery, Hintergrund-Task) - File Watcher (watchdog, 30s Debounce) - Matching-Skelett (MusicBrainz, OpenLibrary, Google Books – Phase 5) - Docker-Setup: backend (Python 3.12+FFmpeg), frontend (React/Vite), nginx Reverse-Proxy auf Port 3000 - setup.sh: Installiert Docker auf Debian/Ubuntu, richtet .env ein Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
109 lines
3.3 KiB
Python
109 lines
3.3 KiB
Python
import os
|
|
import asyncio
|
|
import uuid
|
|
import shutil
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
from ..config import get_settings
|
|
|
|
|
|
HLS_SEGMENT_DURATION = 10 # Sekunden pro Segment
|
|
|
|
|
|
async def create_hls_session(
|
|
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()
|
|
session_dir = os.path.join(settings.hls_cache_dir, session_id)
|
|
os.makedirs(session_dir, exist_ok=True)
|
|
|
|
playlist_path = os.path.join(session_dir, "output.m3u8")
|
|
|
|
if len(audio_files) == 1:
|
|
input_path = audio_files[0]
|
|
else:
|
|
# Mehrere Dateien: Concat-Liste erstellen
|
|
concat_file = os.path.join(session_dir, "concat.txt")
|
|
with open(concat_file, "w", encoding="utf-8") as f:
|
|
for af in audio_files:
|
|
safe_path = af.replace("\\", "/")
|
|
f.write(f"file '{safe_path}'\n")
|
|
input_path = concat_file
|
|
|
|
if len(audio_files) == 1:
|
|
cmd = [
|
|
"ffmpeg", "-y",
|
|
"-ss", str(start_time),
|
|
"-i", input_path,
|
|
"-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_list_size", "0",
|
|
"-hls_segment_filename", os.path.join(session_dir, "seg%05d.ts"),
|
|
"-hls_flags", "independent_segments",
|
|
playlist_path,
|
|
]
|
|
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.DEVNULL,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
_, stderr = await proc.communicate()
|
|
|
|
if proc.returncode != 0:
|
|
error_msg = stderr.decode(errors="replace") if stderr else "unknown error"
|
|
raise RuntimeError(f"FFmpeg fehler: {error_msg}")
|
|
|
|
return session_dir
|
|
|
|
|
|
def cleanup_hls_session(session_id: str):
|
|
settings = get_settings()
|
|
session_dir = os.path.join(settings.hls_cache_dir, session_id)
|
|
if os.path.exists(session_dir):
|
|
shutil.rmtree(session_dir, ignore_errors=True)
|
|
|
|
|
|
def get_hls_session_path(session_id: str) -> Optional[str]:
|
|
settings = get_settings()
|
|
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.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
|