diff --git a/usr/lib/hypnotix/hypnotix.py b/usr/lib/hypnotix/hypnotix.py index 9612aa94..03d14d2a 100755 --- a/usr/lib/hypnotix/hypnotix.py +++ b/usr/lib/hypnotix/hypnotix.py @@ -27,12 +27,14 @@ import mpv import requests import setproctitle -from imdb import IMDb +from imdb import IMDb, IMDbError from unidecode import unidecode from common import Manager, Provider, Channel, MOVIES_GROUP, PROVIDERS_PATH, SERIES_GROUP, TV_GROUP,\ async_function, idle_function +# Load xtream class +from xtream import XTream setproctitle.setproctitle("hypnotix") @@ -250,6 +252,8 @@ def __init__(self, application): "referer_entry", "mpv_entry", "mpv_link", + "adult_switch", + "empty_groups_switch", "ytdlp_local_switch", "ytdlp_system_version_label", "ytdlp_local_version_label", @@ -287,6 +291,7 @@ def __init__(self, application): # Widget signals self.window.connect("key-press-event", self.on_key_press_event) + self.window.connect("realize", self.on_window_realize) self.mpv_drawing_area.connect("realize", self.on_mpv_drawing_area_realize) self.mpv_drawing_area.connect("draw", self.on_mpv_drawing_area_draw) self.fullscreen_button.connect("clicked", self.on_fullscreen_button_clicked) @@ -359,6 +364,18 @@ def __init__(self, application): except Exception: pass + # hide adult stream + self.prefer_hide_adult = self.adult_switch.get_active() + self.prefer_hide_adult = self.settings.get_boolean("prefer-hide-adult") + self.adult_switch.set_active(self.prefer_hide_adult) + self.adult_switch.connect("notify::active", self.on_hide_adult_switch_toggled) + + # hide empty groups stream + self.prefer_hide_empty_groups = self.empty_groups_switch.get_active() + self.prefer_hide_empty_groups = self.settings.get_boolean("prefer-hide-empty-groups") + self.empty_groups_switch.set_active(self.prefer_hide_empty_groups) + self.empty_groups_switch.connect("notify::active", self.on_hide_empty_groups_switch_toggled) + # Menubar accel_group = Gtk.AccelGroup() self.window.add_accel_group(accel_group) @@ -413,12 +430,11 @@ def __init__(self, application): self.movies_logo.set_from_surface(self.get_surface_for_file("/usr/share/hypnotix/pictures/movies.svg", 258, 258)) self.series_logo.set_from_surface(self.get_surface_for_file("/usr/share/hypnotix/pictures/series.svg", 258, 258)) - self.reload(page="landing_page") - # Redownload playlists by default # This is going to get readjusted self._timerid = GLib.timeout_add_seconds(self.reload_timeout_sec, self.force_reload) + self.current_cursor = None self.window.show() self.playback_bar.hide() self.search_bar.hide() @@ -476,8 +492,21 @@ def show_groups(self, widget, content_type): self.active_group = None found_groups = False for group in self.active_provider.groups: - if group.group_type != self.content_type: + # Skip if the group is not from the current displayed content type + if (group.group_type != self.content_type): continue + if self.prefer_hide_empty_groups: + # Skip group with empty channels + if (self.content_type != SERIES_GROUP) and (len(group.channels) == 0): + continue + # Skip group with empty series + if (self.content_type == SERIES_GROUP) and (len(group.series) == 0): + continue + # Check if need to skip channels marked as adult in TV and Movies groups + if self.prefer_hide_adult: + if (self.content_type != SERIES_GROUP) and (hasattr(group.channels[0], "is_adult")): + if (group.channels[0].is_adult == 1): + continue found_groups = True button = Gtk.Button() button.connect("clicked", self.on_category_button_clicked, group) @@ -604,7 +633,7 @@ def show_episodes(self, serie): # If we are using xtream provider # Load every Episodes of every Season for this Series if self.active_provider.type_id == "xtream": - self.x.get_series_info_by_id(self.active_serie) + serie.xtream.get_series_info_by_id(self.active_serie) self.navigate_to("episodes_page") for child in self.episodes_box.get_children(): @@ -659,6 +688,13 @@ def bind_setting_widget(self, key, widget): def on_entry_changed(self, widget, key): self.settings.set_string(key, widget.get_text()) + def on_hide_adult_switch_toggled(self, widget, key): + self.prefer_hide_adult = widget.get_active() + self.settings.set_boolean("prefer-hide-adult", self.prefer_hide_adult) + + def on_hide_empty_groups_switch_toggled(self, widget, key): + self.prefer_hide_empty_groups = widget.get_active() + self.settings.set_boolean("prefer-hide-empty-groups", self.prefer_hide_empty_groups) def on_ytdlp_local_switch_activated(self, widget, data=None): self.settings.set_boolean("use-local-ytdlp", widget.get_active()) if widget.get_active(): @@ -900,7 +936,6 @@ def on_next_channel(self): self.channels_listbox.do_move_cursor(self.channels_listbox, Gtk.MovementStep.DISPLAY_LINES, 1) self.channels_listbox.do_activate_cursor_row(self.channels_listbox) - @async_function def play_async(self, channel): print("CHANNEL: '%s' (%s)" % (channel.name, channel.url)) if channel is not None and channel.url is not None: @@ -910,8 +945,15 @@ def play_async(self, channel): self.before_play(channel) self.reinit_mpv() self.mpv.play(channel.url) + self.wait_for_mpv_playing(channel) + + @async_function + def wait_for_mpv_playing(self, channel): + try: self.mpv.wait_until_playing() - self.after_play(channel) + except mpv.ShutdownError: + pass + self.after_play(channel) @idle_function def before_play(self, channel): @@ -1043,7 +1085,11 @@ def on_audio_codec(self, property, codec): @async_function def get_imdb_details(self, name): - movies = self.ia.search_movie(name) + movies = [] + try: + movies = self.ia.search_movie(name) + except IMDbError: + print("IMDB Redirect Error will be fixed in IMDbPY latest version") match = None for movie in movies: self.ia.update(movie) @@ -1507,6 +1553,7 @@ def close(w, res): dlg.show() def on_menu_quit(self, widget): + self.mpv.terminate() self.application.quit() def on_key_press_event(self, widget, event): @@ -1534,24 +1581,31 @@ def on_key_press_event(self, widget, event): self.on_prev_channel() elif event.keyval == Gdk.KEY_Right: self.on_next_channel() + elif event.keyval == Gdk.KEY_Escape: + # Go back one level + self.on_go_back_button(widget) # elif event.keyval == Gdk.KEY_Up: - # # Up of in the list - # pass + # Up of in the list + # print("UP") + # pass # elif event.keyval == Gdk.KEY_Down: - # # Down of in the list - # pass - # elif event.keyval == Gdk.KEY_Escape: - # # Go back one level + # Down of in the list + # print("DOWN") + # pass + #elif event.keyval == Gdk.KEY_Return: + # Same as click # pass - # #elif event.keyval == Gdk.KEY_Return: - # # Same as click - # # pass @async_function def reload(self, page=None, refresh=False): self.favorite_data = self.manager.load_favorites() self.status(_("Loading providers...")) + self.start_loading_cursor() self.providers = [] + headers = { + 'User-Agent': self.settings.get_string("user-agent"), + 'Referer': self.settings.get_string("http-referer") + } for provider_info in self.settings.get_strv("providers"): try: provider = Provider(name=None, provider_info=provider_info) @@ -1581,43 +1635,40 @@ def reload(self, page=None, refresh=False): self.status(_("Failed to download playlist from %s") % provider.name, provider) else: - # Load xtream class - from xtream import XTream - # Download via Xtream - self.x = XTream( + x = XTream( + self.status, provider.name, provider.username, provider.password, provider.url, - hide_adult_content=False, + headers=headers, + hide_adult_content=self.prefer_hide_adult, cache_path=PROVIDERS_PATH, ) - if self.x.auth_data != {}: - print("XTREAM `{}` Loading Channels".format(provider.name)) - # Save default cursor - current_cursor = self.window.get_window().get_cursor() - # Set waiting cursor - self.window.get_window().set_cursor(Gdk.Cursor.new_from_name(Gdk.Display.get_default(), "wait")) + if x.auth_data != {}: + self.status("Loading Channels...", provider) # Load data - self.x.load_iptv() - # Restore default cursor - self.window.get_window().set_cursor(current_cursor) - # Inform Provider of data - provider.channels = self.x.channels - provider.movies = self.x.movies - provider.series = self.x.series - provider.groups = self.x.groups - - # Change redownload timeout - self.reload_timeout_sec = 60 * 60 * 2 # 2 hours - if self._timerid: - GLib.source_remove(self._timerid) - self._timerid = GLib.timeout_add_seconds(self.reload_timeout_sec, self.force_reload) - - # If no errors, approve provider - if provider.name == self.settings.get_string("active-provider"): - self.active_provider = provider + x.load_iptv() + # If there are no stream to show, pass this provider. + if (len(x.channels) == 0) and (len(x.movies) == 0) and (len(x.series) == 0) and (len(x.groups) == 0): + pass + else: + # Inform Provider of data + provider.channels = x.channels + provider.movies = x.movies + provider.series = x.series + provider.groups = x.groups + + # Change redownload timeout + self.reload_timeout_sec = 60 * 60 * 4 # 4 hours + if self._timerid: + GLib.source_remove(self._timerid) + self._timerid = GLib.timeout_add_seconds(self.reload_timeout_sec, self.force_reload) + + # If no errors, approve provider + if provider.name == self.settings.get_string("active-provider"): + self.active_provider = provider self.status(None) else: print("XTREAM Authentication Failed") @@ -1638,12 +1689,26 @@ def reload(self, page=None, refresh=False): self.status(None) self.latest_search_bar_text = None + self.end_loading_cursor() + + @idle_function + def start_loading_cursor(self): + # Restore default cursor + self.current_cursor = self.window.get_window().get_cursor() + self.window.get_window().set_cursor(Gdk.Cursor.new_from_name(Gdk.Display.get_default(), "wait")) + + @idle_function + def end_loading_cursor(self): + # Restore default cursor + self.window.get_window().set_cursor(self.current_cursor) + self.current_cursor = None + def force_reload(self): self.reload(page=None, refresh=True) return False @idle_function - def status(self, string, provider=None): + def status(self, string, provider=None, guiOnly=False): if string is None: self.status_label.set_text("") self.status_label.hide() @@ -1651,17 +1716,22 @@ def status(self, string, provider=None): self.status_label.show() if provider is not None: self.status_label.set_text("%s: %s" % (provider.name, string)) - print("%s: %s" % (provider.name, string)) + if not guiOnly: + print("%s: %s" % (provider.name, string)) else: self.status_label.set_text(string) - print(string) + if not guiOnly: + print(string) def on_mpv_drawing_area_realize(self, widget): self.reinit_mpv() + def on_window_realize(self, widget): + self.reload(page="landing_page") + def reinit_mpv(self): if self.mpv is not None: - self.mpv.stop() + self.mpv.quit() options = {} try: mpv_options = self.settings.get_string("mpv-options") @@ -1677,14 +1747,17 @@ def reinit_mpv(self): options["user_agent"] = self.settings.get_string("user-agent") options["referrer"] = self.settings.get_string("http-referer") - while not self.mpv_drawing_area.get_window() and not Gtk.events_pending(): - time.sleep(0.1) - osc = True if "osc" in options: # To prevent 'multiple values for keyword argument'! osc = options.pop("osc") != "no" + # This is a race - depending on whether you do tv shows or movies, the drawing area may or may not + # already be realized - just make sure it's done by this point so there's a window to give to the mpv + # initializer. + if not self.mpv_drawing_area.get_realized(): + self.mpv_drawing_area.realize() + self.mpv = mpv.MPV( **options, script_opts="osc-layout=box,osc-seekbarstyle=bar,osc-deadzonesize=0,osc-minmousemove=3", diff --git a/usr/lib/hypnotix/xtream.py b/usr/lib/hypnotix/xtream.py index 9da1532c..9a317f9e 100644 --- a/usr/lib/hypnotix/xtream.py +++ b/usr/lib/hypnotix/xtream.py @@ -16,22 +16,26 @@ """ -__version__ = "0.5.0" +__version__ = "0.6.0" __author__ = "Claudio Olmi" import json import re # used for URL validation import time -from os import path as osp +import sys from os import makedirs +from os import path as osp +from sys import stdout from timeit import default_timer as timer # Timing xtream json downloads -from typing import List, Tuple +from typing import List, Protocol, Tuple, Optional +from datetime import datetime, timedelta import requests class Channel: # Required by Hypnotix + info = "" id = "" name = "" # What is the difference between the below name and title? logo = "" @@ -54,13 +58,11 @@ class Channel: def __init__(self, xtream: object, group_title, stream_info): stream_type = stream_info["stream_type"] # Adjust the odd "created_live" type - if stream_type == "created_live" or stream_type == "radio_streams": + if stream_type in ("created_live", "radio_streams"): stream_type = "live" - if stream_type != "live" and stream_type != "movie": - print("Error the channel has unknown stream type `{}`\n`{}`".format( - stream_type, stream_info - )) + if stream_type not in ("live", "movie"): + print(f"Error the channel has unknown stream type `{stream_type}`\n`{stream_info}`") else: # Raw JSON Channel self.raw = stream_info @@ -98,18 +100,15 @@ def __init__(self, xtream: object, group_title, stream_info): stream_extension = stream_info["container_extension"] # Required by Hypnotix - self.url = "{}/{}/{}/{}/{}.{}".format( - xtream.server, - stream_info["stream_type"], - xtream.authorization["username"], - xtream.authorization["password"], - stream_info["stream_id"], - stream_extension, - ) + self.url = f"{xtream.server}/{stream_type}/{xtream.authorization['username']}/" \ + f"{xtream.authorization['password']}/{stream_info['stream_id']}.{stream_extension}" # Check that the constructed URL is valid if not xtream._validate_url(self.url): - print("{} - Bad URL? `{}`".format(self.name, self.url)) + print(f"{self.name} - Bad URL? `{self.url}`") + + # Add Channel info in M3U8 format to support Favorite Channel + self.info = f'#EXTINF:-1 tvg-name="{self.name}" tvg-logo="{self.logo}" group-title="{self.group_title}",{self.name}' def export_json(self): jsondata = {} @@ -145,12 +144,10 @@ 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 "Live" == stream_type: self.group_type = TV_GROUP else: - print("Unrecognized stream type `{}` for `{}`".format( - stream_type, group_info - )) + print(f"Unrecognized stream type `{stream_type}` for `{group_info}`") self.name = group_info["category_name"] @@ -163,6 +160,7 @@ class Episode: # Required by Hypnotix title = "" name = "" + info = "" # XTream @@ -184,17 +182,13 @@ def __init__(self, xtream: object, series_info, group_title, episode_info) -> No self.logo = series_info["cover"] self.logo_path = xtream._get_logo_local_path(self.logo) - self.url = "{}/series/{}/{}/{}.{}".format( - xtream.server, - xtream.authorization["username"], - xtream.authorization["password"], - self.id, - self.container_extension, - ) + self.url = f"{xtream.server}/series/" \ + f"{xtream.authorization['username']}/" \ + f"{xtream.authorization['password']}/{self.id}.{self.container_extension}" # Check that the constructed URL is valid if not xtream._validate_url(self.url): - print("{} - Bad URL? `{}`".format(self.name, self.url)) + print(f"{self.name} - Bad URL? `{self.url}`") class Serie: @@ -215,6 +209,7 @@ class Serie: def __init__(self, xtream: object, series_info): # Raw JSON Series self.raw = series_info + self.xtream = xtream # Required by Hypnotix self.name = series_info["name"] @@ -249,13 +244,22 @@ def __init__(self, name): self.name = name self.episodes = {} +class MyStatus(Protocol): + def __call__(self, string: str, guiOnly: bool) -> None: ... class XTream: name = "" server = "" + secure_server = "" username = "" password = "" + base_url = "" + base_url_ssl = "" + + cache_path = "" + + account_expiration: timedelta live_type = "Live" vod_type = "VOD" @@ -269,17 +273,20 @@ class XTream: series = [] movies = [] - state = {"authenticated": False, "loaded": False} + connection_headers = {} + + state = {'authenticated': False, 'loaded': False} hide_adult_content = False - catch_all_group = Group( - { - "category_id": "9999", - "category_name":"xEverythingElse", - "parent_id":0 - }, - "" + live_catch_all_group = Group( + {"category_id": "9999", "category_name":"xEverythingElse", "parent_id":0}, live_type + ) + vod_catch_all_group = Group( + {"category_id": "9999", "category_name":"xEverythingElse", "parent_id":0}, vod_type + ) + series_catch_all_group = Group( + {"category_id": "9999", "category_name":"xEverythingElse", "parent_id":0}, series_type ) # If the cached JSON file is older than threshold_time_sec then load a new # JSON dictionary from the provider @@ -287,12 +294,14 @@ class XTream: def __init__( self, + update_status: MyStatus, provider_name: str, provider_username: str, provider_password: str, provider_url: str, + headers: dict = None, hide_adult_content: bool = False, - cache_path: str = "", + cache_path: str = "" ): """Initialize Xtream Class @@ -301,8 +310,9 @@ def __init__( provider_username (str): User name of the IPTV provider provider_password (str): Password of the IPTV provider provider_url (str): URL of the IPTV provider - hide_adult_content(bool): When `True` hide stream that are marked for adult - cache_path (str, optional): Location where to save loaded files. Defaults to empty string. + headers (dict): Requests Headers + hide_adult_content(bool, optional): When `True` hide stream that are marked for adult + cache_path (str, optional): Location where to save loaded files. Defaults to empty string Returns: XTream Class Instance @@ -316,13 +326,14 @@ def __init__( self.name = provider_name self.cache_path = cache_path self.hide_adult_content = hide_adult_content + self.update_status = update_status # 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 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 == "": @@ -330,6 +341,11 @@ def __init__( if not osp.isdir(self.cache_path): makedirs(self.cache_path, exist_ok=True) + if headers is not None: + self.connection_headers = headers + else: + self.connection_headers = {'User-Agent':"Wget/1.20.3 (linux-gnu)"} + self.authenticate() def search_stream(self, keyword: str, ignore_case: bool = True, return_type: str = "LIST") -> List: @@ -351,24 +367,24 @@ def search_stream(self, keyword: str, ignore_case: bool = True, return_type: str else: regex = re.compile(keyword) - print("Checking {} movies".format(len(self.movies))) + print(f"Checking {len(self.movies)} movies") for stream in self.movies: if re.match(regex, stream.name) is not None: search_result.append(stream.export_json()) - print("Checking {} channels".format(len(self.channels))) + print(f"Checking {len(self.channels)} channels") for stream in self.channels: if re.match(regex, stream.name) is not None: search_result.append(stream.export_json()) - print("Checking {} series".format(len(self.series))) + print(f"Checking {len(self.series)} series") for stream in self.series: if re.match(regex, 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)) + print(f"Found {len(search_result)} results `{keyword}`") return json.dumps(search_result, ensure_ascii=False) else: return search_result @@ -414,10 +430,9 @@ def _get_logo_local_path(self, logo_url: str) -> str: if not self._validate_url(logo_url): logo_url = None else: - local_logo_path = osp.join(self.cache_path, "{}-{}".format( - self._slugify(self.name), - self._slugify(osp.split(logo_url)[-1]) - ) + local_logo_path = osp.join( + self.cache_path, + f"{self._slugify(self.name)}-{self._slugify(osp.split(logo_url)[-1])}" ) return local_logo_path @@ -427,22 +442,50 @@ def authenticate(self): if self.state["authenticated"] is False: # Erase any previous data self.auth_data = {} - try: - # Request authentication, wait 4 seconds maximum - r = requests.get(self.get_authenticate_URL(), timeout=(4)) + # Loop through 30 seconds + i = 0 + r = None + # Prepare the authentication url + url = f"{self.server}/player_api.php?username={self.username}&password={self.password}" + print("Attempting connection... ", end='') + while i < 30: + try: + # Request authentication, wait 4 seconds maximum + r = requests.get(url, timeout=(4), headers=self.connection_headers) + i = 31 + except requests.exceptions.ConnectionError: + time.sleep(1) + print(f"{i} ", end='',flush=True) + i += 1 + + if r is not None: # If the answer is ok, process data and change state if r.ok: + print("Connected") self.auth_data = r.json() self.authorization = { "username": self.auth_data["user_info"]["username"], - "password": self.auth_data["user_info"]["password"], + "password": self.auth_data["user_info"]["password"] } + # Account expiration date + self.account_expiration = timedelta( + seconds=( + int(self.auth_data["user_info"]["exp_date"])-datetime.now().timestamp() + ) + ) + # Mark connection authorized self.state["authenticated"] = True + # Construct the base url for all requests + self.base_url = f"{self.server}/player_api.php?username={self.username}&password={self.password}" + # If there is a secure server connection, construct the base url SSL for all requests + if "https_port" in self.auth_data["server_info"]: + self.base_url_ssl = f"https://{self.auth_data['server_info']['url']}:{self.auth_data['server_info']['https_port']}" \ + f"/player_api.php?username={self.username}&password={self.password}" + print(f"Account expires in {str(self.account_expiration)}") else: - print("Provider `{}` could not be loaded. Reason: `{} {}`".format(self.name, r.status_code, r.reason)) - except requests.exceptions.ConnectionError: - # If connection refused - print("{} - Connection refused URL: {}".format(self.name, self.server)) + self.update_status(f"{self.name}: Provider could not be loaded. Reason: `{r.status_code} {r.reason}`") + else: + self.update_status(f"{self.name}: Provider refused the connection") def _load_from_file(self, filename) -> dict: """Try to load the dictionary from file @@ -454,21 +497,19 @@ def _load_from_file(self, filename) -> dict: dict: Dictionary if found and no errors, None if file does not exists """ # Build the full path - full_filename = osp.join(self.cache_path, "{}-{}".format( - self._slugify(self.name), - filename - )) + full_filename = osp.join(self.cache_path, f"{self._slugify(self.name)}-{filename}") + # If the cached file exists, attempt to load it if osp.isfile(full_filename): my_data = None # Get the enlapsed seconds since last file update - diff_time = time.time() - osp.getmtime(full_filename) + file_age_sec = time.time() - osp.getmtime(full_filename) # If the file was updated less than the threshold time, # it means that the file is still fresh, we can load it. # Otherwise skip and return None to force a re-download - if self.threshold_time_sec > diff_time: + if self.threshold_time_sec > file_age_sec: # Load the JSON data try: with open(full_filename, mode="r", encoding="utf-8") as myfile: @@ -476,12 +517,10 @@ def _load_from_file(self, filename) -> dict: if len(my_data) == 0: my_data = None except Exception as e: - print(" - Could not load from file `{}`: e=`{}`".format( - full_filename, e - )) + print(f" - Could not load from file `{full_filename}`: e=`{e}`") return my_data - else: - return None + + return None def _save_to_file(self, data_list: dict, filename: str) -> bool: """Save a dictionary to file @@ -498,26 +537,21 @@ def _save_to_file(self, data_list: dict, filename: str) -> bool: if data_list is not None: #Build the full path - full_filename = osp.join(self.cache_path, "{}-{}".format( - self._slugify(self.name), - filename - )) + full_filename = osp.join(self.cache_path, f"{self._slugify(self.name)}-{filename}") # If the path makes sense, save the file json_data = json.dumps(data_list, ensure_ascii=False) try: with open(full_filename, mode="wt", encoding="utf-8") as myfile: myfile.write(json_data) except Exception as e: - print(" - Could not save to file `{}`: e=`{}`".format( - full_filename, e - )) + print(f" - Could not save to file `{full_filename}`: e=`{e}`") return False return True else: return False - def load_iptv(self): + def load_iptv(self) -> bool: """Load XTream IPTV - Add all Live TV to XTream.channels @@ -528,169 +562,213 @@ def load_iptv(self): - Add all groups to XTream.groups Groups are for all three channel types, Live TV, VOD, and Series + Returns: + bool: True if successfull, False if error """ - # If pyxtream has already authenticated the connection and not loaded the data, start loading - if self.state["authenticated"] is True: - if self.state["loaded"] is False: - - for loading_stream_type in (self.live_type, self.vod_type, self.series_type): - ## Get GROUPS - - # Try loading local file - dt = 0 - all_cat = self._load_from_file("all_groups_{}.json".format( - loading_stream_type - )) - # If file empty or does not exists, download it from remote - if all_cat is None: - # Load all Groups and save file locally - start = timer() - all_cat = self._load_categories_from_provider(loading_stream_type) - self._save_to_file(all_cat,"all_groups_{}.json".format( - loading_stream_type - )) - dt = timer() - start - - # If we got the GROUPS data, show the statistics and load GROUPS - if all_cat is not None: - print("Loaded {} {} Groups in {:.3f} seconds".format( - len(all_cat), loading_stream_type, dt - )) - ## 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) - # Add to xtream class - self.groups.append(new_group) - - # Add the catch-all-errors group - self.groups.append(Group({"category_id": "9999", "category_name": "xEverythingElse", "parent_id": 0}, loading_stream_type)) - - # Sort Categories - self.groups.sort(key=lambda x: x.name) - else: - print(" - Could not load {} Groups".format(loading_stream_type)) - break - - ## Get Streams - - # Try loading local file - dt = 0 - all_streams = self._load_from_file("all_stream_{}.json".format( - loading_stream_type - )) - # If file empty or does not exists, download it from remote - if all_streams is None: - # Load all Streams and save file locally - start = timer() - all_streams = self._load_streams_from_provider(loading_stream_type) - self._save_to_file(all_streams,"all_stream_{}.json".format( - loading_stream_type - )) - dt = timer() - start - - # If we got the STREAMS data, show the statistics and load Streams - if all_streams is not None: - print("Loaded {} {} Streams in {:.3f} seconds".format( - len(all_streams), loading_stream_type, dt - )) - ## Add Streams to dictionaries - - skipped_adult_content = 0 - skipped_no_name_content = 0 - - for stream_channel in all_streams: - skip_stream = False - - # Skip if the name of the stream is empty - if stream_channel["name"] == "": - skip_stream = True - skipped_no_name_content = skipped_no_name_content + 1 - self._save_to_file_skipped_streams(stream_channel) + # If pyxtream has not authenticated the connection, return empty + if self.state["authenticated"] is False: + print("Warning, cannot load steams since authorization failed") + return False - # Skip if the user chose to hide adult streams - if self.hide_adult_content and loading_stream_type == self.live_type: - try: - if stream_channel["is_adult"] == "1": - skip_stream = True - skipped_adult_content = skipped_adult_content + 1 - self._save_to_file_skipped_streams(stream_channel) - except Exception: - print(" - Stream does not have `is_adult` key:\n\t`{}`".format(json.dumps(stream_channel))) - pass - - if not skip_stream: - # Some channels have no group, - # so let's add them to the catch all group - if not stream_channel["category_id"]: - stream_channel["category_id"] = "9999" - - # Find the first occurence of the group that the - # Channel or Stream is pointing to - the_group = next( - (x for x in self.groups if x.group_id == int(stream_channel["category_id"])), - None + # If pyxtream has already loaded the data, skip and return success + if self.state["loaded"] is True: + print("Warning, data has already been loaded.") + return True + + # Delete skipped channels from cache + full_filename = osp.join(self.cache_path, "skipped_streams.json") + try: + f = open(full_filename, mode="r+", encoding="utf-8") + f.truncate(0) + f.close() + except FileNotFoundError: + pass + + for loading_stream_type in (self.live_type, self.vod_type, self.series_type): + ## Get GROUPS + + # Try loading local file + dt = 0 + start = timer() + all_cat = self._load_from_file(f"all_groups_{loading_stream_type}.json") + # If file empty or does not exists, download it from remote + if all_cat is None: + # Load all Groups and save file locally + all_cat = self._load_categories_from_provider(loading_stream_type) + if all_cat is not None: + self._save_to_file(all_cat,f"all_groups_{loading_stream_type}.json") + dt = timer() - start + + # If we got the GROUPS data, show the statistics and load GROUPS + if all_cat is not None: + self.update_status( + f"{self.name}: Loaded {len(all_cat)} {loading_stream_type} Groups in {dt:.3f} seconds" + ) + + ## Add GROUPS to dictionaries + + # Add the catch-all-errors group + if loading_stream_type == self.live_type: + self.groups.append(self.live_catch_all_group) + elif loading_stream_type == self.vod_type: + self.groups.append(self.vod_catch_all_group) + elif loading_stream_type == self.series_type: + self.groups.append(self.series_catch_all_group) + + for cat_obj in all_cat: + # Create Group (Category) + new_group = Group(cat_obj, loading_stream_type) + # Add to xtream class + self.groups.append(new_group) + + # Add the catch-all-errors group + self.groups.append(Group({"category_id": "9999", "category_name": "xEverythingElse", "parent_id": 0}, loading_stream_type)) + + # Sort Categories + self.groups.sort(key=lambda x: x.name) + else: + print(f" - Could not load {loading_stream_type} Groups") + break + + ## Get Streams + + # Try loading local file + dt = 0 + start = timer() + all_streams = self._load_from_file(f"all_stream_{loading_stream_type}.json") + # If file empty or does not exists, download it from remote + if all_streams is None: + # Load all Streams and save file locally + all_streams = self._load_streams_from_provider(loading_stream_type) + self._save_to_file(all_streams,f"all_stream_{loading_stream_type}.json") + dt = timer() - start + + # If we got the STREAMS data, show the statistics and load Streams + if all_streams is not None: + print( + f"{self.name}: Loaded {len(all_streams)} {loading_stream_type} Streams " \ + f"in {dt:.3f} seconds" + ) + ## Add Streams to dictionaries + + skipped_adult_content = 0 + skipped_no_name_content = 0 + + number_of_streams = len(all_streams) + current_stream_number = 0 + # Calculate 1% of total number of streams + # This is used to slow down the progress bar + one_percent_number_of_streams = number_of_streams/100 + start = timer() + for stream_channel in all_streams: + skip_stream = False + current_stream_number += 1 + + # Show download progress every 1% of total number of streams + if current_stream_number < one_percent_number_of_streams: + percent = progress( + current_stream_number, + number_of_streams, + f"Processing {loading_stream_type} Streams" ) + one_percent_number_of_streams *= 2 + + # Inform the user + self.update_status( + f"{self.name}: Processing {number_of_streams} {loading_stream_type} Streams {percent:.0f}%", + None, + True + ) + + # Skip if the name of the stream is empty + if stream_channel["name"] == "": + skip_stream = True + skipped_no_name_content = skipped_no_name_content + 1 + self._save_to_file_skipped_streams(stream_channel) + + # Skip if the user chose to hide adult streams + if self.hide_adult_content and loading_stream_type == self.live_type: + if "is_adult" in stream_channel: + if stream_channel["is_adult"] == "1": + skip_stream = True + skipped_adult_content = skipped_adult_content + 1 + self._save_to_file_skipped_streams(stream_channel) - # Set group title - if the_group is not None: - group_title = the_group.name - else: - group_title = self.catch_all_group.name - the_group = self.catch_all_group - - if loading_stream_type == self.series_type: - # Load all Series - new_series = Serie(self, stream_channel) - # To get all the Episodes for every Season of each - # Series is very time consuming, we will only - # populate the Series once the user click on the - # Series, the Seasons and Episodes will be loaded - # using x.getSeriesInfoByID() function - - else: - new_channel = Channel( - 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: - self.channels.append(new_channel) - elif loading_stream_type == self.vod_type: - self.movies.append(new_channel) - else: - self.series.append(new_series) - - # Add stream to the specific Group - if the_group is not None: - if loading_stream_type != self.series_type: - the_group.channels.append(new_channel) - else: - the_group.series.append(new_series) - else: - print(" - Group not found `{}`".format(stream_channel["name"])) - - # Print information of which streams have been skipped - if self.hide_adult_content: - print(" - Skipped {} adult {} streams".format(skipped_adult_content, loading_stream_type)) - if skipped_no_name_content > 0: - print(" - Skipped {} unprintable {} streams".format(skipped_no_name_content, loading_stream_type)) - else: - print(" - Could not load {} Streams".format(loading_stream_type)) - - self.state["loaded"] = True + if not skip_stream: + # Some channels have no group, + # so let's add them to the catch all group + if stream_channel["category_id"] is None: + stream_channel["category_id"] = "9999" + elif stream_channel["category_id"] != "1": + pass + + # Find the first occurence of the group that the + # Channel or Stream is pointing to + the_group = next( + (x for x in self.groups if x.group_id == int(stream_channel["category_id"])), + None + ) + # Set group title + if the_group is not None: + group_title = the_group.name + else: + if loading_stream_type == self.live_type: + group_title = self.live_catch_all_group.name + the_group = self.live_catch_all_group + elif loading_stream_type == self.vod_type: + group_title = self.vod_catch_all_group.name + the_group = self.vod_catch_all_group + elif loading_stream_type == self.series_type: + group_title = self.series_catch_all_group.name + the_group = self.series_catch_all_group + + + if loading_stream_type == self.series_type: + # Load all Series + new_series = Serie(self, stream_channel) + # To get all the Episodes for every Season of each + # Series is very time consuming, we will only + # populate the Series once the user click on the + # Series, the Seasons and Episodes will be loaded + # using x.getSeriesInfoByID() function + + else: + new_channel = Channel( + self, group_title, stream_channel + ) + + if new_channel.group_id == "9999": + print(f" - xEverythingElse Channel -> {new_channel.name} - {new_channel.stream_type}") + + # Save the new channel to the local list of channels + if loading_stream_type == self.live_type: + self.channels.append(new_channel) + elif loading_stream_type == self.vod_type: + self.movies.append(new_channel) + else: + self.series.append(new_series) + + # Add stream to the specific Group + if the_group is not None: + if loading_stream_type != self.series_type: + the_group.channels.append(new_channel) + else: + the_group.series.append(new_series) + else: + print(f" - Group not found `{stream_channel['name']}`") + print("\n") + dt = timer() - start + # Print information of which streams have been skipped + if self.hide_adult_content: + print(f" - Skipped {skipped_adult_content} adult {loading_stream_type} streams") + if skipped_no_name_content > 0: + print(f" - Skipped {skipped_no_name_content} " + "unprintable {loading_stream_type} streams") else: - print("Warning, data has already been loaded.") - else: - print("Warning, cannot load steams since authorization failed") + print(f" - Could not load {loading_stream_type} Streams") + + self.state["loaded"] = True def _save_to_file_skipped_streams(self, stream_channel: Channel): @@ -702,25 +780,28 @@ def _save_to_file_skipped_streams(self, stream_channel: Channel): try: with open(full_filename, mode="a", encoding="utf-8") as myfile: myfile.writelines(json_data) + myfile.write('\n') + return True except Exception as e: - print(" - Could not save to skipped stream file `{}`: e=`{}`".format( - full_filename, e - )) - return False + print(f" - Could not save to skipped stream file `{full_filename}`: e=`{e}`") + return False def get_series_info_by_id(self, get_series: dict): - """Get Seasons and Episodes for a Serie + """Get Seasons and Episodes for a Series Args: - get_series (dict): Serie dictionary + get_series (dict): Series dictionary """ - start = timer() + series_seasons = self._load_series_info_by_id_from_provider(get_series.series_id) - dt = timer() - start - # print("Loaded in {:.3f} sec".format(dt)) + + if series_seasons["seasons"] is None: + series_seasons["seasons"] = [ + {"name": "Season 1", "cover": series_seasons["info"]["cover"]} + ] + for series_info in series_seasons["seasons"]: season_name = series_info["name"] - season_key = series_info["season_number"] season = Season(season_name) get_series.seasons[season_name] = season if "episodes" in series_seasons.keys(): @@ -731,32 +812,77 @@ def get_series_info_by_id(self, get_series: dict): ) season.episodes[episode_info["title"]] = new_episode_channel - def _get_request(self, URL: str, timeout: Tuple = (2, 15)): + def _handle_request_exception(self, exception: requests.exceptions.RequestException): + """Handle different types of request exceptions.""" + if isinstance(exception, requests.exceptions.ConnectionError): + print(" - Connection Error: Possible network problem \ + (e.g. DNS failure, refused connection, etc)") + elif isinstance(exception, requests.exceptions.HTTPError): + print(" - HTTP Error") + elif isinstance(exception, requests.exceptions.TooManyRedirects): + print(" - TooManyRedirects") + elif isinstance(exception, requests.exceptions.ReadTimeout): + print(" - Timeout while loading data") + else: + print(f" - An unexpected error occurred: {exception}") + + def _get_request(self, url: str, timeout: Tuple[int, int] = (2, 15)) -> Optional[dict]: """Generic GET Request with Error handling Args: URL (str): The URL where to GET content - timeout (Tuple, optional): Connection and Downloading Timeout. Defaults to (2,15). + timeout (Tuple[int, int], optional): Connection and Downloading Timeout. + Defaults to (2,15). Returns: - [type]: JSON dictionary of the loaded data, or None + Optional[dict]: JSON dictionary of the loaded data, or None """ - try: - r = requests.get(URL, timeout=timeout) - if r.status_code == 200: - return r.json() - - except requests.exceptions.ConnectionError: - print(" - Connection Error") - except requests.exceptions.HTTPError: - print(" - HTTP Error") + kb_size = 1024 + all_data = [] + down_stats = {"bytes": 0, "kbytes": 0, "mbytes": 0, "start": 0.0, "delta_sec": 0.0} - except requests.exceptions.TooManyRedirects: - print(" - TooManyRedirects") - - except requests.exceptions.ReadTimeout: - print(" - Timeout while loading data") + for attempt in range(10): + try: + response = requests.get( + url, + stream=True, + timeout=timeout, + headers=self.connection_headers + ) + response.raise_for_status() # Raise an HTTPError for bad responses (4xx and 5xx) + break + except requests.exceptions.RequestException as e: + self._handle_request_exception(e) + return None + + # If there is an answer from the remote server + if response.status_code in (200, 206): + down_stats["start"] = time.perf_counter() + + # Set downloaded size + down_stats["bytes"] = 0 + + # Set stream blocks + block_bytes = int(1*kb_size*kb_size) # 4 MB + + # Grab data by block_bytes + for data in response.iter_content(block_bytes, decode_unicode=False): + down_stats["bytes"] += len(data) + down_stats["kbytes"] = down_stats["bytes"]/kb_size + down_stats["mbytes"] = down_stats["bytes"]/kb_size/kb_size + down_stats["delta_sec"] = time.perf_counter() - down_stats["start"] + download_speed_average = down_stats["kbytes"]//down_stats["delta_sec"] + msg = f'\rDownloading {down_stats["kbytes"]:.1f} MB at {download_speed_average:.0f} kB/s' + sys.stdout.write(msg) + sys.stdout.flush() + self.update_status(msg, None, True) + all_data.append(data) + print(" - Done") + full_content = b''.join(all_data) + return json.loads(full_content) + + print(f"HTTP error {response.status_code} while retrieving from {url}") return None @@ -770,17 +896,17 @@ def _load_categories_from_provider(self, stream_type: str): Returns: [type]: JSON if successfull, otherwise None """ - theURL = "" + url = "" if stream_type == self.live_type: - theURL = self.get_live_categories_URL() + url = self.get_live_categories_URL() elif stream_type == self.vod_type: - theURL = self.get_vod_cat_URL() + url = self.get_vod_cat_URL() elif stream_type == self.series_type: - theURL = self.get_series_cat_URL() + url = self.get_series_cat_URL() else: - theURL = "" + url = "" - return self._get_request(theURL) + return self._get_request(url) # GET Streams def _load_streams_from_provider(self, stream_type: str): @@ -792,17 +918,17 @@ def _load_streams_from_provider(self, stream_type: str): Returns: [type]: JSON if successfull, otherwise None """ - theURL = "" + url = "" if stream_type == self.live_type: - theURL = self.get_live_streams_URL() + url = self.get_live_streams_URL() elif stream_type == self.vod_type: - theURL = self.get_vod_streams_URL() + url = self.get_vod_streams_URL() elif stream_type == self.series_type: - theURL = self.get_series_URL() + url = self.get_series_URL() else: - theURL = "" + url = "" - return self._get_request(theURL) + return self._get_request(url) # GET Streams by Category def _load_streams_by_category_from_provider(self, stream_type: str, category_id): @@ -815,18 +941,18 @@ def _load_streams_by_category_from_provider(self, stream_type: str, category_id) Returns: [type]: JSON if successfull, otherwise None """ - theURL = "" + url = "" if stream_type == self.live_type: - theURL = self.get_live_streams_URL_by_category(category_id) + url = self.get_live_streams_URL_by_category(category_id) elif stream_type == self.vod_type: - theURL = self.get_vod_streams_URL_by_category(category_id) + url = self.get_vod_streams_URL_by_category(category_id) elif stream_type == self.series_type: - theURL = self.get_series_URL_by_category(category_id) + url = self.get_series_URL_by_category(category_id) else: - theURL = "" + url = "" - return self._get_request(theURL) + return self._get_request(url) # GET SERIES Info def _load_series_info_by_id_from_provider(self, series_id: str): @@ -868,66 +994,79 @@ def allEpg(self): return self._get_request(self.get_all_epg_URL()) ## URL-builder methods - def get_authenticate_URL(self): - URL = "%s/player_api.php?username=%s&password=%s" % (self.server, self.username, self.password) - return URL - - def get_live_categories_URL(self): - URL = "%s/player_api.php?username=%s&password=%s&action=%s" % (self.server, self.username, self.password, "get_live_categories") - return URL - - def get_live_streams_URL(self): - URL = "%s/player_api.php?username=%s&password=%s&action=%s" % (self.server, self.username, self.password, "get_live_streams") - return URL - - def get_live_streams_URL_by_category(self, category_id): - URL = "%s/player_api.php?username=%s&password=%s&action=%s&category_id=%s" % (self.server, self.username, self.password, "get_live_streams", category_id) - return URL - - def get_vod_cat_URL(self): - URL = "%s/player_api.php?username=%s&password=%s&action=%s" % (self.server, self.username, self.password, "get_vod_categories") - return URL - - def get_vod_streams_URL(self): - URL = "%s/player_api.php?username=%s&password=%s&action=%s" % (self.server, self.username, self.password, "get_vod_streams") - return URL - - def get_vod_streams_URL_by_category(self, category_id): - URL = "%s/player_api.php?username=%s&password=%s&action=%s&category_id=%s" % (self.server, self.username, self.password, "get_vod_streams", category_id) - return URL - - def get_series_cat_URL(self): - URL = "%s/player_api.php?username=%s&password=%s&action=%s" % (self.server, self.username, self.password, "get_series_categories") - return URL - - def get_series_URL(self): - URL = "%s/player_api.php?username=%s&password=%s&action=%s" % (self.server, self.username, self.password, "get_series") - return URL - - def get_series_URL_by_category(self, category_id): - URL = "%s/player_api.php?username=%s&password=%s&action=%s&category_id=%s" % (self.server, self.username, self.password, "get_series", category_id) - return URL - - def get_series_info_URL_by_ID(self, series_id): - URL = "%s/player_api.php?username=%s&password=%s&action=%s&series_id=%s" % (self.server, self.username, self.password, "get_series_info", series_id) - return URL - - def get_VOD_info_URL_by_ID(self, vod_id): - URL = "%s/player_api.php?username=%s&password=%s&action=%s&vod_id=%s" % (self.server, self.username, self.password, "get_vod_info", vod_id) - return URL - - def get_live_epg_URL_by_stream(self, stream_id): - URL = "%s/player_api.php?username=%s&password=%s&action=%s&stream_id=%s" % (self.server, self.username, self.password, "get_short_epg", stream_id) - return URL - - def get_live_epg_URL_by_stream_and_limit(self, stream_id, limit): - URL = "%s/player_api.php?username=%s&password=%s&action=%s&stream_id=%s&limit=%s" % (self.server, self.username, self.password, "get_short_epg", stream_id, limit) - return URL - - def get_all_live_epg_URL_by_stream(self, stream_id): - URL = "%s/player_api.php?username=%s&password=%s&action=%s&stream_id=%s" % (self.server, self.username, self.password, "get_simple_data_table", stream_id) - return URL - - def get_all_epg_URL(self): - URL = "%s/xmltv.php?username=%s&password=%s" % (self.server, self.username, self.password) - return URL + def get_live_categories_URL(self) -> str: + return f"{self.base_url}&action=get_live_categories" + + def get_live_streams_URL(self) -> str: + return f"{self.base_url}&action=get_live_streams" + + def get_live_streams_URL_by_category(self, category_id) -> str: + return f"{self.base_url}&action=get_live_streams&category_id={category_id}" + + def get_vod_cat_URL(self) -> str: + return f"{self.base_url}&action=get_vod_categories" + + def get_vod_streams_URL(self) -> str: + return f"{self.base_url}&action=get_vod_streams" + + def get_vod_streams_URL_by_category(self, category_id) -> str: + return f"{self.base_url}&action=get_vod_streams&category_id={category_id}" + + def get_series_cat_URL(self) -> str: + return f"{self.base_url}&action=get_series_categories" + + def get_series_URL(self) -> str: + return f"{self.base_url}&action=get_series" + + def get_series_URL_by_category(self, category_id) -> str: + return f"{self.base_url}&action=get_series&category_id={category_id}" + + def get_series_info_URL_by_ID(self, series_id) -> str: + return f"{self.base_url}&action=get_series_info&series_id={series_id}" + + def get_VOD_info_URL_by_ID(self, vod_id) -> str: + return f"{self.base_url}&action=get_vod_info&vod_id={vod_id}" + + def get_live_epg_URL_by_stream(self, stream_id) -> str: + return f"{self.base_url}&action=get_short_epg&stream_id={stream_id}" + + def get_live_epg_URL_by_stream_and_limit(self, stream_id, limit) -> str: + return f"{self.base_url}&action=get_short_epg&stream_id={stream_id}&limit={limit}" + + def get_all_live_epg_URL_by_stream(self, stream_id) -> str: + return f"{self.base_url}&action=get_simple_data_table&stream_id={stream_id}" + + def get_all_epg_URL(self) -> str: + return f"{self.server}/xmltv.php?username={self.username}&password={self.password}" + +# The MIT License (MIT) +# Copyright (c) 2016 Vladimir Ignatev +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the Software +# is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +# INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +# FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT +# OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE +# OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +def progress(count, total, status='') -> float: + bar_len = 60 + filled_len = int(round(bar_len * count / float(total))) + + percents = round(100.0 * count / float(total), 1) + bar_value = '=' * filled_len + '-' * (bar_len - filled_len) + + #stdout.write('[%s] %s%s ...%s\r' % (bar_value, percents, '%', status)) + stdout.write(f"[{bar_value}] {percents:.0f}% ...{status}\r") + stdout.flush() # As suggested by Rom Ruben (see: http://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console/27871113#comment50529068_27871113) + return percents 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..00be8a3a 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 @@ -16,6 +16,16 @@ + + true + + + + + true + + + "Free-TV" Provider selected by default diff --git a/usr/share/hypnotix/hypnotix.ui b/usr/share/hypnotix/hypnotix.ui index 0308f1bb..d7eef450 100644 --- a/usr/share/hypnotix/hypnotix.ui +++ b/usr/share/hypnotix/hypnotix.ui @@ -1002,7 +1002,7 @@ False 12 - + True False @@ -1015,9 +1015,10 @@ True False + This option only works if the provider adds relevant information (is_adult). start center - MPV Options + Hide Adult Streams @@ -1028,11 +1029,10 @@ - + True True - center - True + This option only works if the provider adds relevant information (is_adult). 1 @@ -1040,15 +1040,24 @@ - - List of MPV options + True - True - True + False + Removes empty groups from the lists start center - none - https://mpv.io/manual/master/#options + Hide Empty Groups + + + 0 + 1 + + + + + True + True + Removes empty groups from the lists 1 @@ -1073,10 +1082,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - Playback - video-x-generic-symbolic + General + preferences-system-network-symbolic @@ -1337,6 +1376,85 @@ 2 + + + + True + False + start + 20 + 20 + 12 + 12 + + + True + False + start + center + MPV Options + + + + + + 0 + 0 + + + + + True + True + center + True + + + 1 + 0 + + + + + List of MPV options + True + True + True + start + center + none + https://mpv.io/manual/master/#options + + + 1 + 1 + + + + + + + + + + + + + + + + + + + + + + + Playback + video-x-generic-symbolic + 3 + + True