diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4073b8e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + # Keeps your GitHub Actions (CI/CD) up to date daily + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + + # Keeps your Python dependencies (requirements.txt / pyproject.toml) up to date daily + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "daily" + ignore: + - dependency-name: "darkdetect" + versions: [">=0.8.0"] diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 7cddcd5..ff65d8c 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -33,7 +33,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 @@ -190,7 +190,7 @@ jobs: - name: Upload build artifact (Windows) if: runner.os == 'Windows' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.os }}-build path: | @@ -200,7 +200,7 @@ jobs: - name: Upload build artifact (Linux/macOS) if: runner.os != 'Windows' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.os }}-build path: dist-final/${{ matrix.artifact_name }} @@ -213,8 +213,8 @@ jobs: if: success() steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v8 with: path: artifacts @@ -248,4 +248,4 @@ jobs: release/SoftwareManager-stable-${{ needs.build.outputs.short_sha }}-macos release/SHA256SUMS.txt env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index f18f4e2..bec478b 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 @@ -195,7 +195,7 @@ jobs: - name: Upload build artifact (Windows) if: runner.os == 'Windows' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.os }}-build path: | @@ -205,7 +205,7 @@ jobs: - name: Upload build artifact (Linux/macOS) if: runner.os != 'Windows' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.os }}-build path: dist-final/${{ matrix.artifact_name }} @@ -218,8 +218,8 @@ jobs: if: success() steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v8 with: path: artifacts diff --git a/.github/workflows/sync-readme-dependencies.yml b/.github/workflows/sync-readme-dependencies.yml new file mode 100644 index 0000000..7651ac0 --- /dev/null +++ b/.github/workflows/sync-readme-dependencies.yml @@ -0,0 +1,39 @@ +name: Sync README Dependencies + +on: + push: + branches: + - main + paths: + - requirements.txt + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + runs-on: ubuntu-22.04 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Update README dependencies + run: python scripts/sync_readme_dependencies.py + + - name: Create pull request + uses: peter-evans/create-pull-request@v7 + with: + commit-message: "Sync README dependencies" + branch: auto/sync-readme-dependencies + title: "Sync README dependencies" + body: "Automated update of the Python Dependencies section in README.md to match `requirements.txt`." + labels: documentation + token: ${{ secrets.PR_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9997bbe..09927c9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 @@ -170,7 +170,7 @@ jobs: - name: Upload artifact (Windows) if: runner.os == 'Windows' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.os }}-build path: dist-final/${{ matrix.artifact_name }}.zip @@ -178,7 +178,7 @@ jobs: - name: Upload artifact (Linux/macOS) if: runner.os != 'Windows' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.os }}-build path: dist-final/${{ matrix.artifact_name }} diff --git a/README.md b/README.md index 27baad2..8c6bac9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # SoftwareManager -SoftwareManager is a Python-based GUI tool that simplifies searching and downloading software from various sources, including Rutracker, Uztracker and the official M0nkrus Telegram channel. +SoftwareManager is a Python-based GUI tool that simplifies searching and downloading software from various sources, including Rutracker, Uztracker, Steamrip and the official M0nkrus Telegram channel. + +> [!NOTE] +> Our Steamrip implementation is currently very experimental and potentially unstable.
+> Downloads will fail if there's no BuzzHeavier download available. We're working on this. ## Features - Searching & Downloading Software from various pages @@ -35,20 +39,21 @@ SoftwareManager is a Python-based GUI tool that simplifies searching and downloa ## Python Dependencies (also included in requirements.txt) - ```bash - requests (2.32.2) - PySide6 (6.10.1) - beautifulsoup (44.13.5) + + ```text + requests (2.33.1) + PySide6-Essentials (6.11.0) + beautifulsoup4 (4.14.3) darkdetect (0.7.1) - pyinstaller (6.15.0) + pyinstaller (6.19.0) PyQtDarkTheme-fork (2.3.4) - aiohttp (3.13.0) plyer (2.1.0) psutil (7.1.0) libtorrent (2.0.11) libtorrent-windows-dll (0.0.3) send2trash (2.1.0) ``` + ## Activity 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 @@ + SoftwareManager - Main @@ -225,9 +226,11 @@ openTab(null, 'api', false); } else if (currentPath === '/docs') { openTab(null, 'docs', false); - } else { + } else if (currentPath === '/') { history.replaceState({ tab: 'start' }, "", "/"); - openTab(null, 'start', false); + openTab(null, 'start', false); + } else { + openTab(null, 'notfound', false); } }; diff --git a/requirements.txt b/requirements.txt index fa53177..8bf8dba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,9 @@ -requests==2.32.2 -PySide6-Essentials==6.10.1 -beautifulsoup4==4.13.5 +requests==2.33.1 +PySide6-Essentials==6.11.0 +beautifulsoup4==4.14.3 darkdetect==0.7.1 -pyinstaller==6.15.0 +pyinstaller==6.19.0 PyQtDarkTheme-fork==2.3.4 -aiohttp==3.13.0 plyer==2.1.0 psutil==7.1.0 libtorrent==2.0.11 diff --git a/scripts/sync_readme_dependencies.py b/scripts/sync_readme_dependencies.py new file mode 100644 index 0000000..5820139 --- /dev/null +++ b/scripts/sync_readme_dependencies.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from pathlib import Path +import re + + +ROOT_DIR = Path(__file__).resolve().parent.parent +README_PATH = ROOT_DIR / "README.md" +REQUIREMENTS_PATH = ROOT_DIR / "requirements.txt" + +START_MARKER = "" +END_MARKER = "" +REQUIREMENT_PATTERN = re.compile( + r"^(?P[A-Za-z0-9_.\-\[\]]+)\s*(?P(==|~=|>=|<=|!=|>|<).+)?$" +) + + +def iter_requirements(requirements_text: str) -> list[str]: + formatted_lines: list[str] = [] + + for raw_line in requirements_text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + + match = REQUIREMENT_PATTERN.match(line) + if not match: + formatted_lines.append(f" {line}") + continue + + name = match.group("name") + specifier = (match.group("specifier") or "").strip() + + if specifier.startswith("=="): + formatted_lines.append(f" {name} ({specifier[2:]})") + elif specifier: + formatted_lines.append(f" {name} {specifier}") + else: + formatted_lines.append(f" {name}") + + return formatted_lines + + +def render_dependency_block(requirements_text: str) -> str: + dependency_lines = iter_requirements(requirements_text) + return "\n".join( + [ + START_MARKER, + " ```text", + *dependency_lines, + " ```", + END_MARKER, + ] + ) + + +def replace_managed_block(readme_text: str, rendered_block: str) -> str: + start_index = readme_text.find(START_MARKER) + end_index = readme_text.find(END_MARKER) + + if start_index == -1 or end_index == -1 or end_index < start_index: + raise RuntimeError("README dependency markers are missing or invalid.") + + end_index += len(END_MARKER) + return readme_text[:start_index] + rendered_block + readme_text[end_index:] + + +def main() -> None: + requirements_text = REQUIREMENTS_PATH.read_text(encoding="utf-8") + readme_text = README_PATH.read_text(encoding="utf-8") + rendered_block = render_dependency_block(requirements_text) + updated_readme = replace_managed_block(readme_text, rendered_block) + + if updated_readme != readme_text: + README_PATH.write_text(updated_readme, encoding="utf-8") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/data/sources/steamrip.py b/src/data/sources/steamrip.py index 596688e..0206433 100644 --- a/src/data/sources/steamrip.py +++ b/src/data/sources/steamrip.py @@ -49,6 +49,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, url): @@ -70,8 +73,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 08ed1f0..b6ace10 100644 --- a/src/interface/dialogs/contextmenu.py +++ b/src/interface/dialogs/contextmenu.py @@ -53,10 +53,12 @@ class ContextMenu_Downloads: 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": @@ -70,9 +72,11 @@ class ContextMenu_Downloads: 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) 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/dialogs/image.py b/src/interface/dialogs/image.py index f921958..408f880 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,14 @@ 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._apply_layout_fast() + 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 +129,62 @@ class Image(QObject): self._set_wallpaper_transparency(False) return self._current_image_path = image_path + + # 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_fast(self): + if self._cached_qimage is None or not state.image_enabled: + return + parent = self.application - image = QImage(image_path) - if image.isNull(): + 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() return + parent = self.application + image = self._cached_qimage + if getattr(state, "image_as_wallpaper", False): scaled_image = image.scaled( parent.size(), @@ -184,4 +246,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 diff --git a/src/interface/dialogs/settings.py b/src/interface/dialogs/settings.py index 1f2b2c8..14ff7a6 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) @@ -305,6 +330,13 @@ def settings_dialog(self): accent_color=accent_color_input.text().strip(), close_to_tray=close_to_tray_checkbox.isChecked() ) + 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/interface/gui.py b/src/interface/gui.py index 91f3546..bcf85e5 100644 --- a/src/interface/gui.py +++ b/src/interface/gui.py @@ -317,6 +317,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/config/settings.py b/src/utils/config/settings.py index 24ab35b..7ef7d90 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) if close_to_tray is not None: state.close_to_tray = close_to_tray diff --git a/src/utils/data/state.py b/src/utils/data/state.py index d0526f6..722fbe2 100644 --- a/src/utils/data/state.py +++ b/src/utils/data/state.py @@ -39,6 +39,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)