Compare commits

...

84 Commits

Author SHA1 Message Date
shayaa d4b7067484 Merge pull request #51 from KeksPirates/refactor/improve-code-quality
Refactor/improve code quality
2026-03-21 02:50:07 +01:00
Vxrtrauter 77c2e9cd9e fix: tab layout, clear search state, and cleanup
UI: remove trackertable from the Search tab and reorder widgets to correct layout. Tab helper: add QLayout directly instead of wrapping it in a temporary QWidget to avoid extra nesting. Search helper: clear the trackertable rows and reset state.posts when starting a new search to ensure fresh results. Minor: import check_space in main and simplify an unused loop variable in libtorrent_int for clarity/linting.
2026-03-21 02:48:48 +01:00
Vxrtrauter 290388a7dc Merge branch 'main' into refactor/improve-code-quality 2026-03-21 02:48:04 +01:00
KeksNino 3b51552313 check for free disk space at startup
resolve merge
2026-03-21 02:08:23 +01:00
Vxrtrauter 9e7607dd36 refactor: get_magnet to get_download_link
Rename scraper method get_magnet -> get_download_link in monkrus, rutracker and uztracker. Update downloader code to call .get_download_link and use the .is_magnet flag on tracker objects (replace dict access). Switch direct_download package imports to absolute paths and perform a small import reorder in direct_download/utils.py. These changes unify the download API and adapt call sites to the new tracker interface.
2026-03-21 02:03:49 +01:00
KeksNino 616552063d fix gui not starting, caused by tabhelper 2026-03-21 01:47:46 +01:00
KeksNino 9eb70af1a1 refactored tabhelper function 2026-03-21 01:14:25 +01:00
Vxrtrauter 4d4187ce63 fix: revert update.py changes 2026-03-21 01:09:31 +01:00
shayaa cac2ad3ab1 Revise disclaimer in README.md
Updated disclaimer for legal and ethical use of SoftwareManager.
2026-03-21 00:58:17 +01:00
shayaa cdbf9165d2 Update image path for logo in test workflow 2026-03-21 00:55:34 +01:00
KeksNino 23e44cb128 remove qt widget from updater.py and use class instead 2026-03-21 00:39:28 +01:00
Vxrtrauter 0a591e1cda refactor: update progress dialog and tidy UI 2026-03-21 00:27:56 +01:00
Vxrtrauter c0f5060451 fix: searchbar disabling itself when searching for nothing 2026-03-20 22:51:28 +01:00
Vxrtrauter c6940f0178 fix: add self argument to function 2026-03-20 02:26:42 +01:00
Vxrtrauter 2afb3e7716 refactor: improve scraper handling by utilizing classes for registration 2026-03-20 02:22:40 +01:00
Vxrtrauter 325eda54fa refactor: remove core/ folder 2026-03-19 20:42:28 +01:00
Vxrtrauter 53d4a240a0 refactor state.py and add comments to it, add another check to version getter. 2026-03-18 23:33:33 +01:00
shayaa a56969f19d Add GitHub Actions workflow for building testing executables 2026-03-17 23:43:47 +01:00
Vxrtrauter 2beabbb1ba refactor: improve search functionality and code organization in GUI components 2026-03-16 14:24:23 +01:00
shayaa 5bb732072e Merge pull request #48 from KeksPirates/fix/link-updater-to-gui
link updater to gui again
2026-03-16 03:04:35 +01:00
Vxrtrauter f50bfd0aa0 link updater to gui again 2026-03-16 03:04:14 +01:00
shayaa 71adbfcd96 Merge pull request #47 from KeksPirates/fix/invalid-version-detection
Fix invalid version detection
2026-03-16 02:45:46 +01:00
Vxrtrauter 270ee027f7 feat: add semi-dynamic discovering of build info file 2026-03-16 02:31:03 +01:00
Vxrtrauter a443f64501 refactor: move build_info.json to root folder, update workflow to support changes, rename function to more accurate name 2026-03-16 02:02:03 +01:00
Vxrtrauter 0e05f42741 Fix invalid version detection, refactor main.py and add comments for improved code understanding 2026-03-16 01:35:52 +01:00
shayaa d33c01941e Merge pull request #46 from KeksPirates/feat/improve-code-quality
feat: improve output statements on exception blocks
2026-03-15 23:20:19 +01:00
Vxrtrauter 053ea7694d feat: improve output statements on exception blocks 2026-03-15 23:19:32 +01:00
shayaa c25802cb95 Merge pull request #45 from KeksPirates/feat/improve-code-quality
feat: improve code quality
2026-03-15 23:08:30 +01:00
Vxrtrauter 62b24c7111 feat: improve code quality by fixing typos, adding print statements to exception blocks, remove unused imports etc 2026-03-15 23:01:02 +01:00
shayaa b0e4368e25 Merge pull request #44 from KeksPirates/refactor/gui
Refactor GUI - Merge due to changes being stable.
2026-03-15 22:34:00 +01:00
KeksNino 79f9155d76 fix settings dialog being empty 2026-03-13 19:26:38 +01:00
KeksNino 7cc36fb3ed refactor (almost) entire gui to split into multiple files 2026-03-13 18:57:25 +01:00
shayaa 7de83ae6d0 remove contributor image from README 2026-03-13 14:30:30 +01:00
shayaa 0374cbf511 Merge branch 'fix/gofile' into main 2026-03-11 22:26:10 +01:00
KeksNino 7df7d87928 fix gofile downloads by adding custom headers to both direct dl and api request
still needs work: 1. fix restart causing the download to instantly jump to 100%
2026-03-11 22:15:00 +01:00
Vxrtrauter e97f9ffe8a refactor imports 2026-03-11 15:40:25 +01:00
Vxrtrauter c4685dc2de fix: retry file deletion to allow libtorrent to release handles 2026-03-11 00:57:48 +01:00
Vxrtrauter f34d02d3d0 fix: minor inexistent variable in loghandler 2026-03-11 00:44:53 +01:00
Vxrtrauter 91b5c12afa feat: multithreaded update downloads + refactor updater into utils/network 2026-03-11 00:43:00 +01:00
Vxrtrauter b77867b111 feat: QoL features to Updater, workflow trigger. 2026-03-11 00:07:11 +01:00
Vxrtrauter bf20625832 Fix Torrent Handle errors 2026-03-10 22:45:25 +01:00
Vxrtrauter 8d218443bf fix: wrong download paths in libtorrent logic 2026-03-10 22:35:49 +01:00
Vxrtrauter a55a049583 Merge branch 'main' of https://github.com/KeksPirates/SoftwareManager 2026-03-10 22:23:26 +01:00
Vxrtrauter 4e7e08c136 fix: download path errors, improve print statements, add exception blocks to prevent errors from crashing the program, removed ability to add custom path on add_download as its not used, removed, fixed config format & parsing issues 2026-03-10 22:21:59 +01:00
KeksNino c3efa39b09 rename cancel download to remove from list in downloadlist context menu 2026-03-10 21:58:21 +01:00
KeksNino d3d7a9d2ac fix: check free disk space before downloading to prevent silent failure after starting dl
also add "(experimental)" to image path in settings
2026-03-10 21:57:53 +01:00
shayaa 74e7fcfd70 Merge pull request #41 from KeksPirates/merge_candidate_noschxl
Merge candidate noschxl
2026-03-10 18:42:23 +01:00
shayaa adecc78aec Merge branch 'main' into merge_candidate_noschxl 2026-03-10 18:40:07 +01:00
Vxrtrauter f6b852309b add: direct http download, fix libtorrent speed limit settings 2026-03-10 18:35:56 +01:00
Vxrtrauter 86ec6daca2 feat: add dynamic default headers in search tab 2026-03-10 16:07:45 +01:00
Vxrtrauter 8d6d02a1af fix: improve checks for logging 2026-03-10 15:52:15 +01:00
shayaa 2e2e526d3a Update gui.py 2026-03-10 00:23:08 +01:00
shayaa 11b8f3f265 Update update_checker.py 2026-03-10 00:21:57 +01:00
Vxrtrauter 5369578194 Merge branch 'feat-steamrip-noschxl' into merge_candidate_noschxl, improve stuff (http dl impl still needed) 2026-03-09 23:48:41 +01:00
Vxrtrauter c5e6608ae2 feat: enhance update handling by verifying installer hash and refactoring asset retrieval 2026-03-09 19:03:28 +01:00
Vxrtrauter 214d9a0e23 feat: implement SVG icon handling and update button styles in settings dialog 2026-03-09 18:09:17 +01:00
shayaa e0da811bfe Merge pull request #40 from noschXL/main 2026-03-09 17:18:40 +01:00
noschXL ef538fc83d Merge pull request #7 from KeksPirates/main
merge upstream
2026-03-09 16:04:22 +00:00
nosch c5e72332dc cleaned requirements.txt 2026-03-09 17:03:01 +01:00
nosch 39522a4068 download is in download.py, needs to move 2026-03-09 16:59:05 +01:00
Vxrtrauter a9f229926b feat: readd image path configuration in settings dialog and update paths tab 2026-03-09 15:16:06 +01:00
noschXL ec950b751b Merge pull request #6 from KeksPirates/main
Merge upstream
2026-03-09 05:52:35 +00:00
nosch 56735c4344 worked on downloads, magnets are next 2026-03-08 22:29:28 +01:00
nosch 87886a5cf9 merged 2026-03-08 16:07:08 +01:00
nosch 7cdae312ce preparing to merge 2026-03-08 15:54:57 +01:00
nosch ffdd656b9c fixed a slight issue in the gui 2026-03-08 01:47:33 +01:00
nosch d8fec74de1 every menu works, now the downloads need fixing 2026-03-08 01:42:59 +01:00
nosch 75e4da33eb merged upstream, fixed the double tables 2026-03-08 00:16:15 +01:00
nosch 003dd0e3aa a semiworking version, line 343 in gui.py is commented cuz it needs work, the table is also a bit screwed 2026-03-07 19:30:24 +01:00
nosch c76d179daa shayaa needs to fix rutracker NOW 2026-03-07 16:52:27 +01:00
noschXL eba4c01fca Merge pull request #4 from KeksPirates/main
Update Repository with latest SoftwareManager commits
2026-03-07 15:43:03 +00:00
nosch 577a1637e4 merged upstream 2026-03-06 22:42:42 +01:00
nosch 889c50e201 worked on gui implementation and cleaned up a bit 2026-02-28 17:58:50 +01:00
noschXL e4d1c189b7 Merge pull request #2 from KeksPirates/main
update
2026-02-28 14:09:47 +01:00
noschXL 7d55af6ac0 Merge pull request #1 from KeksPirates/main
sugma
2026-02-28 02:43:47 +01:00
nosch b9dc6cc18d im not dealing with megadb and fichier, as there are always better alternatives. if not ill add a way to select a downloaded .rar, though the links will be given to the user 2026-02-27 23:27:45 +01:00
nosch 29bac99359 steamrip scrapes downloadlinks for the provider - steamrip connection 2026-02-27 10:02:11 +01:00
nosch d6a388652b accidentally included a pycache 2026-02-26 18:12:12 +01:00
nosch 1c3fb95e40 vikingfile cloudflare turnstyle-captcha bypassed 2026-02-26 16:53:54 +01:00
nosch e354ef50cf made it more user frendly using a placeholdertext 2026-02-26 14:21:26 +01:00
nosch 2066c86001 now displaying the actual names of the game, not the links, links are in the 'link' key. 2026-02-26 14:08:07 +01:00
nosch 7e2b4cb46b moved the offline example 2026-02-26 13:36:13 +01:00
nosch bef019cf8c a fuzzyfind demo works, use python -m core.interface.utils.fzhelper 2026-02-26 13:32:56 +01:00
nosch 886ee8f0f0 installed fuzzyfinder, scraped the links, started UI 2026-02-25 23:22:40 +01:00
68 changed files with 2939 additions and 1588 deletions
+2 -8
View File
@@ -106,7 +106,7 @@ jobs:
run: |
python -c "
from PIL import Image
img = Image.open('src/core/interface/assets/logo.png')
img = Image.open('src/interface/assets/logo.png')
img.save('app_icon.ico', format='ICO', sizes=[(256,256),(128,128),(64,64),(48,48),(32,32),(16,16)])
print('Icon converted successfully')
"
@@ -125,8 +125,6 @@ jobs:
--upx-binary="${{ env.UPX_BINARY }}" \
--noinclude-qt-translations \
--include-data-files=build_info.json=build_info.json \
--include-data-files=build_info.json=src/build_info.json \
--include-data-files=build_info.json=src/core/interface/build_info.json \
--output-filename=${{ env.APP_NAME }}.exe \
--output-dir=nuitka-build \
src/main.py
@@ -137,8 +135,6 @@ jobs:
run: |
pyinstaller --onefile --windowed \
--add-data "build_info.json:." \
--add-data "build_info.json:src/core" \
--add-data "build_info.json:src/core/interface" \
--exclude-module PyQt6 \
--exclude-module PyQt5 \
--hidden-import=backports.tarfile \
@@ -155,8 +151,6 @@ jobs:
run: |
pyinstaller --onefile \
--add-data "build_info.json:." \
--add-data "build_info.json:src/core" \
--add-data "build_info.json:src/core/interface" \
--exclude-module PyQt6 \
--exclude-module PyQt5 \
--hidden-import=backports.tarfile \
@@ -287,4 +281,4 @@ jobs:
release/SoftwareManager-dev-${{ needs.build.outputs.short_sha }}-macos
release/SHA256SUMS.txt
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+185
View File
@@ -0,0 +1,185 @@
name: Build Executables (Test)
on:
workflow_dispatch:
permissions:
contents: read
env:
PYTHON_VERSION: "3.12"
APP_NAME: "SoftwareManager"
jobs:
build:
name: Build ${{ matrix.os }}
strategy:
fail-fast: false # keep other platforms building even if one fails
matrix:
include:
- os: ubuntu-22.04
artifact_name: SoftwareManager-test-${{ github.sha }}-linux
- os: windows-latest
artifact_name: SoftwareManager-test-${{ github.sha }}-windows
- os: macos-latest
artifact_name: SoftwareManager-test-${{ github.sha }}-macos
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "0.9.7"
enable-cache: true
- name: Install Python dependencies
run: |
uv pip install -r requirements.txt
uv pip install Pillow
env:
UV_SYSTEM_PYTHON: 1
- name: Install Nuitka (Windows only)
if: runner.os == 'Windows'
run: uv pip install nuitka
env:
UV_SYSTEM_PYTHON: 1
- name: Generate version
id: version
shell: bash
run: |
SHORT_SHA="${{ github.sha }}"
SHORT_SHA="${SHORT_SHA:0:7}"
BRANCH="${{ github.head_ref || github.ref_name }}"
BRANCH_SLUG="${BRANCH//\//-}" # replace slashes with dashes
VERSION="${BRANCH_SLUG}-${SHORT_SHA}-test"
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
echo "Generated version: $VERSION"
- name: Inject build info
shell: bash
run: |
cat > build_info.json << EOF
{
"version": "${{ env.VERSION }}"
}
EOF
- name: Install UPX (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
curl -sL https://github.com/upx/upx/releases/download/v5.1.0/upx-5.1.0-win64.zip -o upx.zip
7z x upx.zip -oupx-dir
UPX_DIR="$(pwd)/upx-dir/upx-5.1.0-win64"
echo "$UPX_DIR" >> $GITHUB_PATH
export PATH="$UPX_DIR:$PATH"
echo "UPX_BINARY=$UPX_DIR/upx.exe" >> $GITHUB_ENV
upx --version | head -1
- name: Convert icon for Windows
if: runner.os == 'Windows'
shell: bash
run: |
python -c "
from PIL import Image
img = Image.open('src/interface/assets/logo.png')
img.save('app_icon.ico', format='ICO', sizes=[(256,256),(128,128),(64,64),(48,48),(32,32),(16,16)])
print('Icon converted successfully')
"
- name: Build with Nuitka (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
python -m nuitka \
--standalone \
--assume-yes-for-downloads \
--windows-console-mode=disable \
--windows-icon-from-ico=app_icon.ico \
--enable-plugin=pyside6 \
--enable-plugin=upx \
--upx-binary="${{ env.UPX_BINARY }}" \
--noinclude-qt-translations \
--include-data-files=build_info.json=build_info.json \
--output-filename=${{ env.APP_NAME }}.exe \
--output-dir=nuitka-build \
src/main.py
- name: Build with PyInstaller (Linux)
if: runner.os == 'Linux'
shell: bash
run: |
pyinstaller --onefile --windowed \
--add-data "build_info.json:." \
--exclude-module PyQt6 \
--exclude-module PyQt5 \
--hidden-import=backports.tarfile \
--hidden-import=backports \
--hidden-import=jaraco.context \
--hidden-import=jaraco.text \
--hidden-import=jaraco.functools \
--name ${{ env.APP_NAME }} \
src/main.py
- name: Build with PyInstaller (macOS)
if: runner.os == 'macOS'
shell: bash
run: |
pyinstaller --onefile \
--add-data "build_info.json:." \
--exclude-module PyQt6 \
--exclude-module PyQt5 \
--hidden-import=backports.tarfile \
--hidden-import=backports \
--hidden-import=jaraco.context \
--hidden-import=jaraco.text \
--hidden-import=jaraco.functools \
--name ${{ env.APP_NAME }} \
src/main.py
- name: Stage artifact
shell: bash
run: |
mkdir -p dist-final
if [ "$RUNNER_OS" == "Windows" ]; then
mv nuitka-build/main.dist dist-final/${{ env.APP_NAME }}
else
chmod +x dist/${{ env.APP_NAME }}
mv dist/${{ env.APP_NAME }} dist-final/${{ matrix.artifact_name }}
fi
ls -la dist-final/
- name: Create ZIP (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
Compress-Archive -Path "dist-final/${{ env.APP_NAME }}/*" -DestinationPath "dist-final/${{ matrix.artifact_name }}.zip"
- name: Upload artifact (Windows)
if: runner.os == 'Windows'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.os }}-build
path: dist-final/${{ matrix.artifact_name }}.zip
retention-days: 7
- name: Upload artifact (Linux/macOS)
if: runner.os != 'Windows'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.os }}-build
path: dist-final/${{ matrix.artifact_name }}
retention-days: 7
-7
View File
@@ -63,12 +63,5 @@ SoftwareManager is a Python-based GUI tool that simplifies searching and downloa
</picture>
</a>
## Contributors
<a href="https://github.com/KeksPirates/SoftwareManager/graphs/contributors">
<img src="https://contrib.rocks/image?repo=KeksPirates/SoftwareManager" />
</a>
---
**Disclaimer:** SoftwareManager is intended for legal and ethical use only. Ensure compliance with applicable laws and regulations when using this tool.
+1 -1
View File
@@ -8,4 +8,4 @@ aiohttp==3.13.0
plyer==2.1.0
psutil==7.1.0
libtorrent==2.0.11
libtorrent-windows-dll==0.0.3
libtorrent-windows-dll==0.0.3
View File
View File
-70
View File
@@ -1,70 +0,0 @@
from bs4 import BeautifulSoup
import requests
import time
cache = {
"data": [],
"last_fetched": 0
}
cache_expiry = 300
def _get_telegram_posts():
post = 100
max_posts = 250
posts = []
added = set()
current_time = time.time()
if cache["data"] and (current_time - cache["last_fetched"] < cache_expiry):
return cache["data"]
while post <= max_posts:
url = f"https://t.me/s/real_monkrus/{post}"
response = requests.get(url, timeout=10)
soup = BeautifulSoup(response.text, "html.parser")
bubbles = soup.find_all("div", class_="tgme_widget_message_bubble")
if not bubbles:
break
for bubble in bubbles:
post_txt = bubble.find("div", class_="tgme_widget_message_text js-message_text")
if not post_txt or not post_txt.b:
continue
title = post_txt.b.text
link = bubble.find("a", href=lambda x: x and x.startswith("https://uztracker.net"))
if link:
post_url = link["href"]
if post_url not in added:
added.add(post_url)
posts.append(dict(
author="m0nkrus",
id=len(posts) + 1,
title=title,
url=post_url
))
post += 22
posts.reverse()
cache["data"] = posts
cache["last_fetched"] = current_time
return(posts)
def scrape_m0nkrus(query):
posts = _get_telegram_posts()
filtered_posts = []
for post in posts:
if query.lower() in post["title"].lower():
filtered_post = post.copy()
filtered_post["id"] = len(filtered_posts) + 1
filtered_posts.append(filtered_post)
return filtered_posts
-19
View File
@@ -1,19 +0,0 @@
import requests
from core.utils.data.state import state
from core.utils.logging.logs import consoleLog
def scrape_rutracker(query):
search = requests.get(f"{state.api_url}/search?q={query}")
consoleLog("Sent request to server")
if search:
try:
return search.text
except Exception:
consoleLog("No results found / No response from server")
return None
else:
return None
# This function utilizes the SoftwareManager server - source code can be found under the SoftwareManager-Server repository.
-37
View File
@@ -1,37 +0,0 @@
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
from core.utils.logging.logs import consoleLog
def scrape_uztracker(query):
base_url="https://uztracker.net/"
search_url = f"{base_url.rstrip('/')}/tracker.php?nm={query}"
consoleLog(search_url)
posts = []
try:
response = requests.get(search_url)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
links = soup.find_all('tr', class_="tCenter hl-tr", id=lambda x: x and x.startswith('tor_'))
for link in links:
theme_link = link.find('a', class_="genmed tLink", href=lambda x: x and x.startswith('./viewtopic'))
if not theme_link or not theme_link.b:
continue
url = urljoin(base_url, theme_link['href'])
title = theme_link.b.text
author_link = link.find('a', class_="med")
author = author_link.text.strip() if author_link else "Unknown"
posts.append(dict(
title=title,
url=url,
author=author
))
return posts
except requests.RequestException as e:
consoleLog(f"Failed to fetch {search_url}: {e}")
return None
-968
View File
@@ -1,968 +0,0 @@
from PySide6 import QtWidgets
from PySide6.QtCore import QPersistentModelIndex, Qt, QTimer, QModelIndex, QAbstractTableModel, Signal, QEvent, QSize, QByteArray
from PySide6.QtWidgets import (
QLineEdit,
QTableView,
QWidget,
QVBoxLayout,
QListWidget,
QLabel,
QHBoxLayout,
QComboBox,
QTabWidget,
QHeaderView,
QMessageBox,
QTableWidget,
QTextEdit,
QStyledItemDelegate,
QStatusBar
)
from PySide6.QtGui import QIcon, QCloseEvent, QImage, QPixmap, QContextMenuEvent, QGuiApplication, QColor, QBrush
from PySide6.QtSvg import QSvgRenderer
import darkdetect
import threading
import platform
import requests as r
import os
import subprocess
import libtorrent as lt
import sys
import json
import base64
from core.utils.logging.logs import consoleLog, remove_download_log, flush_log_buffer
from core.utils.general.wrappers import run_thread
from core.utils.data.state import state
from core.utils.network.download import download_selected
from core.utils.network.update_checker import check_for_updates
from core.interface.utils.tabhelper import create_tab
from core.interface.utils.searchhelper import return_pressed
from core.interface.dialogs.settings import settings_dialog
from core.interface.assets.base64_icons import settings_black_base64
from core.interface.assets.base64_icons import settings_white_base64
from core.interface.assets.base64_icons import logo_base64
from core.utils.general.shutdown import closehelper
def _is_dark_mode():
return darkdetect.isDark()
def _theme_colors():
dark = _is_dark_mode()
if dark:
return {
"text": "rgba(255, 255, 255, 0.9)",
"border": "rgba(255, 255, 255, 0.06)",
"selected": "rgba(255, 255, 255, 0.04)",
"header_text": "rgba(255, 255, 255, 0.5)",
"header_border": "rgba(255, 255, 255, 0.1)",
"hover": QColor(255, 255, 255, 15),
}
else:
return {
"text": "rgba(0, 0, 0, 0.87)",
"border": "rgba(0, 0, 0, 0.08)",
"selected": "rgba(0, 0, 0, 0.06)",
"header_text": "rgba(0, 0, 0, 0.6)",
"header_border": "rgba(0, 0, 0, 0.12)",
"hover": QColor(0, 0, 0, 15),
}
def _table_stylesheet(view_type="QTableWidget"):
c = _theme_colors()
dark = _is_dark_mode()
color_rule = "" if dark else f"color: {c['text']};"
return f"""
{view_type} {{
border: none;
outline: 0;
font-size: 13px;
{color_rule}
}}
{view_type}::item {{
border-bottom: 1px solid {c["border"]};
padding: 6px 14px;
outline: none;
{color_rule}
}}
{view_type}::item:selected {{
background: {c["selected"]};
outline: none;
border: none;
border-bottom: 1px solid {c["border"]};
}}
{view_type}::item:focus {{
outline: none;
border: none;
border-bottom: 1px solid {c["border"]};
}}
QHeaderView {{
background: transparent;
}}
QHeaderView::section {{
background: transparent;
color: {c["header_text"]};
font-weight: normal;
border: none;
border-bottom: 1px solid {c["header_border"]};
padding: 6px 14px;
}}
QHeaderView::section:checked {{
background: transparent;
color: {c["header_text"]};
font-weight: normal;
}}
"""
def _svg_icon(svg_str, size=20):
app = QtWidgets.QApplication.instance()
if app:
if _is_dark_mode():
color = "white"
else:
color = "#555555"
else:
color = "white"
svg = svg_str.replace("{color}", color)
renderer = QSvgRenderer(QByteArray(svg.encode()))
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
from PySide6.QtGui import QPainter
painter = QPainter(pixmap)
renderer.render(painter)
painter.end()
return QIcon(pixmap)
SVG_PLAY = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><polygon points="6,3 20,12 6,21" fill="{color}"/></svg>'
SVG_PAUSE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect x="5" y="3" width="4" height="18" rx="1" fill="{color}"/><rect x="15" y="3" width="4" height="18" rx="1" fill="{color}"/></svg>'
SVG_FOLDER = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M2 6c0-1.1.9-2 2-2h5l2 2h7c1.1 0 2 .9 2 2v10c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6z" fill="{color}"/></svg>'
def download_update(latest_version):
import tempfile
import time
new_filename = f"SoftwareManager-dev-{latest_version.replace('-dev', '')}-windows-setup.exe"
url = f"https://github.com/KeksPirates/SoftwareManager/releases/latest/download/SoftwareManager-dev-{latest_version.replace('-dev', '')}-windows-setup.exe"
installer_path = os.path.join(tempfile.gettempdir(), new_filename)
progress = QtWidgets.QProgressDialog("Downloading installer...", None, 0, 0)
progress.setWindowTitle("Updating")
progress.setWindowModality(Qt.WindowModality.ApplicationModal)
progress.setCancelButton(None)
progress.setMinimumDuration(0)
progress.setAutoClose(False)
progress.setAutoReset(False)
progress.setRange(0, 100)
progress.setValue(0)
progress.show()
QtWidgets.QApplication.processEvents()
response = r.get(url, allow_redirects=True, stream=True)
total = int(response.headers.get("content-length", 0))
downloaded = 0
with open(installer_path, "wb") as f:
for chunk in response.iter_content(chunk_size=65536):
f.write(chunk)
downloaded += len(chunk)
if total > 0:
progress.setValue(int(downloaded * 100 / total))
QtWidgets.QApplication.processEvents()
if not os.path.exists(installer_path):
progress.close()
raise FileNotFoundError("Executable not found")
progress.setLabelText("Installing update...")
progress.setValue(100)
QtWidgets.QApplication.processEvents()
subprocess.Popen([installer_path, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/SP-", "/CLOSEAPPLICATIONS"])
time.sleep(1)
sys.exit(0)
def windowCloseHelper():
QGuiApplication.quit()
class MainWindow(QtWidgets.QMainWindow, QWidget):
log_signal = Signal(str) # Thread-safe signal for logging
search_finished_signal = Signal() # Thread-safe signal for search completion
def __init__(self):
super().__init__()
MainWindow._instance = self
self.log_signal.connect(self._on_log_signal)
self.search_finished_signal.connect(self._on_search_finished)
pixmap = QPixmap()
image_data = base64.b64decode(logo_base64)
pixmap.loadFromData(image_data)
self.setWindowIcon(QIcon(pixmap))
if platform.system() == "Windows":
try:
import ctypes
appid = 'SoftwareManager'
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid)
except Exception as e:
consoleLog(f"Could not set app ID: {e}")
build_info_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "build_info.json")
if os.path.exists(build_info_path):
with open(build_info_path, "r") as f:
build_info = json.load(f)
state.version = build_info.get("version")
# Check for updates on Windows
if state.ignore_updates is False and platform.system() == "Windows":
result = check_for_updates()
if result != (None, None):
assets, latest_version = result
if assets:
msg = QMessageBox()
msg.setIcon(QMessageBox.Icon.Information)
msg.setWindowTitle("Update Available")
msg.setText("A new version is available.")
msg.setInformativeText("Press Ok to download the update.")
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Ignore)
response = msg.exec_()
if response == QMessageBox.StandardButton.Ok:
download_update(latest_version)
self.setWindowTitle("Software Manager")
self.setGeometry(100, 100, 800, 600)
self.controls = QWidget()
self.controlsLayout = QVBoxLayout()
# Widgets
self.searchbar = QLineEdit()
self.searchbar.setPlaceholderText("Search for software...")
self.searchbar.setClearButtonEnabled(True)
self.searchbar.setMinimumHeight(30)
self.searchbar.returnPressed.connect(self._start_search)
self.dlbutton = QtWidgets.QPushButton("Download")
self.dlbutton.setCursor(Qt.CursorShape.PointingHandCursor)
self.libraryList = QListWidget()
self.emptyResults = QLabel("No Results")
self.emptyResults.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.emptyResults.hide()
self.download_model = None
self.downloadList = QTableView()
self.downloadList.setMouseTracking(True)
self._hovered_row = -1
self._tracker_hovered_row = -1
self._tracker_hovered_table = None
self.downloadList.viewport().installEventFilter(self)
self.emptyLibrary = QLabel("No items in library.")
self.emptyDownload = QLabel("No items in downloads.")
self.emptyDownload.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.consoleLog = QTextEdit()
self.statusbar = QStatusBar()
flush_log_buffer()
class TrackerHoverDelegate(QStyledItemDelegate):
def paint(self, painter, option, index):
if index.row() == MainWindow._instance._tracker_hovered_row:
painter.save()
painter.fillRect(option.rect, _theme_colors()["hover"])
painter.restore()
super().paint(painter, option, index)
self._tracker_hover_delegate = TrackerHoverDelegate(self)
def create_tracker_table(headers):
table = QTableWidget()
table.setColumnCount(len(headers))
table.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers)
table.verticalHeader().setVisible(False)
table.setHorizontalHeaderLabels(headers)
table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows)
table.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection)
header = table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
header.resizeSection(1, 500)
header.setStretchLastSection(False)
header.setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
header.setHighlightSections(False)
table.setShowGrid(False)
table.setStyleSheet(_table_stylesheet("QTableWidget"))
table.setMouseTracking(True)
table.viewport().setMouseTracking(True)
table.setItemDelegate(self._tracker_hover_delegate)
return table
self.rutrackerlist = create_tracker_table(["Post Title", "Author", "Seeders", "Leechers"])
self.uztrackerlist = create_tracker_table(["Post Title", "Author"])
self.monkruslist = create_tracker_table(["Post Title", "Author"])
state.tracker_list.update({"rutracker": self.rutrackerlist, "uztracker": self.uztrackerlist, "m0nkrus": self.monkruslist})
self.rutrackerlist.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.rutrackerlist.viewport().setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.uztrackerlist.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.uztrackerlist.viewport().setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.monkruslist.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.monkruslist.viewport().setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
for tbl in (self.rutrackerlist, self.uztrackerlist, self.monkruslist):
tbl.viewport().installEventFilter(self)
container = QWidget()
containerLayout = QVBoxLayout()
search_row = QHBoxLayout()
search_row.addWidget(self.searchbar)
containerLayout.addLayout(search_row)
containerLayout.addWidget(state.tracker_list[state.tracker])
class DownloadModel(QAbstractTableModel):
def __init__(self):
super().__init__()
self.headers = ["Action", "Name", "Status", "Progress", "Speed", "Size", "Total Size"]
def rowCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int:
return len(state.active_downloads)
def columnCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()):
return len(self.headers)
def headerData(self, section, orientation, role: int = Qt.ItemDataRole.DisplayRole):
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
return self.headers[section]
return None
def data(self, index: QModelIndex | QPersistentModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
if role == Qt.ItemDataRole.DisplayRole:
col = index.column()
if index.row() >= len(state.active_downloads) or index.row() < 0:
return None
magnet_link = list(state.active_downloads.keys())[index.row()]
magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status()
if col == 0:
pass
elif col == 1:
return status.name if status.has_metadata else "Fetching metadata..."
elif col == 2:
if status.state == lt.torrent_status.downloading:
return "Downloading"
elif status.state == lt.torrent_status.seeding:
return "Seeding"
elif status.paused:
is_auto = bool(magnetdl.flags() & lt.torrent_flags.auto_managed)
return "Queued" if is_auto else "Paused"
else:
return "Queued"
elif col == 3:
return f"{status.progress * 100:.1f}%"
elif col == 4:
down_kb = status.download_rate / 1024
up_kb = status.upload_rate / 1024
down_text = f"{down_kb / 1024:.1f} MB/s" if down_kb > 1024 else f"{down_kb:.1f} kB/s"
up_text = f"{up_kb / 1024:.1f} MB/s" if up_kb > 1024 else f"{up_kb:.1f} kB/s"
return f"{down_text}{up_text}"
elif col == 5:
downloaded_mb = status.total_wanted_done / (1024 * 1024)
if downloaded_mb > 1024:
return f"{downloaded_mb / 1024:.2f} GB"
else:
return f"{downloaded_mb:.1f} MB"
elif col == 6:
total_mb = status.total_wanted / (1024 * 1024)
if total_mb > 1024:
return f"{total_mb / 1024:.2f} GB"
else:
return f"{total_mb:.1f} MB"
elif col == 7:
if status.download_rate > 0:
bytes_left = status.total_wanted - status.total_wanted_done
eta_seconds = bytes_left / status.download_rate
if eta_seconds < 60:
return f"{int(eta_seconds)}s"
elif eta_seconds < 3600:
minutes = int(eta_seconds / 60)
seconds = int(eta_seconds % 60)
return f"{minutes}m {seconds}s"
else:
hours = int(eta_seconds / 3600)
minutes = int((eta_seconds % 3600) / 60)
return f"{hours}h {minutes}m"
else:
return "" if status.paused else "Stalled"
if role == Qt.ItemDataRole.UserRole and index.column() == 0:
magnet_link = list(state.active_downloads.keys())[index.row()]
magnetdl = state.active_downloads[magnet_link]
return magnetdl.status().paused
return None
def toggle_pause_resume(self, row):
if row >= len(state.active_downloads) or row < 0:
return
magnet_link = list(state.active_downloads.keys())[row]
magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status()
if status.state == lt.torrent_status.seeding:
save_path = magnetdl.save_path()
if save_path and os.path.exists(save_path):
if platform.system() == "Windows":
os.startfile(os.path.normpath(save_path))
elif platform.system() == "Linux":
subprocess.Popen(["xdg-open", save_path])
elif platform.system() == "Darwin":
subprocess.Popen(["open", save_path])
return
is_paused = bool(magnetdl.flags() & lt.torrent_flags.paused)
if is_paused:
magnetdl.set_flags(lt.torrent_flags.auto_managed)
magnetdl.resume()
consoleLog(f"Resumed download: {status.name}", True)
else:
magnetdl.unset_flags(lt.torrent_flags.auto_managed)
magnetdl.pause()
consoleLog(f"Paused download: {status.name}", True)
idx = self.index(row, 0)
self.dataChanged.emit(idx, idx, [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.UserRole])
class HoverRowDelegate(QStyledItemDelegate):
def paint(self, painter, option, index):
opt = QtWidgets.QStyleOptionViewItem(option)
opt.state &= ~QtWidgets.QStyle.StateFlag.State_HasFocus
opt.state &= ~QtWidgets.QStyle.StateFlag.State_Selected
if index.row() == MainWindow._instance._hovered_row:
painter.save()
painter.fillRect(opt.rect, _theme_colors()["hover"])
painter.restore()
super().paint(painter, opt, index)
class PauseResumeDelegate(QStyledItemDelegate):
clicked = Signal(int)
def paint(self, painter, option, index):
opt = QtWidgets.QStyleOptionViewItem(option)
opt.state &= ~QtWidgets.QStyle.StateFlag.State_HasFocus
opt.state &= ~QtWidgets.QStyle.StateFlag.State_Selected
if index.row() == MainWindow._instance._hovered_row:
painter.save()
painter.fillRect(opt.rect, _theme_colors()["hover"])
painter.restore()
super().paint(painter, opt, index)
def setEditorData(self, editor, index):
button = editor.findChild(QtWidgets.QPushButton)
magnet_link = list(state.active_downloads.keys())[index.row()]
magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status()
if button:
if status.state == lt.torrent_status.seeding:
button.setIcon(_svg_icon(SVG_FOLDER, 18))
button.setText("")
else:
is_user_paused = status.paused and not bool(magnetdl.flags() & lt.torrent_flags.auto_managed)
button.setIcon(_svg_icon(SVG_PLAY if is_user_paused else SVG_PAUSE, 18))
button.setText("")
def createEditor(self, parent, option, index):
magnet_link = list(state.active_downloads.keys())[index.row()]
magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status()
widget = QWidget(parent)
widget.setStyleSheet("border: none; background: transparent;")
layout = QHBoxLayout(widget)
layout.setContentsMargins(0, 0, 0, 0)
if status.state == lt.torrent_status.seeding:
btnPause = QtWidgets.QPushButton()
btnPause.setIcon(_svg_icon(SVG_FOLDER, 18))
else:
btnPause = QtWidgets.QPushButton()
is_user_paused = status.paused and not bool(magnetdl.flags() & lt.torrent_flags.auto_managed)
btnPause.setIcon(_svg_icon(SVG_PLAY if is_user_paused else SVG_PAUSE, 18))
btnPause.setIconSize(QSize(18, 18))
btnPause.setFixedSize(30, 30)
btnPause.setCursor(Qt.CursorShape.PointingHandCursor)
btnPause.setStyleSheet("""
QPushButton {
border: none;
background: transparent;
padding: 0px;
}
""")
btnPause.clicked.connect(lambda: self.clicked.emit(index.row()))
layout.addStretch()
layout.addWidget(btnPause)
layout.addStretch()
widget.setLayout(layout)
return widget
def editorEvent(self, event, model, option, index):
return False
self.download_model = DownloadModel()
self.downloadList.setModel(self.download_model)
self.downloadList.horizontalHeader().setStretchLastSection(False)
self.downloadList.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
self.downloadList.horizontalHeader().resizeSection(0, 70)
self.downloadList.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
self.downloadList.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
self.downloadList.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
self.downloadList.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
self.downloadList.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents)
self.downloadList.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents)
self.downloadList.horizontalHeader().setMinimumSectionSize(60)
self.downloadList.verticalHeader().setDefaultSectionSize(40)
self.downloadList.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
self.downloadList.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection)
self.downloadList.verticalHeader().setVisible(False)
self.downloadList.horizontalHeader().setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
self.downloadList.horizontalHeader().setHighlightSections(False)
self.downloadList.setMouseTracking(True)
self.downloadList.setShowGrid(False)
self.downloadList.setStyleSheet(_table_stylesheet("QTableView"))
self.downloadList.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.downloadList.viewport().setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
download_model = self.download_model
def on_pause_resume_clicked(row):
download_model.toggle_pause_resume(row)
self.downloadList.viewport().setMouseTracking(True)
hover_delegate = HoverRowDelegate(self.downloadList)
self.downloadList.setItemDelegate(hover_delegate)
delegate = PauseResumeDelegate(self.downloadList)
self.downloadList.setItemDelegateForColumn(0, delegate)
delegate.clicked.connect(on_pause_resume_clicked)
# download button triggers
self.dlbutton.clicked.connect(lambda: run_thread(threading.Thread(target=download_selected, args=(state.tracker_list[state.tracker].selectedItems(), state.posts, state.post_titles))))
container.setLayout(containerLayout)
self.setCentralWidget(container)
# Tabs
self.tabs = QTabWidget()
self.tabs.setStyleSheet("""
QTabBar::tab {
font-size: 11px;
padding: 2px 8px;
height: 25px;
}
QTabWidget::pane {
margin-top: -20px;
}
""")
self.horizontal_layout = QHBoxLayout()
self.horizontal_layout.addWidget(self.emptyResults, stretch=3)
self.tracker_widget = state.tracker_list[state.tracker]
self.horizontal_layout.addWidget(self.tracker_widget)
self.tab1 = create_tab("Search", self.searchbar, state.tracker_list[state.tracker], self.tabs, self.dlbutton, self.horizontal_layout)
# self.tab2 = create_tab("Library", self.emptyLibrary, self.libraryList, self.tabs, None, None)
self.tab3 = create_tab("Downloads", self.emptyDownload, self.downloadList, self.tabs, None, None)
if state.image_path is not None and os.path.exists(state.image_path):
self.image = QImage(state.image_path)
self.image = self.image.scaledToWidth(300, Qt.TransformationMode.SmoothTransformation)
self.pixmap = QPixmap.fromImage(self.image)
self.overlay_label = QLabel(self)
self.overlay_label.setPixmap(self.pixmap)
self.overlay_label.adjustSize()
self.overlay_label.raise_()
# offset_x = -1350
# offset_y = -550
x = self.width() - self.overlay_label.width() # - offset_x
y = self.height() - self.overlay_label.height() # - offset_y
self.overlay_label.move(x, y)
# temporarily disabled
# state.image_changed.connect(self.update_image_overlay)
self.corner_widget = QWidget()
self.corner_layout = QHBoxLayout(self.corner_widget)
self.corner_layout.setContentsMargins(0, 0, 0, 0)
self.tracker_list = QComboBox()
self.tracker_list.addItems(["rutracker", "uztracker", "m0nkrus"])
self.tracker_list.setCursor(Qt.CursorShape.PointingHandCursor)
self.tracker_list.activated.connect(self.set_tracker)
self.corner_layout.addWidget(self.tracker_list)
self.settings_btn = QtWidgets.QToolButton()
self.settings_btn.setIconSize(QSize(21, 21))
self.settings_btn.setStyleSheet("background: transparent;")
if darkdetect.isDark():
settings_white_pixmap = QPixmap()
settings_white = base64.b64decode(settings_white_base64)
settings_white_pixmap.loadFromData(settings_white)
self.settings_btn.setIcon(QIcon(settings_white_pixmap))
else:
settings_black_pixmap = QPixmap()
settings_black = base64.b64decode(settings_black_base64)
settings_black_pixmap.loadFromData(settings_black)
self.settings_btn.setIcon(QIcon(settings_black_pixmap))
self.settings_btn.setToolTip("Settings")
self.settings_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self.settings_btn.clicked.connect(lambda: settings_dialog(self))
self.corner_layout.addWidget(self.settings_btn)
self.tab_wrapper = QWidget()
self.tab_layout = QVBoxLayout()
self.tab_layout.setContentsMargins(0, 0, 0, 0)
self.tab_layout.setSpacing(0)
self.tab_bar_layout = QHBoxLayout()
self.tab_bar_layout.setContentsMargins(0, 0, 0, 0)
self.tab_bar_layout.setSpacing(0)
self.tab_bar_layout.addWidget(self.tabs.tabBar(), stretch=0)
self.tab_bar_layout.addStretch()
self.tab_bar_layout.addWidget(self.corner_widget, stretch=0)
self.tab_layout.addLayout(self.tab_bar_layout)
self.tab_layout.addWidget(self.tabs)
self.tab_wrapper.setLayout(self.tab_layout)
containerLayout.addWidget(self.tab_wrapper)
containerLayout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.consoleLog.setReadOnly(True)
self.consoleLog.setFixedHeight(150)
self.consoleLog.setStyleSheet("margin-bottom: 0px; background: transparent;")
containerLayout.addWidget(self.consoleLog)
self.statusbar.show()
self.statusbar.setStyleSheet("background: transparent; border: none; padding-right: 1px; padding-left: 6px; padding-bottom: 1px; margin-top: -8px")
self.version = QLabel(f"Version: {state.version}")
self.statusbar.addPermanentWidget(self.version, Qt.AlignmentFlag.AlignLeft)
self.speed_label = QLabel("\u2193 0.0 kB/s \u2191 0.0 kB/s")
self.speed_label.setStyleSheet("padding-right: 6px;")
self.statusbar.addPermanentWidget(self.speed_label)
self.setStatusBar(self.statusbar)
self.download_timer = QTimer()
self.download_timer.timeout.connect(self.download_list_update)
self.download_timer.start(500)
self.active_timer = QTimer()
self.active_timer.timeout.connect(self.show_empty_downloads)
self.active_timer.start(500)
self.context_menu = QtWidgets.QMenu(self)
self.context_menu.addAction("Open Containing Folder", self.openFolderAction)
self.context_menu.addAction("Copy Magnet URI", self.copyMagnetURIAction)
self.context_menu.addAction("Cancel Download", self.cancelDownloadAction)
self.context_menu.addAction("Delete File", self.deleteFileAction)
def _start_search(self):
self.searchbar.setEnabled(False)
def _search_thread():
try:
return_pressed(self)
finally:
self.search_finished_signal.emit()
run_thread(threading.Thread(target=_search_thread))
def _on_search_finished(self):
self.searchbar.setEnabled(True)
self.searchbar.setFocus()
@staticmethod
def add_log(text):
if MainWindow._instance:
MainWindow._instance.log_signal.emit(text)
def _on_log_signal(self, text):
if hasattr(self, 'consoleLog'):
self.consoleLog.append(text)
self.consoleLog.verticalScrollBar().setValue(
self.consoleLog.verticalScrollBar().maximum()
)
def update_image_overlay(self, new_image_path):
self.image = QImage(new_image_path)
self.pixmap = QPixmap.fromImage(self.image)
self.overlay_label.setPixmap(self.pixmap)
self.overlay_label.adjustSize()
def download_list_update(self):
if self.download_model:
row_count = self.download_model.rowCount()
if row_count > 0:
top_left = self.download_model.index(0, 0)
bottom_right = self.download_model.index(row_count - 1, self.download_model.columnCount() - 1)
self.download_model.dataChanged.emit(top_left, bottom_right, [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.UserRole])
if not hasattr(self, '_last_dl_row_count'):
self._last_dl_row_count = 0
if row_count != self._last_dl_row_count:
for row in range(row_count, self._last_dl_row_count):
idx = self.download_model.index(row, 0)
self.downloadList.closePersistentEditor(idx)
self._last_dl_row_count = row_count
self.download_model.layoutAboutToBeChanged.emit()
self.download_model.layoutChanged.emit()
for row in range(row_count):
idx = self.download_model.index(row, 0)
self.downloadList.closePersistentEditor(idx)
self.downloadList.openPersistentEditor(idx)
else:
delegate = self.downloadList.itemDelegateForColumn(0)
for row in range(row_count):
idx = self.download_model.index(row, 0)
editor = self.downloadList.indexWidget(idx)
if editor and delegate:
delegate.setEditorData(editor, idx)
self._update_speed_label()
def _update_speed_label(self):
total_down = 0
total_up = 0
for handle in state.active_downloads.values():
try:
s = handle.status()
total_down += s.download_rate
total_up += s.upload_rate
except Exception:
pass
down_kb = total_down / 1024
up_kb = total_up / 1024
down_text = f"{down_kb / 1024:.1f} MB/s" if down_kb > 1024 else f"{down_kb:.1f} kB/s"
up_text = f"{up_kb / 1024:.1f} MB/s" if up_kb > 1024 else f"{up_kb:.1f} kB/s"
self.speed_label.setText(f"{down_text}{up_text}")
def mousePressEvent(self, event):
self.rutrackerlist.clearSelection()
self.uztrackerlist.clearSelection()
self.monkruslist.clearSelection()
self.downloadList.clearSelection()
super().mousePressEvent(event)
def changeEvent(self, event):
super().changeEvent(event)
def closeEvent(self, event: QCloseEvent):
closehelper()
event.accept()
from core.utils.general.shutdown import force_exit
force_exit()
def eventFilter(self, obj, event):
try:
for tbl in (self.rutrackerlist, self.uztrackerlist, self.monkruslist):
if obj == tbl.viewport():
if event.type() == QEvent.Type.MouseMove:
pos = event.position().toPoint() if hasattr(event, 'position') else event.pos()
idx = tbl.indexAt(pos)
new_row = idx.row() if idx.isValid() else -1
if new_row != self._tracker_hovered_row or tbl is not self._tracker_hovered_table:
self._tracker_hovered_row = new_row
self._tracker_hovered_table = tbl
tbl.viewport().update()
elif event.type() == QEvent.Type.Leave:
self._tracker_hovered_row = -1
self._tracker_hovered_table = None
tbl.viewport().update()
break
if obj == self.downloadList.viewport():
if event.type() == QEvent.Type.MouseMove:
pos = event.position().toPoint() if hasattr(event, 'position') else event.pos()
idx = self.downloadList.indexAt(pos)
new_row = idx.row() if idx.isValid() else -1
if new_row != self._hovered_row:
old_row = self._hovered_row
self._hovered_row = new_row
self._invalidate_hover_row(old_row)
self._invalidate_hover_row(new_row)
elif event.type() == QEvent.Type.Leave:
old_row = self._hovered_row
self._hovered_row = -1
self._invalidate_hover_row(old_row)
except RuntimeError:
pass
return super().eventFilter(obj, event)
def _invalidate_hover_row(self, row):
if row >= 0 and self.download_model:
self.downloadList.viewport().update()
def set_tracker(self, _):
old_tracker = state.tracker
state.tracker = self.tracker_list.currentText()
old_widget = state.tracker_list[old_tracker]
self.horizontal_layout.removeWidget(old_widget)
old_widget.setParent(None)
tracker_widget = state.tracker_list[state.tracker]
self.horizontal_layout.addWidget(tracker_widget)
def show_empty_results(self, show: bool):
if show:
state.tracker_list[state.tracker].hide()
self.emptyResults.show()
else:
state.tracker_list[state.tracker].show()
self.emptyResults.hide()
def show_empty_downloads(self):
if len(state.active_downloads) > 0:
self.emptyDownload.hide()
self.downloadList.show()
else:
self.emptyDownload.show()
self.downloadList.hide()
# thank you claude
def resizeEvent(self, event):
super().resizeEvent(event)
table_width = state.tracker_list[state.tracker].viewport().width()
state.tracker_list[state.tracker].setColumnWidth(1, int(table_width * 0.3))
def contextMenuEvent(self, event: QContextMenuEvent):
if self.downloadList.underMouse():
pos = self.downloadList.viewport().mapFromGlobal(event.globalPos())
index = self.downloadList.indexAt(pos)
if index.isValid():
row = index.row()
self._context_menu_row = row
self.context_menu.exec(event.globalPos())
else:
super().contextMenuEvent(event)
def openFolderAction(self):
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]
download_path = magnetdl.save_path()
if download_path and os.path.exists(download_path):
if platform.system() == "Windows":
os.startfile(os.path.normpath(download_path))
elif platform.system() == "Linux":
subprocess.Popen(["xdg-open", download_path])
elif platform.system() == "Darwin":
subprocess.Popen(["open", download_path])
def copyMagnetURIAction(self):
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]
clipboard = QtWidgets.QApplication.clipboard()
clipboard.setText(magnet_link)
def cancelDownloadAction(self):
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]
confirm = QMessageBox.question(self, "Cancel Download", f"Are you sure you want to cancel the download of '{magnetdl.status().name}'?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
if confirm == QMessageBox.StandardButton.Yes:
del state.active_downloads[magnet_link]
remove_download_log(magnet_link)
consoleLog(f"Cancelled download: {magnetdl.status().name}", True)
def deleteFileAction(self):
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]
status = magnetdl.status()
save_path = status.save_path
torrent_name = status.name
download_path = os.path.join(save_path, torrent_name)
confirm = QMessageBox.question(self, "Delete Files", f"Are you sure you want to delete the downloaded files of '{magnetdl.status().name}'? This action cannot be undone.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
if confirm == QMessageBox.StandardButton.Yes:
if download_path and os.path.exists(download_path):
try:
if os.path.isfile(download_path):
remove_download_log(magnet_link)
os.remove(download_path)
del state.active_downloads[magnet_link]
consoleLog(f"Deleted files for: {magnetdl.status().name}", True)
else:
import shutil
del state.active_downloads[magnet_link]
remove_download_log(magnet_link)
shutil.rmtree(download_path)
consoleLog(f"Deleted files for: {magnetdl.status().name}", True)
except Exception as e:
consoleLog(f"Error deleting files: {e}", True)
-66
View File
@@ -1,66 +0,0 @@
from PySide6.QtWidgets import QTableWidgetItem
from core.utils.logging.logs import consoleLog
from core.data.scrapers.uztracker import scrape_uztracker
from core.data.scrapers.rutracker import scrape_rutracker
from core.data.scrapers.monkrus import scrape_m0nkrus
from core.utils.network.jsonhandler import split_data, format_data, format_data_minimal
from core.utils.data.state import state
scrapers = {
"uztracker": scrape_uztracker,
"rutracker": scrape_rutracker,
"m0nkrus": scrape_m0nkrus
}
def return_pressed(self):
search_text = self.searchbar.text()
if search_text == "":
consoleLog("Error: Can't search for nothing")
return
consoleLog(f"User searched for: {search_text}")
if state.tracker == "rutracker":
response = scrape_rutracker(search_text)
if response:
_, state.posts, _, _, cached = split_data(response)
if state.posts == []:
consoleLog(f"No Results for {search_text}")
state.tracker_list[state.tracker].clear()
self.show_empty_results(True)
else:
state.post_titles, _, state.post_author, state.post_seeders, state.post_leechers = format_data(state.posts)
self.show_empty_results(False)
state.tracker_list[state.tracker].clear()
state.tracker_list[state.tracker].setHorizontalHeaderLabels(["Post Title", "Author", "Seeders", "Leechers"])
state.tracker_list[state.tracker].setRowCount(len(state.post_titles))
for i, author in enumerate(state.post_author):
state.tracker_list[state.tracker].setItem(i, 1, QTableWidgetItem(author))
for i, title in enumerate(state.post_titles):
state.tracker_list[state.tracker].setItem(i, 0, QTableWidgetItem(title))
for i, seeders in enumerate(state.post_seeders):
state.tracker_list[state.tracker].setItem(i, 2, QTableWidgetItem(seeders))
for i, leechers in enumerate(state.post_leechers):
state.tracker_list[state.tracker].setItem(i, 3, QTableWidgetItem(leechers))
if state.debug == True:
consoleLog(f"Response Cached: {cached}")
else:
consoleLog(f"No response from rutracker")
state.tracker_list[state.tracker].clear()
self.show_empty_results(True)
elif state.tracker is not None:
state.posts = scrapers[state.tracker](search_text)
if not state.posts:
consoleLog(f"No Results for {search_text}")
state.tracker_list[state.tracker].clear()
self.show_empty_results(True)
else:
state.post_author, state.post_titles, state.post_urls = format_data_minimal(state.posts)
self.show_empty_results(False)
state.tracker_list[state.tracker].clear()
state.tracker_list[state.tracker].setHorizontalHeaderLabels(["Post Title", "Author"])
state.tracker_list[state.tracker].setRowCount(len(state.post_titles))
for i, author in enumerate(state.post_author):
state.tracker_list[state.tracker].setItem(i, 1, QTableWidgetItem(author))
for i, title in enumerate(state.post_titles):
state.tracker_list[state.tracker].setItem(i, 0, QTableWidgetItem(title))
-56
View File
@@ -1,56 +0,0 @@
from PySide6.QtWidgets import QWidget, QVBoxLayout
def create_tab(title, searchbar, software_list, tabs, dlbutton, layout2):
tab = QWidget()
layout = QVBoxLayout()
if searchbar:
layout.addWidget(searchbar)
if layout2:
layout.addLayout(layout2)
else:
layout.addWidget(software_list)
if dlbutton:
layout.addWidget(dlbutton)
tab.setLayout(layout)
tabs.addTab(tab, title)
return tab
#################
# SETTINGS TABS #
#################
def general_tab(title, autoresume, update_checkbox, transparent_window, tabs):
tab = QWidget()
layout = QVBoxLayout()
layout.addWidget(autoresume)
layout.addWidget(update_checkbox)
layout.addWidget(transparent_window)
layout.addStretch()
tab.setLayout(layout)
tabs.addTab(tab, title)
return tab
def paths_tab(title, download_path, tabs):
tab = QWidget()
layout = QVBoxLayout()
layout.addWidget(download_path)
# layout.addWidget(image_path)
layout.addStretch()
tab.setLayout(layout)
tabs.addTab(tab, title)
return tab
def network_tab(title, network_interface, max_connections, max_downloads, up_speed_limit, down_speed_limit, api_url, tabs):
tab = QWidget()
layout = QVBoxLayout()
layout.addWidget(network_interface)
layout.addWidget(max_connections)
layout.addWidget(max_downloads)
layout.addWidget(up_speed_limit)
layout.addWidget(down_speed_limit)
layout.addWidget(api_url)
layout.addStretch()
tab.setLayout(layout)
tabs.addTab(tab, title)
return tab
View File
View File
-46
View File
@@ -1,46 +0,0 @@
import requests
from bs4 import BeautifulSoup
from core.utils.data.state import state
from core.utils.logging.logs import consoleLog
from core.utils.network.jsonhandler import format_data
def get_item_url(item, posts, post_titles): # softwarelist currentitem, post list (dict), post titles list
post_index = post_titles.index(item)
if 0 <= post_index < len(post_titles):
if state.tracker == "uztracker":
item = "https://uztracker.net/" + state.post_urls[post_index].lstrip("./")
consoleLog(f"Found post URL: {item}")
return item
if state.tracker == "rutracker":
item_dict = posts[post_index]
_, post_links, _, _, _ = format_data([item_dict])
consoleLog(f"Found post URL: {post_links[0]}")
return post_links[0]
if state.tracker == "m0nkrus":
item_dict = posts[post_index]
_, post_links, _, _, _,= format_data([item_dict])
consoleLog(f"Found post URL: {post_links[0]}")
return post_links[0]
return None
def get_magnet_link(post_url):
try:
response = requests.get(post_url, timeout=15) # eventually impl. cloudscraper
consoleLog("Sent Request to retrieve Magnet Link...")
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
magnet_link = soup.find('a', href=lambda x: x and x.startswith('magnet:'))
if magnet_link:
consoleLog(f"Magnet Link Retrieved: {magnet_link['href']}")
return magnet_link['href']
else:
consoleLog("Magnet Link not Found!")
except requests.RequestException as e:
consoleLog(f"Failed to fetch {post_url}: {e}")
return None
-10
View File
@@ -1,10 +0,0 @@
import threading
import os
shutdown_event = threading.Event()
def closehelper():
shutdown_event.set()
def force_exit():
os._exit(0)
-2
View File
@@ -1,2 +0,0 @@
def run_thread(thread):
thread.start()
-40
View File
@@ -1,40 +0,0 @@
from core.utils.logging.logs import consoleLog
from core.utils.data.tracker import get_item_url
from core.utils.data.tracker import get_magnet_link
from core.network.libtorrent_wrapper import add_download
from core.utils.general.wrappers import run_thread
from core.utils.logging.logs import add_download_log
from core.network.libtorrent_int import add_seed
import threading
def download_selected(items, posts, post_titles):
if not items:
consoleLog("No item selected for download.")
return
seen = set()
for item in items:
if item.column() != 0:
continue
text = item.text()
if text and text not in seen:
seen.add(text)
consoleLog(f"Downloading {text}")
run_thread(threading.Thread(target=run_download, args=(text, posts, post_titles)))
def run_download(item, posts, post_titles):
post_url = get_item_url(item, posts, post_titles)
consoleLog(f"Selected URL: {post_url}")
magnet_uri = get_magnet_link(post_url)
if add_download(magnet_uri):
add_download_log(item, post_url, magnet_uri, False)
def run_download_direct(magnet_uri):
consoleLog(f"Direct download: {magnet_uri[:60]}")
add_download(magnet_uri)
add_download_log("Direct Download", "", magnet_uri, False)
def seed_magnet(magnet_uri, file_path):
consoleLog(f"Seeding: {magnet_uri[:60]}")
add_seed(magnet_uri, file_path)
-31
View File
@@ -1,31 +0,0 @@
import requests
from core.utils.data.state import state
from core.utils.logging.logs import consoleLog
def check_for_updates():
url = f"https://api.github.com/repos/KeksPirates/SoftwareManager/releases"
response = requests.get(url, timeout=15)
if response.status_code != 200:
consoleLog(f"Failed to fetch releases: {response.status_code}")
return None, None
releases = response.json()
releases.sort(key=lambda r: r["published_at"], reverse=True)
latest_release = releases[0]
latest_version = latest_release["name"]
assets = latest_release["assets"]
if latest_version != state.version:
consoleLog(f"New release available: {latest_version}")
if assets:
consoleLog("Assets:")
for asset in assets:
consoleLog(f" - {asset['name']}: {asset['browser_download_url']}")
return assets, latest_version
else:
return None, None
else:
consoleLog("Already up-to-date.")
return None, None
+18
View File
@@ -0,0 +1,18 @@
import requests
def scrape_buzzheavier(url):
response = requests.get(url)
response.raise_for_status()
download_url = url + '/download'
headers = {
'hx-current-url': url,
'hx-request': 'true',
'referer': url
}
head_response = requests.head(download_url, headers=headers, allow_redirects=False)
hx_redirect = head_response.headers.get('hx-redirect')
return hx_redirect
+41
View File
@@ -0,0 +1,41 @@
from utils.logging.logs import consoleLog
import requests
import re
def scrape_gofile(url):
filetoken = re.findall(r"(?<=https...gofile.io\/d\/).*", url)[0]
session = requests.Session()
headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0",
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Referer": "https://gofile.io/",
"Authorization": f"Bearer lUUtRAccOuRUoZhelNSslDFSlpOgKLDj",
"X-Website-Token": "ffd9ffa831c50b9e68ca5e65cbbbd1a50e9a32e63022e17c7c6748388a1ed73b",
"X-BL": "en-US",
"Origin": "https://gofile.io",
"Sec-GPC": "1",
"Connection": "keep-alive",
"TE": "trailers"
}
r = session.get(
f"https://api.gofile.io/contents/{filetoken}",
headers=headers,
)
try:
temp: dict = r.json()["data"]["children"]
child = [key for key in temp.keys()]
link = temp[child[0]]["link"]
return link, headers
except Exception:
consoleLog("GoFile Authorization failed, if the Issue persists, please open an Issue on GitHub")
return None
+12
View File
@@ -0,0 +1,12 @@
from data.sources.rutracker import RutrackerScraper
from data.sources.uztracker import UztrackerScraper
from data.sources.steamrip import SteamripScraper
from data.sources.monkrus import MonkrusScraper
# Register Scrapers here
SCRAPERS = [
RutrackerScraper(),
SteamripScraper(),
MonkrusScraper(),
UztrackerScraper(),
]
+78
View File
@@ -0,0 +1,78 @@
from utils.data.tracker import get_magnet_link
from bs4 import BeautifulSoup
from typing import Dict
import requests
import time
class MonkrusScraper:
name = "m0nkrus"
headers = ["Post Title", "Author"]
is_magnet = True
def __init__(self):
self.cache = { "data": [], "last_fetched": 0 }
self.cache_expiry = 300
def _get_telegram_posts(self):
post = 100
max_posts = 250
posts = []
added = set()
current_time = time.time()
if self.cache["data"] and (current_time - self.cache["last_fetched"] < self.cache_expiry):
return self.cache["data"]
while post <= max_posts:
url = f"https://t.me/s/real_monkrus/{post}"
response = requests.get(url, timeout=10)
soup = BeautifulSoup(response.text, "html.parser")
bubbles = soup.find_all("div", class_="tgme_widget_message_bubble")
if not bubbles:
break
for bubble in bubbles:
post_txt = bubble.find("div", class_="tgme_widget_message_text js-message_text")
if post_txt is None or post_txt.b is None:
continue
title = post_txt.b.text
link = bubble.find("a", href=lambda x: x and x.startswith("https://uztracker.net"))
if link:
post_url = link["href"]
if post_url not in added:
added.add(post_url)
posts.append(dict(
title=title,
author="m0nkrus",
id=len(posts) + 1,
url=post_url
))
post += 22
posts.reverse()
self.cache["data"] = posts
self.cache["last_fetched"] = current_time
return(posts)
def search(self, query):
posts = self._get_telegram_posts()
filtered_posts = []
for post in posts:
if query.lower() in post["title"].lower():
filtered_post = post.copy()
filtered_post["id"] = len(filtered_posts) + 1
filtered_posts.append(filtered_post)
return filtered_posts
def get_download_link(self, post: Dict):
return get_magnet_link(post["url"])
+46
View File
@@ -0,0 +1,46 @@
from utils.network.jsonhandler import split_data, format_data
from utils.data.tracker import get_magnet_link
from utils.logging.logs import consoleLog
from utils.data.state import state
from typing import Dict
import requests
class RutrackerScraper:
name = "rutracker"
headers = ["Post Title", "Author", "Seeders", "Leechers"]
is_magnet = True
def search(self, query):
search = requests.get(f"{state.api_url}/search?q={query}", timeout=15)
consoleLog("Sent request to server")
if search:
_, data, _, success, cached = split_data(search.text)
if cached:
consoleLog("Server response cached")
if success:
sorted_data = []
for entry in data:
sorted_data.append(
{
"title" : entry["title"],
"author" : entry["author"],
"seeders" : entry["seeders"],
"leechers" : entry["leechers"],
"url" : entry["url"],
"id" : entry["id"],
}
)
return sorted_data
else:
consoleLog("Scraping failed server-side, unable to fetch posts from rutracker")
return []
else:
consoleLog("No response from server, returning nothing")
return []
def get_download_link(self, post: Dict):
_, post_links, _, _, _ = format_data([post])
return get_magnet_link(post_links[0])
+107
View File
@@ -0,0 +1,107 @@
from data.hosts.buzzheavier import scrape_buzzheavier
from data.hosts.gofile import scrape_gofile
from utils.logging.logs import consoleLog
from bs4 import BeautifulSoup
from typing import Dict
import webbrowser
import requests
import time
import re
class SteamripScraper:
name = "steamrip"
headers = ["Game"]
is_magnet = False
def __init__(self):
self.cache = { "data": [], "last_fetched": 0 }
self.cache_expiry = 300
def scrape_steamrip_links(self):
current_time = time.time()
if self.cache["data"] != [] and (current_time - self.cache["last_fetched"] < self.cache_expiry):
return self.cache["data"]
url = f"https://steamrip.com/games-list-page/"
response = requests.get(url)
text = response.text
soup = BeautifulSoup(text, "html.parser")
games = soup.find_all("li", class_="az-list-item")
if len(games) == 0:
return []
links = []
names = []
for gamehtml in games:
link = gamehtml.find("a", href=lambda x: x and x.startswith("/"))
links += re.findall(r'(?<=href=")[^"]*', link.__str__())
name = gamehtml.find("a", href=lambda x: x and x.startswith("/"))
names += re.findall(r'(?<=\/">)[^<]*', name.__str__())
# construct the list[dict[str,str]]
ret = []
for i in range(len(names)):
ret.append({"title" : names[i], "url" : links[i]})
return ret
def scrape_steamrip_game_downloads(self, gamelink):
url = "https://steamrip.com" + gamelink
response = requests.get(url)
soup = BeautifulSoup(response.text, "html.parser")
download_link_elements = soup.find_all("a",class_="shortc-button")
download_links = ["buzzheavier", "gofile"]
for download_link in download_link_elements:
pure = download_link.attrs.get("href")
if pure[2] == "b":
download_links[0] = "https:" + pure
if pure[2] == "g":
download_links[1] = "https:" + pure
ret = []
for link in download_links:
if len(link) != 1:
ret.append(link)
self.cache["data"] = ret
return ret
def get_download_link(self, post: Dict):
url = post["url"]
links = self.scrape_steamrip_game_downloads(url)
best = ""
for link in links:
if link[0] == "h":
best = link
break
if links.index(best) == 0:
link = scrape_buzzheavier(best)
return link
elif links.index(best) == 1:
link, headers = scrape_gofile(best)
return link, headers
else:
consoleLog("Unable to retrieve download link due to captcha, launching browser...")
webbrowser.open(best)
return None, None
def search(self, query: str):
games = self.scrape_steamrip_links()
filtered_games = [
game for game in games
if query.lower() in game["title"].lower()
]
return filtered_games
+50
View File
@@ -0,0 +1,50 @@
from utils.data.tracker import get_magnet_link
from utils.logging.logs import consoleLog
from utils.data.state import state
from urllib.parse import urljoin
from bs4 import BeautifulSoup
from typing import Dict
import requests
class UztrackerScraper:
name = "uztracker"
headers = ["Post Title", "Author"]
is_magnet = True
def search(self, query):
base_url="https://uztracker.net/"
search_url = f"{base_url.rstrip('/')}/tracker.php?nm={query}"
consoleLog(search_url)
posts = []
try:
response = requests.get(search_url)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
links = soup.find_all('tr', class_="tCenter hl-tr", id=lambda x: x and x.startswith('tor_'))
for link in links:
theme_link = link.find('a', class_="genmed tLink", href=lambda x: x and x.startswith('./viewtopic'))
if not theme_link or not theme_link.b:
continue
url = urljoin(base_url, theme_link['href'])
title = theme_link.b.text
author_link = link.find('a', class_="med")
author = author_link.text.strip() if author_link else "Unknown"
posts.append(dict(
title=title,
author=author,
url=url,
))
return posts
except requests.RequestException as e:
consoleLog(f"Failed to fetch {search_url}: {e}")
return None
def get_download_link(self, post: Dict):
return get_magnet_link(post["url"])

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

+149
View File
@@ -0,0 +1,149 @@
from utils.logging.logs import consoleLog, remove_download_log
from PySide6.QtWidgets import QMessageBox
from PySide6.QtCore import Qt, QPoint
from utils.data.state import state
from PySide6 import QtWidgets
import subprocess
import platform
import time
import os
class ContextMenu:
def __init__(self, main_window):
self.main_window = main_window
self.context_menu = QtWidgets.QMenu(main_window)
self.context_menu.addAction("Open Containing Folder", self.openFolderAction)
self.context_menu.addAction("Copy Magnet URI", self.copyMagnetURIAction)
self.context_menu.addAction("Remove from list", self.cancelDownloadAction)
self.context_menu.addAction("Delete File", self.deleteFileAction)
main_window.downloadList.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
main_window.downloadList.customContextMenuRequested.connect(self._show_context_menu)
@property
def downloadList(self):
return self.main_window.downloadList
def _show_context_menu(self, pos: QPoint):
index = self.downloadList.indexAt(pos)
if index.isValid():
self._context_menu_row = index.row()
self.context_menu.exec(self.downloadList.viewport().mapToGlobal(pos))
def openFolderAction(self):
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]
download_path = magnetdl.save_path()
if download_path and os.path.exists(download_path):
if platform.system() == "Windows":
os.startfile(os.path.normpath(download_path))
elif platform.system() == "Linux":
subprocess.Popen(["xdg-open", download_path])
elif platform.system() == "Darwin":
subprocess.Popen(["open", download_path])
def copyMagnetURIAction(self):
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]
clipboard = QtWidgets.QApplication.clipboard()
clipboard.setText(magnet_link)
def cancelDownloadAction(self):
if not hasattr(self, '_context_menu_row'):
return
row = self._context_menu_row
with state.downloads_lock:
if row < 0 or row >= len(state.active_downloads):
return
magnet_link = list(state.active_downloads.keys())[row]
magnetdl = state.active_downloads[magnet_link]
# Cache the name BEFORE removing from session
try:
torrent_name = magnetdl.status().name
except RuntimeError:
torrent_name = "Unknown"
confirm = QMessageBox.question(
self.main_window, "Cancel Download",
f"Are you sure you want to cancel the download of '{torrent_name}'?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if confirm == QMessageBox.StandardButton.Yes:
with state.downloads_lock:
state.active_downloads.pop(magnet_link, None)
remove_download_log(magnet_link)
if state.dl_session:
try:
state.dl_session.remove_torrent(magnetdl)
except Exception as e:
consoleLog(f"Exception while removing download from LibTorrent: {e}")
consoleLog(f"Cancelled download: {torrent_name}", True)
def deleteFileAction(self):
if not hasattr(self, '_context_menu_row'):
return
row = self._context_menu_row
with state.downloads_lock:
if row < 0 or row >= len(state.active_downloads):
return
magnet_link = list(state.active_downloads.keys())[row]
magnetdl = state.active_downloads[magnet_link]
try:
status = magnetdl.status()
save_path = status.save_path
torrent_name = status.name
except RuntimeError:
consoleLog("Error: torrent handle already invalid", True)
with state.downloads_lock:
state.active_downloads.pop(magnet_link, None)
remove_download_log(magnet_link)
return
download_path = os.path.join(save_path, torrent_name)
confirm = QMessageBox.question(
self.main_window, "Delete Files",
f"Are you sure you want to delete the downloaded files of '{torrent_name}'? This action cannot be undone.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if confirm == QMessageBox.StandardButton.Yes:
with state.downloads_lock:
state.active_downloads.pop(magnet_link, None)
remove_download_log(magnet_link)
if state.dl_session:
try:
state.dl_session.remove_torrent(magnetdl)
time.sleep(0.5)
except Exception as e:
consoleLog(f"Exception while removing download from LibTorrent: {e}")
if download_path and os.path.exists(download_path):
import shutil
last_error = None
for attempt in range(3):
try:
if os.path.isfile(download_path):
os.remove(download_path)
else:
shutil.rmtree(download_path)
consoleLog(f"Deleted files for: {torrent_name}", True)
last_error = None
break
except Exception as e:
last_error = e
if attempt < 2:
time.sleep(0.5)
if last_error:
consoleLog(f"Error deleting files: {last_error}", True)
else:
consoleLog(f"Removed entry (files not found): {torrent_name}", True)
+89
View File
@@ -0,0 +1,89 @@
from interface.dialogs.pauseresumedelegate import PauseResumeDelegate
from interface.dialogs.hoverrowdelegate import HoverRowDelegate
from interface.dialogs.downloadmodel import DownloadModel
from interface.dialogs.theme import _table_stylesheet
from PySide6.QtWidgets import QTableView, QHeaderView
from utils.logging.logs import consoleLog
from PySide6 import QtWidgets
from PySide6.QtCore import Qt
def _create_download_list(self) -> QTableView:
self.download_model = DownloadModel()
self.downloadList.setModel(self.download_model)
self.downloadList.horizontalHeader().setStretchLastSection(False)
self.downloadList.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
self.downloadList.horizontalHeader().resizeSection(0, 70)
self.downloadList.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
self.downloadList.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
self.downloadList.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
self.downloadList.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
self.downloadList.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents)
self.downloadList.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents)
self.downloadList.horizontalHeader().setMinimumSectionSize(60)
self.downloadList.verticalHeader().setDefaultSectionSize(40)
self.downloadList.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
self.downloadList.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection)
self.downloadList.verticalHeader().setVisible(False)
self.downloadList.horizontalHeader().setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
self.downloadList.horizontalHeader().setHighlightSections(False)
self.downloadList.setMouseTracking(True)
self.downloadList.setShowGrid(False)
self.downloadList.setStyleSheet(_table_stylesheet("QTableView"))
self.downloadList.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.downloadList.viewport().setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
download_model = self.download_model
def on_pause_resume_clicked(row):
download_model.toggle_pause_resume(row)
self.downloadList.viewport().setMouseTracking(True)
hover_delegate = HoverRowDelegate(self.downloadList)
self.downloadList.setItemDelegate(hover_delegate)
delegate = PauseResumeDelegate(self.downloadList)
self.downloadList.setItemDelegateForColumn(0, delegate)
delegate.clicked.connect(on_pause_resume_clicked)
return self.downloadList
def download_list_update(self):
self._update_speed_label()
if not self.download_model:
return
row_count = self.download_model.rowCount()
col_count = self.download_model.columnCount()
if row_count > 0 and col_count > 0:
top_left = self.download_model.index(0, 0)
bottom_right = self.download_model.index(row_count - 1, col_count - 1)
if top_left.isValid() and bottom_right.isValid():
self.download_model.dataChanged.emit(top_left, bottom_right, [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.UserRole])
if row_count != self._last_dl_row_count:
old_count = self._last_dl_row_count
self._last_dl_row_count = row_count
if row_count > 0:
self.download_model.layoutAboutToBeChanged.emit()
self.download_model.layoutChanged.emit()
for row in range(row_count):
idx = self.download_model.index(row, 0)
if idx.isValid():
self.downloadList.closePersistentEditor(idx)
self.downloadList.openPersistentEditor(idx)
elif old_count > 0:
self.download_model.layoutAboutToBeChanged.emit()
self.download_model.layoutChanged.emit()
else:
if row_count > 0:
delegate = self.downloadList.itemDelegateForColumn(0)
for row in range(row_count):
idx = self.download_model.index(row, 0)
if idx.isValid():
editor = self.downloadList.indexWidget(idx)
if editor and delegate:
try:
delegate.setEditorData(editor, idx)
except RuntimeError as e:
consoleLog(f"Exception while updating download list: {e}")
+136
View File
@@ -0,0 +1,136 @@
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
from utils.logging.logs import consoleLog
from utils.data.state import state
import libtorrent as lt
import subprocess
import platform
import os
class DownloadModel(QAbstractTableModel):
def __init__(self):
super().__init__()
self.headers = ["Action", "Name", "Status", "Progress", "Speed", "Size", "Total Size"]
def rowCount(self, parent=QModelIndex()):
if parent.isValid():
return 0
with state.downloads_lock:
return len(state.active_downloads)
def columnCount(self, parent=QModelIndex()):
return len(self.headers)
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
return self.headers[section]
return None
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if not index.isValid():
return None
with state.downloads_lock:
if index.row() >= len(state.active_downloads) or index.row() < 0:
return None
try:
magnet_link = list(state.active_downloads.keys())[index.row()]
magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status()
except (IndexError, KeyError, RuntimeError):
return None
if role == Qt.ItemDataRole.DisplayRole:
col = index.column()
if col == 0:
pass
elif col == 1:
return status.name if status.has_metadata else "Fetching metadata..."
elif col == 2:
if status.paused:
is_auto = getattr(status, 'auto_managed', True)
return "Queued" if is_auto else "Paused"
elif status.state == lt.torrent_status.downloading:
return "Downloading"
elif status.state == lt.torrent_status.seeding:
return "Seeding"
else:
return "Queued"
elif col == 3:
return f"{status.progress * 100:.1f}%"
elif col == 4:
down_kb = status.download_rate / 1024
up_kb = status.upload_rate / 1024
down_text = f"{down_kb / 1024:.1f} MB/s" if down_kb > 1024 else f"{down_kb:.1f} kB/s"
up_text = f"{up_kb / 1024:.1f} MB/s" if up_kb > 1024 else f"{up_kb:.1f} kB/s"
return f"{down_text}{up_text}"
elif col == 5:
downloaded_mb = status.total_wanted_done / (1024 * 1024)
if downloaded_mb > 1024:
return f"{downloaded_mb / 1024:.2f} GB"
else:
return f"{downloaded_mb:.1f} MB"
elif col == 6:
total_mb = status.total_wanted / (1024 * 1024)
if total_mb > 1024:
return f"{total_mb / 1024:.2f} GB"
else:
return f"{total_mb:.1f} MB"
elif col == 7:
if status.download_rate > 0:
bytes_left = status.total_wanted - status.total_wanted_done
eta_seconds = bytes_left / status.download_rate
if eta_seconds < 60:
return f"{int(eta_seconds)}s"
elif eta_seconds < 3600:
minutes = int(eta_seconds / 60)
seconds = int(eta_seconds % 60)
return f"{minutes}m {seconds}s"
else:
hours = int(eta_seconds / 3600)
minutes = int((eta_seconds % 3600) / 60)
return f"{hours}h {minutes}m"
else:
return "" if status.paused else "Stalled"
if role == Qt.ItemDataRole.UserRole and index.column() == 0:
return status.paused
return None
def toggle_pause_resume(self, row):
with state.downloads_lock:
if row >= len(state.active_downloads) or row < 0:
return
try:
magnet_link = list(state.active_downloads.keys())[row]
magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status()
except (IndexError, KeyError, RuntimeError):
return
if status.state == lt.torrent_status.seeding:
try:
save_path = magnetdl.save_path()
if save_path and os.path.exists(save_path):
if platform.system() == "Windows":
os.startfile(os.path.normpath(save_path))
elif platform.system() == "Linux":
subprocess.Popen(["xdg-open", save_path])
elif platform.system() == "Darwin":
subprocess.Popen(["open", save_path])
except Exception as e:
consoleLog(f"Exception while opening file: {e}")
return
is_paused = status.paused
if is_paused:
if hasattr(magnetdl, 'set_flags'):
magnetdl.set_flags(lt.torrent_flags.auto_managed)
magnetdl.resume()
consoleLog(f"Resumed download: {status.name}", True)
else:
if hasattr(magnetdl, 'unset_flags'):
magnetdl.unset_flags(lt.torrent_flags.auto_managed)
magnetdl.pause()
consoleLog(f"Paused download: {status.name}", True)
idx = self.index(row, 0)
self.dataChanged.emit(idx, idx, [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.UserRole])
@@ -0,0 +1,22 @@
from interface.dialogs.theme import _theme_colors
from PySide6.QtWidgets import QStyledItemDelegate
from PySide6.QtCore import Qt
class ElidedItemDelegate(QStyledItemDelegate):
def __init__(self, get_hovered_row, parent=None):
super().__init__(parent)
self._get_hovered_row = get_hovered_row
def initStyleOption(self, option, index):
super().initStyleOption(option, index)
if option.text:
metrics = option.fontMetrics
text_rect = option.rect.adjusted(14, 0, -14, 0)
option.text = metrics.elidedText(option.text, Qt.TextElideMode.ElideMiddle, text_rect.width())
def paint(self, painter, option, index):
if index.row() == self._get_hovered_row():
painter.save()
painter.fillRect(option.rect, _theme_colors()["hover"])
painter.restore()
super().paint(painter, option, index)
+36
View File
@@ -0,0 +1,36 @@
from utils.logging.logs import consoleLog
from utils.data.state import state
from PySide6.QtCore import QEvent
def eventFilter(self, obj, event):
try:
if obj == state.trackertable.viewport():
if event.type() == QEvent.Type.MouseMove:
pos = event.position().toPoint() if hasattr(event, 'position') else event.pos()
idx = state.trackertable.indexAt(pos)
new_row = idx.row() if idx.isValid() else -1
if new_row != self._tracker_hovered_row or state.trackertable is not self._tracker_hovered_table:
self._tracker_hovered_row = new_row
self._tracker_hovered_table = state.trackertable
state.trackertable.viewport().update()
elif event.type() == QEvent.Type.Leave:
self._tracker_hovered_row = -1
self._tracker_hovered_table = None
state.trackertable.viewport().update()
if obj == self.downloadList.viewport():
if event.type() == QEvent.Type.MouseMove:
pos = event.position().toPoint() if hasattr(event, 'position') else event.pos()
idx = self.downloadList.indexAt(pos)
new_row = idx.row() if idx.isValid() else -1
if new_row != self._hovered_row:
old_row = self._hovered_row
self._hovered_row = new_row
self._invalidate_hover_row(old_row)
self._invalidate_hover_row(new_row)
elif event.type() == QEvent.Type.Leave:
old_row = self._hovered_row
self._hovered_row = -1
self._invalidate_hover_row(old_row)
except (RuntimeError, AttributeError) as e:
consoleLog(f"Exception in EventFilter: {e}")
return super(type(self), self).eventFilter(obj, event)
+19
View File
@@ -0,0 +1,19 @@
from interface.dialogs.theme import _theme_colors
from PySide6.QtWidgets import QStyledItemDelegate
from PySide6 import QtWidgets
class HoverRowDelegate(QStyledItemDelegate):
def paint(self, painter, option, index):
from interface.gui import MainWindow
opt = QtWidgets.QStyleOptionViewItem(option)
option.state &= ~QtWidgets.QStyle.StateFlag.State_HasFocus
option.state &= ~QtWidgets.QStyle.StateFlag.State_Selected
hovered_row = MainWindow._instance._hovered_row if MainWindow._instance else -1
if index.row() == hovered_row:
painter.save()
painter.fillRect(option.rect, _theme_colors()["hover"])
painter.restore()
super().paint(painter, opt, index)
+29
View File
@@ -0,0 +1,29 @@
from PySide6.QtGui import QImage, QPixmap
from PySide6.QtWidgets import QLabel
from utils.data.state import state
from PySide6.QtCore import Qt
import os
class Image():
def __init__(self, parent):
self.overlay_label = QLabel(parent)
self.overlay_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
if state.image_path is not None and os.path.exists(state.image_path):
self.image = QImage(state.image_path)
self.image = self.image.scaledToWidth(300, Qt.TransformationMode.SmoothTransformation)
self.pixmap = QPixmap.fromImage(self.image)
self.overlay_label.setPixmap(self.pixmap)
self.overlay_label.adjustSize()
self.overlay_label.raise_()
x = parent.width() - self.overlay_label.width()
y = parent.height() - self.overlay_label.height()
self.overlay_label.move(x, y)
def update_image_overlay(self, new_image_path):
self.image = QImage(new_image_path)
self.pixmap = QPixmap.fromImage(self.image)
self.overlay_label.setPixmap(self.pixmap)
self.overlay_label.adjustSize()
@@ -0,0 +1,86 @@
from interface.dialogs.theme import _theme_colors, SVG_PLAY, SVG_PAUSE, SVG_FOLDER
from PySide6.QtWidgets import QStyledItemDelegate, QWidget, QHBoxLayout
from interface.utils.svghelper import svg_icon
from PySide6.QtCore import Qt, QSize, Signal
from utils.data.state import state
from PySide6 import QtWidgets
import libtorrent as lt
class PauseResumeDelegate(QStyledItemDelegate):
clicked = Signal(int)
def paint(self, painter, option, index):
from interface.gui import MainWindow
opt = QtWidgets.QStyleOptionViewItem(option)
option.state &= ~QtWidgets.QStyle.StateFlag.State_HasFocus
option.state &= ~QtWidgets.QStyle.StateFlag.State_Selected
hovered_row = MainWindow._instance._hovered_row if MainWindow._instance else -1
if index.row() == hovered_row:
painter.save()
painter.fillRect(option.rect, _theme_colors()["hover"])
painter.restore()
super().paint(painter, opt, index)
def setEditorData(self, editor, index):
button = editor.findChild(QtWidgets.QPushButton)
if not button:
return
try:
with state.downloads_lock:
keys = list(state.active_downloads.keys())
if index.row() >= len(keys):
return
magnet_link = keys[index.row()]
magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status()
except (RuntimeError, IndexError, KeyError):
return
if status.state == lt.torrent_status.seeding:
button.setIcon(svg_icon(SVG_FOLDER, 18))
button.setText("")
else:
is_user_paused = status.paused and not status.auto_managed
button.setIcon(svg_icon(SVG_PLAY if is_user_paused else SVG_PAUSE, 18))
button.setText("")
def createEditor(self, parent, option, index):
try:
with state.downloads_lock:
keys = list(state.active_downloads.keys())
if index.row() >= len(keys):
return QWidget(parent)
magnet_link = keys[index.row()]
magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status()
except (RuntimeError, IndexError, KeyError):
return QWidget(parent)
widget = QWidget(parent)
widget.setStyleSheet("border: none; background: transparent;")
layout = QHBoxLayout(widget)
layout.setContentsMargins(0, 0, 0, 0)
if status.state == lt.torrent_status.seeding:
btnPause = QtWidgets.QPushButton()
btnPause.setIcon(svg_icon(SVG_FOLDER, 18))
else:
btnPause = QtWidgets.QPushButton()
is_user_paused = status.paused and not status.auto_managed
btnPause.setIcon(svg_icon(SVG_PLAY if is_user_paused else SVG_PAUSE, 18))
btnPause.setIconSize(QSize(18, 18))
btnPause.setFixedSize(30, 30)
btnPause.setCursor(Qt.CursorShape.PointingHandCursor)
btnPause.setStyleSheet("QPushButton { border: none; background: transparent; padding: 0px; }")
btnPause.clicked.connect(lambda: self.clicked.emit(index.row()))
layout.addStretch()
layout.addWidget(btnPause)
layout.addStretch()
widget.setLayout(layout)
return widget
def editorEvent(self, event, model, option, index):
return False
@@ -1,11 +1,10 @@
from core.utils.config.settings import save_settings
from core.utils.data.state import state
from core.utils.logging.logs import consoleLog
from core.interface.utils.tabhelper import general_tab
from core.interface.utils.tabhelper import paths_tab
from core.interface.utils.tabhelper import network_tab
from interface.utils.tabhelper import create_tab
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 import QtWidgets
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QLineEdit,
QPushButton,
@@ -18,10 +17,12 @@ from PySide6.QtWidgets import (
QCheckBox,
QFileDialog,
QComboBox,
QTabWidget,
)
import platform
SVG_FOLDER = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M2 6c0-1.1.9-2 2-2h5l2 2h7c1.1 0 2 .9 2 2v10c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6z" fill="{color}"/></svg>'
def settings_dialog(self):
@@ -30,7 +31,8 @@ def settings_dialog(self):
dialog.setWindowTitle("Settings")
dialog.setFixedSize(700, 450)
dialog.setLayout(QVBoxLayout())
dialog_layout = QVBoxLayout()
dialog.setLayout(dialog_layout)
if state.window_transparency and platform.system() != "Windows" and dialog:
dialog.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
@@ -41,7 +43,7 @@ def settings_dialog(self):
update_checkbox_container = QWidget()
update_checkbox_layout = QHBoxLayout()
# ignore updates checkbox
# Ignore updates checkbox
if platform.system() == "Windows":
update_checkbox = QCheckBox()
@@ -51,9 +53,8 @@ def settings_dialog(self):
update_checkbox.setChecked(state.ignore_updates)
update_checkbox.toggled.connect(lambda checked: setattr(state, 'ignore_updates', checked))
update_checkbox_layout.addWidget(update_checkbox)
dialog.layout().addWidget(update_checkbox_container)
# auto-resume downloads checkbox
# Auto-resume downloads checkbox
autoresume_container = QWidget()
autoresume_layout = QHBoxLayout()
@@ -67,7 +68,7 @@ def settings_dialog(self):
autoresume_layout.addWidget(autoresume_checkbox)
# transparent window checkbox
# Transparent window checkbox
transparent_window_container = QWidget()
transparent_window_layout = QHBoxLayout()
@@ -80,31 +81,27 @@ def settings_dialog(self):
transparent_window_checkbox.toggled.connect(lambda checked: setattr(state, 'window_transparency', checked))
transparent_window_layout.addWidget(transparent_window_checkbox)
##################
# SERVER SETTING #
##################
# API Server Setting
api_url_container = QWidget()
api_url_layout = QHBoxLayout()
api_url = QLineEdit()
api_url_layout.addWidget(QLabel("API Server URL:"))
api_url_layout.addWidget(api_url)
api_url_container.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed)
api_url_container.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed)
api_url_container.setLayout(api_url_layout)
api_url.setText(state.api_url)
#################
# DOWNLOAD PATH #
#################
# Download Path
download_path_container = QWidget()
download_path_layout = QHBoxLayout()
download_path = QLineEdit()
download_path_layout.addWidget(QLabel("Download Path:"))
download_path_layout.addWidget(download_path)
download_path_container.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed)
download_path_container.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed)
download_path_container.setLayout(download_path_layout)
download_path.setText(state.download_path)
@@ -113,37 +110,56 @@ def settings_dialog(self):
if dir_path:
download_path.setText(dir_path)
browse_button = QPushButton("📁")
browse_button = QPushButton()
browse_button.setFixedSize(36, 36)
browse_button.setIconSize(QSize(24, 24))
browse_button.setIcon(svg_icon(SVG_FOLDER, 24))
browse_button.setCursor(Qt.CursorShape.PointingHandCursor)
browse_button.setStyleSheet("""
QPushButton {
border: none;
background: transparent;
padding: 0px;
}
""")
download_path_layout.addWidget(browse_button)
browse_button.clicked.connect(browse_download_path)
###############
# IMAGE PATH #
###############
# image_path_container = QWidget()
# image_path_layout = QHBoxLayout()
# Image Path
image_path_container = QWidget()
image_path_layout = QHBoxLayout()
# image_path = QLineEdit()
# image_path_layout.addWidget(QLabel("Image Path (requires restart):"))
# image_path_layout.addWidget(image_path)
# image_path_container.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed)
# image_path_container.setLayout(image_path_layout)
# image_path.setText(state.image_path)
image_path = QLineEdit()
image_path_layout.addWidget(QLabel("Image Path (requires restart, experimental):"))
image_path_layout.addWidget(image_path)
image_path_container.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed)
image_path_container.setLayout(image_path_layout)
image_path.setText(state.image_path)
# def browse_image_path():
# file_path = QFileDialog.getOpenFileName(dialog, "Select Image File", state.image_path, "Image Files (*.png *.jpg)")[0]
# if file_path:
# image_path.setText(file_path)
def browse_image_path():
file_path = QFileDialog.getOpenFileName(dialog, "Select Image File", state.image_path, "Image Files (*.png *.jpg)")[0]
if file_path:
image_path.setText(file_path)
# browse_button = QPushButton("📁")
# image_path_layout.addWidget(browse_button)
# browse_button.clicked.connect(browse_image_path)
browse_button = QPushButton()
browse_button.setFixedSize(36, 36)
browse_button.setIconSize(QSize(24, 24))
browse_button.setIcon(svg_icon(SVG_FOLDER, 24))
browse_button.setCursor(Qt.CursorShape.PointingHandCursor)
browse_button.setStyleSheet("""
QPushButton {
border: none;
background: transparent;
padding: 0px;
}
""")
image_path_layout.addWidget(browse_button)
browse_button.clicked.connect(browse_image_path)
##################
# SPEED LIMITING #
##################
# Speed Limiting
down_speed_limit_container = QWidget()
down_speed_limit_layout = QHBoxLayout()
@@ -170,10 +186,8 @@ def settings_dialog(self):
up_speed_limit.setFixedWidth(180)
up_speed_limit.setFixedHeight(30)
######################
# CONNECTION CONFIGS #
######################
# Connection Configs
max_connections_container = QWidget()
max_connections_layout = QHBoxLayout()
@@ -187,10 +201,8 @@ def settings_dialog(self):
max_connections.setFixedWidth(180)
max_connections.setFixedHeight(30)
####################
# DOWNLOAD CONFIGS #
####################
# Download Configs
max_downloads_container = QWidget()
max_downloads_layout = QHBoxLayout()
@@ -204,10 +216,8 @@ def settings_dialog(self):
max_downloads.setFixedWidth(180)
max_downloads.setFixedHeight(30)
#####################
# INTERFACE BINDING #
#####################
# Interface Binding
interface_container = QWidget()
interface_layout = QHBoxLayout()
@@ -230,10 +240,7 @@ def settings_dialog(self):
interface_container.setLayout(interface_layout)
###############
# SAVE/CANCEL #
###############
# Save / Cancel buttons
layout = QHBoxLayout()
save_btn = QPushButton("Save")
@@ -244,7 +251,7 @@ def settings_dialog(self):
download_path.text(),
down_speed_limit.value(),
up_speed_limit.value(),
None,
image_path.text(),
autoresume_checkbox.isChecked(),
max_connections.value(),
max_downloads.value(),
@@ -254,12 +261,12 @@ def settings_dialog(self):
layout.addWidget(cancel_btn)
layout.addWidget(save_btn)
self.tabs = QTabWidget()
self.tab1 = general_tab("General", autoresume_container, update_checkbox_container, transparent_window_container, self.tabs)
self.tab2 = paths_tab("Paths", download_path_container, self.tabs)
self.tab3 = network_tab("Network", interface_container, max_connections_container, max_downloads_container, up_speed_limit_container, down_speed_limit_container, api_url_container, self.tabs)
tabs = QtWidgets.QTabWidget()
create_tab("General", [autoresume_container, update_checkbox_container, transparent_window_container], tabs=tabs, stretch=True)
create_tab("Paths", [download_path_container, image_path_container], tabs=tabs, stretch=True)
create_tab("Network", [interface_container, max_connections_container, max_downloads_container, up_speed_limit_container, down_speed_limit_container, api_url_container], tabs=tabs, stretch=True)
dialog.layout().addWidget(self.tabs)
dialog.layout().addLayout(layout)
dialog_layout.addWidget(tabs)
dialog_layout.addLayout(layout)
dialog.exec()
+79
View File
@@ -0,0 +1,79 @@
from PySide6.QtGui import QColor
import darkdetect
def _is_dark_mode():
return darkdetect.isDark()
def _theme_colors():
dark = _is_dark_mode()
if dark:
return {
"text": "rgba(255, 255, 255, 0.9)",
"border": "rgba(255, 255, 255, 0.06)",
"selected": "rgba(255, 255, 255, 0.04)",
"header_text": "rgba(255, 255, 255, 0.5)",
"header_border": "rgba(255, 255, 255, 0.1)",
"hover": QColor(255, 255, 255, 15),
}
else:
return {
"text": "rgba(0, 0, 0, 0.87)",
"border": "rgba(0, 0, 0, 0.08)",
"selected": "rgba(0, 0, 0, 0.06)",
"header_text": "rgba(0, 0, 0, 0.6)",
"header_border": "rgba(0, 0, 0, 0.12)",
"hover": QColor(0, 0, 0, 15),
}
def _table_stylesheet(view_type="QTableWidget"):
c = _theme_colors()
dark = _is_dark_mode()
color_rule = "" if dark else f"color: {c['text']};"
return f"""
{view_type} {{
border: none;
outline: 0;
font-size: 13px;
{color_rule}
}}
{view_type}::item {{
border-bottom: 1px solid {c["border"]};
padding: 6px 14px;
outline: none;
{color_rule}
}}
{view_type}::item:selected {{
background: {c["selected"]};
outline: none;
border: none;
border-bottom: 1px solid {c["border"]};
}}
{view_type}::item:focus {{
outline: none;
border: none;
border-bottom: 1px solid {c["border"]};
}}
QHeaderView {{
background: transparent;
}}
QHeaderView::section {{
background: transparent;
color: {c["header_text"]};
font-weight: normal;
border: none;
border-bottom: 1px solid {c["header_border"]};
padding: 6px 14px;
}}
QHeaderView::section:checked {{
background: transparent;
color: {c["header_text"]};
font-weight: normal;
}}
"""
SVG_PLAY = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><polygon points="6,3 20,12 6,21" fill="{color}"/></svg>'
SVG_PAUSE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect x="5" y="3" width="4" height="18" rx="1" fill="{color}"/><rect x="15" y="3" width="4" height="18" rx="1" fill="{color}"/></svg>'
SVG_FOLDER = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M2 6c0-1.1.9-2 2-2h5l2 2h7c1.1 0 2 .9 2 2v10c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6z" fill="{color}"/></svg>'
@@ -0,0 +1,14 @@
from interface.dialogs.theme import _theme_colors
from PySide6.QtWidgets import QStyledItemDelegate
class TrackerHoverDelegate(QStyledItemDelegate):
def __init__(self, get_hovered_row, parent=None):
super().__init__(parent)
self._get_hovered_row = get_hovered_row
def paint(self, painter, option, index):
if index.row() == self._get_hovered_row():
painter.save()
painter.fillRect(option.rect, _theme_colors()["hover"])
painter.restore()
super().paint(painter, option, index)
+26
View File
@@ -0,0 +1,26 @@
from interface.dialogs.theme import _table_stylesheet
from PySide6.QtWidgets import QTableWidget
from PySide6 import QtWidgets
from PySide6.QtCore import Qt
def _create_tracker_table(self):
table = QTableWidget()
table.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers)
table.verticalHeader().setVisible(False)
table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows)
table.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection)
table.setShowGrid(False)
table.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
table.viewport().setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
table.horizontalHeader().setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
table.horizontalHeader().setHighlightSections(False)
table.horizontalHeader().setStretchLastSection(False)
table.setStyleSheet(_table_stylesheet("QTableWidget"))
table.setMouseTracking(True)
table.viewport().setMouseTracking(True)
table.viewport().installEventFilter(self)
table.setItemDelegateForColumn(0, self._tracker_elided_delegate)
self._apply_default_headers(table)
return table
+49
View File
@@ -0,0 +1,49 @@
from utils.network.update_checker import get_updates
from PySide6.QtWidgets import QDialog, QMessageBox
from utils.network.updater import download_update
from utils.logging.logs import consoleLog
from utils.data.state import state
from pathlib import Path
import platform
import json
import os
def _get_build_info_path() -> (Path | None):
current_dir = Path(__file__).resolve().parent
for _ in range(6): # Climb max 6 directories
file = current_dir / "build_info.json"
if file.exists():
return file
# Go up one directory if file isn't found
current_dir = current_dir.parent
return None
def get_version() -> None:
# Get build info filepath
build_info_path = _get_build_info_path()
if build_info_path and os.path.exists(build_info_path):
with open(build_info_path, "r") as f:
build_info = json.load(f)
state.version = build_info.get("version")
else:
consoleLog(f"Could not find build info file (Path: {build_info_path})")
class UpdateDialog(QDialog):
def __init__(self, parent=None):
if state.ignore_updates is False and platform.system() == "Windows":
assets, latest = get_updates()
if assets != None:
msg = QMessageBox()
msg.setIcon(QMessageBox.Icon.Information)
msg.setWindowTitle("Update Available")
msg.setText(f"A new version is available\n({latest})")
msg.setInformativeText("Press Ok to download.")
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Ignore)
response = msg.exec_()
if response == QMessageBox.StandardButton.Ok:
download_update(assets)
+413
View File
@@ -0,0 +1,413 @@
from interface.dialogs.trackerhoverdelegate import TrackerHoverDelegate
from interface.dialogs.elideditemdelegate import ElidedItemDelegate
from interface.dialogs.trackertable import _create_tracker_table
from interface.dialogs.downloadlist import _create_download_list
from interface.assets.base64_icons import settings_white_base64
from interface.assets.base64_icons import settings_black_base64
from interface.dialogs.downloadlist import download_list_update
from interface.dialogs.update import get_version, UpdateDialog
from utils.logging.logs import consoleLog, flush_log_buffer
from interface.dialogs.downloadmodel import DownloadModel
from interface.dialogs.settings import settings_dialog
from interface.assets.base64_icons import logo_base64
from interface.dialogs.eventfilter import eventFilter
from utils.network.download import download_selected
from interface.utils.searchhelper import run_search
from interface.utils.tabhelper import create_tab
from utils.general.shutdown import closehelper
from utils.general.wrappers import run_thread
from interface.dialogs.image import Image
from utils.data.state import state
import interface.dialogs.contextmenu
from PySide6 import QtWidgets
from PySide6.QtCore import (
Qt,
QTimer,
Signal,
QSize
)
from PySide6.QtWidgets import (
QLineEdit,
QTableView,
QWidget,
QVBoxLayout,
QLabel,
QHBoxLayout,
QComboBox,
QTabWidget,
QHeaderView,
QTableWidgetItem,
QTextEdit,
QStatusBar
)
from PySide6.QtGui import (
QIcon,
QCloseEvent,
QPixmap,
QGuiApplication,
)
import darkdetect
import threading
import platform
import base64
def windowCloseHelper():
QGuiApplication.quit()
class MainWindow(QtWidgets.QMainWindow, QWidget):
eventFilter = eventFilter
log_signal = Signal(str)
search_results_signal = Signal(list)
_instance = None
def __init__(self):
super().__init__()
MainWindow._instance = self
self.log_signal.connect(self._on_log_signal)
self.search_results_signal.connect(self._on_search_results)
pixmap = QPixmap()
image_data = base64.b64decode(logo_base64)
pixmap.loadFromData(image_data)
self.setWindowIcon(QIcon(pixmap))
# Set AppID on Windows
if platform.system() == "Windows":
try:
import ctypes
appid = 'SoftwareManager'
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid)
except Exception as e:
consoleLog(f"Could not set app ID: {e}")
# Get current version
# interface.dialogs.update
get_version()
UpdateDialog()
# Initialize Window
self.setWindowTitle("Software Manager")
self.setGeometry(100, 100, 800, 600)
self.controls = QWidget()
self.controlsLayout = QVBoxLayout()
# Searchbar
self.searchbar = QLineEdit()
self.searchbar.setPlaceholderText("Search for software...")
self.searchbar.setClearButtonEnabled(True)
self.searchbar.setMinimumHeight(30)
self.searchbar.returnPressed.connect(self._start_search)
# Download Button
self.dlbutton = QtWidgets.QPushButton("Download")
self.dlbutton.setCursor(Qt.CursorShape.PointingHandCursor)
# Dialog for empty results
self.emptyResults = QLabel("No Results")
self.emptyResults.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.emptyResults.hide()
self.downloadList = QTableView()
self.downloadList.setMouseTracking(True)
self._hovered_row = -1
self._tracker_hovered_row = -1
self._tracker_hovered_table = None
self._last_dl_row_count = 0
self.emptyDownload = QLabel("No items in downloads.")
self.emptyDownload.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.consoleLog = QTextEdit()
self.statusbar = QStatusBar()
flush_log_buffer()
self._tracker_elided_delegate = ElidedItemDelegate(lambda: self._tracker_hovered_row, self)
self._tracker_hover_delegate = TrackerHoverDelegate(lambda: self._tracker_hovered_row, self)
state.trackertable = _create_tracker_table(self)
container = QWidget()
containerLayout = QVBoxLayout()
containerLayout.addWidget(self.searchbar)
containerLayout.addWidget(state.trackertable)
# interface.dialogs.hoverrowdelegate
self.download_model = DownloadModel()
self.downloadList = _create_download_list(self)
self.downloadList.viewport().installEventFilter(self)
self.dlbutton.clicked.connect(lambda: run_thread(threading.Thread(target=download_selected, args=(state.trackertable.selectedItems(),))))
container.setLayout(containerLayout)
self.setCentralWidget(container)
self.tabs = QTabWidget()
self.tabs.setStyleSheet("""
QTabBar::tab {
font-size: 11px;
padding: 2px 8px;
height: 25px;
}
QTabWidget::pane {
margin-top: -20px;
}
""")
self.horizontal_layout = QHBoxLayout()
self.horizontal_layout.addWidget(self.emptyResults, stretch=3)
self.horizontal_layout.addWidget(state.trackertable)
# Tabs
create_tab("Search", [self.searchbar, self.horizontal_layout, self.dlbutton], tabs=self.tabs, stretch=False)
create_tab("Downloads", [self.emptyDownload, self.downloadList], tabs=self.tabs, stretch=False)
# Corner Widget (Settings Button, Tracker list, Tab button container)
self.corner_widget = QWidget()
self.corner_layout = QHBoxLayout(self.corner_widget)
self.corner_layout.setContentsMargins(0, 0, 0, 0)
# Tracker list
self.tracker_list = QComboBox()
self.tracker_list.addItems(list(state.trackers.keys()))
self.tracker_list.setCursor(Qt.CursorShape.PointingHandCursor)
self.tracker_list.activated.connect(self.set_tracker)
self.corner_layout.addWidget(self.tracker_list)
# Settings button
self.settings_btn = QtWidgets.QToolButton()
self.settings_btn.setIconSize(QSize(21, 21))
self.settings_btn.setStyleSheet("background: transparent;")
self.settings_btn.setToolTip("Settings")
self.settings_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self.settings_btn.clicked.connect(lambda: settings_dialog(self))
self.corner_layout.addWidget(self.settings_btn)
# Adjust theme based on system preferences
if darkdetect.isDark():
settings_white_pixmap = QPixmap()
settings_white = base64.b64decode(settings_white_base64)
settings_white_pixmap.loadFromData(settings_white)
self.settings_btn.setIcon(QIcon(settings_white_pixmap))
else:
settings_black_pixmap = QPixmap()
settings_black = base64.b64decode(settings_black_base64)
settings_black_pixmap.loadFromData(settings_black)
self.settings_btn.setIcon(QIcon(settings_black_pixmap))
self.tab_wrapper = QWidget()
self.tab_layout = QVBoxLayout()
self.tab_layout.setContentsMargins(0, 0, 0, 0)
self.tab_layout.setSpacing(0)
self.tab_bar_layout = QHBoxLayout()
self.tab_bar_layout.setContentsMargins(0, 0, 0, 0)
self.tab_bar_layout.setSpacing(0)
self.tab_bar_layout.addWidget(self.tabs.tabBar(), stretch=0)
self.tab_bar_layout.addStretch()
self.tab_bar_layout.addWidget(self.corner_widget, stretch=0)
self.tab_layout.addLayout(self.tab_bar_layout)
self.tab_layout.addWidget(self.tabs)
self.tab_wrapper.setLayout(self.tab_layout)
containerLayout.addWidget(self.tab_wrapper)
containerLayout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.consoleLog.setReadOnly(True)
self.consoleLog.setFixedHeight(150)
self.consoleLog.setStyleSheet("margin-bottom: 0px; background: transparent;")
containerLayout.addWidget(self.consoleLog)
self.statusbar.show()
self.statusbar.setStyleSheet("background: transparent; border: none; padding-right: 1px; padding-left: 6px; padding-bottom: 1px; margin-top: -8px")
self.version = QLabel(f"Version: {state.version}")
self.statusbar.addPermanentWidget(self.version, Qt.AlignmentFlag.AlignLeft)
# (\u2193) Arrow down, (\u2191) Arrow up
self.speed_label = QLabel("↓ 0.0 kB/s ↑ 0.0 kB/s")
self.speed_label.setStyleSheet("padding-right: 6px;")
self.statusbar.addPermanentWidget(self.speed_label)
self.setStatusBar(self.statusbar)
self.download_timer = QTimer()
self.download_timer.timeout.connect(lambda: download_list_update(self))
self.download_timer.start(500)
self.active_timer = QTimer()
self.active_timer.timeout.connect(self.show_empty_downloads)
self.active_timer.start(500)
self._context_menu = interface.dialogs.contextmenu.ContextMenu(self)
self.image_overlay = Image(self)
def _apply_default_headers(self, table):
tracker_name = state.currenttracker if hasattr(state, 'currenttracker') and state.currenttracker else None
if tracker_name is None:
keys = list(state.trackers.keys())
tracker_name = keys[0] if keys else None
headers = []
if tracker_name and tracker_name in state.trackers:
tracker_mod = state.trackers[tracker_name]
if hasattr(tracker_mod, 'headers'):
headers = tracker_mod.headers
elif hasattr(tracker_mod, 'HEADERS'):
headers = tracker_mod.HEADERS
if not headers:
if state.currenttracker == "rutracker":
headers = ["Post Title", "Author", "Seeders", "Leechers"]
elif state.currenttracker == "steamrip":
headers = ["Game"]
else:
headers = ["Post Title", "Author"] # i know this makes it less "modular", but its qol
table.setRowCount(0)
col_count = table.columnCount()
if col_count != len(headers):
for i in range(col_count):
table.setItemDelegateForColumn(i, None)
table.setColumnCount(len(headers))
table.setHorizontalHeaderLabels(headers)
self._apply_tracker_column_modes(table, len(headers))
def _apply_tracker_column_modes(self, table, col_count):
header = table.horizontalHeader()
if col_count > 0:
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
table.setItemDelegateForColumn(0, self._tracker_elided_delegate)
for i in range(1, col_count):
header.setSectionResizeMode(i, QHeaderView.ResizeMode.ResizeToContents)
table.setItemDelegateForColumn(i, self._tracker_hover_delegate)
def _start_search(self):
def _search_thread():
try:
run_search(self)
except Exception as e:
consoleLog(f"Search error: {e}", True)
self.search_results_signal.emit([])
run_thread(threading.Thread(target=_search_thread))
@staticmethod
def add_log(text):
if hasattr(MainWindow, "_instance") and MainWindow._instance:
MainWindow._instance.log_signal.emit(text)
return True
return False
def _on_log_signal(self, text):
if hasattr(self, 'consoleLog'):
self.consoleLog.append(text)
self.consoleLog.verticalScrollBar().setValue(
self.consoleLog.verticalScrollBar().maximum()
)
def _on_search_results(self, headers):
if state.posts is None or state.posts == []:
self.show_empty_results(True)
self.searchbar.setEnabled(True)
self.searchbar.setFocus()
return
table = state.trackertable
table.setRowCount(0)
col_count = table.columnCount()
need_reconfig = col_count != len(headers)
if need_reconfig:
for i in range(col_count):
table.setItemDelegateForColumn(i, None)
table.setColumnCount(len(headers))
table.setHorizontalHeaderLabels(headers)
self._apply_tracker_column_modes(table, len(headers))
else:
table.setHorizontalHeaderLabels(headers)
table.setRowCount(len(state.posts))
for x, rowdata in enumerate(state.posts):
for y, (key, data) in enumerate(rowdata.items()):
item = QTableWidgetItem(str(data))
item.setData(Qt.ItemDataRole.UserRole, x)
table.setItem(x, y, item)
self.show_empty_results(False)
self.searchbar.setEnabled(True)
self.searchbar.setFocus()
self._update_speed_label()
def _update_speed_label(self):
total_down = 0
total_up = 0
with state.downloads_lock:
active_items = list(state.active_downloads.values())
for handle in active_items:
try:
s = handle.status()
total_down += s.download_rate
total_up += s.upload_rate
except Exception as e:
consoleLog(f"Exception while updating speed label: {e}")
down_kb = total_down / 1024
up_kb = total_up / 1024
down_text = f"{down_kb / 1024:.1f} MB/s" if down_kb > 1024 else f"{down_kb:.1f} kB/s"
up_text = f"{up_kb / 1024:.1f} MB/s" if up_kb > 1024 else f"{up_kb:.1f} kB/s"
if hasattr(self, 'speed_label'):
self.speed_label.setText(f"{down_text}{up_text}")
def mousePressEvent(self, event):
state.trackertable.clearSelection()
self.downloadList.clearSelection()
super().mousePressEvent(event)
def changeEvent(self, event):
super().changeEvent(event)
def closeEvent(self, event: QCloseEvent):
closehelper()
event.accept()
from utils.general.shutdown import force_exit
force_exit()
def _invalidate_hover_row(self, row):
if row >= 0 and self.download_model:
self.downloadList.viewport().update()
def set_tracker(self, _):
state.currenttracker = self.tracker_list.currentText()
self._apply_default_headers(state.trackertable)
def show_empty_results(self, show: bool):
if show:
state.trackertable.hide()
self.emptyResults.show()
else:
state.trackertable.show()
self.emptyResults.hide()
def show_empty_downloads(self):
with state.downloads_lock:
has_downloads = len(state.active_downloads) > 0
if has_downloads:
self.emptyDownload.hide()
self.downloadList.show()
else:
self.emptyDownload.show()
self.downloadList.hide()
+27
View File
@@ -0,0 +1,27 @@
from utils.logging.logs import consoleLog
from utils.data.state import state
from data.sources import SCRAPERS
for scraper in SCRAPERS:
state.trackers[scraper.name] = scraper
def run_search(self) -> None:
state.trackertable.setRowCount(0)
self.show_empty_results(False)
search_text = self.searchbar.text()
# Check for empty search
if search_text == "":
consoleLog("Error: Can't search for nothing")
return
state.posts = []
self.searchbar.setEnabled(False)
consoleLog(f"User searched for: {search_text}")
# Get current tracker and call its search function
scraper = state.trackers[state.currenttracker]
state.posts = scraper.search(search_text)
# Update GUI headers to match the current scraper
self.search_results_signal.emit(scraper.headers)
+27
View File
@@ -0,0 +1,27 @@
from PySide6.QtGui import QIcon, QPixmap, QPainter
from PySide6.QtCore import Qt, QByteArray
from PySide6.QtSvg import QSvgRenderer
from PySide6 import QtWidgets
import darkdetect
# Check if darkmode is enabled
def _is_dark_mode():
return darkdetect.isDark()
def svg_icon(svg_str, size=20):
app = QtWidgets.QApplication.instance()
if app:
if _is_dark_mode():
color = "white"
else:
color = "#555555"
else:
color = "white"
svg = svg_str.replace("{color}", color)
renderer = QSvgRenderer(QByteArray(svg.encode()))
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
renderer.render(painter)
painter.end()
return QIcon(pixmap)
+22
View File
@@ -0,0 +1,22 @@
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLayout
# Tab creation helper function
def create_tab(title, items, tabs, stretch):
tab = QWidget()
layout = QVBoxLayout()
for item in items:
if item is None:
continue
if isinstance(item, QLayout):
layout.addLayout(item)
else:
layout.addWidget(item)
if stretch:
layout.addStretch()
tab.setLayout(layout)
tabs.addTab(tab, title)
return tab
+28 -23
View File
@@ -1,65 +1,70 @@
from core.interface.gui import MainWindow, windowCloseHelper
from core.utils.data.state import state
from core.utils.logging.logs import consoleLog
from core.network.libtorrent_misc import send_notification, update_log, check_deleted_files
from core.utils.logging.logs import get_download_logs
from core.utils.general.shutdown import closehelper, shutdown_event
from core.utils.general.wrappers import run_thread
from core.utils.logging.loghandler import split_data, check_completed, check_downloads
from core.network.interface import list_interfaces, init_interfaces
from core.utils.config.config import read_config
from network.libtorrent_misc import send_notification, update_log, check_deleted_files
from utils.logging.loghandler import split_data, check_completed, check_downloads
from network.interface import list_interfaces, init_interfaces
from utils.logging.logs import get_download_logs
from utils.logging.logs import set_main_window
from network.libtorrent_int import check_space
from utils.general.shutdown import closehelper
from utils.general.wrappers import run_thread
from utils.general.shutdown import force_exit
from utils.config.config import read_config
from utils.logging.logs import consoleLog
from interface.gui import MainWindow
from utils.data.state import state
from PySide6 import QtWidgets
from PySide6.QtCore import Qt
import qdarktheme
import threading
import platform
import signal
import sys
import time
import sys
def run_gui():
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv)
qdarktheme.setup_theme("auto")
widget = MainWindow()
# Check OS for window transparency compatibility and apply
if state.window_transparency and platform.system() != "Windows":
widget.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
qdarktheme.setup_theme("auto", custom_colors={"background": "#00000000"})
from core.utils.logging.logs import set_main_window
set_main_window(widget)
widget.show()
sys.exit(app.exec())
def keyboardinterrupthandler(signum, frame):
closehelper()
from core.utils.general.shutdown import force_exit
force_exit()
def main():
# Begin counting startup time
start_time = time.perf_counter()
# Parse saved files
read_config()
logs = get_download_logs()
_, downloads = split_data(logs)
# Initialize Keyboardinterrupt signal
signal.signal(signal.SIGINT, keyboardinterrupthandler)
consoleLog("Starting SoftwareManager...")
consoleLog("Fetching Network Interfaces...")
# Get network interface info
list_interfaces()
consoleLog("Initializing Interface variables...")
init_interfaces()
consoleLog(f"Current Bound: {state.bound_interface}")
run_thread(threading.Thread(target=send_notification, args=(shutdown_event,), daemon=True))
consoleLog("Started Thread: send_notification")
run_thread(threading.Thread(target=update_log, args=(shutdown_event,), daemon=True))
consoleLog("Started Thread: update_log")
# Start background daemon threads
run_thread(threading.Thread(target=send_notification, args=(state.shutdown_event,), daemon=True))
run_thread(threading.Thread(target=update_log, args=(state.shutdown_event,), daemon=True))
run_thread(threading.Thread(target=check_deleted_files, args=(state.shutdown_event,), daemon=True))
# Start background non-daemon threads
run_thread(threading.Thread(target=check_completed, args=(downloads, state.autoresume)))
consoleLog("Started Thread: check_completed")
run_thread(threading.Thread(target=check_deleted_files, args=(shutdown_event,), daemon=True))
consoleLog("Started Thread: check_deleted_files")
run_thread(threading.Thread(target=check_downloads, args=(downloads,)))
consoleLog("Started Thread: check_downloads")
run_thread(threading.Thread(target=check_space, args=()))
# Finish counting startup time
elapsed = time.perf_counter() - start_time
consoleLog(f"Initialization completed in {elapsed:.2f}s. Launching GUI")
# Launch GUI
run_gui()
if __name__ == "__main__":
+32
View File
@@ -0,0 +1,32 @@
from utils.logging.logs import consoleLog, add_download_log
from utils.data.state import state
from typing import Optional
from network.direct_download.handle import DirectDownloadHandle
from network.direct_download.utils import (
sanitize_filename,
extract_filename_from_url,
detect_filename_from_headers,
)
def add_direct_download(url: str, title: str, dl_path: Optional[str] = None, headers: Optional[dict] = None, single_threaded: bool = False):
if dl_path is None:
dl_path = state.download_path
if url in state.active_downloads:
consoleLog(f"Download already active: {title}")
return
filename = (
detect_filename_from_headers(url, DirectDownloadHandle.USER_AGENT)
or extract_filename_from_url(url)
or sanitize_filename(title) + ".zip"
)
handle = DirectDownloadHandle(url, filename, dl_path, headers, single_threaded)
state.active_downloads[url] = handle
add_download_log(title, url, "", False)
handle.start()
consoleLog(f"Started direct download: {filename}")
+447
View File
@@ -0,0 +1,447 @@
from network.direct_download.status import DirectDownloadStatus, ChunkSpec
from utils.logging.logs import consoleLog, update_download_completed
from concurrent.futures import ThreadPoolExecutor, as_completed
from network.direct_download.utils import format_size
from utils.data.state import state
from typing import Optional
import libtorrent as lt
import threading
import requests
import hashlib
import json
import time
import os
class SimpleThrottler:
def __init__(self):
self._lock = threading.Lock()
self._last_time = time.monotonic()
self._allowance = 0.0
def throttle(self, bytes_count: int, limit_kbps: int):
if limit_kbps <= 0:
return
limit_bps = limit_kbps * 1024
wait_time = 0
with self._lock:
now = time.monotonic()
elapsed = now - self._last_time
self._last_time = now
self._allowance += elapsed * limit_bps
if self._allowance > 2 * limit_bps:
self._allowance = 2 * limit_bps
self._allowance -= bytes_count
if self._allowance < 0:
wait_time = -self._allowance / limit_bps
self._allowance = 0
if wait_time > 0:
time.sleep(min(wait_time, 1.0))
_throttler = SimpleThrottler()
class DirectDownloadHandle:
STREAM_BLOCK_SIZE = 1 << 17 # 128 KiB per read
MAX_RETRIES = 5
RETRY_BACKOFF_BASE = 2.0
NUM_THREADS = 16
MIN_CHUNK_SIZE = 1 << 20 # 1 MiB minimum per chunk
REQUEST_TIMEOUT = (15, 60) # (connect, read) timeouts
USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/125.0.0.0 Safari/537.36"
)
def __init__(self, url: str, name: str, save_path: str, headers: Optional[dict] = None, single_threaded: bool = False):
self.url = url
self._name = name
self._save_path = save_path
self._file_path = os.path.join(save_path, name)
self._status = DirectDownloadStatus(name, save_path, 0)
self._stop_event = threading.Event()
self._pause_event = threading.Event() # SET = paused
self._thread: Optional[threading.Thread] = None
self._session: Optional[requests.Session] = None
self._supports_range = False
self._custom_headers = headers
self._single_threaded = single_threaded
state_dir = os.path.join(state.settings_path, "direct_downloads")
os.makedirs(state_dir, exist_ok=True)
url_hash = hashlib.sha256(url.encode()).hexdigest()
self._state_file = os.path.join(state_dir, f"{url_hash}.json")
# Load state to initialize progress
state_data = self._load_state()
if state_data:
total_wanted = state_data.get("total_wanted", 0)
chunks_progress = state_data.get("chunks", {})
chunks_progress = {int(k): int(v) for k, v in chunks_progress.items()}
self._status.initialize_progress(total_wanted, chunks_progress)
def status(self) -> DirectDownloadStatus:
return self._status
def pause(self):
self._status.paused = True
self._pause_event.set()
def resume(self):
self._status.paused = False
self._pause_event.clear()
def set_flags(self, flags):
if flags & lt.torrent_flags.auto_managed:
self._status.auto_managed = True
def unset_flags(self, flags):
if flags & lt.torrent_flags.auto_managed:
self._status.auto_managed = False
def save_path(self) -> str:
return self._save_path
def stop(self):
self._stop_event.set()
if self._thread and self._thread.is_alive():
self._thread.join(timeout=10)
def _load_state(self) -> dict:
if os.path.exists(self._state_file):
try:
with open(self._state_file, "r") as f:
return json.load(f)
except Exception as e:
consoleLog(f"Failed to load state for {self._name}: {e}")
return {}
def _save_state(self, chunks_done: dict[int, int]):
try:
with open(self._state_file, "w") as f:
json.dump({
"url": self.url,
"name": self._name,
"save_path": self._save_path,
"total_wanted": self._status.total_wanted,
"chunks": chunks_done
}, f)
except Exception as e:
consoleLog(f"Exception while saving download state: {e}")
def _clear_state(self):
if os.path.exists(self._state_file):
try:
os.remove(self._state_file)
except Exception as e:
consoleLog(f"Exception while removing download state file: {e}")
def start(self):
self._thread = threading.Thread(
target=self._download_orchestrator, name=f"dl-{self._name}", daemon=True
)
self._thread.start()
def _build_session(self) -> requests.Session:
session = requests.Session()
session.headers.update({"User-Agent": self.USER_AGENT})
if self._custom_headers is not None:
session.headers.update(self._custom_headers)
adapter = requests.adapters.HTTPAdapter(
max_retries=0,
pool_connections=self.NUM_THREADS,
pool_maxsize=self.NUM_THREADS + 2,
)
session.mount("https://", adapter)
session.mount("http://", adapter)
return session
def _probe_url(self, session: requests.Session) -> tuple[int, bool]:
resp = session.head(self.url, allow_redirects=True, timeout=self.REQUEST_TIMEOUT)
resp.raise_for_status()
total_size = int(resp.headers.get("content-length", 0))
supports_range = (total_size > 0)
# Double-check range support with a small range request
if supports_range:
try:
test = session.get(
self.url,
headers={"Range": "bytes=0-0"},
timeout=self.REQUEST_TIMEOUT,
stream=True,
)
supports_range = test.status_code == 206
test.close()
except Exception:
supports_range = False
return total_size, supports_range
def _download_orchestrator(self):
try:
self._session = self._build_session()
total_size, self._supports_range = self._probe_url(self._session)
self._status.total_wanted = total_size
os.makedirs(self._save_path, exist_ok=True)
use_multithreaded = (
self._supports_range
and total_size > self.MIN_CHUNK_SIZE * 2
and not self._single_threaded
)
if use_multithreaded:
consoleLog(
f"Multi-threaded download ({self.NUM_THREADS} threads): {self._name} "
f"({format_size(total_size)})"
)
self._preallocate_file(total_size)
self._multithreaded_download(total_size)
else:
reason = "no range support" if not self._supports_range else "file too small or single-threaded mode"
consoleLog(f"Single-threaded download ({reason}): {self._name}")
self._single_threaded_download()
if not self._stop_event.is_set() and self._status.error is None:
self._verify_download(total_size)
self._status.mark_completed()
update_download_completed(self.url, True)
self._clear_state()
consoleLog(f"Finished downloading {self._name}")
except Exception as e:
self._status.mark_error(str(e))
consoleLog(f"Download failed for {self._name}: {e}")
finally:
if self._session:
self._session.close()
def _preallocate_file(self, total_size: int):
if os.path.exists(self._file_path) and os.path.getsize(self._file_path) == total_size:
return
with open(self._file_path, "wb") as f:
f.truncate(total_size)
def _compute_chunks(self, total_size: int) -> list[ChunkSpec]:
num_chunks = min(self.NUM_THREADS, max(1, total_size // self.MIN_CHUNK_SIZE))
chunk_size = total_size // num_chunks
chunks = []
for i in range(num_chunks):
start = i * chunk_size
end = (i + 1) * chunk_size - 1 if i < num_chunks - 1 else total_size - 1
chunks.append(ChunkSpec(chunk_id=i, start=start, end=end))
return chunks
def _multithreaded_download(self, total_size: int):
chunks = self._compute_chunks(total_size)
state_data = self._load_state()
chunks_progress = state_data.get("chunks", {})
chunks_progress = {int(k): int(v) for k, v in chunks_progress.items()}
# Initialize progress in status
for chunk_id, bytes_done in chunks_progress.items():
self._status.update_chunk_progress(chunk_id, bytes_done)
with ThreadPoolExecutor(
max_workers=len(chunks), thread_name_prefix="dl-chunk"
) as executor:
futures = {
executor.submit(self._download_chunk_with_retry, chunk, chunks_progress.get(chunk.chunk_id, 0)): chunk
for chunk in chunks
}
for future in as_completed(futures):
chunk = futures[future]
try:
future.result()
except Exception as e:
consoleLog(
f"Chunk {chunk.chunk_id} ({chunk.start}-{chunk.end}) "
f"failed permanently: {e}"
)
self._status.mark_error(
f"Chunk {chunk.chunk_id} failed: {e}"
)
self._stop_event.set()
def _download_chunk_with_retry(self, chunk: ChunkSpec, initial_bytes: int):
bytes_written = initial_bytes
for attempt in range(1, self.MAX_RETRIES + 1):
if self._stop_event.is_set():
return
current_start = chunk.start + bytes_written
if current_start > chunk.end:
return
try:
# _download_range returns new bytes written
bytes_written += self._download_range(
chunk.chunk_id, current_start, chunk.end, bytes_written
)
return
except Exception as e:
if self._stop_event.is_set():
return
if attempt < self.MAX_RETRIES:
wait = self.RETRY_BACKOFF_BASE ** attempt
consoleLog(
f"Chunk {chunk.chunk_id} attempt {attempt} failed: {e}. "
f"Retrying in {wait:.0f}s (resuming from byte {current_start})..."
)
if self._stop_event.wait(timeout=wait):
return
else:
raise RuntimeError(
f"Chunk {chunk.chunk_id} failed after {self.MAX_RETRIES} attempts: {e}"
) from e
def _download_range(
self, chunk_id: int, start: int, end: int, prior_bytes: int
) -> int:
headers = {"Range": f"bytes={start}-{end}"}
new_bytes = 0
with self._session.get(
self.url, headers=headers, stream=True, timeout=self.REQUEST_TIMEOUT
) as resp:
resp.raise_for_status()
if resp.status_code not in (200, 206):
raise RuntimeError(f"Unexpected status {resp.status_code}")
with open(self._file_path, "r+b") as f:
f.seek(start)
for block in resp.iter_content(chunk_size=self.STREAM_BLOCK_SIZE):
if self._stop_event.is_set():
return new_bytes
self._wait_if_paused()
if self._stop_event.is_set():
return new_bytes
_throttler.throttle(len(block), state.down_speed_limit)
f.write(block)
new_bytes += len(block)
self._status.update_chunk_progress(
chunk_id, prior_bytes + new_bytes
)
if new_bytes % (1024 * 1024) < len(block):
with self._status._lock:
self._save_state(self._status._chunk_bytes)
with self._status._lock:
self._save_state(self._status._chunk_bytes)
return new_bytes
def _single_threaded_download(self):
chunk_id = 0
bytes_written = 0
if os.path.exists(self._file_path):
bytes_written = os.path.getsize(self._file_path)
if bytes_written > 0:
self._status.update_chunk_progress(chunk_id, bytes_written)
for attempt in range(1, self.MAX_RETRIES + 1):
if self._stop_event.is_set():
return
try:
headers = {}
mode = "wb"
if bytes_written > 0 and self._supports_range:
headers["Range"] = f"bytes={bytes_written}-"
mode = "r+b"
elif bytes_written > 0:
bytes_written = 0
mode = "wb"
with self._session.get(
self.url, headers=headers, stream=True, timeout=self.REQUEST_TIMEOUT
) as resp:
resp.raise_for_status()
if self._status.total_wanted == 0:
content_length = int(resp.headers.get("content-length", 0))
self._status.total_wanted = content_length + bytes_written
with open(self._file_path, mode) as f:
if mode == "r+b":
f.seek(bytes_written)
for block in resp.iter_content(
chunk_size=self.STREAM_BLOCK_SIZE
):
if self._stop_event.is_set():
return
self._wait_if_paused()
if self._stop_event.is_set():
return
_throttler.throttle(len(block), state.down_speed_limit)
f.write(block)
bytes_written += len(block)
self._status.update_chunk_progress(chunk_id, bytes_written)
# Periodic state save (single thread, chunk_id=0)
if bytes_written % (1024 * 1024) < len(block):
self._save_state({0: bytes_written})
self._save_state({0: bytes_written})
return
except Exception as e:
if self._stop_event.is_set():
return
if attempt < self.MAX_RETRIES:
wait = self.RETRY_BACKOFF_BASE ** attempt
consoleLog(
f"Download attempt {attempt} failed: {e}. "
f"Retrying in {wait:.0f}s..."
)
if self._stop_event.wait(timeout=wait):
return
else:
raise RuntimeError(
f"Download failed after {self.MAX_RETRIES} attempts: {e}"
) from e
def _wait_if_paused(self):
while self._pause_event.is_set():
if self._stop_event.wait(timeout=0.5):
return
def _verify_download(self, expected_size: int):
if expected_size <= 0:
return
actual_size = os.path.getsize(self._file_path)
if actual_size != expected_size:
raise RuntimeError(
f"Size mismatch: expected {format_size(expected_size)}, "
f"got {format_size(actual_size)}"
)
+96
View File
@@ -0,0 +1,96 @@
from dataclasses import dataclass
from typing import Optional
import libtorrent as lt
import threading
import time
@dataclass
class ChunkSpec:
chunk_id: int
start: int
end: int # inclusive
class DirectDownloadStatus:
def __init__(self, name: str, save_path: str, total_size: int = 0):
self.name = name
self.save_path = save_path
self.total_wanted = total_size
self.paused = False
self.auto_managed = True
self.has_metadata = True
self.state = lt.torrent_status.downloading
self.error: Optional[str] = None
self._lock = threading.Lock()
self._total_wanted_done = 0
self._progress = 0.0
self._download_rate = 0
self._upload_rate = 0
self._chunk_bytes: dict[int, int] = {}
self._speed_window: list[tuple[float, int]] = []
self._speed_window_size = 3.0
@property
def total_wanted_done(self) -> int:
with self._lock:
return self._total_wanted_done
@property
def progress(self) -> float:
with self._lock:
return self._progress
@property
def download_rate(self) -> int:
with self._lock:
return self._download_rate
@property
def upload_rate(self) -> int:
return 0
def update_chunk_progress(self, chunk_id: int, bytes_downloaded: int):
with self._lock:
self._chunk_bytes[chunk_id] = bytes_downloaded
self._total_wanted_done = sum(self._chunk_bytes.values())
if self.total_wanted > 0:
self._progress = min(self._total_wanted_done / self.total_wanted, 1.0)
now = time.monotonic()
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
]
if len(self._speed_window) >= 2:
oldest_time, oldest_bytes = self._speed_window[0]
dt = now - oldest_time
if dt > 0:
self._download_rate = int(
(self._total_wanted_done - oldest_bytes) / dt
)
def initialize_progress(self, total_wanted: int, chunk_bytes: dict[int, int]):
with self._lock:
self.total_wanted = total_wanted
self._chunk_bytes = chunk_bytes.copy()
self._total_wanted_done = sum(self._chunk_bytes.values())
if self.total_wanted > 0:
self._progress = min(self._total_wanted_done / self.total_wanted, 1.0)
def mark_completed(self):
with self._lock:
self.state = lt.torrent_status.seeding
self._progress = 1.0
self._download_rate = 0
def mark_error(self, error: str):
with self._lock:
self.error = error
self._download_rate = 0
+49
View File
@@ -0,0 +1,49 @@
from urllib.parse import urlparse, unquote
from utils.logging.logs import consoleLog
from typing import Optional
import requests
import os
def sanitize_filename(name: str) -> str:
# Remove or replace dangerous characters
keepchars = (" ", ".", "_", "-")
cleaned = "".join(c for c in name if c.isalnum() or c in keepchars).strip()
while " " in cleaned:
cleaned = cleaned.replace(" ", " ")
return cleaned or "download"
def extract_filename_from_url(url: str) -> Optional[str]:
parsed = urlparse(url)
path = unquote(parsed.path)
basename = os.path.basename(path)
if basename and "." in basename and len(basename) < 256:
return basename
return None
def detect_filename_from_headers(url: str, user_agent: str) -> Optional[str]:
try:
resp = requests.head(
url,
headers={"User-Agent": user_agent},
allow_redirects=True,
timeout=15,
)
cd = resp.headers.get("content-disposition", "")
if "filename=" in cd:
parts = cd.split("filename=")
if len(parts) > 1:
fname = parts[-1].strip().strip('"').strip("'")
if fname:
return fname
except Exception as e:
consoleLog(f"Exception while retrieving filename from headers: {e}")
return None
def format_size(size_bytes: int) -> str:
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
if abs(size_bytes) < 1024:
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024
return f"{size_bytes:.1f} PiB"
@@ -1,20 +1,22 @@
from core.utils.logging.logs import consoleLog
from core.utils.data.state import state
from utils.logging.logs import consoleLog
from utils.data.state import state
import psutil
addrs = psutil.net_if_addrs()
stats = psutil.net_if_stats()
def get_net_interfaces():
addrs = psutil.net_if_addrs()
for interface in addrs.keys():
consoleLog(f"Found Interface: {interface}")
return addrs.keys()
def get_active_interfaces():
addrs = psutil.net_if_addrs()
stats = psutil.net_if_stats()
active = []
for interface, addr_list in addrs.items():
if interface not in stats:
continue
up = stats[interface].isup
for addr in addr_list:
if addr.family == 2: # ipv4
@@ -27,26 +29,33 @@ def get_active_interfaces():
def list_interfaces() -> None:
consoleLog("Fetching Network Interfaces...")
addrs = psutil.net_if_addrs()
stats = psutil.net_if_stats()
for interface, addr_list in addrs.items():
if interface not in stats:
continue
up = stats[interface].isup
status = "INACTIVE"
for addr in addr_list:
if addr.family == 2: # ipv4
ipv4 = addr.address
if not ipv4.startswith("127.") and not ipv4.startswith("169.254") and up:
status = "ACTIVE"
else:
status = "INACTIVE"
consoleLog(f"Found Interface: {interface} [{status}]")
def init_interfaces():
consoleLog("Initializing Interface variables...")
addrs = psutil.net_if_addrs()
state.interfaces = list(addrs.keys())
state.active_interfaces = get_active_interfaces()
def get_interface_ip(interface_name):
addrs = psutil.net_if_addrs()
if interface_name in addrs:
for addr in addrs[interface_name]:
if addr.family == 2: # ipv4
if not addr.address.startswith("127.") and not addr.address.startswith("169.254"):
return addr.address
return None
return None
@@ -1,17 +1,45 @@
from network.interface import get_interface_ip
from utils.general.wrappers import run_thread
from utils.logging.logs import consoleLog
from utils.data.state import state
import libtorrent as lt
import threading
import platform
import ctypes
import time
import os
from core.utils.general.wrappers import run_thread
from core.network.interface import get_interface_ip
import threading
import libtorrent as lt
from core.utils.data.state import state
from core.utils.logging.logs import consoleLog
from core.utils.general.shutdown import shutdown_event
global loop_running
loop_running = False
def get_free_space_mb(dirname):
if platform.system() == 'Windows':
free_bytes = ctypes.c_ulonglong(0)
ctypes.windll.kernel32.GetDiskFreeSpaceExW(ctypes.c_wchar_p(dirname), None, None, ctypes.pointer(free_bytes))
return free_bytes.value
else:
st = os.statvfs(dirname)
return st.f_bavail * st.f_frsize
def check_space():
while not state.shutdown_event.is_set():
for _, magnetdl in list(state.active_downloads.items()):
try:
status = magnetdl.status()
except RuntimeError:
continue
if status.state == lt.torrent_status.downloading:
free_space = get_free_space_mb(state.download_path)
total_size = status.total_wanted
if free_space < total_size:
consoleLog(f"Not enough free space to continue downloading: {status.name}")
magnetdl.pause()
break
time.sleep(5)
def init_session():
if state.dl_session is not None:
return
@@ -20,8 +48,8 @@ def init_session():
settings = {
"upload_rate_limit": state.up_speed_limit,
"download_rate_limit": state.down_speed_limit,
"upload_rate_limit": state.up_speed_limit * 1024,
"download_rate_limit": state.down_speed_limit * 1024,
"enable_dht": True,
"enable_lsd": True,
"enable_upnp": True,
@@ -45,43 +73,61 @@ def init_session():
consoleLog("Initialized Session")
def add_download(magnet_uri, dl_path=state.download_path):
def add_download(magnet_uri):
if state.active_downloads is None:
state.active_downloads = {}
init_session()
free_space = get_free_space_mb(state.download_path)
if magnet_uri in state.active_downloads:
try:
handle = state.active_downloads[magnet_uri]
status = handle.status()
if status.has_metadata:
filepath = os.path.join(status.save_path, status.name)
if not os.path.exists(filepath):
consoleLog(f"File Deleted, redownloading: {status.name}")
state.dl_session.remove_torrent(handle)
del state.active_downloads[magnet_uri]
else:
consoleLog("Skipping Downloading, download already running...")
return False
else:
consoleLog("Skipping Downloading, download already running... ")
return False
except RuntimeError as e:
consoleLog(f"Error in LibTorrent Handle: {e}")
if status.has_metadata:
filepath = os.path.join(status.save_path, status.name)
if not os.path.exists(filepath):
consoleLog(f"File Deleted, redownloading: {status.name}")
state.dl_session.remove_torrent(handle)
del state.active_downloads[magnet_uri]
else:
consoleLog("Skipping Downloading, download already running...")
return False
else:
consoleLog("Skipping Downloading, download already running... ")
return False
del state.active_downloads[magnet_uri]
try:
magnetdl = lt.parse_magnet_uri(magnet_uri)
magnetdl.save_path = dl_path
download = state.dl_session.add_torrent(magnetdl)
params = lt.parse_magnet_uri(magnet_uri)
params.save_path = state.download_path
handle = state.dl_session.add_torrent(params)
while not handle.has_metadata():
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:
state.dl_session.remove_torrent(handle)
consoleLog("Not enough free space to download this item.")
return False
except Exception as e:
consoleLog(f"Failed to add torrent: {e}")
consoleLog(f"Failed to add torrent or fetch info: {e}")
return False
state.active_downloads[magnet_uri] = download
if download:
state.active_downloads[magnet_uri] = download
consoleLog(f"Added {magnet_uri} to downloads")
run_thread(threading.Thread(target=dl_status_loop))
@@ -123,7 +169,7 @@ def dl_status_loop():
loop_running = False
return
while state.active_downloads and not shutdown_event.is_set():
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()
@@ -149,8 +195,8 @@ def update_settings():
return
settings = {
"upload_rate_limit": state.up_speed_limit,
"download_rate_limit": state.down_speed_limit,
"upload_rate_limit": state.up_speed_limit * 1024,
"download_rate_limit": state.down_speed_limit * 1024,
"connections_limit": state.max_connections,
"active_downloads": state.max_downloads
}
@@ -171,4 +217,3 @@ def update_bound_interface():
settings = { "outgoing_interfaces": state.bound_interface }
state.dl_session.apply_settings(settings)
@@ -1,10 +1,10 @@
from core.utils.data.state import state
from core.utils.logging.logs import update_download_completed_by_hash
from core.utils.logging.logs import consoleLog
from utils.logging.logs import update_download_completed_by_hash
from utils.logging.logs import consoleLog
from utils.data.state import state
from plyer import notification
import libtorrent as lt
import os
import time
import os
def cleanup_session():
@@ -17,9 +17,6 @@ def cleanup_session():
state.dl_session = None
state.active_downloads.clear()
def send_notification(shutdown_event):
notified = set()
while not shutdown_event.is_set():
@@ -37,8 +34,8 @@ def send_notification(shutdown_event):
timeout=4
)
notified.add(magnet_uri)
except Exception:
pass
except Exception as e:
consoleLog(f"Exception while sending notification: {e}")
time.sleep(5)
def update_log(shutdown_event):
@@ -53,11 +50,14 @@ def update_log(shutdown_event):
if status.state == lt.torrent_status.seeding and magnet_uri not in updated and magnet_uri not in state.seeded_magnets:
consoleLog(f"Marking {status.name} as completed")
info_hash = str(status.info_hash)
if hasattr(status, 'info_hashes'):
info_hash = str(status.info_hashes.v1)
else:
info_hash = str(status.info_hash)
update_download_completed_by_hash(info_hash, True)
updated.add(magnet_uri)
except Exception:
pass
except Exception as e:
consoleLog(f"Exception while updating log file: {e}")
time.sleep(5)
def check_deleted_files(shutdown_event):
@@ -76,6 +76,6 @@ def check_deleted_files(shutdown_event):
consoleLog(f"Registered File Deletion: {status.name}")
state.dl_session.remove_torrent(magnetdl)
del state.active_downloads[magnet_uri]
except Exception:
pass
except Exception as e:
consoleLog(f"Exception while checking for file deletions: {e}")
time.sleep(5)
@@ -1,5 +1,6 @@
from core.utils.logging.logs import consoleLog
from core.network.libtorrent_int import add_download
from network.libtorrent_int import add_download
from utils.logging.logs import consoleLog
def add_magnet(uri):
if uri is not None and uri.startswith("magnet:?"):
@@ -1,7 +1,8 @@
import os
import platform
from utils.data.state import state
import configparser
from core.utils.data.state import state
import platform
import os
def create_config():
config = configparser.ConfigParser()
@@ -15,7 +16,7 @@ def create_config():
config["Network"] = {
"api_url": f"{state.api_url}",
"download_path": f"{state.download_path}",
"bound_interface": f"{state.bound_interface}" if state.bound_interface is not None else "None",
"download_speed_limit": f"{state.down_speed_limit}",
"upload_speed_limit": f"{state.up_speed_limit}",
"max_connections": f"{state.max_connections}",
@@ -23,7 +24,7 @@ def create_config():
}
config["Paths"] = {
"bound_interface": f"{state.bound_interface}",
"download_path": f"{state.download_path}",
"image_path": f"{state.image_path}"
}
@@ -64,14 +65,16 @@ def read_config():
# Network
state.api_url = config.get("Network", "api_url", fallback=state.api_url)
state.download_path = config.get("Network", "download_path", fallback=state.download_path)
state.bound_interface = config.get("Network", "bound_interface", fallback=state.bound_interface)
if state.bound_interface == "None":
state.bound_interface = None
state.down_speed_limit = config.getint("Network", "download_speed_limit", fallback=state.down_speed_limit)
state.up_speed_limit = config.getint("Network", "upload_speed_limit", fallback=state.up_speed_limit)
state.max_connections = config.getint("Network", "max_connections", fallback=state.max_connections)
state.max_downloads = config.getint("Network", "max_downloads", fallback=state.max_downloads)
# Paths
state.bound_interface = config.get("Paths", "bound_interface", fallback=state.bound_interface)
state.download_path = config.get("Paths", "download_path", fallback=state.download_path)
state.image_path = config.get("Paths", "image_path", fallback=state.image_path)
create_config()
@@ -1,7 +1,8 @@
from core.network.libtorrent_int import update_settings
from core.utils.data.state import state
from core.utils.logging.logs import consoleLog
from .config import create_config
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
def save_settings(close=lambda: None, apiurl=None, download_path=None, down_speed_limit=None, up_speed_limit=None, image_path=None, autoresume=None, max_connections=None, max_downloads=None, bound_interface=None):
@@ -23,7 +24,6 @@ def save_settings(close=lambda: None, apiurl=None, download_path=None, down_spee
state.max_downloads = max_downloads
if bound_interface is not None:
state.bound_interface = None if bound_interface == "None" else bound_interface
update_settings()
consoleLog("Saved Settings")
@@ -1,40 +1,52 @@
from PySide6.QtCore import QObject, Signal
from PySide6.QtWidgets import QTableWidget
from typing import Optional, Any, List, Dict
from typing import Any, List, Dict
from pathlib import Path
import threading
class AppState(QObject):
image_changed = Signal(str)
def __init__(self):
super().__init__()
self.posts: list[Any] | None = None
self.post_titles: Optional[List] = None
self.post_urls: Optional[List] = None
self.post_author: List[str] = []
self.post_seeders: List[str] = []
self.post_leechers: List[str] = []
self.version: str = "dev"
self._image_path: str = ""
self.ignore_updates: bool = False
# General
self.version: str = "Unknown"
self.debug: bool = False
self.autoresume: bool = True
self.tracker: str = "rutracker"
self.tracker_list: dict[str, QTableWidget] = {}
self.api_url: str = "https://api.michijackson.xyz"
self.main_window: Any = None
self.loop_running: bool = False
self.shutdown_event = threading.Event()
self.log_buffer: List[str] = []
# Paths
self.settings_path: str = ""
self._image_path: str = ""
self.download_path: str = str(Path.home() / "Downloads")
# GUI
self.window_transparency: bool = False
self.trackertable: QTableWidget
self.interfaces: List = []
self.active_interfaces: List = []
self.bound_interface: Any = None
# Trackers / Scraping
self.posts: list[Dict[str,str]] | None = None # titles, urls, author, seeders, leechers
self.currenttracker: str = "rutracker"
self.api_url: str = "https://api.michijackson.xyz"
self.trackers: Dict[str,Dict[str,Any]] = {}
# LibTorrent / Download related stuff
self.dl_session: Any = None
self.active_downloads: Dict = {}
self.seeded_magnets: set = set()
self.ignore_updates: bool = False
self.autoresume: bool = True
self.up_speed_limit: int = 0
self.down_speed_limit: int = 0
self.max_connections: int = 200
self.max_downloads: int = 10
self.settings_path: str = ""
self.dl_session: Any = None
self.active_downloads: Dict = {}
self.seeded_magnets: set = set()
self.window_transparency: bool = False
self.interfaces: List = []
self.active_interfaces: List = []
self.bound_interface: Any = None
self.downloads_lock = threading.RLock()
@property
def image_path(self) -> str:
+22
View File
@@ -0,0 +1,22 @@
from utils.logging.logs import consoleLog
from bs4 import BeautifulSoup
import requests
def get_magnet_link(post_url):
try:
response = requests.get(post_url, timeout=15) # eventually impl. cloudscraper
consoleLog("Sent Request to retrieve Magnet Link...")
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
magnet_link = soup.find('a', href=lambda x: x and x.startswith('magnet:'))
if magnet_link:
consoleLog(f"Magnet Link Retrieved: {magnet_link['href']}")
return magnet_link['href']
else:
consoleLog("Magnet Link not Found!")
return None
except requests.RequestException as e:
consoleLog(f"Failed to fetch {post_url}: {e}")
return None
+14
View File
@@ -0,0 +1,14 @@
from network.libtorrent_misc import cleanup_session
from utils.logging.logs import consoleLog
from utils.data.state import state
import os
def closehelper():
state.shutdown_event.set()
try:
cleanup_session()
except Exception as e:
consoleLog(f"Exception while cleaning up LibTorrent Session: {e}")
def force_exit():
os._exit(0)
+9
View File
@@ -0,0 +1,9 @@
from utils.logging.logs import consoleLog
def run_thread(thread):
target_name = thread._target.__name__
try:
thread.start()
consoleLog(f"Started Thread: {target_name}")
except Exception as e:
consoleLog(f"Exception while starting thread {target_name}: {e}")
@@ -1,6 +1,5 @@
from core.utils.logging.logs import consoleLog, remove_download_log
from core.utils.network.download import run_download_direct, seed_magnet
from utils.network.download import run_download_direct, seed_magnet
from utils.logging.logs import consoleLog, remove_download_log
import os
def split_data(data):
@@ -15,18 +14,23 @@ def check_completed(downloads, resume):
if download.completed == False:
consoleLog(f"Found unfinished download: {download.title}")
if resume == True:
run_download_direct(download.magnet_uri)
consoleLog(f"Resuming {download.title}")
if download.magnet_uri:
run_download_direct(download.magnet_uri, download.title)
consoleLog(f"Resuming Magnet: {download.title}")
elif download.url:
from network.direct_download import add_direct_download
add_direct_download(download.url, download.title)
consoleLog(f"Resuming Direct Download: {download.title}")
def check_downloads(downloads):
for download in downloads:
if os.path.exists(download.path):
if download.completed == True and os.path.exists(download.path):
consoleLog(f"Existing Download: {download.title}")
try:
seed_magnet(download.magnet_uri, download.path)
except Exception as e:
consoleLog(f"Failed to seed {download.title}: {e}")
else:
elif download.completed == True and not os.path.exists(download.path):
consoleLog(f"Inexistent Download: {download.title}")
remove_download_log(download.magnet_uri)
@@ -1,15 +1,11 @@
from core.utils.data.models import Download, DownloadList
from core.utils.data.state import state
from utils.data.models import Download, DownloadList
from utils.data.state import state
from dataclasses import asdict
from datetime import datetime
import json
import time
import os
import re
import time
import threading
_log_buffer = []
_downloads_lock = threading.RLock()
def add_download_log(title, url, magnet_uri, completed) -> DownloadList:
@@ -28,7 +24,7 @@ def add_download_log(title, url, magnet_uri, completed) -> DownloadList:
path = os.path.join(save_path, torrent_name)
else:
path = os.path.join(state.download_path, title)
with _downloads_lock:
with state.downloads_lock:
return _add_download_log_inner(title, url, magnet_uri, completed, path)
def _add_download_log_inner(title, url, magnet_uri, completed, path) -> DownloadList:
@@ -44,14 +40,15 @@ def _add_download_log_inner(title, url, magnet_uri, completed, path) -> Download
else:
downloads = []
if any(d.magnet_uri == magnet_uri or d.url == url for d in downloads):
if magnet_uri in state.active_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:
consoleLog("Skipping Logging, download already running...")
return
return DownloadList(data=downloads, count=len(downloads))
consoleLog("File already in Log, updating Download State...")
hash = extract_hash_from_magnet(magnet_uri)
update_download_completed_by_hash(hash, False)
return DownloadList(data=downloads, count=len(downloads)) # thanks again claude (im stupid)
if hash:
update_download_completed_by_hash(hash, False)
return DownloadList(data=downloads, count=len(downloads))
downloads.append(Download(
@@ -71,7 +68,7 @@ def _add_download_log_inner(title, url, magnet_uri, completed, path) -> Download
return download_list
def remove_download_log(magnet_uri) -> DownloadList:
with _downloads_lock:
with state.downloads_lock:
return _remove_download_log_inner(magnet_uri)
def _remove_download_log_inner(magnet_uri) -> DownloadList:
@@ -89,6 +86,9 @@ def _remove_download_log_inner(magnet_uri) -> DownloadList:
magnet_link = (magnet_uri or "").strip()
if not magnet_link:
return DownloadList(data=downloads, count=len(downloads))
title = next((getattr(d, 'title', 'Unknown') for d in downloads if (getattr(d, 'magnet_uri', None) or '').strip() == magnet_link or (getattr(d, 'url', None) or '').strip() == magnet_link), 'Unknown')
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]
@@ -101,7 +101,7 @@ def _remove_download_log_inner(magnet_uri) -> DownloadList:
return download_list
def update_download_completed(magnet_uri, completed) -> DownloadList:
with _downloads_lock:
with state.downloads_lock:
return _update_download_completed_inner(magnet_uri, completed)
def _update_download_completed_inner(magnet_uri, completed) -> DownloadList:
@@ -135,9 +135,7 @@ def _update_download_completed_inner(magnet_uri, completed) -> DownloadList:
try:
stored_magnet = (getattr(download, 'magnet_uri', None) or "").strip()
stored_url = (getattr(download, 'url', None) or "").strip()
consoleLog(f"Comparing with magnet: {stored_magnet[:50] if stored_magnet else 'None'}...")
if identifier and (stored_magnet == identifier or stored_url == identifier):
consoleLog(f"Match found! Setting completed={completed}")
download.completed = completed
found = True
except Exception as e:
@@ -157,7 +155,7 @@ def _update_download_completed_inner(magnet_uri, completed) -> DownloadList:
def get_download_logs() -> DownloadList:
with _downloads_lock:
with state.downloads_lock:
return _get_download_logs_inner()
def _get_download_logs_inner() -> DownloadList:
@@ -176,15 +174,24 @@ def _get_download_logs_inner() -> DownloadList:
return DownloadList(data=downloads, count=len(downloads))
def extract_hash_from_magnet(magnet_uri): # full credits to claude for this
match = re.search(r'urn:btih:([A-F0-9]+)', magnet_uri, re.IGNORECASE)
if match:
return match.group(1).upper()
return None
def extract_hash_from_magnet(magnet_uri):
if not magnet_uri:
return None
try:
import libtorrent as lt
params = lt.parse_magnet_uri(magnet_uri)
if hasattr(params, 'info_hashes'): # lt 2.0+
return str(params.info_hashes.v1).upper()
return str(params.info_hash).upper()
except Exception:
match = re.search(r'urn:btih:([a-zA-Z0-9]+)', magnet_uri)
if match:
return match.group(1).upper()
return None
def update_download_completed_by_hash(info_hash, completed) -> DownloadList:
with _downloads_lock:
with state.downloads_lock:
return _update_download_completed_by_hash_inner(info_hash, completed)
def _update_download_completed_by_hash_inner(info_hash, completed) -> DownloadList:
@@ -230,22 +237,18 @@ def _update_download_completed_by_hash_inner(info_hash, completed) -> DownloadLi
return download_list
_main_window = None
def set_main_window(window):
global _main_window
_main_window = window
state.main_window = window
def flush_log_buffer(): # credits to claude
global _log_buffer
if _log_buffer:
if state.log_buffer:
try:
from core.interface.gui import MainWindow
for log_entry in _log_buffer:
from interface.gui import MainWindow
for log_entry in state.log_buffer:
MainWindow.add_log(log_entry)
_log_buffer = []
except Exception:
pass
state.log_buffer = []
except Exception as e:
consoleLog(f"Exception while flushing log buffer: {e}")
def consoleLog(text, printAnyways = False):
now = datetime.now()
@@ -253,11 +256,11 @@ def consoleLog(text, printAnyways = False):
formatted_text = f"[{current_time}] {text}"
try:
from core.interface.gui import MainWindow
MainWindow.add_log(formatted_text)
from interface.gui import MainWindow
if not MainWindow.add_log(formatted_text):
state.log_buffer.append(formatted_text)
except Exception:
global _log_buffer
_log_buffer.append(formatted_text)
state.log_buffer.append(formatted_text)
if state.debug or printAnyways:
print(formatted_text)
+54
View File
@@ -0,0 +1,54 @@
from network.direct_download import add_direct_download
from network.libtorrent_wrapper import add_magnet
from PySide6.QtWidgets import QTableWidgetItem
from utils.logging.logs import add_download_log
from utils.general.wrappers import run_thread
from network.libtorrent_int import add_seed
from utils.logging.logs import consoleLog
from utils.data.state import state
from PySide6.QtCore import Qt
from typing import Optional
import threading
def download_selected(items: list[QTableWidgetItem]):
if not items:
consoleLog("No item selected for download.")
return
seen = set()
for item in items:
if item.column() != 0:
continue
post_idx = item.data(Qt.ItemDataRole.UserRole)
if post_idx is None:
post_idx = item.row()
if post_idx not in seen:
seen.add(post_idx)
post = state.posts[post_idx]
consoleLog(f"Downloading {post.get('title', 'Unknown')}")
run_thread(threading.Thread(target=run_download, args=(post,)))
def run_download(post, headers: Optional[dict] = None):
link_method = state.trackers[state.currenttracker].get_download_link
ismagnet = state.trackers[state.currenttracker].is_magnet
result = link_method(post)
if ismagnet:
link = result
add_magnet(link)
add_download_log(post.get("title", "Unknown"), "", link, False)
else:
link, link_headers = result if isinstance(result, tuple) else (result, None)
final_headers = headers or link_headers
add_direct_download(link, post.get("title", "Unknown"), headers=final_headers, single_threaded=final_headers is not None)
def run_download_direct(magnet_uri, title="Direct Download"):
consoleLog(f"Magnet: {title}")
add_magnet(magnet_uri)
add_download_log(title, "", magnet_uri, False)
def seed_magnet(magnet_uri, file_path):
consoleLog(f"Seeding: {magnet_uri[:60]}")
add_seed(magnet_uri, file_path)
+41
View File
@@ -0,0 +1,41 @@
from utils.logging.logs import consoleLog
from utils.data.state import state
import requests
def get_updates():
url = f"https://api.github.com/repos/KeksPirates/SoftwareManager/releases/latest"
try:
response = requests.get(url, timeout=15)
except requests.RequestException as e:
consoleLog(f"Failed to fetch releases: {e}")
return None, None
if response.status_code != 200:
consoleLog(f"Failed to fetch releases: {response.status_code}")
return None, None
release = response.json()
latest_version = release.get("name") or release.get("tag_name")
assets = release.get("assets", [])
release_assets = []
if latest_version != state.version:
consoleLog(f"New release available: {latest_version}")
if assets:
consoleLog("Assets:")
for asset in assets:
consoleLog(f"{asset['name']}")
release_assets.append(dict(
name=asset['name'],
url=asset['browser_download_url'],
hash=asset.get('digest')
))
return release_assets, latest_version
else:
return None, None
else:
consoleLog("Already up-to-date.")
return None, None
+96
View File
@@ -0,0 +1,96 @@
from network.direct_download.handle import DirectDownloadHandle
from utils.logging.logs import consoleLog
from utils.data.state import state
from PySide6.QtCore import Qt
from PySide6 import QtWidgets
import libtorrent as lt
import subprocess
import tempfile
import hashlib
import time
import sys
import os
def _verify_hash(file_path: str, expected_hash: str) -> bool:
sha256 = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256.update(chunk)
return f"sha256:{sha256.hexdigest()}" == expected_hash
def download_update(assets: list):
filename = None
setup_hash = None
url = None
for asset in assets:
if "-windows-setup.exe" in asset["name"]:
filename = asset["name"]
setup_hash = asset["hash"]
url = asset["url"]
break
if not filename:
consoleLog("Error: No Windows installer found in release assets")
return
installer_path = os.path.join(tempfile.gettempdir(), filename)
progress = QtWidgets.QProgressDialog("Downloading update... (0.0 MB/s)", None, 0, 100)
progress.setWindowTitle("Updating")
progress.setWindowModality(Qt.WindowModality.ApplicationModal)
progress.setCancelButton(None)
progress.setMinimumDuration(0)
progress.setAutoClose(False)
progress.setAutoReset(False)
progress.setValue(0)
progress.show()
QtWidgets.QApplication.processEvents()
original_limit = state.down_speed_limit
state.down_speed_limit = 0
handle = DirectDownloadHandle(url, filename, tempfile.gettempdir())
handle.start()
while True:
QtWidgets.QApplication.processEvents()
status = handle.status()
if status.error:
state.down_speed_limit = original_limit
progress.close()
consoleLog(f"Update download failed: {status.error}")
return
if status.total_wanted > 0:
pct = int(status.total_wanted_done * 100 / status.total_wanted)
speed_mb = status.download_rate / (1024 * 1024)
progress.setValue(pct)
progress.setLabelText(f"Downloading update... ({speed_mb:.1f} MB/s)")
if status.state == lt.torrent_status.seeding:
break
time.sleep(0.1)
state.down_speed_limit = original_limit
if setup_hash:
if _verify_hash(installer_path, setup_hash):
consoleLog(f"Successfully validated installer hash ({setup_hash})")
else:
progress.close()
consoleLog("Error: Invalid file hash, file may be corrupted")
sys.exit(0)
else:
consoleLog("Skipping hash verification (no hash found for release)")
progress.setLabelText("Installing update...")
progress.setValue(100)
QtWidgets.QApplication.processEvents()
subprocess.Popen([installer_path, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/SP-", "/CLOSEAPPLICATIONS"])
time.sleep(1)
sys.exit(0)