From fd8491bc3902dcd474f3b4384b45cd2420c1ff2e Mon Sep 17 00:00:00 2001 From: Nirvana Date: Thu, 4 Dec 2025 12:33:25 +0100 Subject: [PATCH] Implement epg --- .../base/epg/epg_mapping.py | 150 ++++++++- resources/config/epg_mapping.json | 314 ++++++++++++++++-- 2 files changed, 424 insertions(+), 40 deletions(-) diff --git a/lib/streaming_providers/base/epg/epg_mapping.py b/lib/streaming_providers/base/epg/epg_mapping.py index b71a9ad..106bda5 100644 --- a/lib/streaming_providers/base/epg/epg_mapping.py +++ b/lib/streaming_providers/base/epg/epg_mapping.py @@ -16,6 +16,17 @@ class EPGMapping: Manages mapping between provider channel IDs and EPG channel IDs. Supports user-editable mapping with default fallback. + Mapping format (human-editable with channel names): + { + "provider_name": { + "_provider_name": "Display Name", + "channel_id": { + "epg_id": "tkmde_404", + "name": "Channel Display Name" + } + } + } + Uses VFS for all file operations. Automatically detects addon resources path for default mapping (only used on first run to copy defaults). """ @@ -31,7 +42,12 @@ class EPGMapping: # VFS for user data directory (where editable mapping lives) self.user_vfs = VFS(addon_subdir="") # Root of addon data + # Store the raw mapping from file + self.raw_mapping: Dict = {} + # Store the flattened provider->channel->epg_id mapping for fast lookups self.mapping: Dict[str, Dict[str, str]] = {} + # Store channel names for reference + self.channel_names: Dict[str, Dict[str, str]] = {} logger.info(f"EPGMapping: User mapping path: {self.user_vfs.base_path}") @@ -104,11 +120,23 @@ class EPGMapping: logger.error(f"EPGMapping: Failed to read/copy default mapping: {e}") # Fall through to create empty mapping - # No default found or copy failed - create empty mapping + # No default found or copy failed - create empty mapping with format info logger.warning("EPGMapping: No default mapping found, creating empty mapping") - empty_mapping = {} + empty_mapping = { + "_format_version": "1.0", + "_description": "EPG Channel Mapping - Maps provider channel IDs to XMLTV EPG channel IDs", + "_example": { + "provider_key": { + "_provider_name": "Provider Display Name (optional)", + "channel_id": { + "epg_id": "xmltv_channel_id", + "name": "Channel Display Name" + } + } + } + } if self.user_vfs.write_json(self.MAPPING_FILE, empty_mapping): - logger.info("EPGMapping: Created empty user mapping file") + logger.info("EPGMapping: Created empty user mapping file with examples") return True else: logger.error("EPGMapping: Failed to create empty user mapping") @@ -130,6 +158,85 @@ class EPGMapping: logger.info("EPGMapping: User mapping not found, initializing...") return self._copy_default_to_user() + @staticmethod + def _flatten_mapping(self, raw_mapping: Dict) -> tuple: + """ + Flatten mapping format to simple provider->channel->epg_id format for lookups. + Also extracts channel names for reference. + + Input format: + { + "provider_name": { + "_provider_name": "Display Name", + "channel_id": { + "epg_id": "tkmde_404", + "name": "Channel Name" + } + } + } + + Output format: + ( + { # mapping + "provider_name": { + "channel_id": "tkmde_404" + } + }, + { # channel_names + "provider_name": { + "channel_id": "Channel Name" + } + } + ) + + Args: + raw_mapping: Raw mapping from JSON file + + Returns: + Tuple of (flattened mapping dict, channel names dict) + """ + flattened = {} + names = {} + + for provider_name, provider_data in raw_mapping.items(): + # Skip metadata entries (those starting with underscore) + if provider_name.startswith('_'): + continue + + # Skip if not a dict (shouldn't happen, but be safe) + if not isinstance(provider_data, dict): + continue + + # Initialize provider dicts if needed + if provider_name not in flattened: + flattened[provider_name] = {} + names[provider_name] = {} + + # Process each channel in this provider + for channel_id, channel_data in provider_data.items(): + # Skip metadata entries + if channel_id.startswith('_'): + continue + + # Handle both dict format and simple string format + if isinstance(channel_data, dict): + # New format: { "epg_id": "...", "name": "..." } + epg_id = channel_data.get('epg_id') + name = channel_data.get('name', channel_id) + elif isinstance(channel_data, str): + # Legacy simple format: just the epg_id string + epg_id = channel_data + name = channel_id + else: + logger.warning(f"EPGMapping: Invalid format for {provider_name}/{channel_id}") + continue + + if epg_id: + flattened[provider_name][channel_id] = epg_id + names[provider_name][channel_id] = name + + return flattened, names + def _load_mapping(self) -> bool: """ Load mapping from user file. @@ -141,15 +248,25 @@ class EPGMapping: self._ensure_user_mapping_exists() # Load user mapping - user_mapping = self.user_vfs.read_json(self.MAPPING_FILE) - if user_mapping is not None: - self.mapping = user_mapping - logger.info(f"EPGMapping: Loaded mapping with {len(self.mapping)} providers") + raw_mapping = self.user_vfs.read_json(self.MAPPING_FILE) + if raw_mapping is not None: + self.raw_mapping = raw_mapping + + # Flatten the mapping for easy lookups + self.mapping, self.channel_names = self._flatten_mapping(raw_mapping) + + total_channels = sum(len(channels) for channels in self.mapping.values()) + logger.info( + f"EPGMapping: Loaded mapping with {len(self.mapping)} providers " + f"and {total_channels} channels" + ) return True # Failed to load mapping logger.warning("EPGMapping: Failed to load mapping, using empty mapping") + self.raw_mapping = {} self.mapping = {} + self.channel_names = {} return False def get_epg_channel_id(self, provider_name: str, channel_id: str) -> Optional[str]: @@ -161,7 +278,7 @@ class EPGMapping: channel_id: Channel ID within provider Returns: - EPG channel ID (e.g., "de.rtl"), or None if not mapped + EPG channel ID (e.g., "tkmde_404"), or None if not mapped """ # Get provider's mapping provider_mapping = self.mapping.get(provider_name) @@ -178,6 +295,23 @@ class EPGMapping: logger.debug(f"EPGMapping: Mapped '{provider_name}/{channel_id}' -> '{epg_id}'") return epg_id + def get_channel_name(self, provider_name: str, channel_id: str) -> Optional[str]: + """ + Get display name for a channel. + + Args: + provider_name: Name of provider + channel_id: Channel ID within provider + + Returns: + Channel display name, or None if not found + """ + provider_names = self.channel_names.get(provider_name) + if not provider_names: + return None + + return provider_names.get(channel_id) + def get_provider_mapping(self, provider_name: str) -> Dict[str, str]: """ Get all channel mappings for a specific provider. diff --git a/resources/config/epg_mapping.json b/resources/config/epg_mapping.json index 3409c69..e3f6989 100644 --- a/resources/config/epg_mapping.json +++ b/resources/config/epg_mapping.json @@ -1,42 +1,292 @@ { - "_comment": "EPG Channel Mapping - Maps provider/channel IDs to XMLTV EPG channel IDs", - "_format": "{ 'provider_name': { 'channel_id': 'epg_channel_id' } }", - "_note": "Provider names include country suffix for multi-country providers (e.g., joyn_de, joyn_ch)", + "_format_version": "1.0", + "_description": "EPG Channel Mapping - Maps provider channel IDs to XMLTV EPG channel IDs", "rtlplus": { - "rtl": "de.rtl", - "vox": "de.vox", - "rtl2": "de.rtl2", - "nitro": "de.nitro", - "super_rtl": "de.superrtl" + "_provider_name": "RTL+", + "rtl": { + "epg_id": "tkmde_404", + "name": "RTL" + } }, "joyn_de": { - "prosieben": "de.prosieben", - "sat1": "de.sat1", - "kabel_eins": "de.kabeleins", - "sixx": "de.sixx", - "pro7_maxx": "de.pro7maxx", - "sat1_gold": "de.sat1gold" + "_provider_name": "Joyn Germany", + "prosieben": { + "epg_id": "tkmde_374", + "name": "ProSieben" + }, + "sat1": { + "epg_id": "tkmde_381", + "name": "SAT.1" + }, + "kabel_eins": { + "epg_id": "tkmde_405", + "name": "Kabel Eins" + }, + "sat1_gold": { + "epg_id": "tkmde_338", + "name": "SAT.1 GOLD" + }, + "pro7_maxx": { + "epg_id": "tkmde_396", + "name": "ProSieben MAXX" + } }, - "joyn_ch": { - "prosieben_schweiz": "ch.prosieben", - "sat1_schweiz": "ch.sat1", - "kabel_eins_schweiz": "ch.kabeleins" - }, - - "zattoo_de": { - "ard": "de.ard", - "zdf": "de.zdf", - "rtl": "de.rtl", - "sat1": "de.sat1", - "prosieben": "de.prosieben" - }, - - "example_provider": { - "channel1": "epg.channel.one", - "channel2": "epg.channel.two", - "channel3": "epg.channel.three" + "magentaeu_at": { + "_provider_name": "Magenta TV Austria", + "206965288143": { + "epg_id": "tkmde_404", + "name": "RTL HD" + }, + "206964776235": { + "epg_id": "tkmde_374", + "name": "ProSieben HD" + }, + "206963752404": { + "epg_id": "tkmde_381", + "name": "SAT.1 HD" + }, + "206965800213": { + "epg_id": "tkmde_405", + "name": "Kabel Eins HD" + }, + "206963752441": { + "epg_id": "tkmde_338", + "name": "SAT.1 Gold HD" + }, + "206966311964": { + "epg_id": "tkmde_396", + "name": "ProSieben Maxx HD" + }, + "206963752417": { + "epg_id": "tkmde_382", + "name": "RTLZWEI HD" + }, + "206966311961": { + "epg_id": "tkmde_542", + "name": "RTLup HD" + }, + "207381032332": { + "epg_id": "tkmde_216", + "name": "RTL Crime HD" + }, + "207381032331": { + "epg_id": "tkmde_227", + "name": "RTL Living HD" + }, + "207382568087": { + "epg_id": "tkmde_209", + "name": "RTL Passion HD" + }, + "207382568112": { + "epg_id": "tkmde_224", + "name": "SAT.1 emotions HD" + }, + "206966824407": { + "epg_id": "tkmde_259", + "name": "ProSieben FUN HD" + }, + "206965288240": { + "epg_id": "tkmde_573", + "name": "Kabel Eins Doku HD" + }, + "207382568111": { + "epg_id": "tkmde_157", + "name": "kabel eins CLASSICS HD" + }, + "206964776277": { + "epg_id": "tkmde_373", + "name": "Das Erste HD" + }, + "206964776230": { + "epg_id": "tkmde_408", + "name": "ZDF HD" + }, + "206966312080": { + "epg_id": "tkmde_378", + "name": "ZDFinfo HD" + }, + "206965800291": { + "epg_id": "tkmde_391", + "name": "ZDFneo HD" + }, + "206966312188": { + "epg_id": "tkmde_387", + "name": "KiKA HD" + }, + "206965800302": { + "epg_id": "tkmde_402", + "name": "One HD" + }, + "206966312077": { + "epg_id": "tkmde_371", + "name": "phoenix HD" + }, + "207382568115": { + "epg_id": "tkmde_192", + "name": "Sky Sport 1" + }, + "207382568116": { + "epg_id": "tkmde_212", + "name": "Sky Sport 2" + }, + "207382568117": { + "epg_id": "tkmde_189", + "name": "Sky Sport 3" + }, + "207382568118": { + "epg_id": "tkmde_111", + "name": "Sky Sport 4" + }, + "207382568119": { + "epg_id": "tkmde_114", + "name": "Sky Sport 5" + }, + "207382568108": { + "epg_id": "tkmde_113", + "name": "Sky Sport 6" + }, + "207382568110": { + "epg_id": "tkmde_127", + "name": "Sky Sport 7" + }, + "207382568113": { + "epg_id": "tkmde_261", + "name": "Sky Sport 8" + }, + "207382568114": { + "epg_id": "tkmde_187", + "name": "Sky Sport 9" + }, + "301712936310": { + "epg_id": "tkmde_5554", + "name": "Sky Sport 10" + }, + "207382568125": { + "epg_id": "tkmde_42", + "name": "Sky Bundesliga" + }, + "207382568030": { + "epg_id": "tkmde_206", + "name": "Sky Bundesliga 1" + }, + "207381032273": { + "epg_id": "tkmde_211", + "name": "Sky Bundesliga 2" + }, + "207382568028": { + "epg_id": "tkmde_199", + "name": "Sky Bundesliga 3" + }, + "207381032275": { + "epg_id": "tkmde_232", + "name": "Sky Bundesliga 4" + }, + "207381032276": { + "epg_id": "tkmde_234", + "name": "Sky Bundesliga 5" + }, + "207381032277": { + "epg_id": "tkmde_231", + "name": "Sky Bundesliga 6" + }, + "207381032278": { + "epg_id": "tkmde_258", + "name": "Sky Bundesliga 7" + }, + "207381032279": { + "epg_id": "tkmde_219", + "name": "Sky Bundesliga 8" + }, + "207381032274": { + "epg_id": "tkmde_237", + "name": "Sky Bundesliga 9" + }, + "301713448071": { + "epg_id": "tkmde_5557", + "name": "Sky Bundesliga 10" + }, + "207382568163": { + "epg_id": "tkmde_4889", + "name": "Sky Sport F1" + }, + "301709352380": { + "epg_id": "tkmde_5558", + "name": "Sky Sport Golf" + }, + "207381032347": { + "epg_id": "tkmde_5556", + "name": "Sky Sport Mix" + }, + "206967848002": { + "epg_id": "tkmde_190", + "name": "Sky Sport News" + }, + "301712936303": { + "epg_id": "tkmde_5555", + "name": "Sky Sport Premier League" + }, + "207381032358": { + "epg_id": "tkmde_198", + "name": "Sky Sport Tennis" + }, + "306844200112": { + "epg_id": "tkmde_5577", + "name": "Sky Showcase" + }, + "207382568156": { + "epg_id": "tkmde_3756", + "name": "Sky Replay" + }, + "207381032386": { + "epg_id": "tkmde_576", + "name": "Sky One" + }, + "229429288190": { + "epg_id": "tkmde_5142", + "name": "Sky Nature" + }, + "207381032342": { + "epg_id": "tkmde_201", + "name": "Sky Krimi HD" + }, + "229429799984": { + "epg_id": "tkmde_5143", + "name": "Sky Documentaries" + }, + "207382568161": { + "epg_id": "tkmde_4924", + "name": "Sky Crime" + }, + "207381032340": { + "epg_id": "tkmde_195", + "name": "Sky Cinema Premiere" + }, + "207381032388": { + "epg_id": "tkmde_6210", + "name": "Sky Cinema Highlights" + }, + "207381032385": { + "epg_id": "tkmde_572", + "name": "Sky Cinema Family" + }, + "207382568093": { + "epg_id": "tkmde_200", + "name": "Sky Cinema Classics" + }, + "207381032334": { + "epg_id": "tkmde_40", + "name": "Sky Cinema Action" + }, + "207381032335": { + "epg_id": "tkmde_264", + "name": "Sky Atlantic" + }, + "207381032291": { + "epg_id": "tkmde_31", + "name": "Sky Sport Austria 1" + } } } \ No newline at end of file