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: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
@@ -190,7 +190,7 @@ jobs:
- name: Upload build artifact (Windows) - name: Upload build artifact (Windows)
if: runner.os == 'Windows' if: runner.os == 'Windows'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: ${{ matrix.os }}-build name: ${{ matrix.os }}-build
path: | path: |
@@ -200,7 +200,7 @@ jobs:
- name: Upload build artifact (Linux/macOS) - name: Upload build artifact (Linux/macOS)
if: runner.os != 'Windows' if: runner.os != 'Windows'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: ${{ matrix.os }}-build name: ${{ matrix.os }}-build
path: dist-final/${{ matrix.artifact_name }} path: dist-final/${{ matrix.artifact_name }}
@@ -213,8 +213,8 @@ jobs:
if: success() if: success()
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v8
with: with:
path: artifacts path: artifacts
@@ -248,4 +248,4 @@ jobs:
release/SoftwareManager-stable-${{ needs.build.outputs.short_sha }}-macos release/SoftwareManager-stable-${{ needs.build.outputs.short_sha }}-macos
release/SHA256SUMS.txt release/SHA256SUMS.txt
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
+5 -5
View File
@@ -38,7 +38,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
@@ -195,7 +195,7 @@ jobs:
- name: Upload build artifact (Windows) - name: Upload build artifact (Windows)
if: runner.os == 'Windows' if: runner.os == 'Windows'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: ${{ matrix.os }}-build name: ${{ matrix.os }}-build
path: | path: |
@@ -205,7 +205,7 @@ jobs:
- name: Upload build artifact (Linux/macOS) - name: Upload build artifact (Linux/macOS)
if: runner.os != 'Windows' if: runner.os != 'Windows'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: ${{ matrix.os }}-build name: ${{ matrix.os }}-build
path: dist-final/${{ matrix.artifact_name }} path: dist-final/${{ matrix.artifact_name }}
@@ -218,8 +218,8 @@ jobs:
if: success() if: success()
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v8
with: with:
path: artifacts 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: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
@@ -170,7 +170,7 @@ jobs:
- name: Upload artifact (Windows) - name: Upload artifact (Windows)
if: runner.os == 'Windows' if: runner.os == 'Windows'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: ${{ matrix.os }}-build name: ${{ matrix.os }}-build
path: dist-final/${{ matrix.artifact_name }}.zip path: dist-final/${{ matrix.artifact_name }}.zip
@@ -178,7 +178,7 @@ jobs:
- name: Upload artifact (Linux/macOS) - name: Upload artifact (Linux/macOS)
if: runner.os != 'Windows' if: runner.os != 'Windows'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: ${{ matrix.os }}-build name: ${{ matrix.os }}-build
path: dist-final/${{ matrix.artifact_name }} path: dist-final/${{ matrix.artifact_name }}
+12 -7
View File
@@ -1,5 +1,9 @@
# SoftwareManager # 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 ## Features
- Searching & Downloading Software from various pages - Searching & Downloading Software from various pages
@@ -35,20 +39,21 @@ SoftwareManager is a Python-based GUI tool that simplifies searching and downloa
## Python Dependencies ## Python Dependencies
(also included in requirements.txt) (also included in requirements.txt)
```bash <!-- python-dependencies:start -->
requests (2.32.2) ```text
PySide6 (6.10.1) requests (2.33.1)
beautifulsoup (44.13.5) PySide6-Essentials (6.11.0)
beautifulsoup4 (4.14.3)
darkdetect (0.7.1) darkdetect (0.7.1)
pyinstaller (6.15.0) pyinstaller (6.19.0)
PyQtDarkTheme-fork (2.3.4) PyQtDarkTheme-fork (2.3.4)
aiohttp (3.13.0)
plyer (2.1.0) plyer (2.1.0)
psutil (7.1.0) psutil (7.1.0)
libtorrent (2.0.11) libtorrent (2.0.11)
libtorrent-windows-dll (0.0.3) libtorrent-windows-dll (0.0.3)
send2trash (2.1.0) send2trash (2.1.0)
``` ```
<!-- python-dependencies:end -->
## Activity ## Activity
+5 -2
View File
@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="darkreader-lock">
<link rel="stylesheet" href="main.css"/> <link rel="stylesheet" href="main.css"/>
<title>SoftwareManager - Main</title> <title>SoftwareManager - Main</title>
<!-- <link rel="icon" type="image/x-icon" href="/assets/icons/favicon.ico"> --> <!-- <link rel="icon" type="image/x-icon" href="/assets/icons/favicon.ico"> -->
@@ -225,9 +226,11 @@
openTab(null, 'api', false); openTab(null, 'api', false);
} else if (currentPath === '/docs') { } else if (currentPath === '/docs') {
openTab(null, 'docs', false); openTab(null, 'docs', false);
} else { } else if (currentPath === '/') {
history.replaceState({ tab: 'start' }, "", "/"); history.replaceState({ tab: 'start' }, "", "/");
openTab(null, 'start', false); openTab(null, 'start', false);
} else {
openTab(null, 'notfound', false);
} }
}; };
</script> </script>
+4 -5
View File
@@ -1,10 +1,9 @@
requests==2.32.2 requests==2.33.1
PySide6-Essentials==6.10.1 PySide6-Essentials==6.11.0
beautifulsoup4==4.13.5 beautifulsoup4==4.14.3
darkdetect==0.7.1 darkdetect==0.7.1
pyinstaller==6.15.0 pyinstaller==6.19.0
PyQtDarkTheme-fork==2.3.4 PyQtDarkTheme-fork==2.3.4
aiohttp==3.13.0
plyer==2.1.0 plyer==2.1.0
psutil==7.1.0 psutil==7.1.0
libtorrent==2.0.11 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)): for i in range(len(names)):
ret.append({"title" : names[i], "url" : links[i]}) ret.append({"title" : names[i], "url" : links[i]})
self.cache["data"] = ret
self.cache["last_fetched"] = current_time
return ret return ret
def scrape_steamrip_game_downloads(self, url): def scrape_steamrip_game_downloads(self, url):
@@ -70,8 +73,6 @@ class SteamripScraper:
if len(link) != 1: if len(link) != 1:
ret.append(link) ret.append(link)
self.cache["data"] = ret
return ret return ret
def get_download_link(self, post: Dict): def get_download_link(self, post: Dict):
+1 -1
View File
@@ -43,7 +43,7 @@ class UztrackerScraper:
except requests.RequestException as e: except requests.RequestException as e:
consoleLog(f"Failed to fetch {search_url}: {e}") consoleLog(f"Failed to fetch {search_url}: {e}")
return None return []
def get_download_link(self, post: Dict): def get_download_link(self, post: Dict):
return get_magnet_link(post["url"]) return get_magnet_link(post["url"])
+11 -7
View File
@@ -53,10 +53,12 @@ class ContextMenu_Downloads:
if not hasattr(self, '_context_menu_row'): if not hasattr(self, '_context_menu_row'):
return return
row = self._context_menu_row row = self._context_menu_row
if row < 0 or row >= len(state.active_downloads): with state.downloads_lock:
return if row < 0 or row >= len(state.active_downloads):
magnet_link = list(state.active_downloads.keys())[row] return
magnetdl = state.active_downloads[magnet_link] keys = list(state.active_downloads.keys())
magnet_link = keys[row]
magnetdl = state.active_downloads[magnet_link]
download_path = magnetdl.save_path() download_path = magnetdl.save_path()
if download_path and os.path.exists(download_path): if download_path and os.path.exists(download_path):
if platform.system() == "Windows": if platform.system() == "Windows":
@@ -70,9 +72,11 @@ class ContextMenu_Downloads:
if not hasattr(self, '_context_menu_row'): if not hasattr(self, '_context_menu_row'):
return return
row = self._context_menu_row row = self._context_menu_row
if row < 0 or row >= len(state.active_downloads): with state.downloads_lock:
return if row < 0 or row >= len(state.active_downloads):
magnet_link = list(state.active_downloads.keys())[row] return
keys = list(state.active_downloads.keys())
magnet_link = keys[row]
clipboard = QtWidgets.QApplication.clipboard() clipboard = QtWidgets.QApplication.clipboard()
clipboard.setText(magnet_link) clipboard.setText(magnet_link)
+4 -2
View File
@@ -34,7 +34,8 @@ class DownloadModel(QAbstractTableModel):
return None return None
try: 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] magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status() status = magnetdl.status()
except (IndexError, KeyError, RuntimeError): except (IndexError, KeyError, RuntimeError):
@@ -101,7 +102,8 @@ class DownloadModel(QAbstractTableModel):
if row >= len(state.active_downloads) or row < 0: if row >= len(state.active_downloads) or row < 0:
return return
try: 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] magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status() status = magnetdl.status()
except (IndexError, KeyError, RuntimeError): except (IndexError, KeyError, RuntimeError):
+68 -4
View File
@@ -1,5 +1,5 @@
from PySide6.QtWidgets import QLabel, QGraphicsOpacityEffect, QStackedWidget 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 PySide6.QtGui import QImage, QPixmap
from utils.data.state import state from utils.data.state import state
import darkdetect import darkdetect
@@ -17,8 +17,15 @@ class Image(QObject):
self.overlay_label.setGraphicsEffect(self.opacity_effect) self.overlay_label.setGraphicsEffect(self.opacity_effect)
self._current_image_path = None self._current_image_path = None
self._cached_qimage = None
self._wallpaper_active = False self._wallpaper_active = False
self._original_stylesheets = {} 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) parent.installEventFilter(self)
state.image_changed.connect(self.update_image_overlay) state.image_changed.connect(self.update_image_overlay)
@@ -28,9 +35,14 @@ class Image(QObject):
def eventFilter(self, obj, event): def eventFilter(self, obj, event):
if obj == self.application and event.type() == QEvent.Type.Resize: if obj == self.application and event.type() == QEvent.Type.Resize:
if self._current_image_path: if self._current_image_path:
self._load_and_display(self._current_image_path) self._apply_layout_fast()
self._resize_timer.start()
return False return False
def _on_resize_finished(self):
if self._current_image_path:
self._apply_layout()
def _set_wallpaper_transparency(self, enabled): def _set_wallpaper_transparency(self, enabled):
if self._wallpaper_active == enabled: if self._wallpaper_active == enabled:
return return
@@ -117,12 +129,62 @@ class Image(QObject):
self._set_wallpaper_transparency(False) self._set_wallpaper_transparency(False)
return return
self._current_image_path = image_path 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 parent = self.application
image = QImage(image_path) image = self._cached_qimage
if image.isNull():
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() self.overlay_label.hide()
return return
parent = self.application
image = self._cached_qimage
if getattr(state, "image_as_wallpaper", False): if getattr(state, "image_as_wallpaper", False):
scaled_image = image.scaled( scaled_image = image.scaled(
parent.size(), parent.size(),
@@ -184,4 +246,6 @@ class Image(QObject):
self.overlay_label.show() self.overlay_label.show()
def update_image_overlay(self, new_image_path): 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) self._load_and_display(new_image_path)
+34 -2
View File
@@ -1,9 +1,11 @@
from interface.utils.tabhelper import create_tab from interface.utils.tabhelper import create_tab
from interface.dialogs.theme import _accent_selection_color
from utils.config.settings import save_settings from utils.config.settings import save_settings
from interface.utils.svghelper import svg_icon from interface.utils.svghelper import svg_icon
from utils.logging.logs import consoleLog from utils.logging.logs import consoleLog
from PySide6.QtCore import Qt, QSize from PySide6.QtCore import Qt, QSize
from utils.data.state import state from utils.data.state import state
from PySide6.QtGui import QColor
from PySide6 import QtWidgets from PySide6 import QtWidgets
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QLineEdit, QLineEdit,
@@ -13,9 +15,10 @@ from PySide6.QtWidgets import (
QDialog, QDialog,
QLabel, QLabel,
QHBoxLayout, QHBoxLayout,
QSpinBox, QSlider, QCheckBox, QSpinBox, QSlider, QCheckBox,
QFileDialog, QFileDialog,
QComboBox, QComboBox,
QColorDialog
) )
import platform import platform
@@ -91,9 +94,31 @@ def settings_dialog(self):
transparent_window_checkbox.toggled.connect(lambda checked: setattr(state, 'window_transparency', checked)) transparent_window_checkbox.toggled.connect(lambda checked: setattr(state, 'window_transparency', checked))
# Accent Color # 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.setPlaceholderText("e.g. #fca7d7")
accent_color_input.setText(state.accent_color) 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 Widget
api_url_container, api_url = create_widget(QLineEdit, "API Server URL: ", width=180, height=30) 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(), accent_color=accent_color_input.text().strip(),
close_to_tray=close_to_tray_checkbox.isChecked() 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) cancel_btn.clicked.connect(dialog.reject)
layout.addWidget(cancel_btn) layout.addWidget(cancel_btn)
+9
View File
@@ -317,6 +317,15 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
def _on_log_signal(self, text): def _on_log_signal(self, text):
if hasattr(self, 'consoleLog'): if hasattr(self, 'consoleLog'):
self.consoleLog.append(text) 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().setValue(
self.consoleLog.verticalScrollBar().maximum() 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: if dl_path is None:
dl_path = state.download_path dl_path = state.download_path
if url in state.active_downloads: with state.downloads_lock:
consoleLog(f"Download already active: {title}") if url in state.active_downloads:
return consoleLog(f"Download already active: {title}")
return
filename = ( filename = (
detect_filename_from_headers(url, DirectDownloadHandle.USER_AGENT) 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) 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) add_download_log(title, url, "", False)
handle.start() handle.start()
consoleLog(f"Started direct download: {filename}") consoleLog(f"Started direct download: {filename}")
+4 -4
View File
@@ -1,4 +1,5 @@
from dataclasses import dataclass from dataclasses import dataclass
from collections import deque
from typing import Optional from typing import Optional
import libtorrent as lt import libtorrent as lt
import threading import threading
@@ -31,7 +32,7 @@ class DirectDownloadStatus:
self._upload_rate = 0 self._upload_rate = 0
self._chunk_bytes: dict[int, int] = {} 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 self._speed_window_size = 3.0
@property @property
@@ -65,9 +66,8 @@ class DirectDownloadStatus:
self._speed_window.append((now, self._total_wanted_done)) self._speed_window.append((now, self._total_wanted_done))
cutoff = now - self._speed_window_size cutoff = now - self._speed_window_size
self._speed_window = [ while self._speed_window and self._speed_window[0][0] < cutoff:
(t, b) for t, b in self._speed_window if t >= cutoff self._speed_window.popleft()
]
if len(self._speed_window) >= 2: if len(self._speed_window) >= 2:
oldest_time, oldest_bytes = self._speed_window[0] oldest_time, oldest_bytes = self._speed_window[0]
dt = now - oldest_time dt = now - oldest_time
+53 -43
View File
@@ -10,7 +10,7 @@ import time
import os import os
loop_running = False _loop_lock = threading.Lock()
def get_free_space_mb(dirname): def get_free_space_mb(dirname):
if platform.system() == 'Windows': if platform.system() == 'Windows':
@@ -23,7 +23,9 @@ def get_free_space_mb(dirname):
def check_space(): def check_space():
while not state.shutdown_event.is_set(): 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: try:
status = magnetdl.status() status = magnetdl.status()
except RuntimeError: except RuntimeError:
@@ -81,9 +83,11 @@ def add_download(magnet_uri):
init_session() init_session()
free_space = get_free_space_mb(state.download_path) 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: try:
handle = state.active_downloads[magnet_uri]
status = handle.status() status = handle.status()
if status.has_metadata: if status.has_metadata:
@@ -93,7 +97,8 @@ def add_download(magnet_uri):
consoleLog(f"File Deleted, redownloading: {status.name}") consoleLog(f"File Deleted, redownloading: {status.name}")
state.dl_session.remove_torrent(handle) state.dl_session.remove_torrent(handle)
del state.active_downloads[magnet_uri] with state.downloads_lock:
del state.active_downloads[magnet_uri]
else: else:
consoleLog("Skipping Downloading, download already running...") consoleLog("Skipping Downloading, download already running...")
return False return False
@@ -102,23 +107,26 @@ def add_download(magnet_uri):
return False return False
except RuntimeError as e: except RuntimeError as e:
consoleLog(f"Error in LibTorrent Handle: {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: try:
params = lt.parse_magnet_uri(magnet_uri) params = lt.parse_magnet_uri(magnet_uri)
params.save_path = state.download_path params.save_path = state.download_path
handle = state.dl_session.add_torrent(params) handle = state.dl_session.add_torrent(params)
metadata_timeout = 60
metadata_start = time.time()
while not handle.has_metadata(): 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) time.sleep(1)
total_size = handle.get_torrent_info().total_size() total_size = handle.get_torrent_info().total_size()
if free_space > 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:
state.dl_session.remove_torrent(handle) state.dl_session.remove_torrent(handle)
consoleLog("Not enough free space to download this item.") consoleLog("Not enough free space to download this item.")
return False return False
@@ -126,8 +134,9 @@ def add_download(magnet_uri):
except Exception as e: except Exception as e:
consoleLog(f"Failed to add torrent or fetch info: {e}") consoleLog(f"Failed to add torrent or fetch info: {e}")
return False return False
if download: if handle:
state.active_downloads[magnet_uri] = download with state.downloads_lock:
state.active_downloads[magnet_uri] = handle
consoleLog(f"Added {magnet_uri} to downloads") consoleLog(f"Added {magnet_uri} to downloads")
run_thread(threading.Thread(target=dl_status_loop)) run_thread(threading.Thread(target=dl_status_loop))
@@ -140,9 +149,10 @@ def add_seed(magnet_uri, file_path):
init_session() init_session()
if magnet_uri in state.active_downloads: with state.downloads_lock:
consoleLog("Already seeding this torrent") if magnet_uri in state.active_downloads:
return False consoleLog("Already seeding this torrent")
return False
try: try:
magnetdl = lt.parse_magnet_uri(magnet_uri) magnetdl = lt.parse_magnet_uri(magnet_uri)
@@ -151,43 +161,43 @@ def add_seed(magnet_uri, file_path):
except Exception as e: except Exception as e:
consoleLog(f"Failed to add seed: {e}") consoleLog(f"Failed to add seed: {e}")
return False return False
state.active_downloads[magnet_uri] = handle with state.downloads_lock:
state.active_downloads[magnet_uri] = handle
state.seeded_magnets.add(magnet_uri) state.seeded_magnets.add(magnet_uri)
return True return True
def dl_status_loop(): def dl_status_loop():
global loop_running if not _loop_lock.acquire(blocking=False):
if loop_running == True:
return return
loop_running = True try:
completed_set = set() 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)
if not state.active_downloads: if not state.active_downloads:
loop_running = False consoleLog("No active downloads")
break return
time.sleep(1) while state.active_downloads and not state.shutdown_event.is_set():
with state.downloads_lock:
loop_running = False 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(): def update_settings():
+17 -8
View File
@@ -9,19 +9,23 @@ import os
def cleanup_session(): def cleanup_session():
if state.dl_session is not None: if state.dl_session is not None:
for magnetdl in state.active_downloads.values(): with state.downloads_lock:
if hasattr(magnetdl, 'pause'): # Check it's a handle for magnetdl in state.active_downloads.values():
magnetdl.pause() if hasattr(magnetdl, 'pause'): # check it's a handle
magnetdl.pause()
del state.dl_session del state.dl_session
state.dl_session = None state.dl_session = None
state.active_downloads.clear() with state.downloads_lock:
state.active_downloads.clear()
def send_notification(shutdown_event): def send_notification(shutdown_event):
notified = set() notified = set()
while not shutdown_event.is_set(): while not shutdown_event.is_set():
try: 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): if isinstance(magnetdl, dict):
continue continue
@@ -42,7 +46,9 @@ def update_log(shutdown_event):
updated = set() updated = set()
while not shutdown_event.is_set(): while not shutdown_event.is_set():
try: 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): if isinstance(magnetdl, dict):
continue continue
@@ -63,7 +69,9 @@ def update_log(shutdown_event):
def check_deleted_files(shutdown_event): def check_deleted_files(shutdown_event):
while not shutdown_event.is_set(): while not shutdown_event.is_set():
try: 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): if isinstance(magnetdl, dict):
continue continue
@@ -75,7 +83,8 @@ def check_deleted_files(shutdown_event):
if not os.path.exists(file_path): if not os.path.exists(file_path):
consoleLog(f"Registered File Deletion: {status.name}") consoleLog(f"Registered File Deletion: {status.name}")
state.dl_session.remove_torrent(magnetdl) 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: except Exception as e:
consoleLog(f"Exception while checking for file deletions: {e}") consoleLog(f"Exception while checking for file deletions: {e}")
time.sleep(5) 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.config.config import create_config
from utils.logging.logs import consoleLog from utils.logging.logs import consoleLog
from utils.data.state import state 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 state.image_position = image_position
if accent_color is not None: if accent_color is not None:
state.accent_color = accent_color 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: if close_to_tray is not None:
state.close_to_tray = close_to_tray state.close_to_tray = close_to_tray
+3
View File
@@ -39,6 +39,9 @@ class AppState(QObject):
self._image_opacity: int = 100 self._image_opacity: int = 100
self._image_as_wallpaper: bool = False self._image_as_wallpaper: bool = False
self._image_position: str = "bottom-right" # top-left, top-right, bottom-left, bottom-right, center 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 # Trackers / Scraping
self.posts: list[Dict[str,str]] | None = None # titles, urls, author, seeders, leechers 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 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: def add_download_log(title, url, magnet_uri, completed) -> DownloadList:
# wait for metadata outside the lock to avoid blocking other threads # wait for metadata outside the lock to avoid blocking other threads
magnetdl = state.active_downloads.get(magnet_uri) 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) return _add_download_log_inner(title, url, magnet_uri, completed, path)
def _add_download_log_inner(title, url, magnet_uri, completed, path) -> DownloadList: def _add_download_log_inner(title, url, magnet_uri, completed, path) -> DownloadList:
downloads_file = os.path.join(state.settings_path, "downloads.json") downloads = _load_downloads()
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 = []
if any((magnet_uri and d.magnet_uri == magnet_uri) or (url and d.url == url) for d in 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: 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") consoleLog(f"Added {title} to Log File")
return _save_downloads(downloads)
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
def remove_download_log(magnet_uri) -> DownloadList: def remove_download_log(magnet_uri) -> DownloadList:
with state.downloads_lock: with state.downloads_lock:
return _remove_download_log_inner(magnet_uri) return _remove_download_log_inner(magnet_uri)
def _remove_download_log_inner(magnet_uri) -> DownloadList: def _remove_download_log_inner(magnet_uri) -> DownloadList:
downloads_file = os.path.join(state.settings_path, "downloads.json") downloads = _load_downloads()
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 = []
magnet_link = (magnet_uri or "").strip() magnet_link = (magnet_uri or "").strip()
if not magnet_link: 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] 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") consoleLog(f"Removed {title} from Log File")
return _save_downloads(downloads)
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
def update_download_completed(magnet_uri, completed) -> DownloadList: def update_download_completed(magnet_uri, completed) -> DownloadList:
with state.downloads_lock: with state.downloads_lock:
return _update_download_completed_inner(magnet_uri, completed) return _update_download_completed_inner(magnet_uri, completed)
def _update_download_completed_inner(magnet_uri, completed) -> DownloadList: def _update_download_completed_inner(magnet_uri, completed) -> DownloadList:
downloads_file = os.path.join(state.settings_path, "downloads.json") downloads = _load_downloads()
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 = []
identifier = (magnet_uri or "").strip() identifier = (magnet_uri or "").strip()
if state.debug: if state.debug:
@@ -145,13 +124,8 @@ def _update_download_completed_inner(magnet_uri, completed) -> DownloadList:
consoleLog("No matching download found to update") consoleLog("No matching download found to update")
return DownloadList(data=downloads, count=len(downloads)) 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") consoleLog("Updated download log")
return _save_downloads(downloads)
return download_list
def get_download_logs() -> DownloadList: def get_download_logs() -> DownloadList:
@@ -159,18 +133,7 @@ def get_download_logs() -> DownloadList:
return _get_download_logs_inner() return _get_download_logs_inner()
def _get_download_logs_inner() -> DownloadList: def _get_download_logs_inner() -> DownloadList:
downloads_file = os.path.join(state.settings_path, "downloads.json") downloads = _load_downloads()
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 = []
return DownloadList(data=downloads, count=len(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) return _update_download_completed_by_hash_inner(info_hash, completed)
def _update_download_completed_by_hash_inner(info_hash, completed) -> DownloadList: def _update_download_completed_by_hash_inner(info_hash, completed) -> DownloadList:
downloads_file = os.path.join(state.settings_path, "downloads.json") downloads = _load_downloads()
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 = []
info_hash_upper = (info_hash or "").upper().strip() info_hash_upper = (info_hash or "").upper().strip()
consoleLog(f"Updating download by hash: {info_hash_upper}") 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") consoleLog("No matching download found to update")
return DownloadList(data=downloads, count=len(downloads)) 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") consoleLog("Updated download log")
return _save_downloads(downloads)
return download_list
def set_main_window(window): def set_main_window(window):
+6
View File
@@ -37,10 +37,16 @@ def run_download(post, headers: Optional[dict] = None):
if ismagnet: if ismagnet:
link = result link = result
if not link:
consoleLog("Failed to retrieve magnet link")
return
add_magnet(link) add_magnet(link)
add_download_log(post.get("title", "Unknown"), "", link, False) add_download_log(post.get("title", "Unknown"), "", link, False)
else: else:
link, link_headers = result if isinstance(result, tuple) else (result, None) 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 final_headers = headers or link_headers
add_direct_download(link, post.get("title", "Unknown"), headers=final_headers, single_threaded=final_headers is not None) add_direct_download(link, post.get("title", "Unknown"), headers=final_headers, single_threaded=final_headers is not None)