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''
+ 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()