#!/usr/bin/python3 """Report which channels in `lists/` are no longer reachable. The playlist checker records the URLs it could not open, but a bare URL does not say which channel or which list it came from, and one failed attempt does not mean a channel is gone. Streams time out, rate limit, move between CDNs, and some are only served inside their own country. This script maps every URL back to its channel and list, probes each one several times, and separates the cases that look alike from the outside: dead every attempt said "not found", so the stream is really gone disputed looked dead over HTTP, but ffprobe found a real audio/video stream behind it - needs a human, not a bot, to decide blocked the server answered but refused us, which is what a channel served only in its own country looks like from anywhere else unreachable every attempt timed out or failed to connect flaky answered at least once, so it is up but unreliable from here alive answered every time Only `dead` is safe to act on without a second opinion. `blocked` in particular must not be treated as a fault: the lists mark geo-blocked channels deliberately. Both playlist formats used by the lists are understood, HLS (.m3u8) and MPEG-DASH (.mpd), so a DASH stream is not mistaken for a broken one. Neither check is airtight on its own: a manifest that starts with `#EXTM3U`/`]` rows - the channels currently listed as working. A row already marked `[x]` (Invalid) is never looked at again once it is marked, so a source that comes back online stays listed as dead forever. --invalid flips that around: it probes only the `[x]` rows, and anything that now answers `alive` or `flaky` is printed as a recovery candidate instead of being judged dead/unreachable - which is the expected, uninteresting outcome for a row that is supposed to be broken. --include-invalid runs both passes together, in one pool of workers, so a dashboard or history file sees the whole list rather than just the working half of it. The exit code only ever reflects dead `[>]` channels - a dead `[x]` channel is not a regression, it is the status quo - so --invalid on its own always exits 0. Usage: ./check_channels.py # every list, working channels only ./check_channels.py greece italy # only those lists ./check_channels.py --attempts 5 greece # more attempts per channel ./check_channels.py --confirm-dead greece # + ffprobe second opinion on dead ones ./check_channels.py --invalid greece # only the channels already flagged Invalid ./check_channels.py --include-invalid # working channels + Invalid ones, one run """ import argparse import http.client import json import os import re import shutil import ssl import subprocess import sys import time import urllib.error import urllib.request from collections import namedtuple from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone LISTS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lists") # one bundle for the knobs every probe/list/run function otherwise had to repeat ProbeSettings = namedtuple( "ProbeSettings", ["attempts", "timeout", "pause", "workers", "confirm_dead"], ) # `| 1 | Channel name | [>](url) | ...` (working) or `| 1 | Channel name | [x](url) | ...` # (flagged Invalid) is the row format used by every list ROW = re.compile(r"^\|[^|]*\|([^|]+)\|[^|]*\[([>x])\]\((https?://[^)\s]+)\)") USER_AGENT = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/131.0 Safari/537.36" ) OK = "ok" GONE = "gone" REFUSED = "refused" UNREACHABLE = "unreachable" ALIVE = "alive" DEAD = "dead" DISPUTED = "disputed" BLOCKED = "blocked" UNREACHED = "unreachable" FLAKY = "flaky" # a channel served only in its own country answers, then refuses us REFUSING_CODES = (401, 402, 403, 451) GONE_CODES = (404, 410) # ffprobe gets much longer than an HTTP probe: it has to open a connection, read # enough of the stream to find a decodable frame, and do that over whatever the # channel's own CDN feels like doing today, not just get a response header back FFPROBE_TIMEOUT = 25 def read_channels(name, markers): """Return the `(channel, url, marker)` triples of the list called `name`. `markers` selects which rows to pull: `>` for the ones currently listed as working, `x` for the ones already flagged Invalid. """ path = os.path.join(LISTS_DIR, name + ".md") channels = [] with open(path, encoding="utf-8") as handle: for line in handle: match = ROW.match(line.strip()) if match and match.group(2) in markers: channels.append((match.group(1).strip(), match.group(3), match.group(2))) return channels def looks_like_a_playlist(head): """Return True if `head` is the start of an HLS or a DASH playlist.""" text = head.lstrip() if text.startswith("#EXTM3U"): return True # DASH manifests are XML, and may carry a declaration, a comment or neither return ""}` for the default working-channels-only run, `{"x"}` for an Invalid-only recovery scan, or both at once for --include-invalid. All channels of all lists share one pool of `settings.workers`, so a run across many lists is not slower per list than a run of one - a list with two channels does not pay the same wall-clock floor as a list with two hundred just because it was handed its own pool that then sits mostly idle. """ channels_by_list = { name: [ c for c in read_channels(name, markers) if not c[1].startswith("https://www.youtube.com") ] for name in names } def run(item): list_name, channel = item return list_name, check(channel, settings) jobs = [(name, channel) for name, channels in channels_by_list.items() for channel in channels] results_by_list = {name: [] for name in names} with ThreadPoolExecutor(max_workers=settings.workers) as pool: for list_name, result in pool.map(run, jobs): results_by_list[list_name].append(result) return results_by_list def report(name, results, attempts): """Print the results of one list; return `(dead, recovered)` for this list. `dead` only counts `[>]` (working) channels - a dead `[x]` channel is the status quo, not a regression, so it never counts here. `recovered` counts `[x]` channels that answered `alive` or `flaky` this run: the opposite of the usual case, so it gets printed even though `report` otherwise skips `alive` as uninteresting. """ states = {} for _, _, _, state, _ in results: states[state] = states.get(state, 0) + 1 summary = ", ".join(f"{states[s]} {s}" for s in sorted(states)) print(f"{name}: {len(results)} checked, {summary}") dead = 0 recovered = 0 for channel, url, marker, state, outcomes in results: detail = "/".join(outcomes) if state != FLAKY else f"{outcomes.count(OK)}/{attempts} ok" if marker == "x": if state in (ALIVE, FLAKY): recovered += 1 print(f" RECOVERED? {channel} now looks {state} [{detail}] -> {url}") continue if state == DEAD: dead += 1 if state != ALIVE: print(f" {state:12} {channel} [{detail}] -> {url}") return dead, recovered def as_records(list_name, results, checked_at, confirm_dead): """Turn one list's results into flat dicts, one per channel, for --json output. `confirm_dead` is recorded on every row so a later reader can tell a `dead` verdict that already survived an ffprobe second opinion from one that has not been asked yet - the two are not the same strength of evidence. """ return [ { "checked_at": checked_at, "list": list_name, "channel": channel, "url": url, "marker": marker, "state": state, "outcomes": outcomes, "confirm_dead": confirm_dead, } for channel, url, marker, state, outcomes in results ] def parse_args(): """Parse the command line into an `argparse.Namespace`.""" parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("lists", nargs="*", help="lists to check, without the .md suffix") parser.add_argument("--attempts", type=int, default=3, help="probes per channel") parser.add_argument("--timeout", type=int, default=12, help="seconds per probe") parser.add_argument("--pause", type=int, default=5, help="seconds between probes") parser.add_argument("--workers", type=int, default=8, help="channels probed at once") parser.add_argument( "--confirm-dead", action="store_true", help="give ffprobe a second opinion on channels that look dead (slower; needs ffmpeg)", ) mode = parser.add_mutually_exclusive_group() mode.add_argument( "--invalid", action="store_true", help="check only the channels already flagged Invalid ([x]), to find ones that " "work again - never affects the exit code", ) mode.add_argument( "--include-invalid", action="store_true", help="check the working channels and the ones flagged Invalid together, in one run", ) parser.add_argument( "--json", metavar="PATH", help="also write every channel's result as one JSON record per line to PATH, for " "building a history across runs (state, outcomes, timestamp, list, channel, url)", ) return parser.parse_args() def write_json_records(handle, names, results_by_list, checked_at, confirm_dead): """Write every list's results to `handle` as one JSON record per channel per line.""" for name in names: for record in as_records(name, results_by_list[name], checked_at, confirm_dead): handle.write(json.dumps(record, ensure_ascii=False) + "\n") def main(): """Check the lists named on the command line, or every list.""" args = parse_args() if args.confirm_dead and shutil.which("ffprobe") is None: print( "--confirm-dead needs ffprobe (part of ffmpeg) on PATH; proceeding without it.", file=sys.stderr, ) args.confirm_dead = False all_lists = (f[:-3] for f in os.listdir(LISTS_DIR) if f.endswith(".md")) names = args.lists or sorted(all_lists) checked_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") settings = ProbeSettings( args.attempts, args.timeout, args.pause, args.workers, args.confirm_dead, ) markers = {"x"} if args.invalid else ({">", "x"} if args.include_invalid else {">"}) results_by_list = check_all(names, markers, settings) dead_total = 0 recovered_total = 0 for name in names: dead, recovered = report(name, results_by_list[name], args.attempts) dead_total += dead recovered_total += recovered if recovered_total: print( f"\n{recovered_total} channel(s) flagged Invalid answered like a live stream " "this run - worth a human look before restoring them." ) if args.json: with open(args.json, "w", encoding="utf-8") as handle: write_json_records(handle, names, results_by_list, checked_at, args.confirm_dead) return 1 if dead_total else 0 if __name__ == "__main__": sys.exit(main())