ASCII-art YouTube streaming for the Tesla in-car browser. - FastAPI server on a Mac mini, no Docker. - yt-dlp resolver: ID/URL/search. - ffmpeg with -re -fps_mode cfr for source-paced video; trivial drain consumer. Separate ffmpeg for AAC/ADTS audio. - Vendored ASCILINE renderer (MIT) for the binary wire protocol; pure fillText color path, on-demand selection flush. - HMAC PIN-gated cookie; Secure flag scheme-aware so /audio works on plain http during local dev. - LOW preset (120x50 24fps) verified clean on M4: FPS 24/24, JIT ~42ms.
274 lines
9.2 KiB
Python
274 lines
9.2 KiB
Python
"""Single-active-session state machine.
|
|
|
|
States: idle | resolving | playing | error(message). Exactly one session
|
|
can be active at a time; a new play() terminates the previous session's
|
|
pipeline before starting the new one. Pipeline teardown is dispatched
|
|
as a background task so /api/play returns quickly while the previous
|
|
ffmpeg exits.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import signal
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum, auto
|
|
from typing import Optional
|
|
|
|
from config import PRESETS, DEFAULT_PRESET, SERVER_DEFAULTS
|
|
from pipeline import AudioPipeline, PresetSpec, VideoPipeline
|
|
from resolver import ResolvedMedia, ResolverError, resolve
|
|
|
|
log = logging.getLogger("session")
|
|
|
|
|
|
class State(Enum):
|
|
IDLE = auto()
|
|
RESOLVING = auto()
|
|
PLAYING = auto()
|
|
ERROR = auto()
|
|
|
|
|
|
@dataclass
|
|
class Session:
|
|
session_id: str
|
|
query: str
|
|
preset_name: str
|
|
state: State = State.RESOLVING
|
|
error_msg: str = ""
|
|
media: Optional[ResolvedMedia] = None
|
|
pipeline: Optional[VideoPipeline] = None
|
|
audio_pipeline: Optional[AudioPipeline] = None
|
|
# Set when the session is superseded or stopped.
|
|
cancel_event: asyncio.Event = field(default_factory=asyncio.Event)
|
|
|
|
|
|
class SessionManager:
|
|
"""Single-session lifecycle manager.
|
|
|
|
All methods are called from asyncio tasks on a single event loop.
|
|
Pipeline stop() calls are dispatched as background tasks so the state
|
|
machine never blocks waiting for ffmpeg to exit.
|
|
"""
|
|
|
|
def __init__(self, max_source_height: int = SERVER_DEFAULTS["max_source_height"]):
|
|
self._max_height = max_source_height
|
|
self._current: Optional[Session] = None
|
|
self._lock = asyncio.Lock()
|
|
|
|
async def play(self, query: str, preset_name: str) -> Session:
|
|
"""Start a new session.
|
|
|
|
Kills the previous session, resolves the query, starts the
|
|
pipeline, returns a Session with state=PLAYING. Raises
|
|
ResolverError if resolution fails (state set to ERROR first).
|
|
"""
|
|
async with self._lock:
|
|
await self._kill_current()
|
|
|
|
session_id = uuid.uuid4().hex[:16]
|
|
session = Session(
|
|
session_id=session_id,
|
|
query=query,
|
|
preset_name=preset_name,
|
|
)
|
|
self._current = session
|
|
log.info("session %s: resolving %r preset=%s", session_id, query, preset_name)
|
|
|
|
# Resolve outside the lock so a hammered Play can supersede us.
|
|
try:
|
|
media = await asyncio.to_thread(resolve, query, self._max_height)
|
|
except ResolverError as exc:
|
|
async with self._lock:
|
|
if self._current is session:
|
|
session.state = State.ERROR
|
|
session.error_msg = str(exc)
|
|
raise
|
|
|
|
async with self._lock:
|
|
if self._current is not session:
|
|
log.info("session %s superseded during resolve — discarding", session_id)
|
|
return session
|
|
|
|
preset_cfg = PRESETS.get(preset_name) or PRESETS[DEFAULT_PRESET]
|
|
preset = PresetSpec(
|
|
cols=preset_cfg["cols"],
|
|
rows=preset_cfg["rows"],
|
|
fps_cap=preset_cfg["fps_cap"],
|
|
mode=preset_cfg["mode"],
|
|
)
|
|
|
|
pipeline = VideoPipeline(media.video_url, preset)
|
|
pipeline.start()
|
|
|
|
# Audio: prefer the separate audio URL; fall back to the muxed
|
|
# video URL (ffmpeg -vn extracts the audio track).
|
|
audio_src = media.audio_url if media.audio_url else media.video_url
|
|
audio_pipeline = AudioPipeline(audio_src)
|
|
audio_pipeline.start()
|
|
|
|
session.media = media
|
|
session.pipeline = pipeline
|
|
session.audio_pipeline = audio_pipeline
|
|
session.state = State.PLAYING
|
|
|
|
log.info(
|
|
"session %s: playing %r is_live=%s title=%r",
|
|
session_id, query, media.is_live, media.title,
|
|
)
|
|
|
|
return session
|
|
|
|
async def stop(self) -> None:
|
|
async with self._lock:
|
|
await self._kill_current()
|
|
|
|
def get(self, session_id: str) -> Optional[Session]:
|
|
sess = self._current
|
|
if sess is not None and sess.session_id == session_id:
|
|
return sess
|
|
return None
|
|
|
|
@property
|
|
def current(self) -> Optional[Session]:
|
|
return self._current
|
|
|
|
def status(self) -> dict:
|
|
sess = self._current
|
|
if sess is None:
|
|
return {"state": "idle", "session_id": None, "title": None}
|
|
state_str = {
|
|
State.IDLE: "idle",
|
|
State.RESOLVING: "resolving",
|
|
State.PLAYING: "playing",
|
|
State.ERROR: "error",
|
|
}.get(sess.state, "unknown")
|
|
result: dict = {"state": state_str, "session_id": sess.session_id}
|
|
if sess.state == State.ERROR:
|
|
result["error"] = sess.error_msg
|
|
if sess.media:
|
|
result["title"] = sess.media.title
|
|
result["is_live"] = sess.media.is_live
|
|
else:
|
|
result["title"] = None
|
|
return result
|
|
|
|
async def _kill_current(self) -> None:
|
|
"""Signal the current session to stop and dispatch teardown.
|
|
|
|
Must be called with self._lock held. Returns immediately; the
|
|
teardown task runs concurrently so callers are not delayed.
|
|
"""
|
|
sess = self._current
|
|
if sess is None:
|
|
return
|
|
sess.cancel_event.set()
|
|
pipeline = sess.pipeline
|
|
audio_pipeline = sess.audio_pipeline
|
|
if pipeline is not None:
|
|
sess.pipeline = None
|
|
asyncio.ensure_future(_stop_pipeline(pipeline, sess.session_id))
|
|
if audio_pipeline is not None:
|
|
sess.audio_pipeline = None
|
|
asyncio.ensure_future(_stop_audio_pipeline(audio_pipeline, sess.session_id))
|
|
self._current = None
|
|
|
|
def stop_sync(self) -> None:
|
|
"""Synchronous teardown for atexit / signal handlers.
|
|
|
|
Sends SIGTERM to both pipelines first, then waits on each, so the
|
|
worst-case time is max(video_wait, audio_wait), not their sum.
|
|
"""
|
|
sess = self._current
|
|
if sess is None:
|
|
return
|
|
sess.cancel_event.set()
|
|
pipeline = sess.pipeline
|
|
audio_pipeline = sess.audio_pipeline
|
|
sess.pipeline = None
|
|
sess.audio_pipeline = None
|
|
|
|
if pipeline is not None:
|
|
log.info("atexit: terminating session %s video pipeline", sess.session_id)
|
|
pipeline._stop.set()
|
|
proc = pipeline._proc
|
|
if proc and proc.poll() is None:
|
|
try:
|
|
proc.terminate()
|
|
except Exception:
|
|
pass
|
|
if audio_pipeline is not None:
|
|
log.info("atexit: terminating session %s audio pipeline", sess.session_id)
|
|
audio_pipeline._stop.set()
|
|
aproc = audio_pipeline._proc
|
|
if aproc and aproc.poll() is None:
|
|
try:
|
|
aproc.terminate()
|
|
except Exception:
|
|
pass
|
|
|
|
import subprocess as _sp
|
|
if pipeline is not None and pipeline._proc:
|
|
try:
|
|
pipeline._proc.wait(timeout=3)
|
|
except _sp.TimeoutExpired:
|
|
try:
|
|
pipeline._proc.kill()
|
|
pipeline._proc.wait(timeout=2)
|
|
except Exception:
|
|
pass
|
|
if audio_pipeline is not None and audio_pipeline._proc:
|
|
try:
|
|
audio_pipeline._proc.wait(timeout=3)
|
|
except _sp.TimeoutExpired:
|
|
try:
|
|
audio_pipeline._proc.kill()
|
|
audio_pipeline._proc.wait(timeout=2)
|
|
except Exception:
|
|
pass
|
|
log.info("atexit: both pipelines stopped")
|
|
|
|
|
|
async def _stop_pipeline(pipeline: VideoPipeline, session_id: str) -> None:
|
|
log.info("session %s: tearing down pipeline in background", session_id)
|
|
await asyncio.to_thread(pipeline.stop)
|
|
log.info("session %s: pipeline torn down", session_id)
|
|
|
|
|
|
async def _stop_audio_pipeline(pipeline: AudioPipeline, session_id: str) -> None:
|
|
log.info("session %s: tearing down audio pipeline in background", session_id)
|
|
await asyncio.to_thread(pipeline.stop)
|
|
log.info("session %s: audio pipeline torn down", session_id)
|
|
|
|
|
|
# Module-level singleton: server.py imports it; signal handlers register here.
|
|
_manager: Optional[SessionManager] = None
|
|
|
|
|
|
def init_manager(max_source_height: int = SERVER_DEFAULTS["max_source_height"]) -> SessionManager:
|
|
global _manager
|
|
_manager = SessionManager(max_source_height=max_source_height)
|
|
return _manager
|
|
|
|
|
|
def get_manager() -> SessionManager:
|
|
if _manager is None:
|
|
raise RuntimeError("SessionManager not initialised — call init_manager() first")
|
|
return _manager
|
|
|
|
|
|
def _atexit_cleanup() -> None:
|
|
if _manager is not None:
|
|
_manager.stop_sync()
|
|
|
|
|
|
def _signal_handler(signum: int, frame) -> None:
|
|
import os
|
|
log.info("received signal %d — stopping pipeline before exit", signum)
|
|
if _manager is not None:
|
|
_manager.stop_sync()
|
|
signal.signal(signum, signal.SIG_DFL)
|
|
os.kill(os.getpid(), signum)
|