From 5b42d8d607b3cca4e0f920f3b088ce333bef156c Mon Sep 17 00:00:00 2001 From: KeksNino Date: Tue, 7 Apr 2026 23:23:40 +0200 Subject: [PATCH 01/16] feat: add context menu to search list to copy magnet uri and open in browser --- src/interface/dialogs/contextmenu.py | 72 +++++++++++++++++++++++++++- src/interface/gui.py | 8 +++- src/utils/logging/loghandler.py | 2 +- 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/src/interface/dialogs/contextmenu.py b/src/interface/dialogs/contextmenu.py index fb8c98f..050de75 100644 --- a/src/interface/dialogs/contextmenu.py +++ b/src/interface/dialogs/contextmenu.py @@ -1,14 +1,18 @@ from utils.logging.logs import consoleLog, remove_download_log +from utils.data.tracker import get_magnet_link +from PySide6.QtCore import Qt, QPoint, QTimer +from utils.general.wrappers import run_thread from PySide6.QtWidgets import QMessageBox -from PySide6.QtCore import Qt, QPoint from utils.data.state import state from PySide6 import QtWidgets +import threading +import webbrowser import subprocess import platform import time import os -class ContextMenu: +class ContextMenu_Downloads: def __init__(self, main_window): self.main_window = main_window self.context_menu = QtWidgets.QMenu(main_window) @@ -147,3 +151,67 @@ class ContextMenu: consoleLog(f"Error deleting files: {last_error}", True) else: consoleLog(f"Removed entry (files not found): {torrent_name}", True) + +class ContextMenu_TrackerTable: + def __init__(self, main_window): + self.main_window = main_window + self.context_menu = QtWidgets.QMenu(main_window) + self.context_menu.addAction("Copy Tracker URL", self.copyMagnetURIAction) + self.context_menu.addAction("Open in Browser", self.openInBrowserAction) + + state.trackertable.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + state.trackertable.customContextMenuRequested.connect(self._show_context_menu) + + @property + def TrackerTable(self): + return state.trackertable + + def _show_context_menu(self, pos: QPoint): + index = self.TrackerTable.indexAt(pos) + if index.isValid(): + self._context_menu_row = index.row() + self.context_menu.exec(self.TrackerTable.viewport().mapToGlobal(pos)) + + def copyMagnetURIAction(self): + if not hasattr(self, '_context_menu_row'): + return + row = self._context_menu_row + if row < 0 or row >= len(state.trackers): + return + + item = list(state.trackertable.selectedItems())[0] + + def copy_magnet_uri(items): + if state.posts: + row = items[0].row() + post = state.posts[row] + url = post.get("url") + + magnet = get_magnet_link(url) + + if magnet: + QTimer.singleShot(0, lambda: copy_to_clipboard(magnet)) + + def copy_to_clipboard(magnet): + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(magnet) + consoleLog("Magnet URI copied to clipboard!", True) + + run_thread(threading.Thread(target=copy_magnet_uri, args=([item],))) + + def openInBrowserAction(self): + if not hasattr(self, '_context_menu_row'): + return + row = self._context_menu_row + if row < 0 or row >= len(state.trackers): + return + + item = list(state.trackertable.selectedItems())[0] + row = item.row() + + if state.posts: + post = state.posts[row] + url = post.get("url") + if url: + try: webbrowser.open(url) + except Exception as e: consoleLog(f"Failed to open URL: {e}", True) diff --git a/src/interface/gui.py b/src/interface/gui.py index cd1765a..cd3b8fd 100644 --- a/src/interface/gui.py +++ b/src/interface/gui.py @@ -143,6 +143,9 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): self.downloadList = _create_download_list(self) self.downloadList.viewport().installEventFilter(self) + table = state.trackertable + # table.clicked.connect(lambda: setattr(self, '_tracker_hovered_row', table.indexAt(table.viewport().mapFromGlobal(QtGui.QCursor.pos())).row())) + self.dlbutton.clicked.connect(lambda: run_thread(threading.Thread(target=download_selected, args=(state.trackertable.selectedItems(),)))) container.setLayout(containerLayout) @@ -251,7 +254,8 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): self.active_timer.timeout.connect(self.show_empty_downloads) self.active_timer.start(500) - self._context_menu = interface.dialogs.contextmenu.ContextMenu(self) + self._context_menu_downloads = interface.dialogs.contextmenu.ContextMenu_Downloads(self) + self._context_menu_trackertable = interface.dialogs.contextmenu.ContextMenu_TrackerTable(self) self.image_overlay = Image(self) def _apply_default_headers(self, table): @@ -343,7 +347,7 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): table.setRowCount(len(state.posts)) for x, rowdata in enumerate(state.posts): - for y, (key, data) in enumerate(rowdata.items()): + for y, (_, data) in enumerate(rowdata.items()): item = QTableWidgetItem(str(data)) item.setData(Qt.ItemDataRole.UserRole, x) table.setItem(x, y, item) diff --git a/src/utils/logging/loghandler.py b/src/utils/logging/loghandler.py index f9d5b3d..d3a9994 100644 --- a/src/utils/logging/loghandler.py +++ b/src/utils/logging/loghandler.py @@ -32,4 +32,4 @@ def check_downloads(downloads): elif download.completed == True and not os.path.exists(download.path): consoleLog(f"Inexistent Download: {download.title}") remove_download_log(download.magnet_uri) - \ No newline at end of file + From 1f16a58f219beb5b42fecc64f474993fb1130228 Mon Sep 17 00:00:00 2001 From: KeksNino Date: Tue, 7 Apr 2026 23:30:18 +0200 Subject: [PATCH 02/16] feat: double click to download in search results --- src/interface/gui.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/interface/gui.py b/src/interface/gui.py index cd3b8fd..c95d8c5 100644 --- a/src/interface/gui.py +++ b/src/interface/gui.py @@ -132,6 +132,7 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): self._tracker_hover_delegate = TrackerHoverDelegate(lambda: self._tracker_hovered_row, self) state.trackertable = _create_tracker_table(self) + state.trackertable.cellDoubleClicked.connect(lambda: run_thread(threading.Thread(target=download_selected, args=(state.trackertable.selectedItems(),)))) container = QWidget() containerLayout = QVBoxLayout() @@ -143,9 +144,6 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): self.downloadList = _create_download_list(self) self.downloadList.viewport().installEventFilter(self) - table = state.trackertable - # table.clicked.connect(lambda: setattr(self, '_tracker_hovered_row', table.indexAt(table.viewport().mapFromGlobal(QtGui.QCursor.pos())).row())) - self.dlbutton.clicked.connect(lambda: run_thread(threading.Thread(target=download_selected, args=(state.trackertable.selectedItems(),)))) container.setLayout(containerLayout) From 7526eb5d3052bc8832f3eb702e461e651b7cc17a Mon Sep 17 00:00:00 2001 From: KeksNino Date: Tue, 7 Apr 2026 23:42:50 +0200 Subject: [PATCH 03/16] feat: add download item context menu widget --- requirements.txt | 2 +- src/interface/dialogs/contextmenu.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a5fea62..0fecfdd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,4 @@ aiohttp==3.13.0 plyer==2.1.0 psutil==7.1.0 libtorrent==2.0.11 -libtorrent-windows-dll==0.0.3 \ No newline at end of file +libtorrent-windows-dll==0.0.3 diff --git a/src/interface/dialogs/contextmenu.py b/src/interface/dialogs/contextmenu.py index 050de75..327b7ba 100644 --- a/src/interface/dialogs/contextmenu.py +++ b/src/interface/dialogs/contextmenu.py @@ -1,9 +1,11 @@ from utils.logging.logs import consoleLog, remove_download_log +from utils.network.download import download_selected from utils.data.tracker import get_magnet_link from PySide6.QtCore import Qt, QPoint, QTimer from utils.general.wrappers import run_thread from PySide6.QtWidgets import QMessageBox from utils.data.state import state +from send2trash import send2trash from PySide6 import QtWidgets import threading import webbrowser @@ -158,6 +160,7 @@ class ContextMenu_TrackerTable: self.context_menu = QtWidgets.QMenu(main_window) self.context_menu.addAction("Copy Tracker URL", self.copyMagnetURIAction) self.context_menu.addAction("Open in Browser", self.openInBrowserAction) + self.context_menu.addAction("Download Item", self.downloadItemAction) state.trackertable.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) state.trackertable.customContextMenuRequested.connect(self._show_context_menu) @@ -215,3 +218,12 @@ class ContextMenu_TrackerTable: if url: try: webbrowser.open(url) except Exception as e: consoleLog(f"Failed to open URL: {e}", True) + + def downloadItemAction(self): + if not hasattr(self, '_context_menu_row'): + return + row = self._context_menu_row + if row < 0 or row >= len(state.trackers): + return + + run_thread(threading.Thread(target=download_selected, args=(state.trackertable.selectedItems(),))) From 75c649afdaec58c09678e478c281184959e9047d Mon Sep 17 00:00:00 2001 From: KeksNino Date: Tue, 7 Apr 2026 23:44:38 +0200 Subject: [PATCH 04/16] feat: move to trash instead of deleting permanently when deleting files --- requirements.txt | 1 + src/interface/dialogs/contextmenu.py | 5 +---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index 0fecfdd..fa53177 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ plyer==2.1.0 psutil==7.1.0 libtorrent==2.0.11 libtorrent-windows-dll==0.0.3 +send2trash==2.1.0 diff --git a/src/interface/dialogs/contextmenu.py b/src/interface/dialogs/contextmenu.py index 327b7ba..837e1a1 100644 --- a/src/interface/dialogs/contextmenu.py +++ b/src/interface/dialogs/contextmenu.py @@ -134,14 +134,11 @@ class ContextMenu_Downloads: except Exception as e: consoleLog(f"Exception while removing download from LibTorrent: {e}") if download_path and os.path.exists(download_path): - import shutil last_error = None for attempt in range(3): try: if os.path.isfile(download_path): - os.remove(download_path) - else: - shutil.rmtree(download_path) + send2trash(download_path) consoleLog(f"Deleted files for: {torrent_name}", True) last_error = None break From 767955e0086a4b024968bbc0cfa5983f15d973ab Mon Sep 17 00:00:00 2001 From: KeksNino Date: Wed, 8 Apr 2026 00:00:15 +0200 Subject: [PATCH 05/16] feat: add tray icon --- src/main.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/main.py b/src/main.py index 5722bca..f84b08c 100644 --- a/src/main.py +++ b/src/main.py @@ -6,16 +6,20 @@ from utils.logging.logs import set_main_window from network.libtorrent_int import check_space from utils.general.shutdown import closehelper from utils.general.wrappers import run_thread +from interface.assets.base64_icons import logo_base64 +from PySide6.QtWidgets import QSystemTrayIcon, QMenu from utils.general.shutdown import force_exit from utils.config.config import read_config from utils.logging.logs import consoleLog from interface.gui import MainWindow from utils.data.state import state +from PySide6.QtGui import QAction, QPixmap from PySide6 import QtWidgets from PySide6.QtCore import Qt import qdarktheme import threading import platform +import base64 import signal import time import sys @@ -26,6 +30,22 @@ def run_gui(app): custom_colors["primary"] = state.accent_color qdarktheme.setup_theme("auto", custom_colors=custom_colors if custom_colors else None) widget = MainWindow() + + pixmap = QPixmap() + image_data = base64.b64decode(logo_base64) + pixmap.loadFromData(image_data) + + tray = QSystemTrayIcon() + tray.setIcon(pixmap) + tray.setVisible(True) + + menu = QMenu() + + quit = QAction("Quit") + quit.triggered.connect(app.quit) + menu.addAction(quit) + + tray.setContextMenu(menu) # Check OS for window transparency compatibility and apply if state.window_transparency and platform.system() != "Windows": From 6592cd4ec1cdd83b8b7bf3da74b648a027dd3d91 Mon Sep 17 00:00:00 2001 From: KeksNino Date: Wed, 8 Apr 2026 00:15:46 +0200 Subject: [PATCH 06/16] feat: properly utilize close to tray and quit action --- src/interface/dialogs/settings.py | 10 ++++++++-- src/interface/gui.py | 8 ++++++++ src/main.py | 2 +- src/utils/config/config.py | 6 ++++-- src/utils/config/settings.py | 4 +++- src/utils/data/state.py | 1 + 6 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/interface/dialogs/settings.py b/src/interface/dialogs/settings.py index 6b6a25e..1f2b2c8 100644 --- a/src/interface/dialogs/settings.py +++ b/src/interface/dialogs/settings.py @@ -272,6 +272,11 @@ def settings_dialog(self): interface_layout.addWidget(interface_select) interface_container.setLayout(interface_layout) + # Close To Tray + close_to_tray_container, close_to_tray_checkbox = create_widget(QCheckBox, "Close to Tray: ") + close_to_tray_checkbox.setChecked(state.close_to_tray) + close_to_tray_checkbox.toggled.connect(lambda checked: setattr(state, 'close_to_tray', checked)) + # Save / Cancel buttons layout = QHBoxLayout() @@ -297,7 +302,8 @@ def settings_dialog(self): image_opacity.value(), image_as_wallpaper=image_mode_checkbox.isChecked(), image_position=image_position_combo.currentText(), - accent_color=accent_color_input.text().strip() + accent_color=accent_color_input.text().strip(), + close_to_tray=close_to_tray_checkbox.isChecked() ) cancel_btn.clicked.connect(dialog.reject) @@ -305,7 +311,7 @@ def settings_dialog(self): layout.addWidget(save_btn) tabs = QtWidgets.QTabWidget() - create_tab("General", [autoresume_container, update_checkbox_container, transparent_window_container, accent_color_container], tabs=tabs, stretch=True) + create_tab("General", [autoresume_container, update_checkbox_container, transparent_window_container, accent_color_container, close_to_tray_container], tabs=tabs, stretch=True) create_tab("Image", [enable_image_container, image_mode_container, image_position_container, image_width_container, image_offset_container, image_opacity_container], tabs=tabs, stretch=True) create_tab("Paths", [download_path_container, image_path_container], tabs=tabs, stretch=True) create_tab("Network", [interface_container, max_connections_container, max_downloads_container, up_speed_limit_container, down_speed_limit_container, api_url_container], tabs=tabs, stretch=True) diff --git a/src/interface/gui.py b/src/interface/gui.py index c95d8c5..91f3546 100644 --- a/src/interface/gui.py +++ b/src/interface/gui.py @@ -388,6 +388,14 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): super().changeEvent(event) def closeEvent(self, event: QCloseEvent): + if state.close_to_tray is True: + event.ignore() + self.hide() + return + else: + self.shutdown(event) + + def shutdown(self, event: QCloseEvent): closehelper() event.accept() from utils.general.shutdown import force_exit diff --git a/src/main.py b/src/main.py index f84b08c..b3e07a3 100644 --- a/src/main.py +++ b/src/main.py @@ -42,7 +42,7 @@ def run_gui(app): menu = QMenu() quit = QAction("Quit") - quit.triggered.connect(app.quit) + quit.triggered.connect(lambda: (closehelper(), force_exit())) menu.addAction(quit) tray.setContextMenu(menu) diff --git a/src/utils/config/config.py b/src/utils/config/config.py index fc481fe..290889a 100644 --- a/src/utils/config/config.py +++ b/src/utils/config/config.py @@ -15,7 +15,8 @@ def create_config(): "ignore_updates": str(state.ignore_updates), "autoresume": str(state.autoresume), "window_transparency": str(state.window_transparency), - "accent_color": str(state.accent_color) + "accent_color": str(state.accent_color), + "close_to_tray": str(state.close_to_tray) } config["Network"] = { @@ -86,6 +87,7 @@ def read_config(): state.autoresume = config.getboolean("General", "autoresume", fallback=state.autoresume) state.window_transparency = config.getboolean("General", "window_transparency", fallback=state.window_transparency) state.accent_color = config.get("General", "accent_color", fallback=state.accent_color) + state.close_to_tray = config.getboolean("General", "close_to_tray", fallback=state.close_to_tray) # Network state.api_url = config.get("Network", "api_url", fallback=state.api_url) @@ -147,4 +149,4 @@ def backup_config(config_path): os.replace(config_path, backup_path) consoleLog(f"Successfully created backup of corrupted config: {backup_path}") except Exception as e: - consoleLog(f"Failed to create backup of corrupted config: {e}") \ No newline at end of file + consoleLog(f"Failed to create backup of corrupted config: {e}") diff --git a/src/utils/config/settings.py b/src/utils/config/settings.py index 09df0da..24ab35b 100644 --- a/src/utils/config/settings.py +++ b/src/utils/config/settings.py @@ -5,7 +5,7 @@ from utils.data.state import state -def save_settings(close=lambda: None, apiurl=None, download_path=None, down_speed_limit=None, up_speed_limit=None, image_path=None, autoresume=None, max_connections=None, max_downloads=None, bound_interface=None, image_width=None, image_offset=None, image_opacity=None, image_as_wallpaper=None, image_position=None, accent_color=None): +def save_settings(close=lambda: None, apiurl=None, download_path=None, down_speed_limit=None, up_speed_limit=None, image_path=None, autoresume=None, max_connections=None, max_downloads=None, bound_interface=None, image_width=None, image_offset=None, image_opacity=None, image_as_wallpaper=None, image_position=None, accent_color=None, close_to_tray=None): if apiurl is not None: state.api_url = apiurl if download_path is not None: @@ -36,6 +36,8 @@ 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 + if close_to_tray is not None: + state.close_to_tray = close_to_tray update_settings() # Update LibTorrent Session Settings consoleLog("Saved Settings") diff --git a/src/utils/data/state.py b/src/utils/data/state.py index 1004d0a..d0526f6 100644 --- a/src/utils/data/state.py +++ b/src/utils/data/state.py @@ -30,6 +30,7 @@ class AppState(QObject): self.interfaces: List = [] self.active_interfaces: List = [] self.bound_interface: Any = None + self.close_to_tray: bool = True # Image self._image_enabled: bool = False From fc67ff11c9cbd43733a45459308f9a969f3ca0b5 Mon Sep 17 00:00:00 2001 From: KeksNino Date: Wed, 8 Apr 2026 00:23:24 +0200 Subject: [PATCH 07/16] feat: add show widget to tray icon and show the gui on left click --- src/main.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main.py b/src/main.py index b3e07a3..77436be 100644 --- a/src/main.py +++ b/src/main.py @@ -41,10 +41,16 @@ def run_gui(app): menu = QMenu() + show = QAction("Show") + show.triggered.connect(lambda: widget.show()) + menu.addAction(show) + quit = QAction("Quit") quit.triggered.connect(lambda: (closehelper(), force_exit())) menu.addAction(quit) + tray.activated.connect(lambda reason: widget.show() if reason == QSystemTrayIcon.ActivationReason.Trigger else None) + tray.setContextMenu(menu) # Check OS for window transparency compatibility and apply From 2568c7307caf8d524e8fcc9d1a67a4bc90c5121c Mon Sep 17 00:00:00 2001 From: Vxrtrauter <101264710+Vxrtrauter@users.noreply.github.com> Date: Wed, 8 Apr 2026 02:08:18 +0200 Subject: [PATCH 08/16] feat: add send2trash dependency to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 751f26e..27baad2 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ SoftwareManager is a Python-based GUI tool that simplifies searching and downloa psutil (7.1.0) libtorrent (2.0.11) libtorrent-windows-dll (0.0.3) + send2trash (2.1.0) ``` ## Activity From df5ebc2bc67ebac9391726b325b4589810ba9b8f Mon Sep 17 00:00:00 2001 From: KeksNino Date: Wed, 8 Apr 2026 02:10:01 +0200 Subject: [PATCH 09/16] feat: multi select, delete and cancel downloads --- src/interface/dialogs/contextmenu.py | 109 ++++++++++++++++++--------- 1 file changed, 73 insertions(+), 36 deletions(-) diff --git a/src/interface/dialogs/contextmenu.py b/src/interface/dialogs/contextmenu.py index 837e1a1..95725ea 100644 --- a/src/interface/dialogs/contextmenu.py +++ b/src/interface/dialogs/contextmenu.py @@ -64,29 +64,44 @@ class ContextMenu_Downloads: clipboard.setText(magnet_link) def cancelDownloadAction(self): - if not hasattr(self, '_context_menu_row'): - return - row = self._context_menu_row - with state.downloads_lock: - if row < 0 or row >= len(state.active_downloads): - return - magnet_link = list(state.active_downloads.keys())[row] - magnetdl = state.active_downloads[magnet_link] + selected_rows = sorted(set(index.row() for index in self.downloadList.selectedIndexes()), reverse=True) - # Cache the name BEFORE removing from session - try: - torrent_name = magnetdl.status().name - except RuntimeError: - torrent_name = "Unknown" + if not selected_rows: + return + + names = [] + items_to_remove = [] + + with state.downloads_lock: + keys = list(state.active_downloads.keys()) + + for row in selected_rows: + if 0 <= row < len(keys): + magnet_link = keys[row] + magnetdl = state.active_downloads[magnet_link] + + try: + name = magnetdl.status().name + except RuntimeError: + name = "Unknown" + + names.append(name) + items_to_remove.append((magnet_link, magnetdl, name)) confirm = QMessageBox.question( - self.main_window, "Cancel Download", - f"Are you sure you want to cancel the download of '{torrent_name}'?", + self.main_window, + "Cancel Downloads", + f"Are you sure you want to cancel {len(names)} download(s)?\n\n" + "\n".join(names), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) - if confirm == QMessageBox.StandardButton.Yes: + + if confirm != QMessageBox.StandardButton.Yes: + return + + for magnet_link, magnetdl, name in items_to_remove: with state.downloads_lock: state.active_downloads.pop(magnet_link, None) + remove_download_log(magnet_link) if state.dl_session: @@ -95,44 +110,65 @@ class ContextMenu_Downloads: except Exception as e: consoleLog(f"Exception while removing download from LibTorrent: {e}") - consoleLog(f"Cancelled download: {torrent_name}", True) + consoleLog(f"Cancelled download: {name}", True) def deleteFileAction(self): - if not hasattr(self, '_context_menu_row'): + selected_rows = sorted(set(index.row() for index in self.downloadList.selectedIndexes()), reverse=True) + + if not selected_rows: return - row = self._context_menu_row + + items_to_remove = [] + with state.downloads_lock: - if row < 0 or row >= len(state.active_downloads): - return - magnet_link = list(state.active_downloads.keys())[row] - magnetdl = state.active_downloads[magnet_link] - try: - status = magnetdl.status() - save_path = status.save_path - torrent_name = status.name - except RuntimeError: - consoleLog("Error: torrent handle already invalid", True) - with state.downloads_lock: - state.active_downloads.pop(magnet_link, None) - remove_download_log(magnet_link) + keys = list(state.active_downloads.keys()) + + for row in selected_rows: + if 0 <= row < len(keys): + magnet_link = keys[row] + magnetdl = state.active_downloads[magnet_link] + + try: + status = magnetdl.status() + save_path = status.save_path + torrent_name = status.name + except RuntimeError: + consoleLog("Error: torrent handle already invalid", True) + continue + + download_path = os.path.join(save_path, torrent_name) + + items_to_remove.append((magnet_link, magnetdl, torrent_name, download_path)) + + if not items_to_remove: return - download_path = os.path.join(save_path, torrent_name) + + names = [item[2] for item in items_to_remove] + confirm = QMessageBox.question( - self.main_window, "Delete Files", - f"Are you sure you want to delete the downloaded files of '{torrent_name}'? This action cannot be undone.", + self.main_window, + "Delete Files", + f"Are you sure you want to delete {len(names)} download(s)?\n\n" + "\n".join(names), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) - if confirm == QMessageBox.StandardButton.Yes: + + if confirm != QMessageBox.StandardButton.Yes: + return + + for magnet_link, magnetdl, torrent_name, download_path in items_to_remove: with state.downloads_lock: state.active_downloads.pop(magnet_link, None) + remove_download_log(magnet_link) + if state.dl_session: try: state.dl_session.remove_torrent(magnetdl) time.sleep(0.5) except Exception as e: consoleLog(f"Exception while removing download from LibTorrent: {e}") + if download_path and os.path.exists(download_path): last_error = None for attempt in range(3): @@ -146,6 +182,7 @@ class ContextMenu_Downloads: last_error = e if attempt < 2: time.sleep(0.5) + if last_error: consoleLog(f"Error deleting files: {last_error}", True) else: From d1df23f4e3b26dbcc873f36238eaaf15e8ae8e60 Mon Sep 17 00:00:00 2001 From: KeksNino Date: Wed, 8 Apr 2026 02:14:05 +0200 Subject: [PATCH 10/16] fix: application not closing fully when doing ctrl + c --- src/main.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main.py b/src/main.py index 77436be..1ab77ad 100644 --- a/src/main.py +++ b/src/main.py @@ -31,6 +31,12 @@ def run_gui(app): qdarktheme.setup_theme("auto", custom_colors=custom_colors if custom_colors else None) widget = MainWindow() + # Check OS for window transparency compatibility and apply + if state.window_transparency and platform.system() != "Windows": + widget.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + transparent_colors = {"background": "#00000000", **custom_colors} + qdarktheme.setup_theme("auto", custom_colors=transparent_colors) + pixmap = QPixmap() image_data = base64.b64decode(logo_base64) pixmap.loadFromData(image_data) @@ -50,17 +56,11 @@ def run_gui(app): menu.addAction(quit) tray.activated.connect(lambda reason: widget.show() if reason == QSystemTrayIcon.ActivationReason.Trigger else None) - tray.setContextMenu(menu) - - # Check OS for window transparency compatibility and apply - if state.window_transparency and platform.system() != "Windows": - widget.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) - transparent_colors = {"background": "#00000000", **custom_colors} - qdarktheme.setup_theme("auto", custom_colors=transparent_colors) set_main_window(widget) widget.show() + signal.signal(signal.SIGINT, signal.SIG_DFL) sys.exit(app.exec()) def keyboardinterrupthandler(signum, frame): From 810bed347bb5ec16f50e81672a09afd313036c9f Mon Sep 17 00:00:00 2001 From: KeksNino Date: Thu, 9 Apr 2026 01:03:13 +0200 Subject: [PATCH 11/16] fix: copy magnet uri to clipboard in context menu --- src/interface/dialogs/contextmenu.py | 36 ++++++++++++++++++---------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/interface/dialogs/contextmenu.py b/src/interface/dialogs/contextmenu.py index 95725ea..6e4c603 100644 --- a/src/interface/dialogs/contextmenu.py +++ b/src/interface/dialogs/contextmenu.py @@ -1,7 +1,7 @@ from utils.logging.logs import consoleLog, remove_download_log from utils.network.download import download_selected from utils.data.tracker import get_magnet_link -from PySide6.QtCore import Qt, QPoint, QTimer +from PySide6.QtCore import Qt, QPoint, QTimer, QObject, Signal from utils.general.wrappers import run_thread from PySide6.QtWidgets import QMessageBox from utils.data.state import state @@ -14,6 +14,14 @@ import platform import time import os +class Worker(QObject): + finished = Signal(str) + + def run(self, url): + magnet = get_magnet_link(url) + if magnet: + self.finished.emit(magnet) + class ContextMenu_Downloads: def __init__(self, main_window): self.main_window = main_window @@ -192,7 +200,7 @@ class ContextMenu_TrackerTable: def __init__(self, main_window): self.main_window = main_window self.context_menu = QtWidgets.QMenu(main_window) - self.context_menu.addAction("Copy Tracker URL", self.copyMagnetURIAction) + self.context_menu.addAction("Copy Magnet URI", self.copyMagnetURIAction) self.context_menu.addAction("Open in Browser", self.openInBrowserAction) self.context_menu.addAction("Download Item", self.downloadItemAction) @@ -216,25 +224,29 @@ class ContextMenu_TrackerTable: if row < 0 or row >= len(state.trackers): return - item = list(state.trackertable.selectedItems())[0] + selected = state.trackertable.selectedItems() + if not selected: + return - def copy_magnet_uri(items): - if state.posts: - row = items[0].row() - post = state.posts[row] - url = post.get("url") + row = selected[0].row() - magnet = get_magnet_link(url) + if not state.posts or row >= len(state.posts): + return - if magnet: - QTimer.singleShot(0, lambda: copy_to_clipboard(magnet)) + post = state.posts[row] + url = post.get("url") + if not url: + return def copy_to_clipboard(magnet): clipboard = QtWidgets.QApplication.clipboard() clipboard.setText(magnet) consoleLog("Magnet URI copied to clipboard!", True) - run_thread(threading.Thread(target=copy_magnet_uri, args=([item],))) + self._magnet_worker = Worker() + self._magnet_worker.finished.connect(copy_to_clipboard) + + run_thread(threading.Thread(target=self._magnet_worker.run, args=(url,))) def openInBrowserAction(self): if not hasattr(self, '_context_menu_row'): From dcba60c69585def14a4157930bead136771c09be Mon Sep 17 00:00:00 2001 From: KeksNino Date: Thu, 9 Apr 2026 02:15:36 +0200 Subject: [PATCH 12/16] fix: cant use copy magnet uri contextmenu widget more than once --- src/interface/dialogs/contextmenu.py | 34 +++++++++++++----- src/main.py | 53 ++++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/src/interface/dialogs/contextmenu.py b/src/interface/dialogs/contextmenu.py index 6e4c603..67bb51d 100644 --- a/src/interface/dialogs/contextmenu.py +++ b/src/interface/dialogs/contextmenu.py @@ -1,7 +1,7 @@ +from PySide6.QtCore import Qt, QPoint, QObject, Signal, QRunnable, QThreadPool from utils.logging.logs import consoleLog, remove_download_log from utils.network.download import download_selected from utils.data.tracker import get_magnet_link -from PySide6.QtCore import Qt, QPoint, QTimer, QObject, Signal from utils.general.wrappers import run_thread from PySide6.QtWidgets import QMessageBox from utils.data.state import state @@ -14,13 +14,21 @@ import platform import time import os -class Worker(QObject): + +class WorkerSignals(QObject): finished = Signal(str) - def run(self, url): - magnet = get_magnet_link(url) + +class Worker(QRunnable): + def __init__(self, url): + super().__init__() + self.url = url + self.signals = WorkerSignals() + + def run(self): + magnet = get_magnet_link(self.url) if magnet: - self.finished.emit(magnet) + self.signals.finished.emit(magnet) class ContextMenu_Downloads: def __init__(self, main_window): @@ -220,6 +228,8 @@ class ContextMenu_TrackerTable: def copyMagnetURIAction(self): if not hasattr(self, '_context_menu_row'): return + if not hasattr(self, "_workers"): + self._workers = [] row = self._context_menu_row if row < 0 or row >= len(state.trackers): return @@ -243,10 +253,18 @@ class ContextMenu_TrackerTable: clipboard.setText(magnet) consoleLog("Magnet URI copied to clipboard!", True) - self._magnet_worker = Worker() - self._magnet_worker.finished.connect(copy_to_clipboard) + + def start_worker(url): + worker = Worker(url) + worker.signals.finished.connect(copy_to_clipboard) - run_thread(threading.Thread(target=self._magnet_worker.run, args=(url,))) + self._workers.append(worker) + + worker.signals.finished.connect(lambda _: self._workers.remove(worker)) + + QThreadPool.globalInstance().start(worker) + + start_worker(url) def openInBrowserAction(self): if not hasattr(self, '_context_menu_row'): diff --git a/src/main.py b/src/main.py index 1ab77ad..3cd40ff 100644 --- a/src/main.py +++ b/src/main.py @@ -1,6 +1,7 @@ from network.libtorrent_misc import send_notification, update_log, check_deleted_files from utils.logging.loghandler import split_data, check_completed, check_downloads from network.interface import list_interfaces, init_interfaces +from PySide6.QtNetwork import QLocalServer, QLocalSocket from utils.logging.logs import get_download_logs from utils.logging.logs import set_main_window from network.libtorrent_int import check_space @@ -10,12 +11,12 @@ from interface.assets.base64_icons import logo_base64 from PySide6.QtWidgets import QSystemTrayIcon, QMenu from utils.general.shutdown import force_exit from utils.config.config import read_config +from PySide6.QtGui import QAction, QPixmap from utils.logging.logs import consoleLog from interface.gui import MainWindow +from PySide6.QtCore import Qt, QObject from utils.data.state import state -from PySide6.QtGui import QAction, QPixmap from PySide6 import QtWidgets -from PySide6.QtCore import Qt import qdarktheme import threading import platform @@ -24,6 +25,40 @@ import signal import time import sys +SERVER_NAME = "SoftwareManager_python" + +class SingleInstance(QObject): + def __init__(self): + super().__init__() + self.server = QLocalServer() + + socket = QLocalSocket() + socket.connectToServer(SERVER_NAME) + + if socket.waitForConnected(100): + socket.write(b"raise") + socket.flush() + socket.waitForBytesWritten(100) + self.is_running = True + else: + QLocalServer.removeServer(SERVER_NAME) + self.server.listen(SERVER_NAME) + self.server.newConnection.connect(self.handle_connection) + self.is_running = False + self.is_running = False + + def handle_connection(self): + socket = self.server.nextPendingConnection() + socket.readyRead.connect(lambda: self.read_socket(socket)) + + def read_socket(self, socket): + msg = socket.readAll().data() + if msg == b"raise": + self.on_raise() + + def on_raise(self): + pass + def run_gui(app): custom_colors = {} if state.accent_color: @@ -58,6 +93,20 @@ def run_gui(app): tray.activated.connect(lambda reason: widget.show() if reason == QSystemTrayIcon.ActivationReason.Trigger else None) tray.setContextMenu(menu) + def show_from_tray(): + widget.show() + widget.raise_() + widget.activateWindow() + + single = SingleInstance() + if single.is_running: + consoleLog("Another instance is already running. Exiting this instance.") + closehelper() + force_exit() + + app._single_instance = single + single.on_raise = show_from_tray + set_main_window(widget) widget.show() signal.signal(signal.SIGINT, signal.SIG_DFL) From b4ba9ea4d18431b96c1dbfc116fd8d766e2e402a Mon Sep 17 00:00:00 2001 From: KeksNino Date: Thu, 9 Apr 2026 22:27:26 +0200 Subject: [PATCH 13/16] fix: fixed absolutely nothing by changing some qt worker stuff --- src/interface/dialogs/contextmenu.py | 39 ++++++++++------------------ 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/src/interface/dialogs/contextmenu.py b/src/interface/dialogs/contextmenu.py index 67bb51d..1281b95 100644 --- a/src/interface/dialogs/contextmenu.py +++ b/src/interface/dialogs/contextmenu.py @@ -1,4 +1,4 @@ -from PySide6.QtCore import Qt, QPoint, QObject, Signal, QRunnable, QThreadPool +from PySide6.QtCore import Qt, QPoint, Signal, QThreadPool, QThread from utils.logging.logs import consoleLog, remove_download_log from utils.network.download import download_selected from utils.data.tracker import get_magnet_link @@ -15,20 +15,17 @@ import time import os -class WorkerSignals(QObject): +class MagnetWorker(QThread): finished = Signal(str) - -class Worker(QRunnable): - def __init__(self, url): - super().__init__() + def __init__(self, url, parent=None): + super().__init__(parent) self.url = url - self.signals = WorkerSignals() def run(self): magnet = get_magnet_link(self.url) if magnet: - self.signals.finished.emit(magnet) + self.finished.emit(magnet) class ContextMenu_Downloads: def __init__(self, main_window): @@ -228,8 +225,6 @@ class ContextMenu_TrackerTable: def copyMagnetURIAction(self): if not hasattr(self, '_context_menu_row'): return - if not hasattr(self, "_workers"): - self._workers = [] row = self._context_menu_row if row < 0 or row >= len(state.trackers): return @@ -248,23 +243,15 @@ class ContextMenu_TrackerTable: if not url: return - def copy_to_clipboard(magnet): - clipboard = QtWidgets.QApplication.clipboard() - clipboard.setText(magnet) - consoleLog("Magnet URI copied to clipboard!", True) + worker = MagnetWorker(url, parent=self.main_window) + worker.finished.connect(self._on_magnet_fetched) + worker.finished.connect(worker.deleteLater) + worker.start() - - def start_worker(url): - worker = Worker(url) - worker.signals.finished.connect(copy_to_clipboard) - - self._workers.append(worker) - - worker.signals.finished.connect(lambda _: self._workers.remove(worker)) - - QThreadPool.globalInstance().start(worker) - - start_worker(url) + def _on_magnet_fetched(self, magnet): + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(magnet) + consoleLog("Magnet URI copied to clipboard!", True) def openInBrowserAction(self): if not hasattr(self, '_context_menu_row'): From 7aea5c882a9a7057ce1a200de3cf17a05d16f1f8 Mon Sep 17 00:00:00 2001 From: Vxrtrauter <101264710+Vxrtrauter@users.noreply.github.com> Date: Thu, 9 Apr 2026 23:13:23 +0200 Subject: [PATCH 14/16] fix: tracker context menu logic and server name contextmenu.py: Harden context-menu handlers by validating row bounds against state.posts, avoid relying on selectedItems() in places where _context_menu_row is used, and handle directory paths when deleting downloads (os.path.isdir or isfile) before send2trash. Also streamline URL opening with exception handling. main.py: Update SERVER_NAME to "SoftwareManager_KeksPirates". These changes prevent out-of-range/selection errors and ensure directories are trashed safely; also update server identification. --- src/interface/dialogs/contextmenu.py | 31 +++++++++------------------- src/main.py | 2 +- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/src/interface/dialogs/contextmenu.py b/src/interface/dialogs/contextmenu.py index 1281b95..c575623 100644 --- a/src/interface/dialogs/contextmenu.py +++ b/src/interface/dialogs/contextmenu.py @@ -186,7 +186,7 @@ class ContextMenu_Downloads: last_error = None for attempt in range(3): try: - if os.path.isfile(download_path): + if os.path.isfile(download_path) or os.path.isdir(download_path): send2trash(download_path) consoleLog(f"Deleted files for: {torrent_name}", True) last_error = None @@ -226,16 +226,8 @@ class ContextMenu_TrackerTable: if not hasattr(self, '_context_menu_row'): return row = self._context_menu_row - if row < 0 or row >= len(state.trackers): - return - selected = state.trackertable.selectedItems() - if not selected: - return - - row = selected[0].row() - - if not state.posts or row >= len(state.posts): + if not state.posts or row < 0 or row >= len(state.posts): return post = state.posts[row] @@ -257,24 +249,21 @@ class ContextMenu_TrackerTable: if not hasattr(self, '_context_menu_row'): return row = self._context_menu_row - if row < 0 or row >= len(state.trackers): + + if not state.posts or row < 0 or row >= len(state.posts): return - item = list(state.trackertable.selectedItems())[0] - row = item.row() - - if state.posts: - post = state.posts[row] - url = post.get("url") - if url: - try: webbrowser.open(url) - except Exception as e: consoleLog(f"Failed to open URL: {e}", True) + post = state.posts[row] + url = post.get("url") + if url: + try: webbrowser.open(url) + except Exception as e: consoleLog(f"Failed to open URL: {e}", True) def downloadItemAction(self): if not hasattr(self, '_context_menu_row'): return row = self._context_menu_row - if row < 0 or row >= len(state.trackers): + if not state.posts or row < 0 or row >= len(state.posts): return run_thread(threading.Thread(target=download_selected, args=(state.trackertable.selectedItems(),))) diff --git a/src/main.py b/src/main.py index 3cd40ff..7ab5c2e 100644 --- a/src/main.py +++ b/src/main.py @@ -25,7 +25,7 @@ import signal import time import sys -SERVER_NAME = "SoftwareManager_python" +SERVER_NAME = "SoftwareManager_KeksPirates" class SingleInstance(QObject): def __init__(self): From eb9d5d60d18daf84dbdb3e1deb1a1816ac1a3a4b Mon Sep 17 00:00:00 2001 From: Vxrtrauter <101264710+Vxrtrauter@users.noreply.github.com> Date: Thu, 9 Apr 2026 23:31:09 +0200 Subject: [PATCH 15/16] fix: Centralize single-instance check and refactor startup Introduce check_running(app) to create and check SingleInstance early in main(), store it on app._single_instance, and reuse that instance in run_gui() instead of constructing a new one. Move single-instance exit logic (print, closehelper, force_exit) into the new helper and wire single.on_raise to show_from_tray. Also replace the previous consoleLog call with print and add a comment indicating the check is for SoftwareManager already running. This centralizes startup single-instance handling and avoids creating multiple SingleInstance objects. --- src/main.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/main.py b/src/main.py index 7ab5c2e..7802e75 100644 --- a/src/main.py +++ b/src/main.py @@ -59,7 +59,18 @@ class SingleInstance(QObject): def on_raise(self): pass +def check_running(app): + single = SingleInstance() + if single.is_running: + print("Another instance is already running. Exiting this instance.") + closehelper() + force_exit() + + app._single_instance = single + + def run_gui(app): + single = app._single_instance custom_colors = {} if state.accent_color: custom_colors["primary"] = state.accent_color @@ -98,13 +109,6 @@ def run_gui(app): widget.raise_() widget.activateWindow() - single = SingleInstance() - if single.is_running: - consoleLog("Another instance is already running. Exiting this instance.") - closehelper() - force_exit() - - app._single_instance = single single.on_raise = show_from_tray set_main_window(widget) @@ -120,10 +124,10 @@ def keyboardinterrupthandler(signum, frame): def main(): # Begin counting startup time start_time = time.perf_counter() - # Initialize UI Engine app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv) - + # Check if SoftwareManager is already running + check_running(app) # Parse saved files read_config() logs = get_download_logs() From 4f943759f4c0cea22cbb9321e7d41b49198c190a Mon Sep 17 00:00:00 2001 From: Vxrtrauter <101264710+Vxrtrauter@users.noreply.github.com> Date: Thu, 9 Apr 2026 23:56:14 +0200 Subject: [PATCH 16/16] Refactor: steamrip scraper & update menu labels Simplify SteamripScraper link/name extraction by using BeautifulSoup results directly (append absolute URLs and use link.get_text()) and avoid fragile regex; adjust scrape_steamrip_game_downloads to accept a full URL argument. Update context menu text: replace "Copy Magnet URI" with "Copy Download Link" in two places and rename "Download Item" to "Download" for clearer UI wording. --- src/data/sources/steamrip.py | 12 +++++------- src/interface/dialogs/contextmenu.py | 6 +++--- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/data/sources/steamrip.py b/src/data/sources/steamrip.py index 71de40e..596688e 100644 --- a/src/data/sources/steamrip.py +++ b/src/data/sources/steamrip.py @@ -37,13 +37,12 @@ class SteamripScraper: links = [] names = [] + for gamehtml in games: link = gamehtml.find("a", href=lambda x: x and x.startswith("/")) - links += re.findall(r'(?<=href=")[^"]*', link.__str__()) - name = gamehtml.find("a", href=lambda x: x and x.startswith("/")) - names += re.findall(r'(?<=\/">)[^<]*', name.__str__()) - - # construct the list[dict[str,str]] + if link: + links.append("https://steamrip.com" + link["href"]) + names.append(link.get_text()) ret = [] @@ -52,8 +51,7 @@ class SteamripScraper: return ret - def scrape_steamrip_game_downloads(self, gamelink): - url = "https://steamrip.com" + gamelink + def scrape_steamrip_game_downloads(self, url): response = requests.get(url) soup = BeautifulSoup(response.text, "html.parser") download_link_elements = soup.find_all("a",class_="shortc-button") diff --git a/src/interface/dialogs/contextmenu.py b/src/interface/dialogs/contextmenu.py index c575623..08ed1f0 100644 --- a/src/interface/dialogs/contextmenu.py +++ b/src/interface/dialogs/contextmenu.py @@ -32,7 +32,7 @@ class ContextMenu_Downloads: self.main_window = main_window self.context_menu = QtWidgets.QMenu(main_window) self.context_menu.addAction("Open Containing Folder", self.openFolderAction) - self.context_menu.addAction("Copy Magnet URI", self.copyMagnetURIAction) + self.context_menu.addAction("Copy Download Link", self.copyMagnetURIAction) self.context_menu.addAction("Remove from list", self.cancelDownloadAction) self.context_menu.addAction("Delete File", self.deleteFileAction) @@ -205,9 +205,9 @@ class ContextMenu_TrackerTable: def __init__(self, main_window): self.main_window = main_window self.context_menu = QtWidgets.QMenu(main_window) - self.context_menu.addAction("Copy Magnet URI", self.copyMagnetURIAction) + self.context_menu.addAction("Copy Download Link", self.copyMagnetURIAction) self.context_menu.addAction("Open in Browser", self.openInBrowserAction) - self.context_menu.addAction("Download Item", self.downloadItemAction) + self.context_menu.addAction("Download", self.downloadItemAction) state.trackertable.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) state.trackertable.customContextMenuRequested.connect(self._show_context_menu)