Initial commit: Phase 1 – Projektstruktur, DB-Schema, Core-API
- 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>
This commit is contained in:
108
backend/app/services/hls.py
Normal file
108
backend/app/services/hls.py
Normal file
@@ -0,0 +1,108 @@
|
||||
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
|
||||
Reference in New Issue
Block a user