Files
XC_VM/tools/test-stream-generator/stream_server.py
T
Divarion_D 505f876cde fix(test-stream-generator): serve /stream.ts as a live channel, not from t=0
The /stream.ts endpoint spawned a fresh per-client ffmpeg reading the file from the start, so the panel re-opening a channel always rewound to the beginning. Each client's ffmpeg now seeks to the current live position of the loop (offset = (now - start) %% duration); -ss lands on the nearest keyframe for a fast LLOD start, and clients connecting at the same wall-clock get the same offset (in sync).
2026-08-23 13:49:48 +03:00

419 lines
16 KiB
Python

#!/usr/bin/env python3
"""
XC_VM test stream generator.
Turns a single MP4 file into looping, HTTP-served streams that can be pasted
straight into the panel as a live stream source:
/stream.ts Continuous MPEG-TS (Content-Type: video/mp2t).
This is the endpoint for testing LLOD (the panel's
Low-Latency On-Demand processor, src/Cli/Commands/LlodCommand.php,
which validates the upstream Content-Type is video/mp2t).
/stream.m3u8 Live HLS playlist + rolling .ts segments (sliding window).
Use as a normal live-stream source.
/playlist.m3u M3U channel list referencing the URLs above, for bulk import.
/ Human-readable index listing every URL.
The MP4 is looped forever and paced in real time (-re), so the panel sees a
never-ending "live" channel. Only the Python 3 standard library + ffmpeg are
required (no pip packages).
Examples:
./stream_server.py -i sample.mp4
./stream_server.py -i sample.mp4 --host 0.0.0.0 --port 8088
./stream_server.py -i sample.mp4 --encode h264 # re-encode for odd codecs
Stop with Ctrl+C.
"""
import argparse
import atexit
import os
import shutil
import signal
import subprocess
import sys
import tempfile
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# MPEG-TS packets are 188 bytes; read/forward in packet-aligned chunks.
TS_PACKET = 188
TS_CHUNK = TS_PACKET * 64 # ~12 KiB
# Sliding-window HLS so the playlist never grows unbounded.
HLS_SEGMENT_TIME = 4
HLS_LIST_SIZE = 6
CONFIG = {} # populated in main()
# --------------------------------------------------------------------------- #
# ffmpeg command builders
# --------------------------------------------------------------------------- #
def _codec_args(for_hls):
"""Return the encode/copy arguments shared by the TS and HLS pipelines."""
if CONFIG["encode"] == "h264":
args = [
"-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency",
"-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", "-ar", "48000",
]
if for_hls:
# Keyframe every segment so HLS can cut cleanly.
args += ["-g", str(HLS_SEGMENT_TIME * 25), "-force_key_frames",
"expr:gte(t,n_forced*%d)" % HLS_SEGMENT_TIME]
return args
# Default: stream copy (lightest). genpts smooths the loop boundary.
return ["-c", "copy"]
def build_ts_cmd(seek=0.0):
"""Per-client continuous MPEG-TS to stdout (pipe:1).
``seek`` (seconds) starts the client at the current LIVE position of the
looping file instead of t=0, so opening the channel does not rewind the clip
to the beginning. ``-ss`` before ``-i`` is input seeking (fast, lands on the
nearest preceding keyframe → immediate decodable frame). On loop the input
restarts from 0 and continues, so the stream never ends.
"""
pre = [CONFIG["ffmpeg"], "-hide_banner", "-loglevel", "error", "-re"]
if seek and seek > 0:
pre += ["-ss", "%.3f" % seek]
return pre + [
"-stream_loop", "-1", "-i", CONFIG["input"],
"-fflags", "+genpts",
*_codec_args(for_hls=False),
"-mpegts_flags", "+initial_discontinuity",
"-pat_period", "2",
"-f", "mpegts", "pipe:1",
]
def build_hls_cmd(hls_dir):
"""Background HLS writer: live sliding-window playlist + segments."""
return [
CONFIG["ffmpeg"], "-hide_banner", "-loglevel", "error",
"-re", "-stream_loop", "-1", "-i", CONFIG["input"],
"-fflags", "+genpts",
*_codec_args(for_hls=True),
"-f", "hls",
"-hls_time", str(HLS_SEGMENT_TIME),
"-hls_list_size", str(HLS_LIST_SIZE),
"-hls_flags", "delete_segments+omit_endlist+independent_segments",
"-hls_segment_type", "mpegts",
"-hls_segment_filename", os.path.join(hls_dir, "seg-%06d.ts"),
os.path.join(hls_dir, "stream.m3u8"),
]
# --------------------------------------------------------------------------- #
# Background HLS writer with auto-restart
# --------------------------------------------------------------------------- #
class HlsWriter:
"""Keeps a single ffmpeg process producing the live HLS output alive."""
def __init__(self, hls_dir):
self.hls_dir = hls_dir
self.proc = None
self._stop = threading.Event()
self._thread = threading.Thread(target=self._run, daemon=True)
def start(self):
self._thread.start()
def _run(self):
while not self._stop.is_set():
self.proc = subprocess.Popen(
build_hls_cmd(self.hls_dir),
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
_, err = self.proc.communicate()
if self._stop.is_set():
return
sys.stderr.write(
"[hls] ffmpeg exited (code %s), restarting in 2s\n%s\n"
% (self.proc.returncode, (err or b"").decode("utf-8", "replace").strip())
)
time.sleep(2)
def stop(self):
self._stop.set()
if self.proc and self.proc.poll() is None:
self.proc.terminate()
try:
self.proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self.proc.kill()
# --------------------------------------------------------------------------- #
# HTTP handler
# --------------------------------------------------------------------------- #
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = "XC_VM-TestStream/1.0"
# ---- helpers ---------------------------------------------------------- #
def _base_url(self):
host = self.headers.get("Host") or "%s:%d" % (CONFIG["advertise_host"], CONFIG["port"])
return "http://%s" % host
def _send_text(self, body, content_type="text/plain; charset=utf-8", status=200):
data = body.encode("utf-8") if isinstance(body, str) else body
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-cache")
self.end_headers()
if self.command != "HEAD":
self.wfile.write(data)
def _serve_hls_file(self, name):
# basename() guards against path traversal.
path = os.path.join(CONFIG["hls_dir"], os.path.basename(name))
if not os.path.isfile(path):
self._send_text("Not ready yet, retry in a moment.\n", status=404)
return
ctype = "application/vnd.apple.mpegurl" if name.endswith(".m3u8") else "video/mp2t"
try:
with open(path, "rb") as fh:
data = fh.read()
except OSError:
self._send_text("Not ready yet, retry in a moment.\n", status=404)
return
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-cache")
self.end_headers()
if self.command != "HEAD":
self.wfile.write(data)
def _serve_playlist_m3u(self):
base = self._base_url()
lines = [
"#EXTM3U",
'#EXTINF:-1 tvg-id="xcvm.test.ts" tvg-name="XC_VM Test TS (LLOD)" '
'group-title="XC_VM Test",XC_VM Test TS (LLOD)',
"%s/stream.ts" % base,
'#EXTINF:-1 tvg-id="xcvm.test.hls" tvg-name="XC_VM Test HLS" '
'group-title="XC_VM Test",XC_VM Test HLS',
"%s/stream.m3u8" % base,
"",
]
self._send_text("\n".join(lines), content_type="audio/x-mpegurl")
def _stream_ts(self):
"""Continuous MPEG-TS — the LLOD-compatible endpoint. A per-client ffmpeg
(which the panel LLOD probe/pull expects), but seeked to the CURRENT live
position of the looping file instead of t=0 — so opening the channel does
not rewind to the beginning. Clients connecting at the same wall-clock get
the same offset (in sync); a later client joins further along (live)."""
self.send_response(200)
self.send_header("Content-Type", "video/mp2t")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "close")
self.end_headers()
if self.command == "HEAD":
return
dur = CONFIG.get("duration") or 0.0
seek = ((time.time() - CONFIG["start_time"]) % dur) if dur > 0 else 0.0
proc = subprocess.Popen(
build_ts_cmd(seek), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL
)
try:
while True:
chunk = proc.stdout.read(TS_CHUNK)
if not chunk:
break
self.wfile.write(chunk)
except (BrokenPipeError, ConnectionResetError):
pass # client (panel) disconnected — expected
finally:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
def _index(self):
base = self._base_url()
html = """<!doctype html>
<html><head><meta charset="utf-8"><title>XC_VM test stream generator</title>
<style>body{{font-family:system-ui,sans-serif;max-width:760px;margin:40px auto;padding:0 16px}}
code{{background:#f3f3f3;padding:2px 6px;border-radius:4px}}
li{{margin:8px 0}}</style></head><body>
<h1>XC_VM test stream generator</h1>
<p>Source file: <code>{input}</code> &mdash; encode mode: <code>{encode}</code></p>
<h2>Stream URLs</h2>
<ul>
<li><b>MPEG-TS (for LLOD)</b>: <a href="{base}/stream.ts"><code>{base}/stream.ts</code></a><br>
Paste into the stream source field, enable <b>LLOD</b>.</li>
<li><b>HLS</b>: <a href="{base}/stream.m3u8"><code>{base}/stream.m3u8</code></a><br>
Paste into the stream source field for a normal live stream.</li>
<li><b>M3U channel list</b>: <a href="{base}/playlist.m3u"><code>{base}/playlist.m3u</code></a><br>
Import as a playlist to add both channels at once.</li>
</ul>
<p>Add the URL in the admin panel under the stream's
<code>stream_source[]</code> field (Streams &rarr; Add/Edit).</p>
</body></html>
""".format(base=base, input=CONFIG["input"], encode=CONFIG["encode"])
self._send_text(html, content_type="text/html; charset=utf-8")
# ---- routing ---------------------------------------------------------- #
def _route(self):
path = self.path.split("?", 1)[0]
if path == "/" or path == "/index.html":
self._index()
elif path == "/stream.ts":
self._stream_ts()
elif path == "/stream.m3u8":
self._serve_hls_file("stream.m3u8")
elif path == "/playlist.m3u":
self._serve_playlist_m3u()
elif path.startswith("/seg-") and path.endswith(".ts"):
self._serve_hls_file(path[1:])
else:
self._send_text("Not found\n", status=404)
def do_GET(self):
self._route()
def do_HEAD(self):
self._route()
def log_message(self, fmt, *args):
sys.stderr.write("[http] %s - %s\n" % (self.address_string(), fmt % args))
# --------------------------------------------------------------------------- #
# main
# --------------------------------------------------------------------------- #
def parse_args():
p = argparse.ArgumentParser(
description="Serve a looping MP4 as TS/HLS/M3U streams for XC_VM testing."
)
p.add_argument("-i", "--input", required=True, help="path to the source .mp4 file")
p.add_argument("--host", default="0.0.0.0", help="bind address (default 0.0.0.0)")
p.add_argument("--port", type=int, default=8088, help="bind port (default 8088)")
p.add_argument(
"--advertise-host", default=None,
help="host/IP to print in URLs (default: autodetected LAN IP)",
)
p.add_argument(
"--encode", choices=["copy", "h264"], default="copy",
help="copy = remux (fast, needs H.264/AAC mp4); h264 = re-encode (any codec)",
)
p.add_argument("--ffmpeg", default="ffmpeg", help="ffmpeg binary (default: ffmpeg in PATH)")
return p.parse_args()
def probe_duration(path, ffmpeg):
"""Source duration in seconds (0 if it can't be determined). Used to seek
/stream.ts clients to the current live position of the loop."""
cand = os.path.join(os.path.dirname(os.path.abspath(ffmpeg)), "ffprobe")
ffprobe = cand if os.path.isfile(cand) else "ffprobe"
try:
out = subprocess.check_output(
[ffprobe, "-v", "error", "-show_entries", "format=duration",
"-of", "default=nk=1:nw=1", path], stderr=subprocess.DEVNULL)
return float(out.decode("utf-8", "replace").strip() or 0)
except Exception:
return 0.0
def detect_lan_ip():
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except OSError:
return "127.0.0.1"
finally:
s.close()
class QuietThreadingHTTPServer(ThreadingHTTPServer):
"""Threaded HTTP server that ignores expected client disconnects.
The panel's ffmpeg (probe/LLOD) reads part of a stream then resets the
connection — the stdlib server would otherwise print a full traceback per
disconnect. Those are normal, so swallow them and keep the log clean.
"""
daemon_threads = True
def handle_error(self, request, client_address):
exc = sys.exc_info()[1]
if isinstance(exc, (ConnectionResetError, BrokenPipeError,
ConnectionAbortedError, TimeoutError)):
return # client (panel) disconnected mid-request — expected
super().handle_error(request, client_address)
def main():
args = parse_args()
if not os.path.isfile(args.input):
sys.exit("error: input file not found: %s" % args.input)
if shutil.which(args.ffmpeg) is None and not os.path.isfile(args.ffmpeg):
sys.exit("error: ffmpeg not found (looked for %r). Install it or pass --ffmpeg." % args.ffmpeg)
hls_dir = tempfile.mkdtemp(prefix="xcvm-teststream-")
atexit.register(lambda: shutil.rmtree(hls_dir, ignore_errors=True))
CONFIG.update({
"input": os.path.abspath(args.input),
"host": args.host,
"port": args.port,
"advertise_host": args.advertise_host or detect_lan_ip(),
"encode": args.encode,
"ffmpeg": args.ffmpeg,
"hls_dir": hls_dir,
})
# For the live-position seek on /stream.ts (start viewers "now", not at t=0).
CONFIG["duration"] = probe_duration(CONFIG["input"], CONFIG["ffmpeg"])
CONFIG["start_time"] = time.time()
writer = HlsWriter(hls_dir)
writer.start()
httpd = QuietThreadingHTTPServer((args.host, args.port), Handler)
base = "http://%s:%d" % (CONFIG["advertise_host"], args.port)
print("XC_VM test stream generator")
print(" input : %s" % CONFIG["input"])
print(" encode: %s" % CONFIG["encode"])
print(" bind : %s:%d" % (args.host, args.port))
print("")
print("Paste one of these into the panel's stream source field:")
print(" TS (LLOD): %s/stream.ts" % base)
print(" HLS : %s/stream.m3u8" % base)
print(" M3U list : %s/playlist.m3u" % base)
print(" Index : %s/" % base)
print("")
print("Ctrl+C to stop.")
def shutdown(*_):
print("\nstopping...")
writer.stop()
threading.Thread(target=httpd.shutdown, daemon=True).start()
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
try:
httpd.serve_forever()
finally:
httpd.server_close()
writer.stop()
if __name__ == "__main__":
main()