Building an M3U and XMLTV EPG Validator with Python
An offline, standard-library tutorial for checking channel identifiers, missing guide data, and common IPTV playlist mistakes.
You have an extended M3U playlist. Your IPTV player loads the channels, but its electronic programme guide (EPG) shows empty rows. The playlist might be fine as a list of streams; the problem could simply be that the playlist identifiers do not match the XMLTV guide.
Instead of changing EPG offsets or restarting the player repeatedly, let's inspect the two files with a small Python script. This tutorial builds a local validator that reports:
- channels with an M3U
tvg-idthat exists in XMLTV; - channels with a missing or unknown
tvg-id; - matched channels that have no
<programme>entries; - malformed extended-M3U entries that never received a source line.
The script does not play streams, fetch URLs, test subscriptions, or verify content rights. It works exclusively on local example files and helps narrow down metadata problems.
How M3U and XMLTV fit together
An extended M3U playlist commonly includes records such as:
#EXTM3U
#EXTINF:-1 tvg-id="kanal-1.rs" group-title="Demo",Primer Kanal 1
https://example.org/video/1.m3u8
The tvg-id is a mapping hint. In the guide, an XMLTV <channel> element has an id, and <programme> elements reference that same channel ID:
<tv>
<channel id="kanal-1.rs">
<display-name>Primer Kanal 1</display-name>
</channel>
<programme channel="kanal-1.rs" start="20261011120000 +0200" stop="20261011130000 +0200">
<title>Demo emisija</title>
</programme>
</tv>
For this simple validator, IDs must match exactly, including their case. Some players implement fallback matching using tvg-name or XMLTV display names; this demonstration intentionally checks only the exact-ID route so its results are predictable. The Kodi IPTV Simple Client documentation describes that player's specific fallback behaviour and configuration options.
Build the offline validator
You only need Python 3 and the standard library. Create a file named m3u_xmltv_validator.py and paste this code:
#!/usr/bin/env python3
"""Offline M3U/XMLTV consistency checker for local, trusted files.
This tool does not download, authenticate to, or play any streams.
"""
import argparse
from collections import Counter
from pathlib import Path
import re
import xml.etree.ElementTree as ET
ATTRIBUTE = re.compile(r'([A-Za-z][\w-]*)="([^"]*)"')
MAX_FILE_BYTES = 5_000_000
def load_text(path: Path) -> str:
if path.stat().st_size > MAX_FILE_BYTES:
raise ValueError(f"File too large: {path.name} (5 MB limit)")
return path.read_text(encoding="utf-8-sig")
def read_m3u(path: Path):
lines = load_text(path).splitlines()
first = next((line.strip() for line in lines if line.strip()), "")
if not first.startswith("#EXTM3U"):
raise ValueError("Expected an extended M3U file starting with #EXTM3U")
channels = []
warnings = []
pending = None
for number, raw in enumerate(lines, start=1):
line = raw.strip()
if line.startswith("#EXTINF:"):
if pending is not None:
warnings.append(f"Line {pending['line']}: no source after #EXTINF")
metadata, _, display_name = line.partition(",")
attrs = dict(ATTRIBUTE.findall(metadata))
pending = {
"name": display_name.strip() or attrs.get("tvg-name", "(unnamed)"),
"tvg_id": attrs.get("tvg-id", "").strip(),
"line": number,
}
elif line and not line.startswith("#"):
if pending is None:
warnings.append(f"Line {number}: URL/path without #EXTINF")
else:
# The source may contain credentials: intentionally do not print it.
channels.append(pending)
pending = None
if pending is not None:
warnings.append(f"Line {pending['line']}: no source after #EXTINF")
return channels, warnings
def read_xmltv(path: Path):
content = load_text(path)
# Use trusted local XML only. Reject DTD/entity declarations in this demo.
upper = content.upper()
if "<!DOCTYPE" in upper or "<!ENTITY" in upper:
raise ValueError("DTD/entity declarations are not supported")
root = ET.fromstring(content)
if root.tag != "tv":
raise ValueError("Expected an XMLTV <tv> root element")
ids = {element.get("id") for element in root.findall("channel") if element.get("id")}
programmes = Counter(
element.get("channel")
for element in root.findall("programme")
if element.get("channel")
)
return ids, programmes
def main():
parser = argparse.ArgumentParser(description="Check M3U tvg-id against XMLTV channel IDs")
parser.add_argument("playlist", type=Path, help="Local extended M3U file")
parser.add_argument("guide", type=Path, help="Local XMLTV file")
args = parser.parse_args()
try:
channels, warnings = read_m3u(args.playlist)
xml_ids, programmes = read_xmltv(args.guide)
except (OSError, ValueError, ET.ParseError, UnicodeError) as exc:
parser.exit(2, f"Error: {exc}\n")
mapped = [c for c in channels if c["tvg_id"] in xml_ids]
missing_ids = [c for c in channels if not c["tvg_id"]]
unknown_ids = [c for c in channels if c["tvg_id"] and c["tvg_id"] not in xml_ids]
without_programmes = [c for c in mapped if programmes[c["tvg_id"]] == 0]
print(f"M3U channels: {len(channels)}")
print(f"XMLTV channel IDs: {len(xml_ids)}")
print(f"M3U channels matched by tvg-id: {len(mapped)}")
print(f"M3U channels with no tvg-id: {len(missing_ids)}")
print(f"M3U channels with an unknown tvg-id: {len(unknown_ids)}")
print(f"Matched channels without programmes: {len(without_programmes)}")
for title, items in (
("Unknown XMLTV IDs", unknown_ids),
("Matched but no programmes", without_programmes),
):
if items:
print(f"\n{title} (up to 5):")
for c in items[:5]:
print(f" - {c['name']} [tvg-id={c['tvg_id']}]")
for warning in warnings[:5]:
print(f"Warning: {warning}")
if __name__ == "__main__":
main()
What the code does
First, read_m3u() pairs each #EXTINF line with the next non-comment source line. It stores the channel's display name and tvg-id, but deliberately does not print the source URL. Real playlist URLs sometimes contain usernames, tokens, or other credentials.
Then, read_xmltv() extracts declared XMLTV channel IDs and counts programmes per channel. Finally, main() compares them and prints a concise report. All matching here is exact and case-sensitive.
This is a learning tool for local files. The example caps files at 5 MB and rejects DTD/entity declarations. For processing arbitrary or untrusted XML in an application, follow the Python XML security guidance and consider a hardened XML parser such as defusedxml. It is not a production-grade XMLTV ingestion service.
Try it with local sample data
Create sample_playlist.m3u:
#EXTM3U
#EXTINF:-1 tvg-id="kanal-1.rs" group-title="Demo",Primer Kanal 1
https://example.org/video/1.m3u8
#EXTINF:-1 tvg-id="kanal-2.rs" group-title="Demo",Primer Kanal 2
https://example.org/video/2.m3u8
#EXTINF:-1 tvg-id="nepoznat.rs" group-title="Demo",Primer Kanal 3
https://example.org/video/3.m3u8
Create sample_guide.xml:
<?xml version="1.0" encoding="UTF-8"?>
<tv>
<channel id="kanal-1.rs"><display-name>Primer Kanal 1</display-name></channel>
<channel id="kanal-2.rs"><display-name>Primer Kanal 2</display-name></channel>
<programme start="20261011120000 +0200" stop="20261011130000 +0200" channel="kanal-1.rs">
<title>Demo emisija</title>
</programme>
</tv>
The example.org URLs are placeholders: the validator never visits them.
Run:
python m3u_xmltv_validator.py sample_playlist.m3u sample_guide.xml
The expected report is:
M3U channels: 3
XMLTV channel IDs: 2
M3U channels matched by tvg-id: 2
M3U channels with no tvg-id: 0
M3U channels with an unknown tvg-id: 1
Matched channels without programmes: 1
Unknown XMLTV IDs (up to 5):
- Primer Kanal 3 [tvg-id=nepoznat.rs]
Matched but no programmes (up to 5):
- Primer Kanal 2 [tvg-id=kanal-2.rs]
We now have two distinct diagnoses. Primer Kanal 3 refers to an identifier missing from XMLTV. Primer Kanal 2 has a valid guide identifier but no scheduled programme elements. Fixing one problem will not automatically fix the other.
What a metadata validator cannot tell you
Even perfectly matching identifiers do not prove that a guide's time zone is correct, that its programme data is current, or that a stream is playable. In particular, tvg-id matching and time-offset troubleshooting are separate checks.
A broader implementation might validate timestamps, detect duplicate channel IDs, support compressed XMLTV, or compare names as an explicitly labelled fallback. It should also treat playlist URLs as sensitive data and avoid dereferencing arbitrary user-supplied URLs.
For Serbian-language background on devices, M3U and choosing an IPTV service, I contribute to the consumer-oriented IPTV Srbija guide. That is a commercially related reference, not a benchmark, independent test, or proof that a provider has content-distribution rights. The technical validator above is self-contained and does not require that website.
Takeaway
When channels appear but EPG entries are missing, begin with the simplest verifiable question: does each playlist tvg-id exist in the XMLTV channel IDs, and does it have programmes? A small local Python tool can make those metadata mismatches visible before you investigate the player, network, or upstream source.
Disclosure: This tutorial was prepared with AI assistance and the example code was executed against the sample files. I contribute to the IPTV Srbija website mentioned for Serbian-language consumer context.
Top comments (0)