From c65cd067ebb3c201cde91535c57e2501fb21807f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1lm=C3=A1n=20=E2=80=9EKAMI=E2=80=9D=20Szalai?= Date: Sat, 14 Mar 2026 12:39:12 +0100 Subject: [PATCH 01/18] common: Replace os.system() mkdir with os.makedirs() os.system("mkdir -p '%s'" % PROVIDERS_PATH) is a shell injection risk: if PROVIDERS_PATH ever contains shell metacharacters the shell would execute arbitrary commands. Replace with os.makedirs(exist_ok=True) which is both safer and avoids spawning a shell process. --- usr/lib/hypnotix/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/usr/lib/hypnotix/common.py b/usr/lib/hypnotix/common.py index 0e60b5f0..106f2594 100755 --- a/usr/lib/hypnotix/common.py +++ b/usr/lib/hypnotix/common.py @@ -133,7 +133,7 @@ def __init__(self, provider, info): class Manager: def __init__(self, settings): - os.system("mkdir -p '%s'" % PROVIDERS_PATH) + os.makedirs(PROVIDERS_PATH, exist_ok=True) self.verbose = False self.settings = settings From 8074bee018d75f715aaf237b8e65ede793e1141d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1lm=C3=A1n=20=E2=80=9EKAMI=E2=80=9D=20Szalai?= Date: Sat, 14 Mar 2026 12:39:21 +0100 Subject: [PATCH 02/18] common: Validate provider info field count before unpacking provider_info.split(":::") with no bounds check raises a confusing ValueError/TypeError on malformed data and makes it impossible to distinguish corrupt settings from other errors. Count the fields first and raise a descriptive ValueError so the caller (reload()) can log and skip the entry cleanly. --- usr/lib/hypnotix/common.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/usr/lib/hypnotix/common.py b/usr/lib/hypnotix/common.py index 106f2594..a16f0ddb 100755 --- a/usr/lib/hypnotix/common.py +++ b/usr/lib/hypnotix/common.py @@ -47,7 +47,10 @@ def slugify(string): class Provider: def __init__(self, name, provider_info): if provider_info is not None: - self.name, self.type_id, self.url, self.username, self.password, self.epg = provider_info.split(":::") + parts = provider_info.split(":::") + if len(parts) != 6: + raise ValueError("Invalid provider info: expected 6 fields, got %d" % len(parts)) + self.name, self.type_id, self.url, self.username, self.password, self.epg = parts else: self.name = name self.path = os.path.join(PROVIDERS_PATH, slugify(self.name)) From 899992d56c641ee6e62d7d09878fb5e375ad429b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1lm=C3=A1n=20=E2=80=9EKAMI=E2=80=9D=20Szalai?= Date: Sat, 14 Mar 2026 12:39:32 +0100 Subject: [PATCH 03/18] common: Fix downloaded_bytes counter to track actual received bytes downloaded_bytes was incremented by the fixed block_bytes constant (4 MB) on every iteration rather than by the actual number of bytes received. The final size check therefore almost always reported an incorrect file size and deleted the freshly-downloaded playlist. Fix by incrementing by len(data) for byte chunks and by len(data.encode('utf-8')) for already-decoded string chunks. Also remove the spurious per-block print statement. --- usr/lib/hypnotix/common.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/usr/lib/hypnotix/common.py b/usr/lib/hypnotix/common.py index a16f0ddb..151b3d39 100755 --- a/usr/lib/hypnotix/common.py +++ b/usr/lib/hypnotix/common.py @@ -186,13 +186,13 @@ def get_playlist(self, provider, refresh=False) -> bool: with open(provider.path, "w", encoding=response.encoding) as file: # Grab data by block_bytes for data in response.iter_content(block_bytes, decode_unicode=True): - downloaded_bytes += block_bytes - print("{} bytes".format(downloaded_bytes)) # if data is still bytes, decode it if isinstance(data, bytes): + downloaded_bytes += len(data) data = data.decode('utf-8', errors='ignore') else: data = str(data) + downloaded_bytes += len(data.encode('utf-8')) # Write data to file file.write(data) if downloaded_bytes < total_content_size: From d4e6206803c9d8f982e9cb8e1dea44cf5d15be44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1lm=C3=A1n=20=E2=80=9EKAMI=E2=80=9D=20Szalai?= Date: Sat, 14 Mar 2026 12:39:40 +0100 Subject: [PATCH 04/18] common: Fix favorites file handling for first-run and missing directory load_favorites() raised FileNotFoundError on first run before any favorites were ever saved. Guard with an existence check and return an empty list instead. save_favorites() assumed the parent directory already existed, which also fails on first run. Add os.makedirs(exist_ok=True) before opening the file for writing. --- usr/lib/hypnotix/common.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/usr/lib/hypnotix/common.py b/usr/lib/hypnotix/common.py index 151b3d39..859e442a 100755 --- a/usr/lib/hypnotix/common.py +++ b/usr/lib/hypnotix/common.py @@ -298,12 +298,15 @@ def load_channels(self, provider): def load_favorites(self): favorites = [] + if not os.path.exists(FAVORITES_PATH): + return favorites with open(FAVORITES_PATH, 'r', encoding="utf-8", errors="ignore") as f: for line in f: favorites.append(line.strip()) return favorites def save_favorites(self, favorites): + os.makedirs(os.path.dirname(FAVORITES_PATH), exist_ok=True) with open(FAVORITES_PATH, "w", encoding="utf-8") as f: for fav in favorites: f.write(f"{fav}\n") From d75899434f7b0e6b4c2e6f5dd7d7f4a25b065d26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1lm=C3=A1n=20=E2=80=9EKAMI=E2=80=9D=20Szalai?= Date: Sat, 14 Mar 2026 12:39:53 +0100 Subject: [PATCH 05/18] hypnotix: Remove credential-exposing data from log output Two places logged sensitive information to stdout: 1. play_async() printed the full channel URL, which for Xtream providers contains the username and password embedded in the path (e.g. /live/user/pass/id.ts). 2. The reload() exception handler printed the raw provider_info string, which also contains the stored username and password. Remove the URL from the channel log line and replace the provider_info dump with a generic message. --- usr/lib/hypnotix/hypnotix.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index a6be99fe..22311927 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -877,7 +877,7 @@ def play_async(self, channel): if self.mpv is not None: self.mpv.stop() self.mpv.pause = False - print("CHANNEL: '%s' (%s)" % (channel.name, channel.url)) + print("CHANNEL: '%s'" % channel.name) if channel is not None and channel.url is not None: # os.system("mpv --wid=%s %s &" % (self.wid, channel.url)) # self.mpv_drawing_area.show() @@ -1584,7 +1584,7 @@ def reload(self, page=None, refresh=False): except Exception as e: print(e) traceback.print_exc() - print("Couldn't parse provider info: ", provider_info) + print("Couldn't parse provider info (details omitted)") # 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: From 70770e6371ae8fe150e1093b9677deaada743748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1lm=C3=A1n=20=E2=80=9EKAMI=E2=80=9D=20Szalai?= Date: Sat, 14 Mar 2026 12:40:05 +0100 Subject: [PATCH 06/18] hypnotix: Replace bare except clauses with except Exception Two bare 'except: pass' blocks swallow all exceptions including SystemExit, KeyboardInterrupt, and GeneratorExit, which can prevent the application from shutting down cleanly. Replace with 'except Exception: pass' to only catch regular exceptions. --- usr/lib/hypnotix/hypnotix.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index 22311927..621e63fb 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -936,7 +936,7 @@ def monitor_playback(self): self.mpv.unobserve_property("video-bitrate", self.on_bitrate) self.mpv.unobserve_property("audio-bitrate", self.on_bitrate) self.mpv.unobserve_property("core-idle", self.on_playback_changed) - except: + except Exception: pass self.mpv.observe_property("video-params", self.on_video_params) self.mpv.observe_property("video-format", self.on_video_format) @@ -1489,7 +1489,7 @@ def on_key_press_event(self, widget, event): elif not event.keyval in [Gdk.KEY_F1, Gdk.KEY_F2]: try: self.mpv.command("keypress", Gdk.keyval_name(event.keyval)) - except: + except Exception: pass return True # elif event.keyval == Gdk.KEY_Up: From d5132e74fbcf2c6c94af396df54f53d1f1e86e20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1lm=C3=A1n=20=E2=80=9EKAMI=E2=80=9D=20Szalai?= Date: Sat, 14 Mar 2026 12:40:26 +0100 Subject: [PATCH 07/18] hypnotix: Fix update_ytdlp to use absolute paths instead of os.chdir() os.chdir() changes the working directory for the entire process, so any code running concurrently or afterwards that relies on the CWD would break. Relative paths like './yt-dlp' also fail if the CWD changes again before the next call. Build an absolute path with os.path.join() and pass it directly to subprocess.getoutput() as a list (no shell involved). Replace the shell 'chmod a+rx' invocation with os.chmod() for the same reason. --- usr/lib/hypnotix/hypnotix.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index 621e63fb..43d3c6dd 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -331,8 +331,9 @@ def __init__(self, application): self.ytdlp_local_switch.set_active(self.settings.get_boolean("use-local-ytdlp")) self.ytdlp_local_switch.connect("notify::active", self.on_ytdlp_local_switch_activated) self.ytdlp_system_version_label.set_text(subprocess.getoutput("/usr/bin/yt-dlp --version")) - if os.path.exists(os.path.expanduser("~/.cache/hypnotix/yt-dlp/yt-dlp")): - self.ytdlp_local_version_label.set_text(subprocess.getoutput("~/.cache/hypnotix/yt-dlp/yt-dlp --version")) + ytdlp_local_bin = os.path.expanduser("~/.cache/hypnotix/yt-dlp/yt-dlp") + if os.path.exists(ytdlp_local_bin): + self.ytdlp_local_version_label.set_text(subprocess.getoutput([ytdlp_local_bin, "--version"])) self.ytdlp_update_button.connect("clicked", self.update_ytdlp) # Dark mode manager @@ -647,13 +648,13 @@ def on_ytdlp_local_switch_activated(self, widget, data=None): def update_ytdlp(self, widget=None): path = os.path.expanduser("~/.cache/hypnotix/yt-dlp") - os.chdir(path) - if os.path.exists("yt-dlp"): - subprocess.getoutput("./yt-dlp --update") + ytdlp_bin = os.path.join(path, "yt-dlp") + if os.path.exists(ytdlp_bin): + subprocess.getoutput([ytdlp_bin, "--update"]) else: - subprocess.getoutput("wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp") - subprocess.getoutput("chmod a+rx ./yt-dlp") - self.ytdlp_local_version_label.set_text(subprocess.getoutput("~/.cache/hypnotix/yt-dlp/yt-dlp --version")) + subprocess.getoutput(["wget", "-P", path, "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp"]) + os.chmod(ytdlp_bin, 0o755) + self.ytdlp_local_version_label.set_text(subprocess.getoutput([ytdlp_bin, "--version"])) @async_function def download_channel_logos(self, logos_to_refresh): From 5bdf013287e1620a885bafb2752c6a8e02e72185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1lm=C3=A1n=20=E2=80=9EKAMI=E2=80=9D=20Szalai?= Date: Sat, 14 Mar 2026 12:40:35 +0100 Subject: [PATCH 08/18] hypnotix: Fix search debounce timer truncating float to zero GLib.timeout_add_seconds() requires an integer number of seconds. Passing 0.1 silently truncates to 0, which fires the callback immediately with no debounce delay. Replace with GLib.timeout_add(100, ...) which takes milliseconds, providing the intended 100 ms debounce. --- usr/lib/hypnotix/hypnotix.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index 43d3c6dd..e8d0282c 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -713,7 +713,7 @@ def on_search_bar(self, widget): if search_bar_text != self.latest_search_bar_text: self.latest_search_bar_text = search_bar_text self.search_bar.set_sensitive(False) - GLib.timeout_add_seconds(0.1, self.on_search) + GLib.timeout_add(100, self.on_search) def on_search(self): self.visible_search_results = 0 From cf05cf8f7d545e97fcd835395b1e960266fda4ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1lm=C3=A1n=20=E2=80=9EKAMI=E2=80=9D=20Szalai?= Date: Sat, 14 Mar 2026 12:40:43 +0100 Subject: [PATCH 09/18] hypnotix: Prevent crash in on_search when no provider is selected on_search() accessed self.active_provider.channels without checking whether active_provider is None. If the user typed in the search bar before any provider was loaded or selected, this raised an AttributeError. Guard with an early return that re-enables the search bar so the user is not left with a frozen input. --- usr/lib/hypnotix/hypnotix.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index e8d0282c..a8a2d4d8 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -718,6 +718,9 @@ def on_search_bar(self, widget): def on_search(self): self.visible_search_results = 0 channels = [] + if self.active_provider is None: + self.search_bar.set_sensitive(True) + return False for channel in self.active_provider.channels: if self.latest_search_bar_text in channel.name.lower(): channels.append(channel) From f3f48e5c2e91f17040164f4fbabcab6ec74cdf8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1lm=C3=A1n=20=E2=80=9EKAMI=E2=80=9D=20Szalai?= Date: Sat, 14 Mar 2026 16:42:24 +0100 Subject: [PATCH 10/18] hypnotix: Escape provider name before inserting into Pango markup set_markup("%s" % provider.name) passes the raw provider name into a Pango markup string. A name containing '&', '<', or '>' causes a Pango parse error and a GTK critical warning, breaking the providers page layout. Wrap the name with GLib.markup_escape_text() before interpolation. --- usr/lib/hypnotix/hypnotix.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index a8a2d4d8..fa3dde22 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -1066,7 +1066,7 @@ def refresh_providers_page(self): image.set_from_icon_name("xsi-tv-symbolic", Gtk.IconSize.BUTTON) labels_box.pack_start(image, False, False, 0) label = Gtk.Label() - label.set_markup("%s" % provider.name) + label.set_markup("%s" % GLib.markup_escape_text(provider.name)) labels_box.pack_start(label, False, False, 0) num = len(provider.channels) if num > 0: From 2db42d2071f03da74cbc7be9c213da9f094e4cb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1lm=C3=A1n=20=E2=80=9EKAMI=E2=80=9D=20Szalai?= Date: Sat, 14 Mar 2026 16:43:02 +0100 Subject: [PATCH 11/18] hypnotix: Use context manager and read() for GPL license file The open_about() method opened the GPL file without a context manager, read it line-by-line into a list, then concatenated all lines in a loop. If an exception occurred mid-loop the file handle would leak. Replace with a 'with' block and a single h.read() call. --- usr/lib/hypnotix/hypnotix.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index fa3dde22..8cbce631 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -1428,13 +1428,8 @@ def open_about(self, widget): dlg.set_program_name(_("Hypnotix")) dlg.set_comments(_("Watch TV")) try: - h = open("/usr/share/common-licenses/GPL", encoding="utf-8") - s = h.readlines() - gpl = "" - for line in s: - gpl += line - h.close() - dlg.set_license(gpl) + with open("/usr/share/common-licenses/GPL", encoding="utf-8") as h: + dlg.set_license(h.read()) except Exception as e: print(e) From ddeee4b293b5bdbed728e6624d18b267ad109dc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1lm=C3=A1n=20=E2=80=9EKAMI=E2=80=9D=20Szalai?= Date: Sat, 14 Mar 2026 16:43:10 +0100 Subject: [PATCH 12/18] hypnotix: Replace type() == dict with isinstance() checks 'not type(params) == dict' uses identity comparison on a type object. It does not recognise dict subclasses and reads less clearly than the idiomatic 'not isinstance(params, dict)'. Fix both occurrences in on_video_params() and on_audio_params(). --- usr/lib/hypnotix/hypnotix.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index 8cbce631..4cc1deed 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -994,7 +994,7 @@ def on_bitrate(self, prop, bitrate): @idle_function def on_video_params(self, property, params): - if not params or not type(params) == dict: + if not params or not isinstance(params, dict): return if "w" in params and "h" in params: self.video_properties[_("General")][_("Dimensions")] = "%sx%s" % (params["w"],params["h"]) @@ -1016,7 +1016,7 @@ def on_video_format(self, property, vformat): @idle_function def on_audio_params(self, property, params): - if not params or not type(params) == dict: + if not params or not isinstance(params, dict): return if "channels" in params: chans = params["channels"] From fdd4c14afa97735df9a8bf08fcc01dfc6dda2fe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1lm=C3=A1n=20=E2=80=9EKAMI=E2=80=9D=20Szalai?= Date: Sat, 14 Mar 2026 16:43:20 +0100 Subject: [PATCH 13/18] xtream: Fix cache_path fallback never triggering (== vs =) 'self.cache_path == ""' is a comparison that evaluates and discards True/False; it does not reassign the attribute. The intended assignment 'self.cache_path = ""' was never executed, so an invalid cache directory was never replaced with the default ~/.xtream-cache/. --- usr/lib/hypnotix/xtream.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/usr/lib/hypnotix/xtream.py b/usr/lib/hypnotix/xtream.py index 05775cac..e24339c2 100644 --- a/usr/lib/hypnotix/xtream.py +++ b/usr/lib/hypnotix/xtream.py @@ -326,7 +326,7 @@ def __init__( # If the cache_path is not a directory, clear it if not osp.isdir(self.cache_path): print(" - Cache Path is not a directory, using default '~/.xtream-cache/'") - self.cache_path == "" + self.cache_path = "" # If the cache_path is still empty, use default if self.cache_path == "": From 2da41aa035a4a2ca2e6e5ba0c1ca50cc5b4e0937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1lm=C3=A1n=20=E2=80=9EKAMI=E2=80=9D=20Szalai?= Date: Sat, 14 Mar 2026 16:43:29 +0100 Subject: [PATCH 14/18] xtream: Fix always-true condition in Group stream type check 'elif "Live":' evaluates the non-empty string literal as a boolean, which is always True. This means the else branch that prints an "Unrecognized stream type" warning can never be reached, and any stream type that is not "VOD" or "Series" silently becomes TV_GROUP instead of being reported as an error. Fix: 'elif stream_type == "Live":' --- usr/lib/hypnotix/xtream.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/usr/lib/hypnotix/xtream.py b/usr/lib/hypnotix/xtream.py index e24339c2..6d39b419 100644 --- a/usr/lib/hypnotix/xtream.py +++ b/usr/lib/hypnotix/xtream.py @@ -145,7 +145,7 @@ def __init__(self, group_info: dict, stream_type: str): self.group_type = MOVIES_GROUP elif "Series" == stream_type: self.group_type = SERIES_GROUP - elif "Live": + elif stream_type == "Live": self.group_type = TV_GROUP else: print("Unrecognized stream type `{}` for `{}`".format( From 06bb03d2571be9d887aa35a3bd23484c18573466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1lm=C3=A1n=20=E2=80=9EKAMI=E2=80=9D=20Szalai?= Date: Sat, 14 Mar 2026 16:43:47 +0100 Subject: [PATCH 15/18] xtream: Move mutable class attributes to instance __init__ groups, channels, series, movies, auth_data, authorization, state, and catch_all_group were declared as class-level attributes with mutable default values (lists and dicts). In Python these are shared across all instances of the class, so a second XTream instance would inherit and mutate the first instance's data. Move all mutable attributes into __init__ so each instance gets its own independent copy. Also pass "Live" as the stream_type for catch_all_group instead of the empty string that previously produced an "Unrecognized stream type" warning on every construction. --- usr/lib/hypnotix/xtream.py | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/usr/lib/hypnotix/xtream.py b/usr/lib/hypnotix/xtream.py index 6d39b419..75f466bb 100644 --- a/usr/lib/hypnotix/xtream.py +++ b/usr/lib/hypnotix/xtream.py @@ -262,26 +262,8 @@ class XTream: vod_type = "VOD" series_type = "Series" - auth_data = {} - authorization = {} - - groups = [] - channels = [] - series = [] - movies = [] - - state = {"authenticated": False, "loaded": False} - hide_adult_content = False - catch_all_group = Group( - { - "category_id": "9999", - "category_name":"xEverythingElse", - "parent_id":0 - }, - "" - ) # If the cached JSON file is older than threshold_time_sec then load a new # JSON dictionary from the provider threshold_time_sec = 60 * 60 * 8 @@ -321,6 +303,18 @@ def __init__( self.hide_adult_content = hide_adult_content self.user_agent = user_agent + self.auth_data = {} + self.authorization = {} + self.groups = [] + self.channels = [] + self.series = [] + self.movies = [] + self.state = {"authenticated": False, "loaded": False} + self.catch_all_group = Group( + {"category_id": "9999", "category_name": "xEverythingElse", "parent_id": 0}, + "Live" + ) + # if the cache_path is specified, test that it is a directory if self.cache_path != "": # If the cache_path is not a directory, clear it From 5e433b04078cd122370e892a0816c83ce58ce451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1lm=C3=A1n=20=E2=80=9EKAMI=E2=80=9D=20Szalai?= Date: Sat, 14 Mar 2026 16:44:02 +0100 Subject: [PATCH 16/18] xtream: Fix UnboundLocalError when processing series streams After the if/else block that creates either new_series (series branch) or new_channel (live/vod branch), the code unconditionally evaluated new_channel.group_id. When loading series streams new_channel was never assigned, causing an UnboundLocalError crash on every series stream entry. Move the group_id == "9999" debug print inside the live and vod branches where new_channel is guaranteed to exist. --- usr/lib/hypnotix/xtream.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/usr/lib/hypnotix/xtream.py b/usr/lib/hypnotix/xtream.py index 75f466bb..c99a669a 100644 --- a/usr/lib/hypnotix/xtream.py +++ b/usr/lib/hypnotix/xtream.py @@ -655,13 +655,14 @@ def load_iptv(self): self, group_title, stream_channel ) - if new_channel.group_id == "9999": - print(" - xEverythingElse Channel -> {} - {}".format(new_channel.name,new_channel.stream_type)) - # Save the new channel to the local list of channels if loading_stream_type == self.live_type: + if new_channel.group_id == "9999": + print(" - xEverythingElse Channel -> {} - {}".format(new_channel.name, new_channel.stream_type)) self.channels.append(new_channel) elif loading_stream_type == self.vod_type: + if new_channel.group_id == "9999": + print(" - xEverythingElse Channel -> {} - {}".format(new_channel.name, new_channel.stream_type)) self.movies.append(new_channel) else: self.series.append(new_series) From 22845bdf01e23b77236b5795a6bede766c858635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1lm=C3=A1n=20=E2=80=9EKAMI=E2=80=9D=20Szalai?= Date: Sat, 14 Mar 2026 16:44:18 +0100 Subject: [PATCH 17/18] xtream: Remove duplicate catch_all_group insertion in load_iptv load_iptv() appended self.catch_all_group (the class-level instance) to self.groups at the start of each stream-type iteration, then appended a second freshly-constructed catch_all Group at the end of the same iteration. With three stream types (Live, VOD, Series) this produced up to six catch_all entries in self.groups. The per-stream-type Group added at the end of the loop is sufficient; remove the redundant prepend. --- usr/lib/hypnotix/xtream.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/usr/lib/hypnotix/xtream.py b/usr/lib/hypnotix/xtream.py index c99a669a..6d75ac3d 100644 --- a/usr/lib/hypnotix/xtream.py +++ b/usr/lib/hypnotix/xtream.py @@ -556,9 +556,6 @@ def load_iptv(self): )) ## Add GROUPS to dictionaries - # Add the catch-all-errors group - self.groups.append(self.catch_all_group) - for cat_obj in all_cat: # Create Group (Category) new_group = Group(cat_obj, loading_stream_type) From e732ce932e02cebf76dfcc4f0c46e1be10a237b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1lm=C3=A1n=20=E2=80=9EKAMI=E2=80=9D=20Szalai?= Date: Sat, 14 Mar 2026 16:44:32 +0100 Subject: [PATCH 18/18] xtream: Use compiled regex.match() directly in search_stream re.match(compiled_regex, string) re-enters the module to dispatch back to the compiled pattern object. Call regex.match(string) directly on the compiled object to skip the redundant dispatch. Also remove the dead 'if search_result is not None' guard: search_result is always a list and therefore never None, so the condition was always True and the inner block always executed. --- usr/lib/hypnotix/xtream.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/usr/lib/hypnotix/xtream.py b/usr/lib/hypnotix/xtream.py index 6d75ac3d..ee8931fd 100644 --- a/usr/lib/hypnotix/xtream.py +++ b/usr/lib/hypnotix/xtream.py @@ -351,23 +351,22 @@ def search_stream(self, keyword: str, ignore_case: bool = True, return_type: str print("Checking {} movies".format(len(self.movies))) for stream in self.movies: - if re.match(regex, stream.name) is not None: + if regex.match(stream.name) is not None: search_result.append(stream.export_json()) print("Checking {} channels".format(len(self.channels))) for stream in self.channels: - if re.match(regex, stream.name) is not None: + if regex.match(stream.name) is not None: search_result.append(stream.export_json()) print("Checking {} series".format(len(self.series))) for stream in self.series: - if re.match(regex, stream.name) is not None: + if regex.match(stream.name) is not None: search_result.append(stream.export_json()) if return_type == "JSON": - if search_result is not None: - print("Found {} results `{}`".format(len(search_result), keyword)) - return json.dumps(search_result, ensure_ascii=False) + print("Found {} results `{}`".format(len(search_result), keyword)) + return json.dumps(search_result, ensure_ascii=False) else: return search_result