From 6774e46da8b67c8b4a3fde9e91da42dbd8ce44ef Mon Sep 17 00:00:00 2001 From: pcjco Date: Sun, 3 Nov 2024 22:15:53 +0100 Subject: [PATCH 1/7] Centralized provider-related operations through a new `provider_manager` in `channel_list.py`, improving code organization and maintainability. Enhanced the `ChannelList` UI with a splitter and content info panel for STB vod and serie. Introduced `SetProviderThread` for improved responsiveness during initialization. Added `ContentLoader` class in `content_loader.py` for asynchronous content loading. Updated `ConfigManager` in `config_manager.py` and added new default options. Added new properties for configuration options. Restructured `OptionsDialog` in `options.py` to include tabs for settings and providers, with new fields and buttons for managing provider settings. `ProviderContext` and `ProviderManager` classes in `provider_manager.py` for managing provider configurations and interactions. Updated `requirements.txt` to include new dependencies. Added new signals to `VideoPlayer` in `video_player.py`. --- channel_list.py | 883 +++++++++++++++++++++++++------------------- config_manager.py | 90 +++-- content_loader.py | 160 ++++++++ main.py | 9 +- options.py | 260 +++++++++---- provider_manager.py | 189 ++++++++++ requirements.txt | 1 + video_player.py | 27 +- 8 files changed, 1121 insertions(+), 498 deletions(-) create mode 100644 content_loader.py create mode 100644 provider_manager.py diff --git a/channel_list.py b/channel_list.py index fcab0a1..2b9d9d6 100644 --- a/channel_list.py +++ b/channel_list.py @@ -1,30 +1,28 @@ -import asyncio import os import platform -import random import re import shutil -import string import subprocess import time from urllib.parse import urlparse +from content_loader import ContentLoader -import aiohttp -import orjson import requests from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtGui import QColor from PySide6.QtWidgets import ( QCheckBox, QFileDialog, - QGridLayout, QHBoxLayout, + QLabel, QLineEdit, QMainWindow, QMessageBox, QProgressBar, QPushButton, QRadioButton, + QSizePolicy, + QSplitter, QTreeWidget, QTreeWidgetItem, QVBoxLayout, @@ -62,200 +60,127 @@ def __lt__(self, other): return super(NumberedTreeWidgetItem, self).__lt__(other) sort_column = self.treeWidget().sortColumn() - sort_header = self.treeWidget().headerItem().text(sort_column) - if sort_header == "#": + if sort_column == 0: # Channel number return int(self.text(sort_column)) < int(other.text(sort_column)) return self.text(sort_column) < other.text(sort_column) +class SetProviderThread(QThread): + progress = Signal(str) -class ContentLoader(QThread): - content_loaded = Signal(dict) - progress_updated = Signal(int, int) - - def __init__( - self, - url, - headers, - content_type, - category_id=None, - parent_id=None, - movie_id=None, - season_id=None, - action="get_ordered_list", - sortby="name", - ): + def __init__(self, provider_manager): super().__init__() - self.url = url - self.headers = headers - self.content_type = content_type - self.category_id = category_id - self.parent_id = parent_id - self.movie_id = movie_id - self.season_id = season_id - self.action = action - self.sortby = sortby - self.items = [] - - async def fetch_page(self, session, page, max_retries=3): - for attempt in range(max_retries): - try: - params = self.get_params(page) - async with session.get( - self.url, headers=self.headers, params=params, timeout=30 - ) as response: - content = await response.read() - if response.status == 503 or not content: - wait_time = (2**attempt) + random.uniform(0, 1) - print( - f"Received error or empty response. Retrying in {wait_time:.2f} seconds..." - ) - await asyncio.sleep(wait_time) - continue - result = orjson.loads(content) - return ( - result["js"]["data"], - int(result["js"]["total_items"]), - int(result["js"]["max_page_items"]), - ) - except ( - aiohttp.ClientError, - orjson.JSONDecodeError, - asyncio.TimeoutError, - ) as e: - print(f"Error fetching page {page}: {e}") - if attempt == max_retries - 1: - raise - wait_time = (2**attempt) + random.uniform(0, 1) - print(f"Retrying in {wait_time:.2f} seconds...") - await asyncio.sleep(wait_time) - return [], 0, 0 - - def get_params(self, page): - params = { - "type": self.content_type, - "action": self.action, - "p": str(page), - "JsHttpRequest": "1-xml", - } - if self.content_type == "itv": - params.update( - { - "genre": self.category_id if self.category_id else "*", - "force_ch_link_check": "", - "fav": "0", - "sortby": self.sortby, - "hd": "0", - } - ) - elif self.content_type == "vod": - params.update( - { - "category": self.category_id if self.category_id else "*", - "sortby": self.sortby, - } - ) - elif self.content_type == "series": - params.update( - { - "category": self.category_id if self.category_id else "*", - "movie_id": self.movie_id if self.movie_id else "0", - "season_id": self.season_id if self.season_id else "0", - "episode_id": "0", - "sortby": self.sortby, - } - ) - return params - - async def load_content(self): - async with aiohttp.ClientSession() as session: - # Fetch initial data to get total items and max page items - page = 1 - page_items, total_items, max_page_items = await self.fetch_page( - session, page - ) - self.items.extend(page_items) - - pages = (total_items + max_page_items - 1) // max_page_items - self.progress_updated.emit(1, pages) - - tasks = [] - for page_num in range(2, pages + 1): - tasks.append(self.fetch_page(session, page_num)) - - for i, task in enumerate(asyncio.as_completed(tasks), 2): - page_items, _, _ = await task - self.items.extend(page_items) - self.progress_updated.emit(i, pages) - - # Emit all items once done - self.content_loaded.emit( - { - "category_id": self.category_id, - "items": self.items, - "parent_id": self.parent_id, - "movie_id": self.movie_id, - "season_id": self.season_id, - } - ) + self.provider_manager = provider_manager def run(self): try: - asyncio.run(self.load_content()) + self.provider_manager.set_current_provider(self.progress) except Exception as e: - print(f"Error in content loading: {e}") - + print(f"Error in initializing provider: {e}") class ChannelList(QMainWindow): - content_loaded = Signal(list) - def __init__(self, app, player, config_manager): + def __init__(self, app, player, config_manager, provider_manager): super().__init__() self.app = app self.player = player self.config_manager = config_manager - self.config = self.config_manager.config + self.provider_manager = provider_manager + self.splitter_ratio = 0.75 self.config_manager.apply_window_settings("channel_list", self) self.setWindowTitle("QiTV Content List") self.container_widget = QWidget(self) self.setCentralWidget(self.container_widget) - self.grid_layout = QGridLayout(self.container_widget) self.content_type = "itv" # Default to channels (STB type) self.create_upper_panel() - self.create_left_panel() + self.create_list_panel() + self.create_content_info_panel() self.create_media_controls() + + self.main_layout = QVBoxLayout() + self.main_layout.addWidget(self.upper_layout) + self.main_layout.addWidget(self.list_panel) + self.main_layout.setContentsMargins(8, 8, 8, 8) + + widget_top = QWidget() + widget_top.setLayout(self.main_layout) + + # Splitter with content info part + self.splitter = QSplitter(Qt.Vertical) + self.splitter.addWidget(widget_top) + self.splitter.addWidget(self.content_info_panel) + self.splitter.setSizes([1, 0]) + + container_layout = QVBoxLayout(self.container_widget) + container_layout.setContentsMargins(0, 0, 0, 0) # Set margins to zero + container_layout.addWidget(self.splitter) + container_layout.addWidget(self.media_controls) + self.link = None self.current_category = None # For back navigation self.current_series = None self.current_season = None self.navigation_stack = [] # To keep track of navigation for back button - self.load_content() + # Connect player signals to show/hide media controls + self.player.playing.connect(self.show_media_controls) + self.player.stopped.connect(self.hide_media_controls) + + self.splitter.splitterMoved.connect(self.update_splitter_ratio) self.channels_radio.toggled.connect(self.toggle_content_type) self.movies_radio.toggled.connect(self.toggle_content_type) self.series_radio.toggled.connect(self.toggle_content_type) + self.set_provider() + def closeEvent(self, event): self.app.quit() self.player.close() - self.config_manager.save_window_settings(self.geometry(), "channel_list") + self.config_manager.save_window_settings(self, "channel_list") event.accept() + def set_provider(self): + self.lock_ui_before_loading() + self.progress_bar.setRange(0, 0) # busy indicator + self.content_list.setEnabled(False) + + self.set_provider_thread = SetProviderThread(self.provider_manager) + self.set_provider_thread.progress.connect(self.update_busy_progress) + self.set_provider_thread.finished.connect(self.set_provider_finished) + self.set_provider_thread.start() + + def set_provider_finished(self): + self.progress_bar.setRange(0, 100) # Stop busy indicator + if hasattr(self, "set_provider_thread"): + self.set_provider_thread.deleteLater() + del self.set_provider_thread + + self.load_content() + self.content_list.setEnabled(True) + self.unlock_ui_after_loading() + + def update_splitter_ratio(self, pos, index): + sizes = self.splitter.sizes() + total_size = sizes[0] + sizes[1] + self.splitter_ratio = sizes[0] / total_size + def create_upper_panel(self): self.upper_layout = QWidget(self.container_widget) main_layout = QVBoxLayout(self.upper_layout) + main_layout.setContentsMargins(0, 0, 0, 0) # Set margins to zero # Top row top_layout = QHBoxLayout() + top_layout.setContentsMargins(0, 0, 0, 0) # Set margins to zero self.open_button = QPushButton("Open File") self.open_button.clicked.connect(self.open_file) top_layout.addWidget(self.open_button) - self.options_button = QPushButton("Options") + self.options_button = QPushButton("Settings") self.options_button.clicked.connect(self.options_dialog) top_layout.addWidget(self.options_button) @@ -272,6 +197,7 @@ def create_upper_panel(self): # Bottom row (export buttons) bottom_layout = QHBoxLayout() + bottom_layout.setContentsMargins(0, 0, 0, 0) # Set margins to zero self.export_button = QPushButton("Export Browsed") self.export_button.clicked.connect(self.export_content) @@ -283,70 +209,164 @@ def create_upper_panel(self): main_layout.addLayout(bottom_layout) - self.grid_layout.addWidget(self.upper_layout, 0, 0) + def create_list_panel(self): + self.list_panel = QWidget(self.container_widget) + list_layout = QVBoxLayout(self.list_panel) + list_layout.setContentsMargins(0, 0, 0, 0) # Set margins to zero + + # Add content type selection + self.content_switch_group = QWidget(self.list_panel) + content_switch_layout = QHBoxLayout(self.content_switch_group) + content_switch_layout.setContentsMargins(0, 0, 0, 0) # Set margins to zero + + self.channels_radio = QRadioButton("Channels") + self.movies_radio = QRadioButton("Movies") + self.series_radio = QRadioButton("Series") + + content_switch_layout.addWidget(self.channels_radio) + content_switch_layout.addWidget(self.movies_radio) + content_switch_layout.addWidget(self.series_radio) + + self.channels_radio.setChecked(True) - def create_left_panel(self): - self.left_panel = QWidget(self.container_widget) - left_layout = QVBoxLayout(self.left_panel) + list_layout.addWidget(self.content_switch_group) - self.search_box = QLineEdit(self.left_panel) + self.search_box = QLineEdit(self.list_panel) self.search_box.setPlaceholderText("Search content...") self.search_box.textChanged.connect( lambda: self.filter_content(self.search_box.text()) ) - left_layout.addWidget(self.search_box) + list_layout.addWidget(self.search_box) - self.content_list = QTreeWidget(self.left_panel) + self.content_list = QTreeWidget(self.list_panel) + self.content_list.setSelectionMode(QTreeWidget.SingleSelection) self.content_list.setIndentation(0) - self.content_list.itemClicked.connect(self.item_selected) + self.content_list.setAlternatingRowColors(True) + self.content_list.itemSelectionChanged.connect(self.item_selected) + self.content_list.itemActivated.connect(self.item_activated) - left_layout.addWidget(self.content_list) - - self.grid_layout.addWidget(self.left_panel, 1, 0) - self.grid_layout.setColumnStretch(0, 1) + list_layout.addWidget(self.content_list, 1) + # Create a horizontal layout for the favorite button and checkbox + self.favorite_layout = QHBoxLayout() + # Add favorite button and action self.favorite_button = QPushButton("Favorite/Unfavorite") self.favorite_button.clicked.connect(self.toggle_favorite) - left_layout.addWidget(self.favorite_button) + self.favorite_layout.addWidget(self.favorite_button) # Add checkbox to show only favorites self.favorites_only_checkbox = QCheckBox("Show only favorites") self.favorites_only_checkbox.stateChanged.connect( lambda: self.filter_content(self.search_box.text()) ) - left_layout.addWidget(self.favorites_only_checkbox) - - # Add content type selection - self.content_switch_group = QWidget(self.left_panel) - content_switch_layout = QHBoxLayout(self.content_switch_group) - - self.channels_radio = QRadioButton("Channels") - self.movies_radio = QRadioButton("Movies") - self.series_radio = QRadioButton("Series") - - content_switch_layout.addWidget(self.channels_radio) - content_switch_layout.addWidget(self.movies_radio) - content_switch_layout.addWidget(self.series_radio) - - self.channels_radio.setChecked(True) - - self.channels_radio.toggled.connect(self.toggle_content_type) - self.movies_radio.toggled.connect(self.toggle_content_type) - self.series_radio.toggled.connect(self.toggle_content_type) + self.favorite_layout.addWidget(self.favorites_only_checkbox) - left_layout.addWidget(self.content_switch_group) + # Add the horizontal layout to the main vertical layout + list_layout.addLayout(self.favorite_layout) self.progress_bar = QProgressBar(self) self.progress_bar.setRange(0, 100) self.progress_bar.setValue(0) self.progress_bar.setVisible(False) - left_layout.addWidget(self.progress_bar) + list_layout.addWidget(self.progress_bar) self.cancel_button = QPushButton("Cancel") - self.cancel_button.clicked.connect(self.cancel_content_loading) + self.cancel_button.clicked.connect(self.cancel_loading) self.cancel_button.setVisible(False) - left_layout.addWidget(self.cancel_button) + list_layout.addWidget(self.cancel_button) + + def can_show_content_info(self, item_type): + return self.config_manager.show_stb_content_info and item_type in ["movie", "serie"] and self.provider_manager.current_provider["type"] == "STB" + + def create_content_info_panel(self): + self.content_info_panel = QWidget(self.container_widget) + self.content_info_layout = QVBoxLayout(self.content_info_panel) + self.content_info_panel.setVisible(False) + + def setup_movie_tvshow_content_info(self): + self.clear_content_info_panel() + self.content_info_layout.setContentsMargins(8, 4, 8, 8) + self.content_info_text = QLabel(self.content_info_panel) + self.content_info_text.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Ignored) # Allow to reduce splitter below label minimum size + self.content_info_text.setAlignment(Qt.AlignLeft | Qt.AlignTop) + self.content_info_text.setWordWrap(True) + self.content_info_layout.addWidget(self.content_info_text, 1) + + def clear_content_info_panel(self): + # Clear all widgets from the content_info layout + for i in reversed(range(self.content_info_layout.count())): + widget = self.content_info_layout.itemAt(i).widget() + if widget is not None: + widget.setParent(None) + widget.deleteLater() + + # Clear the layout itself + while self.content_info_layout.count(): + item = self.content_info_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + elif item.layout(): + self.clear_layout(item.layout()) + + # Hide the content_info panel if it is visible + if self.content_info_panel.isVisible(): + self.content_info_panel.setVisible(False) + self.splitter.setSizes([1, 0]) + self.main_layout.setContentsMargins(8, 8, 8, 8) + + def clear_layout(self, layout): + while layout.count(): + item = layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + elif item.layout(): + self.clear_layout(item.layout()) + layout.deleteLater() + + def switch_content_info_panel(self, content_type): + if content_type == "channel": + pass # for program content info + else: + self.setup_movie_tvshow_content_info() + + if not self.content_info_panel.isVisible(): + self.main_layout.setContentsMargins(8, 8, 8, 4) + + # set splitter sizes to show both panels using the splitter_ratio + self.splitter.setSizes([int(self.container_widget.height() * self.splitter_ratio), int(self.container_widget.height() * (1 - self.splitter_ratio))]) + self.content_info_panel.setVisible(True) + + def populate_movie_tvshow_content_info(self, item_data): + content_info_label = { + "name": "Title", + "rating_imdb": "Rating", + "age": "Age", + "country": "Country", + "year": "Year", + "genre_str": "Genre", + "length": "Length", + "director": "Director", + "actors": "Actors", + "description": "Summary" + } + + info = "" + for key, label in content_info_label.items(): + if key in item_data: + value = item_data[key] + # if string, check is not empty and not "na" or "n/a" + if value: + if isinstance(value, str) and value.lower() in ["na", "n/a"]: + continue + info += f"{label}: {value}
" + self.content_info_text.setText(info) + + def show_favorite_layout(self, show): + for i in range(self.favorite_layout.count()): + item = self.favorite_layout.itemAt(i) + if item.widget(): + item.widget().setVisible(show) def toggle_favorite(self): selected_item = self.content_list.currentItem() @@ -361,19 +381,25 @@ def toggle_favorite(self): self.filter_content(self.search_box.text()) def add_to_favorites(self, item_name): - if item_name not in self.config["favorites"]: - self.config["favorites"].append(item_name) + if item_name not in self.config_manager.favorites: + self.config_manager.favorites.append(item_name) self.save_config() def remove_from_favorites(self, item_name): - if item_name in self.config["favorites"]: - self.config["favorites"].remove(item_name) + if item_name in self.config_manager.favorites: + self.config_manager.favorites.remove(item_name) self.save_config() def check_if_favorite(self, item_name): - return item_name in self.config["favorites"] + return item_name in self.config_manager.favorites def toggle_content_type(self): + # Checking only when receiving event of something checked + # Ignore when receiving event of something unchecked + rb = self.sender() + if not rb.isChecked(): + return + if self.channels_radio.isChecked(): self.content_type = "itv" elif self.movies_radio.isChecked(): @@ -393,7 +419,12 @@ def toggle_content_type(self): self.filter_content(self.search_box.text()) def display_categories(self, categories): + # Unregister the content_list selection change event + self.content_list.itemSelectionChanged.disconnect(self.item_selected) self.content_list.clear() + # Re-egister the content_list selection change event + self.content_list.itemSelectionChanged.connect(self.item_selected) + self.content_list.setSortingEnabled(False) self.content_list.setColumnCount(1) if self.content_type == "itv": @@ -403,7 +434,7 @@ def display_categories(self, categories): elif self.content_type == "series": self.content_list.setHeaderLabels(["Serie Categories"]) - self.favorite_button.setHidden(False) + self.show_favorite_layout(True) for category in categories: item = CategoryTreeWidgetItem(self.content_list) @@ -417,8 +448,14 @@ def display_categories(self, categories): self.content_list.setSortingEnabled(True) self.back_button.setVisible(False) + self.clear_content_info_panel() + def display_content(self, items, content_type="content"): + # Unregister the content_list selection change event + self.content_list.itemSelectionChanged.disconnect(self.item_selected) self.content_list.clear() + # Re-egister the content_list selection change event + self.content_list.itemSelectionChanged.connect(self.item_selected) self.content_list.setSortingEnabled(False) # Define headers for different content types @@ -468,25 +505,30 @@ def display_content(self, items, content_type="content"): "headers": ["#", self.shorten_header(f"{category_header} > Channels")], "keys": ["number", "name"], }, - "content": {"headers": ["Name"]}, + "content": { + "headers": ["Group", "Name"], + "keys": ["group", "name"] + }, } self.content_list.setColumnCount(len(header_info[content_type]["headers"])) self.content_list.setHeaderLabels(header_info[content_type]["headers"]) - # no need to check favorites or allow to add favorites on seasons or episodes folders + # no favorites on seasons or episodes folders check_fav = content_type in ["channel", "movie", "serie", "content"] - self.favorite_button.setHidden(not check_fav) + self.show_favorite_layout(check_fav) for item_data in items: - list_item = NumberedTreeWidgetItem(self.content_list) - item_name = item_data.get("name") or item_data.get("title") - if content_type == "content": - list_item.setText(0, item_name) + if content_type == "channel": + list_item = NumberedTreeWidgetItem(self.content_list) else: - for i, key in enumerate(header_info[content_type]["keys"]): - list_item.setText(i, item_data.get(key, "N/A")) + list_item = QTreeWidgetItem(self.content_list) + + for i, key in enumerate(header_info[content_type]["keys"]): + list_item.setText(i, item_data.get(key, "N/A")) + list_item.setData(0, Qt.UserRole, {"type": content_type, "data": item_data}) # Highlight favorite items + item_name = item_data.get("name") or item_data.get("title") if check_fav and self.check_if_favorite(item_name): list_item.setBackground(0, QColor(0, 0, 255, 20)) @@ -497,6 +539,10 @@ def display_content(self, items, content_type="content"): self.content_list.setSortingEnabled(True) self.back_button.setVisible(content_type != "content") + # Select 1st item in the list + if self.content_list.topLevelItemCount() > 0: + self.content_list.setCurrentItem(self.content_list.topLevelItem(0)) + def filter_content(self, text=""): show_favorites = self.favorites_only_checkbox.isChecked() search_text = text.lower() if isinstance(text, str) else "" @@ -524,20 +570,45 @@ def filter_content(self, text=""): def create_media_controls(self): self.media_controls = QWidget(self.container_widget) control_layout = QHBoxLayout(self.media_controls) + control_layout.setContentsMargins(8, 0, 8, 8) self.play_button = QPushButton("Play/Pause") - self.play_button.clicked.connect(self.player.toggle_play_pause) + self.play_button.clicked.connect(self.toggle_play_pause) control_layout.addWidget(self.play_button) self.stop_button = QPushButton("Stop") - self.stop_button.clicked.connect(self.player.stop_video) + self.stop_button.clicked.connect(self.stop_video) control_layout.addWidget(self.stop_button) self.vlc_button = QPushButton("Open in VLC") self.vlc_button.clicked.connect(self.open_in_vlc) control_layout.addWidget(self.vlc_button) - self.grid_layout.addWidget(self.media_controls, 2, 0) + self.media_controls.setVisible(False) # Initially hidden + + def show_media_controls(self): + self.media_controls.setVisible(True) + + if not self.content_info_panel.isVisible(): + self.main_layout.setContentsMargins(8, 8, 8, 0) + else: + self.content_info_layout.setContentsMargins(8, 8, 8, 0) + + def hide_media_controls(self): + self.media_controls.setVisible(False) + + if not self.content_info_panel.isVisible(): + self.main_layout.setContentsMargins(8, 8, 8, 8) + else: + self.content_info_layout.setContentsMargins(8, 8, 8, 8) + + def toggle_play_pause(self): + self.player.toggle_play_pause() + self.show_media_controls() + + def stop_video(self): + self.player.stop_video() + self.hide_media_controls() def open_in_vlc(self): # Invoke user's VLC player to open the current stream @@ -583,8 +654,8 @@ def open_file(self): self.player.play_video(file_path) def export_all_live_channels(self): - selected_provider = self.config["data"][self.config["selected"]] - if selected_provider.get("type") != "STB": + provider = self.provider_manager.current_provider + if provider.get("type") != "STB": QMessageBox.warning( self, "Export Error", @@ -602,20 +673,20 @@ def export_all_live_channels(self): self.fetch_and_export_all_live_channels(file_path) def fetch_and_export_all_live_channels(self, file_path): - selected_provider = self.config["data"][self.config["selected"]] - options = selected_provider.get("options", {}) + selected_provider = self.provider_manager.current_provider url = selected_provider.get("url", "") url = URLObject(url) base_url = f"{url.scheme}://{url.netloc}" mac = selected_provider.get("mac", "") try: - fetchurl = f"{base_url}/server/load.php?type=itv&action=get_all_channels&JsHttpRequest=1-xml" - response = requests.get(fetchurl, headers=options["headers"]) - result = response.json() - channels = result["js"]["data"] + # Get all channels and categories (in provider cache) + provider_itv_content = self.provider_manager.current_provider_content.setdefault("itv", {}) + categories_list = provider_itv_content.setdefault("categories", []) + categories = {c.get("id", "None"): c.get("title", "Unknown Category") for c in categories_list} + channels = provider_itv_content["contents"] - self.save_channel_list(base_url, channels, mac, file_path) + self.save_channel_list(base_url, channels, categories, mac, file_path) QMessageBox.information( self, "Export Successful", @@ -628,7 +699,7 @@ def fetch_and_export_all_live_channels(self, file_path): f"An error occurred while exporting channels: {str(e)}", ) - def save_channel_list(self, base_url, channels_data, mac, file_path) -> None: + def save_channel_list(self, base_url, channels_data, categories, mac, file_path) -> None: try: with open(file_path, "w", encoding="utf-8") as file: file.write("#EXTM3U\n") @@ -636,6 +707,9 @@ def save_channel_list(self, base_url, channels_data, mac, file_path) -> None: for channel in channels_data: name = channel.get("name", "Unknown Channel") logo = channel.get("logo", "") + category = channel.get("tv_genre_id", "None") + xmltv_id = channel.get("xmltv_id", "") + group = categories.get(category, "Unknown Group") cmd_url = channel.get("cmd", "").replace("ffmpeg ", "") if "localhost" in cmd_url: ch_id_match = re.search(r"/ch/(\d+)_", cmd_url) @@ -643,7 +717,7 @@ def save_channel_list(self, base_url, channels_data, mac, file_path) -> None: ch_id = ch_id_match.group(1) cmd_url = f"{base_url}/play/live.php?mac={mac}&stream={ch_id}&extension=m3u8" - channel_str = f'#EXTINF:-1 tvg-logo="{logo}" ,{name}\n{cmd_url}\n' + channel_str = f'#EXTINF:-1 tvg-id="{xmltv_id}" tvg-logo="{logo}" group-title="{group}" ,{name}\n{cmd_url}\n' count += 1 file.write(channel_str) print(f"Channels = {count}") @@ -659,8 +733,10 @@ def export_content(self): self, "Export Content", "", "M3U files (*.m3u)" ) if file_path: - provider = self.config["data"][self.config["selected"]] - content_data = provider.get(self.content_type, {}) + provider = self.provider_manager.current_provider + # Get the content data from the provider manager on content type + provider_content = self.provider_manager.current_provider_content.setdefault(self.content_type, {}) + base_url = provider.get("url", "") config_type = provider.get("type", "") mac = provider.get("mac", "") @@ -668,11 +744,11 @@ def export_content(self): if config_type == "STB": # Extract all content items from categories all_items = [] - for items in content_data.get("contents", {}).values(): + for items in provider_content.get("contents", {}).values(): all_items.extend(items) self.save_stb_content(base_url, all_items, mac, file_path) elif config_type in ["M3UPLAYLIST", "M3USTREAM", "XTREAM"]: - content_items = provider.get(self.content_type, []) + content_items = provider_content if provider_content else [] self.save_m3u_content(content_items, file_path) else: print(f"Unknown provider type: {config_type}") @@ -686,10 +762,12 @@ def save_m3u_content(content_data, file_path): for item in content_data: name = item.get("name", "Unknown") logo = item.get("logo", "") + group = item.get("group", "") + xmltv_id = item.get("xmltv_id", "") cmd_url = item.get("cmd") if cmd_url: - item_str = f'#EXTINF:-1 tvg-logo="{logo}" ,{name}\n{cmd_url}\n' + item_str = f'#EXTINF:-1 tvg-id="{xmltv_id}" tvg-logo="{logo}" group-title="{group}" ,{name}\n{cmd_url}\n' count += 1 file.write(item_str) print(f"Items exported: {count}") @@ -706,6 +784,7 @@ def save_stb_content(base_url, content_data, mac, file_path): for item in content_data: name = item.get("name", "Unknown") logo = item.get("logo", "") + xmltv_id = item.get("xmltv_id", "") cmd_url = item.get("cmd", "").replace("ffmpeg ", "") # Generalized URL construction @@ -719,7 +798,7 @@ def save_stb_content(base_url, content_data, mac, file_path): elif content_type == "vod": cmd_url = f"{base_url}/play/vod.php?mac={mac}&stream={content_id}&extension=m3u8" - item_str = f'#EXTINF:-1 tvg-logo="{logo}" ,{name}\n{cmd_url}\n' + item_str = f'#EXTINF:-1 tvg-id="{xmltv_id}" tvg-logo="{logo}" ,{name}\n{cmd_url}\n' count += 1 file.write(item_str) print(f"Items exported: {count}") @@ -730,10 +809,13 @@ def save_stb_content(base_url, content_data, mac, file_path): def save_config(self): self.config_manager.save_config() + def save_provider(self): + self.provider_manager.save_provider() + def load_content(self): - selected_provider = self.config["data"][self.config["selected"]] + selected_provider = self.provider_manager.current_provider config_type = selected_provider.get("type", "") - content = selected_provider.get(self.content_type, {}) + content = self.provider_manager.current_provider_content.setdefault(self.content_type, {}) if content: # If we have categories cached, display them if config_type == "STB": @@ -745,7 +827,7 @@ def load_content(self): self.update_content() def update_content(self): - selected_provider = self.config["data"][self.config["selected"]] + selected_provider = self.provider_manager.current_provider config_type = selected_provider.get("type", "") if config_type == "M3UPLAYLIST": self.load_m3u_playlist(selected_provider["url"]) @@ -766,9 +848,7 @@ def update_content(self): ) self.load_m3u_playlist(url) elif config_type == "STB": - self.do_handshake( - selected_provider["url"], selected_provider["mac"], load=True - ) + self.load_stb_categories(selected_provider["url"], self.provider_manager.headers) elif config_type == "M3USTREAM": self.load_stream(selected_provider["url"]) @@ -784,10 +864,10 @@ def load_m3u_playlist(self, url): parsed_content = self.parse_m3u(content) self.display_content(parsed_content) # Update the content in the config - self.config["data"][self.config["selected"]][ + self.provider_manager.current_provider_content[ self.content_type ] = parsed_content - self.save_config() + self.save_provider() except (requests.RequestException, IOError) as e: print(f"Error loading M3U Playlist: {e}") @@ -795,35 +875,53 @@ def load_stream(self, url): item = {"id": 1, "name": "Stream", "cmd": url} self.display_content([item]) # Update the content in the config - self.config["data"][self.config["selected"]][self.content_type] = [item] - self.save_config() + self.provider_manager.current_provider_content[self.content_type] = [item] + self.save_provider() + + def item_selected(self): + selected_items = self.content_list.selectedItems() + if selected_items: + item = selected_items[0] + data = item.data(0, Qt.UserRole) + if data and "type" in data: + item_data = data["data"] + item_type = item.data(0, Qt.UserRole)["type"] + + if self.can_show_content_info(item_type): + self.switch_content_info_panel("movie_tvshow") + self.populate_movie_tvshow_content_info(item_data) + else: + self.clear_content_info_panel() - def item_selected(self, item): + def item_activated(self, item): data = item.data(0, Qt.UserRole) if data and "type" in data: + item_data = data["data"] + item_type = item.data(0, Qt.UserRole)["type"] + nav_len = len(self.navigation_stack) - if data["type"] == "category": - self.navigation_stack.append(("root", self.current_category)) - self.current_category = data["data"] - self.load_content_in_category(data["data"]) - elif data["type"] == "serie": + if item_type == "category": + self.navigation_stack.append(("root", self.current_category, item.text(0))) + self.current_category = item_data + self.load_content_in_category(item_data) + elif item_type == "serie": if self.content_type == "series": # For series, load seasons - self.navigation_stack.append(("category", self.current_category)) - self.current_series = data["data"] - self.load_series_seasons(data["data"]) + self.navigation_stack.append(("category", self.current_category, item.text(0))) + self.current_series = item_data + self.load_series_seasons(item_data) else: - self.play_item(data["data"]) - elif data["type"] == "season": + self.play_item(item_data) + elif item_type == "season": # Load episodes for the selected season - self.navigation_stack.append(("series", self.current_series)) - self.current_season = data["data"] - self.load_season_episodes(data["data"]) - elif data["type"] in ["content", "channel", "movie"]: - self.play_item(data["data"]) - elif data["type"] == "episode": + self.navigation_stack.append(("series", self.current_series, item.text(0))) + self.current_season = item_data + self.load_season_episodes(item_data) + elif item_type in ["content", "channel", "movie"]: + self.play_item(item_data) + elif item_type == "episode": # Play the selected episode - self.play_item(data["data"], is_episode=True) + self.play_item(item_data, is_episode=True) else: print("Unknown item type selected.") @@ -837,10 +935,10 @@ def item_selected(self, item): def go_back(self): if self.navigation_stack: - nav_type, previous_data = self.navigation_stack.pop() + nav_type, previous_data, previous_selected_id = self.navigation_stack.pop() if nav_type == "root": # Display root categories - content = self.config["data"][self.config["selected"]].get( + content = self.provider_manager.current_provider_content.setdefault( self.content_type, {} ) categories = content.get("categories", []) @@ -861,6 +959,13 @@ def go_back(self): self.search_box.clear() if not self.search_box.isModified(): self.filter_content(self.search_box.text()) + + # Select previous item + if previous_selected_id: + previous_selected = self.content_list.findItems(previous_selected_id, Qt.MatchExactly, 0) + if previous_selected: + self.content_list.setCurrentItem(previous_selected[0]) + self.content_list.scrollToItem(previous_selected[0], QTreeWidget.PositionAtTop) else: # Already at the root level pass @@ -880,70 +985,85 @@ def parse_m3u(data): tvg_id_match = re.search(r'tvg-id="([^"]+)"', line) tvg_logo_match = re.search(r'tvg-logo="([^"]+)"', line) group_title_match = re.search(r'group-title="([^"]+)"', line) - item_name_match = re.search(r",(.+)", line) + user_agent_match = re.search(r'user-agent="([^"]+)"', line) + item_name_match = re.search(r',([^,]+)$', line) tvg_id = tvg_id_match.group(1) if tvg_id_match else None tvg_logo = tvg_logo_match.group(1) if tvg_logo_match else None group_title = group_title_match.group(1) if group_title_match else None + user_agent = user_agent_match.group(1) if user_agent_match else None item_name = item_name_match.group(1) if item_name_match else None id_counter += 1 item = { "id": id_counter, + "group": group_title, + "xmltv_id": tvg_id, "name": item_name, "logo": tvg_logo, + "user_agent": user_agent, } + elif line.startswith("#EXTVLCOPT:http-user-agent="): + user_agent = line.split("=", 1)[1] + item["user_agent"] = user_agent + elif line.startswith("http"): urlobject = urlparse(line) item["cmd"] = urlobject.geturl() result.append(item) return result - def do_handshake(self, url, mac, serverload="/server/load.php", load=True): - token = ( - self.config.get("token") - if self.config.get("token") - else self.random_token() - ) - options = self.create_options(url, mac, token) - try: - fetchurl = f"{url}{serverload}?type=stb&action=handshake&prehash=0&token={token}&JsHttpRequest=1-xml" - handshake = requests.get(fetchurl, headers=options["headers"]) - body = handshake.json() - token = body["js"]["token"] - options["headers"]["Authorization"] = f"Bearer {token}" - self.config["data"][self.config["selected"]]["options"] = options - self.save_config() - if load: - self.load_stb_categories(url, options) - return True - except Exception as e: - if serverload != "/portal.php": - serverload = "/portal.php" - return self.do_handshake(url, mac, serverload) - print("Error in handshake:", e) - return False - - def load_stb_categories(self, url, options): + def load_stb_categories(self, url, headers): url = URLObject(url) url = f"{url.scheme}://{url.netloc}" try: fetchurl = ( f"{url}/server/load.php?{self.get_categories_params(self.content_type)}" ) - response = requests.get(fetchurl, headers=options["headers"]) + response = requests.get(fetchurl, headers=headers) result = response.json() categories = result["js"] if not categories: print("No categories found.") return # Save categories in config - self.config["data"][self.config["selected"]][self.content_type] = { - "categories": categories, - "contents": {}, - } - self.save_config() + provider_content = self.provider_manager.current_provider_content.setdefault(self.content_type, {}) + provider_content["categories"] = categories + provider_content["contents"] = {} + + # Sorting all channels now by category + if self.content_type == "itv": + fetchurl = ( + f"{url}/server/load.php?{self.get_allchannels_params()}" + ) + response = requests.get(fetchurl, headers=headers) + result = response.json() + provider_content["contents"] = result["js"]["data"] + + # Split channels by category, and sort them number-wise + sorted_channels = {} + + for i in range(len(provider_content["contents"])): + genre_id = provider_content["contents"][i]["tv_genre_id"] + category = str(genre_id) + if category not in sorted_channels: + sorted_channels[category] = [] + sorted_channels[category].append(i) + + for category in sorted_channels: + sorted_channels[category].sort(key=lambda x: int(provider_content["contents"][x]["number"])) + + # Add a specific category for null genre_id + if "None" in sorted_channels: + categories.append({ + "id": "None", + "title": "Unknown Category" + }) + + provider_content["sorted_channels"] = sorted_channels + + self.save_provider() self.display_categories(categories) except Exception as e: print(f"Error loading STB categories: {e}") @@ -957,53 +1077,76 @@ def get_categories_params(_type): } return "&".join(f"{k}={v}" for k, v in params.items()) + @staticmethod + def get_allchannels_params(): + params = { + "type": "itv", + "action": "get_all_channels", + "JsHttpRequest": str(int(time.time() * 1000)) + "-xml", + } + return "&".join(f"{k}={v}" for k, v in params.items()) + def load_content_in_category(self, category): - selected_provider = self.config["data"][self.config["selected"]] - content_data = selected_provider.get(self.content_type, {}) + content_data = self.provider_manager.current_provider_content.setdefault(self.content_type, {}) category_id = category.get("id", "*") - # Check if we have cached content for this category - if category_id in content_data.get("contents", {}): - items = content_data["contents"][category_id] - if self.content_type == "itv": - self.display_content(items, content_type="channel") - elif self.content_type == "series": - self.display_content(items, content_type="serie") - elif self.content_type == "vod": - self.display_content(items, content_type="movie") + if self.content_type == "itv": + # Show only channels for the selected category + if category_id == "*": + items = content_data["contents"] + else: + items = [content_data["contents"][i] for i in content_data["sorted_channels"].get(category_id, [])] + self.display_content(items, content_type="channel") else: - # Fetch content for the category - self.fetch_content_in_category(category_id) + # Check if we have cached content for this category + if category_id in content_data.get("contents", {}): + items = content_data["contents"][category_id] + if self.content_type == "itv": + self.display_content(items, content_type="channel") + elif self.content_type == "series": + self.display_content(items, content_type="serie") + elif self.content_type == "vod": + self.display_content(items, content_type="movie") + else: + # Fetch content for the category + self.fetch_content_in_category(category_id) def fetch_content_in_category(self, category_id): - selected_provider = self.config["data"][self.config["selected"]] - options = selected_provider.get("options", {}) + selected_provider = self.provider_manager.current_provider + headers = self.provider_manager.headers url = selected_provider.get("url", "") url = URLObject(url) url = f"{url.scheme}://{url.netloc}/server/load.php" + self.lock_ui_before_loading() + self.content_list.setEnabled(False) + if hasattr(self, "content_loader") and self.content_loader.isRunning(): + self.content_loader.wait() self.content_loader = ContentLoader( - url, options["headers"], self.content_type, category_id=category_id + url, headers, self.content_type, category_id=category_id ) self.content_loader.content_loaded.connect(self.update_content_list) self.content_loader.progress_updated.connect(self.update_progress) self.content_loader.finished.connect(self.content_loader_finished) self.content_loader.start() - self.progress_bar.setVisible(True) - self.cancel_button.setVisible(True) + self.cancel_button.setText("Cancel loading content in category") def load_series_seasons(self, series_item): - selected_provider = self.config["data"][self.config["selected"]] - options = selected_provider.get("options", {}) + selected_provider = self.provider_manager.current_provider + headers = self.provider_manager.headers url = selected_provider.get("url", "") url = URLObject(url) url = f"{url.scheme}://{url.netloc}/server/load.php" self.current_series = series_item # Store current series + self.lock_ui_before_loading() + self.content_list.setEnabled(False) + if hasattr(self, "content_loader") and self.content_loader.isRunning(): + self.content_loader.wait() self.content_loader = ContentLoader( url=url, - headers=options["headers"], + headers=headers, content_type="series", category_id=series_item["category_id"], movie_id=series_item["id"], # series ID @@ -1015,21 +1158,24 @@ def load_series_seasons(self, series_item): self.content_loader.progress_updated.connect(self.update_progress) self.content_loader.finished.connect(self.content_loader_finished) self.content_loader.start() - self.progress_bar.setVisible(True) - self.cancel_button.setVisible(True) + self.cancel_button.setText("Cancel loading seasons") def load_season_episodes(self, season_item): - selected_provider = self.config["data"][self.config["selected"]] - options = selected_provider.get("options", {}) + selected_provider = self.provider_manager.current_provider + headers = self.provider_manager.headers url = selected_provider.get("url", "") url = URLObject(url) url = f"{url.scheme}://{url.netloc}/server/load.php" self.current_season = season_item # Store current season + self.lock_ui_before_loading() + self.content_list.setEnabled(False) + if hasattr(self, "content_loader") and self.content_loader.isRunning(): + self.content_loader.wait() self.content_loader = ContentLoader( url=url, - headers=options["headers"], + headers=headers, content_type="series", category_id=self.current_category["id"], # Category ID movie_id=self.current_series["id"], # Series ID @@ -1041,8 +1187,7 @@ def load_season_episodes(self, season_item): self.content_loader.progress_updated.connect(self.update_progress) self.content_loader.finished.connect(self.content_loader_finished) self.content_loader.start() - self.progress_bar.setVisible(True) - self.cancel_button.setVisible(True) + self.cancel_button.setText("Cancel loading episodes") def display_episodes(self, season_item): episodes = season_item.get("series", []) @@ -1057,34 +1202,8 @@ def display_episodes(self, season_item): episode_items.append(episode_item) self.display_content(episode_items, content_type="episode") - @staticmethod - def get_channel_or_series_params( - typ, category, sortby, page_number, movie_id, series_id - ): - params = { - "type": typ, - "action": "get_ordered_list", - "genre": category, - "force_ch_link_check": "", - "fav": "0", - "sortby": sortby, # name, number, added - "hd": "0", - "p": str(page_number), - "JsHttpRequest": str(int(time.time() * 1000)) + "-xml", - } - if typ == "series": - params.update( - { - "movie_id": movie_id if movie_id else "0", - "category": category, - "season_id": series_id if series_id else "0", - "episode_id": "0", - } - ) - return "&".join(f"{k}={v}" for k, v in params.items()) - def play_item(self, item_data, is_episode=False): - if self.config["data"][self.config["selected"]]["type"] == "STB": + if self.provider_manager.current_provider["type"] == "STB": url = self.create_link(item_data, is_episode=is_episode) if url: self.link = url @@ -1096,32 +1215,50 @@ def play_item(self, item_data, is_episode=False): self.link = cmd self.player.play_video(cmd) - def cancel_content_loading(self): + def cancel_loading(self): if hasattr(self, "content_loader") and self.content_loader.isRunning(): self.content_loader.terminate() - self.content_loader.wait() + if hasattr(self, "content_loader"): + self.content_loader.wait() self.content_loader_finished() QMessageBox.information( self, "Cancelled", "Content loading has been cancelled." ) + def lock_ui_before_loading(self): + self.update_ui_on_loading(loading=True) + + def unlock_ui_after_loading(self): + self.update_ui_on_loading(loading=False) + + def update_ui_on_loading(self, loading): + self.open_button.setEnabled(not loading) + self.options_button.setEnabled(not loading) + self.export_button.setEnabled(not loading) + self.export_all_live_button.setEnabled(not loading) + self.update_button.setEnabled(not loading) + self.back_button.setEnabled(not loading) + self.progress_bar.setVisible(loading) + self.cancel_button.setVisible(loading) + self.content_switch_group.setEnabled(not loading) + def content_loader_finished(self): - self.progress_bar.setVisible(False) - self.cancel_button.setVisible(False) + self.content_list.setEnabled(True) if hasattr(self, "content_loader"): self.content_loader.deleteLater() del self.content_loader + self.unlock_ui_after_loading() def update_content_list(self, data): category_id = data.get("category_id") items = data.get("items") # Cache the items in config - selected_provider = self.config["data"][self.config["selected"]] + selected_provider = self.provider_manager.current_provider_content content_data = selected_provider.setdefault(self.content_type, {}) contents = content_data.setdefault("contents", {}) contents[category_id] = items - self.save_config() + self.save_provider() if self.content_type == "series": self.display_content(items, content_type="serie") @@ -1158,20 +1295,24 @@ def update_episodes_list(self, data): print("Season not found in data.") def update_progress(self, current, total): - progress_percentage = int((current / total) * 100) - self.progress_bar.setValue(progress_percentage) - if progress_percentage == 100: - self.progress_bar.setVisible(False) - else: - self.progress_bar.setVisible(True) + if total: + progress_percentage = int((current / total) * 100) + self.progress_bar.setValue(progress_percentage) + if progress_percentage == 100: + self.progress_bar.setVisible(False) + else: + self.progress_bar.setVisible(True) + + def update_busy_progress(self, msg): + self.cancel_button.setText(msg) def create_link(self, item, is_episode=False): try: - selected_provider = self.config["data"][self.config["selected"]] - url = selected_provider["url"] + selected_provider = self.provider_manager.current_provider + headers = self.provider_manager.headers + url = selected_provider.get("url", "") url = URLObject(url) url = f"{url.scheme}://{url.netloc}" - options = selected_provider["options"] cmd = item.get("cmd") if is_episode: # For episodes, we need to pass 'series' parameter @@ -1185,7 +1326,7 @@ def create_link(self, item, is_episode=False): f"{url}/server/load.php?type={self.content_type}&action=create_link" f"&cmd={requests.utils.quote(cmd)}&JsHttpRequest=1-xml" ) - response = requests.get(fetchurl, headers=options["headers"]) + response = requests.get(fetchurl, headers=headers) if response.status_code != 200 or not response.content: print( f"Error creating link: status code {response.status_code}, response content empty" @@ -1206,44 +1347,6 @@ def sanitize_url(url): url = url.strip() return url - @staticmethod - def random_token(): - return "".join(random.choices(string.ascii_letters + string.digits, k=32)) - - @staticmethod - def create_options(url, mac, token): - url = URLObject(url) - options = { - "headers": { - "User-Agent": "Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG200 stbapp ver: 2 rev: 250 Safari/533.3", - "Accept-Charset": "UTF-8,*;q=0.8", - "X-User-Agent": "Model: MAG200; Link: Ethernet", - "Host": f"{url.netloc}", - "Range": "bytes=0-", - "Accept": "*/*", - "Referer": f"{url}/c/" if not url.path else f"{url}/", - "Cookie": f"mac={mac}; stb_lang=en; timezone=Europe/Kiev; PHPSESSID=null;", - "Authorization": f"Bearer {token}", - } - } - return options - - def generate_headers(self): - selected_provider = self.config["data"][self.config["selected"]] - return selected_provider["options"]["headers"] - - @staticmethod - def verify_url(url): - if url.startswith(("http://", "https://")): - try: - response = requests.head(url, timeout=5) - return response.status_code == 200 - except requests.RequestException as e: - print(f"Error verifying URL: {e}") - return False - else: - return os.path.isfile(url) - @staticmethod def shorten_header(s): return s[:20] + "..." + s[-25:] if len(s) > 45 else s diff --git a/config_manager.py b/config_manager.py index 9e9c462..5a2d421 100644 --- a/config_manager.py +++ b/config_manager.py @@ -8,6 +8,9 @@ class ConfigManager: CURRENT_VERSION = "1.5.8" # Set your current version here + DEFAULT_OPTION_CHECKUPDATE = True + DEFAULT_OPTION_STB_CONTENT_INFO = False + def __init__(self): self.config = {} self.options = {} @@ -34,6 +37,9 @@ def _get_config_path(self): os.makedirs(config_dir, exist_ok=True) return os.path.join(config_dir, "config.json") + def get_config_dir(self): + return os.path.dirname(self.config_path) + def _migrate_old_config(self): try: old_config_path = "config.json" @@ -55,53 +61,91 @@ def load_config(self): self.update_patcher() - selected_config = self.config["data"][self.config["selected"]] - if "options" in selected_config: - self.options = selected_config["options"] - self.token = self.options["headers"]["Authorization"].split(" ")[1] - else: - self.options = { - "headers": { - "User-Agent": "Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG200 stbapp ver: 2 rev: 250 Safari/533.3", - "Accept-Charset": "UTF-8,*;q=0.8", - "X-User-Agent": "Model: MAG200; Link: Ethernet", - "Content-Type": "application/json", - } - } + def update_patcher(self): - self.url = selected_config.get("url") - self.mac = selected_config.get("mac") + need_update = False - def update_patcher(self): # add favorites to the loaded config if it doesn't exist if "favorites" not in self.config: - self.config["favorites"] = [] + self.favorites = [] + need_update = True + + # add check_updates to the loaded config if it doesn't exist + if "check_updates" not in self.config: + self.check_updates = ConfigManager.DEFAULT_OPTION_CHECKUPDATE + need_update = True + + # add show_stb_content_info to the loaded config if it doesn't exist + if "show_stb_content_info" not in self.config: + self.show_stb_content_info = ConfigManager.DEFAULT_OPTION_STB_CONTENT_INFO + need_update = True + + if need_update: self.save_config() + @property + def check_updates(self): + return self.config.get("check_updates", ConfigManager.DEFAULT_OPTION_CHECKUPDATE) + + @check_updates.setter + def check_updates(self, value): + self.config["check_updates"] = value + + @property + def favorites(self): + return self.config.get("favorites", []) + + @favorites.setter + def favorites(self, value): + self.config["favorites"] = value + + @property + def show_stb_content_info(self): + return self.config.get("show_stb_content_info", ConfigManager.DEFAULT_OPTION_STB_CONTENT_INFO) + + @show_stb_content_info.setter + def show_stb_content_info(self, value): + self.config["show_stb_content_info"] = value + + @property + def selected_provider_name(self): + return self.config.get("selected_provider_name", "iptv-org.github.io") + + @selected_provider_name.setter + def selected_provider_name(self, value): + self.config["selected_provider_name"] = value + @staticmethod def default_config(): return { - "selected": 0, + "selected_provider_name": "iptv-org.github.io", + "check_updates": ConfigManager.DEFAULT_OPTION_CHECKUPDATE, "data": [ { "type": "M3UPLAYLIST", + "name": "iptv-org.github.io", "url": "https://iptv-org.github.io/iptv/index.m3u", } ], "window_positions": { - "channel_list": {"x": 1250, "y": 100, "width": 400, "height": 800}, + "channel_list": {"x": 1250, "y": 100, "width": 400, "height": 800, "splitter_ratio": 0.75}, "video_player": {"x": 50, "y": 100, "width": 1200, "height": 800}, }, "favorites": [], + "show_stb_content_info": ConfigManager.DEFAULT_OPTION_STB_CONTENT_INFO, } - def save_window_settings(self, pos, window_name): + def save_window_settings(self, window, window_name): + pos = window.geometry() self.config["window_positions"][window_name] = { "x": pos.x(), "y": pos.y(), "width": pos.width(), "height": pos.height(), } + if window_name == "channel_list": + self.config["window_positions"][window_name]["splitter_ratio"] = window.splitter_ratio + self.save_config() def apply_window_settings(self, window_name, window): @@ -109,10 +153,8 @@ def apply_window_settings(self, window_name, window): window.setGeometry( settings["x"], settings["y"], settings["width"], settings["height"] ) - - # def save_config(self): - # with open(self.config_path, "wb") as f: - # f.write(json.dumps(self.config, option=json.OPT_INDENT_2)) + if window_name == "channel_list": + window.splitter_ratio = settings.get("splitter_ratio", 0.75) def save_config(self): serialized_config = json.dumps(self.config, option=json.OPT_INDENT_2) diff --git a/content_loader.py b/content_loader.py new file mode 100644 index 0000000..ba3ea6b --- /dev/null +++ b/content_loader.py @@ -0,0 +1,160 @@ +import random +import aiohttp +import asyncio +import orjson as json +from PySide6.QtCore import QThread, Signal + +class ContentLoader(QThread): + content_loaded = Signal(dict) + progress_updated = Signal(int, int) + + def __init__( + self, + url, + headers, + content_type, + category_id=None, + parent_id=None, + movie_id=None, + season_id=None, + period=None, + ch_id=None, + size=0, + action="get_ordered_list", + sortby="name", + ): + super().__init__() + self.url = url + self.headers = headers + self.content_type = content_type + self.category_id = category_id + self.parent_id = parent_id + self.movie_id = movie_id + self.season_id = season_id + self.action = action + self.sortby = sortby + self.period= period + self.ch_id = ch_id + self.size = size + self.items = [] + + async def fetch_page(self, session, page, max_retries=2, timeout=5): + for attempt in range(max_retries): + try: + params = self.get_params(page) + async with session.get( + self.url, headers=self.headers, params=params, timeout=timeout + ) as response: + content = await response.read() + if response.status == 503 or not content: + wait_time = (2**attempt) + random.uniform(0, 1) + print( + f"Received error or empty response. Retrying in {wait_time:.2f} seconds..." + ) + await asyncio.sleep(wait_time) + continue + result = json.loads(content) + if self.action == "get_short_epg": + return ( + result["js"], + 1, + 1, + ) + + return ( + result["js"]["data"], + int(result["js"].get("total_items", 1)), + int(result["js"].get("max_page_items", 1)), + ) + except ( + aiohttp.ClientError, + json.JSONDecodeError, + asyncio.TimeoutError, + ) as e: + print(f"Error fetching page {page}: {e}") + if attempt == max_retries - 1: + raise + wait_time = (2**attempt) + random.uniform(0, 1) + print(f"Retrying in {wait_time:.2f} seconds...") + await asyncio.sleep(wait_time) + return [], 0, 0 + + def get_params(self, page): + params = { + "type": self.content_type, + "action": self.action, + "p": str(page), + "JsHttpRequest": "1-xml", + } + if self.content_type == "itv": + params.update( + { + "genre": self.category_id if self.category_id else "*", + "force_ch_link_check": "", + "fav": "0", + "sortby": self.sortby, + "hd": "0", + } + ) + elif self.content_type == "vod": + params.update( + { + "category": self.category_id if self.category_id else "*", + "sortby": self.sortby, + } + ) + elif self.content_type == "series": + params.update( + { + "category": self.category_id if self.category_id else "*", + "movie_id": self.movie_id if self.movie_id else "0", + "season_id": self.season_id if self.season_id else "0", + "episode_id": "0", + "sortby": self.sortby, + } + ) + return params + + async def load_content(self): + async with aiohttp.ClientSession() as session: + # Fetch initial data to get total items and max page items + page = 1 + page_items, total_items, max_page_items = await self.fetch_page( + session, page + ) + # if page_items is list, extend items + if isinstance(page_items, list): + self.items.extend(page_items) + # if page_items is dict, extend items + elif isinstance(page_items, dict): + self.items.append(page_items) + + pages = (total_items + max_page_items - 1) // max_page_items + self.progress_updated.emit(1, pages) + + tasks = [] + for page_num in range(2, pages + 1): + tasks.append(self.fetch_page(session, page_num)) + + for i, task in enumerate(asyncio.as_completed(tasks), 2): + page_items, _, _ = await task + self.items.extend(page_items) + self.progress_updated.emit(i, pages) + + # Emit all items once done + self.content_loaded.emit( + { + "page_count": (total_items + max_page_items - 1) // max_page_items, + "category_id": self.category_id, + "items": self.items, + "parent_id": self.parent_id, + "movie_id": self.movie_id, + "season_id": self.season_id, + } + ) + + def run(self): + try: + asyncio.run(self.load_content()) + except Exception as e: + print(f"Error in content loading: {e}") diff --git a/main.py b/main.py index 2f277d7..bdb2407 100644 --- a/main.py +++ b/main.py @@ -11,12 +11,15 @@ from sleep_manager import allow_sleep, prevent_sleep from update_checker import check_for_updates from video_player import VideoPlayer +from provider_manager import ProviderManager, ProviderContext if __name__ == "__main__": app = QApplication(sys.argv) icon_path = "assets/qitv.png" config_manager = ConfigManager() + provider_context = ProviderContext() + provider_manager = ProviderManager(config_manager, provider_context) if platform.system() == "Windows": myappid = f"com.ozankaraali.qitv.{config_manager.CURRENT_VERSION}" ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) # type: ignore @@ -28,12 +31,14 @@ prevent_sleep() try: player = VideoPlayer(config_manager) - channel_list = ChannelList(app, player, config_manager) + channel_list = ChannelList(app, player, config_manager, provider_manager) qdarktheme.setup_theme("auto") player.show() channel_list.show() - check_for_updates() + if config_manager.check_updates: + check_for_updates() + sys.exit(app.exec()) finally: allow_sleep() diff --git a/options.py b/options.py index e948d1b..957c9dd 100644 --- a/options.py +++ b/options.py @@ -1,72 +1,138 @@ import os +from update_checker import check_for_updates +import requests +from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QButtonGroup, + QCheckBox, QComboBox, QDialog, QFileDialog, QFormLayout, + QGridLayout, + QHBoxLayout, QLabel, QLineEdit, QPushButton, QRadioButton, + QTabWidget, + QVBoxLayout, + QWidget ) class OptionsDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) - self.setWindowTitle("Options") - self.layout = QFormLayout(self) - self.config = parent.config - self.selected_provider_index = self.config.get("selected", 0) + + self.setWindowTitle("Settings") + + self.config_manager = parent.config_manager + self.provider_manager = parent.provider_manager + self.providers = self.provider_manager.providers + self.selected_provider_name = self.config_manager.selected_provider_name + self.selected_provider_index = 0 + self.providers_modified = False + self.current_provider_changed = False + + for i in range(len(self.providers)): + if self.providers[i]["name"] == self.config_manager.selected_provider_name: + self.selected_provider_index = i + break + + self.main_layout = QVBoxLayout(self); self.create_options_ui() + + self.save_button = QPushButton("Save", self) + self.save_button.clicked.connect(self.save_settings) + + self.main_layout.addWidget(self.options_tab) + self.main_layout.addWidget(self.save_button) + self.load_providers() def create_options_ui(self): - self.provider_label = QLabel("Select Provider:", self) - self.provider_combo = QComboBox(self) + self.options_tab = QTabWidget(self) + + # Add tab with settings + self.create_settings_ui() + + # Add tab with providers + self.create_providers_ui() + + def create_settings_ui(self): + self.settings_tab = QWidget(self) + self.options_tab.addTab(self.settings_tab, "Options") + self.settings_layout = QFormLayout(self.settings_tab) + + # Add check button to allow checking for updates + self.check_updates_checkbox = QCheckBox("Allow Check for Updates", self.settings_tab) + self.check_updates_checkbox.setChecked(self.config_manager.check_updates) + self.check_updates_checkbox.stateChanged.connect(self.on_check_updates_toggled) + self.settings_layout.addRow(self.check_updates_checkbox) + + # Add check button to show STB content info + self.show_stb_content_info_checkbox = QCheckBox("Show movie and serie info on STB provider", self.settings_tab) + self.show_stb_content_info_checkbox.setChecked(self.config_manager.show_stb_content_info) + self.settings_layout.addRow(self.show_stb_content_info_checkbox) + + def create_providers_ui(self): + self.providers_tab = QWidget(self) + self.options_tab.addTab(self.providers_tab, "Providers") + self.providers_layout = QFormLayout(self.providers_tab) + + self.provider_label = QLabel("Select Provider:", self.providers_tab) + self.provider_combo = QComboBox(self.providers_tab) self.provider_combo.currentIndexChanged.connect(self.load_provider_settings) - self.layout.addRow(self.provider_label, self.provider_combo) + self.providers_layout.addRow(self.provider_label, self.provider_combo) - self.add_provider_button = QPushButton("Add Provider", self) + self.add_provider_button = QPushButton("Add Provider", self.providers_tab) self.add_provider_button.clicked.connect(self.add_new_provider) - self.layout.addWidget(self.add_provider_button) + self.providers_layout.addWidget(self.add_provider_button) - self.remove_provider_button = QPushButton("Remove Provider", self) + self.remove_provider_button = QPushButton("Remove Provider", self.providers_tab) self.remove_provider_button.clicked.connect(self.remove_provider) - self.layout.addWidget(self.remove_provider_button) + self.providers_layout.addWidget(self.remove_provider_button) + + self.name_label = QLabel("Name:", self.providers_tab) + self.name_input = QLineEdit(self.providers_tab) + self.providers_layout.addRow(self.name_label, self.name_input) self.create_stream_type_ui() - self.url_label = QLabel("Server URL:", self) - self.url_input = QLineEdit(self) - self.layout.addRow(self.url_label, self.url_input) - self.mac_label = QLabel("MAC Address (STB only):", self) - self.mac_input = QLineEdit(self) - self.layout.addRow(self.mac_label, self.mac_input) + self.url_label = QLabel("Server URL:", self.providers_tab) + self.url_input = QLineEdit(self.providers_tab) + self.providers_layout.addRow(self.url_label, self.url_input) + + self.mac_label = QLabel("MAC Address:", self.providers_tab) + self.mac_input = QLineEdit(self.providers_tab) + self.providers_layout.addRow(self.mac_label, self.mac_input) - self.file_button = QPushButton("Load File", self) + self.file_button = QPushButton("Load File", self.providers_tab) self.file_button.clicked.connect(self.load_file) - self.layout.addWidget(self.file_button) + self.providers_layout.addWidget(self.file_button) - self.username_label = QLabel("Username:", self) - self.username_input = QLineEdit(self) - self.layout.addRow(self.username_label, self.username_input) + self.username_label = QLabel("Username:", self.providers_tab) + self.username_input = QLineEdit(self.providers_tab) + self.providers_layout.addRow(self.username_label, self.username_input) - self.password_label = QLabel("Password:", self) - self.password_input = QLineEdit(self) - self.layout.addRow(self.password_label, self.password_input) + self.password_label = QLabel("Password:", self.providers_tab) + self.password_input = QLineEdit(self.providers_tab) + self.providers_layout.addRow(self.password_label, self.password_input) - self.verify_button = QPushButton("Verify Provider", self) + self.verify_apply_group = QWidget(self.providers_tab) + self.verify_button = QPushButton("Verify Provider", self.verify_apply_group) self.verify_button.clicked.connect(self.verify_provider) - self.layout.addWidget(self.verify_button) - self.verify_result = QLabel("", self) - self.layout.addWidget(self.verify_result) - self.save_button = QPushButton("Save", self) - self.save_button.clicked.connect(self.save_settings) - self.layout.addWidget(self.save_button) + self.apply_button = QPushButton("Apply Change", self.verify_apply_group) + self.apply_button.clicked.connect(self.apply_provider) + verify_apply_layout = QHBoxLayout(self.verify_apply_group) + verify_apply_layout.addWidget(self.verify_button) + verify_apply_layout.addWidget(self.apply_button) + self.verify_result = QLabel("", self.providers_tab) + self.providers_layout.addWidget(self.verify_apply_group) + self.providers_layout.addWidget(self.verify_result) def create_stream_type_ui(self): self.type_label = QLabel("Stream Type:", self) @@ -85,21 +151,22 @@ def create_stream_type_ui(self): self.type_M3USTREAM.toggled.connect(self.update_inputs) self.type_XTREAM.toggled.connect(self.update_inputs) - self.layout.addRow(self.type_label) - self.layout.addRow(self.type_STB) - self.layout.addRow(self.type_M3UPLAYLIST) - self.layout.addRow(self.type_M3USTREAM) - self.layout.addRow(self.type_XTREAM) + grid_layout = QGridLayout() + grid_layout.addWidget(self.type_STB, 0, 0) + grid_layout.addWidget(self.type_M3UPLAYLIST, 0, 1) + grid_layout.addWidget(self.type_M3USTREAM, 1, 0) + grid_layout.addWidget(self.type_XTREAM, 1, 1) + self.providers_layout.addRow(self.type_label, grid_layout) def load_providers(self): self.provider_combo.blockSignals(True) self.provider_combo.clear() - for i, provider in enumerate(self.config["data"]): - # can we get the first couple ... last couple of characters of the url? + for i, provider in enumerate(self.providers): + # can we get the first couple ... last couple of characters of the name? prov = ( - provider["url"][:30] + "..." + provider["url"][-15:] - if len(provider["url"]) > 45 - else provider["url"] + provider["name"][:30] + "..." + provider["name"][-15:] + if len(provider["name"]) > 45 + else provider["name"] ) self.provider_combo.addItem(f"{i + 1}: {prov}", userData=provider) self.provider_combo.blockSignals(False) @@ -107,19 +174,21 @@ def load_providers(self): self.load_provider_settings(self.selected_provider_index) def load_provider_settings(self, index): - if index == -1 or index >= len(self.config["data"]): + if index == -1 or index >= len(self.providers): return + self.selected_provider_name = self.providers[index].get("name", self.providers[index].get("url", "")) self.selected_provider_index = index - self.selected_provider = self.config["data"][index] - self.url_input.setText(self.selected_provider.get("url", "")) - self.mac_input.setText(self.selected_provider.get("mac", "")) - self.username_input.setText(self.selected_provider.get("username", "")) - self.password_input.setText(self.selected_provider.get("password", "")) + self.edited_provider = self.providers[index] + self.name_input.setText(self.edited_provider.get("name", "")) + self.url_input.setText(self.edited_provider.get("url", "")) + self.mac_input.setText(self.edited_provider.get("mac", "")) + self.username_input.setText(self.edited_provider.get("username", "")) + self.password_input.setText(self.edited_provider.get("password", "")) self.update_radio_buttons() self.update_inputs() def update_radio_buttons(self): - provider_type = self.selected_provider.get("type", "") + provider_type = self.edited_provider.get("type", "") self.type_STB.setChecked(provider_type == "STB") self.type_M3UPLAYLIST.setChecked(provider_type == "M3UPLAYLIST") self.type_M3USTREAM.setChecked(provider_type == "M3USTREAM") @@ -140,38 +209,42 @@ def update_inputs(self): self.password_input.setVisible(self.type_XTREAM.isChecked()) def add_new_provider(self): - new_provider = {"type": "STB", "url": "", "mac": ""} - self.config["data"].append(new_provider) + new_provider = {"type": "STB", "name": "", "url": "", "mac": ""} + self.providers.append(new_provider) self.load_providers() - self.provider_combo.setCurrentIndex(len(self.config["data"]) - 1) + self.provider_combo.setCurrentIndex(len(self.providers) - 1) + self.providers_modified = True def remove_provider(self): - if len(self.config["data"]) == 1: + if len(self.providers) == 1: return - del self.config["data"][self.provider_combo.currentIndex()] + del self.providers[self.provider_combo.currentIndex()] self.load_providers() self.provider_combo.setCurrentIndex( - min(self.selected_provider_index, len(self.config["data"]) - 1) + min(self.selected_provider_index, len(self.providers) - 1) ) + self.providers_modified = True def save_settings(self): - if self.selected_provider: - self.selected_provider["url"] = self.url_input.text() - if self.type_STB.isChecked(): - self.selected_provider["type"] = "STB" - self.selected_provider["mac"] = self.mac_input.text() - elif self.type_M3UPLAYLIST.isChecked(): - self.selected_provider["type"] = "M3UPLAYLIST" - elif self.type_M3USTREAM.isChecked(): - self.selected_provider["type"] = "M3USTREAM" - elif self.type_XTREAM.isChecked(): - self.selected_provider["type"] = "XTREAM" - self.selected_provider["username"] = self.username_input.text() - self.selected_provider["password"] = self.password_input.text() - self.config["selected"] = self.selected_provider_index - self.parent().save_config() - self.parent().load_content() - self.accept() + self.config_manager.check_updates = self.check_updates_checkbox.isChecked() + self.config_manager.show_stb_content_info = self.show_stb_content_info_checkbox.isChecked() + + current_provider_changed = False + + if self.config_manager.selected_provider_name != self.selected_provider_name: + self.config_manager.selected_provider_name = self.selected_provider_name + current_provider_changed = True + + # Save the configuration + self.parent().save_config() + + if self.providers_modified: + self.provider_manager.save_providers() + + if current_provider_changed: + self.parent().set_provider() + + self.accept() def load_file(self): file_dialog = QFileDialog(self) @@ -187,7 +260,7 @@ def verify_provider(self): url = self.url_input.text() if self.type_STB.isChecked(): - result = self.parent().do_handshake(url, self.mac_input.text(), load=False) + result = self.provider_manager.do_handshake(url, self.mac_input.text()) elif self.type_M3UPLAYLIST.isChecked() or self.type_M3USTREAM.isChecked(): if url.startswith(("http://", "https://")): result = self.parent().verify_url(url) @@ -202,3 +275,44 @@ def verify_provider(self): else "Failed to verify provider." ) self.verify_result.setStyleSheet("color: green;" if result else "color: red;") + + def apply_provider(self): + if self.edited_provider: + self.edited_provider["name"] = self.name_input.text() + self.edited_provider["url"] = self.url_input.text() + if not self.edited_provider["name"]: + self.edited_provider["name"] = self.edited_provider["url"] + if self.type_STB.isChecked(): + self.edited_provider["type"] = "STB" + self.edited_provider["mac"] = self.mac_input.text() + elif self.type_M3UPLAYLIST.isChecked(): + self.edited_provider["type"] = "M3UPLAYLIST" + elif self.type_M3USTREAM.isChecked(): + self.edited_provider["type"] = "M3USTREAM" + elif self.type_XTREAM.isChecked(): + self.edited_provider["type"] = "XTREAM" + self.edited_provider["username"] = self.username_input.text() + self.edited_provider["password"] = self.password_input.text() + self.selected_provider_name = self.edited_provider["name"] + self.provider_combo.setItemText( + self.selected_provider_index, + f"{self.selected_provider_index + 1}: {self.edited_provider['name']}", + ) + self.providers_modified = True + + def on_check_updates_toggled(self): + if self.check_updates_checkbox.isChecked(): + check_for_updates() + + + @staticmethod + def verify_url(url): + if url.startswith(("http://", "https://")): + try: + response = requests.head(url, timeout=5) + return response.status_code == 200 + except requests.RequestException as e: + print(f"Error verifying URL: {e}") + return False + else: + return os.path.isfile(url) \ No newline at end of file diff --git a/provider_manager.py b/provider_manager.py new file mode 100644 index 0000000..57d3e7f --- /dev/null +++ b/provider_manager.py @@ -0,0 +1,189 @@ +import os +import hashlib +import random +import string +import requests +import tzlocal +import orjson as json +from urlobject import URLObject +from urllib.parse import urlencode +from PySide6.QtCore import QObject, Signal + +class ProviderContext: + def __init__(self): + self.provider_url = None + self.headers = None + +class ProviderManager(QObject): + progress = Signal(str) + + def __init__(self, config_manager, provider_context): + super().__init__() + self.config_manager = config_manager + self.provider_context = provider_context + self.provider_dir = os.path.join(config_manager.get_config_dir(), 'cache', 'provider') + os.makedirs(self.provider_dir, exist_ok=True) + self.index_file = os.path.join(self.provider_dir, 'index.json') + self.providers = [] + self.current_provider = {} + self.current_provider_content = {} + self.token = "" + self.headers = {} + self._load_providers() + + def _current_provider_cache_name(self): + hashed_name = hashlib.sha256(self.current_provider["name"].encode('utf-8')).hexdigest() + return os.path.join(self.provider_dir, f"{hashed_name}.json") + + def _update_provider_context(self): + if self.current_provider["type"] == "STB": + self.provider_context.provider_url = self.current_provider["url"] + self.provider_context.headers = self.headers + else: + self.provider_context.provider_url = None + self.provider_context.headers = None + + def _load_providers(self): + try: + with open(self.index_file, "r", encoding="utf-8") as f: + self.providers = json.loads(f.read()) + if self.providers is None: + self.providers = self.default_providers() + except (FileNotFoundError, json.JSONDecodeError): + self.providers = self.default_providers() + self.save_providers() + + def set_current_provider(self, progress_callback): + progress_callback.emit("Searching for provider...") + # search for provider in the list + if self.config_manager.selected_provider_name: + for provider in self.providers: + if provider["name"] == self.config_manager.selected_provider_name: + self.current_provider = provider + self._update_provider_context() + break + + # if provider not found, set the first one + if not self.current_provider: + self.current_provider = self.providers[0] + + progress_callback.emit("Loading provider content...") + try: + with open(self._current_provider_cache_name(), "r", encoding="utf-8") as f: + self.current_provider_content = json.loads(f.read()) + except (FileNotFoundError, json.JSONDecodeError): + self.current_provider_content = {} + + if self.current_provider["type"] == "STB": + progress_callback.emit("Performing handshake...") + self.token = "" + self.do_handshake(self.current_provider["url"], self.current_provider["mac"]) + progress_callback.emit("Provider setup complete.") + + def save_providers(self): + serialized = json.dumps(self.providers, option=json.OPT_INDENT_2) + with open(self.index_file, "w", encoding="utf-8") as f: + f.write(serialized.decode("utf-8")) + + # Delete provider files not in the providers list + for provider in os.listdir(self.provider_dir): + if provider == "index.json": + continue + if provider not in self.providers: + os.remove(os.path.join(self.provider_dir, provider)) + + def save_provider(self): + serialized = json.dumps(self.current_provider_content, option=json.OPT_INDENT_2) + with open(self._current_provider_cache_name(), "w", encoding="utf-8") as f: + f.write(serialized.decode("utf-8")) + + def do_handshake(self, url, mac, serverload="/portal.php"): + self.token = self.token if self.token else self.random_token() + self.headers = self.create_headers(url, mac, self.token) + try: + prehash = "2614ddf9829ba9d284f389d88e8c669d81f6a5c2" + fetchurl = f"{url}{serverload}?type=stb&action=handshake&prehash={prehash}&token=&JsHttpRequest=1-xml" + handshake = requests.get(fetchurl, timeout=5, headers=self.headers) + if handshake.status_code == 200: + body = handshake.json() + else: + raise Exception(f"Failed to fetch handshake: {handshake.status_code}") + self.token = body["js"]["token"] + self.headers["Authorization"] = f"Bearer {self.token}" + + # Use get_profile request to detect blocked providers + + params = { + "ver": "ImageDescription: 2.20.02-pub-424; ImageDate: Fri May 8 15:39:55 UTC 2020; PORTAL version: 5.3.0; API Version: JS API version: 343; STB API version: 146; Player Engine version: 0x588", + "num_banks" : "2", + "sn": "062014N067770", + "stb_type": "MAG424", + "client_type": "STB", + "image_version":"220", + "video_out": "hdmi", + "device_id": "", + "device_id2": "", + "signature": "", + "auth_second_step": "1", + "hw_version": "1.7-BD-00", + "not_valid_token": "0", + "metrics": f'{{"mac":"{mac}", "sn":"062014N067770","model":"MAG424","type":"STB","uid":"","random":""}}', + "hw_version_2": "bb8b74cdcaa19c7f6a6bdfecc8e91b7e4b5ea556", + "timestamp": "1729441259", + "api_signature": "262", + "prehash": {prehash}, + } + encoded_params = urlencode(params) + + fetchurl = f'{url}{serverload}?type=stb&action=get_profile&hd=1&{encoded_params}&JsHttpRequest=1-xml' + profile = requests.get(fetchurl, timeout=5, headers=self.headers) + if profile.status_code == 200: + body = profile.json() + else: + raise Exception(f"Failed to fetch profile: {profile.status_code}") + + theId = body["js"]["id"] + theName = body["js"]["name"] + if not theId and not theName: + raise Exception("Provider is blocked") + + return True + except Exception as e: + if serverload != "/server/load.php" and 'handshake' in fetchurl: + serverload = "/server/load.php" + return self.do_handshake(url, mac, serverload) + print("Error in handshake:", e) + return False + + + @staticmethod + def default_providers(): + return [ + { + "type": "M3UPLAYLIST", + "name": "iptv-org.github.io", + "url": "https://iptv-org.github.io/iptv/index.m3u", + } + ] + + @staticmethod + def random_token(): + return "".join(random.choices(string.ascii_letters + string.digits, k=32)) + + @staticmethod + def create_headers(url, mac, token): + url = URLObject(url) + timezone = tzlocal.get_localzone().key + headers = { + "User-Agent": "Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG200 stbapp ver: 2 rev: 250 Safari/533.3", + "Accept-Charset": "UTF-8,*;q=0.8", + "X-User-Agent": "Model: MAG200; Link: Ethernet", + "Host": f"{url.netloc}", + "Range": "bytes=0-", + "Accept": "*/*", + "Referer": f"{url}/c/" if not url.path else f"{url}/", + "Cookie": f"mac={mac}; stb_lang=en; timezone={timezone}; PHPSESSID=null;", + "Authorization": f"Bearer {token}", + } + return headers + diff --git a/requirements.txt b/requirements.txt index fe008e6..e411fb5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,5 @@ m3u-parser pyqtdarktheme==2.1.0 PySide6 orjson +tzlocal aiohttp[speedups] \ No newline at end of file diff --git a/video_player.py b/video_player.py index 9192d08..a2c75fb 100644 --- a/video_player.py +++ b/video_player.py @@ -3,8 +3,8 @@ import sys import vlc -from PySide6.QtCore import QEvent, QMetaObject, QPoint, Qt, QTimer, Slot -from PySide6.QtGui import QCursor, QGuiApplication +from PySide6.QtCore import QEvent, QMetaObject, QPoint, Qt, QTimer, Slot, Signal +from PySide6.QtGui import QGuiApplication from PySide6.QtWidgets import QFrame, QMainWindow, QProgressBar, QVBoxLayout logging.basicConfig(level=logging.ERROR) @@ -23,6 +23,9 @@ def get_latest_error(self): class VideoPlayer(QMainWindow): + playing = Signal() + stopped = Signal() + def __init__(self, config_manager, *args, **kwargs): super(VideoPlayer, self).__init__(*args, **kwargs) self.config_manager = config_manager @@ -207,7 +210,8 @@ def mouseDoubleClickEvent(self, event): def closeEvent(self, event): if self.media_player.is_playing(): self.media_player.stop() - self.config_manager.save_window_settings(self.geometry(), "video_player") + self.stopped.emit() + self.config_manager.save_window_settings(self, "video_player") self.hide() event.ignore() @@ -234,20 +238,25 @@ def play_video(self, video_url): else: self.adjust_aspect_ratio() self.show() + self.playing.emit() QTimer.singleShot(5000, self.check_playback_status) def check_playback_status(self): - if not self.media_player.is_playing(): - media_state = self.media.get_state() - if media_state == vlc.State.Error: - self.handle_error("Playback error") - else: - self.handle_error("Failed to start playback") + state = self.media_player.get_state() + if state == vlc.State.Playing: # only check if media has not been paused, or stopped + if not self.media_player.is_playing(): + media_state = self.media.get_state() + if media_state == vlc.State.Error: + self.handle_error("Playback error") + else: + self.handle_error("Failed to start playback") + self.stopped.emit() def stop_video(self): self.media_player.stop() self.progress_bar.setVisible(False) self.update_timer.stop() + self.stopped.emit() def toggle_mute(self): state = self.media_player.audio_get_mute() From 869b1d64442b1b79d5ad34a255a67c33a778bb04 Mon Sep 17 00:00:00 2001 From: pcjco Date: Sat, 9 Nov 2024 23:33:28 +0100 Subject: [PATCH 2/7] Adding some EPG support (from localor remote XMLTV file or from STB) Adding image management for downloaded logos and posters Showing Movie/Serie Genre column --- channel_list.py | 1583 +++++++++++++++++++++++++++++++------------ config_manager.py | 243 ++++++- content_loader.py | 181 +++++ epg_manager.py | 402 +++++++++++ image_loader.py | 72 ++ image_manager.py | 246 +++++++ main.py | 14 +- multikeydict.py | 75 ++ options.py | 627 +++++++++++++++-- provider_manager.py | 175 +++++ requirements.txt | 1 + video_player.py | 27 +- 12 files changed, 3091 insertions(+), 555 deletions(-) create mode 100644 content_loader.py create mode 100644 epg_manager.py create mode 100644 image_loader.py create mode 100644 image_manager.py create mode 100644 multikeydict.py create mode 100644 provider_manager.py diff --git a/channel_list.py b/channel_list.py index fcab0a1..6ff3f72 100644 --- a/channel_list.py +++ b/channel_list.py @@ -1,30 +1,37 @@ -import asyncio import os import platform -import random import re import shutil -import string import subprocess import time +import base64 from urllib.parse import urlparse - -import aiohttp -import orjson +from content_loader import ContentLoader +from image_loader import ImageLoader +from datetime import datetime import requests -from PySide6.QtCore import Qt, QThread, Signal -from PySide6.QtGui import QColor +from PySide6.QtCore import Qt, QThread, Signal, QSize, QTimer, QRect, QBuffer +from PySide6.QtGui import QColor, QFont, QTextDocument, QTextOption, QTextCursor, QFontMetrics from PySide6.QtWidgets import ( + QApplication, QCheckBox, QFileDialog, - QGridLayout, QHBoxLayout, + QLabel, QLineEdit, + QListWidget, + QListWidgetItem, QMainWindow, QMessageBox, QProgressBar, QPushButton, QRadioButton, + QSizePolicy, + QSplitter, + QStyle, + QStyledItemDelegate, + QStyleOptionProgressBar, + QStyleOptionViewItem, QTreeWidget, QTreeWidgetItem, QVBoxLayout, @@ -54,213 +61,326 @@ def __lt__(self, other): return True return t1 < t2 +class ChannelTreeWidgetItem(QTreeWidgetItem): + # Modify the sorting by Channel Number to used integer and not string (1 < 10, but "1" may not be < "10") + # Modify the sorting by Program Progress to read the progress in item data + def __lt__(self, other): + if not isinstance(other, ChannelTreeWidgetItem): + return super(ChannelTreeWidgetItem, self).__lt__(other) + + sort_column = self.treeWidget().sortColumn() + if sort_column == 0: # Channel number + return int(self.text(sort_column)) < int(other.text(sort_column)) + elif sort_column == 2: # EPG Program progress + p1 = self.data(sort_column, Qt.UserRole) + if p1 is None: + return False + p2 = other.data(sort_column, Qt.UserRole) + if p2 is None: + return True + return self.data(sort_column, Qt.UserRole) < other.data(sort_column, Qt.UserRole) + elif sort_column == 3: # EPG Program name + return self.data(sort_column, Qt.UserRole) < other.data(sort_column, Qt.UserRole) + + return self.text(sort_column) < other.text(sort_column) class NumberedTreeWidgetItem(QTreeWidgetItem): - # Modify the sorting by # to used integer and not string (1 < 10, but "1" may not be < "10") + # Modify the sorting by Number to used integer and not string (1 < 10, but "1" may not be < "10") def __lt__(self, other): if not isinstance(other, NumberedTreeWidgetItem): return super(NumberedTreeWidgetItem, self).__lt__(other) sort_column = self.treeWidget().sortColumn() - sort_header = self.treeWidget().headerItem().text(sort_column) - if sort_header == "#": + if sort_column == 0: # Channel number return int(self.text(sort_column)) < int(other.text(sort_column)) return self.text(sort_column) < other.text(sort_column) +class HtmlItemDelegate(QStyledItemDelegate): + elidedPostfix = "..." + doc = QTextDocument() + doc.setDocumentMargin(1); -class ContentLoader(QThread): - content_loaded = Signal(dict) - progress_updated = Signal(int, int) - - def __init__( - self, - url, - headers, - content_type, - category_id=None, - parent_id=None, - movie_id=None, - season_id=None, - action="get_ordered_list", - sortby="name", - ): + def __init__( self ): super().__init__() - self.url = url - self.headers = headers - self.content_type = content_type - self.category_id = category_id - self.parent_id = parent_id - self.movie_id = movie_id - self.season_id = season_id - self.action = action - self.sortby = sortby - self.items = [] - - async def fetch_page(self, session, page, max_retries=3): - for attempt in range(max_retries): - try: - params = self.get_params(page) - async with session.get( - self.url, headers=self.headers, params=params, timeout=30 - ) as response: - content = await response.read() - if response.status == 503 or not content: - wait_time = (2**attempt) + random.uniform(0, 1) - print( - f"Received error or empty response. Retrying in {wait_time:.2f} seconds..." - ) - await asyncio.sleep(wait_time) - continue - result = orjson.loads(content) - return ( - result["js"]["data"], - int(result["js"]["total_items"]), - int(result["js"]["max_page_items"]), - ) - except ( - aiohttp.ClientError, - orjson.JSONDecodeError, - asyncio.TimeoutError, - ) as e: - print(f"Error fetching page {page}: {e}") - if attempt == max_retries - 1: - raise - wait_time = (2**attempt) + random.uniform(0, 1) - print(f"Retrying in {wait_time:.2f} seconds...") - await asyncio.sleep(wait_time) - return [], 0, 0 - - def get_params(self, page): - params = { - "type": self.content_type, - "action": self.action, - "p": str(page), - "JsHttpRequest": "1-xml", - } - if self.content_type == "itv": - params.update( - { - "genre": self.category_id if self.category_id else "*", - "force_ch_link_check": "", - "fav": "0", - "sortby": self.sortby, - "hd": "0", - } - ) - elif self.content_type == "vod": - params.update( - { - "category": self.category_id if self.category_id else "*", - "sortby": self.sortby, - } - ) - elif self.content_type == "series": - params.update( - { - "category": self.category_id if self.category_id else "*", - "movie_id": self.movie_id if self.movie_id else "0", - "season_id": self.season_id if self.season_id else "0", - "episode_id": "0", - "sortby": self.sortby, - } - ) - return params - - async def load_content(self): - async with aiohttp.ClientSession() as session: - # Fetch initial data to get total items and max page items - page = 1 - page_items, total_items, max_page_items = await self.fetch_page( - session, page - ) - self.items.extend(page_items) - - pages = (total_items + max_page_items - 1) // max_page_items - self.progress_updated.emit(1, pages) - - tasks = [] - for page_num in range(2, pages + 1): - tasks.append(self.fetch_page(session, page_num)) - - for i, task in enumerate(asyncio.as_completed(tasks), 2): - page_items, _, _ = await task - self.items.extend(page_items) - self.progress_updated.emit(i, pages) - - # Emit all items once done - self.content_loaded.emit( - { - "category_id": self.category_id, - "items": self.items, - "parent_id": self.parent_id, - "movie_id": self.movie_id, - "season_id": self.season_id, - } - ) + + def paint(self, painter, inOption, index): + options = QStyleOptionViewItem(inOption) + self.initStyleOption(options, index) + if not options.text: + return super().paint(painter, inOption, index) + style = options.widget.style() if options.widget else QApplication.style() + + textOption = QTextOption() + textOption.setWrapMode(QTextOption.WordWrap if options.features & QStyleOptionViewItem.WrapText else QTextOption.ManualWrap) + textOption.setTextDirection(options.direction) + + self.doc.setDefaultTextOption(textOption) + self.doc.setHtml(options.text) + self.doc.setDefaultFont(options.font) + self.doc.setTextWidth(options.rect.width()) + self.doc.adjustSize() + + if self.doc.size().width() > options.rect.width(): + # Elide text + cursor = QTextCursor(self.doc) + cursor.movePosition(QTextCursor.End) + metric = QFontMetrics(options.font) + postfixWidth = metric.horizontalAdvance(self.elidedPostfix) + while (self.doc.size().width() > options.rect.width() - postfixWidth): + cursor.deletePreviousChar() + self.doc.adjustSize() + cursor.insertText(self.elidedPostfix) + + # Painting item without text (this takes care of painting e.g. the highlighted for selected + # or hovered over items in an ItemView) + options.text = '' + style.drawControl(QStyle.CE_ItemViewItem, options, painter, inOption.widget) + + # Figure out where to render the text in order to follow the requested alignment + textRect = style.subElementRect(QStyle.SE_ItemViewItemText, options) + documentSize = QSize(self.doc.size().width(), self.doc.size().height()) # Convert QSizeF to QSize + layoutRect = QRect(QStyle.alignedRect(Qt.LayoutDirectionAuto, options.displayAlignment, documentSize, textRect)) + + painter.save() + + # Translate the painter to the origin of the layout rectangle in order for the text to be + # rendered at the correct position + painter.translate(layoutRect.topLeft()) + self.doc.drawContents(painter, textRect.translated(-textRect.topLeft())) + + painter.restore() + + def sizeHint( self, inOption, index ): + options = QStyleOptionViewItem(inOption) + self.initStyleOption(options, index) + if not options.text: + return super().sizeHint(inOption, index) + self.doc.setHtml(options.text) + self.doc.setTextWidth(options.rect.width()) + return QSize(self.doc.idealWidth(), self.doc.size().height()) + +class ChannelItemDelegate(QStyledItemDelegate): + def __init__(self): + super().__init__() + + def paint(self, painter, inOption, index): + col = index.column() + if col == 2: + progress = index.data(Qt.UserRole) + if not progress is None: + options = QStyleOptionViewItem(inOption) + self.initStyleOption(options, index) + style = options.widget.style() if options.widget else QApplication.style() + opt = QStyleOptionProgressBar() + opt.rect = inOption.rect + opt.minimum = 0 + opt.maximum = 100 + opt.progress = progress + opt.textVisible = False + style.drawControl(QStyle.CE_ProgressBar, opt, painter, inOption.widget) + else: + super().paint(painter, inOption, index) + elif col == 3: + epg_text = index.data(Qt.UserRole) + if epg_text: + options = QStyleOptionViewItem(inOption) + self.initStyleOption(options, index) + style = options.widget.style() if options.widget else QApplication.style() + options.text = epg_text + style.drawControl(QStyle.CE_ItemViewItem, options, painter, inOption.widget) + else: + super().paint(painter, inOption, index) + else: + super().paint(painter, inOption, index) + +class SetProviderThread(QThread): + progress = Signal(str) + + def __init__(self, provider_manager, epg_manager): + super().__init__() + self.provider_manager = provider_manager + self.epg_manager = epg_manager def run(self): try: - asyncio.run(self.load_content()) + self.provider_manager.set_current_provider(self.progress) + self.epg_manager.set_current_epg() except Exception as e: - print(f"Error in content loading: {e}") - + print(f"Error in initializing provider: {e}") class ChannelList(QMainWindow): - content_loaded = Signal(list) - def __init__(self, app, player, config_manager): + def __init__(self, app, player, config_manager, provider_manager, image_manager, epg_manager): super().__init__() self.app = app self.player = player self.config_manager = config_manager - self.config = self.config_manager.config + self.provider_manager = provider_manager + self.image_manager = image_manager + self.epg_manager = epg_manager + self.splitter_ratio = 0.75 + self.splitter_content_info_ratio = 0.33 self.config_manager.apply_window_settings("channel_list", self) self.setWindowTitle("QiTV Content List") self.container_widget = QWidget(self) self.setCentralWidget(self.container_widget) - self.grid_layout = QGridLayout(self.container_widget) self.content_type = "itv" # Default to channels (STB type) + self.content_info_show = None self.create_upper_panel() - self.create_left_panel() + self.create_list_panel() + self.create_content_info_panel() self.create_media_controls() + + self.main_layout = QVBoxLayout() + self.main_layout.addWidget(self.upper_layout) + self.main_layout.addWidget(self.list_panel) + + widget_top = QWidget() + widget_top.setLayout(self.main_layout) + + # Splitter with content info part + self.splitter = QSplitter(Qt.Vertical) + self.splitter.addWidget(widget_top) + self.splitter.addWidget(self.content_info_panel) + self.splitter.setSizes([1, 0]) + + container_layout = QVBoxLayout(self.container_widget) + container_layout.setContentsMargins(0, 0, 0, 0) # Set margins to zero + container_layout.addWidget(self.splitter) + container_layout.addWidget(self.media_controls) + self.link = None self.current_category = None # For back navigation self.current_series = None self.current_season = None self.navigation_stack = [] # To keep track of navigation for back button - self.load_content() + # Connect player signals to show/hide media controls + self.player.playing.connect(self.show_media_controls) + self.player.stopped.connect(self.hide_media_controls) + + self.splitter.splitterMoved.connect(self.update_splitter_ratio) self.channels_radio.toggled.connect(self.toggle_content_type) self.movies_radio.toggled.connect(self.toggle_content_type) self.series_radio.toggled.connect(self.toggle_content_type) + # Create a timer to update "On Air" status + self.refresh_on_air_timer = QTimer(self) + self.refresh_on_air_timer.timeout.connect(self.refresh_on_air) + + self.update_layout() + + self.set_provider() + def closeEvent(self, event): + # Stop and delete timer + if self.refresh_on_air_timer.isActive(): + self.refresh_on_air_timer.stop() + self.refresh_on_air_timer.deleteLater() + self.app.quit() self.player.close() - self.config_manager.save_window_settings(self.geometry(), "channel_list") + self.image_manager.save_index() + self.epg_manager.save_index() + self.config_manager.save_window_settings(self, "channel_list") event.accept() + def refresh_on_air(self): + epg_source = self.config_manager.epg_source + for i in range(self.content_list.topLevelItemCount()): + item = self.content_list.topLevelItem(i) + item_data = item.data(0, Qt.UserRole) + content_type = item_data.get("type") + + if self.can_show_epg(content_type) and self.config_manager.channel_epg: + epg_data = self.epg_manager.get_programs_for_channel(item_data["data"], None, 1) + if epg_data: + epg_item = epg_data[0] + if epg_source == "STB": + start_time = datetime.strptime(epg_item["time"], "%Y-%m-%d %H:%M:%S") + end_time = datetime.strptime(epg_item["time_to"], "%Y-%m-%d %H:%M:%S") + else: + start_time = datetime.strptime(epg_item["@start"], "%Y%m%d%H%M%S %z") + end_time = datetime.strptime(epg_item["@stop"], "%Y%m%d%H%M%S %z") + now = datetime.now(start_time.tzinfo) + if end_time != start_time: + progress = 100 * (now - start_time).total_seconds() / (end_time - start_time).total_seconds() + else: + progress = 0 if now < start_time else 100 + progress = max(0, min(100, progress)) + if epg_source == "STB": + epg_text = f"{epg_item['name']}" + else: + epg_text = f"{epg_item['title'].get('__text')}" + item.setData(2, Qt.UserRole, progress) + item.setData(3, Qt.UserRole, epg_text) + else: + item.setData(2, Qt.UserRole, None) + item.setData(3, Qt.UserRole, "") + + self.content_list.viewport().update() + + def set_provider(self, force_update=False): + self.lock_ui_before_loading() + self.progress_bar.setRange(0, 0) # busy indicator + + self.set_provider_thread = SetProviderThread(self.provider_manager, self.epg_manager) + self.set_provider_thread.progress.connect(self.update_busy_progress) + self.set_provider_thread.finished.connect(lambda: self.set_provider_finished(force_update)) + self.set_provider_thread.start() + + def set_provider_finished(self, force_update=False): + self.progress_bar.setRange(0, 100) # Stop busy indicator + if hasattr(self, "set_provider_thread"): + self.set_provider_thread.deleteLater() + del self.set_provider_thread + self.unlock_ui_after_loading() + + # No need to switch content type if not STB + selected_provider = self.provider_manager.current_provider + config_type = selected_provider.get("type", "") + self.content_switch_group.setVisible(config_type == "STB") + + if force_update: + self.update_content() + else: + self.load_content() + + def update_splitter_ratio(self, pos, index): + sizes = self.splitter.sizes() + total_size = sizes[0] + sizes[1] + if total_size: + self.splitter_ratio = sizes[0] / total_size + + def update_splitter_content_info_ratio(self, pos, index): + sizes = self.splitter_content_info.sizes() + total_size = sizes[0] + sizes[1] + if total_size: + self.splitter_content_info_ratio = sizes[0] / total_size + def create_upper_panel(self): self.upper_layout = QWidget(self.container_widget) main_layout = QVBoxLayout(self.upper_layout) + main_layout.setContentsMargins(0, 0, 0, 0) # Set margins to zero # Top row top_layout = QHBoxLayout() + top_layout.setContentsMargins(0, 0, 0, 0) # Set margins to zero self.open_button = QPushButton("Open File") self.open_button.clicked.connect(self.open_file) top_layout.addWidget(self.open_button) - self.options_button = QPushButton("Options") + self.options_button = QPushButton("Settings") self.options_button.clicked.connect(self.options_dialog) top_layout.addWidget(self.options_button) self.update_button = QPushButton("Update Content") - self.update_button.clicked.connect(self.update_content) + self.update_button.clicked.connect(lambda: self.set_provider(True)) top_layout.addWidget(self.update_button) self.back_button = QPushButton("Back") @@ -272,6 +392,7 @@ def create_upper_panel(self): # Bottom row (export buttons) bottom_layout = QHBoxLayout() + bottom_layout.setContentsMargins(0, 0, 0, 0) # Set margins to zero self.export_button = QPushButton("Export Browsed") self.export_button.clicked.connect(self.export_content) @@ -281,72 +402,467 @@ def create_upper_panel(self): self.export_all_live_button.clicked.connect(self.export_all_live_channels) bottom_layout.addWidget(self.export_all_live_button) + self.rescanlogo_button = QPushButton("Rescan Channel Logos") + self.rescanlogo_button.clicked.connect(self.rescan_logos) + self.rescanlogo_button.setVisible(False) + bottom_layout.addWidget(self.rescanlogo_button) + main_layout.addLayout(bottom_layout) - self.grid_layout.addWidget(self.upper_layout, 0, 0) + def create_list_panel(self): + self.list_panel = QWidget(self.container_widget) + list_layout = QVBoxLayout(self.list_panel) + list_layout.setContentsMargins(0, 0, 0, 0) # Set margins to zero + + # Add content type selection + self.content_switch_group = QWidget(self.list_panel) + content_switch_layout = QHBoxLayout(self.content_switch_group) + content_switch_layout.setContentsMargins(0, 0, 0, 0) # Set margins to zero - def create_left_panel(self): - self.left_panel = QWidget(self.container_widget) - left_layout = QVBoxLayout(self.left_panel) + self.channels_radio = QRadioButton("Channels") + self.movies_radio = QRadioButton("Movies") + self.series_radio = QRadioButton("Series") - self.search_box = QLineEdit(self.left_panel) + content_switch_layout.addWidget(self.channels_radio) + content_switch_layout.addWidget(self.movies_radio) + content_switch_layout.addWidget(self.series_radio) + + self.channels_radio.setChecked(True) + + list_layout.addWidget(self.content_switch_group) + + self.search_box = QLineEdit(self.list_panel) self.search_box.setPlaceholderText("Search content...") self.search_box.textChanged.connect( lambda: self.filter_content(self.search_box.text()) ) - left_layout.addWidget(self.search_box) + list_layout.addWidget(self.search_box) - self.content_list = QTreeWidget(self.left_panel) + self.content_list = QTreeWidget(self.list_panel) + self.content_list.setSelectionMode(QTreeWidget.SingleSelection) self.content_list.setIndentation(0) - self.content_list.itemClicked.connect(self.item_selected) - - left_layout.addWidget(self.content_list) + self.content_list.setAlternatingRowColors(True) + self.content_list.itemSelectionChanged.connect(self.item_selected) + self.content_list.itemActivated.connect(self.item_activated) + self.refresh_content_list_size() - self.grid_layout.addWidget(self.left_panel, 1, 0) - self.grid_layout.setColumnStretch(0, 1) + list_layout.addWidget(self.content_list, 1) + # Create a horizontal layout for the favorite button and checkbox + self.favorite_layout = QHBoxLayout() + # Add favorite button and action self.favorite_button = QPushButton("Favorite/Unfavorite") self.favorite_button.clicked.connect(self.toggle_favorite) - left_layout.addWidget(self.favorite_button) + self.favorite_layout.addWidget(self.favorite_button) # Add checkbox to show only favorites self.favorites_only_checkbox = QCheckBox("Show only favorites") self.favorites_only_checkbox.stateChanged.connect( lambda: self.filter_content(self.search_box.text()) ) - left_layout.addWidget(self.favorites_only_checkbox) + self.favorite_layout.addWidget(self.favorites_only_checkbox) + + # Add checkbox to show EPG + self.epg_checkbox = QCheckBox("Show EPG") + self.epg_checkbox.setChecked(self.config_manager.channel_epg) + self.epg_checkbox.stateChanged.connect(self.show_epg) + self.favorite_layout.addWidget(self.epg_checkbox) + + # Add checkbox to show vod/tvshow content info + self.vodinfo_checkbox = QCheckBox("Show VOD Info") + self.vodinfo_checkbox.setChecked(self.config_manager.show_stb_content_info) + self.vodinfo_checkbox.stateChanged.connect(self.show_vodinfo) + self.favorite_layout.addWidget(self.vodinfo_checkbox) + + # Add the horizontal layout to the main vertical layout + list_layout.addLayout(self.favorite_layout) + + self.progress_bar = QProgressBar(self) + self.progress_bar.setRange(0, 100) + self.progress_bar.setValue(0) + self.progress_bar.setVisible(False) + list_layout.addWidget(self.progress_bar) - # Add content type selection - self.content_switch_group = QWidget(self.left_panel) - content_switch_layout = QHBoxLayout(self.content_switch_group) + self.cancel_button = QPushButton("Cancel") + self.cancel_button.clicked.connect(self.cancel_loading) + self.cancel_button.setVisible(False) + list_layout.addWidget(self.cancel_button) - self.channels_radio = QRadioButton("Channels") - self.movies_radio = QRadioButton("Movies") - self.series_radio = QRadioButton("Series") + def show_vodinfo(self): + self.config_manager.show_stb_content_info = self.vodinfo_checkbox.isChecked() + self.save_config() + self.item_selected() - content_switch_layout.addWidget(self.channels_radio) - content_switch_layout.addWidget(self.movies_radio) - content_switch_layout.addWidget(self.series_radio) + def show_epg(self): + self.config_manager.channel_epg = self.epg_checkbox.isChecked() + self.save_config() - self.channels_radio.setChecked(True) + # Refresh the EPG data + self.epg_manager.set_current_epg() + self.refresh_channels() - self.channels_radio.toggled.connect(self.toggle_content_type) - self.movies_radio.toggled.connect(self.toggle_content_type) - self.series_radio.toggled.connect(self.toggle_content_type) + def refresh_channels(self): + # No refresh for content other than itv + if self.content_type != "itv": + return + # No refresh from itv list of categories + selected_provider = self.provider_manager.current_provider + config_type = selected_provider.get("type", "") + if config_type == "STB" and not self.current_category: + return - left_layout.addWidget(self.content_switch_group) + # Get the index of the selected item in the content list + selected_item = self.content_list.selectedItems() + if selected_item: + selected_row = self.content_list.indexOfTopLevelItem(selected_item[0]) - self.progress_bar = QProgressBar(self) - self.progress_bar.setRange(0, 100) - self.progress_bar.setValue(0) - self.progress_bar.setVisible(False) - left_layout.addWidget(self.progress_bar) + # Store how was sorted the content list + sort_column = self.content_list.sortColumn() - self.cancel_button = QPushButton("Cancel") - self.cancel_button.clicked.connect(self.cancel_content_loading) - self.cancel_button.setVisible(False) - left_layout.addWidget(self.cancel_button) + # Update the content list + if config_type != "STB": + # For non-STB, display content directly + content = self.provider_manager.current_provider_content.setdefault(self.content_type, {}) + self.display_content(content) + else: + # Reload the current category + self.load_content_in_category(self.current_category) + + # Restore the sorting + self.content_list.sortItems(sort_column, self.content_list.header().sortIndicatorOrder()) + + # Restore the selected item + if selected_item: + item = self.content_list.topLevelItem(selected_row) + self.content_list.setCurrentItem(item) + self.item_selected() + + def can_show_content_info(self, item_type): + return item_type in ["movie", "serie", "season", "episode"] and self.provider_manager.current_provider["type"] == "STB" + + def can_show_epg(self, item_type): + if item_type in ["channel", "content"]: + if self.config_manager.epg_source == "No Source": + return False + if self.config_manager.epg_source == "STB" and self.provider_manager.current_provider["type"] != "STB": + return False + return True + return False + + def create_content_info_panel(self): + self.content_info_panel = QWidget(self.container_widget) + self.content_info_layout = QVBoxLayout(self.content_info_panel) + self.content_info_panel.setVisible(False) + + def setup_movie_tvshow_content_info(self): + self.clear_content_info_panel() + self.content_info_text = QLabel(self.content_info_panel) + self.content_info_text.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Ignored) # Allow to reduce splitter below label minimum size + self.content_info_text.setAlignment(Qt.AlignLeft | Qt.AlignTop) + self.content_info_text.setWordWrap(True) + self.content_info_layout.addWidget(self.content_info_text, 1) + self.content_info_shown = "movie_tvshow" + + def setup_channel_program_content_info(self): + self.clear_content_info_panel() + self.splitter_content_info = QSplitter(Qt.Horizontal) + self.program_list = QListWidget() + self.program_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.program_list.setItemDelegate(HtmlItemDelegate()) + self.splitter_content_info.addWidget(self.program_list) + self.content_info_text = QLabel() + self.content_info_text.setAlignment(Qt.AlignLeft | Qt.AlignTop) + self.content_info_text.setWordWrap(True) + self.splitter_content_info.addWidget(self.content_info_text) + self.content_info_layout.addWidget(self.splitter_content_info) + self.splitter_content_info.setSizes([int(self.content_info_panel.width() * self.splitter_content_info_ratio), int(self.content_info_panel.width() * (1 - self.splitter_content_info_ratio))]) + self.content_info_shown = "channel" + + self.program_list.itemSelectionChanged.connect(self.update_channel_program) + self.splitter_content_info.splitterMoved.connect(self.update_splitter_content_info_ratio) + + def clear_content_info_panel(self): + # Clear all widgets from the content_info layout + for i in reversed(range(self.content_info_layout.count())): + widget = self.content_info_layout.itemAt(i).widget() + if widget is not None: + widget.setParent(None) + widget.deleteLater() + + # Clear the layout itself + while self.content_info_layout.count(): + item = self.content_info_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + elif item.layout(): + self.clear_layout(item.layout()) + + # Hide the content_info panel if it is visible + if self.content_info_panel.isVisible(): + self.content_info_panel.setVisible(False) + self.splitter.setSizes([1, 0]) + + self.content_info_shown = None + self.update_layout() + + def update_layout(self): + if self.content_info_panel.isVisible(): + self.main_layout.setContentsMargins(8, 8, 8, 4) + if self.media_controls.isVisible(): + self.content_info_layout.setContentsMargins(8, 4, 8, 0) + else: + self.content_info_layout.setContentsMargins(8, 4, 8, 8) + else: + if self.media_controls.isVisible(): + self.main_layout.setContentsMargins(8, 8, 8, 0) + else: + self.main_layout.setContentsMargins(8, 8, 8, 8) + + @staticmethod + def clear_layout(layout): + while layout.count(): + item = layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + elif item.layout(): + ChannelList.clear_layout(item.layout()) + layout.deleteLater() + + def switch_content_info_panel(self, item_type): + if item_type in ["channel", "content"]: + if self.content_info_shown == "channel": + return + self.setup_channel_program_content_info() + else: + if self.content_info_shown == "movie_tvshow": + return + self.setup_movie_tvshow_content_info() + + if not self.content_info_panel.isVisible(): + self.content_info_panel.setVisible(True) + self.splitter.setSizes([int(self.container_widget.height() * self.splitter_ratio), int(self.container_widget.height() * (1 - self.splitter_ratio))]) + + def populate_channel_programs_content_info(self, item_data): + # Show EPG data for the selected channel + self.program_list.clear() + epg_data = self.epg_manager.get_programs_for_channel(item_data) + if epg_data: + # Fill the program list + for epg_item in epg_data: + if self.config_manager.epg_source == "STB": + epg_text = f"{epg_item.get('t_time', 'start')}-{epg_item.get('t_time_to' ,'end')}  {epg_item['name']}" + else: + epg_text = f"{datetime.strptime(epg_item.get('@start'), '%Y%m%d%H%M%S %z').strftime('%H:%M')}-{datetime.strptime(epg_item.get('@stop'), '%Y%m%d%H%M%S %z').strftime('%H:%M')}  {epg_item['title'].get('__text')}" + item = QListWidgetItem(f"{epg_text}") + item.setData(Qt.UserRole, epg_item) + self.program_list.addItem(item) + self.program_list.setCurrentRow(0) + else: + item = QListWidgetItem("Program not available") + self.program_list.addItem(item) + xmltv_id = item_data.get('xmltv_id', '') + if xmltv_id: + self.content_info_text.setText(f"No EPG found for channel id \"{xmltv_id}\"") + else: + self.content_info_text.setText(f"Channel without id") + + def update_channel_program(self): + selected_items = self.program_list.selectedItems() + if not selected_items: + self.content_info_text.setText("No program selected") + return + selected_item = selected_items[0] + item_data = selected_item.data(Qt.UserRole) + if item_data: + if self.config_manager.epg_source == "STB": + # Extract information from item_data + title = item_data.get("name", {}) + desc = item_data.get("descr") + desc = desc.replace("\r\n", "
") if desc else "" + director = item_data.get("director") + actor = item_data.get("actor") + category = item_data.get("category") + + # Format the content information + info = "" + if title: + info += f"Title: {title}
" + if category: + info += f"Category: {category}
" + if desc: + info += f"Description: {desc}
" + if director: + info += f"Director: {director}
" + if actor: + info += f"Actor: {actor}
" + + self.content_info_text.setText(info if info else "No data available") + + else: + # Extract information from item_data + title = item_data.get("title", {}) + sub_title = item_data.get("sub-title") + desc = item_data.get("desc") + credits = item_data.get("credits", {}) + director = credits.get("director") + actor = credits.get("actor") + writer = credits.get("writer") + presenter = credits.get("presenter") + adapter = credits.get("adapter") + producer = credits.get("producer") + composer = credits.get("composer") + editor = credits.get("editor") + guest = credits.get("guest") + category = item_data.get("category") + country = item_data.get("country") + episode_num = item_data.get("episode-num") + rating = item_data.get("rating", {}).get("value") + + # Format the content information + info = "" + if title: + info += f"Title: {title.get('__text')}
" + if sub_title: + info += f"Sub-title: {sub_title.get('__text')}
" + if episode_num: + info += f"Episode Number: {episode_num.get('__text')}
" + if category: + if isinstance(category, dict): + info += f"Category: {category.get('__text')}
" + elif isinstance(category, list): + info += f"Category: {', '.join([c.get('__text') for c in category])}
" + if rating: + info += f"Rating: {rating.get('__text')}
" + if desc: + info += f"Description: {desc.get('__text')}
" + if credits: + if director: + if isinstance(director, dict): + info += f"Director: {director.get('__text')}
" + elif isinstance(director, list): + info += f"Director: {', '.join([c.get('__text') for c in director])}
" + if actor: + if isinstance(actor, dict): + info += f"Actor: {actor.get('__text')}
" + elif isinstance(actor, list): + info += f"Actor: {', '.join([c.get('__text') for c in actor])}
" + if guest: + if isinstance(guest, dict): + info += f"Guest: {guest.get('__text')}
" + elif isinstance(guest, list): + info += f"Guest: {', '.join([c.get('__text') for c in guest])}
" + if writer: + if isinstance(writer, dict): + info += f"Writer: {writer.get('__text')}
" + elif isinstance(writer, list): + info += f"Writer: {', '.join([c.get('__text') for c in writer])}
" + if presenter: + if isinstance(presenter, dict): + info += f"Presenter: {presenter.get('__text')}
" + elif isinstance(presenter, list): + info += f"Presenter: {', '.join([c.get('__text') for c in presenter])}
" + if adapter: + if isinstance(adapter, dict): + info += f"Adapter: {adapter.get('__text')}
" + elif isinstance(adapter, list): + info += f"Adapter: {', '.join([c.get('__text') for c in adapter])}
" + if producer: + if isinstance(producer, dict): + info += f"Producer: {producer.get('__text')}
" + elif isinstance(producer, list): + info += f"Producer: {', '.join([c.get('__text') for c in producer])}
" + if composer: + if isinstance(composer, dict): + info += f"Composer: {composer.get('__text')}
" + elif isinstance(composer, list): + info += f"Composer: {', '.join([c.get('__text') for c in composer])}
" + if editor: + if isinstance(editor, dict): + info += f"Editor: {editor.get('__text')}
" + elif isinstance(editor, list): + info += f"Editor: {', '.join([c.get('__text') for c in editor])}
" + if country: + info += f"Country: {episode_num.get('__text')}
" + + self.content_info_text.setText(info if info else "No data available") + + # Load poster image if available + icon_url = item_data.get("icon", {}).get("@src") + if icon_url: + self.lock_ui_before_loading() + if hasattr(self, "image_loader") and self.image_loader.isRunning(): + self.image_loader.wait() + self.image_loader = ImageLoader([icon_url,], self.image_manager, iconified=False) + self.image_loader.progress_updated.connect(self.update_poster) + self.image_loader.finished.connect(self.image_loader_finished) + self.image_loader.start() + self.cancel_button.setText("Cancel fetching poster...") + else: + self.content_info_text.setText("No data available") + + def populate_movie_tvshow_content_info(self, item_data): + content_info_label = { + "name": "Title", + "rating_imdb": "Rating", + "age": "Age", + "country": "Country", + "year": "Year", + "genres_str": "Genre", + "length": "Length", + "director": "Director", + "actors": "Actors", + "description": "Summary" + } + + info = "" + for key, label in content_info_label.items(): + if key in item_data: + value = item_data[key] + # if string, check is not empty and not "na" or "n/a" + if value: + if isinstance(value, str) and value.lower() in ["na", "n/a"]: + continue + info += f"{label}: {value}
" + self.content_info_text.setText(info) + + # Load poster image if available + poster_url = item_data.get("screenshot_uri", "") + if poster_url: + self.lock_ui_before_loading() + if hasattr(self, "image_loader") and self.image_loader.isRunning(): + self.image_loader.wait() + self.image_loader = ImageLoader([poster_url,], self.image_manager, iconified=False) + self.image_loader.progress_updated.connect(self.update_poster) + self.image_loader.finished.connect(self.image_loader_finished) + self.image_loader.start() + self.cancel_button.setText("Cancel fetching poster...") + + def refresh_content_list_size(self): + font_size = 12 + icon_size = font_size + 4 + self.content_list.setIconSize(QSize(icon_size, icon_size)) + self.content_list.setStyleSheet(f""" + QTreeWidget {{ font-size: {font_size}px; }} + """) + + # Set main font (specific Qt font compatible with tiny size) + font = QFont("Tahoma") + + font.setPointSize(font_size) + self.content_list.setFont(font) + + # Set header font + header_font = QFont() + header_font.setPointSize(font_size) + header_font.setBold(True) + self.content_list.header().setFont(header_font) + + def show_favorite_layout(self, show): + for i in range(self.favorite_layout.count()): + item = self.favorite_layout.itemAt(i) + if item.widget(): + item.widget().setVisible(show) def toggle_favorite(self): selected_item = self.content_list.currentItem() @@ -361,19 +877,44 @@ def toggle_favorite(self): self.filter_content(self.search_box.text()) def add_to_favorites(self, item_name): - if item_name not in self.config["favorites"]: - self.config["favorites"].append(item_name) + if item_name not in self.config_manager.favorites: + self.config_manager.favorites.append(item_name) self.save_config() def remove_from_favorites(self, item_name): - if item_name in self.config["favorites"]: - self.config["favorites"].remove(item_name) + if item_name in self.config_manager.favorites: + self.config_manager.favorites.remove(item_name) self.save_config() def check_if_favorite(self, item_name): - return item_name in self.config["favorites"] + return item_name in self.config_manager.favorites + + def rescan_logos(self): + # Loop on content_list items to get logos and delete them from image_manager + logo_urls = [] + for i in range(self.content_list.topLevelItemCount()): + item = self.content_list.topLevelItem(i) + url_logo = item.data(0, Qt.UserRole)["data"].get("logo", "") + logo_urls.append(url_logo) + if url_logo: + self.image_manager.remove_icon_from_cache(url_logo) + + self.lock_ui_before_loading() + if hasattr(self, "image_loader") and self.image_loader.isRunning(): + self.image_loader.wait() + self.image_loader = ImageLoader(logo_urls, self.image_manager, iconified=True) + self.image_loader.progress_updated.connect(self.update_channel_logos) + self.image_loader.finished.connect(self.image_loader_finished) + self.image_loader.start() + self.cancel_button.setText("Cancel fetching channel logos...") def toggle_content_type(self): + # Checking only when receiving event of something checked + # Ignore when receiving event of something unchecked + rb = self.sender() + if not rb.isChecked(): + return + if self.channels_radio.isChecked(): self.content_type = "itv" elif self.movies_radio.isChecked(): @@ -392,8 +933,16 @@ def toggle_content_type(self): if not self.search_box.isModified(): self.filter_content(self.search_box.text()) - def display_categories(self, categories): + def display_categories(self, categories, select_first=True): + # Unregister the content_list selection change event + self.content_list.itemSelectionChanged.disconnect(self.item_selected) self.content_list.clear() + # Re-egister the content_list selection change event + self.content_list.itemSelectionChanged.connect(self.item_selected) + + # Stop refreshing content list + self.refresh_on_air_timer.stop() + self.content_list.setSortingEnabled(False) self.content_list.setColumnCount(1) if self.content_type == "itv": @@ -403,7 +952,10 @@ def display_categories(self, categories): elif self.content_type == "series": self.content_list.setHeaderLabels(["Serie Categories"]) - self.favorite_button.setHidden(False) + self.show_favorite_layout(True) + self.rescanlogo_button.setVisible(False) + self.epg_checkbox.setVisible(False) + self.vodinfo_checkbox.setVisible(False) for category in categories: item = CategoryTreeWidgetItem(self.content_list) @@ -417,9 +969,34 @@ def display_categories(self, categories): self.content_list.setSortingEnabled(True) self.back_button.setVisible(False) - def display_content(self, items, content_type="content"): + self.clear_content_info_panel() + + # Select an item in the list (first or a previously selected) + if select_first: + if select_first == True: + if self.content_list.topLevelItemCount() > 0: + self.content_list.setCurrentItem(self.content_list.topLevelItem(0)) + else: + previous_selected_id = select_first + previous_selected = self.content_list.findItems(previous_selected_id, Qt.MatchExactly, 0) + if previous_selected: + self.content_list.setCurrentItem(previous_selected[0]) + self.content_list.scrollToItem(previous_selected[0], QTreeWidget.PositionAtTop) + + def display_content(self, items, content_type="content", select_first=True): + # Unregister the content_list selection change event + self.content_list.itemSelectionChanged.disconnect(self.item_selected) self.content_list.clear() self.content_list.setSortingEnabled(False) + # Re-egister the content_list selection change event + self.content_list.itemSelectionChanged.connect(self.item_selected) + + # Stop refreshing On Air content + self.refresh_on_air_timer.stop() + + need_logos = content_type in ["channel", "content"] and self.config_manager.channel_logos + logo_urls = [] + use_epg = self.can_show_epg(content_type) and self.config_manager.channel_epg # Define headers for different content types category_header = ( @@ -435,25 +1012,28 @@ def display_content(self, items, content_type="content"): "serie": { "headers": [ self.shorten_header(f"{category_header} > Series"), + "Genre", "Added", ], - "keys": ["name", "added"], + "keys": ["name", "genres_str", "added"], }, "movie": { "headers": [ self.shorten_header(f"{category_header} > Movies"), + "Genre", "Added", ], - "keys": ["name", "added"], + "keys": ["name", "genres_str", "added"], }, "season": { "headers": [ + "#", self.shorten_header( f"{category_header} > {serie_header} > Seasons" ), "Added", ], - "keys": ["name", "added"], + "keys": ["number", "o_name", "added"], }, "episode": { "headers": [ @@ -462,31 +1042,47 @@ def display_content(self, items, content_type="content"): f"{category_header} > {serie_header} > {season_header} > Episodes" ), ], - "keys": ["number", "name"], + "keys": ["number", "ename"], }, "channel": { - "headers": ["#", self.shorten_header(f"{category_header} > Channels")], + "headers": ["#", self.shorten_header(f"{category_header} > Channels")] + (["", "On Air"] if use_epg else []), "keys": ["number", "name"], }, - "content": {"headers": ["Name"]}, + "content": { + "headers": ["Group", "Name"] + (["", "On Air"] if use_epg else []), + "keys": ["group", "name"] + }, } self.content_list.setColumnCount(len(header_info[content_type]["headers"])) self.content_list.setHeaderLabels(header_info[content_type]["headers"]) - # no need to check favorites or allow to add favorites on seasons or episodes folders + # no favorites on seasons or episodes genre_sfolders check_fav = content_type in ["channel", "movie", "serie", "content"] - self.favorite_button.setHidden(not check_fav) + self.show_favorite_layout(check_fav) for item_data in items: - list_item = NumberedTreeWidgetItem(self.content_list) - item_name = item_data.get("name") or item_data.get("title") - if content_type == "content": - list_item.setText(0, item_name) + if content_type == "channel": + list_item = ChannelTreeWidgetItem(self.content_list) + elif content_type in ["season", "episode"]: + list_item = NumberedTreeWidgetItem(self.content_list) else: - for i, key in enumerate(header_info[content_type]["keys"]): + list_item = QTreeWidgetItem(self.content_list) + + for i, key in enumerate(header_info[content_type]["keys"]): + if key == "added": + # Change a date time from "YYYY-MM-DD HH:MM:SS" to "YYYY-MM-DD" only + list_item.setText(i, item_data.get(key, "N/A").split()[0]) + else: list_item.setText(i, item_data.get(key, "N/A")) + list_item.setData(0, Qt.UserRole, {"type": content_type, "data": item_data}) + + # If content type is channel, collect the logo urls from the image_manager + if need_logos: + logo_urls.append(item_data.get("logo", "")) + # Highlight favorite items + item_name = item_data.get("name") or item_data.get("title") if check_fav and self.check_if_favorite(item_name): list_item.setBackground(0, QColor(0, 0, 255, 20)) @@ -496,6 +1092,61 @@ def display_content(self, items, content_type="content"): self.content_list.sortItems(0, Qt.AscendingOrder) self.content_list.setSortingEnabled(True) self.back_button.setVisible(content_type != "content") + self.epg_checkbox.setVisible(self.can_show_epg(content_type)) + self.vodinfo_checkbox.setVisible(self.can_show_content_info(content_type)) + + if use_epg: + self.content_list.setItemDelegate(ChannelItemDelegate()) + # Start refreshing content list (On Air progress) + self.refresh_on_air() + self.refresh_on_air_timer.start(30000) + + # Select an item in the list (first or a previously selected) + if select_first: + if select_first == True: + if self.content_list.topLevelItemCount() > 0: + self.content_list.setCurrentItem(self.content_list.topLevelItem(0)) + else: + previous_selected_id = select_first + previous_selected = self.content_list.findItems(previous_selected_id, Qt.MatchExactly, 0) + if previous_selected: + self.content_list.setCurrentItem(previous_selected[0]) + self.content_list.scrollToItem(previous_selected[0], QTreeWidget.PositionAtTop) + + # Load channel logos if needed + self.rescanlogo_button.setVisible(need_logos) + if need_logos: + self.lock_ui_before_loading() + if hasattr(self, "image_loader") and self.image_loader.isRunning(): + self.image_loader.wait() + self.image_loader = ImageLoader(logo_urls, self.image_manager, iconified=True) + self.image_loader.progress_updated.connect(self.update_channel_logos) + self.image_loader.finished.connect(self.image_loader_finished) + self.image_loader.start() + self.cancel_button.setText("Cancel fetching channel logos...") + + def update_channel_logos(self, current, total, data): + self.update_progress(current, total) + if data: + qicon = data.get("icon", None) + if qicon: + rank = data["rank"] + item = self.content_list.topLevelItem(rank) + item.setIcon(1, qicon) + + def update_poster(self, current, total, data): + self.update_progress(current, total) + if data: + pixmap = data.get("pixmap", None) + if pixmap: + scaled_pixmap = pixmap.scaled(200, 300, Qt.KeepAspectRatio, Qt.SmoothTransformation) + buffer = QBuffer() + buffer.open(QBuffer.ReadWrite) + scaled_pixmap.save(buffer, "PNG") + buffer.close() + base64_data = base64.b64encode(buffer.data()).decode('utf-8') + img_tag = f'Poster Image' + self.content_info_text.setText(img_tag + self.content_info_text.text()) def filter_content(self, text=""): show_favorites = self.favorites_only_checkbox.isChecked() @@ -524,20 +1175,37 @@ def filter_content(self, text=""): def create_media_controls(self): self.media_controls = QWidget(self.container_widget) control_layout = QHBoxLayout(self.media_controls) + control_layout.setContentsMargins(8, 0, 8, 8) self.play_button = QPushButton("Play/Pause") - self.play_button.clicked.connect(self.player.toggle_play_pause) + self.play_button.clicked.connect(self.toggle_play_pause) control_layout.addWidget(self.play_button) self.stop_button = QPushButton("Stop") - self.stop_button.clicked.connect(self.player.stop_video) + self.stop_button.clicked.connect(self.stop_video) control_layout.addWidget(self.stop_button) self.vlc_button = QPushButton("Open in VLC") self.vlc_button.clicked.connect(self.open_in_vlc) control_layout.addWidget(self.vlc_button) - self.grid_layout.addWidget(self.media_controls, 2, 0) + self.media_controls.setVisible(False) # Initially hidden + + def show_media_controls(self): + self.media_controls.setVisible(True) + self.update_layout() + + def hide_media_controls(self): + self.media_controls.setVisible(False) + self.update_layout() + + def toggle_play_pause(self): + self.player.toggle_play_pause() + self.show_media_controls() + + def stop_video(self): + self.player.stop_video() + self.hide_media_controls() def open_in_vlc(self): # Invoke user's VLC player to open the current stream @@ -583,8 +1251,8 @@ def open_file(self): self.player.play_video(file_path) def export_all_live_channels(self): - selected_provider = self.config["data"][self.config["selected"]] - if selected_provider.get("type") != "STB": + provider = self.provider_manager.current_provider + if provider.get("type") != "STB": QMessageBox.warning( self, "Export Error", @@ -602,20 +1270,20 @@ def export_all_live_channels(self): self.fetch_and_export_all_live_channels(file_path) def fetch_and_export_all_live_channels(self, file_path): - selected_provider = self.config["data"][self.config["selected"]] - options = selected_provider.get("options", {}) + selected_provider = self.provider_manager.current_provider url = selected_provider.get("url", "") url = URLObject(url) base_url = f"{url.scheme}://{url.netloc}" mac = selected_provider.get("mac", "") try: - fetchurl = f"{base_url}/server/load.php?type=itv&action=get_all_channels&JsHttpRequest=1-xml" - response = requests.get(fetchurl, headers=options["headers"]) - result = response.json() - channels = result["js"]["data"] + # Get all channels and categories (in provider cache) + provider_itv_content = self.provider_manager.current_provider_content.setdefault("itv", {}) + categories_list = provider_itv_content.setdefault("categories", []) + categories = {c.get("id", "None"): c.get("title", "Unknown Category") for c in categories_list} + channels = provider_itv_content["contents"] - self.save_channel_list(base_url, channels, mac, file_path) + self.save_channel_list(base_url, channels, categories, mac, file_path) QMessageBox.information( self, "Export Successful", @@ -628,7 +1296,7 @@ def fetch_and_export_all_live_channels(self, file_path): f"An error occurred while exporting channels: {str(e)}", ) - def save_channel_list(self, base_url, channels_data, mac, file_path) -> None: + def save_channel_list(self, base_url, channels_data, categories, mac, file_path) -> None: try: with open(file_path, "w", encoding="utf-8") as file: file.write("#EXTM3U\n") @@ -636,6 +1304,9 @@ def save_channel_list(self, base_url, channels_data, mac, file_path) -> None: for channel in channels_data: name = channel.get("name", "Unknown Channel") logo = channel.get("logo", "") + category = channel.get("tv_genre_id", "None") + xmltv_id = channel.get("xmltv_id", "") + group = categories.get(category, "Unknown Group") cmd_url = channel.get("cmd", "").replace("ffmpeg ", "") if "localhost" in cmd_url: ch_id_match = re.search(r"/ch/(\d+)_", cmd_url) @@ -643,7 +1314,7 @@ def save_channel_list(self, base_url, channels_data, mac, file_path) -> None: ch_id = ch_id_match.group(1) cmd_url = f"{base_url}/play/live.php?mac={mac}&stream={ch_id}&extension=m3u8" - channel_str = f'#EXTINF:-1 tvg-logo="{logo}" ,{name}\n{cmd_url}\n' + channel_str = f'#EXTINF:-1 tvg-id="{xmltv_id}" tvg-logo="{logo}" group-title="{group}" ,{name}\n{cmd_url}\n' count += 1 file.write(channel_str) print(f"Channels = {count}") @@ -659,8 +1330,10 @@ def export_content(self): self, "Export Content", "", "M3U files (*.m3u)" ) if file_path: - provider = self.config["data"][self.config["selected"]] - content_data = provider.get(self.content_type, {}) + provider = self.provider_manager.current_provider + # Get the content data from the provider manager on content type + provider_content = self.provider_manager.current_provider_content.setdefault(self.content_type, {}) + base_url = provider.get("url", "") config_type = provider.get("type", "") mac = provider.get("mac", "") @@ -668,11 +1341,11 @@ def export_content(self): if config_type == "STB": # Extract all content items from categories all_items = [] - for items in content_data.get("contents", {}).values(): + for items in provider_content.get("contents", {}).values(): all_items.extend(items) self.save_stb_content(base_url, all_items, mac, file_path) elif config_type in ["M3UPLAYLIST", "M3USTREAM", "XTREAM"]: - content_items = provider.get(self.content_type, []) + content_items = provider_content if provider_content else [] self.save_m3u_content(content_items, file_path) else: print(f"Unknown provider type: {config_type}") @@ -686,10 +1359,12 @@ def save_m3u_content(content_data, file_path): for item in content_data: name = item.get("name", "Unknown") logo = item.get("logo", "") + group = item.get("group", "") + xmltv_id = item.get("xmltv_id", "") cmd_url = item.get("cmd") if cmd_url: - item_str = f'#EXTINF:-1 tvg-logo="{logo}" ,{name}\n{cmd_url}\n' + item_str = f'#EXTINF:-1 tvg-id="{xmltv_id}" tvg-logo="{logo}" group-title="{group}" ,{name}\n{cmd_url}\n' count += 1 file.write(item_str) print(f"Items exported: {count}") @@ -706,6 +1381,7 @@ def save_stb_content(base_url, content_data, mac, file_path): for item in content_data: name = item.get("name", "Unknown") logo = item.get("logo", "") + xmltv_id = item.get("xmltv_id", "") cmd_url = item.get("cmd", "").replace("ffmpeg ", "") # Generalized URL construction @@ -719,7 +1395,7 @@ def save_stb_content(base_url, content_data, mac, file_path): elif content_type == "vod": cmd_url = f"{base_url}/play/vod.php?mac={mac}&stream={content_id}&extension=m3u8" - item_str = f'#EXTINF:-1 tvg-logo="{logo}" ,{name}\n{cmd_url}\n' + item_str = f'#EXTINF:-1 tvg-id="{xmltv_id}" tvg-logo="{logo}" ,{name}\n{cmd_url}\n' count += 1 file.write(item_str) print(f"Items exported: {count}") @@ -730,10 +1406,13 @@ def save_stb_content(base_url, content_data, mac, file_path): def save_config(self): self.config_manager.save_config() + def save_provider(self): + self.provider_manager.save_provider() + def load_content(self): - selected_provider = self.config["data"][self.config["selected"]] + selected_provider = self.provider_manager.current_provider config_type = selected_provider.get("type", "") - content = selected_provider.get(self.content_type, {}) + content = self.provider_manager.current_provider_content.setdefault(self.content_type, {}) if content: # If we have categories cached, display them if config_type == "STB": @@ -745,7 +1424,7 @@ def load_content(self): self.update_content() def update_content(self): - selected_provider = self.config["data"][self.config["selected"]] + selected_provider = self.provider_manager.current_provider config_type = selected_provider.get("type", "") if config_type == "M3UPLAYLIST": self.load_m3u_playlist(selected_provider["url"]) @@ -766,9 +1445,7 @@ def update_content(self): ) self.load_m3u_playlist(url) elif config_type == "STB": - self.do_handshake( - selected_provider["url"], selected_provider["mac"], load=True - ) + self.load_stb_categories(selected_provider["url"], self.provider_manager.headers) elif config_type == "M3USTREAM": self.load_stream(selected_provider["url"]) @@ -784,10 +1461,10 @@ def load_m3u_playlist(self, url): parsed_content = self.parse_m3u(content) self.display_content(parsed_content) # Update the content in the config - self.config["data"][self.config["selected"]][ + self.provider_manager.current_provider_content[ self.content_type ] = parsed_content - self.save_config() + self.save_provider() except (requests.RequestException, IOError) as e: print(f"Error loading M3U Playlist: {e}") @@ -795,35 +1472,55 @@ def load_stream(self, url): item = {"id": 1, "name": "Stream", "cmd": url} self.display_content([item]) # Update the content in the config - self.config["data"][self.config["selected"]][self.content_type] = [item] - self.save_config() + self.provider_manager.current_provider_content[self.content_type] = [item] + self.save_provider() + + def item_selected(self): + selected_items = self.content_list.selectedItems() + if selected_items: + item = selected_items[0] + data = item.data(0, Qt.UserRole) + if data and "type" in data: + item_data = data["data"] + item_type = item.data(0, Qt.UserRole)["type"] + + if self.can_show_content_info(item_type) and self.config_manager.show_stb_content_info: + self.switch_content_info_panel(item_type) + self.populate_movie_tvshow_content_info(item_data) + elif self.can_show_epg(item_type) and self.config_manager.channel_epg: + self.switch_content_info_panel(item_type) + self.populate_channel_programs_content_info(item_data) + else: + self.clear_content_info_panel() + self.update_layout() - def item_selected(self, item): + def item_activated(self, item): data = item.data(0, Qt.UserRole) if data and "type" in data: + item_data = data["data"] + item_type = item.data(0, Qt.UserRole)["type"] + nav_len = len(self.navigation_stack) - if data["type"] == "category": - self.navigation_stack.append(("root", self.current_category)) - self.current_category = data["data"] - self.load_content_in_category(data["data"]) - elif data["type"] == "serie": + if item_type == "category": + self.navigation_stack.append(("root", self.current_category, item.text(0))) + self.current_category = item_data + self.load_content_in_category(item_data) + elif item_type == "serie": if self.content_type == "series": # For series, load seasons - self.navigation_stack.append(("category", self.current_category)) - self.current_series = data["data"] - self.load_series_seasons(data["data"]) - else: - self.play_item(data["data"]) - elif data["type"] == "season": + self.navigation_stack.append(("category", self.current_category, item.text(0))) + self.current_series = item_data + self.load_series_seasons(item_data) + elif item_type == "season": # Load episodes for the selected season - self.navigation_stack.append(("series", self.current_series)) - self.current_season = data["data"] - self.load_season_episodes(data["data"]) - elif data["type"] in ["content", "channel", "movie"]: - self.play_item(data["data"]) - elif data["type"] == "episode": + self.navigation_stack.append(("series", self.current_series, item.text(0))) + self.current_season = item_data + self.load_season_episodes(item_data) + elif item_type in ["content", "channel", "movie"]: + self.play_item(item_data) + elif item_type == "episode": # Play the selected episode - self.play_item(data["data"], is_episode=True) + self.play_item(item_data, is_episode=True) else: print("Unknown item type selected.") @@ -837,24 +1534,24 @@ def item_selected(self, item): def go_back(self): if self.navigation_stack: - nav_type, previous_data = self.navigation_stack.pop() + nav_type, previous_data, previous_selected_id = self.navigation_stack.pop() if nav_type == "root": # Display root categories - content = self.config["data"][self.config["selected"]].get( + content = self.provider_manager.current_provider_content.setdefault( self.content_type, {} ) categories = content.get("categories", []) - self.display_categories(categories) + self.display_categories(categories, select_first=previous_selected_id) self.current_category = None elif nav_type == "category": # Go back to category content self.current_category = previous_data - self.load_content_in_category(self.current_category) + self.load_content_in_category(self.current_category, select_first=previous_selected_id) self.current_series = None elif nav_type == "series": # Go back to series seasons self.current_series = previous_data - self.load_series_seasons(self.current_series) + self.load_series_seasons(self.current_series, select_first=previous_selected_id) self.current_season = None # Clear search box after navigating backward and force re-filtering if needed @@ -880,70 +1577,85 @@ def parse_m3u(data): tvg_id_match = re.search(r'tvg-id="([^"]+)"', line) tvg_logo_match = re.search(r'tvg-logo="([^"]+)"', line) group_title_match = re.search(r'group-title="([^"]+)"', line) - item_name_match = re.search(r",(.+)", line) + user_agent_match = re.search(r'user-agent="([^"]+)"', line) + item_name_match = re.search(r',([^,]+)$', line) tvg_id = tvg_id_match.group(1) if tvg_id_match else None tvg_logo = tvg_logo_match.group(1) if tvg_logo_match else None group_title = group_title_match.group(1) if group_title_match else None + user_agent = user_agent_match.group(1) if user_agent_match else None item_name = item_name_match.group(1) if item_name_match else None id_counter += 1 item = { "id": id_counter, + "group": group_title, + "xmltv_id": tvg_id, "name": item_name, "logo": tvg_logo, + "user_agent": user_agent, } + elif line.startswith("#EXTVLCOPT:http-user-agent="): + user_agent = line.split("=", 1)[1] + item["user_agent"] = user_agent + elif line.startswith("http"): urlobject = urlparse(line) item["cmd"] = urlobject.geturl() result.append(item) return result - def do_handshake(self, url, mac, serverload="/server/load.php", load=True): - token = ( - self.config.get("token") - if self.config.get("token") - else self.random_token() - ) - options = self.create_options(url, mac, token) - try: - fetchurl = f"{url}{serverload}?type=stb&action=handshake&prehash=0&token={token}&JsHttpRequest=1-xml" - handshake = requests.get(fetchurl, headers=options["headers"]) - body = handshake.json() - token = body["js"]["token"] - options["headers"]["Authorization"] = f"Bearer {token}" - self.config["data"][self.config["selected"]]["options"] = options - self.save_config() - if load: - self.load_stb_categories(url, options) - return True - except Exception as e: - if serverload != "/portal.php": - serverload = "/portal.php" - return self.do_handshake(url, mac, serverload) - print("Error in handshake:", e) - return False - - def load_stb_categories(self, url, options): + def load_stb_categories(self, url, headers): url = URLObject(url) url = f"{url.scheme}://{url.netloc}" try: fetchurl = ( f"{url}/server/load.php?{self.get_categories_params(self.content_type)}" ) - response = requests.get(fetchurl, headers=options["headers"]) + response = requests.get(fetchurl, headers=headers) result = response.json() categories = result["js"] if not categories: print("No categories found.") return # Save categories in config - self.config["data"][self.config["selected"]][self.content_type] = { - "categories": categories, - "contents": {}, - } - self.save_config() + provider_content = self.provider_manager.current_provider_content.setdefault(self.content_type, {}) + provider_content["categories"] = categories + provider_content["contents"] = {} + + # Sorting all channels now by category + if self.content_type == "itv": + fetchurl = ( + f"{url}/server/load.php?{self.get_allchannels_params()}" + ) + response = requests.get(fetchurl, headers=headers) + result = response.json() + provider_content["contents"] = result["js"]["data"] + + # Split channels by category, and sort them number-wise + sorted_channels = {} + + for i in range(len(provider_content["contents"])): + genre_id = provider_content["contents"][i]["tv_genre_id"] + category = str(genre_id) + if category not in sorted_channels: + sorted_channels[category] = [] + sorted_channels[category].append(i) + + for category in sorted_channels: + sorted_channels[category].sort(key=lambda x: int(provider_content["contents"][x]["number"])) + + # Add a specific category for null genre_id + if "None" in sorted_channels: + categories.append({ + "id": "None", + "title": "Unknown Category" + }) + + provider_content["sorted_channels"] = sorted_channels + + self.save_provider() self.display_categories(categories) except Exception as e: print(f"Error loading STB categories: {e}") @@ -957,53 +1669,74 @@ def get_categories_params(_type): } return "&".join(f"{k}={v}" for k, v in params.items()) - def load_content_in_category(self, category): - selected_provider = self.config["data"][self.config["selected"]] - content_data = selected_provider.get(self.content_type, {}) + @staticmethod + def get_allchannels_params(): + params = { + "type": "itv", + "action": "get_all_channels", + "JsHttpRequest": str(int(time.time() * 1000)) + "-xml", + } + return "&".join(f"{k}={v}" for k, v in params.items()) + + def load_content_in_category(self, category, select_first=True): + content_data = self.provider_manager.current_provider_content.setdefault(self.content_type, {}) category_id = category.get("id", "*") - # Check if we have cached content for this category - if category_id in content_data.get("contents", {}): - items = content_data["contents"][category_id] - if self.content_type == "itv": - self.display_content(items, content_type="channel") - elif self.content_type == "series": - self.display_content(items, content_type="serie") - elif self.content_type == "vod": - self.display_content(items, content_type="movie") + if self.content_type == "itv": + # Show only channels for the selected category + if category_id == "*": + items = content_data["contents"] + else: + items = [content_data["contents"][i] for i in content_data["sorted_channels"].get(category_id, [])] + self.display_content(items, content_type="channel") else: - # Fetch content for the category - self.fetch_content_in_category(category_id) + # Check if we have cached content for this category + if category_id in content_data.get("contents", {}): + items = content_data["contents"][category_id] + if self.content_type == "itv": + self.display_content(items, content_type="channel", select_first=select_first) + elif self.content_type == "series": + self.display_content(items, content_type="serie", select_first=select_first) + elif self.content_type == "vod": + self.display_content(items, content_type="movie", select_first=select_first) + else: + # Fetch content for the category + self.fetch_content_in_category(category_id, select_first=select_first) - def fetch_content_in_category(self, category_id): - selected_provider = self.config["data"][self.config["selected"]] - options = selected_provider.get("options", {}) + def fetch_content_in_category(self, category_id, select_first=True): + selected_provider = self.provider_manager.current_provider + headers = self.provider_manager.headers url = selected_provider.get("url", "") url = URLObject(url) url = f"{url.scheme}://{url.netloc}/server/load.php" + self.lock_ui_before_loading() + if hasattr(self, "content_loader") and self.content_loader.isRunning(): + self.content_loader.wait() self.content_loader = ContentLoader( - url, options["headers"], self.content_type, category_id=category_id + url, headers, self.content_type, category_id=category_id ) - self.content_loader.content_loaded.connect(self.update_content_list) + self.content_loader.content_loaded.connect(lambda data: self.update_content_list(data, select_first)) self.content_loader.progress_updated.connect(self.update_progress) self.content_loader.finished.connect(self.content_loader_finished) self.content_loader.start() - self.progress_bar.setVisible(True) - self.cancel_button.setVisible(True) + self.cancel_button.setText("Cancel loading content in category") - def load_series_seasons(self, series_item): - selected_provider = self.config["data"][self.config["selected"]] - options = selected_provider.get("options", {}) + def load_series_seasons(self, series_item, select_first=True): + selected_provider = self.provider_manager.current_provider + headers = self.provider_manager.headers url = selected_provider.get("url", "") url = URLObject(url) url = f"{url.scheme}://{url.netloc}/server/load.php" self.current_series = series_item # Store current series + self.lock_ui_before_loading() + if hasattr(self, "content_loader") and self.content_loader.isRunning(): + self.content_loader.wait() self.content_loader = ContentLoader( url=url, - headers=options["headers"], + headers=headers, content_type="series", category_id=series_item["category_id"], movie_id=series_item["id"], # series ID @@ -1011,25 +1744,28 @@ def load_series_seasons(self, series_item): action="get_ordered_list", sortby="name", ) - self.content_loader.content_loaded.connect(self.update_seasons_list) + + self.content_loader.content_loaded.connect(lambda data: self.update_seasons_list(data, select_first)) self.content_loader.progress_updated.connect(self.update_progress) self.content_loader.finished.connect(self.content_loader_finished) self.content_loader.start() - self.progress_bar.setVisible(True) - self.cancel_button.setVisible(True) + self.cancel_button.setText("Cancel loading seasons") - def load_season_episodes(self, season_item): - selected_provider = self.config["data"][self.config["selected"]] - options = selected_provider.get("options", {}) + def load_season_episodes(self, season_item, select_first=True): + selected_provider = self.provider_manager.current_provider + headers = self.provider_manager.headers url = selected_provider.get("url", "") url = URLObject(url) url = f"{url.scheme}://{url.netloc}/server/load.php" self.current_season = season_item # Store current season + self.lock_ui_before_loading() + if hasattr(self, "content_loader") and self.content_loader.isRunning(): + self.content_loader.wait() self.content_loader = ContentLoader( url=url, - headers=options["headers"], + headers=headers, content_type="series", category_id=self.current_category["id"], # Category ID movie_id=self.current_series["id"], # Series ID @@ -1037,54 +1773,14 @@ def load_season_episodes(self, season_item): action="get_ordered_list", sortby="added", ) - self.content_loader.content_loaded.connect(self.update_episodes_list) + self.content_loader.content_loaded.connect(lambda data: self.update_episodes_list(data, select_first)) self.content_loader.progress_updated.connect(self.update_progress) self.content_loader.finished.connect(self.content_loader_finished) self.content_loader.start() - self.progress_bar.setVisible(True) - self.cancel_button.setVisible(True) - - def display_episodes(self, season_item): - episodes = season_item.get("series", []) - episode_items = [] - for episode_num in episodes: - episode_item = { - "number": f"{episode_num}", - "name": f"Episode {episode_num}", - "cmd": season_item.get("cmd"), - "series": episode_num, - } - episode_items.append(episode_item) - self.display_content(episode_items, content_type="episode") - - @staticmethod - def get_channel_or_series_params( - typ, category, sortby, page_number, movie_id, series_id - ): - params = { - "type": typ, - "action": "get_ordered_list", - "genre": category, - "force_ch_link_check": "", - "fav": "0", - "sortby": sortby, # name, number, added - "hd": "0", - "p": str(page_number), - "JsHttpRequest": str(int(time.time() * 1000)) + "-xml", - } - if typ == "series": - params.update( - { - "movie_id": movie_id if movie_id else "0", - "category": category, - "season_id": series_id if series_id else "0", - "episode_id": "0", - } - ) - return "&".join(f"{k}={v}" for k, v in params.items()) + self.cancel_button.setText("Cancel loading episodes") def play_item(self, item_data, is_episode=False): - if self.config["data"][self.config["selected"]]["type"] == "STB": + if self.provider_manager.current_provider["type"] == "STB": url = self.create_link(item_data, is_episode=is_episode) if url: self.link = url @@ -1096,45 +1792,84 @@ def play_item(self, item_data, is_episode=False): self.link = cmd self.player.play_video(cmd) - def cancel_content_loading(self): + def cancel_loading(self): if hasattr(self, "content_loader") and self.content_loader.isRunning(): self.content_loader.terminate() - self.content_loader.wait() + if hasattr(self, "content_loader"): + self.content_loader.wait() self.content_loader_finished() QMessageBox.information( self, "Cancelled", "Content loading has been cancelled." ) + elif hasattr(self, "image_loader") and self.image_loader.isRunning(): + self.image_loader.terminate() + if hasattr(self, "image_loader"): + self.image_loader.wait() + self.image_loader_finished() + self.image_manager.save_index() + QMessageBox.information( + self, "Cancelled", "Image loading has been cancelled." + ) + + def lock_ui_before_loading(self): + self.update_ui_on_loading(loading=True) + + def unlock_ui_after_loading(self): + self.update_ui_on_loading(loading=False) + + def update_ui_on_loading(self, loading): + self.open_button.setEnabled(not loading) + self.options_button.setEnabled(not loading) + self.export_button.setEnabled(not loading) + self.export_all_live_button.setEnabled(not loading) + self.update_button.setEnabled(not loading) + self.back_button.setEnabled(not loading) + self.progress_bar.setVisible(loading) + self.cancel_button.setVisible(loading) + self.content_switch_group.setEnabled(not loading) + if loading: + self.content_list.setSelectionMode(QListWidget.NoSelection) + else: + self.content_list.setSelectionMode(QListWidget.SingleSelection) def content_loader_finished(self): - self.progress_bar.setVisible(False) - self.cancel_button.setVisible(False) if hasattr(self, "content_loader"): self.content_loader.deleteLater() del self.content_loader + self.unlock_ui_after_loading() + + def image_loader_finished(self): + if hasattr(self, "image_loader"): + self.image_loader.deleteLater() + del self.image_loader + self.unlock_ui_after_loading() - def update_content_list(self, data): + def update_content_list(self, data, select_first=True): category_id = data.get("category_id") items = data.get("items") # Cache the items in config - selected_provider = self.config["data"][self.config["selected"]] + selected_provider = self.provider_manager.current_provider_content content_data = selected_provider.setdefault(self.content_type, {}) contents = content_data.setdefault("contents", {}) contents[category_id] = items - self.save_config() + self.save_provider() if self.content_type == "series": - self.display_content(items, content_type="serie") + self.display_content(items, content_type="serie", select_first=select_first) elif self.content_type == "vod": - self.display_content(items, content_type="movie") + self.display_content(items, content_type="movie", select_first=select_first) elif self.content_type == "itv": - self.display_content(items, content_type="channel") + self.display_content(items, content_type="channel", select_first=select_first) - def update_seasons_list(self, data): + def update_seasons_list(self, data, select_first=True): items = data.get("items") - self.display_content(items, content_type="season") + for item in items: + item["number"] = item["name"].split(" ")[-1] + item["name"] = f'{self.current_series["name"]}.{item["name"]}' + self.display_content(items, content_type="season", select_first=select_first) - def update_episodes_list(self, data): + def update_episodes_list(self, data, select_first=True): items = data.get("items") selected_season = None for item in items: @@ -1146,32 +1881,36 @@ def update_episodes_list(self, data): episodes = selected_season.get("series", []) episode_items = [] for episode_num in episodes: - episode_item = { - "number": f"{episode_num}", - "name": f"Episode {episode_num}", - "cmd": selected_season.get("cmd"), - "series": episode_num, - } + # merge episode data with series data + episode_item = self.current_series.copy() + episode_item["number"] = f"{episode_num}" + episode_item["ename"] = f"Episode {episode_num}" + episode_item["cmd"] = selected_season.get("cmd") + episode_item["series"] = episode_num episode_items.append(episode_item) - self.display_content(episode_items, content_type="episode") + self.display_content(episode_items, content_type="episode", select_first=select_first) else: print("Season not found in data.") def update_progress(self, current, total): - progress_percentage = int((current / total) * 100) - self.progress_bar.setValue(progress_percentage) - if progress_percentage == 100: - self.progress_bar.setVisible(False) - else: - self.progress_bar.setVisible(True) + if total: + progress_percentage = int((current / total) * 100) + self.progress_bar.setValue(progress_percentage) + if progress_percentage == 100: + self.progress_bar.setVisible(False) + else: + self.progress_bar.setVisible(True) + + def update_busy_progress(self, msg): + self.cancel_button.setText(msg) def create_link(self, item, is_episode=False): try: - selected_provider = self.config["data"][self.config["selected"]] - url = selected_provider["url"] + selected_provider = self.provider_manager.current_provider + headers = self.provider_manager.headers + url = selected_provider.get("url", "") url = URLObject(url) url = f"{url.scheme}://{url.netloc}" - options = selected_provider["options"] cmd = item.get("cmd") if is_episode: # For episodes, we need to pass 'series' parameter @@ -1185,7 +1924,7 @@ def create_link(self, item, is_episode=False): f"{url}/server/load.php?type={self.content_type}&action=create_link" f"&cmd={requests.utils.quote(cmd)}&JsHttpRequest=1-xml" ) - response = requests.get(fetchurl, headers=options["headers"]) + response = requests.get(fetchurl, headers=headers) if response.status_code != 200 or not response.content: print( f"Error creating link: status code {response.status_code}, response content empty" @@ -1206,44 +1945,6 @@ def sanitize_url(url): url = url.strip() return url - @staticmethod - def random_token(): - return "".join(random.choices(string.ascii_letters + string.digits, k=32)) - - @staticmethod - def create_options(url, mac, token): - url = URLObject(url) - options = { - "headers": { - "User-Agent": "Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG200 stbapp ver: 2 rev: 250 Safari/533.3", - "Accept-Charset": "UTF-8,*;q=0.8", - "X-User-Agent": "Model: MAG200; Link: Ethernet", - "Host": f"{url.netloc}", - "Range": "bytes=0-", - "Accept": "*/*", - "Referer": f"{url}/c/" if not url.path else f"{url}/", - "Cookie": f"mac={mac}; stb_lang=en; timezone=Europe/Kiev; PHPSESSID=null;", - "Authorization": f"Bearer {token}", - } - } - return options - - def generate_headers(self): - selected_provider = self.config["data"][self.config["selected"]] - return selected_provider["options"]["headers"] - - @staticmethod - def verify_url(url): - if url.startswith(("http://", "https://")): - try: - response = requests.head(url, timeout=5) - return response.status_code == 200 - except requests.RequestException as e: - print(f"Error verifying URL: {e}") - return False - else: - return os.path.isfile(url) - @staticmethod def shorten_header(s): return s[:20] + "..." + s[-25:] if len(s) > 45 else s @@ -1255,4 +1956,4 @@ def get_item_type(item): @staticmethod def get_item_name(item, item_type): - return item.text(1 if item_type == "channel" else 0) + return item.text(1 if item_type in ["channel", "content"] else 0) diff --git a/config_manager.py b/config_manager.py index 9e9c462..3c62e66 100644 --- a/config_manager.py +++ b/config_manager.py @@ -1,19 +1,26 @@ import os import platform import shutil - +from multikeydict import MultiKeyDict import orjson as json class ConfigManager: CURRENT_VERSION = "1.5.8" # Set your current version here + DEFAULT_OPTION_CHECKUPDATE = True + DEFAULT_OPTION_STB_CONTENT_INFO = False + DEFAULT_OPTION_CHANNEL_EPG = False + DEFAULT_OPTION_CHANNEL_LOGO = False + DEFAULT_OPTION_MAX_CACHE_IMAGE_SIZE = 100 + DEFAULT_OPTION_EPG_SOURCE = "STB" # Default EPG source + DEFAULT_OPTION_EPG_URL = "" + DEFAULT_OPTION_EPG_FILE = "" + DEFAULT_OPTION_EPG_EXPIRATION_VALUE = 2 + DEFAULT_OPTION_EPG_EXPIRATION_UNIT = "Hours" + def __init__(self): self.config = {} - self.options = {} - self.token = "" - self.url = "" - self.mac = "" self.config_path = self._get_config_path() self._migrate_old_config() self.load_config() @@ -34,6 +41,9 @@ def _get_config_path(self): os.makedirs(config_dir, exist_ok=True) return os.path.join(config_dir, "config.json") + def get_config_dir(self): + return os.path.dirname(self.config_path) + def _migrate_old_config(self): try: old_config_path = "config.json" @@ -53,55 +63,227 @@ def load_config(self): self.config = self.default_config() self.save_config() + if isinstance(self.xmltv_channel_map, list): + self.xmltv_channel_map = MultiKeyDict.deserialize(self.xmltv_channel_map) + self.update_patcher() - selected_config = self.config["data"][self.config["selected"]] - if "options" in selected_config: - self.options = selected_config["options"] - self.token = self.options["headers"]["Authorization"].split(" ")[1] - else: - self.options = { - "headers": { - "User-Agent": "Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG200 stbapp ver: 2 rev: 250 Safari/533.3", - "Accept-Charset": "UTF-8,*;q=0.8", - "X-User-Agent": "Model: MAG200; Link: Ethernet", - "Content-Type": "application/json", - } - } + def update_patcher(self): - self.url = selected_config.get("url") - self.mac = selected_config.get("mac") + need_update = False - def update_patcher(self): # add favorites to the loaded config if it doesn't exist if "favorites" not in self.config: - self.config["favorites"] = [] + self.favorites = [] + need_update = True + + # add check_updates to the loaded config if it doesn't exist + if "check_updates" not in self.config: + self.check_updates = ConfigManager.DEFAULT_OPTION_CHECKUPDATE + need_update = True + + # add show_stb_content_info to the loaded config if it doesn't exist + if "show_stb_content_info" not in self.config: + self.show_stb_content_info = ConfigManager.DEFAULT_OPTION_STB_CONTENT_INFO + need_update = True + + # add channel logo to the loaded config if it doesn't exist + if "channel_logos" not in self.config: + self.channel_logos = ConfigManager.DEFAULT_OPTION_CHANNEL_LOGO + need_update = True + + # add max_cache_image_size to the loaded config if it doesn't exist + if "max_cache_image_size" not in self.config: + self.max_cache_image_size = ConfigManager.DEFAULT_OPTION_MAX_CACHE_IMAGE_SIZE + need_update = True + + # add epg_source to the loaded config if it doesn't exist + if "epg_source" not in self.config: + self.epg_source = ConfigManager.DEFAULT_OPTION_EPG_SOURCE + need_update = True + + # add epg_url to the loaded config if it doesn't exist + if "epg_url" not in self.config: + self.epg_url = ConfigManager.DEFAULT_OPTION_EPG_URL + need_update = True + + # add epg_file to the loaded config if it doesn't exist + if "epg_file" not in self.config: + self.epg_file = ConfigManager.DEFAULT_OPTION_EPG_FILE + need_update = True + + # add epg_expiration_value to the loaded config if it doesn't exist + if "epg_expiration_value" not in self.config: + self.epg_expiration_value = ConfigManager.DEFAULT_OPTION_EPG_EXPIRATION_VALUE + need_update = True + + # add epg_expiration_unit to the loaded config if it doesn't exist + if "epg_expiration_unit" not in self.config: + self.epg_expiration_unit = ConfigManager.DEFAULT_OPTION_EPG_EXPIRATION_UNIT + need_update = True + + # add xmltv_channel_map to the loaded config if it doesn't exist + if "xmltv_channel_map" not in self.config: + self.config["xmltv_channel_map"] = MultiKeyDict() + need_update = True + + if need_update: self.save_config() + @property + def check_updates(self): + return self.config.get("check_updates", ConfigManager.DEFAULT_OPTION_CHECKUPDATE) + + @check_updates.setter + def check_updates(self, value): + self.config["check_updates"] = value + + @property + def favorites(self): + return self.config.get("favorites", []) + + @favorites.setter + def favorites(self, value): + self.config["favorites"] = value + + @property + def show_stb_content_info(self): + return self.config.get("show_stb_content_info", ConfigManager.DEFAULT_OPTION_STB_CONTENT_INFO) + + @show_stb_content_info.setter + def show_stb_content_info(self, value): + self.config["show_stb_content_info"] = value + + @property + def selected_provider_name(self): + return self.config.get("selected_provider_name", "iptv-org.github.io") + + @selected_provider_name.setter + def selected_provider_name(self, value): + self.config["selected_provider_name"] = value + + @property + def channel_epg(self): + return self.config.get("channel_epg", ConfigManager.DEFAULT_OPTION_CHANNEL_EPG) + + @channel_epg.setter + def channel_epg(self, value): + self.config["channel_epg"] = value + + @property + def channel_logos(self): + return self.config.get("channel_logos", ConfigManager.DEFAULT_OPTION_CHANNEL_LOGO) + + @channel_logos.setter + def channel_logos(self, value): + self.config["channel_logos"] = value + + @property + def max_cache_image_size(self): + return self.config.get("max_cache_image_size", ConfigManager.DEFAULT_OPTION_MAX_CACHE_IMAGE_SIZE) + + @max_cache_image_size.setter + def max_cache_image_size(self, value): + self.config["max_cache_image_size"] = value + + @property + def epg_source(self): + return self.config.get("epg_source", ConfigManager.DEFAULT_OPTION_EPG_SOURCE) + + @epg_source.setter + def epg_source(self, value): + self.config["epg_source"] = value + + @property + def epg_url(self): + return self.config.get("epg_url", ConfigManager.DEFAULT_OPTION_EPG_URL) + + @epg_url.setter + def epg_url(self, value): + self.config["epg_url"] = value + + @property + def epg_file(self): + return self.config.get("epg_file", ConfigManager.DEFAULT_OPTION_EPG_FILE) + + @epg_file.setter + def epg_file(self, value): + self.config["epg_file"] = value + + @property + def epg_expiration_value(self): + return self.config.get("epg_expiration_value", ConfigManager.DEFAULT_OPTION_EPG_EXPIRATION_VALUE) + + @epg_expiration_value.setter + def epg_expiration_value(self, value): + self.config["epg_expiration_value"] = value + + @property + def epg_expiration_unit(self): + return self.config.get("epg_expiration_unit", ConfigManager.DEFAULT_OPTION_EPG_EXPIRATION_UNIT) + + @epg_expiration_unit.setter + def epg_expiration_unit(self, value): + self.config["epg_expiration_unit"] = value + + @property + def epg_expiration(self): + # Get expiration in seconds + if self.epg_expiration_unit == "Months": + return self.epg_expiration_value * 30 * 24 * 60 * 60 # Approximate month as 30 days + elif self.epg_expiration_unit == "Days": + return self.epg_expiration_value * 24 * 60 * 60 + elif self.epg_expiration_unit == "Hours": + return self.epg_expiration_value * 60 * 60 + elif self.epg_expiration_unit == "Minutes": + return self.epg_expiration_value * 60 + else: + raise ValueError(f"Unsupported expiration unit: {self.epg_expiration_unit}") + + @property + def xmltv_channel_map(self): + return self.config.get("xmltv_channel_map", MultiKeyDict()) + + @xmltv_channel_map.setter + def xmltv_channel_map(self, value): + self.config["xmltv_channel_map"] = value + @staticmethod def default_config(): return { - "selected": 0, + "selected_provider_name": "iptv-org.github.io", + "check_updates": ConfigManager.DEFAULT_OPTION_CHECKUPDATE, "data": [ { "type": "M3UPLAYLIST", + "name": "iptv-org.github.io", "url": "https://iptv-org.github.io/iptv/index.m3u", } ], "window_positions": { - "channel_list": {"x": 1250, "y": 100, "width": 400, "height": 800}, + "channel_list": {"x": 1250, "y": 100, "width": 400, "height": 800, "splitter_ratio": 0.75, "splitter_content_info_ratio": 0.33}, "video_player": {"x": 50, "y": 100, "width": 1200, "height": 800}, }, "favorites": [], + "show_stb_content_info": ConfigManager.DEFAULT_OPTION_STB_CONTENT_INFO, + "channel_logos": ConfigManager.DEFAULT_OPTION_CHANNEL_LOGO, + "channel_epg": ConfigManager.DEFAULT_OPTION_CHANNEL_EPG, + "xmltv_channel_map": [], + "max_cache_image_size": ConfigManager.DEFAULT_OPTION_MAX_CACHE_IMAGE_SIZE, } - def save_window_settings(self, pos, window_name): + def save_window_settings(self, window, window_name): + pos = window.geometry() self.config["window_positions"][window_name] = { "x": pos.x(), "y": pos.y(), "width": pos.width(), "height": pos.height(), } + if window_name == "channel_list": + self.config["window_positions"][window_name]["splitter_ratio"] = window.splitter_ratio + self.config["window_positions"][window_name]["splitter_content_info_ratio"] = window.splitter_content_info_ratio + self.save_config() def apply_window_settings(self, window_name, window): @@ -109,12 +291,15 @@ def apply_window_settings(self, window_name, window): window.setGeometry( settings["x"], settings["y"], settings["width"], settings["height"] ) - - # def save_config(self): - # with open(self.config_path, "wb") as f: - # f.write(json.dumps(self.config, option=json.OPT_INDENT_2)) + if window_name == "channel_list": + window.splitter_ratio = settings.get("splitter_ratio", 0.75) + window.splitter_content_info_ratio = settings.get("splitter_content_info_ratio", 0.33) def save_config(self): + self.xmltv_channel_map = self.xmltv_channel_map.serialize() + serialized_config = json.dumps(self.config, option=json.OPT_INDENT_2) with open(self.config_path, "w", encoding="utf-8") as f: f.write(serialized_config.decode("utf-8")) + + self.xmltv_channel_map = MultiKeyDict.deserialize(self.xmltv_channel_map) diff --git a/content_loader.py b/content_loader.py new file mode 100644 index 0000000..dd944d8 --- /dev/null +++ b/content_loader.py @@ -0,0 +1,181 @@ +import random +import aiohttp +import asyncio +import orjson as json +from PySide6.QtCore import QThread, Signal + +class ContentLoader(QThread): + content_loaded = Signal(dict) + progress_updated = Signal(int, int) + + def __init__( + self, + url, + headers, + content_type, + category_id=None, + parent_id=None, + movie_id=None, + season_id=None, + period=None, + ch_id=None, + size=0, + action="get_ordered_list", + sortby="name", + ): + super().__init__() + self.url = url + self.headers = headers + self.content_type = content_type + self.category_id = category_id + self.parent_id = parent_id + self.movie_id = movie_id + self.season_id = season_id + self.action = action + self.sortby = sortby + self.period= period + self.ch_id = ch_id + self.size = size + self.items = [] + + async def fetch_page(self, session, page, max_retries=2, timeout=5): + for attempt in range(max_retries): + try: + params = self.get_params(page) + async with session.get( + self.url, headers=self.headers, params=params, timeout=timeout + ) as response: + content = await response.read() + if response.status == 503 or not content: + wait_time = (2**attempt) + random.uniform(0, 1) + print( + f"Received error or empty response. Retrying in {wait_time:.2f} seconds..." + ) + await asyncio.sleep(wait_time) + continue + result = json.loads(content) + if self.action == "get_short_epg": + return ( + result["js"], + 1, + 1, + ) + + return ( + result["js"]["data"], + int(result["js"].get("total_items", 1)), + int(result["js"].get("max_page_items", 1)), + ) + except ( + aiohttp.ClientError, + json.JSONDecodeError, + asyncio.TimeoutError, + ) as e: + print(f"Error fetching page {page}: {e}") + if attempt == max_retries - 1: + raise + wait_time = (2**attempt) + random.uniform(0, 1) + print(f"Retrying in {wait_time:.2f} seconds...") + await asyncio.sleep(wait_time) + return [], 0, 0 + + def get_params(self, page): + params = { + "type": self.content_type, + "action": self.action, + "p": str(page), + "JsHttpRequest": "1-xml", + } + if self.content_type == "itv": + if self.action == "get_short_epg": + params.update( + { + "ch_id": self.ch_id, + "size": self.size, + } + ) + # remove unnecessary params + params.pop("p") + elif self.action == "get_epg_info": + params.update( + { + "period": self.period, + } + ) + # remove unnecessary params + params.pop("p") + else: + params.update( + { + "genre": self.category_id if self.category_id else "*", + "force_ch_link_check": "", + "fav": "0", + "sortby": self.sortby, + "hd": "0", + } + ) + elif self.content_type == "vod": + params.update( + { + "category": self.category_id if self.category_id else "*", + "sortby": self.sortby, + } + ) + elif self.content_type == "series": + params.update( + { + "category": self.category_id if self.category_id else "*", + "movie_id": self.movie_id if self.movie_id else "0", + "season_id": self.season_id if self.season_id else "0", + "episode_id": "0", + "sortby": self.sortby, + } + ) + return params + + async def load_content(self): + async with aiohttp.ClientSession() as session: + # Fetch initial data to get total items and max page items + page = 1 + page_items, total_items, max_page_items = await self.fetch_page( + session, page + ) + # if page_items is list, extend items + if isinstance(page_items, list): + self.items.extend(page_items) + # if page_items is dict, extend items + elif isinstance(page_items, dict): + self.items.append(page_items) + + if max_page_items: + pages = (total_items + max_page_items - 1) // max_page_items + else: + pages = 0 + + self.progress_updated.emit(1, pages) + + tasks = [] + for page_num in range(2, pages + 1): + tasks.append(self.fetch_page(session, page_num)) + + for i, task in enumerate(asyncio.as_completed(tasks), 2): + page_items, _, _ = await task + self.items.extend(page_items) + self.progress_updated.emit(i, pages) + + # Emit all items once done + self.content_loaded.emit( + { + "category_id": self.category_id, + "items": self.items, + "parent_id": self.parent_id, + "movie_id": self.movie_id, + "season_id": self.season_id, + } + ) + + def run(self): + try: + asyncio.run(self.load_content()) + except Exception as e: + print(f"Error in content loading: {e}") diff --git a/epg_manager.py b/epg_manager.py new file mode 100644 index 0000000..b6fb416 --- /dev/null +++ b/epg_manager.py @@ -0,0 +1,402 @@ +import os +import orjson as json +import pickle +import hashlib +import requests +import zipfile, gzip, io +from datetime import datetime, timedelta +from urlobject import URLObject +from content_loader import ContentLoader +from multikeydict import MultiKeyDict +import xml.etree.ElementTree as ET + +def xml_to_dict(element): + """ + Recursively converts an XML element and its children into a dictionary. + Handles multiple occurrences of the same child element by storing them in a list. + Includes attributes of elements in the resulting dictionary. + """ + def parse_element(element): + parsed_data = {} + + # Include element attributes + if element.attrib: + parsed_data.update(('@' + k, v) for k, v in element.attrib.items()) + + for child in element: + if len(child): + child_data = parse_element(child) + else: + child_data = {'__text':child.text} + if child.attrib: + child_data.update(('@' + k, v) for k, v in child.attrib.items()) + + if child.tag in parsed_data: + if isinstance(parsed_data[child.tag], list): + parsed_data[child.tag].append(child_data) + else: + parsed_data[child.tag] = [parsed_data[child.tag], child_data] + else: + parsed_data[child.tag] = child_data + return parsed_data + + return {element.tag: parse_element(element)} + +class EpgManager: + def __init__(self, config_manager, provider_manager): + self.config_manager = config_manager + self.provider_manager = provider_manager + + self.index = {} + self.epg = {} + self._load_index() + + def _cache_dir(self): + d = os.path.join(self.config_manager.get_config_dir(), 'cache', 'epg') + os.makedirs(d, exist_ok=True) + return d + + def _index_file(self): + cache_dir = self._cache_dir() + return os.path.join(cache_dir, 'index.json') + + def _load_index(self): + index_file = self._index_file() + self.index.clear() + if os.path.exists(index_file): + with open(index_file, 'r', encoding="utf-8") as f: + try: + self.index = json.loads(f.read()) + except (json.JSONDecodeError, IOError) as e: + print(f"Error loading index file: {e}") + + def clear_index(self): + cache_dir = self._cache_dir() + for file in os.listdir(cache_dir): + file_path = os.path.join(cache_dir, file) + if os.path.isfile(file_path): + os.remove(file_path) + self.index.clear() + self.save_index() + + def _index_programs(self, xmltv_file): + programs = MultiKeyDict() + + tree = ET.parse(xmltv_file).getroot() + for programme in tree.findall("programme"): + channel_id = programme.get("channel") + start_time = programme.get("start") + stop_time = programme.get("stop") + + # Fix stop_time < start_time, which means the program ends on the next day + if start_time > stop_time: + stop_time = (datetime.strptime(stop_time, "%Y%m%d%H%M%S %z") + timedelta(days=1)).strftime("%Y%m%d%H%M%S %z") + + multikeys = self.config_manager.xmltv_channel_map.get_keys(channel_id, channel_id) + program_data = xml_to_dict(programme)["programme"] + programs.setdefault(multikeys, []).append(program_data) + return programs + + def reindex_programs(self): + # Reindex existing epg + new_epg = MultiKeyDict() + for keys, programs in self.epg.items(): + for key in keys: + new_keys = self.config_manager.xmltv_channel_map.get_keys(key) + if new_keys: + new_epg[new_keys] = programs + break + self.epg = new_epg + + def save_index(self): + index_file = self._index_file() + with open(index_file, 'w', encoding="utf-8") as f: + f.write(json.dumps(self.index, option=json.OPT_INDENT_2).decode("utf-8")) + + def refresh_epg(self): + epg_source = self.config_manager.epg_source + + if epg_source == "STB": + return self._refresh_epg_stb(self.provider_manager.current_provider["url"], self.provider_manager.headers) + elif epg_source == "Local File": + return self._refresh_epg_file(self.config_manager.epg_file) + elif epg_source == "URL": + return self._refresh_epg_url(self.config_manager.epg_url) + return False + + def _refresh_epg_stb(self, provider_url, headers): + provider_hash = hashlib.md5(provider_url.encode()).hexdigest() + if provider_hash in self.index: + epg_info = self.index[provider_hash] + if epg_info: + current_time = datetime.now() + # Check expiration time + epg_date = datetime.strptime(epg_info["date"], "%Y-%m-%d %H:%M:%S") + if (current_time - epg_date).total_seconds() > self.config_manager.epg_expiration: + self._fetch_epg_from_stb(provider_url, headers) + return True + return False + + def _refresh_epg_file(self, xmltv_file): + xmltv_filehash = hashlib.md5(xmltv_file.encode()).hexdigest() + if xmltv_filehash in self.index: + epg_info = self.index[xmltv_filehash] + if epg_info: + # Check modified time + epg_date = datetime.strptime(epg_info["date"], "%Y-%m-%d %H:%M:%S") + if (datetime.fromtimestamp(os.path.getmtime(xmltv_file)) - epg_date).total_seconds() > 2: + self._fetch_epg_from_file(xmltv_filehash, xmltv_file) + return True + return False + + def _refresh_epg_url(self, url): + url_hash = hashlib.md5(url.encode()).hexdigest() + if url_hash in self.index: + epg_info = self.index[url_hash] + if epg_info: + # Check expiration time first, if expired check header for last-modified + last_access = datetime.strptime(epg_info["last_access"], "%Y-%m-%d %H:%M:%S") + current_time = datetime.now() + if (current_time - last_access).total_seconds() > self.config_manager.epg_expiration: + epg_date = datetime.strptime(epg_info["date"], "%Y-%m-%d %H:%M:%S") + # Request the URL with "If-Modified-Since" header + headers = {"If-Modified-Since": epg_date.strftime("%a, %d %b %Y %H:%M:%S GMT")} + r = requests.get(url, headers=headers) + if r.status_code == 304: + # EPG is still fresh + self.index[url_hash]["last_access"] = current_time.strftime("%Y-%m-%d %H:%M:%S") + return False + # EPG is not fresh, fetch it + self._fetch_epg_from_url(url) + return True + return False + + def set_current_epg(self): + self.epg = {} + if not self.config_manager.channel_epg: + return + + epg_source = self.config_manager.epg_source + if epg_source == "STB" and self.provider_manager.current_provider["type"] == "STB": + self._set_epg_from_stb(self.provider_manager.current_provider["url"], self.provider_manager.headers) + elif epg_source == "Local File": + self._set_epg_from_file(self.config_manager.epg_file) + elif epg_source == "URL": + self._set_epg_from_url(self.config_manager.epg_url) + + def _set_epg_from_stb(self, provider_url, headers): + provider_hash = hashlib.md5(provider_url.encode()).hexdigest() + if provider_hash in self.index: + epg_info = self.index[provider_hash] + if epg_info is None: + self.epg = {} + return + refreshed = self._refresh_epg_stb(provider_url, headers) + if refreshed: + return + + # EPG was fresh enough + cache_dir = self._cache_dir() + epg_file = os.path.join(cache_dir, f"{provider_hash}.pkl") + if os.path.exists(epg_file): + with open(epg_file, 'rb') as f: + self.epg = pickle.load(f) + current_time = datetime.now() + self.index[provider_hash]["last_access"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + return + + # no EPG or not fresh enough, fetch it + self._fetch_epg_from_stb(provider_url, headers) + + def _set_epg_from_file(self, xmltv_file): + xmltv_filehash = hashlib.md5(xmltv_file.encode()).hexdigest() + if xmltv_filehash in self.index: + epg_info = self.index[xmltv_filehash] + if epg_info is None: + self.epg = {} + return + refreshed = self._refresh_epg_file(xmltv_file) + if refreshed: + return + + # EPG is fresh enough + cache_dir = self._cache_dir() + programs_pickle = os.path.join(cache_dir, f"{xmltv_filehash}.pkl") + if os.path.exists(programs_pickle): + with open(programs_pickle, 'rb') as f: + self.epg = pickle.load(f) + self.index[xmltv_filehash]["last_access"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + return + + # no EPG or not fresh enough, fetch it + self._fetch_epg_from_file(xmltv_filehash, xmltv_file) + + def _set_epg_from_url(self, url): + url_hash = hashlib.md5(url.encode()).hexdigest() + if url_hash in self.index: + epg_info = self.index[url_hash] + if epg_info is None: + self.epg = {} + return + refreshed = self._refresh_epg_url(url) + if refreshed: + return + + # EPG is fresh enough + cache_dir = self._cache_dir() + programs_pickle = os.path.join(cache_dir, f"{url_hash}.pkl") + if os.path.exists(programs_pickle): + with open(programs_pickle, 'rb') as f: + self.epg = pickle.load(f) + self.index[url_hash]["last_access"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + return + + # no EPG or not fresh enough, fetch it + self._fetch_epg_from_url(url) + + def _fetch_epg_from_file(self, xmltv_filehash, xmltv_file): + self.epg = self._index_programs(xmltv_file) + if self.epg: + cache_dir = self._cache_dir() + programs_pickle = os.path.join(cache_dir, f"{xmltv_filehash}.pkl") + with open(programs_pickle, 'wb') as f: + pickle.dump(self.epg, f) + self.index[xmltv_filehash] = { + "date": datetime.fromtimestamp(os.path.getmtime(xmltv_file)).strftime("%Y-%m-%d %H:%M:%S"), + "last_access": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + } + else: + self.index[xmltv_filehash] = None + self.save_index() + + def _fetch_epg_from_stb(self, provider_url, headers): + provider_hash = hashlib.md5(provider_url.encode()).hexdigest() + url = URLObject(provider_url) + url = f"{url.scheme}://{url.netloc}/server/load.php" + period = 5 + content_loader = ContentLoader( + url=url, + headers=headers, + content_type="itv", + action="get_epg_info", + period=period, + ) + content_loader.run() + if content_loader.items: + self.epg = content_loader.items[0] + cache_dir = self._cache_dir() + epg_file = os.path.join(cache_dir, f"{provider_hash}.pkl") + with open(epg_file, 'wb') as f: + pickle.dump(self.epg, f) + current_time = datetime.now() + self.index[provider_hash] = { + "date": current_time.strftime("%Y-%m-%d %H:%M:%S"), + "last_access": current_time.strftime("%Y-%m-%d %H:%M:%S"), + } + else: + self.index[provider_hash] = None + self.epg = {} + self.save_index() + + def _fetch_epg_from_url(self, url): + r = requests.get(url, stream = True) + if r.status_code == 200: + content_type = r.headers.get("Content-Type", "") + xmltv_file_path = None + cache_dir = self._cache_dir() + url_hash = hashlib.md5(url.encode()).hexdigest() + xmltv_file_path = os.path.join(cache_dir, f"{url_hash}.xml") + + if content_type == "application/zip": + with zipfile.ZipFile(io.BytesIO(r.raw.read())) as z: + for name in z.namelist(): + if name.endswith(".xml"): + with z.open(name) as xml_file, open(xmltv_file_path, 'wb') as f: + f.write(xml_file.read()) + break + elif content_type == "application/gzip": + with gzip.GzipFile(fileobj=io.BytesIO(r.raw.read())) as gz, open(xmltv_file_path, 'wb') as f: + f.write(gz.read()) + else: + with open(xmltv_file_path, 'wb') as f: + f.write(r.content) + + if os.path.exists(xmltv_file_path): + self.epg = self._index_programs(xmltv_file_path) + os.remove(xmltv_file_path) + if self.epg: + programs_pickle = os.path.join(cache_dir, f"{url_hash}.pkl") + with open(programs_pickle, 'wb') as f: + pickle.dump(self.epg, f) + current_time = datetime.now() + last_modified = datetime.strptime(r.headers.get("Last-Modified",current_time.strftime("%a, %d %b %Y %H:%M:%S %Z")), "%a, %d %b %Y %H:%M:%S %Z") + self.index[url_hash] = { + "date": last_modified.strftime("%Y-%m-%d %H:%M:%S"), + "last_access": current_time.strftime("%Y-%m-%d %H:%M:%S"), + } + else: + self.index[url_hash] = None + self.epg = {} + self.save_index() + + def get_programs_for_channel(self, channel_data, start_time=None, max_programs=5): + epg_source = self.config_manager.epg_source + + if epg_source == "STB": + channel_id = channel_data.get("id", "") + return self._get_programs_for_channel_from_stb(channel_id, start_time, max_programs) + else: + channel_id = channel_data.get("xmltv_id", "") + return self._get_programs_for_channel_from_xmltv(channel_id, start_time, max_programs) + + def _get_programs_for_channel_from_stb(self, channel_id, start_time, max_programs): + if start_time is None: + start_time = datetime.now() + + programs = self.epg.get(channel_id, []) + return self._filter_and_sort_programs(programs, start_time, max_programs) + + def _get_programs_for_channel_from_xmltv(self, channel_id, start_time, max_programs): + if start_time is None: + start_time = datetime.now() + + if channel_id not in self.epg: + return [] + + # search the timezone used by programs for channel_id by looking at very 1st program + ref_time_str = self.epg[channel_id][0]['@start'] + ref_time = datetime.strptime(ref_time_str, "%Y%m%d%H%M%S %z") + ref_timezone = ref_time.tzinfo + + # check if timezone for last program is same, otherwise, we might be in time span with a DST + ref_time_str1 = self.epg[channel_id][-1]['@start'] + ref_time1 = datetime.strptime(ref_time_str1, "%Y%m%d%H%M%S %z") + ref_timezone1 = ref_time1.tzinfo + need_check_tz = (ref_timezone1 != ref_timezone) + + # Get the start time in the timezone of the programs + start_time_str = start_time.astimezone(ref_timezone).strftime("%Y%m%d%H%M%S %z") + + programs = [] + for entry in self.epg[channel_id]: + if need_check_tz: + tz = datetime.strptime(entry['@start'], "%Y%m%d%H%M%S %z").tzinfo + start_time_str = start_time.astimezone(tz).strftime("%Y%m%d%H%M%S %z") + if entry['@start'] >= start_time_str or entry['@stop'] > start_time_str: + programs.append(entry) + if len(programs) >= max_programs: + break + + programs.sort(key=lambda program: program['@start']) + return programs[:max_programs] + + def _filter_and_sort_programs(self, programs, start_time, max_programs): + filtered_programs = [] + for program in programs: + if datetime.strptime(program["time"], "%Y-%m-%d %H:%M:%S") >= start_time or datetime.strptime(program["time_to"], "%Y-%m-%d %H:%M:%S") > start_time: + filtered_programs.append(program) + if len(filtered_programs) >= max_programs: + break + + filtered_programs.sort(key=lambda program: datetime.strptime(program["time"], "%Y-%m-%d %H:%M:%S")) + return filtered_programs[:max_programs] diff --git a/image_loader.py b/image_loader.py new file mode 100644 index 0000000..5980d9b --- /dev/null +++ b/image_loader.py @@ -0,0 +1,72 @@ +import aiohttp +import asyncio +from PySide6.QtCore import QThread, Signal + +class ImageLoader(QThread): + progress_updated = Signal(int, int, dict) + + def __init__( + self, + image_urls, + image_manager, + iconified = False, + ): + super().__init__() + self.image_urls = image_urls + self.image_manager = image_manager + self.iconified = iconified + + async def fetch_image(self, session, image_rank, image_url): + try: + # Use ImageManager to get QIcon or QPixmap + image = await self.image_manager.get_image_from_url(session, image_url, self.iconified) + if image: + if self.iconified: + return {"rank":image_rank, "icon":image} + else: + return {"pixmap":image} + except Exception as e: + print(f"Error fetching image {image_url}: {e}") + raise + return None + + async def decode_base64_image(self, image_rank, image_str): + try: + # Use ImageManager to get QIcon or QPixmap + image = await self.image_manager.get_image_from_base64(image_str, self.iconified) + if image: + if self.iconified: + return {"rank":image_rank, "icon":image} + else: + return {"pixmap":image} + except Exception as e: + print(f"Error decoding base64 image : {e}") + raise + return None + + async def load_images(self, max_concurrent_fetch=5): + async with aiohttp.ClientSession() as session: + tasks = [] + for image_rank, url in enumerate(self.image_urls): + if url: + if url.startswith(("http://", "https://")): + tasks.append(self.fetch_image(session, image_rank, url)) + elif url.startswith("data:image"): + tasks.append(self.decode_base64_image(image_rank, url)) + image_count = len(tasks) + + for i, task in enumerate(asyncio.as_completed(tasks), 1): + try: + image_item = await task + except Exception as e: + image_item = None + print(f"Error processing image task: {e}") + finally: + self.progress_updated.emit(i, image_count, image_item) + + def run(self): + try: + asyncio.run(self.load_images()) + except Exception as e: + print(f"Error in image loading: {e}") + diff --git a/image_manager.py b/image_manager.py new file mode 100644 index 0000000..ed80783 --- /dev/null +++ b/image_manager.py @@ -0,0 +1,246 @@ +import asyncio +import os +import hashlib +import json +import orjson +import base64 +import random +import aiohttp +from io import BytesIO +from datetime import datetime +from collections import OrderedDict +from PySide6.QtGui import QIcon, QPixmap +from PySide6.QtCore import Qt + +class ImageManager: + def __init__(self, config_manager, max_cache_size=50 * 1024 * 1024): # Default max cache size: 50 MB + self.cache_dir = os.path.join(config_manager.get_config_dir(), 'cache', 'image') + os.makedirs(self.cache_dir, exist_ok=True) + self.index_file = os.path.join(self.cache_dir, 'index.json') + self.cache = OrderedDict() # cache is an ordered dict where last accessed items are at the end + self.max_cache_size = max_cache_size + self.current_cache_size = 0 + self._load_index() + + async def get_image_from_base64(self, image_str, iconified): + image_type = "qicon" if iconified else "qpixmap" + ext = "png" if iconified else "jpg" + image_hash = self._hash_string(image_str + ext) + if image_hash in self.cache: + if self.cache[image_hash]: + self.cache[image_hash]["last_access"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + self.cache.move_to_end(image_hash) # Update access order + cache_path = os.path.join(self.cache_dir, f"{image_hash}.{ext}") + if os.path.exists(cache_path): + if image_type in self.cache[image_hash]: + return self.cache[image_hash][image_type] + else: + image = QPixmap(cache_path, "PNG" if iconified else "JPG") + if iconified: + image = QIcon(image) + self.cache[image_hash][image_type] = image + return image + else: + # File doesn't exist, remove the entry from cache + self.cache.pop(image_hash) + self.current_cache_size -= self.cache[image_hash]['size'] + else: + return None + + cache_path = os.path.join(self.cache_dir, f"{image_hash}.{ext}") + if os.path.exists(cache_path): + image = QPixmap(cache_path, "PNG" if iconified else "JPG") + if iconified: + image = QIcon(image) + self.cache[image_hash] = { + image_type: image, + "size": os.path.getsize(cache_path), + "last_access": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + self.cache.move_to_end(image_hash) # Update access order + return image + + # Extract and decode base64 data from the image string + base64_data = image_str.split(",", 1)[1] + image_data = base64.b64decode(base64_data) + image = QPixmap() + if image.loadFromData(image_data): + if iconified: + image = image.scaled(64, 64, Qt.KeepAspectRatio, Qt.SmoothTransformation) + else: + image = image.scaled(300, 400, Qt.KeepAspectRatio, Qt.SmoothTransformation) + if image.save(cache_path, "PNG" if iconified else "JPG"): + if iconified: + image = QIcon(image) + file_size = os.path.getsize(cache_path) + self.cache[image_hash] = { + image_type: image, + "size": file_size, + "last_access": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + self.current_cache_size += file_size + self.cache.move_to_end(image_hash) # Update access order + self._manage_cache_size() + return image + self.cache[image_hash] = None + return None + + async def get_image_from_url(self, session, url, iconified, max_retries=2, timeout=5): + image_type = "qicon" if iconified else "qpixmap" + ext = "png" if iconified else "jpg" + url_hash = self._hash_string(url + ext) + if url_hash in self.cache: + if self.cache[url_hash]: + self.cache[url_hash]["last_access"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + self.cache.move_to_end(url_hash) # Update access order + cache_path = os.path.join(self.cache_dir, f"{url_hash}.{ext}") + if os.path.exists(cache_path): + if image_type in self.cache[url_hash]: + return self.cache[url_hash][image_type] + else: + image = QPixmap(cache_path, "PNG" if iconified else "JPG") + if iconified: + image = QIcon(image) + self.cache[url_hash][image_type] = image + return image + else: + # File doesn't exist, remove the entry from cache + self.current_cache_size -= self.cache[url_hash]['size'] + self.cache.pop(url_hash) + else: + return None + + cache_path = os.path.join(self.cache_dir, f"{url_hash}.{ext}") + if os.path.exists(cache_path): + image = QPixmap(cache_path, "PNG" if iconified else "JPG") + if iconified: + image = QIcon(image) + self.cache[url_hash] = { + image_type: image, + "size": os.path.getsize(cache_path), + "last_access": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + self.cache.move_to_end(url_hash) # Update access order + return image + for attempt in range(max_retries): + try: + async with session.get(url, timeout=timeout) as response: + content = await response.read() + if response.status == 503 or not content: + if attempt == max_retries - 1: + continue + wait_time = (2**attempt) + random.uniform(0, 1) + print(f"Received error or empty response. Retrying in {wait_time:.2f} seconds...") + await asyncio.sleep(wait_time) + continue + + # check if content type is image + if response.headers.get('content-type', '').startswith('image/'): + image_data = BytesIO(content) + image = QPixmap() + if image.loadFromData(image_data.read()): + if iconified: + image = image.scaled(64, 64, Qt.KeepAspectRatio, Qt.SmoothTransformation) + else: + image = image.scaled(300, 400, Qt.KeepAspectRatio, Qt.SmoothTransformation) + if image.save(cache_path, "PNG" if iconified else "JPG"): + if iconified: + image = QIcon(image) + file_size = os.path.getsize(cache_path) + self.cache[url_hash] = { + image_type: image, + "size": file_size, + "last_access": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + self.current_cache_size += file_size + self.cache.move_to_end(url_hash) # Update access order + self._manage_cache_size() + return image + self.cache[url_hash] = None + return None + except ( + aiohttp.ClientError, + asyncio.TimeoutError, + ) as e: + print(f"Error fetching image: {e}") + if attempt == max_retries - 1: + self.cache[url_hash] = None + return None + wait_time = (2**attempt) + random.uniform(0, 1) + print(f"Retrying in {wait_time:.2f} seconds...") + await asyncio.sleep(wait_time) + + self.cache[url_hash] = None + return None + + def clear_cache(self): + for filename in os.listdir(self.cache_dir): + file_path = os.path.join(self.cache_dir, filename) + if os.path.isfile(file_path): + os.remove(file_path) + self.cache.clear() + self.current_cache_size = 0 + # Save empty cache index + self.save_index() + + def remove_icon_from_cache(self, url): + ext = "png" + url_hash = self._hash_string(url + ext) + if url_hash in self.cache: + cache_path = os.path.join(self.cache_dir, f"{url_hash}.{ext}") + if os.path.exists(cache_path): + file_size = os.path.getsize(cache_path) + os.remove(cache_path) + self.current_cache_size -= file_size + self.cache.pop(url_hash) + + def _hash_string(self, url): + return hashlib.sha256(url.encode('utf-8')).hexdigest() + + def _load_index(self): + self.cache.clear() + if os.path.exists(self.index_file): + with open(self.index_file, 'r') as f: + try: + self.cache = json.load(f, object_pairs_hook=OrderedDict) + except (json.JSONDecodeError, IOError) as e: + print(f"Error loading index file: {e}") + + # Add missing keys to the cache + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + for filename in os.listdir(self.cache_dir): + if filename.endswith(".png") or filename.endswith(".jpg"): + image_hash = filename.split(".")[0] + if image_hash not in self.cache: + iconified = filename.endswith(".png") + image_type = "qicon" if iconified else "qpixmap" + cache_path = os.path.join(self.cache_dir, filename) + image = QPixmap(cache_path, "PNG" if iconified else "JPG") + if iconified: + image = QIcon(image) + self.cache[image_hash] = { + image_type: image, + "size": os.path.getsize(cache_path), + "last_access": now + } + + self.current_cache_size = sum( + entry["size"] for entry in self.cache.values() if entry + ) + + def save_index(self): + index_data = {url: {k: v for k, v in data.items() if k not in ['qicon', 'qpixmap']} if data else None for url, data in self.cache.items()} + with open(self.index_file, 'w', encoding="utf-8") as f: + f.write(orjson.dumps(index_data, option=orjson.OPT_INDENT_2).decode("utf-8")) + + def _manage_cache_size(self): + # Remove oldest accessed items until cache size is within limits + while self.current_cache_size > self.max_cache_size: + # Pick the oldest accessed item (reminder: cache is an ordered dict where last accessed items are at the end) + oldest_hash, oldest_data = self.cache.popitem(last=False) + ext = "png" if 'qicon' in oldest_data else "jpg" + cache_path = os.path.join(self.cache_dir, f"{oldest_hash}.{ext}") + if os.path.exists(cache_path): + file_size = os.path.getsize(cache_path) + os.remove(cache_path) + self.current_cache_size -= file_size \ No newline at end of file diff --git a/main.py b/main.py index 2f277d7..c1f49b1 100644 --- a/main.py +++ b/main.py @@ -11,12 +11,20 @@ from sleep_manager import allow_sleep, prevent_sleep from update_checker import check_for_updates from video_player import VideoPlayer +from image_manager import ImageManager +from provider_manager import ProviderManager +from epg_manager import EpgManager if __name__ == "__main__": app = QApplication(sys.argv) icon_path = "assets/qitv.png" + config_manager = ConfigManager() + image_manager = ImageManager(config_manager, config_manager.max_cache_image_size * 1024 * 1024) + provider_manager = ProviderManager(config_manager) + epg_manager = EpgManager(config_manager, provider_manager) + if platform.system() == "Windows": myappid = f"com.ozankaraali.qitv.{config_manager.CURRENT_VERSION}" ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) # type: ignore @@ -28,12 +36,14 @@ prevent_sleep() try: player = VideoPlayer(config_manager) - channel_list = ChannelList(app, player, config_manager) + channel_list = ChannelList(app, player, config_manager, provider_manager, image_manager, epg_manager) qdarktheme.setup_theme("auto") player.show() channel_list.show() - check_for_updates() + if config_manager.check_updates: + check_for_updates() + sys.exit(app.exec()) finally: allow_sleep() diff --git a/multikeydict.py b/multikeydict.py new file mode 100644 index 0000000..d495df9 --- /dev/null +++ b/multikeydict.py @@ -0,0 +1,75 @@ +class MultiKeyDict: + def __init__(self): + self._data = {} + self._keys_map = {} + + def __len__(self): + return len(self._data) + + def __setitem__(self, keys, value): + if not isinstance(keys, tuple): + keys = (keys,) + for key in keys: + self._keys_map[key] = keys + self._data[keys] = value + + def __getitem__(self, key): + keys = self._keys_map.get(key) + if keys is None: + raise KeyError(key) + return self._data[keys] + + def __delitem__(self, key): + keys = self._keys_map.get(key) + if keys is None: + raise KeyError(key) + for k in keys: + del self._keys_map[k] + del self._data[keys] + + def __contains__(self, key): + return key in self._keys_map + + def __repr__(self): + return f"{self.__class__.__name__}({self._data})" + + def items(self): + return self._data.items() + + def get(self, key, default=None): + keys = self._keys_map.get(key) + if keys is None: + return default + return self._data[keys] + + def get_keys(self, key, default=None): + keys = self._keys_map.get(key) + if keys is None: + return default + return keys + + def pop(self, key, default=None): + if key in self: + value = self[key] + del self[key] + return value + return default + + def setdefault(self, keys, default=None): + if not isinstance(keys, tuple): + keys = (keys,) + if keys in self._data: + return self._data[keys] + self[keys] = default + return default + + def serialize(self): + return [list(keys) + [value] for keys, value in self._data.items()] + + @classmethod + def deserialize(cls, serialized_data): + multi_key_dict = cls() + for item in serialized_data: + *keys, value = item + multi_key_dict[tuple(keys)] = value + return multi_key_dict \ No newline at end of file diff --git a/options.py b/options.py index e948d1b..3b79aea 100644 --- a/options.py +++ b/options.py @@ -1,72 +1,198 @@ import os +from update_checker import check_for_updates +from config_manager import MultiKeyDict +import orjson as json +import requests from PySide6.QtWidgets import ( + QAbstractItemView, QButtonGroup, + QCheckBox, QComboBox, QDialog, QFileDialog, QFormLayout, + QGridLayout, + QHBoxLayout, + QHeaderView, QLabel, QLineEdit, QPushButton, QRadioButton, + QSpinBox, + QTabWidget, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget ) +class AddXmltvMappingDialog(QDialog): + def __init__(self, parent=None, channel_name="", logo_url="", channel_ids=""): + super().__init__(parent) + self.setWindowTitle("Add/Edit XMLTV Mapping") + + self.layout = QFormLayout(self) + + self.channel_name_input = QLineEdit(self) + self.channel_name_input.setText(channel_name) + self.layout.addRow("Channel Name:", self.channel_name_input) + + self.logo_url_input = QLineEdit(self) + self.logo_url_input.setText(logo_url) + self.layout.addRow("Logo URL:", self.logo_url_input) + + self.channel_ids_input = QLineEdit(self) + self.channel_ids_input.setText(channel_ids) + self.layout.addRow("Channel IDs (comma-separated):", self.channel_ids_input) + + self.button_box = QHBoxLayout() + self.ok_button = QPushButton("OK", self) + self.ok_button.clicked.connect(self.accept) + self.cancel_button = QPushButton("Cancel", self) + self.cancel_button.clicked.connect(self.reject) + self.button_box.addWidget(self.ok_button) + self.button_box.addWidget(self.cancel_button) + + self.layout.addRow(self.button_box) + + def get_data(self): + return ( + self.channel_name_input.text(), + self.logo_url_input.text(), + self.channel_ids_input.text() + ) + + class OptionsDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) - self.setWindowTitle("Options") - self.layout = QFormLayout(self) - self.config = parent.config - self.selected_provider_index = self.config.get("selected", 0) + + self.setWindowTitle("Settings") + + self.config_manager = parent.config_manager + self.provider_manager = parent.provider_manager + self.epg_manager = parent.epg_manager + self.providers = self.provider_manager.providers + self.selected_provider_name = self.config_manager.selected_provider_name + self.selected_provider_index = 0 + self.epg_settings_modified = False + self.xmltv_mapping_modified = False + self.providers_modified = False + self.current_provider_changed = False + + for i in range(len(self.providers)): + if self.providers[i]["name"] == self.config_manager.selected_provider_name: + self.selected_provider_index = i + break + + self.main_layout = QVBoxLayout(self); self.create_options_ui() + + self.save_button = QPushButton("Save", self) + self.save_button.clicked.connect(self.save_settings) + + self.main_layout.addWidget(self.options_tab) + self.main_layout.addWidget(self.save_button) + self.load_providers() def create_options_ui(self): - self.provider_label = QLabel("Select Provider:", self) - self.provider_combo = QComboBox(self) + self.options_tab = QTabWidget(self) + + # Add tab with settings + self.create_settings_ui() + + # Add tab with providers + self.create_providers_ui() + + # Add tab with EPG settings + self.create_epg_ui() + + def create_settings_ui(self): + self.settings_tab = QWidget(self) + self.options_tab.addTab(self.settings_tab, "Settings") + self.settings_layout = QFormLayout(self.settings_tab) + + # Add check button to allow checking for updates + self.check_updates_checkbox = QCheckBox("Allow Check for Updates", self.settings_tab) + self.check_updates_checkbox.setChecked(self.config_manager.check_updates) + self.check_updates_checkbox.stateChanged.connect(self.on_check_updates_toggled) + self.settings_layout.addRow(self.check_updates_checkbox) + + # Add check button to enable channel logos + self.channel_logos_checkbox = QCheckBox("Enable Channel Logos", self.settings_tab) + self.channel_logos_checkbox.setChecked(self.config_manager.channel_logos) + self.settings_layout.addRow(self.channel_logos_checkbox) + + # Add cache options + self.cache_options_layout = QVBoxLayout() + self.cache_image_size_label = QLabel(f"Max size of image cache (actual size: {self.get_cache_image_size():.2f} MB)", self.settings_tab) + self.cache_image_size_input = QLineEdit(self.settings_tab) + self.cache_image_size_input.setText(str(self.config_manager.max_cache_image_size)) + self.settings_layout.addRow(self.cache_image_size_label, self.cache_image_size_input) + + self.clear_image_cache_button = QPushButton("Clear Image Cache", self.settings_tab) + self.clear_image_cache_button.clicked.connect(self.clear_image_cache) + self.settings_layout.addRow(self.clear_image_cache_button) + + def create_providers_ui(self): + self.providers_tab = QWidget(self) + self.options_tab.addTab(self.providers_tab, "Providers") + self.providers_layout = QFormLayout(self.providers_tab) + + self.provider_label = QLabel("Select Provider:", self.providers_tab) + self.provider_combo = QComboBox(self.providers_tab) self.provider_combo.currentIndexChanged.connect(self.load_provider_settings) - self.layout.addRow(self.provider_label, self.provider_combo) + self.providers_layout.addRow(self.provider_label, self.provider_combo) - self.add_provider_button = QPushButton("Add Provider", self) + self.add_provider_button = QPushButton("Add Provider", self.providers_tab) self.add_provider_button.clicked.connect(self.add_new_provider) - self.layout.addWidget(self.add_provider_button) + self.providers_layout.addWidget(self.add_provider_button) - self.remove_provider_button = QPushButton("Remove Provider", self) + self.remove_provider_button = QPushButton("Remove Provider", self.providers_tab) self.remove_provider_button.clicked.connect(self.remove_provider) - self.layout.addWidget(self.remove_provider_button) + self.providers_layout.addWidget(self.remove_provider_button) + + self.name_label = QLabel("Name:", self.providers_tab) + self.name_input = QLineEdit(self.providers_tab) + self.providers_layout.addRow(self.name_label, self.name_input) self.create_stream_type_ui() - self.url_label = QLabel("Server URL:", self) - self.url_input = QLineEdit(self) - self.layout.addRow(self.url_label, self.url_input) - self.mac_label = QLabel("MAC Address (STB only):", self) - self.mac_input = QLineEdit(self) - self.layout.addRow(self.mac_label, self.mac_input) + self.url_label = QLabel("Server URL:", self.providers_tab) + self.url_input = QLineEdit(self.providers_tab) + self.providers_layout.addRow(self.url_label, self.url_input) + + self.mac_label = QLabel("MAC Address:", self.providers_tab) + self.mac_input = QLineEdit(self.providers_tab) + self.providers_layout.addRow(self.mac_label, self.mac_input) - self.file_button = QPushButton("Load File", self) + self.file_button = QPushButton("Load File", self.providers_tab) self.file_button.clicked.connect(self.load_file) - self.layout.addWidget(self.file_button) + self.providers_layout.addWidget(self.file_button) - self.username_label = QLabel("Username:", self) - self.username_input = QLineEdit(self) - self.layout.addRow(self.username_label, self.username_input) + self.username_label = QLabel("Username:", self.providers_tab) + self.username_input = QLineEdit(self.providers_tab) + self.providers_layout.addRow(self.username_label, self.username_input) - self.password_label = QLabel("Password:", self) - self.password_input = QLineEdit(self) - self.layout.addRow(self.password_label, self.password_input) + self.password_label = QLabel("Password:", self.providers_tab) + self.password_input = QLineEdit(self.providers_tab) + self.providers_layout.addRow(self.password_label, self.password_input) - self.verify_button = QPushButton("Verify Provider", self) + self.verify_apply_group = QWidget(self.providers_tab) + self.verify_button = QPushButton("Verify Provider", self.verify_apply_group) self.verify_button.clicked.connect(self.verify_provider) - self.layout.addWidget(self.verify_button) - self.verify_result = QLabel("", self) - self.layout.addWidget(self.verify_result) - self.save_button = QPushButton("Save", self) - self.save_button.clicked.connect(self.save_settings) - self.layout.addWidget(self.save_button) + self.apply_button = QPushButton("Apply Change", self.verify_apply_group) + self.apply_button.clicked.connect(self.apply_provider) + verify_apply_layout = QHBoxLayout(self.verify_apply_group) + verify_apply_layout.addWidget(self.verify_button) + verify_apply_layout.addWidget(self.apply_button) + self.verify_result = QLabel("", self.providers_tab) + self.providers_layout.addWidget(self.verify_apply_group) + self.providers_layout.addWidget(self.verify_result) def create_stream_type_ui(self): self.type_label = QLabel("Stream Type:", self) @@ -85,21 +211,117 @@ def create_stream_type_ui(self): self.type_M3USTREAM.toggled.connect(self.update_inputs) self.type_XTREAM.toggled.connect(self.update_inputs) - self.layout.addRow(self.type_label) - self.layout.addRow(self.type_STB) - self.layout.addRow(self.type_M3UPLAYLIST) - self.layout.addRow(self.type_M3USTREAM) - self.layout.addRow(self.type_XTREAM) + grid_layout = QGridLayout() + grid_layout.addWidget(self.type_STB, 0, 0) + grid_layout.addWidget(self.type_M3UPLAYLIST, 0, 1) + grid_layout.addWidget(self.type_M3USTREAM, 1, 0) + grid_layout.addWidget(self.type_XTREAM, 1, 1) + self.providers_layout.addRow(self.type_label, grid_layout) + + def create_epg_ui(self): + self.epg_tab = QWidget(self) + self.options_tab.addTab(self.epg_tab, "EPG") + self.epg_layout = QFormLayout(self.epg_tab) + + # Add EPG settings + self.epg_source_label = QLabel("EPG Source") + self.epg_source_combo = QComboBox() + self.epg_source_combo.addItems(["No Source", "STB", "Local File", "URL"]) + self.epg_source_combo.setCurrentText(self.config_manager.epg_source) + self.epg_source_combo.currentIndexChanged.connect(self.on_epg_source_changed) + self.epg_layout.addRow(self.epg_source_label) + self.epg_layout.addRow(self.epg_source_combo) + + self.epg_url_label = QLabel("EPG URL") + self.epg_url_input = QLineEdit() + self.epg_url_input.setText(self.config_manager.epg_url) + self.epg_layout.addRow(self.epg_url_label) + self.epg_layout.addRow(self.epg_url_input) + + self.epg_file_label = QLabel("EPG File") + self.epg_file_input = QLineEdit() + self.epg_file_input.setText(self.config_manager.epg_file) + self.epg_file_button = QPushButton("Browse") + self.epg_file_button.clicked.connect(self.browse_epg_file) + self.epg_layout.addRow(self.epg_file_label) + self.epg_layout.addRow(self.epg_file_input) + self.epg_layout.addRow(self.epg_file_button) + + # Add expiring EPG settings + self.epg_expiration_layout = QHBoxLayout() + self.epg_expiration_label = QLabel("Check update every") + self.epg_expiration_spinner = QSpinBox() + self.epg_expiration_spinner.setValue(self.config_manager.epg_expiration_value) + self.epg_expiration_spinner.setMinimum(1) + self.epg_expiration_spinner.setMaximum(9999) + self.epg_expiration_spinner.setSingleStep(1) + self.epg_expiration_combo = QComboBox() + self.epg_expiration_combo.addItems(["Minutes", "Hours", "Days", "Weeks", "Monthes"]) + self.epg_expiration_combo.setCurrentText(self.config_manager.epg_expiration_unit) + self.epg_expiration_layout.addWidget(self.epg_expiration_label) + self.epg_expiration_layout.addWidget(self.epg_expiration_spinner) + self.epg_expiration_layout.addWidget(self.epg_expiration_combo) + self.epg_layout.addRow(self.epg_expiration_layout) + + # Create a vertical layout for the settings, XMLTV mapping table, and buttons + self.xmltv_group_widget = QWidget() + self.xmltv_group_layout = QVBoxLayout(self.xmltv_group_widget) + self.xmltv_group_label = QLabel("XMLTV Mapping") + self.xmltv_group_layout.addWidget(self.xmltv_group_label) + + # XMLTV mapping table + self.xmltv_mapping_table = QTableWidget(self.xmltv_group_widget) + self.xmltv_mapping_table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.xmltv_mapping_table.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.xmltv_mapping_table.setColumnCount(3) + self.xmltv_mapping_table.setHorizontalHeaderLabels(["Channel Name", "Logo URL", "Channel IDs"]) + self.xmltv_mapping_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) + self.xmltv_mapping_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) + self.xmltv_mapping_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) + self.xmltv_mapping_table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.xmltv_group_layout.addWidget(self.xmltv_mapping_table) + + self.load_xmltv_channel_mapping() + + # Create a horizontal layout for the buttons + self.xmltv_buttons_layout = QHBoxLayout() + self.add_xmltv_mapping_button = QPushButton("Add") + self.add_xmltv_mapping_button.clicked.connect(self.add_xmltv_mapping) + self.xmltv_buttons_layout.addWidget(self.add_xmltv_mapping_button) + + self.edit_xmltv_mapping_button = QPushButton("Edit") + self.edit_xmltv_mapping_button.clicked.connect(self.edit_xmltv_mapping) + self.xmltv_buttons_layout.addWidget(self.edit_xmltv_mapping_button) + + self.delete_xmltv_mapping_button = QPushButton("Delete") + self.delete_xmltv_mapping_button.clicked.connect(self.delete_xmltv_mapping) + self.xmltv_buttons_layout.addWidget(self.delete_xmltv_mapping_button) + + self.import_xmltv_mapping_button = QPushButton("Import") + self.import_xmltv_mapping_button.clicked.connect(self.import_xmltv_mapping) + self.xmltv_buttons_layout.addWidget(self.import_xmltv_mapping_button) + + self.export_xmltv_mapping_button = QPushButton("Export") + self.export_xmltv_mapping_button.clicked.connect(self.export_xmltv_mapping) + self.xmltv_buttons_layout.addWidget(self.export_xmltv_mapping_button) + + # Add the horizontal layout to the vertical layout + self.xmltv_group_layout.addLayout(self.xmltv_buttons_layout) + + self.epg_layout.addRow(self.xmltv_group_widget) + + # Initial call to set visibility based on the current selection + self.on_epg_source_changed() def load_providers(self): self.provider_combo.blockSignals(True) self.provider_combo.clear() - for i, provider in enumerate(self.config["data"]): - # can we get the first couple ... last couple of characters of the url? + for i, provider in enumerate(self.providers): + # can we get the first couple ... last couple of characters of the name? prov = ( - provider["url"][:30] + "..." + provider["url"][-15:] - if len(provider["url"]) > 45 - else provider["url"] + provider["name"][:30] + "..." + provider["name"][-15:] + if len(provider["name"]) > 45 + else provider["name"] ) self.provider_combo.addItem(f"{i + 1}: {prov}", userData=provider) self.provider_combo.blockSignals(False) @@ -107,19 +329,50 @@ def load_providers(self): self.load_provider_settings(self.selected_provider_index) def load_provider_settings(self, index): - if index == -1 or index >= len(self.config["data"]): + if index == -1 or index >= len(self.providers): return + self.selected_provider_name = self.providers[index].get("name", self.providers[index].get("url", "")) self.selected_provider_index = index - self.selected_provider = self.config["data"][index] - self.url_input.setText(self.selected_provider.get("url", "")) - self.mac_input.setText(self.selected_provider.get("mac", "")) - self.username_input.setText(self.selected_provider.get("username", "")) - self.password_input.setText(self.selected_provider.get("password", "")) + self.edited_provider = self.providers[index] + self.name_input.setText(self.edited_provider.get("name", "")) + self.url_input.setText(self.edited_provider.get("url", "")) + self.mac_input.setText(self.edited_provider.get("mac", "")) + self.username_input.setText(self.edited_provider.get("username", "")) + self.password_input.setText(self.edited_provider.get("password", "")) self.update_radio_buttons() self.update_inputs() + def on_epg_source_changed(self): + epg_source = self.epg_source_combo.currentText() + + self.epg_url_label.hide() + self.epg_url_input.hide() + self.epg_expiration_label.hide() + self.epg_expiration_spinner.hide() + self.epg_expiration_combo.hide() + self.epg_file_label.hide() + self.epg_file_input.hide() + self.epg_file_button.hide() + self.xmltv_group_widget.hide() + + if epg_source == "URL": + self.epg_url_label.show() + self.epg_url_input.show() + + if epg_source == "Local File": + self.epg_file_label.show() + self.epg_file_input.show() + self.epg_file_button.show() + elif epg_source != "No Source": + self.epg_expiration_label.show() + self.epg_expiration_spinner.show() + self.epg_expiration_combo.show() + + if epg_source not in ["STB", "No Source"]: + self.xmltv_group_widget.show() + def update_radio_buttons(self): - provider_type = self.selected_provider.get("type", "") + provider_type = self.edited_provider.get("type", "") self.type_STB.setChecked(provider_type == "STB") self.type_M3UPLAYLIST.setChecked(provider_type == "M3UPLAYLIST") self.type_M3USTREAM.setChecked(provider_type == "M3USTREAM") @@ -140,38 +393,85 @@ def update_inputs(self): self.password_input.setVisible(self.type_XTREAM.isChecked()) def add_new_provider(self): - new_provider = {"type": "STB", "url": "", "mac": ""} - self.config["data"].append(new_provider) + new_provider = {"type": "STB", "name": "", "url": "", "mac": ""} + self.providers.append(new_provider) self.load_providers() - self.provider_combo.setCurrentIndex(len(self.config["data"]) - 1) + self.provider_combo.setCurrentIndex(len(self.providers) - 1) + self.providers_modified = True def remove_provider(self): - if len(self.config["data"]) == 1: + if len(self.providers) == 1: return - del self.config["data"][self.provider_combo.currentIndex()] + del self.providers[self.provider_combo.currentIndex()] self.load_providers() self.provider_combo.setCurrentIndex( - min(self.selected_provider_index, len(self.config["data"]) - 1) + min(self.selected_provider_index, len(self.providers) - 1) ) + self.providers_modified = True + + def browse_epg_file(self): + file_dialog = QFileDialog(self) + file_path, _ = file_dialog.getOpenFileName() + if file_path: + self.epg_file_input.setText(file_path) def save_settings(self): - if self.selected_provider: - self.selected_provider["url"] = self.url_input.text() - if self.type_STB.isChecked(): - self.selected_provider["type"] = "STB" - self.selected_provider["mac"] = self.mac_input.text() - elif self.type_M3UPLAYLIST.isChecked(): - self.selected_provider["type"] = "M3UPLAYLIST" - elif self.type_M3USTREAM.isChecked(): - self.selected_provider["type"] = "M3USTREAM" - elif self.type_XTREAM.isChecked(): - self.selected_provider["type"] = "XTREAM" - self.selected_provider["username"] = self.username_input.text() - self.selected_provider["password"] = self.password_input.text() - self.config["selected"] = self.selected_provider_index - self.parent().save_config() - self.parent().load_content() - self.accept() + self.config_manager.check_updates = self.check_updates_checkbox.isChecked() + self.config_manager.max_cache_image_size= int(self.cache_image_size_input.text()) + + need_to_refresh_content_list_size = False + current_provider_changed = False + + if self.config_manager.channel_logos != self.channel_logos_checkbox.isChecked(): + self.config_manager.channel_logos = self.channel_logos_checkbox.isChecked() + need_to_refresh_content_list_size = True + + if self.epg_source_combo.currentText() != self.config_manager.epg_source: + self.config_manager.epg_source = self.epg_source_combo.currentText() + self.epg_settings_modified = True + if self.config_manager.epg_url != self.epg_url_input.text(): + self.config_manager.epg_url = self.epg_url_input.text() + self.epg_settings_modified = True + if self.config_manager.epg_file != self.epg_file_input.text(): + self.config_manager.epg_file = self.epg_file_input.text() + self.epg_settings_modified = True + if self.config_manager.epg_expiration_value != self.epg_expiration_spinner.value(): + self.config_manager.epg_expiration_value = self.epg_expiration_spinner.value() + if self.config_manager.epg_expiration_unit != self.epg_expiration_combo.currentText(): + self.config_manager.epg_expiration_unit = self.epg_expiration_combo.currentText() + + if self.config_manager.selected_provider_name != self.selected_provider_name: + self.config_manager.selected_provider_name = self.selected_provider_name + current_provider_changed = True + + # Save the configuration + self.parent().save_config() + + if self.providers_modified: + self.provider_manager.save_providers() + + if current_provider_changed: + self.parent().set_provider() + elif self.epg_settings_modified: + self.epg_manager.set_current_epg() + self.parent().refresh_channels() + elif self.xmltv_mapping_modified: + if self.config_manager.epg_source != "STB": + self.epg_manager.reindex_programs() + + if need_to_refresh_content_list_size: + self.parent().refresh_content_list_size() + + self.accept() + + def get_cache_size(self): + cache_dir = self.parent().get_cache_directory() + total_size = 0 + for dirpath, dirnames, filenames in os.walk(cache_dir): + for f in filenames: + fp = os.path.join(dirpath, f) + total_size += os.path.getsize(fp) + return total_size / (1024 * 1024) # Convert to MB def load_file(self): file_dialog = QFileDialog(self) @@ -187,14 +487,14 @@ def verify_provider(self): url = self.url_input.text() if self.type_STB.isChecked(): - result = self.parent().do_handshake(url, self.mac_input.text(), load=False) + result = self.provider_manager.do_handshake(url, self.mac_input.text()) elif self.type_M3UPLAYLIST.isChecked() or self.type_M3USTREAM.isChecked(): if url.startswith(("http://", "https://")): result = self.parent().verify_url(url) else: result = os.path.isfile(url) elif self.type_XTREAM.isChecked(): - result = self.parent().verify_url(url) + result = self.verify_url(url) self.verify_result.setText( "Provider verified successfully." @@ -202,3 +502,182 @@ def verify_provider(self): else "Failed to verify provider." ) self.verify_result.setStyleSheet("color: green;" if result else "color: red;") + + def apply_provider(self): + if self.edited_provider: + self.edited_provider["name"] = self.name_input.text() + self.edited_provider["url"] = self.url_input.text() + if not self.edited_provider["name"]: + self.edited_provider["name"] = self.edited_provider["url"] + if self.type_STB.isChecked(): + self.edited_provider["type"] = "STB" + self.edited_provider["mac"] = self.mac_input.text() + elif self.type_M3UPLAYLIST.isChecked(): + self.edited_provider["type"] = "M3UPLAYLIST" + elif self.type_M3USTREAM.isChecked(): + self.edited_provider["type"] = "M3USTREAM" + elif self.type_XTREAM.isChecked(): + self.edited_provider["type"] = "XTREAM" + self.edited_provider["username"] = self.username_input.text() + self.edited_provider["password"] = self.password_input.text() + self.selected_provider_name = self.edited_provider["name"] + self.provider_combo.setItemText( + self.selected_provider_index, + f"{self.selected_provider_index + 1}: {self.edited_provider['name']}", + ) + self.providers_modified = True + + def clear_image_cache(self): + self.parent().image_manager.clear_cache() + self.cache_image_size_label = QLabel(f"Max size of image cache (actual size: {self.get_cache_image_size():.2f} MB)", self.settings_tab) + + def get_cache_image_size(self): + total_size = self.parent().image_manager.current_cache_size + return total_size / (1024 * 1024) # Convert to MB + + def on_check_updates_toggled(self): + if self.check_updates_checkbox.isChecked(): + check_for_updates() + + def load_xmltv_channel_mapping(self): + self.xmltv_mapping_table.setRowCount(len(self.config_manager.xmltv_channel_map)) + for row_position, (key, value) in enumerate(self.config_manager.xmltv_channel_map.items()): + self.xmltv_mapping_table.setItem(row_position, 0, QTableWidgetItem(value["name"])) + self.xmltv_mapping_table.setItem(row_position, 1, QTableWidgetItem(value.get("icon", ""))) + self.xmltv_mapping_table.setItem(row_position, 2, QTableWidgetItem(", ".join(key))) + + def add_xmltv_mapping(self): + dialog = AddXmltvMappingDialog(self) + if dialog.exec() == QDialog.Accepted: + channel_name, logo_url, channel_ids = dialog.get_data() + + if not channel_name or not channel_ids: + # Show an error message if the input is invalid + error_dialog = QDialog(self) + error_dialog.setWindowTitle("Error") + error_layout = QVBoxLayout(error_dialog) + error_label = QLabel("Channel Name and Channel IDs are required.", error_dialog) + error_layout.addWidget(error_label) + error_button = QPushButton("OK", error_dialog) + error_button.clicked.connect(error_dialog.accept) + error_layout.addWidget(error_button) + error_dialog.exec() + return + + # Split the Channel IDs by comma and strip any extra whitespace + channel_ids_list = [id.strip() for id in channel_ids.split(",")] + + # Add the new mapping to the config manager + self.config_manager.xmltv_channel_map[tuple(channel_ids_list)] = { + "name": channel_name, + "icon": logo_url + } + + # Refresh the XMLTV mapping table + self.xmltv_mapping_modified = True + self.load_xmltv_channel_mapping() + + def edit_xmltv_mapping(self): + # Assuming you have a way to get the selected mapping's current values + selected_items = self.xmltv_mapping_table.selectedItems() + if not selected_items: + return + + row = selected_items[0].row() + current_channel_name = self.xmltv_mapping_table.item(row, 0).text() + current_logo_url = self.xmltv_mapping_table.item(row, 1).text() + current_channel_ids = self.xmltv_mapping_table.item(row, 2).text() + + dialog = AddXmltvMappingDialog( + self, + channel_name=current_channel_name, + logo_url=current_logo_url, + channel_ids=current_channel_ids + ) + if dialog.exec() == QDialog.Accepted: + channel_name, logo_url, channel_ids = dialog.get_data() + + if not channel_name or not channel_ids: + # Show an error message if the input is invalid + error_dialog = QDialog(self) + error_dialog.setWindowTitle("Error") + error_layout = QVBoxLayout(error_dialog) + error_label = QLabel("Channel Name and Channel IDs are required.", error_dialog) + error_layout.addWidget(error_label) + error_button = QPushButton("OK", error_dialog) + error_button.clicked.connect(error_dialog.accept) + error_layout.addWidget(error_button) + error_dialog.exec() + return + + # Split the Channel IDs by comma and strip any extra whitespace + channel_ids_list = [id.strip() for id in channel_ids.split(",")] + + # Update the existing mapping in the config manager + key_tuple = tuple(current_channel_ids.split(",")) + del self.config_manager.xmltv_channel_map[key_tuple[0].strip()] + self.config_manager.xmltv_channel_map[tuple(channel_ids_list)] = { + "name": channel_name, + "icon": logo_url + } + + # Refresh the XMLTV mapping table + self.xmltv_mapping_modified = True + self.load_xmltv_channel_mapping() + + def delete_xmltv_mapping(self): + selected_items = self.xmltv_mapping_table.selectedItems() + if not selected_items: + return + + self.xmltv_mapping_modified = True + rows = {item.row() for item in selected_items} + keys = [self.xmltv_mapping_table.item(row, 2).text() for row in rows] + for key in keys: + self.config_manager.xmltv_channel_map.pop(tuple(key.split(","))[0].strip(), None) + + self.load_xmltv_channel_mapping() + + def import_xmltv_mapping(self): + file_dialog = QFileDialog(self) + file_path, _ = file_dialog.getOpenFileName() + if file_path: + try: + with open(file_path, "r", encoding="utf-8") as f: + list_channels = json.loads(f.read()) + if list_channels is not None: + multiKey = MultiKeyDict() + for k,v in list_channels.items(): + xmltv_ids = v.get("xmltv_id", []) + if xmltv_ids: + v.pop("xmltv_id") + multiKey[tuple(xmltv_ids)] = v + self.config_manager.xmltv_channel_map = multiKey + self.load_xmltv_channel_mapping() + self.xmltv_mapping_modified = True + except (FileNotFoundError, json.JSONDecodeError): + pass + + def export_xmltv_mapping(self): + file_dialog = QFileDialog(self) + file_path, _ = file_dialog.getSaveFileName() + if file_path: + with open(file_path if file_path.endswith(".json") else file_path + ".json", "w", encoding="utf-8") as f: + export = {} + for k,v in self.config_manager.xmltv_channel_map.items(): + mainKey = k[0].strip() + export[mainKey] = v + export[mainKey]["xmltv_id"] = list(k) + f.write(json.dumps(export, option=json.OPT_INDENT_2).decode("utf-8")) + + @staticmethod + def verify_url(url): + if url.startswith(("http://", "https://")): + try: + response = requests.head(url, timeout=5) + return response.status_code == 200 + except requests.RequestException as e: + print(f"Error verifying URL: {e}") + return False + else: + return os.path.isfile(url) \ No newline at end of file diff --git a/provider_manager.py b/provider_manager.py new file mode 100644 index 0000000..d8d5a8b --- /dev/null +++ b/provider_manager.py @@ -0,0 +1,175 @@ +import os +import hashlib +import random +import string +import requests +import tzlocal +import orjson as json +from urlobject import URLObject +from urllib.parse import urlencode +from PySide6.QtCore import QObject, Signal + +class ProviderManager(QObject): + progress = Signal(str) + + def __init__(self, config_manager): + super().__init__() + self.config_manager = config_manager + self.provider_dir = os.path.join(config_manager.get_config_dir(), 'cache', 'provider') + os.makedirs(self.provider_dir, exist_ok=True) + self.index_file = os.path.join(self.provider_dir, 'index.json') + self.providers = [] + self.current_provider = {} + self.current_provider_content = {} + self.token = "" + self.headers = {} + self._load_providers() + + def _current_provider_cache_name(self): + hashed_name = hashlib.sha256(self.current_provider["name"].encode('utf-8')).hexdigest() + return os.path.join(self.provider_dir, f"{hashed_name}.json") + + def _load_providers(self): + try: + with open(self.index_file, "r", encoding="utf-8") as f: + self.providers = json.loads(f.read()) + if self.providers is None: + self.providers = self.default_providers() + except (FileNotFoundError, json.JSONDecodeError): + self.providers = self.default_providers() + self.save_providers() + + def set_current_provider(self, progress_callback): + progress_callback.emit("Searching for provider...") + # search for provider in the list + if self.config_manager.selected_provider_name: + for provider in self.providers: + if provider["name"] == self.config_manager.selected_provider_name: + self.current_provider = provider + break + + # if provider not found, set the first one + if not self.current_provider: + self.current_provider = self.providers[0] + + progress_callback.emit("Loading provider content...") + try: + with open(self._current_provider_cache_name(), "r", encoding="utf-8") as f: + self.current_provider_content = json.loads(f.read()) + except (FileNotFoundError, json.JSONDecodeError): + self.current_provider_content = {} + + if self.current_provider["type"] == "STB": + progress_callback.emit("Performing handshake...") + self.token = "" + self.do_handshake(self.current_provider["url"], self.current_provider["mac"]) + + progress_callback.emit("Provider setup complete.") + + def save_providers(self): + serialized = json.dumps(self.providers, option=json.OPT_INDENT_2) + with open(self.index_file, "w", encoding="utf-8") as f: + f.write(serialized.decode("utf-8")) + + # Delete provider files not in the providers list + for provider in os.listdir(self.provider_dir): + if provider == "index.json": + continue + if provider not in self.providers: + os.remove(os.path.join(self.provider_dir, provider)) + + def save_provider(self): + serialized = json.dumps(self.current_provider_content, option=json.OPT_INDENT_2) + with open(self._current_provider_cache_name(), "w", encoding="utf-8") as f: + f.write(serialized.decode("utf-8")) + + def do_handshake(self, url, mac, serverload="/portal.php"): + self.token = self.token if self.token else self.random_token() + self.headers = self.create_headers(url, mac, self.token) + try: + prehash = "2614ddf9829ba9d284f389d88e8c669d81f6a5c2" + fetchurl = f"{url}{serverload}?type=stb&action=handshake&prehash={prehash}&token=&JsHttpRequest=1-xml" + handshake = requests.get(fetchurl, timeout=5, headers=self.headers) + if handshake.status_code == 200: + body = handshake.json() + else: + raise Exception(f"Failed to fetch handshake: {handshake.status_code}") + self.token = body["js"]["token"] + self.headers["Authorization"] = f"Bearer {self.token}" + + # Use get_profile request to detect blocked providers + + params = { + "ver": "ImageDescription: 2.20.02-pub-424; ImageDate: Fri May 8 15:39:55 UTC 2020; PORTAL version: 5.3.0; API Version: JS API version: 343; STB API version: 146; Player Engine version: 0x588", + "num_banks" : "2", + "sn": "062014N067770", + "stb_type": "MAG424", + "client_type": "STB", + "image_version":"220", + "video_out": "hdmi", + "device_id": "", + "device_id2": "", + "signature": "", + "auth_second_step": "1", + "hw_version": "1.7-BD-00", + "not_valid_token": "0", + "metrics": f'{{"mac":"{mac}", "sn":"062014N067770","model":"MAG424","type":"STB","uid":"","random":""}}', + "hw_version_2": "bb8b74cdcaa19c7f6a6bdfecc8e91b7e4b5ea556", + "timestamp": "1729441259", + "api_signature": "262", + "prehash": {prehash}, + } + encoded_params = urlencode(params) + + fetchurl = f'{url}{serverload}?type=stb&action=get_profile&hd=1&{encoded_params}&JsHttpRequest=1-xml' + profile = requests.get(fetchurl, timeout=5, headers=self.headers) + if profile.status_code == 200: + body = profile.json() + else: + raise Exception(f"Failed to fetch profile: {profile.status_code}") + + theId = body["js"]["id"] + theName = body["js"]["name"] + if not theId and not theName: + raise Exception("Provider is blocked") + + return True + except Exception as e: + if serverload != "/server/load.php" and 'handshake' in fetchurl: + serverload = "/server/load.php" + return self.do_handshake(url, mac, serverload) + print("Error in handshake:", e) + return False + + + @staticmethod + def default_providers(): + return [ + { + "type": "M3UPLAYLIST", + "name": "iptv-org.github.io", + "url": "https://iptv-org.github.io/iptv/index.m3u", + } + ] + + @staticmethod + def random_token(): + return "".join(random.choices(string.ascii_letters + string.digits, k=32)) + + @staticmethod + def create_headers(url, mac, token): + url = URLObject(url) + timezone = tzlocal.get_localzone().key + headers = { + "User-Agent": "Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG200 stbapp ver: 2 rev: 250 Safari/533.3", + "Accept-Charset": "UTF-8,*;q=0.8", + "X-User-Agent": "Model: MAG200; Link: Ethernet", + "Host": f"{url.netloc}", + "Range": "bytes=0-", + "Accept": "*/*", + "Referer": f"{url}/c/" if not url.path else f"{url}/", + "Cookie": f"mac={mac}; stb_lang=en; timezone={timezone}; PHPSESSID=null;", + "Authorization": f"Bearer {token}", + } + return headers + diff --git a/requirements.txt b/requirements.txt index fe008e6..e411fb5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,5 @@ m3u-parser pyqtdarktheme==2.1.0 PySide6 orjson +tzlocal aiohttp[speedups] \ No newline at end of file diff --git a/video_player.py b/video_player.py index 9192d08..5b6c89b 100644 --- a/video_player.py +++ b/video_player.py @@ -3,8 +3,8 @@ import sys import vlc -from PySide6.QtCore import QEvent, QMetaObject, QPoint, Qt, QTimer, Slot -from PySide6.QtGui import QCursor, QGuiApplication +from PySide6.QtCore import QMetaObject, QPoint, Qt, QTimer, Slot, Signal +from PySide6.QtGui import QGuiApplication from PySide6.QtWidgets import QFrame, QMainWindow, QProgressBar, QVBoxLayout logging.basicConfig(level=logging.ERROR) @@ -23,6 +23,9 @@ def get_latest_error(self): class VideoPlayer(QMainWindow): + playing = Signal() + stopped = Signal() + def __init__(self, config_manager, *args, **kwargs): super(VideoPlayer, self).__init__(*args, **kwargs) self.config_manager = config_manager @@ -207,7 +210,8 @@ def mouseDoubleClickEvent(self, event): def closeEvent(self, event): if self.media_player.is_playing(): self.media_player.stop() - self.config_manager.save_window_settings(self.geometry(), "video_player") + self.stopped.emit() + self.config_manager.save_window_settings(self, "video_player") self.hide() event.ignore() @@ -234,20 +238,25 @@ def play_video(self, video_url): else: self.adjust_aspect_ratio() self.show() + self.playing.emit() QTimer.singleShot(5000, self.check_playback_status) def check_playback_status(self): - if not self.media_player.is_playing(): - media_state = self.media.get_state() - if media_state == vlc.State.Error: - self.handle_error("Playback error") - else: - self.handle_error("Failed to start playback") + state = self.media_player.get_state() + if state == vlc.State.Playing: # only check if media has not been paused, or stopped + if not self.media_player.is_playing(): + media_state = self.media.get_state() + if media_state == vlc.State.Error: + self.handle_error("Playback error") + else: + self.handle_error("Failed to start playback") + self.stopped.emit() def stop_video(self): self.media_player.stop() self.progress_bar.setVisible(False) self.update_timer.stop() + self.stopped.emit() def toggle_mute(self): state = self.media_player.audio_get_mute() From 145a9fdb1abb3651093accbb0da805993e739f8b Mon Sep 17 00:00:00 2001 From: pcjco Date: Sun, 10 Nov 2024 09:56:50 +0100 Subject: [PATCH 3/7] Fix verify_url Sync with main branch --- channel_list.py | 4 +--- options.py | 2 +- video_player.py | 7 ++++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/channel_list.py b/channel_list.py index 6ff3f72..64ee617 100644 --- a/channel_list.py +++ b/channel_list.py @@ -846,9 +846,7 @@ def refresh_content_list_size(self): QTreeWidget {{ font-size: {font_size}px; }} """) - # Set main font (specific Qt font compatible with tiny size) - font = QFont("Tahoma") - + font = QFont() font.setPointSize(font_size) self.content_list.setFont(font) diff --git a/options.py b/options.py index 3b79aea..678fbb1 100644 --- a/options.py +++ b/options.py @@ -490,7 +490,7 @@ def verify_provider(self): result = self.provider_manager.do_handshake(url, self.mac_input.text()) elif self.type_M3UPLAYLIST.isChecked() or self.type_M3USTREAM.isChecked(): if url.startswith(("http://", "https://")): - result = self.parent().verify_url(url) + result = self.verify_url(url) else: result = os.path.isfile(url) elif self.type_XTREAM.isChecked(): diff --git a/video_player.py b/video_player.py index 5b6c89b..a6ff865 100644 --- a/video_player.py +++ b/video_player.py @@ -59,12 +59,11 @@ def __init__(self, config_manager, *args, **kwargs): t_lay_parent.addWidget(self.video_frame) # Custom user-agent string - user_agent = "Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG200 stbapp ver: 2 rev: 250 Safari/533.3" self.vlc_logger = VLCLogger() # Initialize VLC instance self.instance = vlc.Instance( - ["--video-on-top", f"--http-user-agent={user_agent}"] + ["--video-on-top"] ) # vlc.Instance(["--verbose=2"]) # Enable verbose logging self.media_player = self.instance.media_player_new() @@ -130,12 +129,14 @@ def update_progress(self): current_time = self.media_player.get_time() total_time = self.media.get_duration() - if total_time > 0: + if current_time > 0: # let's give the control after play + self.progress_bar.setVisible(True) formatted_current = self.format_time(current_time) formatted_total = self.format_time(total_time) self.progress_bar.setFormat(f"{formatted_current} / {formatted_total}") self.progress_bar.setValue(int(current_time * 1000 / total_time)) else: + self.progress_bar.setVisible(False) self.progress_bar.setFormat("Live") self.progress_bar.setValue(0) elif state == vlc.State.Error: From 58e68cb598f7376bdb2685b9e1a78cb381ebd338 Mon Sep 17 00:00:00 2001 From: ozankaraali Date: Sat, 16 Nov 2024 22:23:52 +0100 Subject: [PATCH 4/7] fixes live stream detection, versioning by string, provider context initialization?, indentation? also why we have param remove? why do we disconnect more than once? prehook tzlocal why do we have duplicate definitions in channel_list: channel_list.py:962: error: Name "can_show_content_info" already defined on line 599 [no-redef] channel_list.py:969: error: Name "create_content_info_panel" already defined on line 617 [no-redef] channel_list.py:974: error: Name "setup_movie_tvshow_content_info" already defined on line 622 [no-redef] channel_list.py:985: error: Name "clear_content_info_panel" already defined on line 661 [no-redef] channel_list.py:1007: error: Name "clear_layout" already defined on line 698 [no-redef] channel_list.py:1016: error: Name "switch_content_info_panel" already defined on line 708 [no-redef] channel_list.py:1034: error: Name "populate_movie_tvshow_content_info" already defined on line 893 [no-redef] channel_list.py:1059: error: Name "show_favorite_layout" already defined on line 956 [no-redef] --- .pre-commit-config.yaml | 1 + config_manager.py | 63 +++++++++++++++++++++++++++++++---------- content_loader.py | 26 +++++++++-------- provider_manager.py | 55 ++++++++++++++++++++--------------- update_checker.py | 5 ++-- video_player.py | 10 ++++--- 6 files changed, 103 insertions(+), 57 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c83811e..a1cb1a1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,6 +30,7 @@ repos: - id: mypy additional_dependencies: - types-requests + - types-tzlocal exclude: ^.*\b(migrations)\b.*$ - repo: https://github.com/pycqa/isort rev: 5.13.2 diff --git a/config_manager.py b/config_manager.py index e490155..e5de85d 100644 --- a/config_manager.py +++ b/config_manager.py @@ -1,12 +1,14 @@ import os import platform import shutil -from multikeydict import MultiKeyDict + import orjson as json +from multikeydict import MultiKeyDict + class ConfigManager: - CURRENT_VERSION = "1.5.9" # Set your current version here + CURRENT_VERSION = "1.6.0.dev1" # Set your current version here DEFAULT_OPTION_CHECKUPDATE = True DEFAULT_OPTION_STB_CONTENT_INFO = False @@ -94,7 +96,9 @@ def update_patcher(self): # add max_cache_image_size to the loaded config if it doesn't exist if "max_cache_image_size" not in self.config: - self.max_cache_image_size = ConfigManager.DEFAULT_OPTION_MAX_CACHE_IMAGE_SIZE + self.max_cache_image_size = ( + ConfigManager.DEFAULT_OPTION_MAX_CACHE_IMAGE_SIZE + ) need_update = True # add epg_source to the loaded config if it doesn't exist @@ -114,7 +118,9 @@ def update_patcher(self): # add epg_expiration_value to the loaded config if it doesn't exist if "epg_expiration_value" not in self.config: - self.epg_expiration_value = ConfigManager.DEFAULT_OPTION_EPG_EXPIRATION_VALUE + self.epg_expiration_value = ( + ConfigManager.DEFAULT_OPTION_EPG_EXPIRATION_VALUE + ) need_update = True # add epg_expiration_unit to the loaded config if it doesn't exist @@ -132,7 +138,9 @@ def update_patcher(self): @property def check_updates(self): - return self.config.get("check_updates", ConfigManager.DEFAULT_OPTION_CHECKUPDATE) + return self.config.get( + "check_updates", ConfigManager.DEFAULT_OPTION_CHECKUPDATE + ) @check_updates.setter def check_updates(self, value): @@ -148,7 +156,9 @@ def favorites(self, value): @property def show_stb_content_info(self): - return self.config.get("show_stb_content_info", ConfigManager.DEFAULT_OPTION_STB_CONTENT_INFO) + return self.config.get( + "show_stb_content_info", ConfigManager.DEFAULT_OPTION_STB_CONTENT_INFO + ) @show_stb_content_info.setter def show_stb_content_info(self, value): @@ -172,7 +182,9 @@ def channel_epg(self, value): @property def channel_logos(self): - return self.config.get("channel_logos", ConfigManager.DEFAULT_OPTION_CHANNEL_LOGO) + return self.config.get( + "channel_logos", ConfigManager.DEFAULT_OPTION_CHANNEL_LOGO + ) @channel_logos.setter def channel_logos(self, value): @@ -180,7 +192,9 @@ def channel_logos(self, value): @property def max_cache_image_size(self): - return self.config.get("max_cache_image_size", ConfigManager.DEFAULT_OPTION_MAX_CACHE_IMAGE_SIZE) + return self.config.get( + "max_cache_image_size", ConfigManager.DEFAULT_OPTION_MAX_CACHE_IMAGE_SIZE + ) @max_cache_image_size.setter def max_cache_image_size(self, value): @@ -212,7 +226,9 @@ def epg_file(self, value): @property def epg_expiration_value(self): - return self.config.get("epg_expiration_value", ConfigManager.DEFAULT_OPTION_EPG_EXPIRATION_VALUE) + return self.config.get( + "epg_expiration_value", ConfigManager.DEFAULT_OPTION_EPG_EXPIRATION_VALUE + ) @epg_expiration_value.setter def epg_expiration_value(self, value): @@ -220,7 +236,9 @@ def epg_expiration_value(self, value): @property def epg_expiration_unit(self): - return self.config.get("epg_expiration_unit", ConfigManager.DEFAULT_OPTION_EPG_EXPIRATION_UNIT) + return self.config.get( + "epg_expiration_unit", ConfigManager.DEFAULT_OPTION_EPG_EXPIRATION_UNIT + ) @epg_expiration_unit.setter def epg_expiration_unit(self, value): @@ -230,7 +248,9 @@ def epg_expiration_unit(self, value): def epg_expiration(self): # Get expiration in seconds if self.epg_expiration_unit == "Months": - return self.epg_expiration_value * 30 * 24 * 60 * 60 # Approximate month as 30 days + return ( + self.epg_expiration_value * 30 * 24 * 60 * 60 + ) # Approximate month as 30 days elif self.epg_expiration_unit == "Days": return self.epg_expiration_value * 24 * 60 * 60 elif self.epg_expiration_unit == "Hours": @@ -261,7 +281,14 @@ def default_config(): } ], "window_positions": { - "channel_list": {"x": 1250, "y": 100, "width": 400, "height": 800, "splitter_ratio": 0.75, "splitter_content_info_ratio": 0.33}, + "channel_list": { + "x": 1250, + "y": 100, + "width": 400, + "height": 800, + "splitter_ratio": 0.75, + "splitter_content_info_ratio": 0.33, + }, "video_player": {"x": 50, "y": 100, "width": 1200, "height": 800}, }, "favorites": [], @@ -281,8 +308,12 @@ def save_window_settings(self, window, window_name): "height": pos.height(), } if window_name == "channel_list": - self.config["window_positions"][window_name]["splitter_ratio"] = window.splitter_ratio - self.config["window_positions"][window_name]["splitter_content_info_ratio"] = window.splitter_content_info_ratio + self.config["window_positions"][window_name][ + "splitter_ratio" + ] = window.splitter_ratio + self.config["window_positions"][window_name][ + "splitter_content_info_ratio" + ] = window.splitter_content_info_ratio self.save_config() @@ -293,7 +324,9 @@ def apply_window_settings(self, window_name, window): ) if window_name == "channel_list": window.splitter_ratio = settings.get("splitter_ratio", 0.75) - window.splitter_content_info_ratio = settings.get("splitter_content_info_ratio", 0.33) + window.splitter_content_info_ratio = settings.get( + "splitter_content_info_ratio", 0.33 + ) def save_config(self): self.xmltv_channel_map = self.xmltv_channel_map.serialize() diff --git a/content_loader.py b/content_loader.py index a2e1db5..6e119e7 100644 --- a/content_loader.py +++ b/content_loader.py @@ -1,9 +1,11 @@ +import asyncio import random + import aiohttp -import asyncio import orjson as json from PySide6.QtCore import QThread, Signal + class ContentLoader(QThread): content_loaded = Signal(dict) progress_updated = Signal(int, int) @@ -33,7 +35,7 @@ def __init__( self.season_id = season_id self.action = action self.sortby = sortby - self.period= period + self.period = period self.ch_id = ch_id self.size = size self.items = [] @@ -97,8 +99,8 @@ def get_params(self, page): # remove unnecessary params params.pop("p") elif self.action == "get_epg_info": - params.update( - { + params.update( + { "period": self.period, } ) @@ -107,13 +109,13 @@ def get_params(self, page): else: params.update( { - "genre": self.category_id if self.category_id else "*", - "force_ch_link_check": "", - "fav": "0", - "sortby": self.sortby, - "hd": "0", - } - ) + "genre": self.category_id if self.category_id else "*", + "force_ch_link_check": "", + "fav": "0", + "sortby": self.sortby, + "hd": "0", + } + ) elif self.content_type == "vod": params.update( { @@ -148,7 +150,7 @@ async def load_content(self): self.items.append(page_items) if max_page_items: - pages = (total_items + max_page_items - 1) // max_page_items + pages = (total_items + max_page_items - 1) // max_page_items else: pages = 0 diff --git a/provider_manager.py b/provider_manager.py index d91f769..1629d50 100644 --- a/provider_manager.py +++ b/provider_manager.py @@ -1,29 +1,36 @@ -import os import hashlib +import os import random import string +from urllib.parse import urlencode + +import orjson as json import requests import tzlocal -import orjson as json -from urlobject import URLObject -from urllib.parse import urlencode from PySide6.QtCore import QObject, Signal +from urlobject import URLObject + class ProviderContext: def __init__(self): self.provider_url = None self.headers = None + class ProviderManager(QObject): progress = Signal(str) def __init__(self, config_manager): super().__init__() self.config_manager = config_manager - self.provider_context = provider_context - self.provider_dir = os.path.join(config_manager.get_config_dir(), 'cache', 'provider') + self.provider_context = ( + ProviderContext() + ) # is it the proper way to initialize the object? what was it before, no initialization? + self.provider_dir = os.path.join( + config_manager.get_config_dir(), "cache", "provider" + ) os.makedirs(self.provider_dir, exist_ok=True) - self.index_file = os.path.join(self.provider_dir, 'index.json') + self.index_file = os.path.join(self.provider_dir, "index.json") self.providers = [] self.current_provider = {} self.current_provider_content = {} @@ -32,7 +39,9 @@ def __init__(self, config_manager): self._load_providers() def _current_provider_cache_name(self): - hashed_name = hashlib.sha256(self.current_provider["name"].encode('utf-8')).hexdigest() + hashed_name = hashlib.sha256( + self.current_provider["name"].encode("utf-8") + ).hexdigest() return os.path.join(self.provider_dir, f"{hashed_name}.json") def _update_provider_context(self): @@ -77,7 +86,9 @@ def set_current_provider(self, progress_callback): if self.current_provider["type"] == "STB": progress_callback.emit("Performing handshake...") self.token = "" - self.do_handshake(self.current_provider["url"], self.current_provider["mac"]) + self.do_handshake( + self.current_provider["url"], self.current_provider["mac"] + ) progress_callback.emit("Provider setup complete.") @@ -113,14 +124,14 @@ def do_handshake(self, url, mac, serverload="/portal.php"): self.headers["Authorization"] = f"Bearer {self.token}" # Use get_profile request to detect blocked providers - + params = { "ver": "ImageDescription: 2.20.02-pub-424; ImageDate: Fri May 8 15:39:55 UTC 2020; PORTAL version: 5.3.0; API Version: JS API version: 343; STB API version: 146; Player Engine version: 0x588", - "num_banks" : "2", + "num_banks": "2", "sn": "062014N067770", "stb_type": "MAG424", "client_type": "STB", - "image_version":"220", + "image_version": "220", "video_out": "hdmi", "device_id": "", "device_id2": "", @@ -136,7 +147,7 @@ def do_handshake(self, url, mac, serverload="/portal.php"): } encoded_params = urlencode(params) - fetchurl = f'{url}{serverload}?type=stb&action=get_profile&hd=1&{encoded_params}&JsHttpRequest=1-xml' + fetchurl = f"{url}{serverload}?type=stb&action=get_profile&hd=1&{encoded_params}&JsHttpRequest=1-xml" profile = requests.get(fetchurl, timeout=5, headers=self.headers) if profile.status_code == 200: body = profile.json() @@ -144,28 +155,27 @@ def do_handshake(self, url, mac, serverload="/portal.php"): raise Exception(f"Failed to fetch profile: {profile.status_code}") theId = body["js"]["id"] - theName = body["js"]["name"] + theName = body["js"]["name"] if not theId and not theName: raise Exception("Provider is blocked") return True except Exception as e: - if serverload != "/server/load.php" and 'handshake' in fetchurl: + if serverload != "/server/load.php" and "handshake" in fetchurl: serverload = "/server/load.php" return self.do_handshake(url, mac, serverload) print("Error in handshake:", e) return False - @staticmethod def default_providers(): return [ - { - "type": "M3UPLAYLIST", - "name": "iptv-org.github.io", - "url": "https://iptv-org.github.io/iptv/index.m3u", - } - ] + { + "type": "M3UPLAYLIST", + "name": "iptv-org.github.io", + "url": "https://iptv-org.github.io/iptv/index.m3u", + } + ] @staticmethod def random_token(): @@ -187,4 +197,3 @@ def create_headers(url, mac, token): "Authorization": f"Bearer {token}", } return headers - diff --git a/update_checker.py b/update_checker.py index ec5b810..7a6216d 100644 --- a/update_checker.py +++ b/update_checker.py @@ -1,4 +1,5 @@ import requests +from packaging.version import parse from PySide6.QtWidgets import QMessageBox from config_manager import ConfigManager @@ -35,9 +36,7 @@ def extract_version_from_tag(tag): def compare_versions(latest_version, current_version): - latest_version_parts = list(map(int, latest_version.split("."))) - current_version_parts = list(map(int, current_version.split("."))) - return latest_version_parts > current_version_parts + return parse(latest_version) > parse(current_version) def show_update_dialog(latest_version, release_url): diff --git a/video_player.py b/video_player.py index a6ff865..896ed5b 100644 --- a/video_player.py +++ b/video_player.py @@ -3,7 +3,7 @@ import sys import vlc -from PySide6.QtCore import QMetaObject, QPoint, Qt, QTimer, Slot, Signal +from PySide6.QtCore import QMetaObject, QPoint, Qt, QTimer, Signal, Slot from PySide6.QtGui import QGuiApplication from PySide6.QtWidgets import QFrame, QMainWindow, QProgressBar, QVBoxLayout @@ -128,8 +128,8 @@ def update_progress(self): if state == vlc.State.Playing: current_time = self.media_player.get_time() total_time = self.media.get_duration() - - if current_time > 0: # let's give the control after play + # if we have current time, but if current time is bigger than total time then it is live stream so we go to else + if current_time > 0 and current_time < total_time: self.progress_bar.setVisible(True) formatted_current = self.format_time(current_time) formatted_total = self.format_time(total_time) @@ -244,7 +244,9 @@ def play_video(self, video_url): def check_playback_status(self): state = self.media_player.get_state() - if state == vlc.State.Playing: # only check if media has not been paused, or stopped + if ( + state == vlc.State.Playing + ): # only check if media has not been paused, or stopped if not self.media_player.is_playing(): media_state = self.media.get_state() if media_state == vlc.State.Error: From 1811dd0cc409bcc1f9aabc8e5002ce079e689c93 Mon Sep 17 00:00:00 2001 From: pcjco Date: Mon, 18 Nov 2024 20:29:28 +0100 Subject: [PATCH 5/7] Some fixes for https://github.com/ozankaraali/QiTV/pull/23 --- channel_list.py | 256 +++++++++++++++++--------------------------- content_loader.py | 45 ++++++-- image_loader.py | 4 +- provider_manager.py | 25 ++--- 4 files changed, 142 insertions(+), 188 deletions(-) diff --git a/channel_list.py b/channel_list.py index 8be64b4..e47b21b 100644 --- a/channel_list.py +++ b/channel_list.py @@ -1,10 +1,12 @@ +import base64 +import html import os import platform import re +import requests import shutil import subprocess import time -import base64 from urllib.parse import urlparse from content_loader import ContentLoader from image_loader import ImageLoader @@ -165,7 +167,7 @@ def __init__(self): def paint(self, painter, inOption, index): col = index.column() - if col == 2: + if col == 2: # EPG program progress progress = index.data(Qt.UserRole) if not progress is None: options = QStyleOptionViewItem(inOption) @@ -180,7 +182,7 @@ def paint(self, painter, inOption, index): style.drawControl(QStyle.CE_ProgressBar, opt, painter, inOption.widget) else: super().paint(painter, inOption, index) - elif col == 3: + elif col == 3: # EPG program name epg_text = index.data(Qt.UserRole) if epg_text: options = QStyleOptionViewItem(inOption) @@ -193,6 +195,23 @@ def paint(self, painter, inOption, index): else: super().paint(painter, inOption, index) + def sizeHint( self, inOption, index ): + col = index.column() + if col == 2: + return QSize(100, 20) + elif col == 3: + options = QStyleOptionViewItem(inOption) + self.initStyleOption(options, index) + style = options.widget.style() if options.widget else QApplication.style() + text = index.data(Qt.UserRole) + font = options.font + if not font: + font = style.font(QStyle.CE_ItemViewItem, options, index) + metrics = QFontMetrics(font) + return QSize(metrics.boundingRect(text).width(), metrics.height()) + + return super().sizeHint(inOption, index) + class SetProviderThread(QThread): progress = Signal(str) @@ -228,6 +247,7 @@ def __init__(self, app, player, config_manager, provider_manager, image_manager, self.setCentralWidget(self.container_widget) self.content_type = "itv" # Default to channels (STB type) + self.current_list_content = None self.content_info_show = None self.create_upper_panel() @@ -296,7 +316,7 @@ def refresh_on_air(self): item_data = item.data(0, Qt.UserRole) content_type = item_data.get("type") - if self.can_show_epg(content_type) and self.config_manager.channel_epg: + if self.config_manager.channel_epg and self.can_show_epg(content_type): epg_data = self.epg_manager.get_programs_for_channel(item_data["data"], None, 1) if epg_data: epg_item = epg_data[0] @@ -328,6 +348,9 @@ def set_provider(self, force_update=False): self.lock_ui_before_loading() self.progress_bar.setRange(0, 0) # busy indicator + if force_update: + self.provider_manager.clear_current_provider_cache() + self.set_provider_thread = SetProviderThread(self.provider_manager, self.epg_manager) self.set_provider_thread.progress.connect(self.update_busy_progress) self.set_provider_thread.finished.connect(lambda: self.set_provider_finished(force_update)) @@ -380,7 +403,7 @@ def create_upper_panel(self): top_layout.addWidget(self.options_button) self.update_button = QPushButton("Update Content") - self.update_button.clicked.connect(lambda: self.set_provider(True)) + self.update_button.clicked.connect(lambda: self.set_provider(force_update=True)) top_layout.addWidget(self.update_button) self.back_button = QPushButton("Back") @@ -477,7 +500,7 @@ def create_list_panel(self): # Add the horizontal layout to the main vertical layout list_layout.addLayout(self.favorite_layout) - + self.progress_bar = QProgressBar(self) self.progress_bar.setRange(0, 100) self.progress_bar.setValue(0) @@ -542,7 +565,7 @@ def can_show_content_info(self, item_type): return item_type in ["movie", "serie", "season", "episode"] and self.provider_manager.current_provider["type"] == "STB" def can_show_epg(self, item_type): - if item_type in ["channel", "content"]: + if item_type in ["channel", "m3ucontent"]: if self.config_manager.epg_source == "No Source": return False if self.config_manager.epg_source == "STB" and self.provider_manager.current_provider["type"] != "STB": @@ -630,7 +653,7 @@ def clear_layout(layout): layout.deleteLater() def switch_content_info_panel(self, item_type): - if item_type in ["channel", "content"]: + if item_type in ["channel", "m3ucontent"]: if self.content_info_shown == "channel": return self.setup_channel_program_content_info() @@ -644,8 +667,11 @@ def switch_content_info_panel(self, item_type): self.splitter.setSizes([int(self.container_widget.height() * self.splitter_ratio), int(self.container_widget.height() * (1 - self.splitter_ratio))]) def populate_channel_programs_content_info(self, item_data): - # Show EPG data for the selected channel + self.program_list.itemSelectionChanged.disconnect() self.program_list.clear() + self.program_list.itemSelectionChanged.connect(self.update_channel_program) + + # Show EPG data for the selected channel epg_data = self.epg_manager.get_programs_for_channel(item_data) if epg_data: # Fill the program list @@ -805,8 +831,6 @@ def populate_movie_tvshow_content_info(self, item_data): content_info_label = { "name": "Title", "rating_imdb": "Rating", - "age": "Age", - "country": "Country", "year": "Year", "genres_str": "Genre", "length": "Length", @@ -862,98 +886,6 @@ def show_favorite_layout(self, show): if item.widget(): item.widget().setVisible(show) - def can_show_content_info(self, item_type): - return self.config_manager.show_stb_content_info and item_type in ["movie", "serie"] and self.provider_manager.current_provider["type"] == "STB" - - def create_content_info_panel(self): - self.content_info_panel = QWidget(self.container_widget) - self.content_info_layout = QVBoxLayout(self.content_info_panel) - self.content_info_panel.setVisible(False) - - def setup_movie_tvshow_content_info(self): - self.clear_content_info_panel() - self.content_info_layout.setContentsMargins(8, 4, 8, 8) - self.content_info_text = QLabel(self.content_info_panel) - self.content_info_text.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Ignored) # Allow to reduce splitter below label minimum size - self.content_info_text.setAlignment(Qt.AlignLeft | Qt.AlignTop) - self.content_info_text.setWordWrap(True) - self.content_info_layout.addWidget(self.content_info_text, 1) - - def clear_content_info_panel(self): - # Clear all widgets from the content_info layout - for i in reversed(range(self.content_info_layout.count())): - widget = self.content_info_layout.itemAt(i).widget() - if widget is not None: - widget.setParent(None) - widget.deleteLater() - - # Clear the layout itself - while self.content_info_layout.count(): - item = self.content_info_layout.takeAt(0) - if item.widget(): - item.widget().deleteLater() - elif item.layout(): - self.clear_layout(item.layout()) - - # Hide the content_info panel if it is visible - if self.content_info_panel.isVisible(): - self.content_info_panel.setVisible(False) - self.splitter.setSizes([1, 0]) - self.main_layout.setContentsMargins(8, 8, 8, 8) - - def clear_layout(self, layout): - while layout.count(): - item = layout.takeAt(0) - if item.widget(): - item.widget().deleteLater() - elif item.layout(): - self.clear_layout(item.layout()) - layout.deleteLater() - - def switch_content_info_panel(self, content_type): - if content_type == "channel": - pass # for program content info - else: - self.setup_movie_tvshow_content_info() - - if not self.content_info_panel.isVisible(): - self.main_layout.setContentsMargins(8, 8, 8, 4) - - # set splitter sizes to show both panels using the splitter_ratio - self.splitter.setSizes([int(self.container_widget.height() * self.splitter_ratio), int(self.container_widget.height() * (1 - self.splitter_ratio))]) - self.content_info_panel.setVisible(True) - - def populate_movie_tvshow_content_info(self, item_data): - content_info_label = { - "name": "Title", - "rating_imdb": "Rating", - "age": "Age", - "country": "Country", - "year": "Year", - "genre_str": "Genre", - "length": "Length", - "director": "Director", - "actors": "Actors", - "description": "Summary" - } - - info = "" - for key, label in content_info_label.items(): - if key in item_data: - value = item_data[key] - # if string, check is not empty and not "na" or "n/a" - if value: - if isinstance(value, str) and value.lower() in ["na", "n/a"]: - continue - info += f"{label}: {value}
" - self.content_info_text.setText(info) - - def show_favorite_layout(self, show): - for i in range(self.favorite_layout.count()): - item = self.favorite_layout.itemAt(i) - if item.widget(): - item.widget().setVisible(show) - def toggle_favorite(self): selected_item = self.content_list.currentItem() if selected_item: @@ -1033,14 +965,16 @@ def display_categories(self, categories, select_first=True): # Stop refreshing content list self.refresh_on_air_timer.stop() + self.current_list_content = "category" + self.content_list.setSortingEnabled(False) self.content_list.setColumnCount(1) if self.content_type == "itv": - self.content_list.setHeaderLabels(["Channel Categories"]) + self.content_list.setHeaderLabels([f"Channel Categories ({len(categories)})"]) elif self.content_type == "vod": - self.content_list.setHeaderLabels(["Movie Categories"]) + self.content_list.setHeaderLabels([f"Movie Categories ({len(categories)})"]) elif self.content_type == "series": - self.content_list.setHeaderLabels(["Serie Categories"]) + self.content_list.setHeaderLabels([f"Serie Categories ({len(categories)})"]) self.show_favorite_layout(True) self.rescanlogo_button.setVisible(False) @@ -1073,24 +1007,21 @@ def display_categories(self, categories, select_first=True): self.content_list.setCurrentItem(previous_selected[0]) self.content_list.scrollToItem(previous_selected[0], QTreeWidget.PositionAtTop) - def display_content(self, items, content_type="content", select_first=True): - # Unregister the content_list selection change event - self.content_list.itemSelectionChanged.disconnect(self.item_selected) - # Unregister the content_list selection change event + def display_content(self, items, content="m3ucontent", select_first=True): + # Unregister the selection change event self.content_list.itemSelectionChanged.disconnect(self.item_selected) self.content_list.clear() - # Re-egister the content_list selection change event - self.content_list.itemSelectionChanged.connect(self.item_selected) self.content_list.setSortingEnabled(False) - # Re-egister the content_list selection change event + # Re-register the selection change event self.content_list.itemSelectionChanged.connect(self.item_selected) # Stop refreshing On Air content self.refresh_on_air_timer.stop() - need_logos = content_type in ["channel", "content"] and self.config_manager.channel_logos + self.current_list_content = content + need_logos = content in ["channel", "m3ucontent"] and self.config_manager.channel_logos logo_urls = [] - use_epg = self.can_show_epg(content_type) and self.config_manager.channel_epg + use_epg = self.can_show_epg(content) and self.config_manager.channel_epg # Define headers for different content types category_header = ( @@ -1105,7 +1036,7 @@ def display_content(self, items, content_type="content", select_first=True): header_info = { "serie": { "headers": [ - self.shorten_header(f"{category_header} > Series"), + self.shorten_header(f"{category_header} > Series ({len(items)})"), "Genre", "Added", ], @@ -1113,7 +1044,7 @@ def display_content(self, items, content_type="content", select_first=True): }, "movie": { "headers": [ - self.shorten_header(f"{category_header} > Movies"), + self.shorten_header(f"{category_header} > Movies ({len(items)})"), "Genre", "Added", ], @@ -1139,37 +1070,37 @@ def display_content(self, items, content_type="content", select_first=True): "keys": ["number", "ename"], }, "channel": { - "headers": ["#", self.shorten_header(f"{category_header} > Channels")] + (["", "On Air"] if use_epg else []), + "headers": ["#", self.shorten_header(f"{category_header} > Channels ({len(items)})")] + (["", "On Air"] if use_epg else []), "keys": ["number", "name"], }, - "content": { - "headers": ["Group", "Name"] + (["", "On Air"] if use_epg else []), - "keys": ["group", "name"] + "m3ucontent": { + "headers": [f"Name ({len(items)})", "Group"] + (["", "On Air"] if use_epg else []), + "keys": ["name", "group"] }, } - self.content_list.setColumnCount(len(header_info[content_type]["headers"])) - self.content_list.setHeaderLabels(header_info[content_type]["headers"]) + self.content_list.setColumnCount(len(header_info[content]["headers"])) + self.content_list.setHeaderLabels(header_info[content]["headers"]) # no favorites on seasons or episodes genre_sfolders - check_fav = content_type in ["channel", "movie", "serie", "content"] + check_fav = content in ["channel", "movie", "serie", "m3ucontent"] self.show_favorite_layout(check_fav) for item_data in items: - if content_type == "channel": + if content == "channel": list_item = ChannelTreeWidgetItem(self.content_list) - elif content_type in ["season", "episode"]: + elif content in ["season", "episode"]: list_item = NumberedTreeWidgetItem(self.content_list) else: list_item = QTreeWidgetItem(self.content_list) - for i, key in enumerate(header_info[content_type]["keys"]): + for i, key in enumerate(header_info[content]["keys"]): if key == "added": # Change a date time from "YYYY-MM-DD HH:MM:SS" to "YYYY-MM-DD" only - list_item.setText(i, item_data.get(key, "N/A").split()[0]) + list_item.setText(i, html.unescape(item_data.get(key, "")).split()[0]) else: - list_item.setText(i, item_data.get(key, "N/A")) + list_item.setText(i, html.unescape(item_data.get(key, ""))) - list_item.setData(0, Qt.UserRole, {"type": content_type, "data": item_data}) + list_item.setData(0, Qt.UserRole, {"type": content, "data": item_data}) # If content type is channel, collect the logo urls from the image_manager if need_logos: @@ -1180,18 +1111,18 @@ def display_content(self, items, content_type="content", select_first=True): if check_fav and self.check_if_favorite(item_name): list_item.setBackground(0, QColor(0, 0, 255, 20)) - for i in range(len(header_info[content_type]["headers"])): + for i in range(len(header_info[content]["headers"])): self.content_list.resizeColumnToContents(i) self.content_list.sortItems(0, Qt.AscendingOrder) self.content_list.setSortingEnabled(True) - self.back_button.setVisible(content_type != "content") - self.epg_checkbox.setVisible(self.can_show_epg(content_type)) - self.vodinfo_checkbox.setVisible(self.can_show_content_info(content_type)) + self.back_button.setVisible(content != "m3ucontent") + self.epg_checkbox.setVisible(self.can_show_epg(content)) + self.vodinfo_checkbox.setVisible(self.can_show_content_info(content)) if use_epg: self.content_list.setItemDelegate(ChannelItemDelegate()) - # Start refreshing content list (On Air progress) + # Start refreshing content list (currently aired program) self.refresh_on_air() self.refresh_on_air_timer.start(30000) @@ -1224,9 +1155,10 @@ def update_channel_logos(self, current, total, data): if data: qicon = data.get("icon", None) if qicon: + logo_column = ChannelList.get_logo_column(self.current_list_content) rank = data["rank"] item = self.content_list.topLevelItem(rank) - item.setIcon(1, qicon) + item.setIcon(logo_column, qicon) def update_poster(self, current, total, data): self.update_progress(current, total) @@ -1242,10 +1174,6 @@ def update_poster(self, current, total, data): img_tag = f'Poster Image' self.content_info_text.setText(img_tag + self.content_info_text.text()) - # Select 1st item in the list - if self.content_list.topLevelItemCount() > 0: - self.content_list.setCurrentItem(self.content_list.topLevelItem(0)) - def filter_content(self, text=""): show_favorites = self.favorites_only_checkbox.isChecked() search_text = text.lower() if isinstance(text, str) else "" @@ -1259,7 +1187,7 @@ def filter_content(self, text=""): item = self.content_list.topLevelItem(i) item_name = self.get_item_name(item, item_type) matches_search = search_text in item_name.lower() - if item_type in ["category", "channel", "movie", "serie", "content"]: + if item_type in ["category", "channel", "movie", "serie", "m3ucontent"]: # For category, channel, movie, serie and generic content, filter by search text and favorite is_favorite = self.check_if_favorite(item_name) if show_favorites and not is_favorite: @@ -1614,7 +1542,7 @@ def item_activated(self, item): self.navigation_stack.append(("series", self.current_series, item.text(0))) self.current_season = item_data self.load_season_episodes(item_data) - elif item_type in ["content", "channel", "movie"]: + elif item_type in ["m3ucontent", "channel", "movie"]: self.play_item(item_data) elif item_type == "episode": # Play the selected episode @@ -1656,13 +1584,6 @@ def go_back(self): self.search_box.clear() if not self.search_box.isModified(): self.filter_content(self.search_box.text()) - - # Select previous item - if previous_selected_id: - previous_selected = self.content_list.findItems(previous_selected_id, Qt.MatchExactly, 0) - if previous_selected: - self.content_list.setCurrentItem(previous_selected[0]) - self.content_list.scrollToItem(previous_selected[0], QTreeWidget.PositionAtTop) else: # Already at the root level pass @@ -1793,22 +1714,35 @@ def load_content_in_category(self, category, select_first=True): items = content_data["contents"] else: items = [content_data["contents"][i] for i in content_data["sorted_channels"].get(category_id, [])] - self.display_content(items, content_type="channel") + self.display_content(items, content="channel") else: # Check if we have cached content for this category if category_id in content_data.get("contents", {}): items = content_data["contents"][category_id] if self.content_type == "itv": - self.display_content(items, content_type="channel", select_first=select_first) + self.display_content(items, content="channel", select_first=select_first) elif self.content_type == "series": - self.display_content(items, content_type="serie", select_first=select_first) + self.display_content(items, content="serie", select_first=select_first) elif self.content_type == "vod": - self.display_content(items, content_type="movie", select_first=select_first) + self.display_content(items, content="movie", select_first=select_first) else: # Fetch content for the category self.fetch_content_in_category(category_id, select_first=select_first) def fetch_content_in_category(self, category_id, select_first=True): + + # Ask confirmation if the user wants to load all content + if category_id == '*': + reply = QMessageBox.question( + self, + "Load All Content", + "This will load all content in this category. Continue?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply == QMessageBox.No: + return + selected_provider = self.provider_manager.current_provider headers = self.provider_manager.headers url = selected_provider.get("url", "") @@ -1961,18 +1895,18 @@ def update_content_list(self, data, select_first=True): self.save_provider() if self.content_type == "series": - self.display_content(items, content_type="serie", select_first=select_first) + self.display_content(items, content="serie", select_first=select_first) elif self.content_type == "vod": - self.display_content(items, content_type="movie", select_first=select_first) + self.display_content(items, content="movie", select_first=select_first) elif self.content_type == "itv": - self.display_content(items, content_type="channel", select_first=select_first) + self.display_content(items, content="channel", select_first=select_first) def update_seasons_list(self, data, select_first=True): items = data.get("items") for item in items: item["number"] = item["name"].split(" ")[-1] item["name"] = f'{self.current_series["name"]}.{item["name"]}' - self.display_content(items, content_type="season", select_first=select_first) + self.display_content(items, content="season", select_first=select_first) def update_episodes_list(self, data, select_first=True): items = data.get("items") @@ -1993,7 +1927,7 @@ def update_episodes_list(self, data, select_first=True): episode_item["cmd"] = selected_season.get("cmd") episode_item["series"] = episode_num episode_items.append(episode_item) - self.display_content(episode_items, content_type="episode", select_first=select_first) + self.display_content(episode_items, content="episode", select_first=select_first) else: print("Season not found in data.") @@ -2061,4 +1995,8 @@ def get_item_type(item): @staticmethod def get_item_name(item, item_type): - return item.text(1 if item_type in ["channel", "content"] else 0) + return item.text(1 if item_type == "channel" else 0) + + @staticmethod + def get_logo_column(item_type): + return 0 if item_type == "m3ucontent" else 1 diff --git a/content_loader.py b/content_loader.py index 6e119e7..a1e8505 100644 --- a/content_loader.py +++ b/content_loader.py @@ -9,6 +9,7 @@ class ContentLoader(QThread): content_loaded = Signal(dict) progress_updated = Signal(int, int) + counter_page_not_fetched = 0 def __init__( self, @@ -24,6 +25,8 @@ def __init__( size=0, action="get_ordered_list", sortby="name", + max_retries=2, + timeout=5, ): super().__init__() self.url = url @@ -38,20 +41,31 @@ def __init__( self.period = period self.ch_id = ch_id self.size = size + self.max_retries = max_retries + self.timeout = timeout self.items = [] + self.counter_page_not_fetched = 0 async def fetch_page(self, session, page, max_retries=2, timeout=5): for attempt in range(max_retries): try: + if attempt: + print(f"Retrying page {page}...") params = self.get_params(page) async with session.get( self.url, headers=self.headers, params=params, timeout=timeout ) as response: content = await response.read() if response.status == 503 or not content: + print( + f"Received error or empty response fetching page {page}" + ) + if attempt == max_retries - 1: + self.counter_page_not_fetched += 1 + return [], 0, 0 wait_time = (2**attempt) + random.uniform(0, 1) print( - f"Received error or empty response. Retrying in {wait_time:.2f} seconds..." + f"Retrying in {wait_time:.2f} seconds..." ) await asyncio.sleep(wait_time) continue @@ -62,11 +76,14 @@ async def fetch_page(self, session, page, max_retries=2, timeout=5): 1, 1, ) - + ret = result.get("js", {}) + if not isinstance(ret, dict): + print(f"Invalid response fetching page {page}") + return [], 0, 0 return ( - result["js"]["data"], - int(result["js"].get("total_items", 1)), - int(result["js"].get("max_page_items", 1)), + ret.get("data", []), + int(ret.get("total_items", 0)), + int(ret.get("max_page_items", 0)), ) except ( aiohttp.ClientError, @@ -75,7 +92,8 @@ async def fetch_page(self, session, page, max_retries=2, timeout=5): ) as e: print(f"Error fetching page {page}: {e}") if attempt == max_retries - 1: - raise + self.counter_page_not_fetched += 1 + return [], 0, 0 wait_time = (2**attempt) + random.uniform(0, 1) print(f"Retrying in {wait_time:.2f} seconds...") await asyncio.sleep(wait_time) @@ -136,11 +154,13 @@ def get_params(self, page): return params async def load_content(self): + semaphore = asyncio.Semaphore(10) # Limit concurrent fetch_page calls + async with aiohttp.ClientSession() as session: # Fetch initial data to get total items and max page items page = 1 page_items, total_items, max_page_items = await self.fetch_page( - session, page + session, page, self.timeout ) # if page_items is list, extend items if isinstance(page_items, list): @@ -156,19 +176,26 @@ async def load_content(self): self.progress_updated.emit(1, pages) + async def fetch_with_semaphore(page_num): + async with semaphore: + return await self.fetch_page(session, page_num, self.max_retries, self.timeout) + tasks = [] for page_num in range(2, pages + 1): - tasks.append(self.fetch_page(session, page_num)) + tasks.append(fetch_with_semaphore(page_num)) for i, task in enumerate(asyncio.as_completed(tasks), 2): page_items, _, _ = await task self.items.extend(page_items) self.progress_updated.emit(i, pages) + if self.counter_page_not_fetched: + print(f"Failed to fetch {self.counter_page_not_fetched} pages ({self.counter_page_not_fetched/pages*100:.2f}%)") + # Emit all items once done self.content_loaded.emit( { - "page_count": (total_items + max_page_items - 1) // max_page_items, + "page_count": pages, "category_id": self.category_id, "items": self.items, "parent_id": self.parent_id, diff --git a/image_loader.py b/image_loader.py index 5980d9b..d3fc2cd 100644 --- a/image_loader.py +++ b/image_loader.py @@ -44,7 +44,7 @@ async def decode_base64_image(self, image_rank, image_str): raise return None - async def load_images(self, max_concurrent_fetch=5): + async def load_images(self): async with aiohttp.ClientSession() as session: tasks = [] for image_rank, url in enumerate(self.image_urls): @@ -54,7 +54,7 @@ async def load_images(self, max_concurrent_fetch=5): elif url.startswith("data:image"): tasks.append(self.decode_base64_image(image_rank, url)) image_count = len(tasks) - + for i, task in enumerate(asyncio.as_completed(tasks), 1): try: image_item = await task diff --git a/provider_manager.py b/provider_manager.py index 1629d50..e1a476b 100644 --- a/provider_manager.py +++ b/provider_manager.py @@ -11,21 +11,12 @@ from urlobject import URLObject -class ProviderContext: - def __init__(self): - self.provider_url = None - self.headers = None - - class ProviderManager(QObject): progress = Signal(str) def __init__(self, config_manager): super().__init__() self.config_manager = config_manager - self.provider_context = ( - ProviderContext() - ) # is it the proper way to initialize the object? what was it before, no initialization? self.provider_dir = os.path.join( config_manager.get_config_dir(), "cache", "provider" ) @@ -44,14 +35,6 @@ def _current_provider_cache_name(self): ).hexdigest() return os.path.join(self.provider_dir, f"{hashed_name}.json") - def _update_provider_context(self): - if self.current_provider["type"] == "STB": - self.provider_context.provider_url = self.current_provider["url"] - self.provider_context.headers = self.headers - else: - self.provider_context.provider_url = None - self.provider_context.headers = None - def _load_providers(self): try: with open(self.index_file, "r", encoding="utf-8") as f: @@ -62,6 +45,13 @@ def _load_providers(self): self.providers = self.default_providers() self.save_providers() + def clear_current_provider_cache(self): + try: + os.remove(self._current_provider_cache_name()) + except FileNotFoundError: + pass + self.current_provider_content = {} + def set_current_provider(self, progress_callback): progress_callback.emit("Searching for provider...") # search for provider in the list @@ -69,7 +59,6 @@ def set_current_provider(self, progress_callback): for provider in self.providers: if provider["name"] == self.config_manager.selected_provider_name: self.current_provider = provider - self._update_provider_context() break # if provider not found, set the first one From 37150b4cdbfedaddbb925347cbb36376d6149cc7 Mon Sep 17 00:00:00 2001 From: ozankaraali Date: Sat, 30 Nov 2024 13:18:21 +0100 Subject: [PATCH 6/7] fixes progressbar for macos rendering with rectangle rendering --- channel_list.py | 437 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 318 insertions(+), 119 deletions(-) diff --git a/channel_list.py b/channel_list.py index e47b21b..f9dd5fb 100644 --- a/channel_list.py +++ b/channel_list.py @@ -3,17 +3,22 @@ import os import platform import re -import requests import shutil import subprocess import time -from urllib.parse import urlparse -from content_loader import ContentLoader -from image_loader import ImageLoader from datetime import datetime +from urllib.parse import urlparse + import requests -from PySide6.QtCore import Qt, QThread, Signal, QSize, QTimer, QRect, QBuffer -from PySide6.QtGui import QColor, QFont, QTextDocument, QTextOption, QTextCursor, QFontMetrics +from PySide6.QtCore import QBuffer, QRect, QSize, Qt, QThread, QTimer, Signal +from PySide6.QtGui import ( + QColor, + QFont, + QFontMetrics, + QTextCursor, + QTextDocument, + QTextOption, +) from PySide6.QtWidgets import ( QApplication, QCheckBox, @@ -41,6 +46,8 @@ ) from urlobject import URLObject +from content_loader import ContentLoader +from image_loader import ImageLoader from options import OptionsDialog @@ -63,6 +70,7 @@ def __lt__(self, other): return True return t1 < t2 + class ChannelTreeWidgetItem(QTreeWidgetItem): # Modify the sorting by Channel Number to used integer and not string (1 < 10, but "1" may not be < "10") # Modify the sorting by Program Progress to read the progress in item data @@ -71,21 +79,26 @@ def __lt__(self, other): return super(ChannelTreeWidgetItem, self).__lt__(other) sort_column = self.treeWidget().sortColumn() - if sort_column == 0: # Channel number + if sort_column == 0: # Channel number return int(self.text(sort_column)) < int(other.text(sort_column)) - elif sort_column == 2: # EPG Program progress + elif sort_column == 2: # EPG Program progress p1 = self.data(sort_column, Qt.UserRole) if p1 is None: return False p2 = other.data(sort_column, Qt.UserRole) if p2 is None: return True - return self.data(sort_column, Qt.UserRole) < other.data(sort_column, Qt.UserRole) - elif sort_column == 3: # EPG Program name - return self.data(sort_column, Qt.UserRole) < other.data(sort_column, Qt.UserRole) + return self.data(sort_column, Qt.UserRole) < other.data( + sort_column, Qt.UserRole + ) + elif sort_column == 3: # EPG Program name + return self.data(sort_column, Qt.UserRole) < other.data( + sort_column, Qt.UserRole + ) return self.text(sort_column) < other.text(sort_column) + class NumberedTreeWidgetItem(QTreeWidgetItem): # Modify the sorting by Number to used integer and not string (1 < 10, but "1" may not be < "10") def __lt__(self, other): @@ -93,18 +106,19 @@ def __lt__(self, other): return super(NumberedTreeWidgetItem, self).__lt__(other) sort_column = self.treeWidget().sortColumn() - if sort_column == 0: # Channel number + if sort_column == 0: # Channel number return int(self.text(sort_column)) < int(other.text(sort_column)) return self.text(sort_column) < other.text(sort_column) + class HtmlItemDelegate(QStyledItemDelegate): elidedPostfix = "..." doc = QTextDocument() - doc.setDocumentMargin(1); + doc.setDocumentMargin(1) - def __init__( self ): + def __init__(self): super().__init__() - + def paint(self, painter, inOption, index): options = QStyleOptionViewItem(inOption) self.initStyleOption(options, index) @@ -113,7 +127,11 @@ def paint(self, painter, inOption, index): style = options.widget.style() if options.widget else QApplication.style() textOption = QTextOption() - textOption.setWrapMode(QTextOption.WordWrap if options.features & QStyleOptionViewItem.WrapText else QTextOption.ManualWrap) + textOption.setWrapMode( + QTextOption.WordWrap + if options.features & QStyleOptionViewItem.WrapText + else QTextOption.ManualWrap + ) textOption.setTextDirection(options.direction) self.doc.setDefaultTextOption(textOption) @@ -128,20 +146,26 @@ def paint(self, painter, inOption, index): cursor.movePosition(QTextCursor.End) metric = QFontMetrics(options.font) postfixWidth = metric.horizontalAdvance(self.elidedPostfix) - while (self.doc.size().width() > options.rect.width() - postfixWidth): + while self.doc.size().width() > options.rect.width() - postfixWidth: cursor.deletePreviousChar() self.doc.adjustSize() cursor.insertText(self.elidedPostfix) # Painting item without text (this takes care of painting e.g. the highlighted for selected # or hovered over items in an ItemView) - options.text = '' + options.text = "" style.drawControl(QStyle.CE_ItemViewItem, options, painter, inOption.widget) # Figure out where to render the text in order to follow the requested alignment textRect = style.subElementRect(QStyle.SE_ItemViewItemText, options) - documentSize = QSize(self.doc.size().width(), self.doc.size().height()) # Convert QSizeF to QSize - layoutRect = QRect(QStyle.alignedRect(Qt.LayoutDirectionAuto, options.displayAlignment, documentSize, textRect)) + documentSize = QSize( + self.doc.size().width(), self.doc.size().height() + ) # Convert QSizeF to QSize + layoutRect = QRect( + QStyle.alignedRect( + Qt.LayoutDirectionAuto, options.displayAlignment, documentSize, textRect + ) + ) painter.save() @@ -151,8 +175,8 @@ def paint(self, painter, inOption, index): self.doc.drawContents(painter, textRect.translated(-textRect.topLeft())) painter.restore() - - def sizeHint( self, inOption, index ): + + def sizeHint(self, inOption, index): options = QStyleOptionViewItem(inOption) self.initStyleOption(options, index) if not options.text: @@ -161,46 +185,79 @@ def sizeHint( self, inOption, index ): self.doc.setTextWidth(options.rect.width()) return QSize(self.doc.idealWidth(), self.doc.size().height()) + class ChannelItemDelegate(QStyledItemDelegate): def __init__(self): super().__init__() + # Create a default font to avoid font family issues + self.default_font = QFont() + self.default_font.setPointSize(12) def paint(self, painter, inOption, index): col = index.column() - if col == 2: # EPG program progress + if col == 2: # EPG program progress progress = index.data(Qt.UserRole) - if not progress is None: + if progress is not None: options = QStyleOptionViewItem(inOption) self.initStyleOption(options, index) - style = options.widget.style() if options.widget else QApplication.style() - opt = QStyleOptionProgressBar() - opt.rect = inOption.rect - opt.minimum = 0 - opt.maximum = 100 - opt.progress = progress - opt.textVisible = False - style.drawControl(QStyle.CE_ProgressBar, opt, painter, inOption.widget) + + # Draw selection background first + style = ( + options.widget.style() if options.widget else QApplication.style() + ) + style.drawPrimitive( + QStyle.PE_PanelItemViewItem, options, painter, options.widget + ) + + # Save painter state + painter.save() + + # Calculate progress bar dimensions with padding + padding = 4 + rect = options.rect.adjusted(padding, padding, -padding, -padding) + + # Draw background (gray rectangle) + painter.setPen(Qt.NoPen) + painter.setBrush(QColor(200, 200, 200)) + painter.drawRect(rect) + + # Draw progress (blue rectangle) + if progress > 0: + progress_width = int((rect.width() * progress) / 100) + progress_rect = QRect( + rect.x(), rect.y(), progress_width, rect.height() + ) + painter.setBrush(QColor(0, 120, 215)) # Windows 10 style blue + painter.drawRect(progress_rect) + + # Restore painter state + painter.restore() else: super().paint(painter, inOption, index) - elif col == 3: # EPG program name + elif col == 3: # EPG program name epg_text = index.data(Qt.UserRole) if epg_text: options = QStyleOptionViewItem(inOption) self.initStyleOption(options, index) - style = options.widget.style() if options.widget else QApplication.style() + style = ( + options.widget.style() if options.widget else QApplication.style() + ) options.text = epg_text - style.drawControl(QStyle.CE_ItemViewItem, options, painter, inOption.widget) + style.drawControl( + QStyle.CE_ItemViewItem, options, painter, inOption.widget + ) else: super().paint(painter, inOption, index) else: super().paint(painter, inOption, index) - def sizeHint( self, inOption, index ): + def sizeHint(self, option, index): col = index.column() - if col == 2: - return QSize(100, 20) - elif col == 3: - options = QStyleOptionViewItem(inOption) + if col == 2: # EPG program progress + # Set a minimum width of 100 pixels and height of 24 pixels for the progress bar column + return QSize(100, 24) + elif col == 3: # EPG program name + options = QStyleOptionViewItem(option) self.initStyleOption(options, index) style = options.widget.style() if options.widget else QApplication.style() text = index.data(Qt.UserRole) @@ -209,8 +266,8 @@ def sizeHint( self, inOption, index ): font = style.font(QStyle.CE_ItemViewItem, options, index) metrics = QFontMetrics(font) return QSize(metrics.boundingRect(text).width(), metrics.height()) + return super().sizeHint(option, index) - return super().sizeHint(inOption, index) class SetProviderThread(QThread): progress = Signal(str) @@ -227,9 +284,12 @@ def run(self): except Exception as e: print(f"Error in initializing provider: {e}") + class ChannelList(QMainWindow): - def __init__(self, app, player, config_manager, provider_manager, image_manager, epg_manager): + def __init__( + self, app, player, config_manager, provider_manager, image_manager, epg_manager + ): super().__init__() self.app = app self.player = player @@ -317,18 +377,32 @@ def refresh_on_air(self): content_type = item_data.get("type") if self.config_manager.channel_epg and self.can_show_epg(content_type): - epg_data = self.epg_manager.get_programs_for_channel(item_data["data"], None, 1) + epg_data = self.epg_manager.get_programs_for_channel( + item_data["data"], None, 1 + ) if epg_data: epg_item = epg_data[0] if epg_source == "STB": - start_time = datetime.strptime(epg_item["time"], "%Y-%m-%d %H:%M:%S") - end_time = datetime.strptime(epg_item["time_to"], "%Y-%m-%d %H:%M:%S") + start_time = datetime.strptime( + epg_item["time"], "%Y-%m-%d %H:%M:%S" + ) + end_time = datetime.strptime( + epg_item["time_to"], "%Y-%m-%d %H:%M:%S" + ) else: - start_time = datetime.strptime(epg_item["@start"], "%Y%m%d%H%M%S %z") - end_time = datetime.strptime(epg_item["@stop"], "%Y%m%d%H%M%S %z") + start_time = datetime.strptime( + epg_item["@start"], "%Y%m%d%H%M%S %z" + ) + end_time = datetime.strptime( + epg_item["@stop"], "%Y%m%d%H%M%S %z" + ) now = datetime.now(start_time.tzinfo) if end_time != start_time: - progress = 100 * (now - start_time).total_seconds() / (end_time - start_time).total_seconds() + progress = ( + 100 + * (now - start_time).total_seconds() + / (end_time - start_time).total_seconds() + ) else: progress = 0 if now < start_time else 100 progress = max(0, min(100, progress)) @@ -351,9 +425,13 @@ def set_provider(self, force_update=False): if force_update: self.provider_manager.clear_current_provider_cache() - self.set_provider_thread = SetProviderThread(self.provider_manager, self.epg_manager) + self.set_provider_thread = SetProviderThread( + self.provider_manager, self.epg_manager + ) self.set_provider_thread.progress.connect(self.update_busy_progress) - self.set_provider_thread.finished.connect(lambda: self.set_provider_finished(force_update)) + self.set_provider_thread.finished.connect( + lambda: self.set_provider_finished(force_update) + ) self.set_provider_thread.start() def set_provider_finished(self, force_update=False): @@ -473,7 +551,7 @@ def create_list_panel(self): # Create a horizontal layout for the favorite button and checkbox self.favorite_layout = QHBoxLayout() - + # Add favorite button and action self.favorite_button = QPushButton("Favorite/Unfavorite") self.favorite_button.clicked.connect(self.toggle_favorite) @@ -546,14 +624,18 @@ def refresh_channels(self): # Update the content list if config_type != "STB": # For non-STB, display content directly - content = self.provider_manager.current_provider_content.setdefault(self.content_type, {}) + content = self.provider_manager.current_provider_content.setdefault( + self.content_type, {} + ) self.display_content(content) else: # Reload the current category self.load_content_in_category(self.current_category) # Restore the sorting - self.content_list.sortItems(sort_column, self.content_list.header().sortIndicatorOrder()) + self.content_list.sortItems( + sort_column, self.content_list.header().sortIndicatorOrder() + ) # Restore the selected item if selected_item: @@ -562,13 +644,19 @@ def refresh_channels(self): self.item_selected() def can_show_content_info(self, item_type): - return item_type in ["movie", "serie", "season", "episode"] and self.provider_manager.current_provider["type"] == "STB" + return ( + item_type in ["movie", "serie", "season", "episode"] + and self.provider_manager.current_provider["type"] == "STB" + ) def can_show_epg(self, item_type): if item_type in ["channel", "m3ucontent"]: if self.config_manager.epg_source == "No Source": return False - if self.config_manager.epg_source == "STB" and self.provider_manager.current_provider["type"] != "STB": + if ( + self.config_manager.epg_source == "STB" + and self.provider_manager.current_provider["type"] != "STB" + ): return False return True return False @@ -581,7 +669,9 @@ def create_content_info_panel(self): def setup_movie_tvshow_content_info(self): self.clear_content_info_panel() self.content_info_text = QLabel(self.content_info_panel) - self.content_info_text.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Ignored) # Allow to reduce splitter below label minimum size + self.content_info_text.setSizePolicy( + QSizePolicy.Expanding, QSizePolicy.Ignored + ) # Allow to reduce splitter below label minimum size self.content_info_text.setAlignment(Qt.AlignLeft | Qt.AlignTop) self.content_info_text.setWordWrap(True) self.content_info_layout.addWidget(self.content_info_text, 1) @@ -599,11 +689,21 @@ def setup_channel_program_content_info(self): self.content_info_text.setWordWrap(True) self.splitter_content_info.addWidget(self.content_info_text) self.content_info_layout.addWidget(self.splitter_content_info) - self.splitter_content_info.setSizes([int(self.content_info_panel.width() * self.splitter_content_info_ratio), int(self.content_info_panel.width() * (1 - self.splitter_content_info_ratio))]) + self.splitter_content_info.setSizes( + [ + int(self.content_info_panel.width() * self.splitter_content_info_ratio), + int( + self.content_info_panel.width() + * (1 - self.splitter_content_info_ratio) + ), + ] + ) self.content_info_shown = "channel" self.program_list.itemSelectionChanged.connect(self.update_channel_program) - self.splitter_content_info.splitterMoved.connect(self.update_splitter_content_info_ratio) + self.splitter_content_info.splitterMoved.connect( + self.update_splitter_content_info_ratio + ) def clear_content_info_panel(self): # Clear all widgets from the content_info layout @@ -664,7 +764,12 @@ def switch_content_info_panel(self, item_type): if not self.content_info_panel.isVisible(): self.content_info_panel.setVisible(True) - self.splitter.setSizes([int(self.container_widget.height() * self.splitter_ratio), int(self.container_widget.height() * (1 - self.splitter_ratio))]) + self.splitter.setSizes( + [ + int(self.container_widget.height() * self.splitter_ratio), + int(self.container_widget.height() * (1 - self.splitter_ratio)), + ] + ) def populate_channel_programs_content_info(self, item_data): self.program_list.itemSelectionChanged.disconnect() @@ -687,9 +792,11 @@ def populate_channel_programs_content_info(self, item_data): else: item = QListWidgetItem("Program not available") self.program_list.addItem(item) - xmltv_id = item_data.get('xmltv_id', '') + xmltv_id = item_data.get("xmltv_id", "") if xmltv_id: - self.content_info_text.setText(f"No EPG found for channel id \"{xmltv_id}\"") + self.content_info_text.setText( + f'No EPG found for channel id "{xmltv_id}"' + ) else: self.content_info_text.setText(f"Channel without id") @@ -722,7 +829,7 @@ def update_channel_program(self): info += f"Director: {director}
" if actor: info += f"Actor: {actor}
" - + self.content_info_text.setText(info if info else "No data available") else: @@ -819,7 +926,13 @@ def update_channel_program(self): self.lock_ui_before_loading() if hasattr(self, "image_loader") and self.image_loader.isRunning(): self.image_loader.wait() - self.image_loader = ImageLoader([icon_url,], self.image_manager, iconified=False) + self.image_loader = ImageLoader( + [ + icon_url, + ], + self.image_manager, + iconified=False, + ) self.image_loader.progress_updated.connect(self.update_poster) self.image_loader.finished.connect(self.image_loader_finished) self.image_loader.start() @@ -836,7 +949,7 @@ def populate_movie_tvshow_content_info(self, item_data): "length": "Length", "director": "Director", "actors": "Actors", - "description": "Summary" + "description": "Summary", } info = "" @@ -856,7 +969,13 @@ def populate_movie_tvshow_content_info(self, item_data): self.lock_ui_before_loading() if hasattr(self, "image_loader") and self.image_loader.isRunning(): self.image_loader.wait() - self.image_loader = ImageLoader([poster_url,], self.image_manager, iconified=False) + self.image_loader = ImageLoader( + [ + poster_url, + ], + self.image_manager, + iconified=False, + ) self.image_loader.progress_updated.connect(self.update_poster) self.image_loader.finished.connect(self.image_loader_finished) self.image_loader.start() @@ -866,9 +985,11 @@ def refresh_content_list_size(self): font_size = 12 icon_size = font_size + 4 self.content_list.setIconSize(QSize(icon_size, icon_size)) - self.content_list.setStyleSheet(f""" + self.content_list.setStyleSheet( + f""" QTreeWidget {{ font-size: {font_size}px; }} - """) + """ + ) font = QFont() font.setPointSize(font_size) @@ -970,7 +1091,9 @@ def display_categories(self, categories, select_first=True): self.content_list.setSortingEnabled(False) self.content_list.setColumnCount(1) if self.content_type == "itv": - self.content_list.setHeaderLabels([f"Channel Categories ({len(categories)})"]) + self.content_list.setHeaderLabels( + [f"Channel Categories ({len(categories)})"] + ) elif self.content_type == "vod": self.content_list.setHeaderLabels([f"Movie Categories ({len(categories)})"]) elif self.content_type == "series": @@ -1002,10 +1125,14 @@ def display_categories(self, categories, select_first=True): self.content_list.setCurrentItem(self.content_list.topLevelItem(0)) else: previous_selected_id = select_first - previous_selected = self.content_list.findItems(previous_selected_id, Qt.MatchExactly, 0) + previous_selected = self.content_list.findItems( + previous_selected_id, Qt.MatchExactly, 0 + ) if previous_selected: self.content_list.setCurrentItem(previous_selected[0]) - self.content_list.scrollToItem(previous_selected[0], QTreeWidget.PositionAtTop) + self.content_list.scrollToItem( + previous_selected[0], QTreeWidget.PositionAtTop + ) def display_content(self, items, content="m3ucontent", select_first=True): # Unregister the selection change event @@ -1017,9 +1144,11 @@ def display_content(self, items, content="m3ucontent", select_first=True): # Stop refreshing On Air content self.refresh_on_air_timer.stop() - + self.current_list_content = content - need_logos = content in ["channel", "m3ucontent"] and self.config_manager.channel_logos + need_logos = ( + content in ["channel", "m3ucontent"] and self.config_manager.channel_logos + ) logo_urls = [] use_epg = self.can_show_epg(content) and self.config_manager.channel_epg @@ -1052,7 +1181,7 @@ def display_content(self, items, content="m3ucontent", select_first=True): }, "season": { "headers": [ - "#", + "#", self.shorten_header( f"{category_header} > {serie_header} > Seasons" ), @@ -1070,12 +1199,17 @@ def display_content(self, items, content="m3ucontent", select_first=True): "keys": ["number", "ename"], }, "channel": { - "headers": ["#", self.shorten_header(f"{category_header} > Channels ({len(items)})")] + (["", "On Air"] if use_epg else []), + "headers": [ + "#", + self.shorten_header(f"{category_header} > Channels ({len(items)})"), + ] + + (["", "On Air"] if use_epg else []), "keys": ["number", "name"], }, "m3ucontent": { - "headers": [f"Name ({len(items)})", "Group"] + (["", "On Air"] if use_epg else []), - "keys": ["name", "group"] + "headers": [f"Name ({len(items)})", "Group"] + + (["", "On Air"] if use_epg else []), + "keys": ["name", "group"], }, } self.content_list.setColumnCount(len(header_info[content]["headers"])) @@ -1096,7 +1230,9 @@ def display_content(self, items, content="m3ucontent", select_first=True): for i, key in enumerate(header_info[content]["keys"]): if key == "added": # Change a date time from "YYYY-MM-DD HH:MM:SS" to "YYYY-MM-DD" only - list_item.setText(i, html.unescape(item_data.get(key, "")).split()[0]) + list_item.setText( + i, html.unescape(item_data.get(key, "")).split()[0] + ) else: list_item.setText(i, html.unescape(item_data.get(key, ""))) @@ -1112,7 +1248,8 @@ def display_content(self, items, content="m3ucontent", select_first=True): list_item.setBackground(0, QColor(0, 0, 255, 20)) for i in range(len(header_info[content]["headers"])): - self.content_list.resizeColumnToContents(i) + if i != 2: # Don't auto-resize the progress column + self.content_list.resizeColumnToContents(i) self.content_list.sortItems(0, Qt.AscendingOrder) self.content_list.setSortingEnabled(True) @@ -1122,6 +1259,12 @@ def display_content(self, items, content="m3ucontent", select_first=True): if use_epg: self.content_list.setItemDelegate(ChannelItemDelegate()) + # Set a fixed width for the progress column + self.content_list.setColumnWidth( + 2, 100 + ) # Force column 2 (progress) to be 100 pixels wide + # Prevent user from resizing the progress column too small + self.content_list.header().setMinimumSectionSize(100) # Start refreshing content list (currently aired program) self.refresh_on_air() self.refresh_on_air_timer.start(30000) @@ -1133,10 +1276,14 @@ def display_content(self, items, content="m3ucontent", select_first=True): self.content_list.setCurrentItem(self.content_list.topLevelItem(0)) else: previous_selected_id = select_first - previous_selected = self.content_list.findItems(previous_selected_id, Qt.MatchExactly, 0) + previous_selected = self.content_list.findItems( + previous_selected_id, Qt.MatchExactly, 0 + ) if previous_selected: self.content_list.setCurrentItem(previous_selected[0]) - self.content_list.scrollToItem(previous_selected[0], QTreeWidget.PositionAtTop) + self.content_list.scrollToItem( + previous_selected[0], QTreeWidget.PositionAtTop + ) # Load channel logos if needed self.rescanlogo_button.setVisible(need_logos) @@ -1144,7 +1291,9 @@ def display_content(self, items, content="m3ucontent", select_first=True): self.lock_ui_before_loading() if hasattr(self, "image_loader") and self.image_loader.isRunning(): self.image_loader.wait() - self.image_loader = ImageLoader(logo_urls, self.image_manager, iconified=True) + self.image_loader = ImageLoader( + logo_urls, self.image_manager, iconified=True + ) self.image_loader.progress_updated.connect(self.update_channel_logos) self.image_loader.finished.connect(self.image_loader_finished) self.image_loader.start() @@ -1165,12 +1314,14 @@ def update_poster(self, current, total, data): if data: pixmap = data.get("pixmap", None) if pixmap: - scaled_pixmap = pixmap.scaled(200, 300, Qt.KeepAspectRatio, Qt.SmoothTransformation) + scaled_pixmap = pixmap.scaled( + 200, 300, Qt.KeepAspectRatio, Qt.SmoothTransformation + ) buffer = QBuffer() buffer.open(QBuffer.ReadWrite) scaled_pixmap.save(buffer, "PNG") buffer.close() - base64_data = base64.b64encode(buffer.data()).decode('utf-8') + base64_data = base64.b64encode(buffer.data()).decode("utf-8") img_tag = f'Poster Image' self.content_info_text.setText(img_tag + self.content_info_text.text()) @@ -1304,9 +1455,14 @@ def fetch_and_export_all_live_channels(self, file_path): try: # Get all channels and categories (in provider cache) - provider_itv_content = self.provider_manager.current_provider_content.setdefault("itv", {}) + provider_itv_content = ( + self.provider_manager.current_provider_content.setdefault("itv", {}) + ) categories_list = provider_itv_content.setdefault("categories", []) - categories = {c.get("id", "None"): c.get("title", "Unknown Category") for c in categories_list} + categories = { + c.get("id", "None"): c.get("title", "Unknown Category") + for c in categories_list + } channels = provider_itv_content["contents"] self.save_channel_list(base_url, channels, categories, mac, file_path) @@ -1322,7 +1478,9 @@ def fetch_and_export_all_live_channels(self, file_path): f"An error occurred while exporting channels: {str(e)}", ) - def save_channel_list(self, base_url, channels_data, categories, mac, file_path) -> None: + def save_channel_list( + self, base_url, channels_data, categories, mac, file_path + ) -> None: try: with open(file_path, "w", encoding="utf-8") as file: file.write("#EXTM3U\n") @@ -1358,8 +1516,12 @@ def export_content(self): if file_path: provider = self.provider_manager.current_provider # Get the content data from the provider manager on content type - provider_content = self.provider_manager.current_provider_content.setdefault(self.content_type, {}) - + provider_content = ( + self.provider_manager.current_provider_content.setdefault( + self.content_type, {} + ) + ) + base_url = provider.get("url", "") config_type = provider.get("type", "") mac = provider.get("mac", "") @@ -1438,7 +1600,9 @@ def save_provider(self): def load_content(self): selected_provider = self.provider_manager.current_provider config_type = selected_provider.get("type", "") - content = self.provider_manager.current_provider_content.setdefault(self.content_type, {}) + content = self.provider_manager.current_provider_content.setdefault( + self.content_type, {} + ) if content: # If we have categories cached, display them if config_type == "STB": @@ -1471,7 +1635,9 @@ def update_content(self): ) self.load_m3u_playlist(url) elif config_type == "STB": - self.load_stb_categories(selected_provider["url"], self.provider_manager.headers) + self.load_stb_categories( + selected_provider["url"], self.provider_manager.headers + ) elif config_type == "M3USTREAM": self.load_stream(selected_provider["url"]) @@ -1487,9 +1653,9 @@ def load_m3u_playlist(self, url): parsed_content = self.parse_m3u(content) self.display_content(parsed_content) # Update the content in the config - self.provider_manager.current_provider_content[ - self.content_type - ] = parsed_content + self.provider_manager.current_provider_content[self.content_type] = ( + parsed_content + ) self.save_provider() except (requests.RequestException, IOError) as e: print(f"Error loading M3U Playlist: {e}") @@ -1510,7 +1676,10 @@ def item_selected(self): item_data = data["data"] item_type = item.data(0, Qt.UserRole)["type"] - if self.can_show_content_info(item_type) and self.config_manager.show_stb_content_info: + if ( + self.can_show_content_info(item_type) + and self.config_manager.show_stb_content_info + ): self.switch_content_info_panel(item_type) self.populate_movie_tvshow_content_info(item_data) elif self.can_show_epg(item_type) and self.config_manager.channel_epg: @@ -1528,18 +1697,24 @@ def item_activated(self, item): nav_len = len(self.navigation_stack) if item_type == "category": - self.navigation_stack.append(("root", self.current_category, item.text(0))) + self.navigation_stack.append( + ("root", self.current_category, item.text(0)) + ) self.current_category = item_data self.load_content_in_category(item_data) elif item_type == "serie": if self.content_type == "series": # For series, load seasons - self.navigation_stack.append(("category", self.current_category, item.text(0))) + self.navigation_stack.append( + ("category", self.current_category, item.text(0)) + ) self.current_series = item_data self.load_series_seasons(item_data) elif item_type == "season": # Load episodes for the selected season - self.navigation_stack.append(("series", self.current_series, item.text(0))) + self.navigation_stack.append( + ("series", self.current_series, item.text(0)) + ) self.current_season = item_data self.load_season_episodes(item_data) elif item_type in ["m3ucontent", "channel", "movie"]: @@ -1572,12 +1747,16 @@ def go_back(self): elif nav_type == "category": # Go back to category content self.current_category = previous_data - self.load_content_in_category(self.current_category, select_first=previous_selected_id) + self.load_content_in_category( + self.current_category, select_first=previous_selected_id + ) self.current_series = None elif nav_type == "series": # Go back to series seasons self.current_series = previous_data - self.load_series_seasons(self.current_series, select_first=previous_selected_id) + self.load_series_seasons( + self.current_series, select_first=previous_selected_id + ) self.current_season = None # Clear search box after navigating backward and force re-filtering if needed @@ -1604,7 +1783,7 @@ def parse_m3u(data): tvg_logo_match = re.search(r'tvg-logo="([^"]+)"', line) group_title_match = re.search(r'group-title="([^"]+)"', line) user_agent_match = re.search(r'user-agent="([^"]+)"', line) - item_name_match = re.search(r',([^,]+)$', line) + item_name_match = re.search(r",([^,]+)$", line) tvg_id = tvg_id_match.group(1) if tvg_id_match else None tvg_logo = tvg_logo_match.group(1) if tvg_logo_match else None @@ -1646,15 +1825,17 @@ def load_stb_categories(self, url, headers): print("No categories found.") return # Save categories in config - provider_content = self.provider_manager.current_provider_content.setdefault(self.content_type, {}) + provider_content = ( + self.provider_manager.current_provider_content.setdefault( + self.content_type, {} + ) + ) provider_content["categories"] = categories provider_content["contents"] = {} # Sorting all channels now by category if self.content_type == "itv": - fetchurl = ( - f"{url}/server/load.php?{self.get_allchannels_params()}" - ) + fetchurl = f"{url}/server/load.php?{self.get_allchannels_params()}" response = requests.get(fetchurl, headers=headers) result = response.json() provider_content["contents"] = result["js"]["data"] @@ -1670,14 +1851,13 @@ def load_stb_categories(self, url, headers): sorted_channels[category].append(i) for category in sorted_channels: - sorted_channels[category].sort(key=lambda x: int(provider_content["contents"][x]["number"])) + sorted_channels[category].sort( + key=lambda x: int(provider_content["contents"][x]["number"]) + ) # Add a specific category for null genre_id if "None" in sorted_channels: - categories.append({ - "id": "None", - "title": "Unknown Category" - }) + categories.append({"id": "None", "title": "Unknown Category"}) provider_content["sorted_channels"] = sorted_channels @@ -1705,7 +1885,9 @@ def get_allchannels_params(): return "&".join(f"{k}={v}" for k, v in params.items()) def load_content_in_category(self, category, select_first=True): - content_data = self.provider_manager.current_provider_content.setdefault(self.content_type, {}) + content_data = self.provider_manager.current_provider_content.setdefault( + self.content_type, {} + ) category_id = category.get("id", "*") if self.content_type == "itv": @@ -1713,18 +1895,27 @@ def load_content_in_category(self, category, select_first=True): if category_id == "*": items = content_data["contents"] else: - items = [content_data["contents"][i] for i in content_data["sorted_channels"].get(category_id, [])] + items = [ + content_data["contents"][i] + for i in content_data["sorted_channels"].get(category_id, []) + ] self.display_content(items, content="channel") else: # Check if we have cached content for this category if category_id in content_data.get("contents", {}): items = content_data["contents"][category_id] if self.content_type == "itv": - self.display_content(items, content="channel", select_first=select_first) + self.display_content( + items, content="channel", select_first=select_first + ) elif self.content_type == "series": - self.display_content(items, content="serie", select_first=select_first) + self.display_content( + items, content="serie", select_first=select_first + ) elif self.content_type == "vod": - self.display_content(items, content="movie", select_first=select_first) + self.display_content( + items, content="movie", select_first=select_first + ) else: # Fetch content for the category self.fetch_content_in_category(category_id, select_first=select_first) @@ -1732,7 +1923,7 @@ def load_content_in_category(self, category, select_first=True): def fetch_content_in_category(self, category_id, select_first=True): # Ask confirmation if the user wants to load all content - if category_id == '*': + if category_id == "*": reply = QMessageBox.question( self, "Load All Content", @@ -1755,7 +1946,9 @@ def fetch_content_in_category(self, category_id, select_first=True): self.content_loader = ContentLoader( url, headers, self.content_type, category_id=category_id ) - self.content_loader.content_loaded.connect(lambda data: self.update_content_list(data, select_first)) + self.content_loader.content_loaded.connect( + lambda data: self.update_content_list(data, select_first) + ) self.content_loader.progress_updated.connect(self.update_progress) self.content_loader.finished.connect(self.content_loader_finished) self.content_loader.start() @@ -1784,7 +1977,9 @@ def load_series_seasons(self, series_item, select_first=True): sortby="name", ) - self.content_loader.content_loaded.connect(lambda data: self.update_seasons_list(data, select_first)) + self.content_loader.content_loaded.connect( + lambda data: self.update_seasons_list(data, select_first) + ) self.content_loader.progress_updated.connect(self.update_progress) self.content_loader.finished.connect(self.content_loader_finished) self.content_loader.start() @@ -1812,7 +2007,9 @@ def load_season_episodes(self, season_item, select_first=True): action="get_ordered_list", sortby="added", ) - self.content_loader.content_loaded.connect(lambda data: self.update_episodes_list(data, select_first)) + self.content_loader.content_loaded.connect( + lambda data: self.update_episodes_list(data, select_first) + ) self.content_loader.progress_updated.connect(self.update_progress) self.content_loader.finished.connect(self.content_loader_finished) self.content_loader.start() @@ -1927,7 +2124,9 @@ def update_episodes_list(self, data, select_first=True): episode_item["cmd"] = selected_season.get("cmd") episode_item["series"] = episode_num episode_items.append(episode_item) - self.display_content(episode_items, content="episode", select_first=select_first) + self.display_content( + episode_items, content="episode", select_first=select_first + ) else: print("Season not found in data.") From c116ca4f99587c3f0959206c15258d05570af312 Mon Sep 17 00:00:00 2001 From: Ozan Karaali Date: Sat, 30 Nov 2024 13:19:18 +0100 Subject: [PATCH 7/7] Update config_manager.py --- config_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config_manager.py b/config_manager.py index e5de85d..c858982 100644 --- a/config_manager.py +++ b/config_manager.py @@ -8,7 +8,7 @@ class ConfigManager: - CURRENT_VERSION = "1.6.0.dev1" # Set your current version here + CURRENT_VERSION = "1.6.0" # Set your current version here DEFAULT_OPTION_CHECKUPDATE = True DEFAULT_OPTION_STB_CONTENT_INFO = False