| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381 |
- # -*- coding: utf-8 -*-
- """
- XMLTV-Parser fuer EPG-Daten.
- Parst XMLTV-XML-Dateien speichereffizient via iterparse und wandelt
- Sendungsdaten in interne Programm-Dicts um.
- """
- from __future__ import annotations
- import io
- import re
- from datetime import datetime, timedelta, timezone
- from pathlib import Path
- from xml.etree.ElementTree import iterparse
- from trixy_core.utils.debug import pdebug, perror, pwarn
- from plugins.tv_program.epg.channels import ChannelMapper
- # Regex fuer XMLTV-Zeitformat: "YYYYMMDDHHmmss +HHMM"
- _XMLTV_TIME_RE = re.compile(
- r"(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{4})"
- )
- # Genre-Kategorien fuer Typ-Erkennung
- _FILM_GENRES = {"spielfilm", "film", "kinofilm", "fernsehfilm", "tv-film", "movie"}
- _SERIE_GENRES = {"serie", "series", "soap", "telenovela", "sitcom", "dramedy"}
- _SHOW_GENRES = {"show", "unterhaltung", "entertainment", "gameshow", "talkshow", "reality"}
- _DOKU_GENRES = {"dokumentation", "doku", "documentary", "reportage", "wissen"}
- _SPORT_GENRES = {"sport", "fussball", "football", "tennis", "formel 1", "motorsport"}
- _NEWS_GENRES = {"nachrichten", "news", "magazin", "journal"}
- _KIDS_GENRES = {"kinder", "zeichentrick", "animation", "anime", "children"}
- class XMLTVParser:
- """
- Parser fuer XMLTV-Daten (Standard-EPG-Format).
- Nutzt iterparse fuer speichereffizientes Parsen grosser XML-Dateien.
- Konvertiert nur Sendungen von Sendern, die im ChannelMapper registriert sind.
- """
- def __init__(self, channel_mapper: ChannelMapper) -> None:
- """
- Args:
- channel_mapper: Mapper zur Aufloesung von XMLTV-Sender-IDs.
- """
- self._mapper = channel_mapper
- # Mapping: XMLTV channel-id (z.B. "76740") → interner channel_id (z.B. "rtl")
- # Wird beim Parsen aus <channel> Elementen aufgebaut
- self._xmltv_id_map: dict[str, str] = {}
- def parse_file(self, path: Path) -> list[dict]:
- """
- Parst eine XMLTV-Datei und gibt eine Liste von Programm-Dicts zurueck.
- Args:
- path: Pfad zur XMLTV-XML-Datei.
- Returns:
- Liste der geparsten Sendungen.
- """
- if not path.exists():
- perror(f"XMLTVParser: Datei nicht gefunden: {path}")
- return []
- pdebug(f"XMLTVParser: Parse Datei {path}")
- programs: list[dict] = []
- self._xmltv_id_map = {}
- try:
- for event, elem in iterparse(str(path), events=("end",)):
- if elem.tag == "channel":
- self._map_channel_element(elem)
- elem.clear()
- elif elem.tag == "programme":
- program = self._parse_programme(elem)
- if program is not None:
- programs.append(program)
- elem.clear()
- except Exception as e:
- perror(f"XMLTVParser: Fehler beim Parsen von {path}: {e}")
- pdebug(f"XMLTVParser: {len(programs)} Sendungen aus {path.name} geparst")
- return programs
- def parse_bytes(self, data: bytes) -> list[dict]:
- """
- Parst XMLTV-Daten aus einem Byte-Buffer.
- Args:
- data: Rohe XML-Bytes.
- Returns:
- Liste der geparsten Sendungen.
- """
- programs: list[dict] = []
- self._xmltv_id_map = {}
- try:
- stream = io.BytesIO(data)
- for event, elem in iterparse(stream, events=("end",)):
- if elem.tag == "channel":
- # Channel-Element: XMLTV-ID → Display-Name → interner channel_id
- self._map_channel_element(elem)
- elem.clear()
- elif elem.tag == "programme":
- program = self._parse_programme(elem)
- if program is not None:
- programs.append(program)
- elem.clear()
- except Exception as e:
- perror(f"XMLTVParser: Fehler beim Parsen der Byte-Daten: {e}")
- pdebug(
- f"XMLTVParser: {len(programs)} Sendungen aus Byte-Daten geparst "
- f"({len(self._xmltv_id_map)} Sender zugeordnet)"
- )
- return programs
- def _map_channel_element(self, elem) -> None:
- """
- Mappt ein <channel>-Element auf einen internen Sender.
- XMLTV-Quellen nutzen unterschiedliche ID-Formate:
- - Domain-basiert: "DasErste.de", "RTL.de"
- - Numerisch: "76740", "76762"
- In beiden Faellen versuchen wir den Display-Name
- ueber den ChannelMapper aufzuloesen.
- """
- xmltv_id = elem.get("id", "")
- if not xmltv_id:
- return
- # Zuerst direkte XMLTV-ID-Aufloesung versuchen (Domain-Format)
- channel_id = self._mapper.resolve_xmltv_id(xmltv_id)
- if channel_id:
- self._xmltv_id_map[xmltv_id] = channel_id
- return
- # Fallback: Display-Name ueber resolve() matchen
- display_name_elem = elem.find("display-name")
- if display_name_elem is not None and display_name_elem.text:
- display_name = display_name_elem.text.strip()
- channel_id = self._mapper.resolve(display_name)
- if channel_id:
- self._xmltv_id_map[xmltv_id] = channel_id
- pdebug(f"XMLTVParser: '{display_name}' (ID={xmltv_id}) → {channel_id}")
- def _parse_programme(self, elem) -> dict | None:
- """
- Parst ein einzelnes <programme>-Element in ein Programm-Dict.
- Args:
- elem: Das XML-Element <programme>.
- Returns:
- Programm-Dict oder None, wenn der Sender nicht bekannt ist.
- """
- # XMLTV-Sender-ID pruefen (nutzt vorab aufgebaute Map)
- xmltv_channel = elem.get("channel", "")
- channel_id = self._xmltv_id_map.get(xmltv_channel)
- if channel_id is None:
- # Fallback: direkte XMLTV-ID-Aufloesung (Domain-Format)
- channel_id = self._mapper.resolve_xmltv_id(xmltv_channel)
- if channel_id is None:
- return None
- # Start- und Endzeit parsen
- start_str = elem.get("start", "")
- stop_str = elem.get("stop", "")
- if not start_str:
- return None
- try:
- start_iso = self._parse_xmltv_time(start_str)
- except ValueError:
- pwarn(f"XMLTVParser: Ungueltiges Startzeit-Format: {start_str}")
- return None
- stop_iso = ""
- if stop_str:
- try:
- stop_iso = self._parse_xmltv_time(stop_str)
- except ValueError:
- pass
- # Titel (Pflichtfeld)
- title_elem = elem.find("title")
- if title_elem is None or not title_elem.text:
- return None
- title = title_elem.text.strip()
- # Optionale Felder
- subtitle = ""
- sub_elem = elem.find("sub-title")
- if sub_elem is not None and sub_elem.text:
- subtitle = sub_elem.text.strip()
- description = ""
- desc_elem = elem.find("desc")
- if desc_elem is not None and desc_elem.text:
- description = desc_elem.text.strip()
- # Kategorien sammeln
- categories: list[str] = []
- for cat_elem in elem.findall("category"):
- if cat_elem.text:
- categories.append(cat_elem.text.strip())
- # Episoden-Informationen (xmltv_ns: 0-basiert!)
- season: int | None = None
- episode: int | None = None
- ep_num_elem = elem.find("episode-num")
- if ep_num_elem is not None and ep_num_elem.text:
- system = ep_num_elem.get("system", "")
- if system == "xmltv_ns":
- season, episode = self._parse_episode_ns(ep_num_elem.text)
- # Wiederholung oder Premiere
- is_premiere = elem.find("previously-shown") is None
- # Wenn <premiere> vorhanden, ist es definitiv eine Premiere
- if elem.find("premiere") is not None:
- is_premiere = True
- # Altersfreigabe
- rating = ""
- rating_elem = elem.find("rating")
- if rating_elem is not None:
- value_elem = rating_elem.find("value")
- if value_elem is not None and value_elem.text:
- rating = value_elem.text.strip()
- # Produktionsjahr
- year = ""
- date_elem = elem.find("date")
- if date_elem is not None and date_elem.text:
- year = date_elem.text.strip()
- # Typ anhand der Kategorien und des Titels ermitteln
- program_type = self._detect_type(categories, title)
- # Programm-Dict zusammenbauen
- program: dict = {
- "ch": channel_id,
- "t": title,
- "s": start_iso,
- "e": stop_iso,
- "pr": is_premiere,
- "ty": program_type,
- }
- # Optionale Felder nur setzen, wenn vorhanden
- if subtitle:
- program["et"] = subtitle
- if description:
- program["d"] = description
- if categories:
- program["g"] = categories
- if season is not None:
- program["sn"] = season
- if episode is not None:
- program["ep"] = episode
- if rating:
- program["rt"] = rating
- if year:
- program["yr"] = year
- # Sprach-Info: Deutsches TV ist fast immer synchronisiert
- # Wird spaeter durch Enrichment (AniList/Streaming) ergaenzt
- program["lang"] = "de" # Audio-Sprache (de = deutsche Synchro)
- # "sub" wird nur gesetzt wenn bekannt (z.B. bei OmU-Sendungen)
- return program
- @staticmethod
- def _parse_xmltv_time(time_str: str) -> str:
- """
- Konvertiert XMLTV-Zeitformat in ISO 8601.
- XMLTV-Format: "YYYYMMDDHHmmss +HHMM"
- Ergebnis: ISO 8601 String mit Zeitzone, z.B. "2026-04-11T20:15:00+02:00"
- Args:
- time_str: Zeitstring im XMLTV-Format.
- Returns:
- ISO 8601 Zeitstring.
- Raises:
- ValueError: Bei ungueltigem Format.
- """
- match = _XMLTV_TIME_RE.match(time_str.strip())
- if not match:
- raise ValueError(f"Ungueltiges XMLTV-Zeitformat: {time_str}")
- year, month, day = int(match.group(1)), int(match.group(2)), int(match.group(3))
- hour, minute, second = int(match.group(4)), int(match.group(5)), int(match.group(6))
- tz_str = match.group(7)
- # Zeitzone parsen: "+0200" -> timedelta(hours=2)
- tz_sign = 1 if tz_str[0] == "+" else -1
- tz_hours = int(tz_str[1:3])
- tz_minutes = int(tz_str[3:5])
- tz_offset = timezone(timedelta(hours=tz_sign * tz_hours, minutes=tz_sign * tz_minutes))
- dt = datetime(year, month, day, hour, minute, second, tzinfo=tz_offset)
- return dt.isoformat()
- @staticmethod
- def _detect_type(categories: list[str], title: str) -> str:
- """
- Erkennt den Sendungstyp anhand der Kategorien und des Titels.
- Args:
- categories: Liste der XMLTV-Kategorien.
- title: Titel der Sendung.
- Returns:
- Typ-String: "film", "serie", "show", "doku", "sport",
- "news", "kids" oder "other".
- """
- # Kategorien normalisieren
- normalized_cats = {cat.lower() for cat in categories}
- # Priorisierte Erkennung
- if normalized_cats & _FILM_GENRES:
- return "film"
- if normalized_cats & _SERIE_GENRES:
- return "serie"
- if normalized_cats & _KIDS_GENRES:
- return "kids"
- if normalized_cats & _SPORT_GENRES:
- return "sport"
- if normalized_cats & _DOKU_GENRES:
- return "doku"
- if normalized_cats & _NEWS_GENRES:
- return "news"
- if normalized_cats & _SHOW_GENRES:
- return "show"
- # Heuristik: Anime oft als Kategorie erwaehnt
- if "anime" in normalized_cats:
- return "kids"
- return "other"
- @staticmethod
- def _parse_episode_ns(text: str) -> tuple[int | None, int | None]:
- """
- Parst xmltv_ns Episodennummerierung.
- Format: "season.episode." (0-basiert) -> +1 fuer menschliche Nummerierung.
- Args:
- text: Der xmltv_ns-String, z.B. "2.15."
- Returns:
- Tuple (season, episode), jeweils 1-basiert oder None.
- """
- parts = text.strip().split(".")
- season: int | None = None
- episode: int | None = None
- if len(parts) >= 1 and parts[0].strip():
- try:
- season = int(parts[0].strip()) + 1
- except ValueError:
- pass
- if len(parts) >= 2 and parts[1].strip():
- try:
- episode = int(parts[1].strip()) + 1
- except ValueError:
- pass
- return season, episode
|