Files
XC_VM/tools/stream_queue_check.py
T
Divarion_D 6d6aa6652e feat(tools): add stream_queue_check.py queue-integrity monitor
Standalone Python (stdlib-only) tool that verifies a stream delivers
segments correctly and its delivery queue does not break:

- HLS: EXT-X-MEDIA-SEQUENCE contiguity, no dropped/rewound segments, no
  EXT-X-DISCONTINUITY, every newly appearing segment downloadable.
- MPEG-TS: per-PID continuity_counter, sync-byte loss, TEI, delivery stalls,
  with a --tolerance for rare source glitches relayed by -c copy.
- --live: colored TUI dashboard modelling a virtual player — received
  timeline from PCR (TS) / EXTINF (HLS), playhead, and buffered cache
  seconds graphed over time.

Documented in docs/{en,ru}/development/streaming-subsystem.md.
2026-08-06 21:22:33 +03:00

843 lines
32 KiB
Python

#!/usr/bin/env python3
"""
stream_queue_check.py — verify that a live stream delivers its segments
correctly and that the "queue" does not break.
Two independent queues are checked, depending on the stream type:
* HLS (.m3u8): the media-playlist segment queue. EXT-X-MEDIA-SEQUENCE must
advance monotonically and contiguously (no rewind, no skipped segments that
slid off before we read them), no EXT-X-DISCONTINUITY, and every newly
appearing segment must be downloadable.
* MPEG-TS (.ts / XC_VM /play/<token>/ts): the transport packet queue. Each
PID carries a 4-bit continuity_counter that must increment by one per
payload packet; a break means lost / duplicated / reordered packets. Sync
loss and delivery stalls are reported too.
The type is auto-detected. Exit code: 0 = healthy, 2 = queue problem, 1 = usage.
Usage:
stream_queue_check.py <url> [--duration SEC] [--json] [--ua UA]
Examples:
stream_queue_check.py "http://host/live/stream.m3u8" --duration 60
stream_queue_check.py "http://45.90.13.217/play/<token>/ts" --json
@package XC_VM_Tools
@license AGPL-3.0
"""
import sys
import time
import json
import argparse
import threading
import urllib.request
import urllib.error
from urllib.parse import urljoin
DEFAULT_UA = "Mozilla/5.0 (X11; Linux x86_64) stream_queue_check/1.0"
# ─────────────────────────────── HTTP ────────────────────────────────
def http_open(url, ua, timeout=15, headers=None):
"""Open a URL (redirects followed by urllib), returning the response."""
h = {"User-Agent": ua}
if headers:
h.update(headers)
req = urllib.request.Request(url, headers=h)
return urllib.request.urlopen(req, timeout=timeout)
def http_get(url, ua, timeout=15):
"""Fetch a whole body. Returns (status, final_url, bytes) or raises."""
resp = http_open(url, ua, timeout)
body = resp.read()
return resp.status, resp.geturl(), body
# ───────────────────────────── detection ─────────────────────────────
def detect_kind(url, ua):
"""Peek at the stream to decide HLS vs TS. Returns (kind, final_url, head)."""
resp = http_open(url, ua, timeout=15)
final = resp.geturl()
head = resp.read(4096)
resp.close()
if head.lstrip()[:7] == b"#EXTM3U":
return "hls", final, head
# A raw TS stream begins with sync byte 0x47 (possibly after a few bytes).
return "ts", final, head
# ─────────────────────────────── HLS ─────────────────────────────────
def parse_media_playlist(text):
"""Extract sequence, target duration, segment URIs and flags from a media
playlist. Returns a dict."""
media_seq = 0
target = 0
seg_uris = []
disc_count = 0
endlist = False
is_master = False
variant_uris = []
lines = text.replace("\r\n", "\n").split("\n")
for i, line in enumerate(lines):
line = line.strip()
if not line:
continue
if line.startswith("#EXT-X-STREAM-INF"):
is_master = True
for j in range(i + 1, len(lines)):
nxt = lines[j].strip()
if nxt and not nxt.startswith("#"):
variant_uris.append(nxt)
break
elif line.startswith("#EXT-X-MEDIA-SEQUENCE:"):
media_seq = int(line.split(":", 1)[1] or 0)
elif line.startswith("#EXT-X-TARGETDURATION:"):
try:
target = float(line.split(":", 1)[1])
except ValueError:
target = 0
elif line.startswith("#EXT-X-DISCONTINUITY") and not line.startswith("#EXT-X-DISCONTINUITY-SEQUENCE"):
disc_count += 1
elif line.startswith("#EXT-X-ENDLIST"):
endlist = True
elif not line.startswith("#"):
seg_uris.append(line)
return {
"is_master": is_master,
"variant_uris": variant_uris,
"media_seq": media_seq,
"target": target,
"seg_uris": seg_uris,
"disc_count": disc_count,
"endlist": endlist,
}
def resolve_master(url, text, ua):
"""If the playlist is a master, follow its first variant to a media
playlist. Returns (media_url, media_text)."""
pl = parse_media_playlist(text)
if not pl["is_master"]:
return url, text
if not pl["variant_uris"]:
raise RuntimeError("master playlist has no variants")
media_url = urljoin(url, pl["variant_uris"][0])
_, final, body = http_get(media_url, ua)
return final, body.decode("utf-8", "replace")
def check_hls(url, ua, duration, report):
report["mode"] = "hls"
_, final_url, body = http_get(url, ua)
text = body.decode("utf-8", "replace")
media_url, text = resolve_master(final_url, text, ua)
report["details"]["media_playlist"] = media_url
highest_index = None # global index of the newest segment seen
seg_ok = 0
seg_fail = 0
gaps = [] # skipped segment indices (queue break)
rewinds = 0
disc_events = 0
stalls = 0
polls = 0
last_change = time.time()
prev_signature = None
deadline = time.time() + duration
while time.time() < deadline:
polls += 1
try:
_, _, raw = http_get(media_url, ua, timeout=15)
pl = parse_media_playlist(raw.decode("utf-8", "replace"))
except Exception as e:
report["errors"].append("playlist fetch failed: %s" % e)
seg_fail += 1
time.sleep(1)
continue
base = pl["media_seq"]
signature = (base, tuple(pl["seg_uris"]))
# Stall: playlist has not advanced for > 3 target durations.
if signature == prev_signature:
if time.time() - last_change > max(6.0, pl["target"] * 3 or 18):
stalls += 1
report["errors"].append(
"stall: playlist has not advanced for %ds" % int(time.time() - last_change))
last_change = time.time() # avoid repeating every poll
else:
last_change = time.time()
prev_signature = signature
if pl["disc_count"] > disc_events:
report["errors"].append("EXT-X-DISCONTINUITY present (timeline break)")
disc_events = max(disc_events, pl["disc_count"])
# Assign a global index to each segment: media_seq + position.
for pos, uri in enumerate(pl["seg_uris"]):
idx = base + pos
if highest_index is None:
highest_index = idx - 1 # so the first segment counts as "new"
if idx <= highest_index:
# Already seen (still in the sliding window) — check for rewind.
continue
# Queue-break checks on a freshly appearing segment.
if idx > highest_index + 1:
skipped = list(range(highest_index + 1, idx))
gaps.extend(skipped)
report["errors"].append(
"queue gap: segments %s never appeared (deleted before read)"
% (skipped if len(skipped) <= 5 else "%d..%d" % (skipped[0], skipped[-1])))
# Download & verify the new segment.
seg_url = urljoin(media_url, uri)
try:
st, _, seg = http_get(seg_url, ua, timeout=15)
if st == 200 and seg:
# Optional TS-level sanity: first byte should be a sync byte.
if seg[:1] == b"\x47" or b"\x47" in seg[:376]:
seg_ok += 1
else:
seg_ok += 1 # non-TS segment container; still counts as delivered
else:
seg_fail += 1
report["errors"].append("segment %d unfetchable (HTTP %s)" % (idx, st))
except Exception as e:
seg_fail += 1
report["errors"].append("segment %d fetch error: %s" % (idx, e))
highest_index = idx
# Detect a hard rewind (media sequence went backwards vs the newest seen).
if highest_index is not None and base > 0 and base > highest_index + 1:
rewinds += 1
if pl["endlist"]:
report["details"]["endlist"] = True
break
time.sleep(max(1.0, (pl["target"] or 4) / 2.0))
d = report["details"]
d["polls"] = polls
d["segments_delivered"] = seg_ok
d["segments_failed"] = seg_fail
d["queue_gaps"] = len(gaps)
d["rewinds"] = rewinds
d["discontinuities"] = disc_events
d["stalls"] = stalls
# Queue intact = ordered, contiguous, no dropped/rewound segments, no timeline
# break, every referenced segment fetchable. Stalls are separate.
queue_intact = (seg_fail == 0 and len(gaps) == 0 and rewinds == 0
and disc_events == 0 and seg_ok > 0)
d["queue_intact"] = queue_intact
report["healthy"] = queue_intact and stalls == 0
# ─────────────────────────────── TS ──────────────────────────────────
PACKET = 188
SYNC = 0x47
NULL_PID = 0x1FFF
def check_ts(url, ua, duration, head, report, stall_timeout=15.0, tolerance=0):
report["mode"] = "ts"
resp = http_open(url, ua, timeout=15)
last_cc = {} # pid -> last continuity_counter
cc_errors = {} # pid -> count of continuity breaks
dup_packets = 0
pid_packets = {} # pid -> packet count
total_packets = 0
sync_errors = 0
error_indicator = 0
total_bytes = 0
stalls = 0
aligned = False # first sync-byte lock is setup, not an error
buf = bytearray(head) # bytes already peeked during detection
total_bytes += len(head)
deadline = time.time() + duration
last_data = time.time()
try:
while time.time() < deadline:
try:
chunk = resp.read(65536)
except Exception as e:
report["errors"].append("read error: %s" % e)
break
now = time.time()
if not chunk:
# EOF or no data.
if now - last_data > stall_timeout:
stalls += 1
report["errors"].append("stall: no data for >%ds" % int(stall_timeout))
break
time.sleep(0.1)
continue
if now - last_data > stall_timeout:
stalls += 1
report["errors"].append("stall: gap of %.1fs in delivery" % (now - last_data))
last_data = now
total_bytes += len(chunk)
buf.extend(chunk)
# Process whole 188-byte packets; keep alignment on the sync byte.
i = 0
n = len(buf)
while i + PACKET <= n:
if buf[i] != SYNC:
# Lost alignment: hunt for the next sync byte. The very first
# lock is initial alignment (peeked bytes may start mid-packet)
# and is not counted as a stream error.
j = buf.find(b"\x47", i + 1)
if j == -1:
i = n
break
if aligned:
sync_errors += 1
i = j
continue
aligned = True
pkt = buf[i:i + PACKET]
i += PACKET
total_packets += 1
tei = (pkt[1] & 0x80) >> 7
pid = ((pkt[1] & 0x1F) << 8) | pkt[2]
afc = (pkt[3] >> 4) & 0x03
cc = pkt[3] & 0x0F
if pid == NULL_PID:
continue
pid_packets[pid] = pid_packets.get(pid, 0) + 1
if tei:
error_indicator += 1
continue
has_payload = afc in (1, 3)
# discontinuity_indicator lives in the adaptation field flags.
disc_flag = False
if afc in (2, 3) and pkt[4] > 0:
disc_flag = bool(pkt[5] & 0x80)
if pid in last_cc:
prev = last_cc[pid]
if disc_flag:
pass # signalled discontinuity — not a queue break
elif not has_payload:
# CC must NOT change when there is no payload.
if cc != prev:
cc_errors[pid] = cc_errors.get(pid, 0) + 1
else:
expected = (prev + 1) & 0x0F
if cc == prev:
dup_packets += 1 # allowed single duplicate
elif cc != expected:
cc_errors[pid] = cc_errors.get(pid, 0) + 1
last_cc[pid] = cc
# Drop consumed bytes, keep the remainder.
del buf[:i]
finally:
try:
resp.close()
except Exception:
pass
elapsed = max(0.1, min(duration, time.time() - (deadline - duration)))
total_cc_err = sum(cc_errors.values())
d = report["details"]
d["bytes"] = total_bytes
d["packets"] = total_packets
d["kbit_s"] = int(total_bytes * 8 / 1000 / elapsed)
d["sync_errors"] = sync_errors
d["error_indicator_packets"] = error_indicator
d["duplicate_packets"] = dup_packets
d["continuity_errors"] = total_cc_err
d["continuity_errors_by_pid"] = {("0x%04X" % p): c for p, c in sorted(cc_errors.items())}
d["pids"] = {("0x%04X" % p): c for p, c in sorted(pid_packets.items())}
d["stalls"] = stalls
if total_cc_err:
report["errors"].append("%d continuity-counter break(s) — the packet queue is breaking"
% total_cc_err)
if sync_errors:
report["errors"].append("%d sync-byte loss event(s)" % sync_errors)
# "Queue intact" is about ordering/loss (CC + sync); stalls are a separate
# delivery-smoothness signal. Both must be clean for overall health.
# --tolerance allows N transient breaks (rare source glitches relayed by
# `-c copy`) before the queue is declared broken.
queue_breaks = total_cc_err + sync_errors
queue_intact = (total_packets > 0 and queue_breaks <= tolerance)
d["queue_breaks"] = queue_breaks
d["queue_intact"] = queue_intact
report["healthy"] = queue_intact and stalls == 0
# ────────────────────────────── output ───────────────────────────────
def print_human(r):
print("Source : %s" % r["url"])
print("Mode : %s" % r["mode"])
d = r["details"]
if r["mode"] == "hls":
print("Media : %s" % d.get("media_playlist", "?"))
print("Polls : %s" % d.get("polls", 0))
print("Segments delivered : %s (failed %s)" % (d.get("segments_delivered", 0), d.get("segments_failed", 0)))
print("Queue gaps : %s" % d.get("queue_gaps", 0))
print("Rewinds : %s" % d.get("rewinds", 0))
print("Discontinuities : %s" % d.get("discontinuities", 0))
print("Stalls : %s" % d.get("stalls", 0))
else:
print("Throughput : %s kbit/s packets %s bytes %s" % (d.get("kbit_s", 0), d.get("packets", 0), d.get("bytes", 0)))
print("Continuity errors : %s %s" % (d.get("continuity_errors", 0), d.get("continuity_errors_by_pid") or ""))
print("Sync loss / TEI : %s / %s" % (d.get("sync_errors", 0), d.get("error_indicator_packets", 0)))
print("Duplicate packets : %s" % d.get("duplicate_packets", 0))
print("Stalls : %s" % d.get("stalls", 0))
print("PIDs : %s" % (d.get("pids") or {}))
for e in r["errors"]:
print(" ! %s" % e)
print("QUEUE : %s" % ("OK — intact" if d.get("queue_intact") else "BROKEN"))
print("DELIVERY : %s" % ("OK" if d.get("stalls", 0) == 0 else "%d stall(s)" % d.get("stalls", 0)))
print("OVERALL : %s" % ("HEALTHY" if r["healthy"] else "PROBLEM"))
# ─────────────────────────── live dashboard ──────────────────────────
#
# A virtual player model: the playhead advances at wall-clock rate, content is
# "received" as data arrives. For TS the received timeline comes from PCR (the
# stream clock); for HLS from the segments' EXTINF durations. Buffered playtime
# ("cache") = received_seconds - played_seconds. If it hits zero the playhead
# freezes (rebuffer). The dashboard draws a colored buffer-depth graph over time,
# the playhead, and how much is left in cache.
ANSI = {
"reset": "\033[0m", "dim": "\033[2m", "bold": "\033[1m",
"green": "\033[92m", "yellow": "\033[93m", "red": "\033[91m",
"cyan": "\033[96m", "blue": "\033[94m", "grey": "\033[90m",
"home": "\033[H", "clear": "\033[2J", "clreol": "\033[K",
"hide": "\033[?25l", "show": "\033[?25h",
}
SPARK = "▁▂▃▄▅▆▇█"
def colors(enabled):
return ANSI if enabled else {k: "" for k in ANSI}
def parse_extinf(text):
"""Segment durations in playlist order (aligned with parse_media_playlist's
seg_uris)."""
durs = []
for line in text.replace("\r\n", "\n").split("\n"):
line = line.strip()
if line.startswith("#EXTINF:"):
val = line.split(":", 1)[1].split(",", 1)[0]
try:
durs.append(float(val))
except ValueError:
durs.append(0.0)
return durs
class LiveModel:
def __init__(self):
self.lock = threading.Lock()
self.start = time.time()
self.mode = "?"
self.total_bytes = 0
self.last_data = time.time()
self.kbit_s = 0
self.cc_errors = 0
self.sync_errors = 0
self.stopped = False
self.error = None
# TS: received media seconds from PCR
self.received_media = 0.0
self.have_pcr = False
# HLS: downloaded segments (idx, dur, downloaded)
self.segments = []
self.hls_gaps = 0
self.hls_disc = 0
def received_seconds(self):
if self.mode == "ts":
return self.received_media
return sum(s["dur"] for s in self.segments if s["downloaded"])
def ts_reader(url, ua, model, stop):
try:
resp = http_open(url, ua, timeout=15)
except Exception as e:
with model.lock:
model.error = "open failed: %s" % e
model.stopped = True
return
buf = bytearray()
aligned = False
prev_cc = {}
prev_pcr = [None]
win_bytes = 0
win_t = time.time()
try:
while not stop.is_set():
try:
chunk = resp.read(65536)
except Exception:
break
if not chunk:
time.sleep(0.05)
continue
now = time.time()
win_bytes += len(chunk)
local_cc = local_sync = 0
pcr_seen = []
buf.extend(chunk)
i, n = 0, len(buf)
while i + PACKET <= n:
if buf[i] != SYNC:
j = buf.find(b"\x47", i + 1)
if j == -1:
i = n
break
if aligned:
local_sync += 1
i = j
continue
aligned = True
pkt = buf[i:i + PACKET]
i += PACKET
pid = ((pkt[1] & 0x1F) << 8) | pkt[2]
afc = (pkt[3] >> 4) & 0x03
cc = pkt[3] & 0x0F
if pid == NULL_PID:
continue
if afc in (2, 3) and pkt[4] > 0 and (pkt[5] & 0x10):
b = pkt[6:12]
base = (b[0] << 25) | (b[1] << 17) | (b[2] << 9) | (b[3] << 1) | (b[4] >> 7)
pcr_seen.append(base / 90000.0)
if pkt[1] & 0x80: # transport_error_indicator
continue
has_payload = afc in (1, 3)
disc = bool(afc in (2, 3) and pkt[4] > 0 and (pkt[5] & 0x80))
if pid in prev_cc:
p = prev_cc[pid]
if disc:
pass
elif not has_payload:
if cc != p:
local_cc += 1
else:
if cc != p and cc != ((p + 1) & 0x0F):
local_cc += 1
prev_cc[pid] = cc
del buf[:i]
with model.lock:
model.total_bytes += len(chunk)
model.last_data = now
model.cc_errors += local_cc
model.sync_errors += local_sync
for pcr in pcr_seen:
if prev_pcr[0] is not None:
d = pcr - prev_pcr[0]
if 0 < d < 10:
model.received_media += d
prev_pcr[0] = pcr
model.have_pcr = True
if now - win_t >= 1.0:
model.kbit_s = int(win_bytes * 8 / 1000 / (now - win_t))
win_bytes = 0
win_t = now
finally:
try:
resp.close()
except Exception:
pass
with model.lock:
model.stopped = True
def hls_poller(media_url, ua, model, stop):
highest = [None]
while not stop.is_set():
try:
_, _, raw = http_get(media_url, ua, timeout=15)
text = raw.decode("utf-8", "replace")
pl = parse_media_playlist(text)
durs = parse_extinf(text)
except Exception:
time.sleep(1)
continue
target = pl["target"] or 4.0
base = pl["media_seq"]
with model.lock:
model.hls_disc = max(model.hls_disc, pl["disc_count"])
for pos, uri in enumerate(pl["seg_uris"]):
idx = base + pos
if highest[0] is None:
highest[0] = idx - 1
if idx <= highest[0]:
continue
if idx > highest[0] + 1:
with model.lock:
model.hls_gaps += idx - (highest[0] + 1)
dur = durs[pos] if pos < len(durs) else target
seg = {"idx": idx, "dur": dur, "downloaded": False}
try:
st, _, body = http_get(urljoin(media_url, uri), ua, timeout=15)
if st == 200 and body:
seg["downloaded"] = True
with model.lock:
model.total_bytes += len(body)
model.last_data = time.time()
model.kbit_s = int(len(body) * 8 / 1000 / max(0.5, dur))
except Exception:
pass
with model.lock:
model.segments.append(seg)
highest[0] = idx
time.sleep(max(1.0, target / 2))
def _buf_color(c, frac):
return c["green"] if frac >= 0.5 else c["yellow"] if frac >= 0.2 else c["red"]
def render_frame(c, m, buffer_ahead, played, state, target, hist, rebuffers):
"""Build the dashboard as a list of lines (each cleared to EOL)."""
up = int(time.time() - m["start"])
lines = []
def L(s=""):
lines.append(s + c["clreol"])
L(c["bold"] + c["cyan"] + " STREAM QUEUE / BUFFER MONITOR" + c["reset"]
+ c["grey"] + " %s up %02d:%02d" % (m["mode"].upper(), up // 60, up % 60) + c["reset"])
L(c["grey"] + " " + (m["url"][:76]) + c["reset"])
L()
# Buffer-depth graph over time (colored sparkline).
spark = ""
for v in hist:
frac = max(0.0, min(1.0, v / target)) if target > 0 else 0.0
ch = SPARK[min(len(SPARK) - 1, int(frac * (len(SPARK) - 1) + 0.5))]
spark += _buf_color(c, frac) + ch
spark += c["reset"]
L(" cache buffer (s), last %ds:" % len(hist))
L(" " + spark)
L()
# Current buffer gauge.
frac = max(0.0, min(1.0, buffer_ahead / target)) if target > 0 else 0.0
width = 30
filled = int(round(frac * width))
gauge = _buf_color(c, frac) + "█" * filled + c["grey"] + "░" * (width - filled) + c["reset"]
L(" IN CACHE : [%s] %s%5.1fs%s / %ds" % (gauge, _buf_color(c, frac), buffer_ahead, c["reset"], int(target)))
# Playhead / state.
st_color = {"PLAYING": c["green"], "PREBUFFER": c["yellow"], "BUFFERING": c["red"]}.get(state, c["grey"])
L(" PLAYING : %s%-10s%s head %02d:%02d received %02d:%02d"
% (st_color, state, c["reset"], int(played) // 60, int(played) % 60,
int(m["received"]) // 60, int(m["received"]) % 60))
L()
# HLS: segments remaining in cache ahead of the playhead.
if m["mode"] == "hls":
segs = sorted((s for s in m["segments"] if s["downloaded"]), key=lambda s: s["idx"])
run = 0.0
ahead = []
for s in segs:
end = run + s["dur"]
if end > played: # not fully played yet
ahead.append(s)
run = end
blocks = ""
for s in ahead[:20]:
w = max(1, int(round(s["dur"])))
blocks += c["green"] + "█" * w + c["grey"] + "|" + c["reset"]
L(" segments in cache: %s%d%s (%.1fs)" % (c["bold"], len(ahead), c["reset"], sum(s["dur"] for s in ahead)))
L(" " + (blocks or (c["grey"] + "(none)" + c["reset"])))
L()
# Delivery / queue health.
age = time.time() - m["last_data"]
recv_c = c["green"] if age < 3 else c["red"]
L(" rate %s%d kbit/s%s received %.1f MB last data %s%.1fs ago%s"
% (c["cyan"], m["kbit_s"], c["reset"], m["total_bytes"] / 1048576.0, recv_c, age, c["reset"]))
q_ok = (m["cc_errors"] == 0 and m["sync_errors"] == 0 and m["hls_gaps"] == 0)
qc = c["green"] if q_ok else c["red"]
L(" QUEUE %s%s%s cc:%d sync:%d gaps:%d disc:%d rebuffers:%s%d%s"
% (qc, "OK" if q_ok else "BREAKING", c["reset"],
m["cc_errors"], m["sync_errors"], m["hls_gaps"], m["hls_disc"],
(c["green"] if rebuffers == 0 else c["red"]), rebuffers, c["reset"]))
L()
L(c["grey"] + " Ctrl-C to quit" + c["reset"])
return "\n".join(lines)
def run_live(url, ua, args):
tty = sys.stdout.isatty()
c = colors(not args.no_color and tty)
try:
kind, final_url, _head = detect_kind(url, ua)
except Exception as e:
print("cannot open stream: %s" % e)
return 2
model = LiveModel()
model.mode = kind
model.url = url
stop = threading.Event()
if kind == "ts":
worker = threading.Thread(target=ts_reader, args=(final_url, ua, model, stop), daemon=True)
else:
try:
_, _, body = http_get(final_url, ua)
media_url, _ = resolve_master(final_url, body.decode("utf-8", "replace"), ua)
except Exception as e:
print("cannot start HLS: %s" % e)
return 2
worker = threading.Thread(target=hls_poller, args=(media_url, ua, model, stop), daemon=True)
worker.start()
prebuffer = args.prebuffer
target = args.buffer_target
playing = False
played = 0.0
rebuffers = 0
starving = False
hist = [0.0] * 60
last_tick = time.time()
if tty:
sys.stdout.write(c["hide"] + c["clear"])
deadline = model.start + args.duration if args.duration > 0 else None
tick = 0
try:
while True:
now = time.time()
dt = now - last_tick
last_tick = now
with model.lock:
recv = model.received_seconds()
snap = {
"start": model.start, "mode": model.mode, "url": model.url,
"total_bytes": model.total_bytes, "last_data": model.last_data,
"kbit_s": model.kbit_s, "cc_errors": model.cc_errors,
"sync_errors": model.sync_errors, "hls_gaps": model.hls_gaps,
"hls_disc": model.hls_disc, "received": recv,
"segments": list(model.segments),
}
stopped = model.stopped
err = model.error
if not playing and recv >= prebuffer:
playing = True
state = "PLAYING" if playing else "PREBUFFER"
if playing:
if recv - played > 1e-6:
played += dt
if played > recv:
played = recv
starving = False
else:
state = "BUFFERING"
if not starving:
rebuffers += 1
starving = True
buffer_ahead = max(0.0, recv - played)
# update history once per ~1s of ticks (4 ticks/s → every 4th)
tick += 1
if tick % 4 == 0:
hist.append(buffer_ahead)
hist = hist[-60:]
frame = render_frame(c, snap, buffer_ahead, played, state, target, hist, rebuffers)
if tty:
sys.stdout.write(c["home"] + frame + "\n")
else:
sys.stdout.write(frame + "\n" + "-" * 40 + "\n")
sys.stdout.flush()
if err:
print("\n" + err)
break
if stopped and buffer_ahead <= 0:
break
if deadline and now >= deadline:
break
time.sleep(0.25)
except KeyboardInterrupt:
pass
finally:
stop.set()
if tty:
sys.stdout.write(c["show"] + c["reset"] + "\n")
return 0
def main():
ap = argparse.ArgumentParser(description="Check stream segment/packet queue integrity.")
ap.add_argument("url", help="stream URL (HLS .m3u8 or MPEG-TS .ts)")
ap.add_argument("--duration", type=int, default=30, help="seconds to observe (default 30)")
ap.add_argument("--stall-timeout", type=float, default=15.0,
help="TS: gap in delivery (s) counted as a stall; keep it above the "
"segment duration so normal per-segment bursts aren't flagged (default 15)")
ap.add_argument("--tolerance", type=int, default=0,
help="max transient queue breaks (CC + sync loss) tolerated before the "
"queue is declared BROKEN; useful to ignore rare source glitches (default 0)")
ap.add_argument("--json", action="store_true", help="machine-readable output")
ap.add_argument("--ua", default=DEFAULT_UA, help="User-Agent header to send")
ap.add_argument("--live", action="store_true",
help="live colored dashboard: received segments, playhead, and buffered "
"cache (seconds) over time")
ap.add_argument("--prebuffer", type=float, default=10.0,
help="live: seconds to buffer before the virtual playhead starts (default 10)")
ap.add_argument("--buffer-target", type=float, default=30.0,
help="live: full-scale of the cache-buffer graph in seconds (default 30)")
ap.add_argument("--no-color", action="store_true", help="disable ANSI colors")
args = ap.parse_args()
if args.live:
return run_live(args.url, args.ua, args)
report = {"url": args.url, "mode": "?", "healthy": False, "details": {}, "errors": []}
try:
kind, final_url, head = detect_kind(args.url, args.ua)
if kind == "hls":
check_hls(final_url, args.ua, args.duration, report)
else:
check_ts(final_url, args.ua, args.duration, head, report, args.stall_timeout, args.tolerance)
except urllib.error.HTTPError as e:
report["errors"].append("HTTP %s: %s" % (e.code, e.reason))
except Exception as e:
report["errors"].append("fatal: %s" % e)
if args.json:
print(json.dumps(report, indent=2, ensure_ascii=False))
else:
print_human(report)
return 0 if report["healthy"] else 2
if __name__ == "__main__":
sys.exit(main())