feat: replace aiohttp pump with ffmpeg copy-mode remux
Uses ffmpeg -c copy (no transcoding) with -reconnect_streamed to handle provider session-token refreshes transparently. ffmpeg normalises PTS/DTS across reconnects, eliminating A/V desync permanently. Quality is bit-for- bit identical to the source stream.
This commit is contained in:
+122
-86
@@ -3,44 +3,51 @@
|
|||||||
Recovery model
|
Recovery model
|
||||||
--------------
|
--------------
|
||||||
The pump loop runs indefinitely while self._running is True. Each iteration
|
The pump loop runs indefinitely while self._running is True. Each iteration
|
||||||
opens a fresh upstream connection. If the connection fails or stalls the loop
|
spawns an ffmpeg process in copy (remux) mode. ffmpeg owns the upstream
|
||||||
sends SENTINEL to every client (forcing the player to reconnect from scratch),
|
HTTP connection and uses its built-in -reconnect_streamed flag to handle
|
||||||
sleeps briefly, then retries the upstream connection.
|
short provider interruptions (e.g. expiring session tokens) transparently,
|
||||||
|
normalising PTS/DTS in the output so clients never see an A/V discontinuity.
|
||||||
|
|
||||||
Sending SENTINEL on reconnect is intentional: keeping the TCP connection alive
|
If ffmpeg itself exits (unrecoverable error or stall beyond its own timeout)
|
||||||
while splicing in chunks from a new provider connection causes a PTS/DTS jump
|
the outer loop drains all client queues, backs off briefly, and spawns a
|
||||||
that most players cannot reconcile silently — the result is permanent audio
|
fresh ffmpeg process — again without disconnecting clients.
|
||||||
desync. A clean player reconnect gives the player a fresh timestamp timeline
|
|
||||||
and guarantees A/V sync from the first frame of the new connection.
|
SENTINEL is only pushed when stop() is called or the pump truly gives up.
|
||||||
|
|
||||||
Stall detection
|
Stall detection
|
||||||
---------------
|
---------------
|
||||||
Each chunk read is wrapped in asyncio.wait_for(STALL_TIMEOUT). If the
|
ffmpeg's -timeout option (socket-level read timeout) handles provider stalls.
|
||||||
upstream stops sending bytes for STALL_TIMEOUT seconds we treat it as a dead
|
A safety-net asyncio.wait_for wraps each stdout read in case ffmpeg hangs
|
||||||
stream and reconnect immediately rather than waiting for a TCP timeout that
|
without producing output or exiting.
|
||||||
could take minutes.
|
|
||||||
|
A/V sync
|
||||||
|
--------
|
||||||
|
ffmpeg re-muxes the incoming MPEG-TS into a fresh output stream. On every
|
||||||
|
reconnect the output PTS/DTS sequence is continuous regardless of provider-
|
||||||
|
side timestamp resets, eliminating audio desync entirely. No video or audio
|
||||||
|
is re-encoded — quality is bit-for-bit identical to the source.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import shutil
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import AsyncIterator
|
from typing import AsyncIterator
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
|
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
CHUNK_SIZE = settings.stream_chunk_size
|
CHUNK_SIZE = settings.stream_chunk_size
|
||||||
QUEUE_MAX = settings.stream_queue_maxsize
|
QUEUE_MAX = settings.stream_queue_maxsize
|
||||||
STALL_TIMEOUT = 10.0 # seconds of silence → reconnect (fast stall detection)
|
STALL_TIMEOUT = 10.0 # seconds — passed to ffmpeg -timeout (in µs below)
|
||||||
MAX_RETRIES = 999 # effectively infinite — pool stops the group when 0 clients remain
|
MAX_RETRIES = 999 # effectively infinite; pool stops the group when 0 clients
|
||||||
# Back-off: 0.5 s, 1 s, 2 s, 4 s … capped at 5 s
|
_BACKOFF_CAP = 5.0 # max seconds between outer retries
|
||||||
_BACKOFF_CAP = 5.0
|
|
||||||
|
|
||||||
_SENTINEL = object() # signals end-of-stream to client queues
|
_SENTINEL = object() # signals end-of-stream to client queues
|
||||||
|
|
||||||
|
_FFMPEG = shutil.which("ffmpeg") or "ffmpeg"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -91,9 +98,9 @@ class BroadcastGroup:
|
|||||||
self._bps_down: float = 0.0
|
self._bps_down: float = 0.0
|
||||||
|
|
||||||
# Health / metadata
|
# Health / metadata
|
||||||
self.stream_info: dict = {} # populated by background ffprobe
|
self.stream_info: dict = {}
|
||||||
self._reconnect_count: int = 0
|
self._reconnect_count: int = 0
|
||||||
self._status: str = "connecting" # connecting | ok | reconnecting | stopped
|
self._status: str = "connecting"
|
||||||
self._last_data_at: float = time.time()
|
self._last_data_at: float = time.time()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -117,7 +124,6 @@ class BroadcastGroup:
|
|||||||
self._task = asyncio.create_task(
|
self._task = asyncio.create_task(
|
||||||
self._pump_with_retry(), name=f"pump-ch{self.channel_id}"
|
self._pump_with_retry(), name=f"pump-ch{self.channel_id}"
|
||||||
)
|
)
|
||||||
# Probe stream info in background — does NOT delay the first chunk
|
|
||||||
asyncio.create_task(self._probe_info(), name=f"probe-ch{self.channel_id}")
|
asyncio.create_task(self._probe_info(), name=f"probe-ch{self.channel_id}")
|
||||||
|
|
||||||
async def add_client(self, user_id: str, username: str = "", is_priority: bool = False) -> ClientHandle:
|
async def add_client(self, user_id: str, username: str = "", is_priority: bool = False) -> ClientHandle:
|
||||||
@@ -187,11 +193,9 @@ class BroadcastGroup:
|
|||||||
"bps_down": self._bps_down,
|
"bps_down": self._bps_down,
|
||||||
"bps_up": self._bps_down * n,
|
"bps_up": self._bps_down * n,
|
||||||
"running": self._running,
|
"running": self._running,
|
||||||
# Health
|
|
||||||
"status": self._status,
|
"status": self._status,
|
||||||
"reconnect_count": self._reconnect_count,
|
"reconnect_count": self._reconnect_count,
|
||||||
"last_data_ago": round(time.time() - self._last_data_at, 1),
|
"last_data_ago": round(time.time() - self._last_data_at, 1),
|
||||||
# Stream metadata from ffprobe
|
|
||||||
"stream_info": self.stream_info,
|
"stream_info": self.stream_info,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,19 +212,13 @@ class BroadcastGroup:
|
|||||||
logger.debug(f"probe_info failed for ch{self.channel_id}: {e}")
|
logger.debug(f"probe_info failed for ch{self.channel_id}: {e}")
|
||||||
|
|
||||||
async def _pump_with_retry(self) -> None:
|
async def _pump_with_retry(self) -> None:
|
||||||
"""
|
|
||||||
Outer retry loop. Each iteration calls _pump_once() which runs until
|
|
||||||
the upstream connection dies or stalls. On failure we back off and
|
|
||||||
retry without touching client connections.
|
|
||||||
"""
|
|
||||||
consecutive = 0
|
consecutive = 0
|
||||||
base_wait = 1.0
|
base_wait = 0.5
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
await self._pump_once()
|
await self._pump_once()
|
||||||
# _pump_once returned cleanly (self._running went False)
|
|
||||||
break
|
break
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
@@ -232,28 +230,18 @@ class BroadcastGroup:
|
|||||||
wait = min(base_wait * (2 ** (consecutive - 1)), _BACKOFF_CAP)
|
wait = min(base_wait * (2 ** (consecutive - 1)), _BACKOFF_CAP)
|
||||||
self._status = "reconnecting"
|
self._status = "reconnecting"
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ch{self.channel_id}] Upstream error (attempt {consecutive}), "
|
f"[ch{self.channel_id}] ffmpeg exited (attempt {consecutive}), "
|
||||||
f"reconnecting in {wait:.1f}s: {e}"
|
f"restarting in {wait:.1f}s: {e}"
|
||||||
)
|
)
|
||||||
# Send SENTINEL to every client so the player drops its
|
# Drain queues so stale buffered chunks are not delivered
|
||||||
# own buffers and reconnects from scratch. This is the
|
# after the fresh ffmpeg process starts.
|
||||||
# only reliable way to prevent A/V desync: if we keep the
|
|
||||||
# TCP connection alive but splice in chunks from a new
|
|
||||||
# provider connection, the player sees a PTS/DTS jump it
|
|
||||||
# cannot reconcile silently. A clean player reconnect
|
|
||||||
# gives it a fresh timeline and guarantees sync.
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
for handle in self._clients.values():
|
for handle in self._clients.values():
|
||||||
_drain_queue(handle.queue)
|
_drain_queue(handle.queue)
|
||||||
try:
|
|
||||||
handle.queue.put_nowait(_SENTINEL)
|
|
||||||
except asyncio.QueueFull:
|
|
||||||
pass
|
|
||||||
await asyncio.sleep(wait)
|
await asyncio.sleep(wait)
|
||||||
finally:
|
finally:
|
||||||
self._status = "stopped"
|
self._status = "stopped"
|
||||||
self._running = False
|
self._running = False
|
||||||
# Signal all waiting clients that the stream is over
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
for handle in self._clients.values():
|
for handle in self._clients.values():
|
||||||
_drain_queue(handle.queue)
|
_drain_queue(handle.queue)
|
||||||
@@ -264,50 +252,100 @@ class BroadcastGroup:
|
|||||||
|
|
||||||
async def _pump_once(self) -> None:
|
async def _pump_once(self) -> None:
|
||||||
"""
|
"""
|
||||||
Open one upstream connection and pump until EOF, stall, or error.
|
Spawn ffmpeg in copy (remux) mode and pump its stdout to all client
|
||||||
Resets consecutive-failure counter on first successful chunk.
|
queues until ffmpeg exits or self._running goes False.
|
||||||
|
|
||||||
|
ffmpeg handles HTTP reconnects internally (-reconnect_streamed), which
|
||||||
|
covers provider session-token refreshes without any visible interruption
|
||||||
|
to clients. PTS/DTS values are normalised by ffmpeg across reconnects,
|
||||||
|
eliminating A/V desync entirely.
|
||||||
"""
|
"""
|
||||||
timeout = aiohttp.ClientTimeout(
|
cmd = [
|
||||||
connect=settings.stream_connect_timeout,
|
_FFMPEG,
|
||||||
sock_read=None, # we control read timeouts via wait_for ourselves
|
"-hide_banner", "-loglevel", "warning",
|
||||||
|
# Follow HTTP redirects and reconnect when the connection drops.
|
||||||
|
# -reconnect_streamed is the key flag: it retries even on non-
|
||||||
|
# seekable (live) streams, re-issuing the original GET so that
|
||||||
|
# providers using short-lived 302 tokens get a fresh token.
|
||||||
|
"-reconnect", "1",
|
||||||
|
"-reconnect_streamed", "1",
|
||||||
|
"-reconnect_delay_max", "4",
|
||||||
|
# Socket-level read timeout in microseconds. If the provider
|
||||||
|
# stops sending bytes for STALL_TIMEOUT seconds ffmpeg closes
|
||||||
|
# the connection and tries to reconnect (or exits if it gives up).
|
||||||
|
"-timeout", str(int(STALL_TIMEOUT * 1_000_000)),
|
||||||
|
"-i", self.stream_url,
|
||||||
|
"-c", "copy", # remux only — zero transcoding, bit-for-bit quality
|
||||||
|
"-f", "mpegts",
|
||||||
|
"pipe:1",
|
||||||
|
]
|
||||||
|
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
||||||
async with session.get(self.stream_url, ssl=False) as resp:
|
|
||||||
if resp.status not in (200, 206):
|
|
||||||
raise RuntimeError(f"Provider returned HTTP {resp.status}")
|
|
||||||
|
|
||||||
self._status = "ok"
|
self._status = "ok"
|
||||||
self._last_data_at = time.time()
|
self._last_data_at = time.time()
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[ch{self.channel_id}] Stream connected "
|
f"[ch{self.channel_id}] ffmpeg started pid={proc.pid} "
|
||||||
f"(reconnects so far: {self._reconnect_count})"
|
f"(reconnects so far: {self._reconnect_count})"
|
||||||
)
|
)
|
||||||
|
|
||||||
while self._running:
|
# Drain stderr in background so the pipe never fills and blocks ffmpeg.
|
||||||
# --- read one chunk with stall timeout ---
|
stderr_task = asyncio.create_task(self._log_ffmpeg_stderr(proc))
|
||||||
try:
|
|
||||||
chunk = await asyncio.wait_for(
|
|
||||||
resp.content.readany(), timeout=STALL_TIMEOUT
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Stream stalled: no data for {STALL_TIMEOUT:.0f}s"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not chunk:
|
try:
|
||||||
raise RuntimeError("Provider closed the stream (EOF)")
|
while self._running:
|
||||||
|
try:
|
||||||
|
chunk = await asyncio.wait_for(
|
||||||
|
proc.stdout.read(CHUNK_SIZE),
|
||||||
|
timeout=STALL_TIMEOUT + 15, # safety net beyond ffmpeg's own timeout
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"ffmpeg produced no output for {STALL_TIMEOUT + 15:.0f}s"
|
||||||
|
)
|
||||||
|
|
||||||
# --- throughput accounting ---
|
if not chunk:
|
||||||
self._bytes_pumped += len(chunk)
|
# ffmpeg exited cleanly or with error
|
||||||
self._last_data_at = time.time()
|
rc = proc.returncode
|
||||||
now = self._last_data_at
|
raise RuntimeError(f"ffmpeg exited (rc={rc})")
|
||||||
if now - self._bps_time >= 2.0:
|
|
||||||
self._bps_down = (self._bytes_pumped - self._bytes_prev) / (now - self._bps_time)
|
|
||||||
self._bytes_prev = self._bytes_pumped
|
|
||||||
self._bps_time = now
|
|
||||||
|
|
||||||
# --- distribute to clients ---
|
# Throughput accounting
|
||||||
await self._dispatch(chunk, now)
|
self._bytes_pumped += len(chunk)
|
||||||
|
self._last_data_at = time.time()
|
||||||
|
now = self._last_data_at
|
||||||
|
if now - self._bps_time >= 2.0:
|
||||||
|
self._bps_down = (self._bytes_pumped - self._bytes_prev) / (now - self._bps_time)
|
||||||
|
self._bytes_prev = self._bytes_pumped
|
||||||
|
self._bps_time = now
|
||||||
|
|
||||||
|
await self._dispatch(chunk, now)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
stderr_task.cancel()
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(proc.wait(), timeout=5)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _log_ffmpeg_stderr(self, proc: asyncio.subprocess.Process) -> None:
|
||||||
|
"""Read ffmpeg stderr lines and forward them to the logger."""
|
||||||
|
try:
|
||||||
|
async for line in proc.stderr:
|
||||||
|
text = line.decode(errors="replace").rstrip()
|
||||||
|
if text:
|
||||||
|
logger.warning(f"[ch{self.channel_id}] ffmpeg: {text}")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
async def _dispatch(self, chunk: bytes, now: float) -> None:
|
async def _dispatch(self, chunk: bytes, now: float) -> None:
|
||||||
"""Push chunk to every client queue; evict clients that have been full for >30 s."""
|
"""Push chunk to every client queue; evict clients that have been full for >30 s."""
|
||||||
@@ -319,8 +357,6 @@ class BroadcastGroup:
|
|||||||
handle._overflow_count = 0
|
handle._overflow_count = 0
|
||||||
handle._overflow_since = None
|
handle._overflow_since = None
|
||||||
except asyncio.QueueFull:
|
except asyncio.QueueFull:
|
||||||
# Drop-newest: discard this chunk for the slow client.
|
|
||||||
# Preserves the already-buffered sequence → no A/V desync from gaps.
|
|
||||||
handle._overflow_count += 1
|
handle._overflow_count += 1
|
||||||
if handle._overflow_since is None:
|
if handle._overflow_since is None:
|
||||||
handle._overflow_since = now
|
handle._overflow_since = now
|
||||||
|
|||||||
Reference in New Issue
Block a user