M3U files look simple. They're plain text: a header, then pairs of lines where one describes a stream and the next one points to it. Because they're so easy to edit by hand, they're also easy to break. One missing URL line shifts every entry below it, a duplicated link hides in line 4,000, and a typo like htps:// fails silently in most players.
In this post we'll build m3u_check.py, a small command-line linter that:
- validates the basic M3U structure
- reports entries with no URL (and URLs with no entry)
- flags duplicate URLs and duplicate names
- warns when
tvg-idis missing, so you know EPG data won't match - optionally checks every URL with a concurrent
HEADrequest - returns a non-zero exit code so you can drop it into CI
No pip install needed. Everything comes from Python's standard library.
A 30-second refresher on the format
An extended M3U playlist looks like this:
#EXTM3U #EXTINF:-1 tvg-id="news.one" tvg-logo="https://example.com/n1.png" group-title="News",News One https://example.com/live/news1.m3u8 -
#EXTM3Umust be on the first line. -
#EXTINF:starts an entry.-1is the duration (-1means a live stream with no fixed length). - Everything between the duration and the last comma is a set of
key="value"attributes.tvg-idlinks the entry to an EPG (TV guide) source,tvg-logoholds an icon, andgroup-titlesets the category. - After the comma comes the display name.
- The next non-comment line is the URL.
That "next line" rule is where most bugs come from, so it's the core of our parser.
The broken playlist we'll test with
Save this as sample.m3u. It hides four problems. Try to spot them before reading on:
#EXTM3U #EXTINF:-1 tvg-id="news.one" tvg-logo="https://example.com/n1.png" group-title="News",News One https://example.com/live/news1.m3u8 #EXTINF:-1 tvg-id="" group-title="Sports",Sports Two https://example.com/live/sports2.m3u8 #EXTINF:-1 tvg-id="movies.three" group-title="Movies",Movies Three https://example.com/live/news1.m3u8 #EXTINF:-1 tvg-id="docs.four" group-title="Docs",Docs Four #EXTINF:-1 tvg-id="kids.five" group-title="Kids",Kids Five htps://example.com/live/kids5.m3u8 Step 1: Parse entries with a tiny state machine
We read the file line by line and keep one variable, pending, which holds the #EXTINF entry that is still waiting for its URL.
- If we see
#EXTINFwhile something is already pending, the previous entry never got a URL. - If we see a URL with nothing pending, it's an orphan.
- Otherwise we attach the URL and store the finished entry.
Attributes are pulled out with one regex: ([\w-]+)="([^"]*)". It matches tvg-id="news.one", group-title="News" and so on, and dict() turns the matches into a lookup table.
Two small details make the parser more forgiving in real life:
-
encoding="utf-8-sig"silently strips the BOM that some Windows editors add, which would otherwise break the#EXTM3Ucheck. - We
strip()every line, so stray\rcharacters and trailing spaces don't create fake URLs.
Step 2: Lint the parsed entries
Once we have clean entries, the checks are short:
- URL scheme.
urllib.parse.urlparsegives us the scheme. Anything other thanhttp,https,rtmp,rtsporudpis suspicious, and that's how we catch thehtps://typo. - Missing
tvg-id. The entry still plays, but players can't match it to guide data, so it shows "No information". - Duplicates.
collections.Counterover URLs and names finds them in one pass, even in very large files.
Step 3: Probe URLs concurrently (optional)
Checking 2,000 URLs one by one with a 5-second timeout could take hours. ThreadPoolExecutor lets us send many HEAD requests in parallel. Network calls spend most of their time waiting, so threads work well here despite the GIL.
We use HEAD instead of GET because we only want the status code, not the video data. A few servers reject HEAD with a 405. If you see a lot of those, switching the probe to a GET that reads only the first few bytes is a simple fix.
The full script
#!/usr/bin/env python3 """m3u_check.py - lint an M3U/M3U8 playlist and (optionally) probe its URLs.""" import argparse import re import sys from collections import Counter from concurrent.futures import ThreadPoolExecutor from urllib.parse import urlparse from urllib.request import Request, urlopen ATTR_RE = re.compile(r'([\w-]+)="([^"]*)"') def parse(path): """Return (entries, problems). Each entry is a dict with line, name, attrs, url.""" entries, problems = [], [] with open(path, encoding="utf-8-sig", errors="replace") as f: lines = [l.rstrip("\r\n") for l in f] if not lines or not lines[0].strip().startswith("#EXTM3U"): problems.append((1, "missing #EXTM3U header on line 1")) pending = None for n, raw in enumerate(lines, start=1): line = raw.strip() if not line or (line.startswith("#") and not line.startswith("#EXTINF")): continue # blank lines, header and other directives if line.startswith("#EXTINF"): if pending: problems.append((pending["line"], f'"{pending["name"]}" has no URL')) meta, _, name = line.partition(",") pending = {"line": n, "name": name.strip() or "(no name)", "attrs": dict(ATTR_RE.findall(meta))} else: # a URL line if pending is None: problems.append((n, "URL without an #EXTINF line above it")) pending = {"line": n, "name": "(no name)", "attrs": {}} pending["url"] = line entries.append(pending) pending = None if pending: problems.append((pending["line"], f'"{pending["name"]}" has no URL')) return entries, problems def lint(entries): problems = [] for e in entries: scheme = urlparse(e["url"]).scheme if scheme not in ("http", "https", "rtmp", "rtsp", "udp"): problems.append((e["line"], f'"{e["name"]}" has an odd URL: {e["url"]}')) if not e["attrs"].get("tvg-id"): problems.append((e["line"], f'"{e["name"]}" has no tvg-id (EPG will not match)')) for url, count in Counter(e["url"] for e in entries).items(): if count > 1: problems.append((0, f"duplicate URL x{count}: {url}")) for name, count in Counter(e["name"] for e in entries).items(): if count > 1: problems.append((0, f'duplicate name x{count}: "{name}"')) return problems def probe(entry, timeout): url = entry["url"] if not url.startswith(("http://", "https://")): return entry, "skipped (not http)" try: req = Request(url, method="HEAD", headers={"User-Agent": "m3u-check/1.0"}) with urlopen(req, timeout=timeout) as resp: return entry, f"HTTP {resp.status}" except Exception as exc: # timeouts, DNS errors, 4xx/5xx return entry, f"FAIL ({exc.__class__.__name__}: {exc})" def main(): ap = argparse.ArgumentParser(description="Lint an M3U playlist.") ap.add_argument("playlist") ap.add_argument("--check-urls", action="store_true", help="send a HEAD request to each http(s) URL") ap.add_argument("--workers", type=int, default=10) ap.add_argument("--timeout", type=float, default=5.0) args = ap.parse_args() entries, problems = parse(args.playlist) problems += lint(entries) groups = Counter(e["attrs"].get("group-title", "(none)") for e in entries) print(f"{len(entries)} entries in {len(groups)} groups") for group, count in groups.most_common(): print(f" {group}: {count}") print(f"\n{len(problems)} problem(s)") for line, msg in sorted(problems): print(f" line {line}: {msg}" if line else f" {msg}") if args.check_urls: print("\nURL check") with ThreadPoolExecutor(max_workers=args.workers) as pool: for entry, result in pool.map(lambda e: probe(e, args.timeout), entries): print(f" {entry['name']}: {result}") sys.exit(1 if problems else 0) if __name__ == "__main__": main() Running it
python3 m3u_check.py sample.m3u 4 entries in 4 groups News: 1 Sports: 1 Movies: 1 Kids: 1 4 problem(s) duplicate URL x2: https://example.com/live/news1.m3u8 line 4: "Sports Two" has no tvg-id (EPG will not match) line 8: "Docs Four" has no URL line 9: "Kids Five" has an odd URL: htps://example.com/live/kids5.m3u8 All four hidden problems are caught. Notice that "Docs Four" is reported instead of silently swallowing the next entry's URL, which is exactly what many players do.
To also test the links:
python3 m3u_check.py my_playlist.m3u --check-urls --workers 20 --timeout 3 Using it in CI
The script exits with 1 when it finds a problem, so it works as a guard in any pipeline. A minimal GitHub Actions step:
- name: Lint playlists run: | for f in playlists/*.m3u; do python3 m3u_check.py "$f" done Leave --check-urls out of CI unless you control the servers. Remote streams go up and down for reasons unrelated to your change, and flaky builds teach people to ignore failures.
Ideas to extend it
-
--fixmode: drop duplicates and orphan lines, then write a cleaned file. - EPG cross-check: load an XMLTV guide and report
tvg-idvalues that don't exist in it. - JSON output: add
--jsonso other tools can consume the report. - Group filter:
--group Sportsto check only one category.
Wrapping up
M3U is one of those formats that's "just text" until it isn't. A hundred lines of standard-library Python turns silent playback failures into clear, line-numbered errors you can fix in seconds. A state machine for parsing, Counter for duplicates and a thread pool for I/O are patterns you'll reuse well beyond playlists.
What would you add to it? Let me know in the comments.
This article was written with AI assistance. The code was run and the output above was verified before publishing.