From cf9185e9e7cd83e599093b4b6789c5efdf560e98 Mon Sep 17 00:00:00 2001 From: Vxrtrauter <101264710+Vxrtrauter@users.noreply.github.com> Date: Wed, 8 Apr 2026 01:06:41 +0200 Subject: [PATCH 01/24] feat: Enhance accent color options in settings dialog and save functionality --- src/interface/dialogs/settings.py | 36 +++++++++++++++++++++++++++++-- src/utils/config/settings.py | 7 ++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/interface/dialogs/settings.py b/src/interface/dialogs/settings.py index 6b6a25e..811c4bc 100644 --- a/src/interface/dialogs/settings.py +++ b/src/interface/dialogs/settings.py @@ -1,9 +1,11 @@ from interface.utils.tabhelper import create_tab +from interface.dialogs.theme import _accent_selection_color from utils.config.settings import save_settings from interface.utils.svghelper import svg_icon from utils.logging.logs import consoleLog from PySide6.QtCore import Qt, QSize from utils.data.state import state +from PySide6.QtGui import QColor from PySide6 import QtWidgets from PySide6.QtWidgets import ( QLineEdit, @@ -13,9 +15,10 @@ from PySide6.QtWidgets import ( QDialog, QLabel, QHBoxLayout, - QSpinBox, QSlider, QCheckBox, + QSpinBox, QSlider, QCheckBox, QFileDialog, QComboBox, + QColorDialog ) import platform @@ -91,9 +94,31 @@ def settings_dialog(self): transparent_window_checkbox.toggled.connect(lambda checked: setattr(state, 'window_transparency', checked)) # Accent Color - accent_color_container, accent_color_input = create_widget(QLineEdit, "Accent Color (requires restart): ", width=180, height=30) + accent_color_container, accent_color_input = create_widget(QLineEdit, "Accent Color: ", width=180, height=30) + accent_color_button = QPushButton("") + accent_color_button.setFixedSize(21, 21) + accent_color_button.setCursor(Qt.CursorShape.PointingHandCursor) + + def update_accent_button_color(color_str): + c = color_str.strip() + accent_color_button.setStyleSheet( + f"QPushButton {{ background-color: {c if c else 'transparent'}; border: 1px solid palette(mid); border-radius: 3px; }}" + ) + + def browse_accent_color(): + current = accent_color_input.text().strip() + initial = QColor(current) if current else QColor() + color = QColorDialog.getColor(initial, dialog, "Select Accent Color") + if color.isValid(): + accent_color_input.setText(color.name()) + + accent_color_input.textChanged.connect(update_accent_button_color) + accent_color_button.clicked.connect(browse_accent_color) accent_color_input.setPlaceholderText("e.g. #fca7d7") accent_color_input.setText(state.accent_color) + update_accent_button_color(state.accent_color) + accent_color_container.layout().insertWidget(1, accent_color_button) + # API URL Widget api_url_container, api_url = create_widget(QLineEdit, "API Server URL: ", width=180, height=30) @@ -299,6 +324,13 @@ def settings_dialog(self): image_position=image_position_combo.currentText(), accent_color=accent_color_input.text().strip() ) + color = _accent_selection_color() + if color: + self.tracker_list.setStyleSheet( + f"QComboBox QAbstractItemView::item:selected {{ background: {color}; }}" + ) + else: + self.tracker_list.setStyleSheet("") cancel_btn.clicked.connect(dialog.reject) layout.addWidget(cancel_btn) diff --git a/src/utils/config/settings.py b/src/utils/config/settings.py index 09df0da..115fd7a 100644 --- a/src/utils/config/settings.py +++ b/src/utils/config/settings.py @@ -2,6 +2,9 @@ from network.libtorrent_int import update_settings from utils.config.config import create_config from utils.logging.logs import consoleLog from utils.data.state import state +import qdarktheme +import platform + @@ -36,6 +39,10 @@ def save_settings(close=lambda: None, apiurl=None, download_path=None, down_spee state.image_position = image_position if accent_color is not None: state.accent_color = accent_color + custom_colors = {"primary": state.accent_color} if state.accent_color else {} + if state.window_transparency and platform.system() != "Windows": + custom_colors["background"] = "#00000000" + qdarktheme.setup_theme("auto", custom_colors=custom_colors if custom_colors else None) update_settings() # Update LibTorrent Session Settings consoleLog("Saved Settings") From cc51f57e183c868862f249a1901fd6403b22f0d4 Mon Sep 17 00:00:00 2001 From: Vxrtrauter <101264710+Vxrtrauter@users.noreply.github.com> Date: Wed, 8 Apr 2026 01:41:32 +0200 Subject: [PATCH 02/24] fix: Add locks, logging refactor, and bugfixes Improve thread-safety, logging, and various bug fixes across the app. Key changes: - Add and use state.downloads_lock around accesses to state.active_downloads in multiple modules (UI, libtorrent, misc, direct downloads) to prevent race conditions. - Prevent concurrent dl_status_loop runs using a non-blocking _loop_lock and ensure proper iteration over a snapshot of downloads. - Add metadata timeout when waiting for torrent metadata and fix free-space check logic to remove or reject downloads appropriately. - Refactor download log persistence: add _load_downloads and _save_downloads helpers and centralize JSON read/write logic in utils/logging/logs.py. - Optimize DirectDownloadStatus speed window by switching to deque and using popleft to trim older samples. - Trim console log widget to 500 lines to avoid unbounded growth. - Add small state fields for custom image positioning (x/y and enabled flag). - Fix Steamrip scraper caching behavior and make Uztracker return an empty list on fetch failures. - Add defensive checks in run_download to handle missing links. These changes focus on stability and correctness for concurrent download handling and durable logging. --- src/data/sources/steamrip.py | 5 +- src/data/sources/uztracker.py | 2 +- src/interface/dialogs/contextmenu.py | 21 +++-- src/interface/dialogs/downloadmodel.py | 6 +- src/interface/gui.py | 9 ++ src/network/direct_download/__init__.py | 10 ++- src/network/direct_download/status.py | 8 +- src/network/libtorrent_int.py | 96 +++++++++++--------- src/network/libtorrent_misc.py | 25 ++++-- src/utils/data/state.py | 3 + src/utils/logging/logs.py | 113 +++++++----------------- src/utils/network/download.py | 6 ++ 12 files changed, 149 insertions(+), 155 deletions(-) diff --git a/src/data/sources/steamrip.py b/src/data/sources/steamrip.py index 71de40e..387d860 100644 --- a/src/data/sources/steamrip.py +++ b/src/data/sources/steamrip.py @@ -50,6 +50,9 @@ class SteamripScraper: for i in range(len(names)): ret.append({"title" : names[i], "url" : links[i]}) + self.cache["data"] = ret + self.cache["last_fetched"] = current_time + return ret def scrape_steamrip_game_downloads(self, gamelink): @@ -72,8 +75,6 @@ class SteamripScraper: if len(link) != 1: ret.append(link) - self.cache["data"] = ret - return ret def get_download_link(self, post: Dict): diff --git a/src/data/sources/uztracker.py b/src/data/sources/uztracker.py index 027394e..1e871af 100644 --- a/src/data/sources/uztracker.py +++ b/src/data/sources/uztracker.py @@ -43,7 +43,7 @@ class UztrackerScraper: except requests.RequestException as e: consoleLog(f"Failed to fetch {search_url}: {e}") - return None + return [] def get_download_link(self, post: Dict): return get_magnet_link(post["url"]) diff --git a/src/interface/dialogs/contextmenu.py b/src/interface/dialogs/contextmenu.py index fb8c98f..1125b43 100644 --- a/src/interface/dialogs/contextmenu.py +++ b/src/interface/dialogs/contextmenu.py @@ -34,10 +34,12 @@ class ContextMenu: if not hasattr(self, '_context_menu_row'): return row = self._context_menu_row - if row < 0 or row >= len(state.active_downloads): - return - magnet_link = list(state.active_downloads.keys())[row] - magnetdl = state.active_downloads[magnet_link] + with state.downloads_lock: + if row < 0 or row >= len(state.active_downloads): + return + keys = list(state.active_downloads.keys()) + magnet_link = keys[row] + magnetdl = state.active_downloads[magnet_link] download_path = magnetdl.save_path() if download_path and os.path.exists(download_path): if platform.system() == "Windows": @@ -51,9 +53,11 @@ class ContextMenu: if not hasattr(self, '_context_menu_row'): return row = self._context_menu_row - if row < 0 or row >= len(state.active_downloads): - return - magnet_link = list(state.active_downloads.keys())[row] + with state.downloads_lock: + if row < 0 or row >= len(state.active_downloads): + return + keys = list(state.active_downloads.keys()) + magnet_link = keys[row] clipboard = QtWidgets.QApplication.clipboard() clipboard.setText(magnet_link) @@ -64,7 +68,8 @@ class ContextMenu: with state.downloads_lock: if row < 0 or row >= len(state.active_downloads): return - magnet_link = list(state.active_downloads.keys())[row] + keys = list(state.active_downloads.keys()) + magnet_link = keys[row] magnetdl = state.active_downloads[magnet_link] # Cache the name BEFORE removing from session diff --git a/src/interface/dialogs/downloadmodel.py b/src/interface/dialogs/downloadmodel.py index 6cb0237..d6e872e 100644 --- a/src/interface/dialogs/downloadmodel.py +++ b/src/interface/dialogs/downloadmodel.py @@ -34,7 +34,8 @@ class DownloadModel(QAbstractTableModel): return None try: - magnet_link = list(state.active_downloads.keys())[index.row()] + keys = list(state.active_downloads.keys()) + magnet_link = keys[index.row()] magnetdl = state.active_downloads[magnet_link] status = magnetdl.status() except (IndexError, KeyError, RuntimeError): @@ -101,7 +102,8 @@ class DownloadModel(QAbstractTableModel): if row >= len(state.active_downloads) or row < 0: return try: - magnet_link = list(state.active_downloads.keys())[row] + keys = list(state.active_downloads.keys()) + magnet_link = keys[row] magnetdl = state.active_downloads[magnet_link] status = magnetdl.status() except (IndexError, KeyError, RuntimeError): diff --git a/src/interface/gui.py b/src/interface/gui.py index cd1765a..348ef41 100644 --- a/src/interface/gui.py +++ b/src/interface/gui.py @@ -315,6 +315,15 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): def _on_log_signal(self, text): if hasattr(self, 'consoleLog'): self.consoleLog.append(text) + # Delete lines if exceeding 500 + doc = self.consoleLog.document() + if doc.blockCount() > 500: + cursor = self.consoleLog.textCursor() + cursor.movePosition(cursor.MoveOperation.Start) + cursor.movePosition(cursor.MoveOperation.Down, cursor.MoveMode.KeepAnchor, doc.blockCount() - 500) + cursor.removeSelectedText() + cursor.deleteChar() + self.consoleLog.verticalScrollBar().setValue( self.consoleLog.verticalScrollBar().maximum() ) diff --git a/src/network/direct_download/__init__.py b/src/network/direct_download/__init__.py index a082b51..f423484 100644 --- a/src/network/direct_download/__init__.py +++ b/src/network/direct_download/__init__.py @@ -15,9 +15,10 @@ def add_direct_download(url: str, title: str, dl_path: Optional[str] = None, hea if dl_path is None: dl_path = state.download_path - if url in state.active_downloads: - consoleLog(f"Download already active: {title}") - return + with state.downloads_lock: + if url in state.active_downloads: + consoleLog(f"Download already active: {title}") + return filename = ( detect_filename_from_headers(url, DirectDownloadHandle.USER_AGENT) @@ -26,7 +27,8 @@ def add_direct_download(url: str, title: str, dl_path: Optional[str] = None, hea ) handle = DirectDownloadHandle(url, filename, dl_path, headers, single_threaded) - state.active_downloads[url] = handle + with state.downloads_lock: + state.active_downloads[url] = handle add_download_log(title, url, "", False) handle.start() consoleLog(f"Started direct download: {filename}") diff --git a/src/network/direct_download/status.py b/src/network/direct_download/status.py index aabb87b..7f997a0 100644 --- a/src/network/direct_download/status.py +++ b/src/network/direct_download/status.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from collections import deque from typing import Optional import libtorrent as lt import threading @@ -31,7 +32,7 @@ class DirectDownloadStatus: self._upload_rate = 0 self._chunk_bytes: dict[int, int] = {} - self._speed_window: list[tuple[float, int]] = [] + self._speed_window: deque[tuple[float, int]] = deque() self._speed_window_size = 3.0 @property @@ -65,9 +66,8 @@ class DirectDownloadStatus: self._speed_window.append((now, self._total_wanted_done)) cutoff = now - self._speed_window_size - self._speed_window = [ - (t, b) for t, b in self._speed_window if t >= cutoff - ] + while self._speed_window and self._speed_window[0][0] < cutoff: + self._speed_window.popleft() if len(self._speed_window) >= 2: oldest_time, oldest_bytes = self._speed_window[0] dt = now - oldest_time diff --git a/src/network/libtorrent_int.py b/src/network/libtorrent_int.py index db31889..a1e59da 100644 --- a/src/network/libtorrent_int.py +++ b/src/network/libtorrent_int.py @@ -10,7 +10,7 @@ import time import os -loop_running = False +_loop_lock = threading.Lock() def get_free_space_mb(dirname): if platform.system() == 'Windows': @@ -23,7 +23,9 @@ def get_free_space_mb(dirname): def check_space(): while not state.shutdown_event.is_set(): - for _, magnetdl in list(state.active_downloads.items()): + with state.downloads_lock: + items = list(state.active_downloads.items()) + for _, magnetdl in items: try: status = magnetdl.status() except RuntimeError: @@ -81,9 +83,11 @@ def add_download(magnet_uri): init_session() free_space = get_free_space_mb(state.download_path) - if magnet_uri in state.active_downloads: + with state.downloads_lock: + already_active = magnet_uri in state.active_downloads + handle = state.active_downloads.get(magnet_uri) if already_active else None + if already_active: try: - handle = state.active_downloads[magnet_uri] status = handle.status() if status.has_metadata: @@ -93,7 +97,8 @@ def add_download(magnet_uri): consoleLog(f"File Deleted, redownloading: {status.name}") state.dl_session.remove_torrent(handle) - del state.active_downloads[magnet_uri] + with state.downloads_lock: + del state.active_downloads[magnet_uri] else: consoleLog("Skipping Downloading, download already running...") return False @@ -102,23 +107,26 @@ def add_download(magnet_uri): return False except RuntimeError as e: consoleLog(f"Error in LibTorrent Handle: {e}") - del state.active_downloads[magnet_uri] + with state.downloads_lock: + del state.active_downloads[magnet_uri] try: params = lt.parse_magnet_uri(magnet_uri) params.save_path = state.download_path handle = state.dl_session.add_torrent(params) + metadata_timeout = 60 + metadata_start = time.time() while not handle.has_metadata(): + if time.time() - metadata_start > metadata_timeout: + state.dl_session.remove_torrent(handle) + consoleLog("Timed out waiting for torrent metadata") + return False time.sleep(1) total_size = handle.get_torrent_info().total_size() - if free_space > total_size: - magnetdl = lt.parse_magnet_uri(magnet_uri) - magnetdl.save_path = state.download_path - download = state.dl_session.add_torrent(magnetdl) - else: + if free_space <= total_size: state.dl_session.remove_torrent(handle) consoleLog("Not enough free space to download this item.") return False @@ -126,8 +134,9 @@ def add_download(magnet_uri): except Exception as e: consoleLog(f"Failed to add torrent or fetch info: {e}") return False - if download: - state.active_downloads[magnet_uri] = download + if handle: + with state.downloads_lock: + state.active_downloads[magnet_uri] = handle consoleLog(f"Added {magnet_uri} to downloads") run_thread(threading.Thread(target=dl_status_loop)) @@ -140,9 +149,10 @@ def add_seed(magnet_uri, file_path): init_session() - if magnet_uri in state.active_downloads: - consoleLog("Already seeding this torrent") - return False + with state.downloads_lock: + if magnet_uri in state.active_downloads: + consoleLog("Already seeding this torrent") + return False try: magnetdl = lt.parse_magnet_uri(magnet_uri) @@ -151,43 +161,43 @@ def add_seed(magnet_uri, file_path): except Exception as e: consoleLog(f"Failed to add seed: {e}") return False - state.active_downloads[magnet_uri] = handle + with state.downloads_lock: + state.active_downloads[magnet_uri] = handle state.seeded_magnets.add(magnet_uri) return True def dl_status_loop(): - global loop_running - if loop_running == True: + if not _loop_lock.acquire(blocking=False): return - loop_running = True - completed_set = set() - - if not state.active_downloads: - consoleLog("No active downloads") - loop_running = False - return - - while state.active_downloads and not state.shutdown_event.is_set(): - for magnet_uri, magnetdl in list(state.active_downloads.items()): - try: - status = magnetdl.status() - except RuntimeError: - continue - - if status.state == lt.torrent_status.seeding and magnet_uri not in completed_set: - consoleLog(f"Download completed: {status.name}") - - completed_set.add(magnet_uri) + try: + completed_set = set() if not state.active_downloads: - loop_running = False - break + consoleLog("No active downloads") + return - time.sleep(1) - - loop_running = False + while state.active_downloads and not state.shutdown_event.is_set(): + with state.downloads_lock: + items = list(state.active_downloads.items()) + for magnet_uri, magnetdl in items: + try: + status = magnetdl.status() + except RuntimeError: + continue + + if status.state == lt.torrent_status.seeding and magnet_uri not in completed_set: + consoleLog(f"Download completed: {status.name}") + + completed_set.add(magnet_uri) + + if not state.active_downloads: + break + + time.sleep(1) + finally: + _loop_lock.release() def update_settings(): diff --git a/src/network/libtorrent_misc.py b/src/network/libtorrent_misc.py index eeb471e..b1c6664 100644 --- a/src/network/libtorrent_misc.py +++ b/src/network/libtorrent_misc.py @@ -9,19 +9,23 @@ import os def cleanup_session(): if state.dl_session is not None: - for magnetdl in state.active_downloads.values(): - if hasattr(magnetdl, 'pause'): # Check it's a handle - magnetdl.pause() + with state.downloads_lock: + for magnetdl in state.active_downloads.values(): + if hasattr(magnetdl, 'pause'): # check it's a handle + magnetdl.pause() del state.dl_session state.dl_session = None - state.active_downloads.clear() + with state.downloads_lock: + state.active_downloads.clear() def send_notification(shutdown_event): notified = set() while not shutdown_event.is_set(): try: - for magnet_uri, magnetdl in list(state.active_downloads.items()): + with state.downloads_lock: + items = list(state.active_downloads.items()) + for magnet_uri, magnetdl in items: if isinstance(magnetdl, dict): continue @@ -42,7 +46,9 @@ def update_log(shutdown_event): updated = set() while not shutdown_event.is_set(): try: - for magnet_uri, magnetdl in list(state.active_downloads.items()): + with state.downloads_lock: + items = list(state.active_downloads.items()) + for magnet_uri, magnetdl in items: if isinstance(magnetdl, dict): continue @@ -63,7 +69,9 @@ def update_log(shutdown_event): def check_deleted_files(shutdown_event): while not shutdown_event.is_set(): try: - for magnet_uri, magnetdl in list(state.active_downloads.items()): + with state.downloads_lock: + items = list(state.active_downloads.items()) + for magnet_uri, magnetdl in items: if isinstance(magnetdl, dict): continue @@ -75,7 +83,8 @@ def check_deleted_files(shutdown_event): if not os.path.exists(file_path): consoleLog(f"Registered File Deletion: {status.name}") state.dl_session.remove_torrent(magnetdl) - del state.active_downloads[magnet_uri] + with state.downloads_lock: + del state.active_downloads[magnet_uri] except Exception as e: consoleLog(f"Exception while checking for file deletions: {e}") time.sleep(5) \ No newline at end of file diff --git a/src/utils/data/state.py b/src/utils/data/state.py index 1004d0a..d1dc6e0 100644 --- a/src/utils/data/state.py +++ b/src/utils/data/state.py @@ -38,6 +38,9 @@ class AppState(QObject): self._image_opacity: int = 100 self._image_as_wallpaper: bool = False self._image_position: str = "bottom-right" # top-left, top-right, bottom-left, bottom-right, center + self._image_custom_position: bool = False + self._image_x: int = 0 + self._image_y: int = 0 # Trackers / Scraping self.posts: list[Dict[str,str]] | None = None # titles, urls, author, seeders, leechers diff --git a/src/utils/logging/logs.py b/src/utils/logging/logs.py index da8ade4..03b9de4 100644 --- a/src/utils/logging/logs.py +++ b/src/utils/logging/logs.py @@ -8,6 +8,27 @@ import os import re +def _downloads_file_path() -> str: + return os.path.join(state.settings_path, "downloads.json") + +def _load_downloads() -> list[Download]: + downloads_file = _downloads_file_path() + if os.path.exists(downloads_file) and os.path.getsize(downloads_file) > 0: + try: + with open(downloads_file, "r") as file: + existing_data = json.load(file) + return [Download(**d) for d in existing_data.get("data", [])] + except (json.JSONDecodeError, TypeError) as e: + consoleLog(f"Error loading downloads.json: {e}") + return [] + +def _save_downloads(downloads: list[Download]) -> DownloadList: + download_list = DownloadList(data=downloads, count=len(downloads)) + with open(_downloads_file_path(), "w") as file: + json.dump(asdict(download_list), file, indent=4) + return download_list + + def add_download_log(title, url, magnet_uri, completed) -> DownloadList: # wait for metadata outside the lock to avoid blocking other threads magnetdl = state.active_downloads.get(magnet_uri) @@ -28,17 +49,7 @@ def add_download_log(title, url, magnet_uri, completed) -> DownloadList: return _add_download_log_inner(title, url, magnet_uri, completed, path) def _add_download_log_inner(title, url, magnet_uri, completed, path) -> DownloadList: - downloads_file = os.path.join(state.settings_path, "downloads.json") - - if os.path.exists(downloads_file) and os.path.getsize(downloads_file) > 0: - try: - with open(downloads_file, "r") as file: - existing_data = json.load(file) - downloads = [Download(**d) for d in existing_data.get("data", [])] - except json.JSONDecodeError: - downloads = [] - else: - downloads = [] + downloads = _load_downloads() if any((magnet_uri and d.magnet_uri == magnet_uri) or (url and d.url == url) for d in downloads): if magnet_uri and magnet_uri in state.active_downloads: @@ -60,30 +71,14 @@ def _add_download_log_inner(title, url, magnet_uri, completed, path) -> Download )) consoleLog(f"Added {title} to Log File") - - download_list = DownloadList(data=downloads, count=len(downloads)) - with open(downloads_file, "w") as file: - json.dump(asdict(download_list), file, indent=4) - - return download_list + return _save_downloads(downloads) def remove_download_log(magnet_uri) -> DownloadList: with state.downloads_lock: return _remove_download_log_inner(magnet_uri) def _remove_download_log_inner(magnet_uri) -> DownloadList: - downloads_file = os.path.join(state.settings_path, "downloads.json") - - if os.path.exists(downloads_file) and os.path.getsize(downloads_file) > 0: - try: - with open(downloads_file, "r") as file: - existing_data = json.load(file) - downloads = [Download(**d) for d in existing_data.get("data", [])] - except json.JSONDecodeError: - downloads = [] - else: - downloads = [] - + downloads = _load_downloads() magnet_link = (magnet_uri or "").strip() if not magnet_link: @@ -93,30 +88,14 @@ def _remove_download_log_inner(magnet_uri) -> DownloadList: downloads = [d for d in downloads if (getattr(d, 'magnet_uri', None) or "").strip() != magnet_link and (getattr(d, 'url', None) or "").strip() != magnet_link] consoleLog(f"Removed {title} from Log File") - - download_list = DownloadList(data=downloads, count=len(downloads)) - with open(downloads_file, "w") as file: - json.dump(asdict(download_list), file, indent=4) - - return download_list + return _save_downloads(downloads) def update_download_completed(magnet_uri, completed) -> DownloadList: with state.downloads_lock: return _update_download_completed_inner(magnet_uri, completed) def _update_download_completed_inner(magnet_uri, completed) -> DownloadList: - downloads_file = os.path.join(state.settings_path, "downloads.json") - - if os.path.exists(downloads_file) and os.path.getsize(downloads_file) > 0: - try: - with open(downloads_file, "r") as file: - existing_data = json.load(file) - downloads = [Download(**d) for d in existing_data.get("data", [])] - except (json.JSONDecodeError, TypeError) as e: - consoleLog(f"Error loading downloads.json: {e}") - downloads = [] - else: - downloads = [] + downloads = _load_downloads() identifier = (magnet_uri or "").strip() if state.debug: @@ -145,13 +124,8 @@ def _update_download_completed_inner(magnet_uri, completed) -> DownloadList: consoleLog("No matching download found to update") return DownloadList(data=downloads, count=len(downloads)) - download_list = DownloadList(data=downloads, count=len(downloads)) - with open(downloads_file, "w") as file: - json.dump(asdict(download_list), file, indent=4) - consoleLog("Updated download log") - - return download_list + return _save_downloads(downloads) def get_download_logs() -> DownloadList: @@ -159,18 +133,7 @@ def get_download_logs() -> DownloadList: return _get_download_logs_inner() def _get_download_logs_inner() -> DownloadList: - downloads_file = os.path.join(state.settings_path, "downloads.json") - - if os.path.exists(downloads_file) and os.path.getsize(downloads_file) > 0: - try: - with open(downloads_file, "r") as file: - existing_data = json.load(file) - downloads = [Download(**d) for d in existing_data.get("data", [])] - except json.JSONDecodeError: - downloads = [] - else: - downloads = [] - + downloads = _load_downloads() return DownloadList(data=downloads, count=len(downloads)) @@ -195,18 +158,7 @@ def update_download_completed_by_hash(info_hash, completed) -> DownloadList: return _update_download_completed_by_hash_inner(info_hash, completed) def _update_download_completed_by_hash_inner(info_hash, completed) -> DownloadList: - downloads_file = os.path.join(state.settings_path, "downloads.json") - - if os.path.exists(downloads_file) and os.path.getsize(downloads_file) > 0: - try: - with open(downloads_file, "r") as file: - existing_data = json.load(file) - downloads = [Download(**d) for d in existing_data.get("data", [])] - except (json.JSONDecodeError, TypeError) as e: - consoleLog(f"Error loading downloads.json: {e}") - downloads = [] - else: - downloads = [] + downloads = _load_downloads() info_hash_upper = (info_hash or "").upper().strip() consoleLog(f"Updating download by hash: {info_hash_upper}") @@ -228,13 +180,8 @@ def _update_download_completed_by_hash_inner(info_hash, completed) -> DownloadLi consoleLog("No matching download found to update") return DownloadList(data=downloads, count=len(downloads)) - download_list = DownloadList(data=downloads, count=len(downloads)) - with open(downloads_file, "w") as file: - json.dump(asdict(download_list), file, indent=4) - consoleLog("Updated download log") - - return download_list + return _save_downloads(downloads) def set_main_window(window): diff --git a/src/utils/network/download.py b/src/utils/network/download.py index 86a8152..74362a5 100644 --- a/src/utils/network/download.py +++ b/src/utils/network/download.py @@ -37,10 +37,16 @@ def run_download(post, headers: Optional[dict] = None): if ismagnet: link = result + if not link: + consoleLog("Failed to retrieve magnet link") + return add_magnet(link) add_download_log(post.get("title", "Unknown"), "", link, False) else: link, link_headers = result if isinstance(result, tuple) else (result, None) + if not link: + consoleLog("Failed to retrieve download link") + return final_headers = headers or link_headers add_direct_download(link, post.get("title", "Unknown"), headers=final_headers, single_threaded=final_headers is not None) From 49ba699155ecc9d00d8a4075753b4e60d58c3ce1 Mon Sep 17 00:00:00 2001 From: Vxrtrauter <101264710+Vxrtrauter@users.noreply.github.com> Date: Wed, 8 Apr 2026 01:44:21 +0200 Subject: [PATCH 03/24] fix: Debounce resize and cache loaded images Add a 100ms single-shot QTimer to debounce Resize events and avoid reloading/layout on every resize. Cache the loaded QImage in _cached_qimage/_cached_path and only reload from disk when the image path changes. Extract layout/apply logic into _apply_layout and clear cache in update_image_overlay when a new path is provided. These changes reduce disk I/O and repeated layout work during rapid resizes. --- src/interface/dialogs/image.py | 38 +++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/src/interface/dialogs/image.py b/src/interface/dialogs/image.py index f921958..922ce46 100644 --- a/src/interface/dialogs/image.py +++ b/src/interface/dialogs/image.py @@ -1,5 +1,5 @@ from PySide6.QtWidgets import QLabel, QGraphicsOpacityEffect, QStackedWidget -from PySide6.QtCore import Qt, QSize, QEvent, QObject +from PySide6.QtCore import Qt, QSize, QEvent, QObject, QTimer from PySide6.QtGui import QImage, QPixmap from utils.data.state import state import darkdetect @@ -17,8 +17,15 @@ class Image(QObject): self.overlay_label.setGraphicsEffect(self.opacity_effect) self._current_image_path = None + self._cached_qimage = None self._wallpaper_active = False self._original_stylesheets = {} + + self._resize_timer = QTimer(self) + self._resize_timer.setSingleShot(True) + self._resize_timer.setInterval(100) + self._resize_timer.timeout.connect(self._on_resize_finished) + parent.installEventFilter(self) state.image_changed.connect(self.update_image_overlay) @@ -28,9 +35,13 @@ class Image(QObject): def eventFilter(self, obj, event): if obj == self.application and event.type() == QEvent.Type.Resize: if self._current_image_path: - self._load_and_display(self._current_image_path) + self._resize_timer.start() return False + def _on_resize_finished(self): + if self._current_image_path: + self._apply_layout() + def _set_wallpaper_transparency(self, enabled): if self._wallpaper_active == enabled: return @@ -117,12 +128,27 @@ class Image(QObject): self._set_wallpaper_transparency(False) return self._current_image_path = image_path - parent = self.application - image = QImage(image_path) - if image.isNull(): + + # Only reload from disk when the path actually changed + if self._cached_qimage is None or image_path != getattr(self, '_cached_path', None): + image = QImage(image_path) + if image.isNull(): + self.overlay_label.hide() + self._cached_qimage = None + return + self._cached_qimage = image + self._cached_path = image_path + + self._apply_layout() + + def _apply_layout(self): + if self._cached_qimage is None or not state.image_enabled: self.overlay_label.hide() return + parent = self.application + image = self._cached_qimage + if getattr(state, "image_as_wallpaper", False): scaled_image = image.scaled( parent.size(), @@ -184,4 +210,6 @@ class Image(QObject): self.overlay_label.show() def update_image_overlay(self, new_image_path): + if new_image_path != getattr(self, '_cached_path', None): + self._cached_qimage = None self._load_and_display(new_image_path) \ No newline at end of file From a29f58c023b8497929058ab4272639c8e016a7c1 Mon Sep 17 00:00:00 2001 From: Vxrtrauter <101264710+Vxrtrauter@users.noreply.github.com> Date: Wed, 8 Apr 2026 01:52:08 +0200 Subject: [PATCH 04/24] Add fast layout update for image overlay Introduce _apply_layout_fast to update the image overlay quickly on window resize using the cached QImage and FastTransformation. Called from the Resize event handler before the resize timer, it handles wallpaper mode (fast scale, center-crop to parent) and non-wallpaper mode (positioning by state.image_position and state.image_offset) to provide immediate visual feedback without running the heavier _apply_layout. --- src/interface/dialogs/image.py | 36 ++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/interface/dialogs/image.py b/src/interface/dialogs/image.py index 922ce46..408f880 100644 --- a/src/interface/dialogs/image.py +++ b/src/interface/dialogs/image.py @@ -35,6 +35,7 @@ class Image(QObject): def eventFilter(self, obj, event): if obj == self.application and event.type() == QEvent.Type.Resize: if self._current_image_path: + self._apply_layout_fast() self._resize_timer.start() return False @@ -141,6 +142,41 @@ class Image(QObject): self._apply_layout() + def _apply_layout_fast(self): + if self._cached_qimage is None or not state.image_enabled: + return + + parent = self.application + image = self._cached_qimage + + if getattr(state, "image_as_wallpaper", False): + scaled = image.scaled( + parent.size(), + Qt.AspectRatioMode.KeepAspectRatioByExpanding, + Qt.TransformationMode.FastTransformation + ) + cx = (scaled.width() - parent.width()) // 2 + cy = (scaled.height() - parent.height()) // 2 + cropped = scaled.copy(cx, cy, parent.width(), parent.height()) + self.overlay_label.setPixmap(QPixmap.fromImage(cropped)) + self.overlay_label.setGeometry(0, 0, parent.width(), parent.height()) + else: + w = self.overlay_label.width() + h = self.overlay_label.height() + pos = state.image_position + off = int(state.image_offset) + if pos == "top-left": + x, y = off, off + elif pos == "top-right": + x, y = parent.width() - w - off, off + elif pos == "bottom-left": + x, y = off, parent.height() - h - off + elif pos == "center": + x, y = (parent.width() - w) // 2, (parent.height() - h) // 2 + else: + x, y = parent.width() - w - off, parent.height() - h - off + self.overlay_label.move(x, y) + def _apply_layout(self): if self._cached_qimage is None or not state.image_enabled: self.overlay_label.hide() From ea2470587d137fdb43e64c15954a8312ebceeb06 Mon Sep 17 00:00:00 2001 From: Vxrtrauter <101264710+Vxrtrauter@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:30:27 +0200 Subject: [PATCH 05/24] feat: Add darkreader lock and 404 tab handling Add a tag to prevent Dark Reader from altering the docs styling. Adjust client-side routing so only '/' is replaced with the 'start' tab; unknown paths now open the 'notfound' tab instead of being redirected to start. --- docs/index.html | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/index.html b/docs/index.html index 35f9f4a..53b4b93 100644 --- a/docs/index.html +++ b/docs/index.html @@ -3,6 +3,7 @@
+