Merge branch 'main' into feat/contextmenu

This commit is contained in:
shayaa
2026-04-10 00:10:36 +02:00
committed by GitHub
24 changed files with 425 additions and 188 deletions
+16
View File
@@ -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"]
+6 -6
View File
@@ -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 }}
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
+5 -5
View File
@@ -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
@@ -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 }}
+3 -3
View File
@@ -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 }}
+12 -7
View File
@@ -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.<br>
> 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)
<!-- python-dependencies:start -->
```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)
```
<!-- python-dependencies:end -->
## Activity
+5 -2
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="darkreader-lock">
<link rel="stylesheet" href="main.css"/>
<title>SoftwareManager - Main</title>
<!-- <link rel="icon" type="image/x-icon" href="/assets/icons/favicon.ico"> -->
@@ -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);
}
};
</script>
+4 -5
View File
@@ -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
+79
View File
@@ -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 = "<!-- python-dependencies:start -->"
END_MARKER = "<!-- python-dependencies:end -->"
REQUIREMENT_PATTERN = re.compile(
r"^(?P<name>[A-Za-z0-9_.\-\[\]]+)\s*(?P<specifier>(==|~=|>=|<=|!=|>|<).+)?$"
)
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()
+3 -2
View File
@@ -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):
+1 -1
View File
@@ -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"])
+11 -7
View File
@@ -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)
+4 -2
View File
@@ -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):
+68 -4
View File
@@ -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)
+34 -2
View File
@@ -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)
+9
View File
@@ -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()
)
+6 -4
View File
@@ -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}")
+4 -4
View File
@@ -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
+53 -43
View File
@@ -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():
+17 -8
View File
@@ -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)
+7
View File
@@ -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
+3
View File
@@ -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
+30 -83
View File
@@ -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):
+6
View File
@@ -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)