Compare commits

..

61 Commits

Author SHA1 Message Date
Vxrtrauter 912d9d8c00 fix: Add shutdown helper call and modify exit behavior in updater 2026-04-07 02:26:15 +02:00
Vxrtrauter ce2cd9cdd8 Update release workflow to use RELEASE_TOKEN instead of GITHUB_TOKEN 2026-04-07 01:48:59 +02:00
Vxrtrauter 5e6e96a8cd Add dynamic release name using short SHA in release workflow 2026-04-07 01:45:26 +02:00
Vxrtrauter 8161e28d67 Merge branch 'main' of https://github.com/KeksPirates/SoftwareManager 2026-04-07 01:13:45 +02:00
Vxrtrauter 6253aa0eb0 Add release workflow and simplify dev workflow
Remove automatic main push trigger from dev workflow (now only workflow_dispatch) and add a new release workflow (.github/workflows/release.yml). The release workflow triggers on tag pushes, builds artifacts for Linux, Windows and macOS using a matrix, sets up Python/uv, installs build tools (Nuitka/PyInstaller/UPX where needed), injects a short commit SHA into build_info.json, packages artifacts, uploads them, generates checksums, and creates a GitHub release with the built assets.
2026-04-07 01:13:38 +02:00
shayaa c063c226db Merge pull request #53 from KeksPirates/fix/image-resizing
Fix/image resizing, add accent color support
2026-04-07 01:09:02 +02:00
Vxrtrauter 929fdf69e5 Update and revert image path in settings dialog
Capture the original state.image_path at dialog start, connect the Image Path QLineEdit's textChanged signal to update state.image_path immediately, and restore the original image_path if the settings dialog is cancelled. This matches existing undo behavior for width/offset/opacity and prevents partial image path edits from persisting when the dialog is rejected.
2026-04-07 01:07:22 +02:00
shayaa 48b9919b7a Merge pull request #52 from KeksPirates/feat/docs
Feat/docs
2026-04-06 23:15:04 +02:00
Vxrtrauter c43fd006fd Add About page and expand docs content/styles
Add a new docs/about page (docs/docs.html) with site header/footer and basic About layout. Expand the Docs tab in docs/index.html with a Features section, General Information, and Sources table (including a link to the server repo and usage notes). Update docs/main.css to add styles for .sources-text, .docs-grid, .docs-tips and headings to support the new documentation layout and improve visual presentation.
2026-04-05 08:27:41 +02:00
Vxrtrauter 12506dd1df feat: Add customizable accent color setting
Introduce an accent color preference across the app: add a settings UI input (with restart note), persist it to AppState and config (read/write), and accept it in save_settings. Integrate the value into theming: compute a semi-transparent selection color, apply it to table selection styles, and pass it to qdarktheme as the primary color (including when window transparency is enabled). Also apply the accent to the tracker combo selection styling as a UI polish.
2026-04-05 06:57:00 +02:00
Vxrtrauter 9e3d1cf90a feat: Add image position presets and sliders
Introduce image positioning presets and UI improvements for image overlay control. image.py: compute overlay coordinates for top-left/top-right/bottom-left/bottom-right/center positions and respect corner offset and opacity. settings.py: replace spinboxes with horizontal sliders (with value labels and custom style), add Wallpaper Mode checkbox and Position combo, wire controls to state and include them in the settings tab; ensure dialog rollback restores new temp settings. config/config.py & utils/settings.py: persist and load new image_as_wallpaper and image_position settings and extend save_settings to accept them. data/state.py: add new state fields and properties for image_position, custom position flags and x/y coordinates; change default image_as_wallpaper to false. These changes enable preset positioning, improved UX for sizing/offset/opacity, and persistence of new image options.
2026-04-05 06:12:54 +02:00
Vxrtrauter 2ad02435a1 feat/fix: "wallpaper" transparency handling
Introduce dynamic wallpaper transparency support for the image overlay. Adds QStackedWidget import and a new _set_wallpaper_transparency method that toggles WA_TranslucentBackground on the main window and key child widgets, updates stylesheets (with originals preserved/restored), and uses darkdetect to adapt combo popup background. Adds state fields (_wallpaper_active, _original_stylesheets), sets overlay opacity from state, and calls the transparency toggler when loading/hiding the overlay to avoid redundant updates.
2026-04-05 05:22:24 +02:00
Vxrtrauter f11837217c feat: add basic "wallpaper" functionality 2026-04-05 04:02:10 +02:00
Vxrtrauter 1a7de70c0b feat: add image opacity, config migration, settings
Add image overlay opacity support and related UI/settings, plus robust config migration and error handling.

Key changes:
- UI: image overlay now uses QGraphicsOpacityEffect, hides when disabled, and updates opacity on state changes (src/interface/dialogs/image.py).
- New NotificationPopup dialog for user alerts (src/interface/dialogs/notificationpopup.py).
- Settings dialog: added Image Opacity control, adjusted labels, persist/rollback behavior on cancel, and include opacity when saving (src/interface/dialogs/settings.py).
- State: added properties for image_opacity and image_enabled with change notifications; renamed image_width setter param for clarity (src/utils/data/state.py).
- Config: switch from config.yml to config.ini, implement one-time migration, typed read/write of values, validation/error handling, automatic backups of corrupted files and user notification (src/utils/config/config.py).
- Settings save: accept and persist image_opacity (src/utils/config/settings.py).
- Startup: initialize QApplication earlier and pass app to run_gui (src/main.py).
- Minor: removed an extra blank line in log handler and small import cleanups.

These changes enable adjustable overlay transparency, improve config reliability, and surface errors to users while preserving/backuping old configs.
2026-04-04 07:36:03 +02:00
Vxrtrauter a3ff23147f Restore image settings on cancel; live sync
Save original image_width/image_offset when opening Settings and restore them if the dialog is cancelled. Add valueChanged handlers to immediately update state.image_width and state.image_offset, enforce an image width maximum of 2500, and refactor Save wiring to a dedicated handler that calls save_settings(dialog.accept, ...). Connect dialog.finished to restore temp values on rejection and clean up dialog layout/logic.
2026-04-04 05:36:16 +02:00
Vxrtrauter fa4e753861 fix: braces in api docs 2026-04-04 04:54:58 +02:00
Vxrtrauter b0bbb98927 Convert docs to single-page tabs and update styles
Merge API and About pages into a single SPA-style index: removed docs/about.html and docs/api.html and moved their content into docs/index.html as tabbed sections (start, api, docs, notfound). Add client-side routing and history handling via a new script to support deep links and pushState, plus a Netlify-style docs/_redirects rule to serve index for all routes. Update styles in docs/main.css to target the new tab UI (.tab, .tablinks, .logo-text) and add styles for the 404 view. Minor markup and layout tweaks (images, buttons, response examples) to match the consolidated page.
2026-04-04 04:52:40 +02:00
KeksNino fda45efcb5 refactor: create a helper function to create widgets in settings dialog 2026-04-04 03:52:20 +02:00
KeksNino 3de2110a3e fix: redraws pixmap after changing image settings so restart is no longer required 2026-04-04 02:51:45 +02:00
Vxrtrauter cb7e74a224 Add configurable image overlay settings
Introduce configurable image overlay options and persist them. Added new state fields (image_width, image_offset, image_enabled) with defaults and wired them into the image overlay: the image display now respects the enabled flag, uses image_width for scaling and image_offset for placement. Extended the Settings dialog (resized, API field sizing) with a new Image tab containing enable checkbox, width and offset spinboxes, and included these values when saving. Updated config read/write to include an Image section so settings persist across runs and extended save_settings to accept image_width and image_offset. Minor import/order cleanup in image dialog.
2026-04-04 02:06:06 +02:00
Vxrtrauter 5fe3502bd6 add initial website structure 2026-03-23 21:36:21 +01:00
KeksNino b59768bcff fix image resizing and general code of image class 2026-03-22 01:41:16 +01:00
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
81 changed files with 3815 additions and 2124 deletions
+2 -11
View File
@@ -1,9 +1,6 @@
name: Build Executables (Dev)
on:
push:
branches:
- main
workflow_dispatch:
inputs:
version:
@@ -106,7 +103,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 +122,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 +132,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 +148,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 +278,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 }}
+237
View File
@@ -0,0 +1,237 @@
name: Release
on:
push:
tags:
- "*"
permissions:
contents: write
env:
PYTHON_VERSION: "3.12"
APP_NAME: "SoftwareManager"
jobs:
build:
name: Build ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-22.04
artifact_name: SoftwareManager-${{ github.sha }}-linux
- os: windows-latest
artifact_name: SoftwareManager-${{ github.sha }}-windows
- os: macos-latest
artifact_name: SoftwareManager-${{ github.sha }}-macos
runs-on: ${{ matrix.os }}
outputs:
short_sha: ${{ steps.version.outputs.short_sha }}
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}"
echo "VERSION=$SHORT_SHA" >> $GITHUB_ENV
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
- name: Inject commit hash into code
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: Rename 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
- 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: Build Installer (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
SETUP_NAME="SoftwareManager-${{ env.VERSION }}-windows-setup"
iscc //DMyAppVersion="${{ env.VERSION }}" \
//DMySourceDir="dist-final\\${{ env.APP_NAME }}" \
//DMyOutputDir="dist-final" \
//DMyOutputFilename="${SETUP_NAME}" \
installer.iss
echo "SETUP_NAME=${SETUP_NAME}" >> $GITHUB_ENV
- name: Upload build artifact (Windows)
if: runner.os == 'Windows'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.os }}-build
path: |
dist-final/${{ matrix.artifact_name }}.zip
dist-final/${{ env.SETUP_NAME }}.exe
retention-days: 1
- name: Upload build 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: 1
release:
name: Create Release
needs: build
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: artifacts
- name: Prepare release files
run: |
mkdir -p release
SHORT_SHA="${{ needs.build.outputs.short_sha }}"
cp artifacts/ubuntu-22.04-build/SoftwareManager-*-linux release/SoftwareManager-${SHORT_SHA}-linux
cp artifacts/windows-latest-build/SoftwareManager-*-windows.zip release/SoftwareManager-${SHORT_SHA}-windows.zip
cp artifacts/windows-latest-build/SoftwareManager-*-windows-setup.exe release/SoftwareManager-${SHORT_SHA}-windows-setup.exe
cp artifacts/macos-latest-build/SoftwareManager-*-macos release/SoftwareManager-${SHORT_SHA}-macos
chmod +x release/SoftwareManager-*-linux release/SoftwareManager-*-macos
- name: Generate checksums
run: |
cd release
sha256sum * > SHA256SUMS.txt
- name: Create Release
uses: softprops/action-gh-release@v2
with:
name: ${{ needs.build.outputs.short_sha }}
generate_release_notes: true
files: |
release/SoftwareManager-${{ needs.build.outputs.short_sha }}-linux
release/SoftwareManager-${{ needs.build.outputs.short_sha }}-windows.zip
release/SoftwareManager-${{ needs.build.outputs.short_sha }}-windows-setup.exe
release/SoftwareManager-${{ needs.build.outputs.short_sha }}-macos
release/SHA256SUMS.txt
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_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
View File
@@ -0,0 +1 @@
/* /index.html 200
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

+238
View File
@@ -0,0 +1,238 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="main.css"/>
<title>SoftwareManager - Main</title>
<!-- <link rel="icon" type="image/x-icon" href="/assets/icons/favicon.ico"> -->
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;700&display=swap" rel="stylesheet">
</head>
<body>
<header>
<div class="logo-text">
<h1>KeksPirates</h1>
</div>
<nav class="tab">
<a class="tablinks" onclick="openTab(event, 'start')" id="defaultOpen">Home</a>
<a class="tablinks" onclick="openTab(event, 'api')">API</a>
<a class="tablinks" onclick="openTab(event, 'docs')">Docs</a>
<!-- <a class="tablinks" onclick="openTab(event, 'notfound')">404</a> -->
</nav>
</header>
<main>
<div id="start" class="tabcontent">
<div class="introduction-frame">
<div class="introduction-text inline-block">
<h1 class="hometitle"><br><span style="color: #db65d5">SoftwareManager</span></h1>
<p class="title_subtext">Simply search and download software from various sources</p>
<a href="https://github.com/KeksPirates/SoftwareManager" class="btn_big github"><img class="github-button" src="assets/icons/GitHub_Invertocat_White.png">GitHub</a>
<a href="https://github.com/KeksPirates/SoftwareManager/releases/latest" class="btn_big download"><img class="download-button" src="assets/icons/download_icon_black.png">Download</a>
</div>
<div class="introduction-image inline-block">
<div class="softwaremanager-scene">
<img class="softwaremanager-image" src="assets/softwaremanager/search_tab_rounded.png">
</div>
<img class="softwaremanager-image-2" src="assets/softwaremanager/search_tab_rounded.png">
</div>
</div>
</div>
<div id="api" class="tabcontent">
<h1 class="title"><span class="json-string">API Documentation</span> </h1>
<p>This API allows you to search for posts on rutracker.</p>
<div class="base-information-frame">
<h3>API URL</h3>
<p class="api-url">https://api.michijackson.xyz/</p>
<br>
<h3>Endpoint</h3>
<p class="endpoint-url"><span class="request-type">GET</span><span class="endpoint-type-container">/search?q=<span class="endpoint-parameter">{query}</span></span></p>
<div class="endpoint-parameters-container">
<div class="endpoint-parameters-header">
<p>Parameter</p>
<p>Type</p>
<p>Required</p>
<p>Description</p>
</div>
<div class="endpoint-parameters-content">
<p class="endpoint-parameter">q</p>
<p class="endpoint-type">String</p>
<p class="endpoint-required">True</p>
<p class="endpoint-description">Search query</p>
</div>
</div>
</div>
<div class="request-frame">
<h2>Example Request</h2>
<div class="request-type-container">
<p><span class="request-type">GET</span> <span class="request-type-subcontainer">api.michijackson.xyz/search?q=keks</span></p>
</div>
</div>
<div class="api-response-frame">
<h3>Response</h3>
<pre class="api-response"><span class="json-punctuation">{</span>
"cached"<span class="json-punctuation">:</span> <span class="json-boolean">false</span><span class="json-punctuation">,</span>
"count"<span class="json-punctuation">:</span> <span class="json-integer">1</span><span class="json-punctuation">,</span>
"data"<span class="json-punctuation">:</span> <span class="json-punctuation">[</span>
<span class="json-punctuation">{</span>
"author"<span class="json-punctuation">:</span> <span class="json-string">"Viterrson"</span><span class="json-punctuation">,</span>
"id"<span class="json-punctuation">:</span> <span class="json-integer">1</span><span class="json-punctuation">,</span>
"leechers"<span class="json-punctuation">:</span> <span class="json-integer">0</span><span class="json-punctuation">,</span>
"seeders"<span class="json-punctuation">:</span> <span class="json-integer">3</span><span class="json-punctuation">,</span>
"title"<span class="json-punctuation">:</span> <span class="json-string">"(Hard - Rock) Keks - Keks - 1983, MP3, 320 kbps"</span><span class="json-punctuation">,</span>
"url"<span class="json-punctuation">:</span> <span class="json-string">"https://rutracker.org/forum/viewtopic.php?t=3835559"</span>
<span class="json-punctuation">}</span>
<span class="json-punctuation">],</span>
"query"<span class="json-punctuation">:</span> <span class="json-string">"keks"</span><span class="json-punctuation">,</span>
"success"<span class="json-punctuation">:</span> <span class="json-boolean">true</span>
<span class="json-punctuation">}</span></pre>
</div>
</div>
<div id="docs" class="tabcontent">
<h1 class="title"><span style="color: #db65d5">Docs</span></h1>
<p>SoftwareManager is a desktop app to search, browse, and download software & games from multiple sources in one place.</p>
<div class="base-information-frame">
<h2>Features</h2>
<div class="endpoint-parameters-container docs-tips">
<p class="sources-text">• Searching & Downloading Software from various pages</p>
<p class="sources-text">• Network Interface Binding</p>
<p class="sources-text">• Simple UI, Speed Limiting etc.</p>
<p class="sources-text">• No Logins required</p>
</div>
</div>
<div class="base-information-frame">
<h2>General Information</h2>
<div class="endpoint-parameters-container docs-tips">
<p class="sources-text">• Rutracker is the default source since it is the largest index overall.</p>
<p class="sources-text">• Use m0nkrus if you want Adobe software, it directly gets the posts from his Telegram channel.</p>
<p class="sources-text">• Torrents start seeding after completion and start seeding on application start.</p>
<p class="sources-text">• MacOS Builds haven't been properly tested - We're thankful to receive feedback.</p>
<p class="sources-text">• If you want to host your own server, clone our <a href="https://github.com/KeksPirates/SoftwareManager-Server" style="color: #db65d5;">server repository</a>, enter your cookie for rutracker.org and run server.py. Detailed Instructions on how to get your rutracker cookie are in the README of the server repo.</p>
<p class="sources-text">• SoftwareManager is still in development and most likely contains issues. Please report any bugs you find via GitHub. Contributions are also greatly appreciated.</p>
</div>
</div>
<div class="base-information-frame">
<h2>Sources</h2>
<br>
<p>Use the dropdown menu in the top right to select your source.</p>
<div class="endpoint-parameters-container docs-grid">
<div class="endpoint-parameters-header">
<p>Source</p>
<p>Category</p>
<p>Notes</p>
</div>
<div class="endpoint-parameters-content">
<p class="sources-text">RuTracker</p>
<p class="sources-text">Software & games</p>
<p class="sources-text">Has an extremely large amount of Software. Relies on an API for searching.</p>
</div>
<div class="endpoint-parameters-content">
<p class="sources-text">UzTracker</p>
<p class="sources-text">Software & games</p>
<p class="sources-text">Similar to RuTracker, smaller. Use this as fallback if RuTracker is down or there's API issues.</p>
</div>
<div class="endpoint-parameters-content">
<p class="sources-text">m0nkrus</p>
<p class="sources-text">Adobe Software</p>
<p class="sources-text">Lots of curated releases. Focused on mainly professional Software.</p>
</div>
<div class="endpoint-parameters-content">
<p class="sources-text">SteamRip</p>
<p class="sources-text">Games</p>
<p class="sources-text">Currently very experimental due to download link retrieveal. May not always work as expected.</p>
</div>
</div>
</div>
</div>
<div id="notfound" class="tabcontent">
<div class="notfound-frame">
<h1 class="notfound-title">404</h1>
<br>
<h1 class="notfound-subtext">(╯°□°)╯︵ ┻━┻</h1>
<a class="btn_big download" onclick="openTab(event, 'start')" style="cursor: pointer;">Go Home</a>
</div>
</div>
</main>
<footer>
<small>KeksPirates - SoftwareManager</small>
</footer>
<script>
function openTab(evt, tabName, pushToHistory = true) {
var i, tabcontent, tablinks;
tabcontent = document.getElementsByClassName("tabcontent");
for (i = 0; i < tabcontent.length; i++) {
tabcontent[i].style.display = "none";
}
tablinks = document.getElementsByClassName("tablinks");
for (i = 0; i < tablinks.length; i++) {
tablinks[i].className = tablinks[i].className.replace(" active", "");
}
document.getElementById(tabName).style.display = "block";
let activeButton = document.querySelector(`.tablinks[onclick*="${tabName}"]`);
if (activeButton) {
activeButton.className += " active";
}
if (pushToHistory) {
let path = "/";
if (tabName === 'api') path = "/api";
if (tabName === 'docs') path = "/docs";
history.pushState({ tab: tabName }, "", path);
}
}
window.addEventListener('popstate', function(event) {
if (event.state && event.state.tab) {
openTab(null, event.state.tab, false);
} else {
openTab(null, 'start', false);
}
});
window.onload = function() {
let currentPath = window.location.pathname;
if (currentPath === '/api') {
openTab(null, 'api', false);
} else if (currentPath === '/docs') {
openTab(null, 'docs', false);
} else {
history.replaceState({ tab: 'start' }, "", "/");
openTab(null, 'start', false);
}
};
</script>
</body>
</html>
+476
View File
@@ -0,0 +1,476 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--main-font: "Manrope", sans-serif;
}
body {
font-family: system-ui, sans-serif;
line-height: 1.5;
display: flex;
flex-direction: column;
min-height: 100vh;
background: #0f1011;
}
header {
height: 65px;
background: #1a1c1d;
color: #fff;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
}
.tab a {
display: inline-block;
padding: 0.4rem 0.8rem;
margin-left: 0.5rem;
text-decoration: none;
color: #fafafa;
border-radius: 8px;
box-shadow: 0 0 6px 1px #dd06cb00;
font-family: var(--main-font);
transition: background 0.5s, color 0.5s;
cursor: pointer;
}
nav a:hover {
background: #db65d517;
color: #ffd1fd;
}
nav a.active {
background: #db65d55d;
color: #fdd2fc;
}
.logo-text h1 {
font-family: var(--main-font);
font-size: 1.2rem;
font-weight: 700;
letter-spacing: -0.02em;
background-image: linear-gradient(147deg, #da84d5, #db65d5);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
main {
font-family: var(--main-font);
color: #fafafa;
padding: 3rem;
}
main h1.hometitle {
font-size: 4.0rem;
line-height: 4rem;
margin-top: 1.8rem;
}
main p.title_subtext {
margin-top: 0.5rem;
font-size: 1.2rem;
}
.softwaremanager-scene {
perspective: 700px;
width: fit-content;
}
main img.softwaremanager-image {
margin-top: 0rem;
user-select: none;
transform: rotateX(8deg) rotateY(-12deg) rotateZ(2deg) scale(0.85) translateX(-1rem);
box-shadow: 15px 20px 15px #0f0f0f;
/* transition: transform 0.4s ease-out; */
}
/* main img.softwaremanager-image:hover {
transform: rotateX(8deg) rotateY(-13deg) rotateZ(2deg) scale(0.86) translateX(-1rem);
} */
/* transform: rotateX(8443deg) rotateY(-14234233deg) rotateZ(2deg) scale(4.863) translateX(-133rem); lmao */
main .introduction-text {
position: relative;
text-align: right;
margin-right: -3rem;
margin-top: 3rem;
z-index: 2;
user-select: none;
}
.introduction-image {
position: relative;
z-index: 1;
}
.inline-block {
display: inline-block;
vertical-align: top;
}
main .introduction-frame {
text-align: center;
position: relative;
font-size: 0
}
main .introduction-frame > * {
font-size: 1rem;
}
main .btn_big {
font-family: var(--main-font);
background: #1a1c1d;
padding: 0.7rem 1.4rem;
margin-top: 1.5rem;
border: none;
border-radius: 10px;
align-items: center;
display: inline-flex;
color: #fafafa;
text-decoration: none;
font-weight: bold;
user-select: none;
}
main .btn_big img {
height: 20px;
width: auto;
margin-right: 0.5rem;
}
main .btn_big.github {
margin-right: 1rem;
transition: background 0.5s;
}
main .btn_big.github:hover {
background: #18191a;
}
main .btn_big.download {
background: #ca62c5;
color: #0f1011;
transition: background 0.5s;
}
main .btn_big.download:hover {
background: #b551b0;
}
main .introduction-text h1,
main .introduction-text p {
user-select: text;
}
.softwaremanager-image-2 {
display: none;
}
footer {
color: #585858;
font-family: var(--main-font);
align-self: center;
padding: 1rem;
position: fixed;
bottom: 0;
user-select: none;
}
::selection {
background: #db65d528;
}
.base-information-frame {
background: #1a1c1d;
padding: 1rem;
border-radius: 10px;
margin-top: 1rem;
}
.program-preview {
box-sizing: inherit;
background: #1a1c1d;
width: auto;
height: 20rem;
border-radius: 10px;
}
pre.api-response {
font-family: monospace;
background: #151718;
padding: 1rem;
border-radius: 10px;
margin-top: 1rem;
}
.api-response-frame {
background: #1a1c1d;
padding: 1rem;
border-radius: 10px;
margin-top: 1rem;
}
.request-type {
color: #fdd2fc;
padding: 1px 6px;
background: #db65d55d;
border-radius: 6px;
}
.request-type-container {
background: #1a1c1d;
padding: 1rem;
border-radius: 10px;
margin-top: 1rem;
}
.endpoint-type-container {
background: #151718;
padding: 1rem;
border-radius: 10px;
margin-top: 1rem;
}
.request-type-subcontainer {
background: #1a1c1d;
padding: 0.5rem;
border-radius: 10px;
margin-top: 1rem;
}
.api-url {
background: #151718;
padding: 1rem;
border-radius: 10px;
margin-top: 1rem;
}
.endpoint-url {
background: #151718;
padding: 1rem;
border-radius: 10px;
margin-top: 1rem;
color: #8f8d8d
}
.endpoint-parameters-container {
background: #151718;
padding: 1rem;
border-radius: 10px;
margin-top: 1rem;
color: #8f8d8d;
}
.endpoint-parameters-header {
margin-top: -0.3rem;
margin-bottom: 0.9rem;
font-size: 0.9rem;
grid-template-columns: 9rem 9rem 9rem 9rem;
display: grid;
}
.endpoint-parameters-content {
font-size: 1rem;
grid-template-columns: 9rem 9rem 9rem 9rem;
display: grid;
}
.request-frame {
margin-top: 1rem;
}
.endpoint-parameter { color: #ffab4a; }
.endpoint-type { color: #db65d5 }
.endpoint-required { color: #66ff80}
.endpoint-description { color: #b1b0b0 }
.json-punctuation { color: #5c5c5c; }
.json-string { color: #db65d5; }
.json-boolean { color: #ffab4a; }
.json-integer { color: #66ff80; }
.sources-text {
color: #ffffff;
font-size: 1rem;
}
.docs-grid .endpoint-parameters-header,
.docs-grid .endpoint-parameters-content {
grid-template-columns: 10rem 14rem 1fr;
}
.docs-grid .endpoint-parameters-header p {
color: #8f8d8d;
}
.docs-grid .endpoint-parameters-content {
padding: 0.5rem 0;
border-top: 1px solid #ffffff0a;
}
.docs-grid .endpoint-parameters-content:first-of-type {
border-top: none;
}
.docs-tips .sources-text {
padding: 0.25rem 0;
}
.base-information-frame h2 {
color: #db65d5;
}
.notfound-frame {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 75vh;
text-align: center;
gap: 10px;
}
.notfound-title {
font-size: 4.0rem;
line-height: 4rem;
margin-top: 1.8rem;
background-image: linear-gradient(135deg, #c574c1, #b851b2);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
.notfound-subtext {
font-size: 2rem;
}
@media(max-width: 560px) {
header {
padding: 1rem;
}
main {
margin: 0rem;
padding: 1rem;
}
main .introduction {
display: block;
margin-top: -1rem;
padding: 1rem;
}
main .introduction-text {
position: relative;
text-align: center;
margin-top: 0rem;
z-index: 2;
user-select: none;
margin-right: 0rem;
padding-bottom: 2rem;
}
main h1.hometitle {
font-size: 2rem;
line-height: 2.2rem;
text-align: left;
}
main p.title_subtext {
text-align: left;
}
.introduction-image {
display: none;
}
main .btn_big {
display: block;
margin: 1rem auto;
}
main .btn_big.github{
margin-right: 0rem;
}
}
@media (min-width: 561px) and (max-width: 1393px) {
header {
padding: 1rem;
}
main {
margin: 0rem;
padding: 1rem;
}
main .introduction {
display: flex;
margin-top: -1rem;
padding: 1rem;
flex-direction: column;
align-items: center;
}
main .introduction-text {
position: relative;
text-align: center;
margin-top: 0rem;
z-index: 2;
user-select: none;
margin-left: 2rem;
margin-right: 2rem;
padding-bottom: 2rem;
}
main h1.hometitle {
font-size: 2rem;
line-height: 2.2rem;
text-align: center;
}
main p.title_subtext {
text-align: center;
}
.softwaremanager-image {
display: none;
}
.softwaremanager-image-2 {
display: block;
width: 100%;
height: auto;
margin-left: auto;
margin-right: auto;
}
.introduction-image {
width: 100%;
max-width: 50rem;
user-select: none;
}
main .introduction .inline-block {
display: block;
}
}
+9
View File
@@ -66,6 +66,15 @@ Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Code]
function PrepareToInstall(var NeedsRestart: Boolean): String;
var
ResultCode: Integer;
begin
Exec('taskkill', '/F /IM {#MyAppExeName}', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Result := '';
end;
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
Filename: "{app}\{#MyAppExeName}"; Flags: nowait skipifnotsilent
View File
View File
-87
View File
@@ -1,87 +0,0 @@
from core.utils.data.state import state
from core.utils.data.tracker import get_magnet_link
from bs4 import BeautifulSoup
from typing import Dict
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 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()
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
def get_magnet(post: Dict):
return get_magnet_link(post["url"])
Metadata = {
"name" : "m0nkrus",
"headers" : ["Post Title", "Author"],
"scrapeFunc" : scrape_m0nkrus,
"linkFunc" : get_magnet,
"isMagnet" : True,
}
def init_m0nkrus():
state.trackers.update({Metadata["name"] : Metadata})
-31
View File
@@ -1,31 +0,0 @@
from core.utils.logging.loghandler import consoleLog
import requests
import re
def scrape_gofile(url):
filetoken = re.findall(r"(?<=https...gofile.io\/d\/).*", url)[0]
session = requests.Session()
acc = session.post("https://api.gofile.io/accounts")
token = acc.json()["data"]["token"]
headers = {
"Authorization": f"Bearer {token}",
"X-Website-Token": "4fd6sg89d7s6", # Maybe make this dynamic in the future
}
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()]
return temp[child[0]]["link"]
except:
consoleLog("gofile didnt auth you, either the api has changed\n or you sent to many requests, please try again later.\n If it still doesnt work please open an issue on Github.")
-52
View File
@@ -1,52 +0,0 @@
import requests
from core.utils.data.state import state
from core.utils.logging.logs import consoleLog
from core.utils.data.tracker import get_magnet_link
from core.utils.network.jsonhandler import split_data, format_data
from typing import Dict
def scrape_rutracker(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_magnet(post: Dict):
_, post_links, _, _, _ = format_data([post])
return get_magnet_link(post_links[0])
Metadata = {
"name" : "rutracker",
"headers" : ["Post Title", "Author", "Seeders", "Leechers"],
"scrapeFunc" : scrape_rutracker,
"linkFunc" : get_magnet,
"isMagnet" : True,
}
def init_rutracker():
state.trackers.update({Metadata["name"] : Metadata})
-123
View File
@@ -1,123 +0,0 @@
from core.data.scrapers.provider.buzzheavier import scrape_buzzheavier
from core.data.scrapers.provider.gofile import scrape_gofile
from core.utils.logging.logs import consoleLog
from core.utils.data.state import state
from bs4 import BeautifulSoup
from typing import Dict
import webbrowser
import requests
import re
import time
cache = {
"data": [],
"last_fetched": 0
}
cache_expiry = 300
def get_Metadata():
return Metadata
def scrape_steamrip_links():
current_time = time.time()
if cache["data"] != [] and (current_time - cache["last_fetched"] < cache_expiry):
return 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(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
if pure[2] == "v":
download_links[2] = "https:" + pure
if pure[2] == "m":
download_links[3] = "https:" + pure
ret = []
for link in download_links:
if len(link) != 1:
ret.append(link)
cache["data"] = ret
return ret
def get_download_link(post: Dict):
url = post["url"]
links = scrape_steamrip_game_downloads(url)
best = ""
for link in links:
if link[0] == "h":
best = link
break
if links.index(best) == 0:
return scrape_buzzheavier(best)
elif links.index(best) == 1:
return scrape_gofile(best)
else:
consoleLog("Unable to retrieve download link due to captcha, launching browser...")
webbrowser.open(best)
return None
def filter_steamrip(query: str):
games = scrape_steamrip_links()
filtered_games = [
game for game in games
if query.lower() in game["title"].lower()
]
return filtered_games
Metadata = {
"name" : "steamrip",
"headers" : ["Game"],
"scrapeFunc" : filter_steamrip,
"linkFunc" : get_download_link,
"isMagnet" : False,
}
def init_steamrip():
state.trackers.update({Metadata["name"] : Metadata})
-55
View File
@@ -1,55 +0,0 @@
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
from core.utils.logging.logs import consoleLog
from core.utils.data.state import state
from core.utils.data.tracker import get_magnet_link
from typing import Dict
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,
author=author,
url=url,
))
return posts
except requests.RequestException as e:
consoleLog(f"Failed to fetch {search_url}: {e}")
return None
def get_magnet(post: Dict):
return get_magnet_link(post["url"])
Metadata = {
"name" : "uztracker",
"headers" : ["Post Title", "Author"],
"scrapeFunc" : scrape_uztracker,
"linkFunc" : get_magnet,
"isMagnet" : True,
}
def init_uztracker():
state.trackers.update({Metadata["name"] : Metadata})
-290
View File
@@ -1,290 +0,0 @@
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 core.interface.utils.svghelper import svg_icon
from PySide6 import QtWidgets
from PySide6.QtCore import Qt, QSize
from PySide6.QtWidgets import (
QLineEdit,
QPushButton,
QWidget,
QVBoxLayout,
QDialog,
QLabel,
QHBoxLayout,
QSpinBox,
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):
consoleLog("Settings dialog opened")
dialog = QDialog(self)
dialog.setWindowTitle("Settings")
dialog.setFixedSize(700, 450)
dialog.setLayout(QVBoxLayout())
if state.window_transparency and platform.system() != "Windows" and dialog:
dialog.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
def close_settings():
dialog.reject()
update_checkbox_container = QWidget()
update_checkbox_layout = QHBoxLayout()
# ignore updates checkbox
if platform.system() == "Windows":
update_checkbox = QCheckBox()
update_checkbox_container.setLayout(update_checkbox_layout)
update_checkbox_layout.addWidget(QLabel("Ignore Updates: "))
update_checkbox_layout.addStretch()
update_checkbox.setChecked(state.ignore_updates)
update_checkbox.toggled.connect(lambda checked: setattr(state, 'ignore_updates', checked))
update_checkbox_layout.addWidget(update_checkbox)
# auto-resume downloads checkbox
autoresume_container = QWidget()
autoresume_layout = QHBoxLayout()
autoresume_checkbox = QCheckBox()
autoresume_container.setLayout(autoresume_layout)
autoresume_layout.addWidget(QLabel("Auto-Resume Downloads: "))
autoresume_layout.addStretch()
autoresume_checkbox.setChecked(state.autoresume)
autoresume_layout.addWidget(autoresume_checkbox)
# transparent window checkbox
transparent_window_container = QWidget()
transparent_window_layout = QHBoxLayout()
transparent_window_checkbox = QCheckBox()
transparent_window_container.setLayout(transparent_window_layout)
transparent_window_layout.addWidget(QLabel("Window Transparency (requires restart) (Linux/MacOS only): "))
transparent_window_layout.addStretch()
transparent_window_checkbox.setChecked(state.window_transparency)
transparent_window_checkbox.toggled.connect(lambda checked: setattr(state, 'window_transparency', checked))
transparent_window_layout.addWidget(transparent_window_checkbox)
##################
# 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.setLayout(api_url_layout)
api_url.setText(state.api_url)
#################
# 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.setLayout(download_path_layout)
download_path.setText(state.download_path)
def browse_download_path():
dir_path = QFileDialog.getExistingDirectory(dialog, "Select Download Directory", state.download_path)
if dir_path:
download_path.setText(dir_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;
}
""")
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 = QLineEdit()
image_path_layout.addWidget(QLabel("Image Path (requires restart, experimental):"))
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)
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()
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 #
##################
down_speed_limit_container = QWidget()
down_speed_limit_layout = QHBoxLayout()
down_speed_limit_layout.addWidget(QLabel("Max Download Speed (KiB, 0 for unlimited): "))
down_speed_limit = QSpinBox()
down_speed_limit.setMinimum(0)
down_speed_limit.setMaximum(10000000)
down_speed_limit.setValue(state.down_speed_limit)
down_speed_limit_container.setLayout(down_speed_limit_layout)
down_speed_limit_layout.addWidget(down_speed_limit)
down_speed_limit.setFixedWidth(180)
down_speed_limit.setFixedHeight(30)
up_speed_limit_container = QWidget()
up_speed_limit_layout = QHBoxLayout()
up_speed_limit_layout.addWidget(QLabel("Max Upload Speed (KiB, 0 for unlimited): "))
up_speed_limit = QSpinBox()
up_speed_limit.setMinimum(0)
up_speed_limit.setMaximum(10000000)
up_speed_limit.setValue(state.up_speed_limit)
up_speed_limit_container.setLayout(up_speed_limit_layout)
up_speed_limit_layout.addWidget(up_speed_limit)
up_speed_limit.setFixedWidth(180)
up_speed_limit.setFixedHeight(30)
######################
# CONNECTION CONFIGS #
######################
max_connections_container = QWidget()
max_connections_layout = QHBoxLayout()
max_connections_layout.addWidget(QLabel("Max Connections: "))
max_connections = QSpinBox()
max_connections.setMinimum(0)
max_connections.setMaximum(10000000)
max_connections.setValue(state.max_connections)
max_connections_container.setLayout(max_connections_layout)
max_connections_layout.addWidget(max_connections)
max_connections.setFixedWidth(180)
max_connections.setFixedHeight(30)
####################
# DOWNLOAD CONFIGS #
####################
max_downloads_container = QWidget()
max_downloads_layout = QHBoxLayout()
max_downloads_layout.addWidget(QLabel("Max Downloads: "))
max_downloads = QSpinBox()
max_downloads.setMinimum(0)
max_downloads.setMaximum(10000000)
max_downloads.setValue(state.max_downloads)
max_downloads_container.setLayout(max_downloads_layout)
max_downloads_layout.addWidget(max_downloads)
max_downloads.setFixedWidth(180)
max_downloads.setFixedHeight(30)
#####################
# INTERFACE BINDING #
#####################
interface_container = QWidget()
interface_layout = QHBoxLayout()
interface_label = QLabel("Network Interface:")
interface_layout.addWidget(interface_label)
interface_select = QComboBox()
interface_select.addItems(["None"] + state.interfaces)
target = state.bound_interface if state.bound_interface else "None"
index = interface_select.findText(target)
if index >= 0:
interface_select.setCurrentIndex(index)
else:
interface_select.setCurrentIndex(0)
interface_select.setFixedWidth(180)
interface_select.setFixedHeight(30)
interface_layout.addWidget(interface_select)
interface_container.setLayout(interface_layout)
###############
# SAVE/CANCEL #
###############
layout = QHBoxLayout()
save_btn = QPushButton("Save")
cancel_btn = QPushButton("Cancel")
save_btn.clicked.connect(lambda: save_settings(
close_settings,
api_url.text(),
download_path.text(),
down_speed_limit.value(),
up_speed_limit.value(),
image_path.text(),
autoresume_checkbox.isChecked(),
max_connections.value(),
max_downloads.value(),
interface_select.currentText()))
cancel_btn.clicked.connect(dialog.reject)
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, image_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)
dialog.layout().addWidget(self.tabs)
dialog.layout().addLayout(layout)
dialog.exec()
File diff suppressed because it is too large Load Diff
-27
View File
@@ -1,27 +0,0 @@
from PySide6.QtWidgets import QTableWidgetItem, QHeaderView
from core.utils.logging.logs import consoleLog
from core.data.scrapers.rutracker import init_rutracker
from core.data.scrapers.uztracker import init_uztracker
from core.data.scrapers.monkrus import init_m0nkrus
from core.data.scrapers.steamrip import init_steamrip
from core.utils.data.state import state
init_rutracker()
init_uztracker()
init_m0nkrus()
init_steamrip()
def return_pressed(self):
self.show_empty_results(False)
search_text = self.searchbar.text()
if search_text == "":
consoleLog("Error: Can't search for nothing")
return
consoleLog(f"User searched for: {search_text}")
tracker = state.trackers[state.currenttracker]
scrapefunc = tracker["scrapeFunc"]
state.posts = scrapefunc(search_text)
from core.interface.gui import MainWindow
MainWindow._instance.search_results_signal.emit(tracker["headers"])
-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, image_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
-80
View File
@@ -1,80 +0,0 @@
from core.utils.data.state import state
import configparser
import platform
import os
def create_config():
config = configparser.ConfigParser()
config["General"] = {
"debug": True,
"ignore_updates": f"{state.ignore_updates}",
"autoresume": f"{state.autoresume}",
"window_transparency": f"{state.window_transparency}"
}
config["Network"] = {
"api_url": f"{state.api_url}",
"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}",
"max_downloads": f"{state.max_downloads}"
}
config["Paths"] = {
"download_path": f"{state.download_path}",
"image_path": f"{state.image_path}"
}
if platform.system() == "Windows":
config_dir = os.environ.get("APPDATA", os.path.expanduser("~\\AppData\\Roaming"))
else:
config_dir = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
state.settings_path = os.path.join(config_dir, "SoftwareManager")
os.makedirs(state.settings_path, exist_ok=True)
with open(os.path.join(state.settings_path, "config.yml"), 'w') as cf:
config.write(cf)
def read_config():
config = configparser.ConfigParser()
if platform.system() == "Windows":
config_dir = os.environ.get("APPDATA", os.path.expanduser("~\\AppData\\Roaming"))
else:
config_dir = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
state.settings_path = os.path.join(config_dir, "SoftwareManager")
config_file = os.path.join(state.settings_path, "config.yml")
if not os.path.exists(config_file):
create_config()
return
config.read(config_file)
# General
state.debug = config.getboolean("General", "debug", fallback=state.debug)
state.ignore_updates = config.getboolean("General", "ignore_updates", fallback=state.ignore_updates)
state.autoresume = config.getboolean("General", "autoresume", fallback=state.autoresume)
state.window_transparency = config.getboolean("General", "window_transparency", fallback=state.window_transparency)
# Network
state.api_url = config.get("Network", "api_url", fallback=state.api_url)
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.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()
-60
View File
@@ -1,60 +0,0 @@
import threading
from PySide6.QtCore import QObject, Signal
from PySide6.QtWidgets import QTableWidget
from typing import Any, List, Dict
from pathlib import Path
class AppState(QObject):
image_changed = Signal(str)
def __init__(self):
super().__init__()
self.posts: list[Dict[str,str]] | None = None # titles, urls, author, seederm leecher
self.version: str = "dev"
self._image_path: str = ""
self.ignore_updates: bool = False
self.debug: bool = False
self.autoresume: bool = True
self.currenttracker: str = "rutracker"
self.trackertable: QTableWidget
self.trackers: Dict[str,Dict[str,Any]] = {} # each tracker should add itself here
'''
an example:
"rutracker" : {
"name" : "rutracker", # name of the tracker
"headers" : ["author", "title"], # keys shown in the table
"scrapeFunc" : function,
}
'''
self.api_url: str = "https://api.michijackson.xyz"
self.seeded_magnets: set = set()
self.download_path: str = str(Path.home() / "Downloads")
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.window_transparency: bool = False
self.interfaces: List = []
self.active_interfaces: List = []
self.bound_interface: Any = None
self.log_buffer: List[str] = []
self.downloads_lock = threading.RLock()
self.main_window: Any = None
self.loop_running: bool = False
self.shutdown_event = threading.Event()
@property
def image_path(self) -> str:
return self._image_path
@image_path.setter
def image_path(self, new_path: str):
if new_path != self._image_path:
self._image_path = new_path
self.image_changed.emit(new_path)
state = AppState()
-13
View File
@@ -1,13 +0,0 @@
from core.utils.data.state import state
import os
def closehelper():
state.shutdown_event.set()
try:
from core.network.libtorrent_misc import cleanup_session
cleanup_session()
except Exception:
pass
def force_exit():
os._exit(0)
-2
View File
@@ -1,2 +0,0 @@
def run_thread(thread):
thread.start()
+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)
+187
View File
@@ -0,0 +1,187 @@
from PySide6.QtWidgets import QLabel, QGraphicsOpacityEffect, QStackedWidget
from PySide6.QtCore import Qt, QSize, QEvent, QObject
from PySide6.QtGui import QImage, QPixmap
from utils.data.state import state
import darkdetect
import os
class Image(QObject):
def __init__(self, parent):
super().__init__(parent)
self.application = parent
self.overlay_label = QLabel(parent)
self.overlay_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
self.opacity_effect = QGraphicsOpacityEffect()
self.overlay_label.setGraphicsEffect(self.opacity_effect)
self._current_image_path = None
self._wallpaper_active = False
self._original_stylesheets = {}
parent.installEventFilter(self)
state.image_changed.connect(self.update_image_overlay)
if state.image_path and os.path.exists(state.image_path):
self._load_and_display(state.image_path)
def eventFilter(self, obj, event):
if obj == self.application and event.type() == QEvent.Type.Resize:
if self._current_image_path:
self._load_and_display(self._current_image_path)
return False
def _set_wallpaper_transparency(self, enabled):
if self._wallpaper_active == enabled:
return
self._wallpaper_active = enabled
parent = self.application
central = parent.centralWidget()
if central:
central.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, enabled)
for attr in ['tab_wrapper', 'corner_widget', 'titlebar']:
w = getattr(parent, attr, None)
if w:
w.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, enabled)
if hasattr(parent, 'tabs'):
tabs = parent.tabs
tabs.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, enabled)
for i in range(tabs.count()):
page = tabs.widget(i)
if page:
page.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, enabled)
stack = tabs.findChild(QStackedWidget)
if stack:
stack.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, enabled)
tab_bar = tabs.tabBar()
if tab_bar:
tab_bar.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, enabled)
if 'tabs' not in self._original_stylesheets:
self._original_stylesheets['tabs'] = tabs.styleSheet()
if enabled:
tabs.setStyleSheet(
self._original_stylesheets['tabs']
+ "\nQTabWidget::pane { background: transparent; }"
+ "\nQTabBar { background: transparent; }"
+ "\nQTabBar::tab { background: transparent; }"
)
else:
tabs.setStyleSheet(self._original_stylesheets['tabs'])
# Searchbar
if hasattr(parent, 'searchbar'):
if 'searchbar' not in self._original_stylesheets:
self._original_stylesheets['searchbar'] = parent.searchbar.styleSheet()
if enabled:
parent.searchbar.setStyleSheet("QLineEdit { background-color: transparent; }")
else:
parent.searchbar.setStyleSheet(self._original_stylesheets['searchbar'])
# Download button
if hasattr(parent, 'dlbutton'):
if 'dlbutton' not in self._original_stylesheets:
self._original_stylesheets['dlbutton'] = parent.dlbutton.styleSheet()
if enabled:
parent.dlbutton.setStyleSheet("QPushButton { background-color: transparent; }")
else:
parent.dlbutton.setStyleSheet(self._original_stylesheets['dlbutton'])
# Labels
for attr in ['emptyResults', 'emptyDownload']:
w = getattr(parent, attr, None)
if w:
w.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, enabled)
if hasattr(parent, 'tracker_list'):
if 'tracker_list' not in self._original_stylesheets:
self._original_stylesheets['tracker_list'] = parent.tracker_list.styleSheet()
if enabled:
popup_bg = '#1e1e1e' if darkdetect.isDark() else '#ffffff'
parent.tracker_list.setStyleSheet(
"QComboBox { background-color: transparent; }"
f" QComboBox QAbstractItemView {{ background-color: {popup_bg}; }}"
)
else:
parent.tracker_list.setStyleSheet(self._original_stylesheets['tracker_list'])
def _load_and_display(self, image_path):
if state.image_enabled is not True:
self.overlay_label.hide()
self._set_wallpaper_transparency(False)
return
self._current_image_path = image_path
parent = self.application
image = QImage(image_path)
if image.isNull():
self.overlay_label.hide()
return
if getattr(state, "image_as_wallpaper", False):
scaled_image = image.scaled(
parent.size(),
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
Qt.TransformationMode.SmoothTransformation
)
crop_x = (scaled_image.width() - parent.width()) // 2
crop_y = (scaled_image.height() - parent.height()) // 2
scaled_image = scaled_image.copy(crop_x, crop_y, parent.width(), parent.height())
pixmap = QPixmap.fromImage(scaled_image)
self.overlay_label.setPixmap(pixmap)
self.overlay_label.setGeometry(0, 0, parent.width(), parent.height())
self.overlay_label.lower()
self.opacity_effect.setOpacity(state.image_opacity / 100)
self._set_wallpaper_transparency(True)
else:
self._set_wallpaper_transparency(False)
scaled_image = image.scaledToWidth(
state.image_width, Qt.TransformationMode.SmoothTransformation
)
max_width = parent.width()
max_height = parent.height()
if scaled_image.width() > max_width or scaled_image.height() > max_height:
scaled_image = scaled_image.scaled(
QSize(max_width, max_height),
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
pixmap = QPixmap.fromImage(scaled_image)
self.overlay_label.setPixmap(pixmap)
self.overlay_label.adjustSize()
self.overlay_label.raise_()
pos = state.image_position
off = int(state.image_offset)
w = self.overlay_label.width()
h = self.overlay_label.height()
if pos == "top-left":
x, y = off, off
elif pos == "top-right":
x, y = parent.width() - w - off, off
elif pos == "bottom-left":
x, y = off, parent.height() - h - off
elif pos == "center":
x, y = (parent.width() - w) // 2, (parent.height() - h) // 2
else: # bottom-right (default)
x, y = parent.width() - w - off, parent.height() - h - off
self.opacity_effect.setOpacity(state.image_opacity / 100)
self.overlay_label.move(x, y)
self.overlay_label.show()
def update_image_overlay(self, new_image_path):
self._load_and_display(new_image_path)
@@ -0,0 +1,17 @@
from PySide6.QtWidgets import QMessageBox
import qdarktheme
class NotificationPopup(QMessageBox):
def __init__(self, title, text, infotext=None, parent=None):
super().__init__(parent)
qdarktheme.setup_theme("auto")
self.setIcon(QMessageBox.Icon.Information)
self.setWindowTitle(title)
self.setText(text)
if infotext is not None:
self.setInformativeText(infotext)
self.setStandardButtons(QMessageBox.StandardButton.Ok)
@@ -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
+328
View File
@@ -0,0 +1,328 @@
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.QtWidgets import (
QLineEdit,
QPushButton,
QWidget,
QVBoxLayout,
QDialog,
QLabel,
QHBoxLayout,
QSpinBox, QSlider, QCheckBox,
QFileDialog,
QComboBox,
)
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):
temp_image_path = state.image_path
temp_image_width = state.image_width
temp_image_offset = state.image_offset
temp_image_opacity = state.image_opacity
temp_image_enabled = state.image_enabled
temp_image_as_wallpaper = state.image_as_wallpaper
temp_image_position = state.image_position
def create_widget(widget_type, label_text, **kwargs):
container = QWidget()
layout = QHBoxLayout()
container.setLayout(layout)
layout.addWidget(QLabel(label_text))
widget = widget_type()
if widget_type in (QSpinBox,):
layout.addWidget(widget)
widget.setMinimum(kwargs.get("minimum", 0))
widget.setMaximum(kwargs.get("maximum", 10000000))
widget.setFixedWidth(kwargs.get("width", 180))
widget.setFixedHeight(kwargs.get("height", 30))
elif widget_type in (QCheckBox,):
layout.addStretch()
layout.addWidget(widget)
elif widget_type in (QLineEdit,):
layout.addWidget(widget)
container.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed)
if "width" in kwargs:
widget.setFixedWidth(kwargs["width"])
if "height" in kwargs:
widget.setFixedHeight(kwargs["height"])
elif widget_type in (QComboBox,):
layout.addWidget(widget)
widget.setFixedWidth(kwargs.get("width", 180))
widget.setFixedHeight(kwargs.get("height", 30))
return container, widget
consoleLog("Settings dialog opened")
dialog = QDialog(self)
dialog.setWindowTitle("Settings")
dialog.setFixedSize(580, 400)
dialog_layout = QVBoxLayout()
dialog.setLayout(dialog_layout)
if state.window_transparency and platform.system() != "Windows" and dialog:
dialog.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
# Ignore Updates checkbox
update_checkbox_container, update_checkbox = create_widget(QCheckBox, "Ignore Updates: ")
if platform.system() == "Windows":
update_checkbox.setChecked(state.ignore_updates)
update_checkbox.toggled.connect(lambda checked: setattr(state, 'ignore_updates', checked))
# Autoresume Container Checkbox
autoresume_container, autoresume_checkbox = create_widget(QCheckBox, "Auto-Resume Downloads: ")
autoresume_checkbox.setChecked(state.autoresume)
autoresume_checkbox.toggled.connect(lambda checked: setattr(state, 'autoresume', checked))
# Transparent Window Checkbox
transparent_window_container, transparent_window_checkbox = create_widget(QCheckBox, "Window Transparency (requires restart) (Linux/MacOS only): ")
transparent_window_checkbox.setChecked(state.window_transparency)
transparent_window_checkbox.toggled.connect(lambda checked: setattr(state, 'window_transparency', checked))
# Accent Color
accent_color_container, accent_color_input = create_widget(QLineEdit, "Accent Color (requires restart): ", width=180, height=30)
accent_color_input.setPlaceholderText("e.g. #fca7d7")
accent_color_input.setText(state.accent_color)
# API URL Widget
api_url_container, api_url = create_widget(QLineEdit, "API Server URL: ", width=180, height=30)
api_url.setText(state.api_url)
# Download Path Widget
download_path_container, download_path = create_widget(QLineEdit, "Download Path: ")
download_path.setText(state.download_path)
download_path_layout = download_path_container.layout()
def browse_download_path():
dir_path = QFileDialog.getExistingDirectory(dialog, "Select Download Directory", state.download_path)
if dir_path:
download_path.setText(dir_path)
browse_button = create_widget(QPushButton, "", width=36, height=36)[1]
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, image_path = create_widget(QLineEdit, "Image Path: ")
image_path.setText(state.image_path)
image_path_layout = image_path_container.layout()
image_path.textChanged.connect(lambda text: setattr(state, 'image_path', text))
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 = create_widget(QPushButton, "", width=36, height=36)[1]
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)
# Enable Image Checkbox
enable_image_container, enable_image_checkbox = create_widget(QCheckBox, "Enable Image (requires image path): ", width=180, height=30)
enable_image_checkbox.setChecked(state.image_enabled)
enable_image_checkbox.toggled.connect(lambda checked: setattr(state, 'image_enabled', checked))
# Image Mode Checkbox
image_mode_container, image_mode_checkbox = create_widget(QCheckBox, "Wallpaper Mode: ")
image_mode_checkbox.setChecked(state.image_as_wallpaper)
image_mode_checkbox.toggled.connect(lambda checked: setattr(state, 'image_as_wallpaper', checked))
# Image Position Preset
positions = ["bottom-right", "bottom-left", "top-right", "top-left", "center"]
image_position_container, image_position_combo = create_widget(QComboBox, "Position: ", width=180, height=30)
image_position_combo.addItems(positions)
idx = image_position_combo.findText(state.image_position)
image_position_combo.setCurrentIndex(idx if idx >= 0 else 0)
image_position_combo.currentTextChanged.connect(lambda val: setattr(state, 'image_position', val))
_slider_style = """
QSlider::groove:horizontal {
height: 3px;
background: palette(mid);
border-radius: 1px;
}
QSlider::handle:horizontal {
width: 10px;
height: 10px;
margin: -4px 0;
border-radius: 5px;
}
"""
# Image Width Slider
image_width_container = QWidget()
image_width_layout = QHBoxLayout(image_width_container)
image_width_layout.addWidget(QLabel("Image Width: "))
image_width = QSlider(Qt.Orientation.Horizontal)
image_width.setFixedWidth(180)
image_width.setStyleSheet(_slider_style)
image_width.setMinimum(0)
image_width.setMaximum(2500)
image_width.setValue(state.image_width)
image_width_val = QLabel(str(state.image_width))
image_width_val.setFixedWidth(36)
image_width.valueChanged.connect(lambda val: (setattr(state, 'image_width', val), image_width_val.setText(str(val))))
image_width_layout.addWidget(image_width_val)
image_width_layout.addWidget(image_width)
# Image Offset Slider
image_offset_container = QWidget()
image_offset_layout = QHBoxLayout(image_offset_container)
image_offset_layout.addWidget(QLabel("Corner Offset: "))
image_offset = QSlider(Qt.Orientation.Horizontal)
image_offset.setFixedWidth(180)
image_offset.setStyleSheet(_slider_style)
image_offset.setMinimum(0)
image_offset.setMaximum(500)
image_offset.setValue(state.image_offset)
image_offset_val = QLabel(str(state.image_offset))
image_offset_val.setFixedWidth(36)
image_offset.valueChanged.connect(lambda val: (setattr(state, 'image_offset', val), image_offset_val.setText(str(val))))
image_offset_layout.addWidget(image_offset_val)
image_offset_layout.addWidget(image_offset)
# Image Opacity Slider
image_opacity_container = QWidget()
image_opacity_layout = QHBoxLayout(image_opacity_container)
image_opacity_layout.addWidget(QLabel("Image Opacity: "))
image_opacity = QSlider(Qt.Orientation.Horizontal)
image_opacity.setFixedWidth(180)
image_opacity.setStyleSheet(_slider_style)
image_opacity.setMinimum(0)
image_opacity.setMaximum(100)
image_opacity.setValue(state.image_opacity)
image_opacity_val = QLabel(str(state.image_opacity))
image_opacity_val.setFixedWidth(36)
image_opacity.valueChanged.connect(lambda val: (setattr(state, 'image_opacity', val), image_opacity_val.setText(str(val))))
image_opacity_layout.addWidget(image_opacity_val)
image_opacity_layout.addWidget(image_opacity)
# Speed Limiting
down_speed_limit_container, down_speed_limit = create_widget(QSpinBox, "Max Download Speed (KiB, 0 for unlimited): ", width=180, height=30)
down_speed_limit.setMinimum(0)
down_speed_limit.setValue(state.down_speed_limit)
up_speed_limit_container, up_speed_limit = create_widget(QSpinBox, "Max Upload Speed (KiB, 0 for unlimited): ", width=180, height=30)
up_speed_limit.setMinimum(0)
up_speed_limit.setValue(state.up_speed_limit)
# Connection Configs
max_connections_container, max_connections = create_widget(QSpinBox, "Max Connections: ", width=180, height=30)
max_connections.setMinimum(0)
max_connections.setValue(state.max_connections)
# Download Configs
max_downloads_container, max_downloads = create_widget(QSpinBox, "Max Downloads: ", width=180, height=30)
max_downloads.setMinimum(0)
max_downloads.setValue(state.max_downloads)
# Interface Binding
interface_container = QWidget()
interface_layout = QHBoxLayout()
interface_label = QLabel("Network Interface:")
interface_layout.addWidget(interface_label)
interface_select = QComboBox()
interface_select.addItems(["None"] + state.interfaces)
target = state.bound_interface if state.bound_interface else "None"
index = interface_select.findText(target)
if index >= 0:
interface_select.setCurrentIndex(index)
else:
interface_select.setCurrentIndex(0)
interface_select.setFixedWidth(180)
interface_select.setFixedHeight(30)
interface_layout.addWidget(interface_select)
interface_container.setLayout(interface_layout)
# Save / Cancel buttons
layout = QHBoxLayout()
save_btn = QPushButton("Save")
cancel_btn = QPushButton("Cancel")
save_btn.clicked.connect(lambda: handle_save())
def handle_save():
save_settings(
dialog.accept,
api_url.text(),
download_path.text(),
down_speed_limit.value(),
up_speed_limit.value(),
image_path.text(),
autoresume_checkbox.isChecked(),
max_connections.value(),
max_downloads.value(),
interface_select.currentText(),
image_width.value(),
image_offset.value(),
image_opacity.value(),
image_as_wallpaper=image_mode_checkbox.isChecked(),
image_position=image_position_combo.currentText(),
accent_color=accent_color_input.text().strip()
)
cancel_btn.clicked.connect(dialog.reject)
layout.addWidget(cancel_btn)
layout.addWidget(save_btn)
tabs = QtWidgets.QTabWidget()
create_tab("General", [autoresume_container, update_checkbox_container, transparent_window_container, accent_color_container], tabs=tabs, stretch=True)
create_tab("Image", [enable_image_container, image_mode_container, image_position_container, image_width_container, image_offset_container, image_opacity_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(tabs)
dialog_layout.addLayout(layout)
def on_dialog_finished(result): # Undo Image changes if "Save" button is not pressed
if result == QtWidgets.QDialog.DialogCode.Rejected:
state.image_path = temp_image_path
state.image_width = temp_image_width
state.image_offset = temp_image_offset
state.image_opacity = temp_image_opacity
state.image_enabled = temp_image_enabled
state.image_as_wallpaper = temp_image_as_wallpaper
state.image_position = temp_image_position
dialog.finished.connect(on_dialog_finished)
dialog.exec()
+98
View File
@@ -0,0 +1,98 @@
from PySide6.QtGui import QColor
from utils.logging.logs import consoleLog
from utils.data.state import state
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 _accent_selection_color(alpha: float = 0.35) -> str:
if not state.accent_color:
return None
try:
hex_color = state.accent_color.lstrip('#')
if len(hex_color) != 6:
return None
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
return f"rgba({r}, {g}, {b}, {alpha})"
except ValueError:
consoleLog(f"Invalid Color: {state.accent_color}")
return None
def _table_stylesheet(view_type="QTableWidget"):
c = _theme_colors()
dark = _is_dark_mode()
color_rule = "" if dark else f"color: {c['text']};"
selected_bg = _accent_selection_color() or c["selected"]
selection_color_rule = f"selection-background-color: {selected_bg};" if state.accent_color else ""
return f"""
{view_type} {{
border: none;
outline: 0;
font-size: 13px;
{selection_color_rule}
{color_rule}
}}
{view_type}::item {{
border-bottom: 1px solid {c["border"]};
padding: 6px 14px;
outline: none;
{color_rule}
}}
{view_type}::item:selected {{
background: {selected_bg};
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)
+418
View File
@@ -0,0 +1,418 @@
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 interface.dialogs.theme import _accent_selection_color
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)
if state.accent_color:
self.tracker_list.setStyleSheet(
f"QComboBox QAbstractItemView::item:selected {{ background: {_accent_selection_color()}; }}"
)
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)
@@ -1,16 +1,13 @@
from PySide6 import QtWidgets
from PySide6.QtCore import Qt, QByteArray
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:
+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
+39 -27
View File
@@ -1,66 +1,78 @@
from core.interface.gui import MainWindow
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
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")
def run_gui(app):
custom_colors = {}
if state.accent_color:
custom_colors["primary"] = state.accent_color
qdarktheme.setup_theme("auto", custom_colors=custom_colors if custom_colors else None)
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"})
transparent_colors = {"background": "#00000000", **custom_colors}
qdarktheme.setup_theme("auto", custom_colors=transparent_colors)
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()
# Initialize UI Engine
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv)
# 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}")
# Start background daemon threads
run_thread(threading.Thread(target=send_notification, args=(state.shutdown_event,), daemon=True))
consoleLog("Started Thread: send_notification")
run_thread(threading.Thread(target=update_log, args=(state.shutdown_event,), daemon=True))
consoleLog("Started Thread: update_log")
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=(state.shutdown_event,), daemon=True))
consoleLog("Started Thread: check_deleted_files")
# Start background non-daemon threads
run_thread(threading.Thread(target=check_completed, args=(downloads, state.autoresume)))
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")
run_gui()
# Launch GUI
run_gui(app)
if __name__ == "__main__":
main()
main()
@@ -1,15 +1,16 @@
from utils.logging.logs import consoleLog, add_download_log
from utils.data.state import state
from typing import Optional
from core.utils.data.state import state
from core.utils.logging.logs import consoleLog, add_download_log
from .handle import DirectDownloadHandle
from .utils import (
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):
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
@@ -24,7 +25,7 @@ def add_direct_download(url: str, title: str, dl_path: Optional[str] = None):
or sanitize_filename(title) + ".zip"
)
handle = DirectDownloadHandle(url, filename, dl_path)
handle = DirectDownloadHandle(url, filename, dl_path, headers, single_threaded)
state.active_downloads[url] = handle
add_download_log(title, url, "", False)
handle.start()
@@ -1,17 +1,17 @@
import os
import threading
import requests
import json
import hashlib
import time
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
from core.utils.logging.logs import consoleLog, update_download_completed
from core.utils.data.state import state
from .status import DirectDownloadStatus, ChunkSpec
from .utils import format_size
class SimpleThrottler:
def __init__(self):
@@ -58,7 +58,7 @@ class DirectDownloadHandle:
"Chrome/125.0.0.0 Safari/537.36"
)
def __init__(self, url: str, name: str, save_path: str):
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
@@ -69,6 +69,8 @@ class DirectDownloadHandle:
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)
@@ -132,14 +134,14 @@ class DirectDownloadHandle:
"chunks": chunks_done
}, f)
except Exception as e:
pass
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:
pass
except Exception as e:
consoleLog(f"Exception while removing download state file: {e}")
def start(self):
@@ -151,6 +153,8 @@ class DirectDownloadHandle:
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,
@@ -194,6 +198,7 @@ class DirectDownloadHandle:
use_multithreaded = (
self._supports_range
and total_size > self.MIN_CHUNK_SIZE * 2
and not self._single_threaded
)
if use_multithreaded:
@@ -204,7 +209,7 @@ class DirectDownloadHandle:
self._preallocate_file(total_size)
self._multithreaded_download(total_size)
else:
reason = "no range support" if not self._supports_range else "file too small"
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()
@@ -439,5 +444,4 @@ class DirectDownloadHandle:
raise RuntimeError(
f"Size mismatch: expected {format_size(expected_size)}, "
f"got {format_size(actual_size)}"
)
)
@@ -1,8 +1,8 @@
import threading
import time
from dataclasses import dataclass
from typing import Optional
import libtorrent as lt
import threading
import time
@dataclass
@@ -1,7 +1,8 @@
import os
import requests
from typing import Optional
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
@@ -36,8 +37,8 @@ def detect_filename_from_headers(url: str, user_agent: str) -> Optional[str]:
fname = parts[-1].strip().strip('"').strip("'")
if fname:
return fname
except Exception:
pass
except Exception as e:
consoleLog(f"Exception while retrieving filename from headers: {e}")
return None
def format_size(size_bytes: int) -> str:
@@ -1,5 +1,5 @@
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
def get_net_interfaces():
@@ -29,6 +29,7 @@ def get_active_interfaces():
def list_interfaces() -> None:
consoleLog("Fetching Network Interfaces...")
addrs = psutil.net_if_addrs()
stats = psutil.net_if_stats()
@@ -45,6 +46,7 @@ def list_interfaces() -> None:
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()
@@ -1,16 +1,15 @@
import time
import os
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
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
import time
import os
global loop_running
loop_running = False
def get_free_space_mb(dirname):
@@ -22,6 +21,25 @@ def get_free_space_mb(dirname):
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
@@ -61,7 +79,6 @@ def add_download(magnet_uri):
state.active_downloads = {}
init_session()
free_space = get_free_space_mb(state.download_path)
if magnet_uri in state.active_downloads:
@@ -92,7 +109,6 @@ def add_download(magnet_uri):
params.save_path = state.download_path
handle = state.dl_session.add_torrent(params)
while not handle.has_metadata():
time.sleep(1)
@@ -103,7 +119,6 @@ def add_download(magnet_uri):
magnetdl.save_path = state.download_path
download = state.dl_session.add_torrent(magnetdl)
else:
download = None
state.dl_session.remove_torrent(handle)
consoleLog("Not enough free space to download this item.")
return False
@@ -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):
@@ -59,8 +56,8 @@ def update_log(shutdown_event):
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):
@@ -79,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:?"):
+150
View File
@@ -0,0 +1,150 @@
from interface.dialogs.notificationpopup import NotificationPopup
from utils.logging.logs import consoleLog
from utils.data.state import state
import configparser
import platform
import time
import os
def create_config():
config = configparser.ConfigParser()
config["General"] = {
"debug": str(state.debug),
"ignore_updates": str(state.ignore_updates),
"autoresume": str(state.autoresume),
"window_transparency": str(state.window_transparency),
"accent_color": str(state.accent_color)
}
config["Network"] = {
"api_url": str(state.api_url),
"bound_interface": str(state.bound_interface) if state.bound_interface is not None else "None",
"download_speed_limit": str(state.down_speed_limit),
"upload_speed_limit": str(state.up_speed_limit),
"max_connections": str(state.max_connections),
"max_downloads": str(state.max_downloads)
}
config["Paths"] = {
"download_path": str(state.download_path),
"image_path": str(state.image_path)
}
config["Image"] = {
"enable_image": str(state.image_enabled),
"image_width": str(state.image_width),
"image_offset": str(state.image_offset),
"image_opacity": str(state.image_opacity),
"image_as_wallpaper": str(state.image_as_wallpaper),
"image_position": str(state.image_position)
}
if platform.system() == "Windows":
config_dir = os.environ.get("APPDATA", os.path.expanduser("~\\AppData\\Roaming"))
else:
config_dir = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
state.settings_path = os.path.join(config_dir, "SoftwareManager")
os.makedirs(state.settings_path, exist_ok=True)
with open(os.path.join(state.settings_path, "config.ini"), 'w') as cf:
config.write(cf)
def read_config():
config = configparser.ConfigParser()
if platform.system() == "Windows":
config_dir = os.environ.get("APPDATA", os.path.expanduser("~\\AppData\\Roaming"))
else:
config_dir = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
state.settings_path = os.path.join(config_dir, "SoftwareManager")
# Temporary migration logic, keep for a while
old_config_file = os.path.join(state.settings_path, "config.yml")
new_config_file = os.path.join(state.settings_path, "config.ini")
if os.path.exists(old_config_file):
try:
os.replace(old_config_file, new_config_file)
consoleLog("Successfully migrated config.yml to config.ini")
except Exception as e:
consoleLog(f"Failed to migrate config file: {e}")
if not os.path.exists(new_config_file):
create_config()
return
try:
config.read(new_config_file)
# General
state.debug = config.getboolean("General", "debug", fallback=state.debug)
state.ignore_updates = config.getboolean("General", "ignore_updates", fallback=state.ignore_updates)
state.autoresume = config.getboolean("General", "autoresume", fallback=state.autoresume)
state.window_transparency = config.getboolean("General", "window_transparency", fallback=state.window_transparency)
state.accent_color = config.get("General", "accent_color", fallback=state.accent_color)
# Network
state.api_url = config.get("Network", "api_url", fallback=state.api_url)
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.download_path = config.get("Paths", "download_path", fallback=state.download_path)
state.image_path = config.get("Paths", "image_path", fallback=state.image_path)
# Image
state.image_enabled = config.getboolean("Image", "enable_image", fallback=state.image_enabled)
state.image_width = config.getint("Image", "image_width", fallback=state.image_width)
state.image_offset = config.getint("Image", "image_offset", fallback=state.image_offset)
state.image_opacity = config.getint("Image", "image_opacity", fallback=state.image_opacity)
state.image_as_wallpaper = config.getboolean("Image", "image_as_wallpaper", fallback=state.image_as_wallpaper)
state.image_position = config.get("Image", "image_position", fallback=state.image_position)
create_config()
except configparser.Error as e:
consoleLog(f"Error: Configuration file corrupted ({e}). Resetting to defaults.")
backup_config(new_config_file) # Back up corrupted config
create_config() # Create new config file
NotificationPopup(
title="Config Reset",
text="Your configuration file has been reset due to a structural error.",
infotext="A backup of your old configuration file is available in the settings folder."
).exec()
except ValueError as e:
consoleLog(f"Error: Invalid data types in configuration file ({e}). Resetting to defaults.")
backup_config(new_config_file) # Back up corrupted config
create_config() # Create new config file
NotificationPopup(
title="Config Reset",
text="Your configuration file has been reset due to invalid data types.",
infotext="A backup of your old configuration file is available in the settings folder."
).exec()
except Exception as e:
consoleLog(f"Unexpected error occurred while loading settings: {e}")
NotificationPopup(
title="Unexpected Error",
text="An unknown error occurred while loading your settings.",
infotext=f"System returned: {e}\n\nThe application may not function as expected."
).exec()
def backup_config(config_path):
if os.path.exists(config_path):
timestamp = time.strftime("%Y%m%d-%H%M%S")
backup_path = f"{config_path}.{timestamp}.bak"
try:
os.replace(config_path, backup_path)
consoleLog(f"Successfully created backup of corrupted config: {backup_path}")
except Exception as e:
consoleLog(f"Failed to create backup of corrupted config: {e}")
@@ -1,10 +1,11 @@
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):
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, image_width=None, image_offset=None, image_opacity=None, image_as_wallpaper=None, image_position=None, accent_color=None):
if apiurl is not None:
state.api_url = apiurl
if download_path is not None:
@@ -23,8 +24,20 @@ 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
if image_width is not None:
state.image_width = image_width
if image_offset is not None:
state.image_offset = image_offset
if image_opacity is not None:
state.image_opacity = image_opacity
if image_as_wallpaper is not None:
state.image_as_wallpaper = image_as_wallpaper
if image_position is not None:
state.image_position = image_position
if accent_color is not None:
state.accent_color = accent_color
update_settings()
update_settings() # Update LibTorrent Session Settings
consoleLog("Saved Settings")
create_config()
close()
+160
View File
@@ -0,0 +1,160 @@
from PySide6.QtCore import QObject, Signal
from PySide6.QtWidgets import QTableWidget
from typing import Any, List, Dict
from pathlib import Path
import threading
class AppState(QObject):
image_changed = Signal(str)
def __init__(self):
super().__init__()
# General
self.version: str = "Unknown"
self.debug: bool = False
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.accent_color: str = ""
self.trackertable: QTableWidget
self.interfaces: List = []
self.active_interfaces: List = []
self.bound_interface: Any = None
# Image
self._image_enabled: bool = False
self._image_width: int = 300 # Default to 300px
self._image_offset: int = 50
self._image_opacity: int = 100
self._image_as_wallpaper: bool = False
self._image_position: str = "bottom-right" # top-left, top-right, bottom-left, bottom-right, center
# 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.downloads_lock = threading.RLock()
@property
def image_path(self) -> str:
return self._image_path
@image_path.setter
def image_path(self, new_path: str):
if new_path != self._image_path:
self._image_path = new_path
self.image_changed.emit(new_path)
@property
def image_offset(self) -> int:
return self._image_offset
@image_offset.setter
def image_offset(self, new_offset: int):
if new_offset != self._image_offset:
self._image_offset = new_offset
self.image_changed.emit(self._image_path)
@property
def image_width(self) -> int:
return self._image_width
@image_width.setter
def image_width(self, new_width: int):
if new_width != self._image_width:
self._image_width = new_width
self.image_changed.emit(self._image_path)
@property
def image_opacity(self) -> int:
return self._image_opacity
@image_opacity.setter
def image_opacity(self, new_opacity: int):
if new_opacity != self._image_opacity:
self._image_opacity = new_opacity
self.image_changed.emit(self._image_path)
@property
def image_enabled(self) -> bool:
return self._image_enabled
@image_enabled.setter
def image_enabled(self, new_state: bool):
if new_state != self._image_enabled:
self._image_enabled = new_state
self.image_changed.emit(self._image_path)
@property
def image_as_wallpaper(self) -> bool:
return self._image_as_wallpaper
@image_as_wallpaper.setter
def image_as_wallpaper(self, new_state: bool):
if new_state != self._image_as_wallpaper:
self._image_as_wallpaper = new_state
self.image_changed.emit(self._image_path)
@property
def image_position(self) -> str:
return self._image_position
@image_position.setter
def image_position(self, new_pos: str):
if new_pos != self._image_position:
self._image_position = new_pos
self.image_changed.emit(self._image_path)
@property
def image_custom_position(self) -> bool:
return self._image_custom_position
@image_custom_position.setter
def image_custom_position(self, new_state: bool):
if new_state != self._image_custom_position:
self._image_custom_position = new_state
self.image_changed.emit(self._image_path)
@property
def image_x(self) -> int:
return self._image_x
@image_x.setter
def image_x(self, val: int):
if val != self._image_x:
self._image_x = val
self.image_changed.emit(self._image_path)
@property
def image_y(self) -> int:
return self._image_y
@image_y.setter
def image_y(self, val: int):
if val != self._image_y:
self._image_y = val
self.image_changed.emit(self._image_path)
state = AppState()
@@ -1,6 +1,6 @@
import requests
from utils.logging.logs import consoleLog
from bs4 import BeautifulSoup
from core.utils.logging.logs import consoleLog
import requests
def get_magnet_link(post_url):
@@ -15,6 +15,7 @@ def get_magnet_link(post_url):
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,9 +1,8 @@
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):
count = data.count
downloads = data.data
@@ -14,13 +13,12 @@ def check_completed(downloads, resume):
if download.completed == False:
consoleLog(f"Found unfinished download: {download.title}")
if resume == True:
dl_dir = os.path.dirname(download.path)
if download.magnet_uri:
run_download_direct(download.magnet_uri, dl_dir, download.title)
run_download_direct(download.magnet_uri, download.title)
consoleLog(f"Resuming Magnet: {download.title}")
elif download.url:
from core.network.direct_download import add_direct_download
add_direct_download(download.url, download.title, dl_dir)
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):
@@ -1,12 +1,12 @@
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
def add_download_log(title, url, magnet_uri, completed) -> DownloadList:
# wait for metadata outside the lock to avoid blocking other threads
@@ -243,12 +243,12 @@ def set_main_window(window):
def flush_log_buffer(): # credits to claude
if state.log_buffer:
try:
from core.interface.gui import MainWindow
from interface.gui import MainWindow
for log_entry in state.log_buffer:
MainWindow.add_log(log_entry)
state.log_buffer = []
except Exception:
pass
except Exception as e:
consoleLog(f"Exception while flushing log buffer: {e}")
def consoleLog(text, printAnyways = False):
now = datetime.now()
@@ -256,7 +256,7 @@ def consoleLog(text, printAnyways = False):
formatted_text = f"[{current_time}] {text}"
try:
from core.interface.gui import MainWindow
from interface.gui import MainWindow
if not MainWindow.add_log(formatted_text):
state.log_buffer.append(formatted_text)
except Exception:
@@ -1,17 +1,17 @@
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 core.utils.logging.logs import consoleLog
from core.network.libtorrent_wrapper import add_magnet
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
from core.utils.data.state import state
from core.network.direct_download import add_direct_download
from typing import Optional
import threading
def download_selected(items: list[QTableWidgetItem]):
if not items:
consoleLog("No item selected for download.")
@@ -30,18 +30,21 @@ def download_selected(items: list[QTableWidgetItem]):
consoleLog(f"Downloading {post.get('title', 'Unknown')}")
run_thread(threading.Thread(target=run_download, args=(post,)))
def run_download(post):
linkfunc = state.trackers[state.currenttracker]["linkFunc"]
ismagnet = state.trackers[state.currenttracker]["isMagnet"]
link = linkfunc(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:
add_direct_download(link, post.get("title", "Unknown"))
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, dl_path=None, title="Direct Download"):
def run_download_direct(magnet_uri, title="Direct Download"):
consoleLog(f"Magnet: {title}")
add_magnet(magnet_uri)
add_download_log(title, "", magnet_uri, False)
@@ -1,6 +1,7 @@
from utils.logging.logs import consoleLog
from utils.data.state import state
import requests
from core.utils.data.state import state
from core.utils.logging.logs import consoleLog
def get_updates():
url = f"https://api.github.com/repos/KeksPirates/SoftwareManager/releases/latest"
@@ -8,11 +9,11 @@ def get_updates():
response = requests.get(url, timeout=15)
except requests.RequestException as e:
consoleLog(f"Failed to fetch releases: {e}")
return None
return None, None
if response.status_code != 200:
consoleLog(f"Failed to fetch releases: {response.status_code}")
return None
return None, None
release = response.json()
+98
View File
@@ -0,0 +1,98 @@
from network.direct_download.handle import DirectDownloadHandle
from utils.general.shutdown import closehelper
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()
closehelper()
subprocess.Popen([installer_path, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/SP-", "/CLOSEAPPLICATIONS"])
time.sleep(1)
os._exit(0)