diff --git a/README.md b/README.md index 0c05922..8c6bac9 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,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) ``` diff --git a/requirements.txt b/requirements.txt index a519554..8bf8dba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ PyQtDarkTheme-fork==2.3.4 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 +send2trash==2.1.0 diff --git a/src/data/sources/steamrip.py b/src/data/sources/steamrip.py index 387d860..0206433 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 = [] @@ -55,8 +54,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 1125b43..b6ace10 100644 --- a/src/interface/dialogs/contextmenu.py +++ b/src/interface/dialogs/contextmenu.py @@ -1,19 +1,38 @@ +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 +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 send2trash import send2trash from PySide6 import QtWidgets +import threading +import webbrowser import subprocess import platform import time import os -class ContextMenu: + +class MagnetWorker(QThread): + finished = Signal(str) + + def __init__(self, url, parent=None): + super().__init__(parent) + self.url = url + + def run(self): + magnet = get_magnet_link(self.url) + if magnet: + self.finished.emit(magnet) + +class ContextMenu_Downloads: def __init__(self, main_window): 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) @@ -62,30 +81,44 @@ class ContextMenu: 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 - keys = list(state.active_downloads.keys()) - magnet_link = 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: @@ -94,53 +127,71 @@ class ContextMenu: 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): - 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) + 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 break @@ -148,7 +199,75 @@ class ContextMenu: last_error = e if attempt < 2: time.sleep(0.5) + if last_error: 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 Download Link", self.copyMagnetURIAction) + self.context_menu.addAction("Open in Browser", self.openInBrowserAction) + self.context_menu.addAction("Download", self.downloadItemAction) + + 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 not state.posts or row < 0 or row >= len(state.posts): + return + + post = state.posts[row] + url = post.get("url") + if not url: + return + + worker = MagnetWorker(url, parent=self.main_window) + worker.finished.connect(self._on_magnet_fetched) + worker.finished.connect(worker.deleteLater) + worker.start() + + 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'): + return + row = self._context_menu_row + + if not state.posts or row < 0 or row >= len(state.posts): + return + + 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 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/interface/dialogs/settings.py b/src/interface/dialogs/settings.py index 811c4bc..14ff7a6 100644 --- a/src/interface/dialogs/settings.py +++ b/src/interface/dialogs/settings.py @@ -297,6 +297,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() @@ -322,7 +327,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() ) color = _accent_selection_color() if color: @@ -337,7 +343,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 348ef41..bcf85e5 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() @@ -251,7 +252,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): @@ -352,7 +354,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) @@ -395,6 +397,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 5722bca..7802e75 100644 --- a/src/main.py +++ b/src/main.py @@ -1,40 +1,119 @@ 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 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 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 import QtWidgets -from PySide6.QtCore import Qt import qdarktheme import threading import platform +import base64 import signal import time import sys +SERVER_NAME = "SoftwareManager_KeksPirates" + +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 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 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) + + tray = QSystemTrayIcon() + tray.setIcon(pixmap) + tray.setVisible(True) + + 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) + + def show_from_tray(): + widget.show() + widget.raise_() + widget.activateWindow() + + single.on_raise = show_from_tray + set_main_window(widget) widget.show() + signal.signal(signal.SIGINT, signal.SIG_DFL) sys.exit(app.exec()) def keyboardinterrupthandler(signum, frame): @@ -45,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() 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 115fd7a..7ef7d90 100644 --- a/src/utils/config/settings.py +++ b/src/utils/config/settings.py @@ -8,7 +8,7 @@ import platform -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: @@ -43,6 +43,8 @@ def save_settings(close=lambda: None, apiurl=None, download_path=None, down_spee 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) + 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 d1dc6e0..722fbe2 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 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 +