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/channel_list.py b/channel_list.py index fcab0a1..f9dd5fb 100644 --- a/channel_list.py +++ b/channel_list.py @@ -1,30 +1,44 @@ -import asyncio +import base64 +import html import os import platform -import random import re import shutil -import string import subprocess import time +from datetime import datetime from urllib.parse import urlparse -import aiohttp -import orjson import requests -from PySide6.QtCore import Qt, QThread, Signal -from PySide6.QtGui import QColor +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, QFileDialog, - QGridLayout, QHBoxLayout, + QLabel, QLineEdit, + QListWidget, + QListWidgetItem, QMainWindow, QMessageBox, QProgressBar, QPushButton, QRadioButton, + QSizePolicy, + QSplitter, + QStyle, + QStyledItemDelegate, + QStyleOptionProgressBar, + QStyleOptionViewItem, QTreeWidget, QTreeWidgetItem, QVBoxLayout, @@ -32,6 +46,8 @@ ) from urlobject import URLObject +from content_loader import ContentLoader +from image_loader import ImageLoader from options import OptionsDialog @@ -55,212 +71,417 @@ def __lt__(self, other): 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 ContentLoader(QThread): - content_loaded = Signal(dict) - progress_updated = Signal(int, int) +class HtmlItemDelegate(QStyledItemDelegate): + elidedPostfix = "..." + doc = QTextDocument() + doc.setDocumentMargin(1) - 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__() + # 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 + progress = index.data(Qt.UserRole) + if progress is not None: + options = QStyleOptionViewItem(inOption) + self.initStyleOption(options, index) + + # 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 + 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) + + def sizeHint(self, option, index): + col = index.column() + 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) + 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(option, 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.current_list_content = None + 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.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] + 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 + + 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) + ) + 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(force_update=True)) top_layout.addWidget(self.update_button) self.back_button = QPushButton("Back") @@ -272,6 +493,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 +503,509 @@ 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 - def create_left_panel(self): - self.left_panel = QWidget(self.container_widget) - left_layout = QVBoxLayout(self.left_panel) + # 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") - 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) + 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() - left_layout.addWidget(self.content_list) + list_layout.addWidget(self.content_list, 1) - self.grid_layout.addWidget(self.left_panel, 1, 0) - self.grid_layout.setColumnStretch(0, 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.favorite_layout.addWidget(self.favorites_only_checkbox) - self.channels_radio.setChecked(True) + # 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) - 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) + # 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) - 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 show_vodinfo(self): + self.config_manager.show_stb_content_info = self.vodinfo_checkbox.isChecked() + self.save_config() + self.item_selected() + + def show_epg(self): + self.config_manager.channel_epg = self.epg_checkbox.isChecked() + self.save_config() + + # Refresh the EPG data + self.epg_manager.set_current_epg() + self.refresh_channels() + + 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 + + # 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]) + + # Store how was sorted the content list + sort_column = self.content_list.sortColumn() + + # 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", "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" + ): + 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", "m3ucontent"]: + 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): + 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 + 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", + "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; }} + """ + ) + + font = QFont() + 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 +1020,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,18 +1076,33 @@ 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.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.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 +1116,41 @@ 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="m3ucontent", select_first=True): + # Unregister the selection change event + self.content_list.itemSelectionChanged.disconnect(self.item_selected) self.content_list.clear() self.content_list.setSortingEnabled(False) + # 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() + + 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) and self.config_manager.channel_epg # Define headers for different content types category_header = ( @@ -434,26 +1165,29 @@ def display_content(self, items, content_type="content"): header_info = { "serie": { "headers": [ - self.shorten_header(f"{category_header} > Series"), + self.shorten_header(f"{category_header} > Series ({len(items)})"), + "Genre", "Added", ], - "keys": ["name", "added"], + "keys": ["name", "genres_str", "added"], }, "movie": { "headers": [ - self.shorten_header(f"{category_header} > Movies"), + self.shorten_header(f"{category_header} > Movies ({len(items)})"), + "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,40 +1196,134 @@ 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 ({len(items)})"), + ] + + (["", "On Air"] if use_epg else []), "keys": ["number", "name"], }, - "content": {"headers": ["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 need to check favorites or allow to add favorites on seasons or episodes folders - check_fav = content_type in ["channel", "movie", "serie", "content"] - self.favorite_button.setHidden(not check_fav) + # no favorites on seasons or episodes genre_sfolders + check_fav = content in ["channel", "movie", "serie", "m3ucontent"] + 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 == "channel": + list_item = ChannelTreeWidgetItem(self.content_list) + elif content in ["season", "episode"]: + 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.setData(0, Qt.UserRole, {"type": content_type, "data": item_data}) + list_item = QTreeWidgetItem(self.content_list) + + 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] + ) + else: + list_item.setText(i, html.unescape(item_data.get(key, ""))) + + 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: + 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)) - for i in range(len(header_info[content_type]["headers"])): - self.content_list.resizeColumnToContents(i) + for i in range(len(header_info[content]["headers"])): + 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) - self.back_button.setVisible(content_type != "content") + 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()) + # 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) + + # 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: + logo_column = ChannelList.get_logo_column(self.current_list_content) + rank = data["rank"] + item = self.content_list.topLevelItem(rank) + item.setIcon(logo_column, 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() @@ -510,7 +1338,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: @@ -524,20 +1352,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 +1428,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 +1447,25 @@ 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 +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, 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 +1488,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 +1498,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 +1514,14 @@ 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 +1529,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 +1547,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 +1569,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 +1583,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 +1594,15 @@ 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 +1614,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,8 +1635,8 @@ 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 +1653,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.content_type - ] = parsed_content - self.save_config() + 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}") @@ -795,35 +1664,64 @@ 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 ["m3ucontent", "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 +1735,28 @@ 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 +1782,86 @@ 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 +1875,100 @@ 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="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="channel", select_first=select_first + ) + elif self.content_type == "series": + 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 + ) + 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 - 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() + 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( + lambda data: self.update_content_list(data, select_first) ) - 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", {}) + 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 +1976,30 @@ 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 +2007,16 @@ 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 +2028,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="serie", select_first=select_first) elif self.content_type == "vod": - self.display_content(items, content_type="movie") + self.display_content(items, content="movie", select_first=select_first) elif self.content_type == "itv": - self.display_content(items, content_type="channel") + self.display_content(items, content="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="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 +2117,38 @@ 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="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 +2162,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 +2183,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 @@ -1256,3 +2195,7 @@ def get_item_type(item): @staticmethod def get_item_name(item, item_type): 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/config_manager.py b/config_manager.py index 71a7c43..c858982 100644 --- a/config_manager.py +++ b/config_manager.py @@ -4,16 +4,25 @@ 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" # 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 +43,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 +65,256 @@ 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 +322,17 @@ 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..a1e8505 --- /dev/null +++ b/content_loader.py @@ -0,0 +1,211 @@ +import asyncio +import random + +import aiohttp +import orjson as json +from PySide6.QtCore import QThread, Signal + + +class ContentLoader(QThread): + content_loaded = Signal(dict) + progress_updated = Signal(int, int) + counter_page_not_fetched = 0 + + 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", + max_retries=2, + timeout=5, + ): + 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.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"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, + ) + ret = result.get("js", {}) + if not isinstance(ret, dict): + print(f"Invalid response fetching page {page}") + return [], 0, 0 + return ( + ret.get("data", []), + int(ret.get("total_items", 0)), + int(ret.get("max_page_items", 0)), + ) + except ( + aiohttp.ClientError, + json.JSONDecodeError, + asyncio.TimeoutError, + ) as e: + print(f"Error fetching page {page}: {e}") + if attempt == max_retries - 1: + 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) + 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): + 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, self.timeout + ) + # 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) + + 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(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": pages, + "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..d3fc2cd --- /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): + 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..f000293 100644 --- a/options.py +++ b/options.py @@ -1,72 +1,199 @@ import os +from update_checker import check_for_updates +from config_manager import MultiKeyDict +import orjson as json +import requests +from PySide6.QtCore import Qt 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 +212,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 +330,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 +394,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 +488,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) + result = self.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 +503,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..e1a476b --- /dev/null +++ b/provider_manager.py @@ -0,0 +1,188 @@ +import hashlib +import os +import random +import string +from urllib.parse import urlencode + +import orjson as json +import requests +import tzlocal +from PySide6.QtCore import QObject, Signal +from urlobject import URLObject + + +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 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 + 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/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 cbbcb64..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 +from PySide6.QtCore import QMetaObject, QPoint, Qt, QTimer, Signal, Slot from PySide6.QtGui import QGuiApplication from PySide6.QtWidgets import QFrame, QMainWindow, QProgressBar, QVBoxLayout @@ -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 @@ -125,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) @@ -208,7 +211,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() @@ -235,20 +239,27 @@ 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()