From 281068e5315d70333bf090ef7bcf54537cb716ca Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Fri, 7 Aug 2026 15:00:15 +0200 Subject: [PATCH 1/6] Add EPG support with XMLTV parsing, caching and channel matching --- usr/lib/hypnotix/common.py | 30 ++- usr/lib/hypnotix/epg.py | 478 +++++++++++++++++++++++++++++++++++ usr/lib/hypnotix/hypnotix.py | 83 +++++- 3 files changed, 580 insertions(+), 11 deletions(-) create mode 100644 usr/lib/hypnotix/epg.py diff --git a/usr/lib/hypnotix/common.py b/usr/lib/hypnotix/common.py index 0e60b5f0..1e482218 100755 --- a/usr/lib/hypnotix/common.py +++ b/usr/lib/hypnotix/common.py @@ -2,6 +2,7 @@ import os import re import threading +from email.utils import formatdate import requests from gi.repository import GLib, GObject @@ -12,6 +13,7 @@ SERIES = re.compile(r"(?P.*?) S(?P.\d{1,2}).*E(?P.\d{1,2}.*)$", re.IGNORECASE) PROVIDERS_PATH = os.path.join(GLib.get_user_cache_dir(), "hypnotix", "providers") +EPG_PATH = os.path.join(GLib.get_user_cache_dir(), "hypnotix", "epg") TV_GROUP, MOVIES_GROUP, SERIES_GROUP = range(3) @@ -98,19 +100,27 @@ def __init__(self, provider, info): self.group_title = None self.title = None self.url = None + self.tvg_id = None + self.tvg_name = None + self.xmltv_id = None match = EXTINF.fullmatch(info) if match is not None: res = match.groupdict() if 'params' in res: params = dict(PARAMS.findall(res['params'])) + if "tvg-id" in params and params['tvg-id'].strip() != "": + self.tvg_id = params['tvg-id'].strip() if "tvg-name" in params and params['tvg-name'].strip() != "": self.name = params['tvg-name'].strip() + self.tvg_name = self.name if "tvg-logo" in params and params['tvg-logo'].strip() != "": self.logo = params['tvg-logo'].strip() if "group-title" in params and params['group-title'].strip() != "": self.group_title = params['group-title'].strip().replace(";", " ").replace(" ", " ") if 'title' in res: - self.title = res['title'] + self.title = res['title'].strip() + if self.title is None and "," in info: + self.title = info.split(",")[-1].strip() if self.name is None and "," in info: self.name = info.split(",")[-1].strip() if self.logo is not None: @@ -134,6 +144,7 @@ def __init__(self, provider, info): class Manager: def __init__(self, settings): os.system("mkdir -p '%s'" % PROVIDERS_PATH) + os.system("mkdir -p '%s'" % EPG_PATH) self.verbose = False self.settings = settings @@ -167,11 +178,19 @@ def get_playlist(self, provider, refresh=False) -> bool: 'User-Agent': self.settings.get_string("user-agent"), 'Referer': self.settings.get_string("http-referer") } + + if os.path.exists(provider.path): + mtime = os.path.getmtime(provider.path) + headers['If-Modified-Since'] = formatdate(mtime, usegmt=True) + try: response = requests.get(provider.url, headers=headers, timeout=(5, 120), stream=True) # If there is an answer from the remote server - if response.status_code == 200: + if response.status_code == 304: + print("HTTP 304 Not Modified: Using cached playlist.") + ret_code = True + elif response.status_code == 200: # Set downloaded size downloaded_bytes = 0 # Get total playlist byte size @@ -293,6 +312,13 @@ def load_channels(self, provider): else: provider.channels.append(channel) + def load_epg(self, provider, refresh=False): + if getattr(provider, "epg", None): + from epg import EPGManager + epg_mgr = EPGManager(user_agent=self.settings.get_string("user-agent")) + epg_mgr.load_epg_for_provider(provider, refresh=refresh) + provider.epg_manager = epg_mgr + def load_favorites(self): favorites = [] with open(FAVORITES_PATH, 'r', encoding="utf-8", errors="ignore") as f: diff --git a/usr/lib/hypnotix/epg.py b/usr/lib/hypnotix/epg.py new file mode 100644 index 00000000..2ae4f4b1 --- /dev/null +++ b/usr/lib/hypnotix/epg.py @@ -0,0 +1,478 @@ +import datetime +import gzip +import json +import os +import sqlite3 +import time +import urllib.request +import urllib.error +import xml.etree.ElementTree as ET +from email.utils import formatdate + +from common import EPG_PATH, async_function, slugify + + +def parse_xmltv_time(s: str) -> int: + """Converts XMLTV timestamp ('20260807143000 +0200') to a Unix epoch integer.""" + if not s: + return 0 + parts = s.strip().split() + dt_str = parts[0][:14] + try: + dt = datetime.datetime.strptime(dt_str, "%Y%m%d%H%M%S") + if len(parts) > 1: + tz_str = parts[1] + sign = 1 if tz_str[0] == "+" else -1 + hours = int(tz_str[1:3]) + mins = int(tz_str[3:5]) + offset = datetime.timedelta(hours=hours, minutes=mins) + if sign == 1: + dt -= offset + else: + dt += offset + return int(dt.replace(tzinfo=datetime.timezone.utc).timestamp()) + except Exception: + return 0 + + +import re + +class IPTVSimpleMatcher: + """ + Implements Kodi pvr.iptvsimple's channel-to-XMLTV matching algorithm, + with an added fallback pass to strip technical quality descriptors. + """ + + # Regex for common technical noise, quality tags, and codecs + TECH_TAGS_REGEX = re.compile( + r'\b(hd|sd|fhd|uhd|qhd|4k|8k|1080p|1080i|720p|576i|hevc|h264|h265|hdr|60fps|fps60)\b', + re.IGNORECASE + ) + + def __init__(self, xmltv_channels): + self.xml_by_id = {} + self.xml_by_display_name = {} + + for ch in xmltv_channels: + cid = ch.get("id") + if not cid or not cid.strip(): + continue + + cid_clean = cid.strip() + cid_lower = cid_clean.lower() + + if cid_lower not in self.xml_by_id: + self.xml_by_id[cid_lower] = cid_clean + + for name in ch.get("display_names", []): + if name and name.strip(): + name_lower = name.strip().lower() + if name_lower not in self.xml_by_display_name: + self.xml_by_display_name[name_lower] = cid_clean + + # Index stripped version of XMLTV display names + clean_name = self._strip_tech_info(name_lower) + if clean_name and clean_name not in self.xml_by_display_name: + self.xml_by_display_name[clean_name] = cid_clean + + @classmethod + def _strip_tech_info(cls, text: str) -> str: + """Removes technical tags (HD, FHD, 1080p, etc.) and clean residual punctuation.""" + if not text: + return "" + # remove anything inside parentheses/brackets (e.g. "(AC3 eng)") + clean = re.sub(r'\s*[(\[].*?[)\]]', '', text) + # Strip tech terms + clean = cls.TECH_TAGS_REGEX.sub('', clean) + # Clean up empty brackets, pipes, or leftover punctuation + clean = re.sub(r'[()\[\]{}|]', ' ', clean) + # Normalize whitespace + return ' '.join(clean.split()) + + def _match_candidate(self, candidate: str) -> str: + if not candidate: + return None + + clean = candidate.strip().lower() + if not clean: + return None + + # 1. Exact match against XMLTV channel ID + if clean in self.xml_by_id: + return self.xml_by_id[clean] + + # 2. Exact match against XMLTV display-name + if clean in self.xml_by_display_name: + return self.xml_by_display_name[clean] + + # 3. Match spaces converted to underscores ("bbc one" -> "bbc_one") + space_to_underscore = clean.replace(" ", "_") + if space_to_underscore in self.xml_by_display_name: + return self.xml_by_display_name[space_to_underscore] + + # 4. Match underscores converted to spaces ("bbc_one" -> "bbc one") + underscore_to_space = clean.replace("_", " ") + if underscore_to_space in self.xml_by_display_name: + return self.xml_by_display_name[underscore_to_space] + + # 5. Technical-stripped fallback pass ("BBC One HD [1080p]" -> "bbc one") + stripped = self._strip_tech_info(clean) + if stripped and stripped != clean: + if stripped in self.xml_by_display_name: + return self.xml_by_display_name[stripped] + + stripped_underscore = stripped.replace(" ", "_") + if stripped_underscore in self.xml_by_display_name: + return self.xml_by_display_name[stripped_underscore] + + return None + + def match_channel(self, tvg_id: str, tvg_name: str, m3u_title: str) -> str: + # Pass 1: tvg-id + match = self._match_candidate(tvg_id) + if match: + return match + + # Pass 2: tvg-name + match = self._match_candidate(tvg_name) + if match: + return match + + # Pass 3: M3U channel stream title + return self._match_candidate(m3u_title) + +class XMLTVParser: + """Stream-parses XMLTV data without loading the entire DOM into RAM.""" + + @staticmethod + def _open_source(file_path: str): + # Download URLs and cache filenames may not reflect the actual format. + with open(file_path, "rb") as source: + is_gzip = source.read(2) == b"\x1f\x8b" + if is_gzip: + return gzip.open(file_path, "rb") + return open(file_path, "rb") + + @classmethod + def parse_channels(cls, file_path: str): + """Pass 1: Parses only elements to enable early matching.""" + channels = [] + try: + with cls._open_source(file_path) as f: + for event, elem in ET.iterparse(f, events=("end",)): + if elem.tag == "channel": + cid = elem.attrib.get("id") + if cid and cid.strip(): + cid = cid.strip() + names = [e.text for e in elem.findall("display-name") if e.text] + channels.append({"id": cid, "display_names": names}) + elem.clear() + elif elem.tag == "programme": + elem.clear() + except Exception as e: + print(f"[EPG] Parse channels error ({file_path}): {e}") + + return channels + + @classmethod + def parse_programmes(cls, file_path: str, valid_xmltv_ids: set = None): + """Pass 2: Parses elements, pruning past shows and unmatched channels.""" + programmes = {} + now = int(time.time()) + + try: + with cls._open_source(file_path) as f: + for event, elem in ET.iterparse(f, events=("end",)): + if elem.tag == "programme": + cid = elem.attrib.get("channel") + if not cid: + elem.clear() + continue + + cid = cid.strip() + # Optimization: Ignore entries not matched to provider.channels + if valid_xmltv_ids is not None and cid not in valid_xmltv_ids: + elem.clear() + continue + + stop = parse_xmltv_time(elem.attrib.get("stop")) + # Optimization: Prune programmes where stop time is in the past + if stop <= now: + elem.clear() + continue + + start = parse_xmltv_time(elem.attrib.get("start")) + + title_elem = elem.find("title") + title = title_elem.text if title_elem is not None else "" + + desc_elem = elem.find("desc") + desc = desc_elem.text if desc_elem is not None else "" + + if start and title: + if cid not in programmes: + programmes[cid] = [] + programmes[cid].append({ + "start": start, + "stop": stop, + "title": title, + "desc": desc + }) + elem.clear() + elif elem.tag == "channel": + elem.clear() + except Exception as e: + print(f"[EPG] Parse programmes error ({file_path}): {e}") + + return programmes + +class EPGManager: + """Handles fetching, caching, matching, and querying EPG data.""" + + def __init__(self, user_agent="Hypnotix"): + self.user_agent = user_agent + self.programmes = {} # { xmltv_id: [program_dicts] } + + def resolve_epg_path(self, provider) -> str: + """Resolves provider.epg into a local file path.""" + epg_src = provider.epg.strip() if provider.epg else "" + if not epg_src: + return None + + # Local file URI or raw filesystem path + if epg_src.startswith("file://") or epg_src.startswith("/"): + path = epg_src[7:] if epg_src.startswith("file://") else epg_src + return os.path.expanduser(path) + + # Remote URL: cache locally in ~/.cache/hypnotix/epg/ + ext = ".xml.gz" if epg_src.endswith(".gz") else ".xml" + return os.path.join(EPG_PATH, f"{slugify(provider.name)}{ext}") + + def get_db_path(self, local_path: str) -> str: + """Returns the SQLite database cache path corresponding to the XML file.""" + return local_path + ".db" + + def is_cache_valid(self, local_path: str, ttl_hours: int = 24) -> bool: + """Checks if the local cache file exists and is newer than TTL.""" + if not local_path or not os.path.exists(local_path): + return False + mtime = os.path.getmtime(local_path) + return (time.time() - mtime) < (ttl_hours * 3600) + + def is_db_valid(self, db_path: str, xml_path: str) -> bool: + """Checks if the SQLite database exists and is newer than the XMLTV file.""" + if not db_path or not os.path.exists(db_path): + return False + if isinstance(xml_path, list): + for xp in xml_path: + if not os.path.exists(xp) or os.path.getmtime(db_path) < os.path.getmtime(xp): + return False + return True + else: + return os.path.exists(xml_path) and os.path.getmtime(db_path) >= os.path.getmtime(xml_path) + + def download_epg(self, epg_url: str, local_path: str) -> bool: + """Downloads a remote EPG XML file to the local cache path.""" + try: + req = urllib.request.Request(epg_url, headers={"User-Agent": self.user_agent}) + if os.path.exists(local_path): + mtime = os.path.getmtime(local_path) + req.add_header("If-Modified-Since", formatdate(mtime, usegmt=True)) + with urllib.request.urlopen(req, timeout=30) as resp, open(local_path, "wb") as out: + out.write(resp.read()) + return True + except urllib.error.HTTPError as e: + if e.code == 304: + print(f"[EPG] Not modified (304), using cached EPG for {epg_url}") + return True + print(f"[EPG] Download failed for {epg_url}: {e}") + return False + except Exception as e: + print(f"[EPG] Download failed for {epg_url}: {e}") + return False + + def save_to_db(self, db_path: str, channels: list, programmes: dict): + """Caches parsed XMLTV channels and programmes into SQLite.""" + try: + print(f"[EPG] Saving EPG cache to SQLite database: {db_path}") + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute("DROP TABLE IF EXISTS channels") + cursor.execute("DROP TABLE IF EXISTS programmes") + cursor.execute(""" + CREATE TABLE channels ( + id TEXT PRIMARY KEY, + display_names TEXT + ) + """) + cursor.execute(""" + CREATE TABLE programmes ( + channel_id TEXT, + start INTEGER, + stop INTEGER, + title TEXT, + desc TEXT, + UNIQUE(channel_id, start) + ) + """) + + ch_rows = [(ch["id"], json.dumps(ch["display_names"])) for ch in channels] + cursor.executemany("INSERT OR REPLACE INTO channels VALUES (?, ?)", ch_rows) + + prog_rows = [] + for cid, progs in programmes.items(): + for p in progs: + prog_rows.append((cid, p["start"], p["stop"], p["title"], p["desc"])) + cursor.executemany("INSERT OR IGNORE INTO programmes VALUES (?, ?, ?, ?, ?)", prog_rows) + + cursor.execute("CREATE INDEX idx_programmes_channel ON programmes(channel_id)") + cursor.execute("CREATE INDEX idx_programmes_stop ON programmes(stop)") + conn.commit() + conn.close() + except Exception as e: + print(f"[EPG] Failed to save SQLite cache ({db_path}): {e}") + + def load_channels_from_db(self, db_path: str) -> list: + """Loads channel definitions from SQLite database.""" + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute("SELECT id, display_names FROM channels") + channels = [] + for cid, names_json in cursor.fetchall(): + try: + names = json.loads(names_json) + except Exception: + names = [] + channels.append({"id": cid, "display_names": names}) + conn.close() + return channels + + def load_programmes_from_db(self, db_path: str, valid_xmltv_ids: set = None) -> dict: + """Loads non-expired EPG programmes from SQLite database.""" + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + now = int(time.time()) + cursor.execute("SELECT channel_id, start, stop, title, desc FROM programmes WHERE stop > ?", (now,)) + + programmes = {} + for cid, start, stop, title, desc in cursor.fetchall(): + if valid_xmltv_ids is not None and cid not in valid_xmltv_ids: + continue + if cid not in programmes: + programmes[cid] = [] + programmes[cid].append({ + "start": start, + "stop": stop, + "title": title, + "desc": desc + }) + conn.close() + return programmes + + def dump_matches_xml(self, provider): + """Dumps channel matching results to an XML file in the EPG cache folder.""" + root = ET.Element("epg_matches", provider=provider.name) + + for ch in provider.channels: + elem = ET.SubElement(root, "channel") + elem.attrib["name"] = ch.name or "" + elem.attrib["tvg_id"] = ch.tvg_id or "" + elem.attrib["tvg_name"] = ch.tvg_name or "" + elem.attrib["title"] = ch.title or "" + elem.attrib["xmltv_id"] = ch.xmltv_id or "" + elem.attrib["matched"] = "true" if ch.xmltv_id else "false" + + tree = ET.ElementTree(root) + ET.indent(tree, space=" ") + + dump_file = os.path.join(EPG_PATH, f"{slugify(provider.name)}_matches.xml") + tree.write(dump_file, encoding="utf-8", xml_declaration=True) + print(f"[EPG] Debug match XML dumped to: {dump_file}") + + def load_epg_for_provider(self, provider, refresh: bool = False): + """Fetches, loads, and matches EPG channels to provider.channels.""" + if not provider.epg: + return + + epg_urls = [u.strip() for u in provider.epg.replace(',', ' ').split() if u.strip()] + if not epg_urls: + return + + db_path = os.path.join(EPG_PATH, f"{slugify(provider.name)}.db") + xml_paths = [] + + for i, epg_url in enumerate(epg_urls): + local_path = os.path.join(EPG_PATH, f"{slugify(provider.name)}_{i}.xml.gz" if epg_url.endswith(".gz") else f"{slugify(provider.name)}_{i}.xml") + xml_paths.append(local_path) + + if epg_url.startswith("http://") or epg_url.startswith("https://"): + if refresh or not self.is_cache_valid(local_path): + print(f"[EPG] Downloading EPG {i+1}/{len(epg_urls)} for provider '{provider.name}'...") + self.download_epg(epg_url, local_path) + elif epg_url.startswith("file://") or epg_url.startswith("/"): + path = epg_url[7:] if epg_url.startswith("file://") else epg_url + xml_paths[-1] = os.path.expanduser(path) + + use_db = (not refresh) and self.is_db_valid(db_path, xml_paths) + + # Step 1: Get channels (from SQLite if valid, otherwise XML) + if use_db: + print(f"[EPG] Loading EPG channels from SQLite cache: {db_path}") + xml_channels = self.load_channels_from_db(db_path) + else: + xml_channels = [] + for xp in xml_paths: + if os.path.exists(xp): + print(f"[EPG] Parsing EPG channels from XML: {xp}") + xml_channels.extend(XMLTVParser.parse_channels(xp)) + + # Step 2: Match M3U channels with XMLTV channels + matcher = IPTVSimpleMatcher(xml_channels) + matched_count = 0 + for channel in provider.channels: + channel.xmltv_id = matcher.match_channel(channel.tvg_id, channel.tvg_name, channel.title) + if channel.xmltv_id: + matched_count += 1 + + print(f"[EPG] Matched {matched_count}/{len(provider.channels)} channels for provider '{provider.name}'") + + # Step 3: Build the filter set of active XMLTV IDs + valid_xmltv_ids = {ch.xmltv_id for ch in provider.channels if ch.xmltv_id} + + # Step 4: Load programmes from SQLite or parse from XML and build SQLite cache + if use_db: + print(f"[EPG] Loading EPG programmes from SQLite cache for {len(valid_xmltv_ids)} matched channels...") + self.programmes = self.load_programmes_from_db(db_path, valid_xmltv_ids=valid_xmltv_ids) + else: + print(f"[EPG] Parsing full EPG programmes from XML(s) to build database cache...") + all_programmes = {} + for xp in xml_paths: + if os.path.exists(xp): + progs = XMLTVParser.parse_programmes(xp, valid_xmltv_ids=None) + for cid, p_list in progs.items(): + if cid not in all_programmes: + all_programmes[cid] = [] + all_programmes[cid].extend(p_list) + self.save_to_db(db_path, xml_channels, all_programmes) + self.programmes = {cid: progs for cid, progs in all_programmes.items() if cid in valid_xmltv_ids} + + # Dump debug report + self.dump_matches_xml(provider) + + def get_current_and_next(self, xmltv_id: str): + """Returns (now_playing, next_up) dicts for a channel at the current time.""" + if not xmltv_id or xmltv_id not in self.programmes: + return None, None + + now = int(time.time()) + now_playing = None + next_up = None + + for prog in self.programmes[xmltv_id]: + if prog["start"] <= now < prog["stop"]: + now_playing = prog + elif prog["start"] >= now: + if next_up is None or prog["start"] < next_up["start"]: + next_up = prog + + return now_playing, next_up diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index 57696ec9..15137cd8 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -89,20 +89,44 @@ def __init__(self, channel, logo, **kwargs): self.set_tooltip_text(channel.name) frame = Gtk.Frame() - label = Gtk.Label(channel.name) - label.set_max_width_chars(30) - label.set_ellipsize(Pango.EllipsizeMode.END) + self.name_label = Gtk.Label(channel.name) + self.name_label.set_max_width_chars(30) + self.name_label.set_ellipsize(Pango.EllipsizeMode.END) + self.name_label.set_no_show_all(True) + + self.epg_label = Gtk.Label() + self.epg_label.set_max_width_chars(30) + self.epg_label.set_ellipsize(Pango.EllipsizeMode.END) + self.epg_label.get_style_context().add_class("dim-label") + self.epg_label.set_no_show_all(True) + + logo.set_halign(Gtk.Align.FILL) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, border_width=6) - box.pack_start(logo, False, False, 0) - box.pack_start(label, False, False, 0) + box.pack_start(logo, True, True, 0) + box.pack_start(self.name_label, False, False, 0) + box.pack_start(self.epg_label, False, False, 0) box.set_spacing(6) frame.add(box) self.add(frame) + self.name_label.show() @property def channel(self): return self._channel + def update_epg(self, epg_manager): + if epg_manager and getattr(self._channel, "xmltv_id", None): + now_playing, _ = epg_manager.get_current_and_next(self._channel.xmltv_id) + if now_playing and "title" in now_playing: + title = now_playing["title"] + self.epg_label.set_text(title) + self.epg_label.show() + self.name_label.hide() + return + self.epg_label.hide() + self.name_label.show() + class MyApplication(Gtk.Application): # Main initialization routine def __init__(self, application_id, flags): @@ -275,6 +299,7 @@ def __init__(self, application): setattr(self, name, widget) # Widget signals + self.window.connect("delete-event", self.on_window_delete) self.window.connect("key-press-event", self.on_key_press_event) self.window.connect("window-state-event", self.on_window_state_event) self.mpv_drawing_area.connect("realize", self.on_mpv_drawing_area_realize) @@ -409,6 +434,9 @@ def __init__(self, application): # This is going to get readjusted self._timerid = GLib.timeout_add_seconds(self.reload_timeout_sec, self.force_reload) + # Refresh EPG show titles every 60 seconds + GLib.timeout_add_seconds(60, self.update_epg_labels) + self.window.show() self.playback_bar.hide() self.search_bar.hide() @@ -544,12 +572,23 @@ def show_channels(self, channels, favorites=False): self.channels_listbox.add(ChannelWidget(channel, image)) self.channels_listbox.show_all() + self.update_epg_labels() self.visible_search_results = len(self.channels_listbox.get_children()) if len(logos_to_refresh) > 0: self.download_channel_logos(logos_to_refresh) else: self.sidebar.hide() + @idle_function + def update_epg_labels(self): + if not self.active_provider or not getattr(self.active_provider, "epg_manager", None): + return True + epg_mgr = self.active_provider.epg_manager + for widget in self.channels_listbox.get_children(): + if isinstance(widget, ChannelWidget): + widget.update_epg(epg_mgr) + return True + def show_vod(self, items): logos_to_refresh = [] self.navigate_to("vod_page") @@ -1291,11 +1330,11 @@ def set_provider_type(self, type_id): for widget in widgets: widget.hide() visible_widgets = [] + visible_widgets.append(self.epg_label) + visible_widgets.append(self.epg_entry) if type_id == PROVIDER_TYPE_URL: visible_widgets.append(self.url_entry) visible_widgets.append(self.url_label) - visible_widgets.append(self.epg_label) - visible_widgets.append(self.epg_entry) elif type_id == PROVIDER_TYPE_LOCAL: visible_widgets.append(self.path_entry) visible_widgets.append(self.path_label) @@ -1307,8 +1346,6 @@ def set_provider_type(self, type_id): visible_widgets.append(self.username_label) visible_widgets.append(self.password_entry) visible_widgets.append(self.password_label) - visible_widgets.append(self.epg_label) - visible_widgets.append(self.epg_entry) else: print("Incorrect provider type: ", type_id) @@ -1464,6 +1501,22 @@ def close(w, res): dlg.show() def on_menu_quit(self, widget): + self.cleanup_and_quit() + + def on_window_delete(self, widget, event): + self.cleanup_and_quit() + return False + + def cleanup_and_quit(self): + if self._timerid > 0: + GLib.source_remove(self._timerid) + self._timerid = -1 + if self.mpv is not None: + try: + self.mpv.stop() + self.mpv.terminate() + except Exception: + pass self.application.quit() def on_key_press_event(self, widget, event): @@ -1549,6 +1602,10 @@ def reload(self, page=None, refresh=False): self.manager.load_channels(provider) if provider.name == self.settings.get_string("active-provider"): self.active_provider = provider + if provider.epg: + self.status(_("Loading EPG..."), provider) + self.manager.load_epg(provider, refresh=refresh) + GLib.idle_add(self.update_epg_labels) self.status(None) print("%s: %d channels, %d groups, %d series, %d movies" % (provider.name, \ len(provider.channels), len(provider.groups), len(provider.series), len(provider.movies))) @@ -1592,6 +1649,10 @@ def reload(self, page=None, refresh=False): # If no errors, approve provider if provider.name == self.settings.get_string("active-provider"): self.active_provider = provider + if provider.epg: + self.status(_("Loading EPG..."), provider) + self.manager.load_epg(provider, refresh=refresh) + GLib.idle_add(self.update_epg_labels) self.status(None) else: print("XTREAM Authentication Failed") @@ -1604,6 +1665,10 @@ def reload(self, page=None, refresh=False): # If there are more than 1 providers and no Active Provider, set to the first one if len(self.providers) > 0 and self.active_provider is None: self.active_provider = self.providers[0] + if self.active_provider.epg and not hasattr(self.active_provider, "epg_manager"): + self.status(_("Loading EPG..."), self.active_provider) + self.manager.load_epg(self.active_provider, refresh=refresh) + GLib.idle_add(self.update_epg_labels) self.refresh_providers_page() From 5aa96a90bc9fdeb8a3eb40c906c1fb62c5259867 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Fri, 7 Aug 2026 18:21:44 +0200 Subject: [PATCH 2/6] Add EPG channel menu and Key-G current program info --- usr/lib/hypnotix/hypnotix.py | 200 ++++++++++++++++++++++++++++++++++- 1 file changed, 197 insertions(+), 3 deletions(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index 15137cd8..3454c266 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -1,6 +1,8 @@ #!/usr/bin/python3 import gettext import locale +import datetime +import textwrap import os import shutil import sys @@ -83,9 +85,10 @@ class ChannelWidget(Gtk.ListBoxRow): """ A custom widget for displaying and holding channel data. """ - def __init__(self, channel, logo, **kwargs): + def __init__(self, channel, logo, main_window=None, **kwargs): super().__init__(**kwargs) self._channel = channel + self.main_window = main_window self.set_tooltip_text(channel.name) frame = Gtk.Frame() @@ -108,8 +111,15 @@ def __init__(self, channel, logo, **kwargs): box.pack_start(self.epg_label, False, False, 0) box.set_spacing(6) frame.add(box) - self.add(frame) + + # Wrap frame in an EventBox so right-click events are captured cleanly + event_box = Gtk.EventBox() + event_box.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) + event_box.add(frame) + self.add(event_box) + self.name_label.show() + event_box.connect("button-press-event", self.on_button_press) @property def channel(self): @@ -127,6 +137,113 @@ def update_epg(self, epg_manager): self.epg_label.hide() self.name_label.show() + def on_button_press(self, widget, event): + if event.type == Gdk.EventType.BUTTON_PRESS and event.button == 3: # Right-click + menu = Gtk.Menu() + + epg_mgr = None + if self.main_window and self.main_window.active_provider: + epg_mgr = getattr(self.main_window.active_provider, "epg_manager", None) + + xmltv_id = getattr(self._channel, "xmltv_id", None) + + # 1. Info Entry + info_item = Gtk.MenuItem(label=_("Info")) + now_playing, next_up = (None, None) + if epg_mgr and xmltv_id: + now_playing, next_up = epg_mgr.get_current_and_next(xmltv_id) + + if not now_playing and not next_up: + info_item.set_sensitive(False) + else: + + info_menu = Gtk.Menu() + added_section = False + for title_hdr, prog in [(_("Now Playing"), now_playing), (_("Next Up"), next_up)]: + if prog: + if added_section: + info_menu.append(Gtk.SeparatorMenuItem()) + added_section = True + + start_str = datetime.datetime.fromtimestamp(prog["start"]).strftime("%H:%M") + stop_str = datetime.datetime.fromtimestamp(prog["stop"]).strftime("%H:%M") + title = prog.get("title", "") + + header_label = _("%s (%s - %s): %s") % (title_hdr, start_str, stop_str, title) + header_item = Gtk.MenuItem(label=header_label) + header_item.set_sensitive(False) + info_menu.append(header_item) + + desc = prog.get("desc", "").strip() + if desc: + wrapped_desc = "\n".join(textwrap.wrap(desc, width=60)) + desc_item = Gtk.MenuItem(label=wrapped_desc) + desc_item.set_sensitive(False) + info_menu.append(desc_item) + else: + desc_item = Gtk.MenuItem(label=_("No description available")) + desc_item.set_sensitive(False) + info_menu.append(desc_item) + + info_item.set_submenu(info_menu) + + menu.append(info_item) + + # 2. Guide Entry + guide_item = Gtk.MenuItem(label=_("Guide")) + programmes = [] + if epg_mgr and xmltv_id: + programmes = epg_mgr.programmes.get(xmltv_id, []) + + if not programmes: + guide_item.set_sensitive(False) + else: + guide_menu = Gtk.Menu() + last_date = None + for prog in programmes: + prog_dt = datetime.datetime.fromtimestamp(prog["start"]) + prog_date = prog_dt.date() + + # Insert a date separator whenever the day changes + if prog_date != last_date: + if last_date is not None: + guide_menu.append(Gtk.SeparatorMenuItem()) + date_str = prog_dt.strftime("%A, %d %b %Y") + date_header = Gtk.MenuItem(label=_("--- %s ---") % date_str) + date_header.set_sensitive(False) + guide_menu.append(date_header) + last_date = prog_date + + start_str = prog_dt.strftime("%H:%M") + stop_str = datetime.datetime.fromtimestamp(prog["stop"]).strftime("%H:%M") + title = prog.get("title", "") + + prog_label = _("%s - %s %s") % (start_str, stop_str, title) + prog_item = Gtk.MenuItem(label=prog_label) + + desc_menu = Gtk.Menu() + desc = prog.get("desc", "").strip() + if desc: + wrapped_desc = "\n".join(textwrap.wrap(desc, width=60)) + desc_item = Gtk.MenuItem(label=wrapped_desc) + desc_item.set_sensitive(False) + desc_menu.append(desc_item) + else: + desc_item = Gtk.MenuItem(label=_("No description available")) + desc_item.set_sensitive(False) + desc_menu.append(desc_item) + + prog_item.set_submenu(desc_menu) + guide_menu.append(prog_item) + + guide_item.set_submenu(guide_menu) + + menu.append(guide_item) + menu.show_all() + menu.popup_at_pointer(event) + return True + return False + class MyApplication(Gtk.Application): # Main initialization routine def __init__(self, application_id, flags): @@ -569,7 +686,7 @@ def show_channels(self, channels, favorites=False): for channel in channels: image = Gtk.Image().new_from_surface(self.get_channel_surface(channel.logo_path)) logos_to_refresh.append((channel, image)) - self.channels_listbox.add(ChannelWidget(channel, image)) + self.channels_listbox.add(ChannelWidget(channel, image, main_window=self)) self.channels_listbox.show_all() self.update_epg_labels() @@ -589,6 +706,61 @@ def update_epg_labels(self): widget.update_epg(epg_mgr) return True + def show_channel_epg_info(self, channel): + """Displays EPG details for current and next program of a channel.""" + dialog = Gtk.Dialog(title=_("Channel Info - %s") % channel.name, transient_for=self.window, flags=0) + dialog.add_button(_("Close"), Gtk.ResponseType.CLOSE) + dialog.set_default_size(450, 300) + + box = dialog.get_content_area() + box.set_spacing(12) + box.set_border_width(12) + + epg_mgr = getattr(self.active_provider, "epg_manager", None) if self.active_provider else None + now_playing, next_up = (None, None) + if epg_mgr and getattr(channel, "xmltv_id", None): + now_playing, next_up = epg_mgr.get_current_and_next(channel.xmltv_id) + + if not now_playing and not next_up: + label = Gtk.Label(label=_("No EPG information available for this channel.")) + box.pack_start(label, True, True, 0) + else: + grid = Gtk.Grid(column_spacing=12, row_spacing=8) + row = 0 + + for title_hdr, prog in [(_("Now Playing"), now_playing), (_("Next Up"), next_up)]: + if prog: + start_str = datetime.datetime.fromtimestamp(prog["start"]).strftime("%H:%M") + stop_str = datetime.datetime.fromtimestamp(prog["stop"]).strftime("%H:%M") + + hdr_label = Gtk.Label() + hdr_label.set_markup(_("%s (%s - %s):") % (title_hdr, start_str, stop_str)) + hdr_label.set_xalign(0) + grid.attach(hdr_label, 0, row, 1, 1) + row += 1 + + title_lbl = Gtk.Label(label=prog.get("title", "")) + title_lbl.set_xalign(0) + grid.attach(title_lbl, 0, row, 1, 1) + row += 1 + + if prog.get("desc"): + desc_lbl = Gtk.Label(label=prog["desc"]) + desc_lbl.set_xalign(0) + desc_lbl.set_line_wrap(True) + desc_lbl.get_style_context().add_class("dim-label") + grid.attach(desc_lbl, 0, row, 1, 1) + row += 1 + + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scrolled.add(grid) + box.pack_start(scrolled, True, True, 0) + + dialog.show_all() + dialog.run() + dialog.destroy() + def show_vod(self, items): logos_to_refresh = [] self.navigate_to("vod_page") @@ -932,6 +1104,8 @@ def on_next_channel(self): @async_function def play_async(self, channel): if self.mpv is not None: + self.mpv.command("show-text", "", 1) + self.osd_epg_visible_until = 0 self.mpv.stop() self.mpv.pause = False print("CHANNEL: '%s' (%s)" % (channel.name, channel.url)) @@ -1531,6 +1705,26 @@ def on_key_press_event(self, widget, event): ctrl = modifier == Gdk.ModifierType.CONTROL_MASK shift = modifier == Gdk.ModifierType.SHIFT_MASK + if event.keyval == Gdk.KEY_g and not isinstance(widget.get_focus(), Gtk.Entry): + # If the EPG is currently showing, toggle it off + if getattr(self, "osd_epg_visible_until", 0) > time.time(): + self.mpv.command("show-text", "", 1) + self.osd_epg_visible_until = 0 + else: + # If it's hidden, fetch the info and toggle it on + if self.active_channel and self.active_provider and getattr(self.active_provider, "epg_manager", None): + xmltv_id = getattr(self.active_channel, "xmltv_id", None) + if xmltv_id: + now_playing, _ = self.active_provider.epg_manager.get_current_and_next(xmltv_id) + if now_playing: + start_str = datetime.datetime.fromtimestamp(now_playing["start"]).strftime("%H:%M") + stop_str = datetime.datetime.fromtimestamp(now_playing["stop"]).strftime("%H:%M") + title = now_playing.get("title", "") + osd_text = f"{self.active_channel.name}\n{title}\n{start_str} - {stop_str}" + self.mpv.command("show-text", osd_text, 6000) + self.osd_epg_visible_until = time.time() + 6 + return True + if ctrl and event.keyval == Gdk.KEY_r: self.reload(page=None, refresh=True) elif ctrl and event.keyval == Gdk.KEY_f: From 52f6aa4a41704a59f6b4532ddb17f7b470bcbb37 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Sat, 8 Aug 2026 17:55:12 +0200 Subject: [PATCH 3/6] Load EPG asynchronously and support it for favourite channels --- usr/lib/hypnotix/epg.py | 11 +- usr/lib/hypnotix/hypnotix.py | 176 ++++++++++++++++-- .../schemas/org.x.hypnotix.gschema.xml | 5 + usr/share/hypnotix/hypnotix.ui | 25 +++ 4 files changed, 201 insertions(+), 16 deletions(-) diff --git a/usr/lib/hypnotix/epg.py b/usr/lib/hypnotix/epg.py index 2ae4f4b1..9ca60ef3 100644 --- a/usr/lib/hypnotix/epg.py +++ b/usr/lib/hypnotix/epg.py @@ -52,6 +52,7 @@ class IPTVSimpleMatcher: def __init__(self, xmltv_channels): self.xml_by_id = {} self.xml_by_display_name = {} + self.name_by_id = {} for ch in xmltv_channels: cid = ch.get("id") @@ -64,7 +65,11 @@ def __init__(self, xmltv_channels): if cid_lower not in self.xml_by_id: self.xml_by_id[cid_lower] = cid_clean - for name in ch.get("display_names", []): + names = ch.get("display_names", []) + if names and names[0]: + self.name_by_id[cid_clean] = names[0] + + for name in names: if name and name.strip(): name_lower = name.strip().lower() if name_lower not in self.xml_by_display_name: @@ -433,6 +438,10 @@ def load_epg_for_provider(self, provider, refresh: bool = False): channel.xmltv_id = matcher.match_channel(channel.tvg_id, channel.tvg_name, channel.title) if channel.xmltv_id: matched_count += 1 + epg_name = matcher.name_by_id.get(channel.xmltv_id) + if epg_name: + channel.tvg_name = epg_name + channel.name = epg_name print(f"[EPG] Matched {matched_count}/{len(provider.channels)} channels for provider '{provider.name}'") diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index 3454c266..0bc2e265 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -142,8 +142,9 @@ def on_button_press(self, widget, event): menu = Gtk.Menu() epg_mgr = None - if self.main_window and self.main_window.active_provider: - epg_mgr = getattr(self.main_window.active_provider, "epg_manager", None) + provider = getattr(self._channel, "provider", None) or (self.main_window.active_provider if self.main_window else None) + if provider: + epg_mgr = getattr(provider, "epg_manager", None) xmltv_id = getattr(self._channel, "xmltv_id", None) @@ -381,6 +382,7 @@ def __init__(self, application): "useragent_entry", "referer_entry", "mpv_entry", + "favorites_epg_entry", "mpv_link", "ytdlp_local_switch", "ytdlp_system_version_label", @@ -473,6 +475,7 @@ def __init__(self, application): self.bind_setting_widget("user-agent", self.useragent_entry) self.bind_setting_widget("http-referer", self.referer_entry) self.bind_setting_widget("mpv-options", self.mpv_entry) + self.bind_setting_widget("favorites-epg", self.favorites_epg_entry) # ytdlp self.ytdlp_local_switch.set_active(self.settings.get_boolean("use-local-ytdlp")) @@ -668,13 +671,143 @@ def on_category_button_clicked(self, widget, group): def show_favorites(self, widget=None): self.content_type = TV_GROUP channels = [] + + if not getattr(self, "fav_provider", None): + self.fav_provider = Provider("Favorites", None) + self.dynamic_fav_providers = {} + + new_epg = self.settings.get_string("favorites-epg").strip() + if getattr(self.fav_provider, "epg", None) != new_epg: + self.fav_provider.epg = new_epg + if hasattr(self.fav_provider, "epg_manager"): + delattr(self.fav_provider, "epg_manager") + + self.fav_provider.channels = [] + for dp in self.dynamic_fav_providers.values(): + dp.channels = [] + + providers_to_load = [] + for line in self.favorite_data: - info, url = line.split(":::") - channel = Channel(None, info) + line = line.strip() + if not line: + continue + parts = line.split(":::") + info, url = parts[0], parts[1] + + channel = Channel(self.fav_provider, info) channel.url = url + channel.provider = self.fav_provider + if len(parts) > 3: + channel.xmltv_id = parts[3] + + matched_existing = False + if len(parts) > 2 and parts[2]: + epg_url = parts[2] + for p in self.providers: + if getattr(p, "epg", None) == epg_url: + channel.provider = p + matched_existing = True + if p not in providers_to_load and not hasattr(p, "epg_manager"): + providers_to_load.append(p) + break + + # Add manual/unmatched channels to fav_provider so it can map their EPG + if not matched_existing: + if len(parts) > 2 and parts[2].strip(): + epg_url = parts[2].strip() + if epg_url not in self.dynamic_fav_providers: + dp = Provider(f"Favorites-Custom-{len(self.dynamic_fav_providers)}", None) + dp.epg = f"{epg_url} {self.fav_provider.epg}".strip() + self.dynamic_fav_providers[epg_url] = dp + providers_to_load.append(dp) + dp = self.dynamic_fav_providers[epg_url] + channel.provider = dp + dp.channels.append(channel) + else: + # Add manual/unmatched channels to fav_provider so it can map their EPG + self.fav_provider.channels.append(channel) + channels.append(channel) + + # Queue the fav_provider to load its EPG database asynchronously + if self.fav_provider.channels and self.fav_provider.epg and not hasattr(self.fav_provider, "epg_manager"): + providers_to_load.append(self.fav_provider) + + if providers_to_load: + self.load_epgs_async(providers_to_load) + self.show_channels(channels, favorites=True) + @async_function + def load_epgs_async(self, providers): + for p in providers: + self.manager.load_epg(p) + + # Trigger an idle callback to save the newly discovered matches permanently + GLib.idle_add(self.save_matched_favorites_idle, providers) + GLib.idle_add(self.update_epg_labels) + + def save_matched_favorites_idle(self, providers): + import re + fav_provider = next((p for p in providers if p.name == "Favorites"), None) + if not fav_provider: + return False + + updated = False + new_fav_data = [] + + for line in self.favorite_data: + line = line.strip() + if not line: + continue + + parts = line.split(":::") + info, url = parts[0], parts[1] + + # Find the corresponding channel to see if the EPG Manager assigned it an xmltv_id + matched_channel = next((c for c in fav_provider.channels if c.url == url), None) + + if matched_channel and matched_channel.xmltv_id: + # If the tvg-id in the EXTINF string doesn't perfectly match the resolved xmltv_id, rewrite it + if f'tvg-id="{matched_channel.xmltv_id}"' not in info: + + # Update or inject tvg-id + if 'tvg-id="' in info: + info = re.sub(r'tvg-id="[^"]*"', f'tvg-id="{matched_channel.xmltv_id}"', info) + else: + info = info.replace('#EXTINF:-1', f'#EXTINF:-1 tvg-id="{matched_channel.xmltv_id}"', 1) + + # Update or inject tvg-name as requested + new_tvg_name = matched_channel.tvg_name or matched_channel.name + if 'tvg-name="' in info: + info = re.sub(r'tvg-name="[^"]*"', f'tvg-name="{new_tvg_name}"', info) + else: + info = info.replace('#EXTINF:-1', f'#EXTINF:-1 tvg-name="{new_tvg_name}"', 1) + + parts[0] = info + + # Update the explicit list indices for EPG url and XMLTV ID + epg_url = getattr(matched_channel.provider, "epg", "") or "" + if len(parts) == 2: + parts.extend([epg_url, matched_channel.xmltv_id]) + elif len(parts) == 3: + parts.append(matched_channel.xmltv_id) + else: + parts[3] = matched_channel.xmltv_id + + line = ":::".join(parts) + updated = True + + new_fav_data.append(line) + + # Only flush to disk if a new match was injected + if updated: + self.favorite_data = new_fav_data + self.manager.save_favorites(self.favorite_data) + + return False + def show_channels(self, channels, favorites=False): self.navigate_to("channels_page", "", favorites) if self.content_type == TV_GROUP: @@ -698,12 +831,13 @@ def show_channels(self, channels, favorites=False): @idle_function def update_epg_labels(self): - if not self.active_provider or not getattr(self.active_provider, "epg_manager", None): - return True - epg_mgr = self.active_provider.epg_manager for widget in self.channels_listbox.get_children(): if isinstance(widget, ChannelWidget): - widget.update_epg(epg_mgr) + provider = getattr(widget.channel, "provider", None) or getattr(self, "active_provider", None) + if provider and getattr(provider, "epg_manager", None): + widget.update_epg(provider.epg_manager) + else: + widget.update_epg(None) return True def show_channel_epg_info(self, channel): @@ -716,7 +850,8 @@ def show_channel_epg_info(self, channel): box.set_spacing(12) box.set_border_width(12) - epg_mgr = getattr(self.active_provider, "epg_manager", None) if self.active_provider else None + provider = getattr(channel, "provider", None) or getattr(self, "active_provider", None) + epg_mgr = getattr(provider, "epg_manager", None) if provider else None now_playing, next_up = (None, None) if epg_mgr and getattr(channel, "xmltv_id", None): now_playing, next_up = epg_mgr.get_current_and_next(channel.xmltv_id) @@ -1069,17 +1204,26 @@ def on_favorite_button_toggled(self, widget): return name = self.active_channel.name - data = f"{self.active_channel.info}:::{self.active_channel.url}" - if widget.get_active() and data not in self.favorite_data: + + data_prefix = f"{self.active_channel.info}:::{self.active_channel.url}" + existing_match = next((fav for fav in self.favorite_data if fav == data_prefix or fav.startswith(data_prefix + ":::")), None) + + if widget.get_active() and not existing_match: print (f"Adding {name} to favorites") + epg_url = "" + xmltv_id = getattr(self.active_channel, "xmltv_id", "") + provider = getattr(self.active_channel, "provider", None) or getattr(self, "active_provider", None) + if provider and getattr(provider, "epg", None): + epg_url = provider.epg + data = f"{data_prefix}:::{epg_url}:::{xmltv_id}" self.favorite_data.append(data) # Dynamically update the tooltip self.favorite_button.set_tooltip_text(_("Remove from favorites")) - elif widget.get_active() == False and data in self.favorite_data: + elif widget.get_active() == False and existing_match: print (f"Removing {name} from favorites") - self.favorite_data.remove(data) + self.favorite_data.remove(existing_match) # Dynamically update the tooltip self.favorite_button.set_tooltip_text(_("Add to favorites")) @@ -1376,6 +1520,7 @@ def open_new_channel(self, widget): self.new_name_entry.set_text("") self.new_url_entry.set_text("") self.new_logo_entry.set_text("") + self.favorites_epg_entry.set_text("") self.new_ok_button.set_sensitive(False) self.navigate_to("new_channel_page") @@ -1712,10 +1857,11 @@ def on_key_press_event(self, widget, event): self.osd_epg_visible_until = 0 else: # If it's hidden, fetch the info and toggle it on - if self.active_channel and self.active_provider and getattr(self.active_provider, "epg_manager", None): + provider = getattr(self.active_channel, "provider", None) or getattr(self, "active_provider", None) + if provider and getattr(provider, "epg_manager", None): xmltv_id = getattr(self.active_channel, "xmltv_id", None) if xmltv_id: - now_playing, _ = self.active_provider.epg_manager.get_current_and_next(xmltv_id) + now_playing, _ = provider.epg_manager.get_current_and_next(xmltv_id) if now_playing: start_str = datetime.datetime.fromtimestamp(now_playing["start"]).strftime("%H:%M") stop_str = datetime.datetime.fromtimestamp(now_playing["stop"]).strftime("%H:%M") diff --git a/usr/share/glib-2.0/schemas/org.x.hypnotix.gschema.xml b/usr/share/glib-2.0/schemas/org.x.hypnotix.gschema.xml index 0ffd7b3d..5e67bef6 100644 --- a/usr/share/glib-2.0/schemas/org.x.hypnotix.gschema.xml +++ b/usr/share/glib-2.0/schemas/org.x.hypnotix.gschema.xml @@ -21,6 +21,11 @@ Provider selected by default + + "" + EPG urls for favorites + + ['Free-TV:::url:::https://raw.githubusercontent.com/Free-TV/IPTV/master/playlist.m3u8:::::::::'] Format: name:::type:::url(or path):::username:::password:::epg diff --git a/usr/share/hypnotix/hypnotix.ui b/usr/share/hypnotix/hypnotix.ui index 54a2ab98..63925b58 100644 --- a/usr/share/hypnotix/hypnotix.ui +++ b/usr/share/hypnotix/hypnotix.ui @@ -1779,6 +1779,31 @@ 1 + + + True + False + start + center + Favorites EPG URL + + + 0 + 4 + + + + + True + True + center + True + + + 1 + 4 + + True From d7a50f5d7f7a4e6232d50a1401a977a815ca0a3a Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Tue, 11 Aug 2026 16:27:53 +0200 Subject: [PATCH 4/6] Add EPG guide page --- usr/lib/hypnotix/epg.py | 10 +- usr/lib/hypnotix/guide.py | 386 +++++++++++++++++++++++++++++++++ usr/lib/hypnotix/hypnotix.py | 56 ++++- usr/share/hypnotix/hypnotix.ui | 26 ++- 4 files changed, 454 insertions(+), 24 deletions(-) create mode 100644 usr/lib/hypnotix/guide.py diff --git a/usr/lib/hypnotix/epg.py b/usr/lib/hypnotix/epg.py index 9ca60ef3..e3e971ef 100644 --- a/usr/lib/hypnotix/epg.py +++ b/usr/lib/hypnotix/epg.py @@ -201,11 +201,6 @@ def parse_programmes(cls, file_path: str, valid_xmltv_ids: set = None): continue stop = parse_xmltv_time(elem.attrib.get("stop")) - # Optimization: Prune programmes where stop time is in the past - if stop <= now: - elem.clear() - continue - start = parse_xmltv_time(elem.attrib.get("start")) title_elem = elem.find("title") @@ -353,11 +348,10 @@ def load_channels_from_db(self, db_path: str) -> list: return channels def load_programmes_from_db(self, db_path: str, valid_xmltv_ids: set = None) -> dict: - """Loads non-expired EPG programmes from SQLite database.""" + """Loads all EPG programmes from SQLite database (including historical).""" conn = sqlite3.connect(db_path) cursor = conn.cursor() - now = int(time.time()) - cursor.execute("SELECT channel_id, start, stop, title, desc FROM programmes WHERE stop > ?", (now,)) + cursor.execute("SELECT channel_id, start, stop, title, desc FROM programmes") programmes = {} for cid, start, stop, title, desc in cursor.fetchall(): diff --git a/usr/lib/hypnotix/guide.py b/usr/lib/hypnotix/guide.py new file mode 100644 index 00000000..dda48a9f --- /dev/null +++ b/usr/lib/hypnotix/guide.py @@ -0,0 +1,386 @@ +import datetime +import time +import gi + +gi.require_version("Gtk", "3.0") +gi.require_version("PangoCairo", "1.0") +from gi.repository import Gtk, Gdk, Pango, PangoCairo, GdkPixbuf, GLib +import gettext +_ = gettext.gettext + +PIXELS_PER_MINUTE = 4 + +class EPGTimeline(Gtk.DrawingArea): + def __init__(self, guide_widget): + super().__init__() + self.guide = guide_widget + self.add_events(Gdk.EventMask.POINTER_MOTION_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK) + self.connect("draw", self.on_draw) + self.connect("motion-notify-event", self.on_motion) + self.connect("leave-notify-event", self.on_leave) + self.set_has_tooltip(True) + self.connect("query-tooltip", self.on_query_tooltip) + + self.hovered_prog = None + + def get_program_at_pos(self, x, y): + if not self.guide.channels or not self.guide.epg_manager: + return None + + # Find which row we are in + current_y = 0 + target_row = -1 + target_channel = None + target_row_height = 0 + + for i, (channel, widget) in enumerate(zip(self.guide.channels, self.guide.channel_widgets)): + min_height, row_height = widget.get_preferred_height() + if current_y <= y < current_y + row_height: + target_row = i + target_channel = channel + target_row_height = row_height + break + current_y += row_height + + if target_row == -1 or not target_channel: + return None + + programmes = self.guide.epg_manager.programmes.get(target_channel.xmltv_id, []) if getattr(target_channel, "xmltv_id", None) else [] + + for prog in programmes: + if prog["stop"] > self.guide.start_window and prog["start"] < self.guide.end_window: + prog_start = max(prog["start"], self.guide.start_window) + prog_stop = min(prog["stop"], self.guide.end_window) + + prog_x = int((prog_start - self.guide.start_window) / 60 * PIXELS_PER_MINUTE) + prog_w = int((prog_stop - prog_start) / 60 * PIXELS_PER_MINUTE) + + if prog_x <= x < prog_x + prog_w: + return prog + return None + + def on_motion(self, widget, event): + prog = self.get_program_at_pos(event.x, event.y) + if prog != self.hovered_prog: + self.hovered_prog = prog + self.queue_draw() # Trigger redraw to show hover state + + def on_leave(self, widget, event): + if self.hovered_prog: + self.hovered_prog = None + self.queue_draw() + + def on_query_tooltip(self, widget, x, y, keyboard_mode, tooltip): + prog = self.get_program_at_pos(x, y) + if prog: + title = prog.get("title", "") + desc = prog.get("desc", "") + start = prog.get("start", "") + stop = prog.get("stop", "") + + tooltip_markup = "" + if title: + # Escape ampersands/angles in the raw string so Pango doesn't crash on bad markup + title_escaped = GLib.markup_escape_text(title) + tooltip_markup += f"{title_escaped}" + if start and stop: + start_str = datetime.datetime.fromtimestamp(start).strftime("%H:%M") + stop_str = datetime.datetime.fromtimestamp(stop).strftime("%H:%M") + if tooltip_markup: + tooltip_markup += "\n" + tooltip_markup += f"{start_str} - {stop_str}" + if desc: + desc_escaped = GLib.markup_escape_text(desc) + if tooltip_markup: + tooltip_markup += "\n" + tooltip_markup += desc_escaped + + if tooltip_markup: + tooltip.set_markup(tooltip_markup) + return True + return False + + def on_draw(self, widget, cr): + if not self.guide.channels: + return + + # Get theme colors dynamically + context = self.get_style_context() + + # In modern GTK3 themes get_background_color often returns transparent + # so we lookup the named theme colors directly. + found, theme_base = context.lookup_color("theme_base_color") + if not found: + found, theme_base = context.lookup_color("theme_bg_color") + if not found: theme_base = Gdk.RGBA(0.2, 0.2, 0.2, 1.0) + + found, theme_fg = context.lookup_color("theme_fg_color") + if not found: theme_fg = Gdk.RGBA(0.9, 0.9, 0.9, 1.0) + + found, theme_sel_bg = context.lookup_color("theme_selected_bg_color") + if not found: theme_sel_bg = Gdk.RGBA(0.2, 0.4, 0.8, 1.0) + + found, theme_sel_fg = context.lookup_color("theme_selected_fg_color") + if not found: theme_sel_fg = Gdk.RGBA(1.0, 1.0, 1.0, 1.0) + + found, theme_borders = context.lookup_color("borders") + if not found: theme_borders = Gdk.RGBA(0.3, 0.3, 0.3, 1.0) + + # We need an alpha blended version of sel_bg_color for 'now playing' + now_playing_bg = Gdk.RGBA(theme_sel_bg.red, theme_sel_bg.green, theme_sel_bg.blue, 0.15) + hover_bg = Gdk.RGBA(theme_sel_bg.red, theme_sel_bg.green, theme_sel_bg.blue, 0.8) + + clip_extents = cr.clip_extents() + clip_y1, clip_y2 = clip_extents[1], clip_extents[3] + clip_x1, clip_x2 = clip_extents[0], clip_extents[2] + + current_time = int(time.time()) + + layout = PangoCairo.create_layout(cr) + layout.set_ellipsize(Pango.EllipsizeMode.END) + font_desc = context.get_font(Gtk.StateFlags.NORMAL) + layout.set_font_description(font_desc) + + y_pos = 0 + for channel, cwidget in zip(self.guide.channels, self.guide.channel_widgets): + min_height, row_height = cwidget.get_preferred_height() + + # Only draw rows that intersect with the clip region (visible screen) + if y_pos + row_height >= clip_y1 and y_pos <= clip_y2: + programmes = self.guide.epg_manager.programmes.get(channel.xmltv_id, []) if self.guide.epg_manager and getattr(channel, "xmltv_id", None) else [] + + for prog in programmes: + if prog["stop"] > self.guide.start_window and prog["start"] < self.guide.end_window: + prog_start = max(prog["start"], self.guide.start_window) + prog_stop = min(prog["stop"], self.guide.end_window) + + x_pos = int((prog_start - self.guide.start_window) / 60 * PIXELS_PER_MINUTE) + width = int((prog_stop - prog_start) / 60 * PIXELS_PER_MINUTE) + + # Only draw blocks that intersect with the clip region horizontally + if x_pos + width >= clip_x1 and x_pos <= clip_x2: + is_hovered = (prog == self.hovered_prog) + is_now_playing = (prog["start"] <= current_time < prog["stop"]) + + # Draw Background + cr.rectangle(x_pos, y_pos, width, row_height) + + if is_hovered: + # Draw base first, then hover on top + cr.set_source_rgba(theme_base.red, theme_base.green, theme_base.blue, theme_base.alpha) + cr.fill() + cr.rectangle(x_pos, y_pos, width, row_height) + cr.set_source_rgba(hover_bg.red, hover_bg.green, hover_bg.blue, hover_bg.alpha) + cr.fill() + elif is_now_playing: + # Draw normal background first + cr.set_source_rgba(theme_base.red, theme_base.green, theme_base.blue, theme_base.alpha) + cr.fill() + # Then overlay the transparent highlight + cr.rectangle(x_pos, y_pos, width, row_height) + cr.set_source_rgba(now_playing_bg.red, now_playing_bg.green, now_playing_bg.blue, now_playing_bg.alpha) + cr.fill() + else: + cr.set_source_rgba(theme_base.red, theme_base.green, theme_base.blue, theme_base.alpha) + cr.fill() + + # Draw Border + cr.rectangle(x_pos, y_pos, width, row_height) + cr.set_source_rgba(theme_borders.red, theme_borders.green, theme_borders.blue, theme_borders.alpha) + cr.set_line_width(1) + cr.stroke() + + # Draw Text + if width > 12: # Only draw text if there's reasonable space + layout.set_text(prog.get("title", "")) + layout.set_width((width - 8) * Pango.SCALE) # 4px padding each side + + if is_hovered: + cr.set_source_rgba(theme_sel_fg.red, theme_sel_fg.green, theme_sel_fg.blue, theme_sel_fg.alpha) + else: + cr.set_source_rgba(theme_fg.red, theme_fg.green, theme_fg.blue, theme_fg.alpha) + + # Center text vertically + ink_rect, logical_rect = layout.get_pixel_extents() + text_y = y_pos + (row_height - logical_rect.height) / 2 + + cr.move_to(x_pos + 4, text_y) + PangoCairo.show_layout(cr, layout) + + y_pos += row_height + + # Draw Time Indicator (Red line) + if current_time >= self.guide.start_window and current_time <= self.guide.end_window: + time_x = int((current_time - self.guide.start_window) / 60 * PIXELS_PER_MINUTE) + if time_x >= clip_x1 and time_x <= clip_x2: + cr.set_source_rgba(1.0, 0.0, 0.0, 1.0) + cr.move_to(time_x, clip_y1) + cr.line_to(time_x, clip_y2) + cr.set_line_width(2) + cr.stroke() + + +class EPGGuideWidget(Gtk.Box): + def __init__(self, main_window): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.main_window = main_window + self.start_window = 0 + self.end_window = 0 + self.channels = [] + self.channel_widgets = [] + self.epg_manager = None + + # Time Header (Right Panel Top) + self.time_header_scroll = Gtk.ScrolledWindow() + self.time_header_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.NEVER) + self.time_header_scroll.set_size_request(-1, 40) + self.time_header_layout = Gtk.Layout() + self.time_header_scroll.add(self.time_header_layout) + + self.now_button = Gtk.Button(label=_("Now")) + self.now_button.set_valign(Gtk.Align.CENTER) + self.now_button.set_halign(Gtk.Align.END) + self.now_button.set_margin_end(0) + self.now_button.set_tooltip_text(_("Scroll to current time")) + self.now_button.connect("clicked", self.on_now_button_clicked) + + self.time_header_overlay = Gtk.Overlay() + self.time_header_overlay.add(self.time_header_scroll) + self.time_header_overlay.add_overlay(self.now_button) + + self.pack_start(self.time_header_overlay, False, False, 0) + + # Timeline Grid (Right Panel Bottom) + self.timeline_scroll = Gtk.ScrolledWindow() + self.timeline_scroll.set_policy(Gtk.PolicyType.ALWAYS, Gtk.PolicyType.AUTOMATIC) + + # Use our custom high-performance drawing area + self.timeline_area = EPGTimeline(self) + self.timeline_scroll.add(self.timeline_area) + + # Synchronize vertical scrolling with main window's sidebar + self.timeline_scroll.set_vadjustment(self.main_window.sidebar.get_vadjustment()) + + # Synchronize horizontal scrolling of time header with timeline grid + self.time_header_scroll.set_hadjustment(self.timeline_scroll.get_hadjustment()) + + self.pack_start(self.timeline_scroll, True, True, 0) + + # Date Label (Left Panel Top) + self.date_label = Gtk.Label() + self.date_label.set_xalign(0.5) + self.date_label.set_valign(Gtk.Align.END) + self.date_label.set_size_request(-1, 40) + self.date_label.set_use_markup(True) + # Prevent it from showing initially until we verify visibility + self.date_label.set_no_show_all(True) + + # Inject the date label natively into the UI's sidebar wrapper + self.main_window.sidebar_vbox.pack_start(self.date_label, False, False, 0) + self.main_window.sidebar_vbox.reorder_child(self.date_label, 0) + + # Connect to scroll event to update the date dynamically + hadj = self.timeline_scroll.get_hadjustment() + hadj.connect("value-changed", self.on_scroll_changed) + + # Sync Date Label visibility with Guide Page visibility + self.main_window.channel_stack.connect("notify::visible-child-name", self.on_visibility_changed) + self.main_window.sidebar_vbox.connect("notify::visible", self.on_visibility_changed) + + def on_visibility_changed(self, widget, param): + is_guide = self.main_window.channel_stack.get_visible_child_name() == "guide_page" + if is_guide and self.main_window.sidebar_vbox.get_visible(): + self.date_label.show() + else: + self.date_label.hide() + + def on_scroll_changed(self, adj): + if not self.start_window: + return + scroll_x = adj.get_value() + current_visible_time = self.start_window + (scroll_x / PIXELS_PER_MINUTE) * 60 + date_str = time.strftime("%A, %d %b", time.localtime(current_visible_time)) + self.date_label.set_markup(f"{date_str}") + + def on_now_button_clicked(self, widget): + current_time = int(time.time()) + self.scroll_to_now(current_time) + + def render_guide(self, channels, epg_manager, initial_load=True): + self.channels = channels + self.channel_widgets = self.main_window.channels_listbox.get_children() + self.epg_manager = epg_manager + + for child in self.time_header_layout.get_children(): + self.time_header_layout.remove(child) + + current_time = int(time.time()) + min_start = current_time - 3600 + max_stop = current_time + (12 * 3600) + + # Calculate dynamic bounds based on actual data + has_any_data = False + if self.epg_manager: + for channel in channels: + xmltv_id = getattr(channel, "xmltv_id", None) + if xmltv_id and xmltv_id in self.epg_manager.programmes: + for prog in self.epg_manager.programmes[xmltv_id]: + if not has_any_data: + min_start = prog["start"] + max_stop = prog["stop"] + has_any_data = True + else: + if prog["start"] < min_start: min_start = prog["start"] + if prog["stop"] > max_stop: max_stop = prog["stop"] + + self.start_window = min_start + self.end_window = max_stop + + total_width = int(((self.end_window - self.start_window) / 60) * PIXELS_PER_MINUTE) + + # Render Time Header (we keep this as Layout with Labels since it's just 24 labels) + start_bound = (self.start_window // 1800) * 1800 + if start_bound < self.start_window: + start_bound += 1800 + + for t in range(start_bound, self.end_window, 1800): + x_pos = int(((t - self.start_window) / 60) * PIXELS_PER_MINUTE) + time_str = time.strftime("%H:%M", time.localtime(t)) + lbl = Gtk.Label(label=f"{time_str}") + lbl.set_use_markup(True) + self.time_header_layout.put(lbl, x_pos, 20) + + self.time_header_layout.set_size(total_width, 40) + + total_height = 0 + for widget in self.channel_widgets: + min_height, row_height = widget.get_preferred_height() + total_height += row_height + + self.timeline_area.set_size_request(total_width, total_height) + self.timeline_area.queue_draw() + + # Trigger an initial scroll update to populate the Date label + self.on_scroll_changed(self.timeline_scroll.get_hadjustment()) + self.show_all() + self.on_visibility_changed(None, None) + + if initial_load: + # Scroll to 'now' + GLib.idle_add(self.scroll_to_now, current_time) + + def scroll_to_now(self, current_time): + if not self.start_window: + return False + + hadj = self.timeline_scroll.get_hadjustment() + # Calculate X position of current time + now_x = ((current_time - self.start_window) / 60) * PIXELS_PER_MINUTE + + # Try to center 'now' on screen by subtracting half the visible width, + # but don't go below 0 (start of timeline). + visible_width = hadj.get_page_size() + target_x = max(0, now_x - (visible_width / 2)) + + hadj.set_value(target_x) + return False diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index 0bc2e265..80c3ba97 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -36,6 +36,7 @@ from common import Manager, Provider, Channel, MOVIES_GROUP, PROVIDERS_PATH, SERIES_GROUP, TV_GROUP,\ async_function, idle_function +from guide import EPGGuideWidget setproctitle.setproctitle("hypnotix") @@ -324,6 +325,7 @@ def __init__(self, application): "status_label", "status_bar", "sidebar", + "sidebar_vbox", "go_back_button", "search_button", "search_bar", @@ -417,6 +419,10 @@ def __init__(self, application): else: setattr(self, name, widget) + self.guide_widget = EPGGuideWidget(self) + self.guide_widget.show_all() + self.channel_stack.add_named(self.guide_widget, "guide_page") + # Widget signals self.window.connect("delete-event", self.on_window_delete) self.window.connect("key-press-event", self.on_key_press_event) @@ -811,7 +817,10 @@ def save_matched_favorites_idle(self, providers): def show_channels(self, channels, favorites=False): self.navigate_to("channels_page", "", favorites) if self.content_type == TV_GROUP: - self.sidebar.show() + if self.fullscreen and getattr(self, "active_channel", None) is not None: + self.sidebar_vbox.hide() + else: + self.sidebar_vbox.show() for child in self.channels_listbox.get_children(): self.channels_listbox.remove(child) @@ -822,12 +831,22 @@ def show_channels(self, channels, favorites=False): self.channels_listbox.add(ChannelWidget(channel, image, main_window=self)) self.channels_listbox.show_all() + + if self.active_channel is None: + self.channel_stack.set_visible_child_name("guide_page") + self.label_channel_name.set_text("") + self.label_channel_url.set_text("") + + provider = getattr(self, "active_provider", None) + epg_mgr = getattr(provider, "epg_manager", None) if provider else None + self.guide_widget.render_guide(channels, epg_mgr) + self.update_epg_labels() self.visible_search_results = len(self.channels_listbox.get_children()) if len(logos_to_refresh) > 0: self.download_channel_logos(logos_to_refresh) else: - self.sidebar.hide() + self.sidebar_vbox.hide() @idle_function def update_epg_labels(self): @@ -838,6 +857,16 @@ def update_epg_labels(self): widget.update_epg(provider.epg_manager) else: widget.update_epg(None) + + # Also asynchronously update the EPG Guide widget if it's currently loaded + if getattr(self, "active_provider", None): + channels = getattr(self.active_provider, "channels", []) + # In case we are currently filtering by search, or looking at favorites + # Note: A safer approach is to pass exactly the channels currently drawn in the listbox + visible_channels = [w.channel for w in self.channels_listbox.get_children() if isinstance(w, ChannelWidget)] + epg_mgr = getattr(self.active_provider, "epg_manager", None) + self.guide_widget.render_guide(visible_channels, epg_mgr, initial_load=False) + return True def show_channel_epg_info(self, channel): @@ -1271,6 +1300,8 @@ def play_async(self, channel): def before_play(self, channel): self.info_menu_item.set_sensitive(False) self.channel_stack.set_visible_child_name("channel_page") + if self.fullscreen: + self.sidebar_vbox.hide() self.mpv_stack.set_visible_child_name("spinner_page") self.video_properties.clear() self.video_properties[_("General")] = {} @@ -1426,6 +1457,11 @@ def on_stop_button(self, widget): self.active_channel = None self.info_menu_item.set_sensitive(False) self.playback_bar.hide() + self.label_channel_name.set_text("") + self.label_channel_url.set_text("") + self.channel_stack.set_visible_child_name("guide_page") + if self.fullscreen and self.content_type == TV_GROUP: + self.sidebar_vbox.show() def on_pause_button(self, widget): self.mpv.pause = not self.mpv.pause @@ -1917,6 +1953,9 @@ def on_key_press_event(self, widget, event): @async_function def reload(self, page=None, refresh=False): + if page is not None: + self.navigate_to(page) + self.favorite_data = self.manager.load_favorites() self.status(_("Loading providers...")) self.providers = [] @@ -2012,8 +2051,6 @@ def reload(self, page=None, refresh=False): self.refresh_providers_page() - if page is not None: - self.navigate_to(page) self.status(None) self.latest_search_bar_text = None @@ -2101,17 +2138,17 @@ def normal_mode(self): self.mpv_top_box.show() self.mpv_bottom_box.hide() if self.content_type == TV_GROUP: - self.sidebar.show() + self.sidebar_vbox.show() self.headerbar.show() self.channels_box.set_border_width(12) self.fullscreen = False def theather_mode(self): if self.stack.get_visible_child_name() == "channels_page": - if self.sidebar.get_visible(): + if self.sidebar_vbox.get_visible(): self.mpv_top_box.hide() self.mpv_bottom_box.hide() - self.sidebar.hide() + self.sidebar_vbox.hide() # self.headerbar.hide() self.status_label.hide() self.channels_box.set_border_width(0) @@ -2123,7 +2160,7 @@ def borderless_mode(self): if self.headerbar.get_visible(): self.mpv_top_box.hide() self.mpv_bottom_box.hide() - self.sidebar.hide() + self.sidebar_vbox.hide() self.headerbar.hide() self.status_label.hide() self.channels_box.set_border_width(0) @@ -2161,7 +2198,8 @@ def full_screen_mode(self): self.window.fullscreen() self.mpv_top_box.hide() self.mpv_bottom_box.hide() - self.sidebar.hide() + if self.active_channel is not None: + self.sidebar_vbox.hide() self.headerbar.hide() self.status_label.hide() self.channels_box.set_border_width(0) diff --git a/usr/share/hypnotix/hypnotix.ui b/usr/share/hypnotix/hypnotix.ui index 63925b58..7499af4f 100644 --- a/usr/share/hypnotix/hypnotix.ui +++ b/usr/share/hypnotix/hypnotix.ui @@ -414,22 +414,34 @@ False 12 - + True - True - 150 + False + vertical - + True - False - 12 + True + 150 - + True False + 12 + + + True + False + + + + True + True + 0 + From 5814d7b67802676b63242b8b10c789f5fdd39100 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Wed, 12 Aug 2026 09:05:08 +0200 Subject: [PATCH 5/6] EPG cleanup and fixes: db validation, atomic download, past-show pruning --- usr/lib/hypnotix/epg.py | 67 +++++++++++++--------- usr/lib/hypnotix/guide.py | 12 ++-- usr/lib/hypnotix/hypnotix.py | 107 ++++++++++------------------------- 3 files changed, 75 insertions(+), 111 deletions(-) diff --git a/usr/lib/hypnotix/epg.py b/usr/lib/hypnotix/epg.py index e3e971ef..fde66141 100644 --- a/usr/lib/hypnotix/epg.py +++ b/usr/lib/hypnotix/epg.py @@ -9,7 +9,7 @@ import xml.etree.ElementTree as ET from email.utils import formatdate -from common import EPG_PATH, async_function, slugify +from common import EPG_PATH, slugify def parse_xmltv_time(s: str) -> int: @@ -183,7 +183,8 @@ def parse_channels(cls, file_path: str): def parse_programmes(cls, file_path: str, valid_xmltv_ids: set = None): """Pass 2: Parses elements, pruning past shows and unmatched channels.""" programmes = {} - now = int(time.time()) + # Keep a bit of trailing history (matches guide.py's default 1h-in-the-past window) + past_cutoff = int(time.time()) - 3600 try: with cls._open_source(file_path) as f: @@ -203,6 +204,10 @@ def parse_programmes(cls, file_path: str, valid_xmltv_ids: set = None): stop = parse_xmltv_time(elem.attrib.get("stop")) start = parse_xmltv_time(elem.attrib.get("start")) + if stop and stop < past_cutoff: + elem.clear() + continue + title_elem = elem.find("title") title = title_elem.text if title_elem is not None else "" @@ -233,25 +238,6 @@ def __init__(self, user_agent="Hypnotix"): self.user_agent = user_agent self.programmes = {} # { xmltv_id: [program_dicts] } - def resolve_epg_path(self, provider) -> str: - """Resolves provider.epg into a local file path.""" - epg_src = provider.epg.strip() if provider.epg else "" - if not epg_src: - return None - - # Local file URI or raw filesystem path - if epg_src.startswith("file://") or epg_src.startswith("/"): - path = epg_src[7:] if epg_src.startswith("file://") else epg_src - return os.path.expanduser(path) - - # Remote URL: cache locally in ~/.cache/hypnotix/epg/ - ext = ".xml.gz" if epg_src.endswith(".gz") else ".xml" - return os.path.join(EPG_PATH, f"{slugify(provider.name)}{ext}") - - def get_db_path(self, local_path: str) -> str: - """Returns the SQLite database cache path corresponding to the XML file.""" - return local_path + ".db" - def is_cache_valid(self, local_path: str, ttl_hours: int = 24) -> bool: """Checks if the local cache file exists and is newer than TTL.""" if not local_path or not os.path.exists(local_path): @@ -260,26 +246,47 @@ def is_cache_valid(self, local_path: str, ttl_hours: int = 24) -> bool: return (time.time() - mtime) < (ttl_hours * 3600) def is_db_valid(self, db_path: str, xml_path: str) -> bool: - """Checks if the SQLite database exists and is newer than the XMLTV file.""" + """Checks if the SQLite database exists, is newer than the XMLTV file, and has required tables.""" if not db_path or not os.path.exists(db_path): return False + + # Check timestamps first to fail fast + is_newer = False if isinstance(xml_path, list): + is_newer = True for xp in xml_path: if not os.path.exists(xp) or os.path.getmtime(db_path) < os.path.getmtime(xp): - return False - return True + is_newer = False + break else: - return os.path.exists(xml_path) and os.path.getmtime(db_path) >= os.path.getmtime(xml_path) + is_newer = os.path.exists(xml_path) and os.path.getmtime(db_path) >= os.path.getmtime(xml_path) + + if not is_newer: + return False + + # Verify schema is intact (tables exist) + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name IN ('channels', 'programmes')") + count = cursor.fetchone()[0] + conn.close() + return count == 2 + except sqlite3.OperationalError: + return False def download_epg(self, epg_url: str, local_path: str) -> bool: """Downloads a remote EPG XML file to the local cache path.""" + tmp_path = f"{local_path}.part" try: req = urllib.request.Request(epg_url, headers={"User-Agent": self.user_agent}) if os.path.exists(local_path): mtime = os.path.getmtime(local_path) req.add_header("If-Modified-Since", formatdate(mtime, usegmt=True)) - with urllib.request.urlopen(req, timeout=30) as resp, open(local_path, "wb") as out: + # Download to a temp file first so an interrupted transfer never clobbers a valid cache + with urllib.request.urlopen(req, timeout=30) as resp, open(tmp_path, "wb") as out: out.write(resp.read()) + os.replace(tmp_path, local_path) return True except urllib.error.HTTPError as e: if e.code == 304: @@ -290,6 +297,9 @@ def download_epg(self, epg_url: str, local_path: str) -> bool: except Exception as e: print(f"[EPG] Download failed for {epg_url}: {e}") return False + finally: + if os.path.exists(tmp_path): + os.remove(tmp_path) def save_to_db(self, db_path: str, channels: list, programmes: dict): """Caches parsed XMLTV channels and programmes into SQLite.""" @@ -348,7 +358,8 @@ def load_channels_from_db(self, db_path: str) -> list: return channels def load_programmes_from_db(self, db_path: str, valid_xmltv_ids: set = None) -> dict: - """Loads all EPG programmes from SQLite database (including historical).""" + """Loads EPG programmes from SQLite database, pruning shows older than the guide's past window.""" + past_cutoff = int(time.time()) - 3600 conn = sqlite3.connect(db_path) cursor = conn.cursor() cursor.execute("SELECT channel_id, start, stop, title, desc FROM programmes") @@ -357,6 +368,8 @@ def load_programmes_from_db(self, db_path: str, valid_xmltv_ids: set = None) -> for cid, start, stop, title, desc in cursor.fetchall(): if valid_xmltv_ids is not None and cid not in valid_xmltv_ids: continue + if stop and stop < past_cutoff: + continue if cid not in programmes: programmes[cid] = [] programmes[cid].append({ diff --git a/usr/lib/hypnotix/guide.py b/usr/lib/hypnotix/guide.py index dda48a9f..0e6749a3 100644 --- a/usr/lib/hypnotix/guide.py +++ b/usr/lib/hypnotix/guide.py @@ -4,7 +4,7 @@ gi.require_version("Gtk", "3.0") gi.require_version("PangoCairo", "1.0") -from gi.repository import Gtk, Gdk, Pango, PangoCairo, GdkPixbuf, GLib +from gi.repository import Gtk, Gdk, Pango, PangoCairo, GLib import gettext _ = gettext.gettext @@ -31,14 +31,12 @@ def get_program_at_pos(self, x, y): current_y = 0 target_row = -1 target_channel = None - target_row_height = 0 for i, (channel, widget) in enumerate(zip(self.guide.channels, self.guide.channel_widgets)): - min_height, row_height = widget.get_preferred_height() + _, row_height = widget.get_preferred_height() if current_y <= y < current_y + row_height: target_row = i target_channel = channel - target_row_height = row_height break current_y += row_height @@ -143,7 +141,7 @@ def on_draw(self, widget, cr): y_pos = 0 for channel, cwidget in zip(self.guide.channels, self.guide.channel_widgets): - min_height, row_height = cwidget.get_preferred_height() + _, row_height = cwidget.get_preferred_height() # Only draw rows that intersect with the clip region (visible screen) if y_pos + row_height >= clip_y1 and y_pos <= clip_y2: @@ -201,7 +199,7 @@ def on_draw(self, widget, cr): cr.set_source_rgba(theme_fg.red, theme_fg.green, theme_fg.blue, theme_fg.alpha) # Center text vertically - ink_rect, logical_rect = layout.get_pixel_extents() + _, logical_rect = layout.get_pixel_extents() text_y = y_pos + (row_height - logical_rect.height) / 2 cr.move_to(x_pos + 4, text_y) @@ -354,7 +352,7 @@ def render_guide(self, channels, epg_manager, initial_load=True): total_height = 0 for widget in self.channel_widgets: - min_height, row_height = widget.get_preferred_height() + _, row_height = widget.get_preferred_height() total_height += row_height self.timeline_area.set_size_request(total_width, total_height) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index 80c3ba97..a6cf253b 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -127,6 +127,9 @@ def channel(self): return self._channel def update_epg(self, epg_manager): + if self.name_label.get_text() != self._channel.name: + self.name_label.set_text(self._channel.name) + self.set_tooltip_text(self._channel.name) if epg_manager and getattr(self._channel, "xmltv_id", None): now_playing, _ = epg_manager.get_current_and_next(self._channel.xmltv_id) if now_playing and "title" in now_playing: @@ -561,7 +564,7 @@ def __init__(self, application): self._timerid = GLib.timeout_add_seconds(self.reload_timeout_sec, self.force_reload) # Refresh EPG show titles every 60 seconds - GLib.timeout_add_seconds(60, self.update_epg_labels) + GLib.timeout_add_seconds(60, self.refresh_epg_labels_periodic) self.window.show() self.playback_bar.hide() @@ -714,7 +717,9 @@ def show_favorites(self, widget=None): if getattr(p, "epg", None) == epg_url: channel.provider = p matched_existing = True - if p not in providers_to_load and not hasattr(p, "epg_manager"): + if channel not in p.channels: + p.channels.append(channel) + if p not in providers_to_load and (not hasattr(p, "epg_manager") or not channel.xmltv_id): providers_to_load.append(p) break @@ -756,8 +761,8 @@ def load_epgs_async(self, providers): def save_matched_favorites_idle(self, providers): import re - fav_provider = next((p for p in providers if p.name == "Favorites"), None) - if not fav_provider: + all_channels = [c for p in providers for c in p.channels] + if not all_channels: return False updated = False @@ -772,7 +777,7 @@ def save_matched_favorites_idle(self, providers): info, url = parts[0], parts[1] # Find the corresponding channel to see if the EPG Manager assigned it an xmltv_id - matched_channel = next((c for c in fav_provider.channels if c.url == url), None) + matched_channel = next((c for c in all_channels if c.url == url), None) if matched_channel and matched_channel.xmltv_id: # If the tvg-id in the EXTINF string doesn't perfectly match the resolved xmltv_id, rewrite it @@ -848,7 +853,6 @@ def show_channels(self, channels, favorites=False): else: self.sidebar_vbox.hide() - @idle_function def update_epg_labels(self): for widget in self.channels_listbox.get_children(): if isinstance(widget, ChannelWidget): @@ -860,71 +864,17 @@ def update_epg_labels(self): # Also asynchronously update the EPG Guide widget if it's currently loaded if getattr(self, "active_provider", None): - channels = getattr(self.active_provider, "channels", []) # In case we are currently filtering by search, or looking at favorites # Note: A safer approach is to pass exactly the channels currently drawn in the listbox visible_channels = [w.channel for w in self.channels_listbox.get_children() if isinstance(w, ChannelWidget)] epg_mgr = getattr(self.active_provider, "epg_manager", None) self.guide_widget.render_guide(visible_channels, epg_mgr, initial_load=False) + def refresh_epg_labels_periodic(self): + # Called from GLib.timeout_add_seconds, which requires True to keep repeating + self.update_epg_labels() return True - def show_channel_epg_info(self, channel): - """Displays EPG details for current and next program of a channel.""" - dialog = Gtk.Dialog(title=_("Channel Info - %s") % channel.name, transient_for=self.window, flags=0) - dialog.add_button(_("Close"), Gtk.ResponseType.CLOSE) - dialog.set_default_size(450, 300) - - box = dialog.get_content_area() - box.set_spacing(12) - box.set_border_width(12) - - provider = getattr(channel, "provider", None) or getattr(self, "active_provider", None) - epg_mgr = getattr(provider, "epg_manager", None) if provider else None - now_playing, next_up = (None, None) - if epg_mgr and getattr(channel, "xmltv_id", None): - now_playing, next_up = epg_mgr.get_current_and_next(channel.xmltv_id) - - if not now_playing and not next_up: - label = Gtk.Label(label=_("No EPG information available for this channel.")) - box.pack_start(label, True, True, 0) - else: - grid = Gtk.Grid(column_spacing=12, row_spacing=8) - row = 0 - - for title_hdr, prog in [(_("Now Playing"), now_playing), (_("Next Up"), next_up)]: - if prog: - start_str = datetime.datetime.fromtimestamp(prog["start"]).strftime("%H:%M") - stop_str = datetime.datetime.fromtimestamp(prog["stop"]).strftime("%H:%M") - - hdr_label = Gtk.Label() - hdr_label.set_markup(_("%s (%s - %s):") % (title_hdr, start_str, stop_str)) - hdr_label.set_xalign(0) - grid.attach(hdr_label, 0, row, 1, 1) - row += 1 - - title_lbl = Gtk.Label(label=prog.get("title", "")) - title_lbl.set_xalign(0) - grid.attach(title_lbl, 0, row, 1, 1) - row += 1 - - if prog.get("desc"): - desc_lbl = Gtk.Label(label=prog["desc"]) - desc_lbl.set_xalign(0) - desc_lbl.set_line_wrap(True) - desc_lbl.get_style_context().add_class("dim-label") - grid.attach(desc_lbl, 0, row, 1, 1) - row += 1 - - scrolled = Gtk.ScrolledWindow() - scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - scrolled.add(grid) - box.pack_start(scrolled, True, True, 0) - - dialog.show_all() - dialog.run() - dialog.destroy() - def show_vod(self, items): logos_to_refresh = [] self.navigate_to("vod_page") @@ -1228,14 +1178,22 @@ def open_keyboard_shortcuts(self, widget): window.set_title(_("Hypnotix")) window.show() + def find_favorite_line(self, url): + """A channel's URL is the stable identifier; the info field can be rewritten in place + (tvg-id/tvg-name injection) so it must not be part of the match key.""" + for fav in self.favorite_data: + parts = fav.split(":::") + if len(parts) > 1 and parts[1] == url: + return fav + return None + def on_favorite_button_toggled(self, widget): if self.page_is_loading or getattr(self, "active_channel", None) is None: return name = self.active_channel.name - data_prefix = f"{self.active_channel.info}:::{self.active_channel.url}" - existing_match = next((fav for fav in self.favorite_data if fav == data_prefix or fav.startswith(data_prefix + ":::")), None) + existing_match = self.find_favorite_line(self.active_channel.url) if widget.get_active() and not existing_match: print (f"Adding {name} to favorites") @@ -1244,7 +1202,7 @@ def on_favorite_button_toggled(self, widget): provider = getattr(self.active_channel, "provider", None) or getattr(self, "active_provider", None) if provider and getattr(provider, "epg", None): epg_url = provider.epg - data = f"{data_prefix}:::{epg_url}:::{xmltv_id}" + data = f"{self.active_channel.info}:::{self.active_channel.url}:::{epg_url}:::{xmltv_id}" self.favorite_data.append(data) # Dynamically update the tooltip @@ -1319,12 +1277,7 @@ def before_play(self, channel): self.label_channel_url.set_text(channel.url) self.page_is_loading = True - - # Use prefix matching to ignore appended EPG/XMLTV metadata - data_prefix = f"{channel.info}:::{channel.url}" - existing_match = next((fav for fav in self.favorite_data if fav == data_prefix or fav.startswith(data_prefix + ":::")), None) - - if existing_match: + if self.find_favorite_line(channel.url) is not None: self.favorite_button.set_active(True) self.favorite_button_image.set_from_icon_name("xsi-starred-symbolic", Gtk.IconSize.BUTTON) self.favorite_button.set_tooltip_text(_("Remove from favorites")) @@ -1886,7 +1839,7 @@ def on_key_press_event(self, widget, event): ctrl = modifier == Gdk.ModifierType.CONTROL_MASK shift = modifier == Gdk.ModifierType.SHIFT_MASK - if event.keyval == Gdk.KEY_g and not isinstance(widget.get_focus(), Gtk.Entry): + if event.keyval == Gdk.KEY_g and not isinstance(widget.get_focus(), Gtk.Entry) and self.mpv is not None: # If the EPG is currently showing, toggle it off if getattr(self, "osd_epg_visible_until", 0) > time.time(): self.mpv.command("show-text", "", 1) @@ -1902,7 +1855,7 @@ def on_key_press_event(self, widget, event): start_str = datetime.datetime.fromtimestamp(now_playing["start"]).strftime("%H:%M") stop_str = datetime.datetime.fromtimestamp(now_playing["stop"]).strftime("%H:%M") title = now_playing.get("title", "") - osd_text = f"{self.active_channel.name}\n{title}\n{start_str} - {stop_str}" + osd_text = f"{title}\n{start_str} - {stop_str}" self.mpv.command("show-text", osd_text, 6000) self.osd_epg_visible_until = time.time() + 6 return True @@ -1953,9 +1906,6 @@ def on_key_press_event(self, widget, event): @async_function def reload(self, page=None, refresh=False): - if page is not None: - self.navigate_to(page) - self.favorite_data = self.manager.load_favorites() self.status(_("Loading providers...")) self.providers = [] @@ -2051,6 +2001,9 @@ def reload(self, page=None, refresh=False): self.refresh_providers_page() + if page is not None: + self.navigate_to(page) + self.status(None) self.latest_search_bar_text = None From 1b873569894f74d9af107679a217152cadf06b5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20Sandstr=C3=B6m?= Date: Wed, 11 Nov 2026 10:12:47 +0100 Subject: [PATCH 6/6] Fix EPG matching for Xtream channels --- usr/lib/hypnotix/xtream.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/usr/lib/hypnotix/xtream.py b/usr/lib/hypnotix/xtream.py index f1311c59..fd9abf28 100644 --- a/usr/lib/hypnotix/xtream.py +++ b/usr/lib/hypnotix/xtream.py @@ -40,6 +40,9 @@ class Channel: title = "" url = "" info = "" + tvg_id = "" + tvg_name = "" + xmltv_id = None # XTream stream_type = "" @@ -75,10 +78,13 @@ def __init__(self, xtream: object, group_title, stream_info): self.logo_path = xtream._get_logo_local_path(self.logo) self.group_title = group_title self.title = stream_name + self.tvg_id = stream_info.get("epg_channel_id") or "" + self.tvg_name = stream_name # Required by the favorites system self.info = ( f'#EXTINF:-1 tvg-name="{self.name}" ' + f'tvg-id="{self.tvg_id}" ' f'tvg-logo="{self.logo}" ' f'group-title="{self.group_title}",' f'{self.name}'