Dispatcharr/apps/proxy/live_proxy/input/http_streamer.py
Cédric Marcoux 3b8717c4ce fix: timeshift stream management, channel resolution, and streaming throughput
- Channel resolution: resolve by Channel.id first (what the XC API emits
  to clients), fall back to provider stream_id for backward compatibility.
  Fixes 404 on all timeshift requests from XC clients.

- Stream management: timeshift is now a first-class citizen alongside live
  and VOD in the stream-limits system.  get_user_active_connections()
  detects type='timeshift', attempt_stream_termination() sets a Redis
  stop key (same pattern as VOD), and the timeshift view calls
  check_user_stream_limits() before connecting upstream.  The stream
  generator checks the stop key every 100 chunks.  Fixes infinite retry
  loops on max_connections=1 providers for live→timeshift and
  timeshift→timeshift transitions.

- Streaming: replaced HTTPStreamReader thread+pipe with direct
  iter_content+yield (same pattern as VOD proxy).  The pipe approach
  deadlocked under gevent because the reader thread's select() competed
  with the greenlet's pipe read for hub scheduling.  Throughput went
  from ~74 B/s to 13+ MB/s.

- TS preamble: peek now strips pre-sync bytes (PHP warnings, BOM) before
  prepending to the stream, preventing corrupt TS output.

- Credential safety: _redact_url() now truncates to scheme://host/...
  to avoid leaking XC path-based credentials in logs.

- Tests: updated 5 tests that referenced the removed HTTPStreamReader.
  21/21 timeshift tests pass.

- Migration: fixed SyntaxWarning for unescaped regex in 0038.
2026-05-27 23:32:57 +02:00

300 lines
11 KiB
Python

"""
HTTP Stream Reader - Thread-based HTTP stream reader that writes to a pipe.
This allows us to use the same fetch_chunk() path for both transcode and HTTP
streams (live TV) and for timeshift/catch-up archives.
When *response* is supplied the reader skips connection setup and streams the
already-open ``requests.Response`` directly. This is used by the timeshift
view which needs to cascade through multiple candidate URLs before handing
the winning response to the reader.
When *strip_ts_preamble* is True the first bytes written to the pipe are
aligned to the first MPEG-TS sync chain (0x47 at 188-byte intervals). Some
XC servers emit PHP warnings or HTML error text before the binary stream;
strict demuxers (ExoPlayer / TiviMate) raise ``UnrecognizedInputFormat`` on
those bytes. The live path typically receives clean TS so the flag defaults
to False, but the timeshift path enables it.
"""
import fcntl
import os
import select as _select
import threading
import requests
from requests.adapters import HTTPAdapter
from ..utils import get_logger
logger = get_logger()
# MPEG-TS sync constants (shared with timeshift views)
_TS_PACKET_SIZE = 188
_TS_SYNC_BYTE = 0x47
_TS_SYNC_SEARCH_LIMIT = 65536
# Linux fcntl constant — increase pipe buffer beyond the 64 KB default.
_F_SETPIPE_SZ = 1031
_PIPE_TARGET_SIZE = 1 << 20 # 1 MB
def find_ts_sync(buf):
"""Offset of the first MPEG-TS sync chain in *buf*, or -1.
A valid chain needs 0x47 at offsets i, i+188 and i+376 — three sync
bytes one packet apart (the standard demuxer probe).
"""
end = len(buf) - 2 * _TS_PACKET_SIZE
for i in range(0, end):
if (
buf[i] == _TS_SYNC_BYTE
and buf[i + _TS_PACKET_SIZE] == _TS_SYNC_BYTE
and buf[i + 2 * _TS_PACKET_SIZE] == _TS_SYNC_BYTE
):
return i
return -1
class HTTPStreamReader:
"""Thread-based HTTP stream reader that writes to an OS pipe.
Parameters
----------
url : str
Provider URL (used when *response* is None to open a new connection).
user_agent : str, optional
User-Agent header for the upstream request.
chunk_size : int
Chunk size for ``iter_content`` and ``pipe_reader.read``.
response : requests.Response, optional
An already-open streaming response. When provided the reader skips
connection setup and streams this response directly. The caller is
responsible for having opened it with ``stream=True``.
extra_headers : dict, optional
Additional HTTP headers (e.g. Range) forwarded to the provider when
*response* is None.
strip_ts_preamble : bool
Strip non-MPEG-TS bytes before the first 0x47 sync chain.
"""
def __init__(
self,
url,
user_agent=None,
chunk_size=8192,
*,
response=None,
extra_headers=None,
strip_ts_preamble=False,
):
self.url = url
self.user_agent = user_agent
self.chunk_size = chunk_size
self._provided_response = response
self._extra_headers = extra_headers or {}
self._strip_ts_preamble = strip_ts_preamble
self.session = None
self.response = response # may be pre-set
self.thread = None
self.pipe_read = None
self.pipe_write = None
self.running = False
def start(self):
"""Start the HTTP stream reader thread."""
self.pipe_read, self.pipe_write = os.pipe()
# Grow the kernel pipe buffer from 64 KB to 1 MB so the producer
# thread is not blocked while the consumer yields to uWSGI. This
# is the single biggest throughput improvement for the timeshift
# path: it turns the producer/consumer from ping-pong alternation
# into genuinely parallel operation.
try:
fcntl.fcntl(self.pipe_write, _F_SETPIPE_SZ, _PIPE_TARGET_SIZE)
except (OSError, ValueError):
pass # non-Linux or unprivileged — keep 64 KB default
# Make the write end non-blocking so that os.write() raises
# BlockingIOError instead of stalling the OS thread when the pipe
# buffer is full. Without this, a full pipe blocks the entire gevent
# worker (all greenlets freeze) because gevent does not patch
# os.write() on pipes.
# Skip for pre-opened responses (timeshift path) — those run in a
# real OS thread where blocking writes are fine and the 1 MB pipe
# buffer provides sufficient decoupling.
if self._provided_response is None:
flags = fcntl.fcntl(self.pipe_write, fcntl.F_GETFL)
fcntl.fcntl(self.pipe_write, fcntl.F_SETFL, flags | os.O_NONBLOCK)
self.running = True
self.thread = threading.Thread(target=self._read_stream, daemon=True)
self.thread.start()
logger.info("Started HTTP stream reader thread for %s", self.url)
return self.pipe_read
# ------------------------------------------------------------------
# Non-blocking pipe write helper
# ------------------------------------------------------------------
def _pipe_write_all(self, data):
"""Write *data* to the pipe, handling O_NONBLOCK + partial writes.
Uses select() on the write fd (gevent-patched — yields to hub) to
wait for space when the pipe is full, instead of blocking the OS
thread.
"""
offset = 0
while offset < len(data) and self.running:
try:
n = os.write(self.pipe_write, data[offset:])
offset += n
except BlockingIOError:
_, writable, _ = _select.select([], [self.pipe_write], [], 1.0)
if not writable and not self.running:
return False
except (BrokenPipeError, OSError):
return False
return True
# ------------------------------------------------------------------
# Producer thread
# ------------------------------------------------------------------
def _read_stream(self):
"""Thread worker: read HTTP stream → write to pipe."""
try:
if self._provided_response is not None:
# Re-use the already-open response (timeshift cascade path).
self.response = self._provided_response
else:
# Open a fresh connection (live TV path).
headers = dict(self._extra_headers)
if self.user_agent:
headers["User-Agent"] = self.user_agent
logger.info("HTTP reader connecting to %s", self.url)
self.session = requests.Session()
adapter = HTTPAdapter(
max_retries=0, pool_connections=1, pool_maxsize=1,
)
self.session.mount("http://", adapter)
self.session.mount("https://", adapter)
self.response = self.session.get(
self.url,
headers=headers,
stream=True,
timeout=(5, 30),
)
if self.response.status_code not in (200, 206):
logger.error(
"HTTP %d from %s", self.response.status_code, self.url,
)
return
logger.info("HTTP reader connected, streaming data…")
# Optional TS preamble stripping — find the first MPEG-TS sync
# chain and discard everything before it.
synced = not self._strip_ts_preamble
sync_buf = bytearray() if not synced else None
# If the caller peeked at the response body (e.g. to validate TS
# sync before accepting the response), prepend those bytes so they
# aren't lost. They've already been consumed from the raw socket.
peek_data = getattr(self.response, "_peek_data", None)
chunk_count = 0
chunks_iter = self.response.iter_content(chunk_size=self.chunk_size)
if peek_data:
import itertools
chunks_iter = itertools.chain([peek_data], chunks_iter)
for chunk in chunks_iter:
if not self.running:
break
if not chunk:
continue
# ---- TS preamble strip logic ----
if not synced:
sync_buf.extend(chunk)
if len(sync_buf) < 2 * _TS_PACKET_SIZE + 1:
continue
offset = find_ts_sync(sync_buf)
if offset >= 0:
chunk = bytes(sync_buf[offset:])
synced = True
sync_buf = None
elif len(sync_buf) >= _TS_SYNC_SEARCH_LIMIT:
logger.warning(
"Upstream produced %d bytes without TS sync "
"chain; passing through unmodified",
len(sync_buf),
)
chunk = bytes(sync_buf)
synced = True
sync_buf = None
else:
continue
# ---- Non-blocking write to pipe ----
if not self._pipe_write_all(chunk):
break
chunk_count += 1
if chunk_count % 1000 == 0:
logger.debug("HTTP reader streamed %d chunks", chunk_count)
# Flush any remaining sync_buf that never found a chain.
if not synced and sync_buf:
self._pipe_write_all(bytes(sync_buf))
logger.info("HTTP stream ended")
except requests.exceptions.RequestException as exc:
logger.error("HTTP reader request error: %s", exc)
except Exception as exc:
logger.error("HTTP reader unexpected error: %s", exc, exc_info=True)
finally:
self.running = False
try:
if self.pipe_write is not None:
os.close(self.pipe_write)
self.pipe_write = None
except OSError:
pass
# ------------------------------------------------------------------
# Shutdown
# ------------------------------------------------------------------
def stop(self):
"""Stop the HTTP stream reader."""
logger.info("Stopping HTTP stream reader")
self.running = False
if self.response:
try:
self.response.close()
except Exception:
pass
if self.session:
try:
self.session.close()
except Exception:
pass
if self.pipe_write is not None:
try:
os.close(self.pipe_write)
self.pipe_write = None
except OSError:
pass
if self.thread and self.thread.is_alive():
self.thread.join(timeout=2.0)