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.
Top comments (0)