DEV Community

drog via
drog via

Posted on

Building an M3U and XMLTV EPG Validator with Python

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-id that 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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

The example.org URLs are placeholders: the validator never visits them.

Run:

python m3u_xmltv_validator.py sample_playlist.m3u sample_guide.xml
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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)