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)