Compare commits

...

179 Commits

Author SHA1 Message Date
Vxrtrauter b2a30af5cd fix: prevent processing of non-downloadable items in download_selected function 2026-03-07 20:46:41 +01:00
Vxrtrauter fbc2225878 refactor: standardize version key in build_info.json and enhance download selection logic in download.py
feat: add SVG icons for play, pause, and folder actions in gui.py; implement status bar for download speed display
fix: remove unnecessary newline in libtorrent_misc.py and streamline update checker logic in update_checker.py
2026-03-07 20:41:03 +01:00
KeksNino 4d1a639651 remove unused QProgressBar widget 2026-03-07 17:55:52 +01:00
Vxrtrauter 8d1b3ba68f feat: implement seeding functionality and enhance download management with seeded magnets tracking 2026-03-08 12:00:00 +01:00
Vxrtrauter dcc12c0b28 fix: LibTorrent not detecting that file has been deleted until LibTorrent Session is reinitialized / enhance download management with file existence checks and cleanup 2026-03-07 00:45:27 +01:00
Vxrtrauter 925fe21d03 Add logging and download tracking to direct downloads 2026-03-07 14:00:00 +01:00
Vxrtrauter ec462afde6 Simplify boolean checks in network interface detection 2026-03-06 12:00:00 +01:00
Vxrtrauter e50ef32c66 refactor: clean up imports and remove unused code in scrapers and download modules 2026-03-05 23:19:08 +01:00
KeksNino d6e418d045 fix: crash when there are no results in search tab 2026-03-04 21:24:56 +01:00
Vxrtrauter a7c71b37f5 refactor: remove duplicate stripping 2026-03-04 21:00:37 +01:00
Vxrtrauter 8fb1bb9e8d Merge branch 'main' of https://github.com/KeksPirates/SoftwareManager 2026-03-04 20:58:58 +01:00
Vxrtrauter 89fd8b4ebb fix: fix scraping for uztracker posts to support displaying authors properly 2026-03-04 20:58:56 +01:00
KeksNino 3915e114a3 fix: logging in uztracker scraper and search for non-ascii letters and remove unused imports 2026-03-04 20:28:55 +01:00
Vxrtrauter 34a50e1a1b refactor: rename scraping functions for consistency and improve data handling 2026-03-04 19:11:31 +01:00
KeksNino 90124fd9c8 fix: smaller author column in search tab 2026-03-04 14:07:43 +01:00
Vxrtrauter 4175f82967 feat: add windowCloseHelper function and integrate it into keyboard interrupt handling 2026-03-03 21:56:28 +01:00
Vxrtrauter 4bf2e9af81 feat: add download existence checks and improve log removal feedback 2026-03-01 23:14:10 +01:00
Vxrtrauter a6bb879db3 feat: enhance download logging with path handling and timeout mechanism 2026-02-28 04:01:00 +01:00
Vxrtrauter 7653748599 chore: remove unused file_list utility 2026-02-28 03:34:56 +01:00
Vxrtrauter 9bec3f9189 Merge branch 'main' of https://github.com/KeksPirates/SoftwareManager 2026-02-28 03:33:13 +01:00
Vxrtrauter 04d93b870a refactor: reorganize logging functionality and remove unused log files 2026-02-28 03:33:10 +01:00
KeksNino 664e660198 fix a bunch of lsp errors 2026-02-28 03:31:17 +01:00
Vxrtrauter 60019e0d2e refactor: update log imports and restructure logging functionality 2026-02-28 03:31:05 +01:00
Vxrtrauter 8238cfbe56 Merge branch 'main' of https://github.com/KeksPirates/SoftwareManager 2026-02-28 03:05:10 +01:00
Vxrtrauter 7ccff5c5a1 Refactor monkrus scraper to implement caching and improve post fetching logic 2026-02-28 03:05:09 +01:00
KeksNino 8abe92fc4f fix: hide seeders and leechers in uztracker and m0nkrus tab 2026-02-28 02:37:35 +01:00
Vxrtrauter 0a3b5eeee2 Remove unused asyncio import in uztracker.py 2026-02-27 23:59:59 +01:00
KeksNino 7662c861ea trigger github CI 2026-02-20 01:13:13 +01:00
KeksNino 2a8f305a76 fix exe not starting on windows 2026-02-19 19:28:24 +01:00
KeksNino 12223d18de add transparency to settings dialog on linux/macos 2026-02-17 00:29:55 +01:00
KeksNino 544733ce6b fix app icon in nuitka build process and remove settings pngs 2026-02-17 00:10:30 +01:00
KeksNino 7aee2bcfe9 remove include flags for assets in github workflows 2026-02-16 23:58:34 +01:00
KeksNino d274e64841 fix assets not showing up by loading them from base64 2026-02-16 23:49:21 +01:00
KeksNino 23c74f6487 fix: wrong assets path on windows 2026-02-16 02:34:44 +01:00
KeksNino 19ea09d035 fix: build_info.json path in github CI 2026-02-11 21:28:06 +01:00
shayaa 92359d75e5 Fix asset paths in dev.yml for logo and data files 2026-02-11 21:27:01 +01:00
shayaa 315a68c000 Update asset paths in dev.yml for PyInstaller 2026-02-11 21:26:13 +01:00
shayaa 4b0ef44efd Update file paths to src/main.py in dev.yml 2026-02-11 21:23:58 +01:00
KeksNino 6dc28615a0 refactor: move main.py and core into new src folder 2026-02-11 21:19:35 +01:00
shayaa 2ea9a329d1 Update server hosting instructions in README 2026-02-11 03:49:12 +01:00
Vxrtrauter 9f8111ac73 refactor: streamline update check logic and enhance log handling 2026-02-11 03:27:49 +01:00
Vxrtrauter e5b809d6d4 # This is a combination of 9 commits.
# This is the 1st commit message:

fix: update Python version to 3.12 and switch from PyInstaller to Nuitka for building executables

# This is the commit message #2:

fix: correct UPX path for Windows installation in CI workflow

# This is the commit message #3:

fix: update UPX installation path handling for Windows and Linux in CI workflow

# This is the commit message #4:

fix: enhance UPX installation by setting UPX_BINARY environment variable for better compatibility

# This is the commit message #5:

fix: add --assume-yes-for-downloads option to Nuitka build commands for improved automation

# This is the commit message #6:

fix: update asset path handling in MainWindow for improved compatibility with frozen applications

# This is the commit message #7:

fix: update Nuitka build options and refine PySide6 dependency for improved compatibility

# This is the commit message #8:

fix: add Windows icon conversion step and update Nuitka build options for improved compatibility

# This is the commit message #9:

fix: refactor Nuitka and UPX installation steps for improved clarity and compatibility
2026-02-11 03:01:31 +01:00
Vxrtrauter b573ca8fed fix: download logging for already completed downloads & improve logging for certain parts of the program 2026-02-10 02:20:14 +01:00
shayaa 8a45d71655 Revise README for clarity and format changes
Updated README to clarify usage instructions and dependencies format.
2026-02-10 01:48:51 +01:00
shayaa 0c7bc426db Change code block syntax from sh to yml 2026-02-10 01:48:25 +01:00
shayaa 1f06a19c17 Change code block language from py to sh 2026-02-10 01:48:03 +01:00
Vxrtrauter da92c4ab0a Merge branch 'main' of https://github.com/KeksPirates/SoftwareManager 2026-02-10 01:47:29 +01:00
Vxrtrauter f6e3ff4f27 docs: update README to clarify features and installation instructions 2026-02-10 01:47:27 +01:00
KeksNino 88042b2d3d fix: downloads randomly resuming after a few seconds when paused
credits to claude opus 4.5, i could not figure this out
2026-02-09 23:17:18 +01:00
Vxrtrauter 9265a9cd1c refactor: change init_uztracker to synchronous and update data formatting for m0nkrus 2026-02-09 04:36:28 +01:00
Vxrtrauter adc9f6f6d4 QOL: tested stuff, fixed main.py typos, removed unused variables and added startup timer because it looks cool 2026-02-08 23:54:10 +01:00
Vxrtrauter a3a6ef5eec fix: update config handling and improve tracker data retrieval 2026-02-07 04:02:14 +01:00
KeksNino 8e5e0425ef feat: add seeders and leechers column in search results 2026-02-07 03:29:52 +01:00
Vxrtrauter c2b329aed4 update: change api request format to new server 2026-02-07 03:03:14 +01:00
KeksNino c5f2be90af fix: move update checkbox to general tab 2026-02-07 01:52:11 +01:00
KeksNino 695b80c6de feat: add tabs to settings menu 2026-02-07 01:47:25 +01:00
Vxrtrauter b7188a515a feat: add flush_log_buffer function and update consoleLog to handle log entries 2026-02-07 01:42:05 +01:00
Vxrtrauter 7b2522438c feat: add network interface binding to settings dialog and update save_settings function 2026-02-06 23:50:18 +01:00
Vxrtrauter 0686c7aec2 Merge branch 'main' of https://github.com/KeksPirates/SoftwareManager 2026-02-06 17:52:21 +01:00
Vxrtrauter 6be1784903 feat: add functionality to retrieve and bind IP address of network interfaces 2026-02-06 17:50:42 +01:00
Vxrtrauter 4589322f0a feat: initialize network interface state and log interface details 2026-02-05 19:15:39 +01:00
shayaa 8132d789ef Add activity section to README
Added activity section with analytics image to README.
2026-02-05 17:35:38 +01:00
Vxrtrauter 3f39e67933 Merge branch 'main' of https://github.com/KeksPirates/SoftwareManager 2026-02-05 17:28:50 +01:00
Vxrtrauter d43d6e57cc add more interface stuff 2026-02-05 00:36:12 +01:00
KeksNino 28cffe59f5 feat: add context menu option to delete file 2026-02-04 21:34:03 +01:00
Vxrtrauter f1726f4e9a Merge branch 'main' of https://github.com/KeksPirates/SoftwareManager 2026-02-04 20:44:51 +01:00
KeksNino b7ca895aeb fix: open folder because state.download_path is None in gui.py 2026-02-04 19:35:35 +01:00
KeksNino e95ad11672 add context menu to cancel downloads, open folder and copy magnet link 2026-02-04 18:56:03 +01:00
Vxrtrauter f2a79e5eed test more stuff 2026-02-04 04:04:31 +01:00
Vxrtrauter 990135450f test network stuff 2026-02-04 03:04:24 +01:00
KeksNino 62c6afe350 disable library tab (may be added again in the future) 2026-02-03 17:41:59 +01:00
KeksNino 1aaf56a4dc fix: window transparency on linux/macos 2026-02-03 17:04:09 +01:00
Vxrtrauter 47b643d68b revert all workflow changes 2026-02-02 02:31:00 +01:00
Vxrtrauter de1c8577d6 test new workflow 2026-02-02 02:26:41 +01:00
Vxrtrauter c835b8375c test new workflow 2026-02-02 02:23:52 +01:00
Vxrtrauter e7291e8e99 revert workflow changes 2026-02-02 02:23:22 +01:00
Vxrtrauter a4efe9185a update workflows 2026-02-02 02:18:33 +01:00
Vxrtrauter 86b4f023ac update QApplication instance handling (switch to pyqtdarktheme-fork) 2026-02-02 00:50:32 +01:00
KeksNino 006da55aa0 fix: pause/resume button showing up twice 2026-02-01 20:28:24 +01:00
KeksNino 214a389ad3 fix: if statement to disable windows transparency 2026-02-01 20:14:49 +01:00
KeksNino d4005d7e86 fix: disable transparency on windows as it is broken 2026-01-31 19:17:30 +01:00
KeksNino 08a45cb422 feat: add qt window transparency 2026-01-31 18:46:40 +01:00
Vxrtrauter c49726fe42 feat: improve cursor styles for buttons and combo box in GUI 2026-01-31 07:51:00 +01:00
Vxrtrauter ecc117dedc feat: enhance GUI layout and styling for better usability 2026-01-31 07:36:40 +01:00
KeksNino c51fc9ea4a feat: remove toolbar, will be improved later 2026-01-31 05:49:30 +01:00
KeksNino 48ed11c248 remove leftover aria2 code 2026-01-31 05:18:45 +01:00
Vxrtrauter d672e3d85f Merge branch 'main' of https://github.com/KeksPirates/SoftwareManager 2026-01-31 05:09:36 +01:00
Vxrtrauter 564c0f2ac5 refactor: enhance settings update logging and ensure console output on save 2026-01-31 05:09:34 +01:00
KeksNino 8a48c6c730 remove aria2 from github CI 2026-01-31 05:08:49 +01:00
Vxrtrauter 6432511639 Merge branch 'main' of https://github.com/KeksPirates/SoftwareManager 2026-01-31 05:04:28 +01:00
Vxrtrauter 054e1d0aa5 add error handling for update_settings 2026-01-31 05:04:26 +01:00
KeksNino 6f7a9e13f4 switch to uv for github workflow to improve workflow time 2026-01-31 05:00:47 +01:00
Vxrtrauter 89de75c3d7 link update_settings to settings dialog 2026-01-31 04:52:38 +01:00
Vxrtrauter f41520e595 fixed minor issues 2026-01-31 04:42:15 +01:00
shayaa 79c8de1dbf Merge pull request #29 from KeksPirates/switch-lt
Fully replace Aria2 with LibTorrent
2026-01-31 04:37:24 +01:00
shayaa dc933d54f3 fix broken merge 2026-01-31 04:36:49 +01:00
shayaa f78d8780cf Merge branch 'main' into switch-lt 2026-01-31 04:25:40 +01:00
Vxrtrauter 7f155547ec add libtorrent and libtorrent-windows-dll to requirements 2026-01-31 04:19:28 +01:00
Vxrtrauter 8e431f9826 integrate libtorrent for download status notifications and update logging 2026-01-31 04:17:20 +01:00
Vxrtrauter 748f0865b7 refactor shutdown process and integrate libtorrent session cleanup 2026-01-31 04:12:37 +01:00
Vxrtrauter a19cf469f6 update configs and settings 2026-01-31 03:54:38 +01:00
Vxrtrauter 8f9f77d63f update settings dialog & config, remove aria2 2026-01-31 03:05:31 +01:00
Vxrtrauter fa3a43887a integrate downloads in downloads tab 2026-01-31 02:20:26 +01:00
Vxrtrauter 685461e453 integrate download func in gui (partially) 2026-01-31 00:44:55 +01:00
KeksNino f3379174b0 feat: improve pause/resume/open folder button in downloads tab 2026-01-30 21:59:31 +01:00
Vxrtrauter b217a74fce add basic libtorrent integration 2026-01-30 21:31:30 +01:00
KeksNino 1da62ef414 feat: browse directories to select download_path and image_path 2026-01-30 10:43:43 +01:00
KeksNino 4b5c36030d fix macos open folder button 2026-01-30 10:32:10 +01:00
KeksNino cc5f6a804c show open folder button when download is complete 2026-01-29 22:55:30 +01:00
KeksNino b783d0a0cb only show ignore auto updates on windows 2026-01-24 10:28:58 +01:00
KeksNino d057624548 fix pause/resume and add eta + completed length in download 2026-01-23 23:18:04 +01:00
KeksNino 81f7970c47 fix workflow with ubuntu 22.04 2026-01-23 15:27:22 +01:00
KeksNino 6a41ac44c9 switch workflow to ubuntu 22.04 for better glibc compat 2026-01-22 19:21:52 +01:00
Vxrtrauter 26c5410f3f Merge branch 'main' of https://github.com/KeksPirates/SoftwareManager 2026-01-20 17:41:43 +01:00
Vxrtrauter 7067f243ae update appid 2026-01-20 17:39:06 +01:00
Vxrtrauter d82008c9fe fix no item in downloads text 2026-01-20 11:26:49 +01:00
Vxrtrauter 83ff2720c8 add error handling for magnet uri being none 2026-01-20 10:43:02 +01:00
Vxrtrauter 83adbb027f fix aria2 connection refused errors caused by autoresume 2026-01-19 13:49:20 +01:00
KeksNino 606c70efcc add unicode icons for pause/resume and fix action column size 2026-01-18 21:39:40 +01:00
KeksNino 9970086901 WIP: add pause/resume button in downloads tab 2026-01-18 02:03:31 +01:00
Vxrtrauter 0d631e0ab8 fix assets not showing up in build 2026-01-17 15:26:08 +01:00
Vxrtrauter 1c1ce3e69d update workflow again 2026-01-17 14:56:30 +01:00
Vxrtrauter b5931f5590 fix actions 2026-01-17 14:51:53 +01:00
Vxrtrauter 84ea030e2a Enhance asset handling: update icon loading to use dynamic path resolution 2026-01-17 14:46:07 +01:00
Vxrtrauter 356f3bf070 fix dependencies 2026-01-17 14:34:47 +01:00
Vxrtrauter 6ff29e2ea6 add logo to gui and assets 2026-01-17 14:30:48 +01:00
Vxrtrauter 847b50ab10 image overlay error message: add check for valid image path 2026-01-17 14:08:47 +01:00
Vxrtrauter 5bc81b5215 Enhance console logging: add option to print logs regardless of debug state 2026-01-17 02:00:16 +01:00
Vxrtrauter efe83613d0 Fixed Download Log Updating and add more Logging 2026-01-17 01:44:28 +01:00
KeksNino 3597cba207 update pyside6 version 2026-01-14 18:50:11 +01:00
Vxrtrauter 749051aa4f Enhance build process: add backports dependencies and generate release notes 2026-01-14 14:21:00 +01:00
Vxrtrauter bd26f073e4 fix config issues (fallback values, prevent errors) 2026-01-14 14:02:31 +01:00
Vxrtrauter 027b4aff8d improved debug & console outputs 2026-01-14 13:47:58 +01:00
Vxrtrauter a386269f29 add console logging in gui, remove progress bar from gui, add new logging function, refactor all print statements to use new log function 2026-01-14 11:27:22 +01:00
Vxrtrauter 9afced8a0b Add auto-resume functionality and update settings dialog 2026-01-14 00:18:39 +01:00
Vxrtrauter db77bed3cf add autoresume variable functionality 2026-01-13 23:58:23 +01:00
Vxrtrauter cc3b201450 added basic download resuming 2026-01-13 23:51:25 +01:00
Vxrtrauter 5a9d9067b9 fix exception spam 2026-01-13 21:35:03 +01:00
Vxrtrauter e0eaf250e5 Merge branch 'main' of https://github.com/KeksPirates/SoftwareManager 2026-01-13 21:32:07 +01:00
Vxrtrauter 1c4c010e4e Add download completion tracking and update logging functionality 2026-01-13 21:31:33 +01:00
shayaa c1509f9172 fix crash because of me being retard 2026-01-13 20:36:15 +01:00
Vxrtrauter 97eabf6ec6 Add basic download logging + fix debug 2026-01-09 23:23:16 +01:00
Vxrtrauter 5eca1a678e Merge branch 'main' of https://github.com/KeksPirates/SoftwareManager 2026-01-07 23:07:14 +01:00
Vxrtrauter 7e59fdc2d3 add (almost) final download log logic 2026-01-07 23:07:08 +01:00
Vxrtrauter ad6fa428a0 add basic download log json structure 2026-01-07 22:41:11 +01:00
Vxrtrauter 56e8fada4d add settings_path to AppState and add models for download logging 2026-01-07 22:23:29 +01:00
shayaa e950d2f90d Refactor dev.yml to use bash for build steps
Updated the workflow to use bash for injecting commit hash and building executables on Windows and Unix. (done with claude)
2025-12-20 04:29:22 +01:00
Vxrtrauter 1a59c45ba4 fix qtablewidget section width 2025-12-16 11:25:40 +01:00
Vxrtrauter 293c1c3ad1 fix settings icon again? probably os issue 2025-12-16 11:00:11 +01:00
KeksNino f30cc5da5e fix typo 2025-11-15 00:20:41 +01:00
KeksNino 004c7ad416 add build_info.json
to fix updater when running from source
2025-11-15 00:19:50 +01:00
Vxrtrauter 65f7c04936 refactor: clean up unused imports and improve code organization 2025-11-15 00:16:47 +01:00
KeksNino 9d0d5332bf fixed updater 2025-10-31 13:45:30 +01:00
KeksNino 6d9991188a fixed config parser error handling 2025-10-31 03:22:47 +01:00
KeksNino f397bc18ce added option to display image in bottom right 2025-10-31 03:18:59 +01:00
KeksNino 59e45efbb6 fix aria2c dependency in pyinstaller 2025-10-25 21:59:29 +02:00
KeksNino 27f0a3f71d fix wget path on windows in workflows 2025-10-25 21:57:31 +02:00
KeksNino 1d500305f7 add aria2c dependency to exe binary on windows 2025-10-25 21:52:39 +02:00
Vxrtrauter 20a310264f Merge branch 'main' of https://github.com/KeksPirates/SoftwareManager 2025-10-24 10:51:51 +02:00
Vxrtrauter aee8f16353 rm unused import 2025-10-24 10:50:52 +02:00
KeksNino 2354adb349 update updater download url 2025-10-20 19:10:49 +02:00
KeksNino 4c69a17f0e change back to releases for github workflow 2025-10-20 18:44:42 +02:00
KeksNino 2bab6c3321 fix old filename for updater 2025-10-20 18:22:16 +02:00
KeksNino 91482760b6 fix updater filename 2025-10-20 18:14:09 +02:00
KeksNino 83fa23df58 fix release file names 2025-10-20 17:58:40 +02:00
KeksNino 41d28095c2 fix workflows 2025-10-20 17:53:35 +02:00
KeksNino de2e49e60b add back commit sha to release file name 2025-10-20 17:40:39 +02:00
KeksNino dd6d16d833 fix workflows (again) 2025-10-20 17:24:04 +02:00
KeksNino 7d0c8d16ec fix workflows (again) 2025-10-20 17:21:47 +02:00
KeksNino 80750f010f fix workflows (again) 2025-10-20 17:00:14 +02:00
KeksNino c93f6345c8 fix workflows (again) 2025-10-20 16:36:10 +02:00
KeksNino a7132ed1cd fix workflows (again) 2025-10-19 19:45:19 +02:00
KeksNino a2cff15d0d fix workflows (hopefully) 2025-10-19 19:39:33 +02:00
KeksNino 2eb384f7cf fix build_info.json 2025-10-19 17:51:21 +02:00
KeksNino 05a5b04507 fix error if build_info doesnt exist in source code 2025-10-19 16:22:49 +02:00
KeksNino 87c974f124 add build_info.json to workflow for win updater 2025-10-19 15:58:16 +02:00
KeksNino 95a687ddd9 fix updater 2025-10-19 04:07:41 +02:00
KeksNino 813d2b31c4 add setting to ignore updates on windows 2025-10-19 04:05:57 +02:00
Vxrtrauter deb5ce5b88 fixed aria2 killing issues on settings close 2025-10-19 03:53:52 +02:00
52 changed files with 2506 additions and 1037 deletions
+180 -68
View File
@@ -15,7 +15,7 @@ permissions:
contents: write
env:
PYTHON_VERSION: "3.11"
PYTHON_VERSION: "3.12"
APP_NAME: "SoftwareManager"
jobs:
@@ -24,119 +24,231 @@ jobs:
strategy:
matrix:
include:
- os: ubuntu-latest
artifact_name: SoftwareManager-dev-linux
asset_name: SoftwareManager-dev-linux
- os: ubuntu-22.04
artifact_name: SoftwareManager-dev-${{ github.sha }}-linux
asset_name: SoftwareManager-dev-${{ github.sha }}-linux
- os: windows-latest
artifact_name: SoftwareManager-dev-windows.exe
asset_name: SoftwareManager-dev-windows.exe
artifact_name: SoftwareManager-dev-${{ github.sha }}-windows.exe
asset_name: SoftwareManager-dev-${{ github.sha }}-windows.exe
- os: macos-latest
artifact_name: SoftwareManager-dev-macos
asset_name: SoftwareManager-dev-macos
artifact_name: SoftwareManager-dev-${{ github.sha }}-macos
asset_name: SoftwareManager-dev-${{ github.sha }}-macos
runs-on: ${{ matrix.os }}
outputs:
version: ${{ steps.version.outputs.version }}
short_sha: ${{ steps.version.outputs.short_sha }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install system dependencies (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libxcb-xinerama0 \
libxkbcommon-x11-0 \
libxcb-cursor0 \
libxcb-keysyms1 \
libxcb-render-util0 \
libxcb-icccm4 \
libxcb-image0 \
libxcb-shape0 \
libxcb-util1
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "0.9.7"
enable-cache: true
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pyinstaller
uv pip install -r requirements.txt
uv pip install Pillow
env:
UV_SYSTEM_PYTHON: 1
- name: Build executable with PyInstaller
run: pyinstaller --onefile --windowed --name ${{ env.APP_NAME }} main.py
- name: Install Nuitka (Windows only)
if: runner.os == 'Windows'
run: uv pip install nuitka
env:
UV_SYSTEM_PYTHON: 1
- name: Make executable (Unix)
if: runner.os != 'Windows'
run: chmod +x dist/${{ env.APP_NAME }}
- name: Generate version
id: version
shell: bash
run: |
COMMIT_SHA=${{ github.sha }}
SHORT_SHA=${COMMIT_SHA:0:7}
VERSION="${SHORT_SHA}-dev"
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
echo "Generated version: $VERSION"
- 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/core/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 \
--onefile \
--onefile-no-compression \
--onefile-tempdir-spec="{CACHE_DIR}/${{ env.APP_NAME }}/${{ env.VERSION }}" \
--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 \
--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 \
src/main.py
- name: Build with PyInstaller (Linux)
if: runner.os == 'Linux'
shell: bash
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 \
--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:." \
--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 \
--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: |
cd dist
mv ${{ env.APP_NAME }}${{ runner.os == 'Windows' && '.exe' || '' }} ${{ matrix.artifact_name }}
ls -la
mkdir -p dist-final
if [ "$RUNNER_OS" == "Windows" ]; then
mv ${{ env.APP_NAME }}.exe dist-final/${{ matrix.artifact_name }}
else
chmod +x dist/${{ env.APP_NAME }}
mv dist/${{ env.APP_NAME }} dist-final/${{ matrix.artifact_name }}
fi
ls -la dist-final/
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.os }}-build
path: dist/${{ matrix.artifact_name }}
path: dist-final/${{ matrix.artifact_name }}
retention-days: 1
release:
name: Create Release
needs: build
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
if: success()
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: artifacts
- name: Display downloaded artifacts
run: |
echo "Downloaded artifacts:"
find artifacts -type f
ls -la artifacts/
- name: Generate version
id: version
run: |
COMMIT_SHA=${{ github.sha }}
SHORT_SHA=${COMMIT_SHA:0:7}
VERSION="${SHORT_SHA}-dev"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT
echo "Generated version: ${VERSION}"
- name: Prepare release files
run: |
mkdir -p release
cp artifacts/ubuntu-latest-build/SoftwareManager-dev-linux release/SoftwareManager-dev-linux
cp artifacts/windows-latest-build/SoftwareManager-dev-windows.exe release/SoftwareManager-dev-windows.exe
cp artifacts/macos-latest-build/SoftwareManager-dev-macos release/SoftwareManager-dev-macos
chmod +x release/SoftwareManager-dev-linux release/SoftwareManager-dev-macos
SHORT_SHA="${{ needs.build.outputs.short_sha }}"
cp artifacts/ubuntu-22.04-build/SoftwareManager-dev-*-linux release/SoftwareManager-dev-${SHORT_SHA}-linux
cp artifacts/windows-latest-build/SoftwareManager-dev-*-windows.exe release/SoftwareManager-dev-${SHORT_SHA}-windows.exe
cp artifacts/macos-latest-build/SoftwareManager-dev-*-macos release/SoftwareManager-dev-${SHORT_SHA}-macos
chmod +x release/SoftwareManager-dev-*-linux release/SoftwareManager-dev-*-macos
ls -la release/
- name: Generate checksums
run: |
cd release
sha256sum * > SHA256SUMS.txt
cat SHA256SUMS.txt
- name: Generate release notes
run: |
SHORT_SHA="${{ needs.build.outputs.short_sha }}"
CHECKSUMS=$(cat release/SHA256SUMS.txt)
cat > release_notes.md << EOF
## Development Build
**Commit:** [\`${SHORT_SHA}\`](https://github.com/${{ github.repository }}/commit/${{ github.sha }})
### Changes
$(git log -1 --pretty=format:"- %s" ${{ github.sha }})
### Downloads
- Linux: \`SoftwareManager-dev-${SHORT_SHA}-linux\`
- Windows: \`SoftwareManager-dev-${SHORT_SHA}-windows.exe\`
- macOS: \`SoftwareManager-dev-${SHORT_SHA}-macos\`
### SHA-256 Checksums
\`\`\`
${CHECKSUMS}
\`\`\`
EOF
cat release_notes.md
- name: Create Release (Public)
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.version }}
name: ${{ steps.version.outputs.version }}
draft: false
prerelease: false
tag_name: ${{ needs.build.outputs.version }}
name: ${{ needs.build.outputs.version }}
body_path: release_notes.md
files: |
release/SoftwareManager-dev-linux
release/SoftwareManager-dev-windows.exe
release/SoftwareManager-dev-macos
release/SoftwareManager-dev-${{ needs.build.outputs.short_sha }}-linux
release/SoftwareManager-dev-${{ needs.build.outputs.short_sha }}-windows.exe
release/SoftwareManager-dev-${{ needs.build.outputs.short_sha }}-macos
release/SHA256SUMS.txt
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+4
View File
@@ -214,3 +214,7 @@ __marimo__/
*.sublime-project
*.code-workspace
*.swp
# Local build files
build-local.ps1
build-local.bat
+40 -10
View File
@@ -1,11 +1,11 @@
# SoftwareManager - A Download Manager/Launcher
SoftwareManager is a Python-based GUI/TUI tool that simplifies downloading and managing free software from various sources.
# SoftwareManager
SoftwareManager is a Python-based GUI tool that simplifies searching and downloading software from various sources, including Rutracker, Uztracker and the official M0nkrus Telegram channel.
## Features
- VPN Binding (impl. soon)
- Bandwidth/Download speed limiting
- Start downloads via a predefined magnet URI.
- View detailed download information (name, speed, and status).
- Searching & Downloading Software from various pages
- Network Interface Binding
- Simple UI, Speed Limiting etc.
- No Logins required
## Installation
1. Download the binary file for your operating system from releases
@@ -14,19 +14,44 @@ SoftwareManager is a Python-based GUI/TUI tool that simplifies downloading and m
## Manual Installation
1. Ensure Python 3.x is installed on your system.
2. Clone the Repository:
```bash
git clone https://github.com/KeksPirates/SoftwareManager.git && cd SoftwareManager
```
2. Install Dependencies:
```bash
pip install -r requirements.txt
```
3. Run main.py:
```bash
python3 main.py
python main.py
```
## Notes
- If you want to host your own server, clone the server branch of this repo, enter your cookie for rutracker.org and run server.py. Detailed Instructions on how to get your rutracker cookie are in the README of its branch.
- This tool is for educational purposes only. Use it responsibly.
## Additional Information
- SoftwareManager is still in Development and most likely contains issues. Please report any bugs you find via the Issues tab. Contributions are also greatly appreciated.
- MacOS Builds haven't been properly tested - We're thankful to receive feedback.
- If you want to host your own server, clone our [server repository](https://github.com/KeksPirates/SoftwareManager-Server), 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.
## Python Dependencies
(also included in requirements.txt)
```bash
requests (2.32.2)
PySide6 (6.10.1)
beautifulsoup (44.13.5)
darkdetect (0.7.1)
pyinstaller (6.15.0)
PyQtDarkTheme-fork (2.3.4)
aiohttp (3.13.0)
plyer (2.1.0)
psutil (7.1.0)
libtorrent (2.0.11)
libtorrent-windows-dll (0.0.3)
```
## Activity
![Alt](https://repobeats.axiom.co/api/embed/3a61ea02ffdeb9ed5dd948d974a21e0d6cfae36c.svg "Repobeats analytics image")
## Star History
@@ -38,6 +63,11 @@ SoftwareManager is a Python-based GUI/TUI tool that simplifies downloading and m
</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.
-22
View File
@@ -1,22 +0,0 @@
import requests
from core.utils.data.state import state
def scrape_rutracker(search_text):
search = requests.get(f"{state.api_url}/search/{search_text}")
if state.debug:
print("Sent request to server")
if search:
try:
return search.text
except Exception:
if state.debug:
print("No results found / No response from server")
return None
else:
return None
# This function utilizes the SoftwareManager server - source code can be found under the "server" branch.
-88
View File
@@ -1,88 +0,0 @@
import requests
import asyncio
import aiohttp
from bs4 import BeautifulSoup
from core.utils.data.state import state
global url_uztracker
url_uztracker = "https://uztracker.net/tracker.php?nm="
async def init_uztracker():
global up
global soup
try:
response = requests.get(url_uztracker, timeout=10)
if response.status_code == 200:
up = True
else:
up = False
if state.debug:
print(f"Uztracker seems down, status code: {response.status_code}")
except requests.exceptions.RequestException as e:
if state.debug:
print(f"\nRequest Exception on {url_uztracker}:")
print(e)
print("\nIs the Site down?")
up = False
asyncio.run(init_uztracker())
def scrape_uztracker(search):
if up:
search_url = url_uztracker + search
if state.debug:
print(search_url)
result = False
global results
global resulttitles
results = []
resulttitles = []
try:
resultCount = 0
response = requests.get(search_url)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
links = soup.find_all('a', class_="genmed tLink", href=lambda x: x and x.startswith('./viewtopic'))
for link in links:
if link.b:
resultCount += 1
results.append(link['href'])
resulttitles.append(link.b.text)
result = True
if result:
return resulttitles, results
if not result:
# add popup in gui for no result
return None
except requests.RequestException as e:
if result and state.debug:
print(f"Failed to fetch {search_url}: {e}")
return None
else:
if state.debug:
print("Error: Uztracker down")
return None, None
def get_post_title(post_url):
if up:
response = requests.get(post_url)
soup = BeautifulSoup(response.text, 'html.parser')
maintitle = soup.find(class_='tt-text')
if maintitle:
return maintitle.text
else:
if state.debug:
print("Program not found")
else:
if state.debug:
print("Error: Uztracker down")
return None
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

-122
View File
@@ -1,122 +0,0 @@
from core.utils.config.settings import save_settings
from core.utils.data.state import state
from PySide6 import QtWidgets
from PySide6.QtWidgets import (
QLineEdit,
QPushButton,
QWidget,
QVBoxLayout,
QDialog,
QLabel,
QHBoxLayout,
QSpinBox,
)
def settings_dialog(self):
if state.debug:
print("Settings dialog opened")
dialog = QDialog(self)
dialog.setWindowTitle("Settings")
dialog.setFixedSize(800, 400)
dialog.setLayout(QVBoxLayout())
dialog.layout().addWidget(QLabel("Settings"))
def close_settings():
dialog.reject()
##################
# THREAD SETTING #
##################
thread_box = QSpinBox()
thread_box.setMinimum(1)
thread_box.setMaximum(16)
thread_box.setValue(state.aria2_threads)
# container for tight space
thread_container = QWidget()
thread_layout = QHBoxLayout()
thread_layout.addWidget(QLabel("Threads:"))
thread_layout.addWidget(thread_box)
thread_container.setLayout(thread_layout)
thread_container.setMaximumHeight(80)
# Dimensions
thread_box.setFixedWidth(90)
thread_box.setFixedHeight(30)
dialog.layout().addWidget(thread_container)
##################
# 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)
dialog.layout().addWidget(api_url_container)
#################
# 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)
dialog.layout().addWidget(download_path_container)
##################
# SPEED LIMITING #
##################
speed_limit_container = QWidget()
speed_limit_layout = QHBoxLayout()
speed_limit_layout.addWidget(QLabel("Max Download Speed (KiB, 0 for unlimited): "))
speed_limit = QSpinBox()
speed_limit.setMinimum(0)
speed_limit.setMaximum(10000000)
speed_limit.setValue(state.speed_limit)
speed_limit_container.setLayout(speed_limit_layout)
speed_limit_layout.addWidget(speed_limit)
speed_limit.setFixedWidth(180)
speed_limit.setFixedHeight(30)
dialog.layout().addWidget(speed_limit_container)
###############
# SAVE/CANCEL #
###############
layout = QHBoxLayout()
save_btn = QPushButton("Save")
cancel_btn = QPushButton("Cancel")
save_btn.clicked.connect(lambda: save_settings(thread_box.value(), close_settings, api_url.text(), download_path.text(), speed_limit.value()))
cancel_btn.clicked.connect(dialog.reject)
layout.addWidget(cancel_btn)
layout.addWidget(save_btn)
dialog.layout().addLayout(layout)
dialog.exec()
-243
View File
@@ -1,243 +0,0 @@
from PySide6 import QtWidgets
from PySide6 import QtCore
from PySide6.QtCore import Qt, QTimer, QModelIndex, QAbstractTableModel
from PySide6.QtWidgets import (
QLineEdit,
QTableView,
QWidget,
QVBoxLayout,
QListWidget,
QToolBar,
QLabel,
QHBoxLayout,
QComboBox,
QTabWidget,
QProgressBar,
QHeaderView,
QMessageBox,
QTableWidget,
QTableWidgetItem
)
from PySide6.QtGui import QIcon, QAction, QCloseEvent, QPalette
import darkdetect
import threading
import platform
import requests as r
import os
import subprocess
import time
import sys
from core.utils.config.settings import save_settings
from core.utils.general.wrappers import run_thread
from core.utils.data.state import state
from core.utils.network.download import download_selected
from core.utils.network.update_checker import check_for_updates
from core.utils.general.shutdown import closehelper
from core.interface.utils.tabhelper import create_tab
from core.interface.utils.searchhelper import return_pressed
from core.interface.dialogs.settings import settings_dialog
from core.network.aria2_integration import dlprogress
def download_update(latest_version):
filename = "SoftwareManager-dev-windows.exe"
url = "https://github.com/KeksPirates/SoftwareManager/releases/latest/download/SoftwareManager-dev-windows.exe"
print("Downloading update...")
if os.path.exists(filename):
os.remove(filename)
response = r.get(url, allow_redirects=True)
with open(filename, "wb") as f:
f.write(response.content)
if not os.path.exists(filename):
raise FileNotFoundError(f"Executable not found")
subprocess.Popen([filename], shell=True)
time.sleep(0.5)
save_settings(version=latest_version)
sys.exit(0)
class MainWindow(QtWidgets.QMainWindow, QWidget):
def __init__(self):
super().__init__()
# Check for updates on Windows
if platform.system() == "Windows":
result = check_for_updates()
assets, latest_version = result
if assets:
msg = QMessageBox()
msg.setIcon(QMessageBox.Information)
msg.setWindowTitle("Update Available")
msg.setText(f"A new version is available.")
msg.setInformativeText("Please visit the GitHub releases page to download the latest version.")
msg.setStandardButtons(QMessageBox.Ok | QMessageBox.Ignore)
response = msg.exec_()
if response == QMessageBox.Ok:
download_update(latest_version)
self.setWindowTitle("Software Manager")
self.setGeometry(100, 100, 800, 600)
self.controls = QWidget()
self.controlsLayout = QVBoxLayout()
# Widgets
self.searchbar = QLineEdit()
self.searchbar.setPlaceholderText("Search for software...")
self.searchbar.setClearButtonEnabled(True)
self.searchbar.setMinimumHeight(30)
self.searchbar.returnPressed.connect(lambda: run_thread(threading.Thread(target=return_pressed, args=(self,)))) # Triggers data function thread on enter
self.dlbutton = QtWidgets.QPushButton("Download")
self.libraryList = QListWidget()
self.emptyResults = QLabel("No Results")
self.emptyResults.setAlignment(Qt.AlignCenter)
self.emptyResults.hide()
self.download_model = None
self.downloadList = QTableView()
self.emptyLibrary = QLabel("No items in library.")
self.emptyDownload = QLabel("No items in downloads.")
self.progressbar = QProgressBar()
self.qtablewidget = QTableWidget()
self.qtablewidget.setColumnCount(2)
self.qtablewidget.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
self.qtablewidget.verticalHeader().setVisible(False)
self.qtablewidget.setHorizontalHeaderLabels(["Post Title", "Author"])
self.qtablewidget.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.qtablewidget.setAttribute(Qt.WA_TranslucentBackground)
self.qtablewidget.viewport().setAttribute(Qt.WA_TranslucentBackground)
header = self.qtablewidget.horizontalHeader()
header.setSectionResizeMode(1, QHeaderView.Fixed)
header.resizeSection(1, 500)
container = QWidget()
containerLayout = QVBoxLayout()
containerLayout.addWidget(self.searchbar)
containerLayout.addWidget(self.qtablewidget)
class DownloadModel(QAbstractTableModel):
def __init__(self):
super().__init__()
self.headers = ["Name", "Status", "Progress", "Speed", "Size", "Total Size"]
def rowCount(self, parent=QModelIndex()):
return len(state.downloads)
def columnCount(self, parent=QModelIndex()):
return len(self.headers)
def headerData(self, section, orientation, role=Qt.DisplayRole):
if role == Qt.DisplayRole and orientation == Qt.Horizontal:
return self.headers[section]
return None
def data(self, index, role=Qt.DisplayRole):
if role == Qt.DisplayRole:
download = state.downloads[index.row()]
col = index.column()
if col == 0:
return download.name
elif col == 1:
return getattr(download, 'status', 'Downloading')
elif col == 2:
return f"{int(download.progress)}%"
elif col == 3:
return f"{download.download_speed_string()}"
elif col == 4:
pass
elif col == 5:
return f"{download.total_length_string()}"
return None
self.download_model = DownloadModel()
self.downloadList.setModel(self.download_model)
self.downloadList.horizontalHeader().setStretchLastSection(False)
self.downloadList.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.downloadList.setSelectionBehavior(QTableView.SelectRows)
self.downloadList.setAttribute(Qt.WA_TranslucentBackground)
self.downloadList.viewport().setAttribute(Qt.WA_TranslucentBackground)
# download button triggers
self.dlbutton.clicked.connect(lambda: run_thread(threading.Thread(target=download_selected, args=(self.qtablewidget.currentItem(), state.posts, state.post_titles))))
container.setLayout(containerLayout)
self.setCentralWidget(container)
# Tabs
self.tabs = QTabWidget()
self.horizontal_layout = QHBoxLayout()
self.horizontal_layout.addWidget(self.emptyResults, stretch=3)
self.horizontal_layout.addWidget(self.qtablewidget)
self.tab1 = create_tab("Search", self.searchbar, self.qtablewidget, self.tabs, self.dlbutton, self.horizontal_layout)
self.tab2 = create_tab("Library", self.emptyLibrary, self.libraryList, self.tabs, None, None)
self.tab3 = create_tab("Downloads", self.emptyDownload, self.downloadList, self.tabs, None, None)
containerLayout.addWidget(self.tabs)
containerLayout.setAlignment(Qt.AlignmentFlag.AlignBottom)
toolbar = QToolBar("Main Toolbar")
self.addToolBar(toolbar)
toolbar.setLayoutDirection(Qt.RightToLeft)
self.tracker_list = QComboBox()
self.tracker_list.addItems(["rutracker", "uztracker", "m0nkrus"])
self.tracker_list.activated.connect(self.set_tracker)
if darkdetect.isDark():
settings_action = QAction(QIcon("core/interface/assets/settings_white.png"), "Settings", self)
else:
settings_action = QAction(QIcon("core/interface/assets/settings_black.png"), "Settings", self)
settings_action.triggered.connect(lambda: settings_dialog(self))
toolbar.addAction(settings_action)
toolbar.addWidget(self.tracker_list)
toolbar.setMovable(False)
self.progressbar.setValue(0)
containerLayout.addWidget(self.progressbar)
self.progress_timer = QTimer()
self.progress_timer.timeout.connect(lambda: run_thread(threading.Thread(target=self.update_progress)))
self.progress_timer.start(1000)
self.download_timer = QTimer()
self.download_timer.timeout.connect(lambda: run_thread(threading.Thread(target=self.download_list_update)))
self.download_timer.start(500)
def download_list_update(self):
if self.download_model:
self.download_model.layoutAboutToBeChanged.emit()
self.download_model.layoutChanged.emit()
def closeEvent(self, event: QCloseEvent):
closehelper()
event.accept()
def set_tracker(self, _):
state.tracker = self.tracker_list.currentText()
def update_progress(self):
progress = dlprogress()
self.progressbar.setValue(progress)
def show_empty_results(self, show: bool):
if show:
self.qtablewidget.hide()
self.emptyResults.show()
else:
self.emptyResults.hide()
self.qtablewidget.show()
-85
View File
@@ -1,85 +0,0 @@
from PySide6.QtWidgets import QTableWidgetItem
from core.data.scrapers.uztracker import scrape_uztracker
from core.data.scrapers.rutracker import scrape_rutracker
from core.data.scrapers.monkrus import scrape_monkrus_telegram
from core.utils.network.jsonhandler import split_data, format_data
from core.utils.data.state import state
def return_pressed(self):
search_text = self.searchbar.text()
if search_text == "":
if state.debug:
print("Error: Can't search for nothing")
return
if state.debug:
print("User searched for:", search_text)
if state.tracker == "uztracker":
response = scrape_uztracker(search_text)
if response:
state.post_titles, state.post_urls = response
self.qtablewidget.clear()
if state.post_titles and len(state.post_titles) > 0:
self.qtablewidget.setHorizontalHeaderLabels(["Post Title", "Author"])
self.qtablewidget.setRowCount(len(state.post_titles))
for i, author in enumerate(state.post_author):
self.qtablewidget.setItem(i, 1, QTableWidgetItem(author))
for i, title in enumerate(state.post_titles):
self.qtablewidget.setItem(i, 0, QTableWidgetItem(title))
self.show_empty_results(False)
else:
if state.debug:
print(f"No Results for {search_text}")
self.qtablewidget.clear()
self.show_empty_results(True)
else:
if state.debug:
print(f"No response from uztracker")
self.qtablewidget.clear()
self.show_empty_results(True)
elif state.tracker == "rutracker":
response = scrape_rutracker(search_text)
if response:
_, state.posts, _, _, cached = split_data(response)
if state.posts == []:
if state.debug:
print(f"No Results for {search_text}")
self.qtablewidget.clear()
self.show_empty_results(True)
else:
state.post_titles, _, state.post_author = format_data(state.posts)
self.show_empty_results(False)
self.qtablewidget.clear()
self.qtablewidget.setHorizontalHeaderLabels(["Post Title", "Author"])
self.qtablewidget.setRowCount(len(state.post_titles))
for i, author in enumerate(state.post_author):
self.qtablewidget.setItem(i, 1, QTableWidgetItem(author))
for i, title in enumerate(state.post_titles):
self.qtablewidget.setItem(i, 0, QTableWidgetItem(title))
if state.debug == True:
print(f"Response Cached: {cached}")
else:
if state.debug:
print(f"No response from rutracker")
self.qtablewidget.clear()
self.show_empty_results(True)
elif state.tracker == "m0nkrus":
state.posts = scrape_monkrus_telegram(search_text)
if state.posts == []:
if state.debug:
print(f"No Results for {search_text}")
self.qtablewidget.clear()
self.show_empty_results(True)
else:
state.post_titles, _, state.post_author = format_data(state.posts)
self.show_empty_results(False)
self.qtablewidget.clear()
self.qtablewidget.setHorizontalHeaderLabels(["Post Title", "Author"])
self.qtablewidget.setRowCount(len(state.post_titles))
for i, author in enumerate(state.post_author):
self.qtablewidget.setItem(i, 1, QTableWidgetItem(author))
for i, title in enumerate(state.post_titles):
self.qtablewidget.setItem(i, 0, QTableWidgetItem(title))
-17
View File
@@ -1,17 +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
-75
View File
@@ -1,75 +0,0 @@
import aria2p
import subprocess
from core.utils.data.state import state
from plyer import notification
import time
import sys
def run_aria2p():
state.aria2 = aria2p.API(
aria2p.Client(
host="http://localhost",
port=6800,
secret=""
)
)
return state.aria2
def aria2server():
download_path = state.download_path
speed_limit = state.speed_limit
cmd = [
"aria2c",
"--enable-rpc",
"--disable-ipv6", # added this since it caused problems with vpns
"--rpc-listen-all",
"--rpc-listen-port=6800",
f"--dir={download_path}",
f"--max-download-limit={speed_limit * 1024}",
"-x", str(state.aria2_threads),
"-s", str(state.aria2_threads),
]
aria2server = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0
)
return aria2server
def dlprogress():
try:
state.downloads = [download for download in state.aria2.get_downloads() if not download.is_metadata]
downloads = state.downloads
if downloads:
for download in downloads:
progress = download.progress_string(0)
progress_int = int(progress.strip('%'))
return progress_int
return 0
except:
return 0
def send_notification(shutdown_event):
notified = set()
while not shutdown_event.is_set():
try:
for d in state.aria2.get_downloads():
if d.is_metadata:
continue
if d.progress == 100 and d.gid not in notified:
notification.notify(
title="Download finished",
message=f"{d.name} has finished downloading.",
timeout=4
)
notified.add(d.gid)
state.downloads = [d for d in state.aria2.get_downloads() if d.is_active and not d.is_metadata]
except Exception:
pass
time.sleep(5)
-16
View File
@@ -1,16 +0,0 @@
from .aria2_integration import run_aria2p
from core.utils.data.state import state
def start_client():
global aria2
aria2 = run_aria2p()
if state.debug:
print("Started Aria2p")
def add_magnet(uri):
aria2.add_magnet(uri)
if state.debug:
print("Magnet URI added to Aria2")
-45
View File
@@ -1,45 +0,0 @@
import os
import platform
import configparser
from core.utils.data.state import state
def create_config():
config = configparser.ConfigParser()
config["General"] = {"debug": True, "api_url": f"{state.api_url}", "aria2_threads": f"{state.aria2_threads}", "download_path": f"{state.download_path}", f"speed_limit": f"{state.speed_limit}",
"version": f"{state.version}"}
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"))
settings_path = os.path.join(config_dir, "SoftwareManager")
os.makedirs(settings_path, exist_ok=True)
with open(os.path.join(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"))
settings_path = os.path.join(config_dir, "SoftwareManager")
config_file = os.path.join(settings_path, "config.yml")
if not os.path.exists(config_file):
create_config()
config.read(config_file)
state.debug = config.getboolean("General", "debug")
state.api_url = config.get("General", "api_url")
state.aria2_threads = config.getint("General", "aria2_threads")
state.download_path = config.get("General", "download_path")
state.speed_limit = config.getint("General", "speed_limit")
state.version = config.get("General", "version")
-50
View File
@@ -1,50 +0,0 @@
from core.utils.data.state import state
from .config import create_config
def restart_aria2c():
import main # had to do this because of circle import :(
import signal
import atexit
import time
try:
main.kill_aria2server()
except Exception as e:
print(f"Warning: aria2server kill failed: {e}")
try:
if state.aria2process:
state.aria2process.wait(timeout=3)
except Exception as e:
print(f"Warning: aria2process wait failed: {e}")
time.sleep(0.5)
try:
state.aria2process = main.run_aria2server()
except Exception as e:
print(f"Error: failed to restart aria2server: {e}")
return
try:
signal.signal(signal.SIGINT, main.keyboardinterrupthandler)
atexit.unregister(main.kill_aria2server)
atexit.register(main.kill_aria2server)
except Exception as e:
print(f"Warning: signal/atexit setup failed: {e}")
def save_settings(thread_count=None, close=lambda: None, apiurl=None, download_path=None, speed_limit=None, version=None):
if thread_count is not None:
state.aria2_threads = thread_count
if apiurl is not None:
state.api_url = apiurl
if download_path is not None:
state.download_path = download_path
if speed_limit is not None:
state.speed_limit = speed_limit
if version is not None:
state.version = version
create_config()
restart_aria2c()
close()
-23
View File
@@ -1,23 +0,0 @@
from dataclasses import dataclass
from typing import Optional, Any, List, Dict
from pathlib import Path
@dataclass
class AppState:
posts: List[Dict[str, Any]]
post_titles: List[str]
post_urls: List[str]
post_author: List[str]
downloads: List[str]
version: str
debug: bool = False
tracker: str = "rutracker"
api_url: str = "https://api.michijackson.xyz"
download_path: str = str(Path.home() / "Downloads")
speed_limit: int = 0
aria2process: Optional[Any] = None
aria2: Any = None
aria2p: Any = None
aria2_threads: int = 4
state = AppState(posts=[], post_titles=[], post_urls=[], post_author=[], downloads=[], download_path="", version="")
-31
View File
@@ -1,31 +0,0 @@
from core.utils.data.state import state
import threading
import psutil
import sys
shutdown_event = threading.Event()
def kill_aria2server():
if sys.platform.startswith("win"):
process = "aria2c.exe"
else:
process = "aria2c"
if state.aria2process:
state.aria2process.kill()
if state.debug:
print("Killed Aria2")
try:
for proc in psutil.process_iter():
if proc.name() == process:
proc.kill()
if state.debug:
print(f"Killed Aria2c (PID {proc.pid})")
except psutil.NoSuchProcess:
pass
def closehelper():
shutdown_event.set()
kill_aria2server()
-29
View File
@@ -1,29 +0,0 @@
from core.utils.data.state import state
from core.utils.data.tracker import get_item_url
from core.utils.data.tracker import get_magnet_link
from core.network.aria2_wrapper import start_client
from core.network.aria2_wrapper import add_magnet
from core.utils.general.wrappers import run_thread
import threading
def download_selected(item, posts, post_titles):
if item is not None:
if state.debug:
print(f"Downloading {item.text()}")
run_thread(threading.Thread(target=run_download, args=(item.text(), posts, post_titles)))
else:
if state.debug:
print("No item selected for download.")
def run_download(item, posts, post_titles):
post_url = get_item_url(item, posts, post_titles)
if state.debug:
print("Selected URL: ", post_url)
magnet_uri = get_magnet_link(post_url)
start_client()
add_magnet(magnet_uri)
-23
View File
@@ -1,23 +0,0 @@
import json
def split_data(data):
p_json = json.loads(data)
count = p_json["count"]
posts = p_json["data"]
query = p_json["query"]
success = p_json["success"]
cached = p_json["cached"]
return count, posts, query, success, cached
def format_data(data):
post_author = [post["author"] for post in data]
post_titles = [post["title"] for post in data]
post_links = [post["url"] for post in data]
return post_titles, post_links, post_author
# https://open.spotify.com/track/70vjXI7oBou5buTtgiJeRW?si=25640cc4243c4bf3
-49
View File
@@ -1,49 +0,0 @@
from core.interface.gui import MainWindow
from core.utils.data.state import state
from core.network.aria2_integration import aria2server
from core.network.aria2_integration import send_notification
from core.utils.general.shutdown import closehelper, shutdown_event
from core.utils.general.wrappers import run_thread
from core.utils.config.config import read_config
from PySide6 import QtWidgets
import qdarktheme
import darkdetect
import threading
import signal
import argparse
import sys
parser = argparse.ArgumentParser()
parser.add_argument("--debug", action="store_true")
args = parser.parse_args()
def run_gui():
app = QtWidgets.QApplication([])
if darkdetect.isDark:
app.setStyleSheet(qdarktheme.load_stylesheet("dark"))
else:
app.setStyleSheet(qdarktheme.load_stylesheet("light"))
widget = MainWindow()
widget.show()
sys.exit(app.exec())
def run_aria2server():
aria2process = aria2server()
return aria2process
def keyboardinterrupthandler(signum, frame):
closehelper()
if __name__ == "__main__":
read_config()
state.debug = args.debug # override of read_config
if state.debug:
print("Starting Aria2 Server")
state.aria2process = run_aria2server()
signal.signal(signal.SIGINT, keyboardinterrupthandler)
run_thread(threading.Thread(target=send_notification, args=(shutdown_event,), daemon=True))
if state.debug:
print("Launching GUI")
run_gui()
+6 -5
View File
@@ -1,10 +1,11 @@
requests==2.32.2
PySide6==6.9.2
PySide6-Essentials==6.10.1
beautifulsoup4==4.13.5
darkdetect==0.8.0
aria2p==0.12.1
darkdetect==0.7.1
pyinstaller==6.15.0
pyqtdarktheme==0.1.7
PyQtDarkTheme-fork==2.3.4
aiohttp==3.13.0
plyer==2.1.0
psutil==7.1.0
psutil==7.1.0
libtorrent==2.0.11
libtorrent-windows-dll==0.0.3
+3
View File
@@ -0,0 +1,3 @@
{
"version": "dev"
}
@@ -1,15 +1,28 @@
from bs4 import BeautifulSoup
import requests
import time
def scrape_monkrus_telegram(query):
cache = {
"data": [],
"last_fetched": 0
}
cache_expiry = 300
def _get_telegram_posts():
post = 100
max_posts = 250
posts: list[dict] = []
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)
response = requests.get(url, timeout=10)
soup = BeautifulSoup(response.text, "html.parser")
bubbles = soup.find_all("div", class_="tgme_widget_message_bubble")
@@ -24,7 +37,7 @@ def scrape_monkrus_telegram(query):
title = post_txt.b.text
link = bubble.find("a", href=lambda x: x and x.startswith("https://uztracker.net"))
if query.lower() in title.lower() and link:
if link:
post_url = link["href"]
if post_url not in added:
added.add(post_url)
@@ -38,4 +51,20 @@ def scrape_monkrus_telegram(query):
post += 22
posts.reverse()
cache["data"] = posts
cache["last_fetched"] = current_time
return(posts)
def scrape_m0nkrus(query):
posts = _get_telegram_posts()
filtered_posts = []
for post in posts:
if query.lower() in post["title"].lower():
filtered_post = post.copy()
filtered_post["id"] = len(filtered_posts) + 1
filtered_posts.append(filtered_post)
return filtered_posts
+19
View File
@@ -0,0 +1,19 @@
import requests
from core.utils.data.state import state
from core.utils.logging.logs import consoleLog
def scrape_rutracker(query):
search = requests.get(f"{state.api_url}/search?q={query}")
consoleLog("Sent request to server")
if search:
try:
return search.text
except Exception:
consoleLog("No results found / No response from server")
return None
else:
return None
# This function utilizes the SoftwareManager server - source code can be found under the SoftwareManager-Server repository.
+35
View File
@@ -0,0 +1,35 @@
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
from core.utils.logging.logs import consoleLog
def scrape_uztracker(query):
base_url="https://uztracker.net/"
search_url = f"{base_url.rstrip('/')}/tracker.php?nm={query}"
consoleLog(search_url)
posts = []
try:
response = requests.get(search_url)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
links = soup.find_all('tr', class_="tCenter hl-tr", id=lambda x: x and x.startswith('tor_'))
for link in links:
theme_link = link.find('a', class_="genmed tLink", href=lambda x: x and x.startswith('./viewtopic'))
url = urljoin(base_url, theme_link['href'])
title = theme_link.b.text
author_link = link.find('a', class_="med")
author = author_link.text.strip() if author_link else "Unknown"
posts.append(dict(
title=title,
url=url,
author=author
))
return posts
except requests.RequestException as e:
consoleLog(f"Failed to fetch {search_url}: {e}")
return None
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+267
View File
@@ -0,0 +1,267 @@
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 PySide6 import QtWidgets
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QLineEdit,
QPushButton,
QWidget,
QVBoxLayout,
QDialog,
QLabel,
QHBoxLayout,
QSpinBox,
QCheckBox,
QFileDialog,
QComboBox,
QTabWidget,
)
import platform
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)
dialog.layout().addWidget(update_checkbox_container)
# 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("📁")
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):"))
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("📁")
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_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()
+868
View File
@@ -0,0 +1,868 @@
from PySide6 import QtWidgets
from PySide6.QtCore import QPersistentModelIndex, Qt, QTimer, QModelIndex, QAbstractTableModel, Signal, QEvent, QSize, QByteArray
from PySide6.QtWidgets import (
QLineEdit,
QTableView,
QWidget,
QVBoxLayout,
QListWidget,
QLabel,
QHBoxLayout,
QComboBox,
QTabWidget,
QHeaderView,
QMessageBox,
QTableWidget,
QTextEdit,
QStyledItemDelegate,
QStatusBar
)
from PySide6.QtGui import QIcon, QCloseEvent, QImage, QPixmap, QContextMenuEvent, QGuiApplication, QColor, QBrush
from PySide6.QtSvg import QSvgRenderer
import darkdetect
import threading
import platform
import requests as r
import os
import subprocess
import libtorrent as lt
import time
import sys
import json
import base64
from core.utils.logging.logs import consoleLog, remove_download_log, flush_log_buffer
from core.utils.general.wrappers import run_thread
from core.utils.data.state import state
from core.utils.network.download import download_selected
from core.utils.network.update_checker import check_for_updates
from core.interface.utils.tabhelper import create_tab
from core.interface.utils.searchhelper import return_pressed
from core.interface.dialogs.settings import settings_dialog
from core.interface.assets.base64_icons import settings_black_base64
from core.interface.assets.base64_icons import settings_white_base64
from core.interface.assets.base64_icons import logo_base64
from core.utils.general.shutdown import closehelper
def _svg_icon(svg_str, size=20):
renderer = QSvgRenderer(QByteArray(svg_str.encode()))
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
from PySide6.QtGui import QPainter
painter = QPainter(pixmap)
renderer.render(painter)
painter.end()
return QIcon(pixmap)
SVG_PLAY = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><polygon points="6,3 20,12 6,21" fill="white"/></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="white"/><rect x="15" y="3" width="4" height="18" rx="1" fill="white"/></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="white"/></svg>'
def download_update(latest_version):
new_filename = f"SoftwareManager-dev-{latest_version.replace('-dev', '')}-windows.exe"
url = f"https://github.com/KeksPirates/SoftwareManager/releases/latest/download/SoftwareManager-dev-{latest_version.replace('-dev', '')}-windows.exe"
print("Downloading update...", True)
response = r.get(url, allow_redirects=True)
with open(new_filename, "wb") as f:
f.write(response.content)
if not os.path.exists(new_filename):
raise FileNotFoundError("Executable not found")
subprocess.Popen([new_filename], shell=True)
time.sleep(0.5)
msg = QMessageBox()
msg.setIcon(QMessageBox.Icon.Information)
msg.setWindowTitle("New Version")
msg.setText("New version installed.")
msg.setInformativeText("Please remove the old exe.")
msg.setStandardButtons(QMessageBox.StandardButton.Ok)
sys.exit(0)
def windowCloseHelper():
QGuiApplication.quit()
class MainWindow(QtWidgets.QMainWindow, QWidget):
log_signal = Signal(str) # Thread-safe signal for logging
def __init__(self):
super().__init__()
MainWindow._instance = self
self.log_signal.connect(self._on_log_signal)
pixmap = QPixmap()
image_data = base64.b64decode(logo_base64)
pixmap.loadFromData(image_data)
self.setWindowIcon(QIcon(pixmap))
if platform.system() == "Windows":
try:
import ctypes
appid = 'SoftwareManager'
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid)
except Exception as e:
consoleLog(f"Could not set app ID: {e}")
build_info_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "build_info.json")
if os.path.exists(build_info_path):
with open(build_info_path, "r") as f:
build_info = json.load(f)
state.version = build_info.get("version")
# Check for updates on Windows
if state.ignore_updates is False and platform.system() == "Windows":
result = check_for_updates()
if result != (None, None):
assets, latest_version = result
if assets:
msg = QMessageBox()
msg.setIcon(QMessageBox.Icon.Information)
msg.setWindowTitle("Update Available")
msg.setText("A new version is available.")
msg.setInformativeText("Press Ok to download the update.")
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Ignore)
response = msg.exec_()
if response == QMessageBox.StandardButton.Ok:
download_update(latest_version)
self.setWindowTitle("Software Manager")
self.setGeometry(100, 100, 800, 600)
self.controls = QWidget()
self.controlsLayout = QVBoxLayout()
# Widgets
self.searchbar = QLineEdit()
self.searchbar.setPlaceholderText("Search for software...")
self.searchbar.setClearButtonEnabled(True)
self.searchbar.setMinimumHeight(30)
self.searchbar.returnPressed.connect(lambda: run_thread(threading.Thread(target=return_pressed, args=(self,)))) # Triggers data function thread on enter
self.dlbutton = QtWidgets.QPushButton("Download")
self.dlbutton.setCursor(Qt.CursorShape.PointingHandCursor)
self.libraryList = QListWidget()
self.emptyResults = QLabel("No Results")
self.emptyResults.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.emptyResults.hide()
self.download_model = None
self.downloadList = QTableView()
self.downloadList.setMouseTracking(True)
self._hovered_row = -1
self._tracker_hovered_row = -1
self._tracker_hovered_table = None
self.downloadList.viewport().installEventFilter(self)
self.emptyLibrary = QLabel("No items in library.")
self.emptyDownload = QLabel("No items in downloads.")
self.emptyDownload.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.consoleLog = QTextEdit()
self.statusbar = QStatusBar()
flush_log_buffer()
class TrackerHoverDelegate(QStyledItemDelegate):
def paint(self, painter, option, index):
if index.row() == MainWindow._instance._tracker_hovered_row:
painter.save()
painter.fillRect(option.rect, QColor(255, 255, 255, 15))
painter.restore()
super().paint(painter, option, index)
self._tracker_hover_delegate = TrackerHoverDelegate(self)
def create_tracker_table(headers):
table = QTableWidget()
table.setColumnCount(len(headers))
table.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers)
table.verticalHeader().setVisible(False)
table.setHorizontalHeaderLabels(headers)
table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows)
table.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection)
header = table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
header.resizeSection(1, 500)
header.setStretchLastSection(False)
header.setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
header.setHighlightSections(False)
table.setShowGrid(False)
table.setStyleSheet("""
QTableWidget {
border: none;
outline: 0;
font-size: 13px;
}
QTableWidget::item {
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding: 6px 14px;
}
QTableWidget::item:selected {
background: rgba(255, 255, 255, 0.04);
}
QHeaderView {
background: transparent;
}
QHeaderView::section {
background: transparent;
color: rgba(255, 255, 255, 0.5);
font-weight: normal;
border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding: 6px 14px;
}
QHeaderView::section:checked {
background: transparent;
color: rgba(255, 255, 255, 0.5);
font-weight: normal;
}
""")
table.setMouseTracking(True)
table.viewport().setMouseTracking(True)
table.setItemDelegate(self._tracker_hover_delegate)
return table
self.rutrackerlist = create_tracker_table(["Post Title", "Author", "Seeders", "Leechers"])
self.uztrackerlist = create_tracker_table(["Post Title", "Author"])
self.monkruslist = create_tracker_table(["Post Title", "Author"])
state.tracker_list.update({"rutracker": self.rutrackerlist, "uztracker": self.uztrackerlist, "m0nkrus": self.monkruslist})
self.rutrackerlist.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.rutrackerlist.viewport().setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.uztrackerlist.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.uztrackerlist.viewport().setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.monkruslist.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.monkruslist.viewport().setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
for tbl in (self.rutrackerlist, self.uztrackerlist, self.monkruslist):
tbl.viewport().installEventFilter(self)
container = QWidget()
containerLayout = QVBoxLayout()
containerLayout.addWidget(self.searchbar)
containerLayout.addWidget(state.tracker_list[state.tracker])
class DownloadModel(QAbstractTableModel):
def __init__(self):
super().__init__()
self.headers = ["Action", "Name", "Status", "Progress", "Speed", "Size", "Total Size"]
def rowCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int:
return len(state.active_downloads)
def columnCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()):
return len(self.headers)
def headerData(self, section, orientation, role: int = Qt.ItemDataRole.DisplayRole):
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
return self.headers[section]
return None
def data(self, index: QModelIndex | QPersistentModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
if role == Qt.ItemDataRole.DisplayRole:
col = index.column()
if index.row() >= len(state.active_downloads) or index.row() < 0:
return None
magnet_link = list(state.active_downloads.keys())[index.row()]
magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status()
if col == 0:
pass
elif col == 1:
return status.name if status.has_metadata else "Fetching metadata..."
elif col == 2:
if status.state == lt.torrent_status.downloading:
return "Downloading"
elif status.state == lt.torrent_status.seeding:
return "Seeding"
elif status.paused:
return "Paused"
else:
return "Queued"
elif col == 3:
return f"{status.progress * 100:.1f}%"
elif col == 4:
down_kb = status.download_rate / 1024
up_kb = status.upload_rate / 1024
down_text = f"{down_kb / 1024:.1f} MB/s" if down_kb > 1024 else f"{down_kb:.1f} kB/s"
up_text = f"{up_kb / 1024:.1f} MB/s" if up_kb > 1024 else f"{up_kb:.1f} kB/s"
return f"{down_text}{up_text}"
elif col == 5:
downloaded_mb = status.total_wanted_done / (1024 * 1024)
if downloaded_mb > 1024:
return f"{downloaded_mb / 1024:.2f} GB"
else:
return f"{downloaded_mb:.1f} MB"
elif col == 6:
total_mb = status.total_wanted / (1024 * 1024)
if total_mb > 1024:
return f"{total_mb / 1024:.2f} GB"
else:
return f"{total_mb:.1f} MB"
elif col == 7:
if status.download_rate > 0:
bytes_left = status.total_wanted - status.total_wanted_done
eta_seconds = bytes_left / status.download_rate
if eta_seconds < 60:
return f"{int(eta_seconds)}s"
elif eta_seconds < 3600:
minutes = int(eta_seconds / 60)
seconds = int(eta_seconds % 60)
return f"{minutes}m {seconds}s"
else:
hours = int(eta_seconds / 3600)
minutes = int((eta_seconds % 3600) / 60)
return f"{hours}h {minutes}m"
else:
return "" if status.paused else "Stalled"
if role == Qt.ItemDataRole.UserRole and index.column() == 0:
magnet_link = list(state.active_downloads.keys())[index.row()]
magnetdl = state.active_downloads[magnet_link]
return magnetdl.status().paused
return None
def toggle_pause_resume(self, row):
if row >= len(state.active_downloads) or row < 0:
return
magnet_link = list(state.active_downloads.keys())[row]
magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status()
if status.state == lt.torrent_status.seeding:
save_path = magnetdl.save_path()
if save_path and os.path.exists(save_path):
if platform.system() == "Windows":
os.startfile(os.path.normpath(save_path))
elif platform.system() == "Linux":
subprocess.Popen(["xdg-open", save_path])
elif platform.system() == "Darwin":
subprocess.Popen(["open", save_path])
return
is_paused = bool(magnetdl.flags() & lt.torrent_flags.paused)
if is_paused:
magnetdl.set_flags(lt.torrent_flags.auto_managed)
magnetdl.resume()
consoleLog(f"Resumed download: {status.name}", True)
else:
magnetdl.unset_flags(lt.torrent_flags.auto_managed)
magnetdl.pause()
consoleLog(f"Paused download: {status.name}", True)
idx = self.index(row, 0)
self.dataChanged.emit(idx, idx, [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.UserRole])
class HoverRowDelegate(QStyledItemDelegate):
def paint(self, painter, option, index):
if index.row() == MainWindow._instance._hovered_row:
painter.save()
painter.fillRect(option.rect, QColor(255, 255, 255, 15))
painter.restore()
super().paint(painter, option, index)
class PauseResumeDelegate(QStyledItemDelegate):
clicked = Signal(int)
def paint(self, painter, option, index):
if index.row() == MainWindow._instance._hovered_row:
painter.save()
painter.fillRect(option.rect, QColor(255, 255, 255, 15))
painter.restore()
super().paint(painter, option, index)
def setEditorData(self, editor, index):
button = editor.findChild(QtWidgets.QPushButton)
magnet_link = list(state.active_downloads.keys())[index.row()]
magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status()
if button:
if status.state == lt.torrent_status.seeding:
button.setIcon(_svg_icon(SVG_FOLDER, 18))
button.setText("")
else:
button.setIcon(_svg_icon(SVG_PLAY if status.paused else SVG_PAUSE, 18))
button.setText("")
def createEditor(self, parent, option, index):
magnet_link = list(state.active_downloads.keys())[index.row()]
magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status()
widget = QWidget(parent)
widget.setStyleSheet("border: none; background: transparent;")
layout = QHBoxLayout(widget)
layout.setContentsMargins(0, 0, 0, 0)
if status.state == lt.torrent_status.seeding:
btnPause = QtWidgets.QPushButton()
btnPause.setIcon(_svg_icon(SVG_FOLDER, 18))
else:
btnPause = QtWidgets.QPushButton()
btnPause.setIcon(_svg_icon(SVG_PLAY if status.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):
if index.column() == 0 and event.type() == QEvent.Type.MouseButtonPress:
self.clicked.emit(index.row())
return True
return False
self.download_model = DownloadModel()
self.downloadList.setModel(self.download_model)
self.downloadList.horizontalHeader().setStretchLastSection(False)
self.downloadList.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
self.downloadList.horizontalHeader().resizeSection(0, 70)
self.downloadList.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
self.downloadList.horizontalHeader().setSectionResizeMode(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("""
QTableView {
border: none;
outline: 0;
font-size: 13px;
}
QTableView::item {
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding: 6px 14px;
}
QTableView::item:selected {
background: rgba(255, 255, 255, 0.04);
}
QHeaderView {
background: transparent;
}
QHeaderView::section {
background: transparent;
color: rgba(255, 255, 255, 0.5);
font-weight: normal;
border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding: 6px 14px;
}
QHeaderView::section:checked {
background: transparent;
color: rgba(255, 255, 255, 0.5);
font-weight: normal;
}
""")
self.downloadList.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.downloadList.viewport().setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
download_model = self.download_model
def on_pause_resume_clicked(row):
download_model.toggle_pause_resume(row)
self.downloadList.viewport().setMouseTracking(True)
hover_delegate = HoverRowDelegate(self.downloadList)
self.downloadList.setItemDelegate(hover_delegate)
delegate = PauseResumeDelegate(self.downloadList)
self.downloadList.setItemDelegateForColumn(0, delegate)
delegate.clicked.connect(on_pause_resume_clicked)
# download button triggers
self.dlbutton.clicked.connect(lambda: run_thread(threading.Thread(target=download_selected, args=(state.tracker_list[state.tracker].selectedItems(), state.posts, state.post_titles))))
container.setLayout(containerLayout)
self.setCentralWidget(container)
# Tabs
self.tabs = QTabWidget()
self.tabs.setStyleSheet("""
QTabBar::tab {
font-size: 11px;
padding: 2px 8px;
height: 25px;
}
QTabWidget::pane {
margin-top: -20px;
}
""")
self.horizontal_layout = QHBoxLayout()
self.horizontal_layout.addWidget(self.emptyResults, stretch=3)
self.tracker_widget = state.tracker_list[state.tracker]
self.horizontal_layout.addWidget(self.tracker_widget)
self.tab1 = create_tab("Search", self.searchbar, state.tracker_list[state.tracker], self.tabs, self.dlbutton, self.horizontal_layout)
# self.tab2 = create_tab("Library", self.emptyLibrary, self.libraryList, self.tabs, None, None)
self.tab3 = create_tab("Downloads", self.emptyDownload, self.downloadList, self.tabs, None, None)
if state.image_path is not None and os.path.exists(state.image_path):
self.image = QImage(state.image_path)
self.image = self.image.scaledToWidth(300, Qt.TransformationMode.SmoothTransformation)
self.pixmap = QPixmap.fromImage(self.image)
self.overlay_label = QLabel(self)
self.overlay_label.setPixmap(self.pixmap)
self.overlay_label.adjustSize()
self.overlay_label.raise_()
# offset_x = -1350
# offset_y = -550
x = self.width() - self.overlay_label.width() # - offset_x
y = self.height() - self.overlay_label.height() # - offset_y
self.overlay_label.move(x, y)
# temporarily disabled
# state.image_changed.connect(self.update_image_overlay)
self.corner_widget = QWidget()
self.corner_layout = QHBoxLayout(self.corner_widget)
self.corner_layout.setContentsMargins(0, 0, 0, 0)
self.tracker_list = QComboBox()
self.tracker_list.addItems(["rutracker", "uztracker", "m0nkrus"])
self.tracker_list.setCursor(Qt.CursorShape.PointingHandCursor)
self.tracker_list.activated.connect(self.set_tracker)
self.corner_layout.addWidget(self.tracker_list)
self.settings_btn = QtWidgets.QToolButton()
self.settings_btn.setIconSize(QSize(21, 21))
self.settings_btn.setStyleSheet("background: transparent;")
if darkdetect.isDark():
settings_white_pixmap = QPixmap()
settings_white = base64.b64decode(settings_white_base64)
settings_white_pixmap.loadFromData(settings_white)
self.settings_btn.setIcon(QIcon(settings_white_pixmap))
else:
settings_black_pixmap = QPixmap()
settings_black = base64.b64decode(settings_black_base64)
settings_black_pixmap.loadFromData(settings_black)
self.settings_btn.setIcon(QIcon(settings_black_pixmap))
self.settings_btn.setToolTip("Settings")
self.settings_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self.settings_btn.clicked.connect(lambda: settings_dialog(self))
self.corner_layout.addWidget(self.settings_btn)
self.tab_wrapper = QWidget()
self.tab_layout = QVBoxLayout()
self.tab_layout.setContentsMargins(0, 0, 0, 0)
self.tab_layout.setSpacing(0)
self.tab_bar_layout = QHBoxLayout()
self.tab_bar_layout.setContentsMargins(0, 0, 0, 0)
self.tab_bar_layout.setSpacing(0)
self.tab_bar_layout.addWidget(self.tabs.tabBar(), stretch=0)
self.tab_bar_layout.addStretch()
self.tab_bar_layout.addWidget(self.corner_widget, stretch=0)
self.tab_layout.addLayout(self.tab_bar_layout)
self.tab_layout.addWidget(self.tabs)
self.tab_wrapper.setLayout(self.tab_layout)
containerLayout.addWidget(self.tab_wrapper)
containerLayout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.consoleLog.setReadOnly(True)
self.consoleLog.setFixedHeight(150)
self.consoleLog.setStyleSheet("margin-bottom: 0px; background: transparent;")
containerLayout.addWidget(self.consoleLog)
self.statusbar.show()
self.statusbar.setStyleSheet("background: transparent; border: none; padding-right: 1px; padding-left: 6px; padding-bottom: 1px; margin-top: -8px")
self.version = QLabel(f"Version: {state.version}")
self.statusbar.addPermanentWidget(self.version, Qt.AlignmentFlag.AlignLeft)
self.speed_label = QLabel("↓ 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(self.download_list_update)
self.download_timer.start(500)
self.active_timer = QTimer()
self.active_timer.timeout.connect(self.show_empty_downloads)
self.active_timer.start(500)
self.context_menu = QtWidgets.QMenu(self)
self.context_menu.addAction("Open Containing Folder", self.openFolderAction)
self.context_menu.addAction("Copy Magnet URI", self.copyMagnetURIAction)
self.context_menu.addAction("Cancel Download", self.cancelDownloadAction)
self.context_menu.addAction("Delete File", self.deleteFileAction)
@staticmethod
def add_log(text):
if MainWindow._instance:
MainWindow._instance.log_signal.emit(text)
def _on_log_signal(self, text):
if hasattr(self, 'consoleLog'):
self.consoleLog.append(text)
self.consoleLog.verticalScrollBar().setValue(
self.consoleLog.verticalScrollBar().maximum()
)
def update_image_overlay(self, new_image_path):
self.image = QImage(new_image_path)
self.pixmap = QPixmap.fromImage(self.image)
self.overlay_label.setPixmap(self.pixmap)
self.overlay_label.adjustSize()
def download_list_update(self):
if self.download_model:
self.download_model.layoutAboutToBeChanged.emit()
self.download_model.layoutChanged.emit()
for row in range(self.download_model.rowCount()):
idx = self.download_model.index(row, 0)
self.downloadList.closePersistentEditor(idx)
self.downloadList.openPersistentEditor(idx)
self._update_speed_label()
def _update_speed_label(self):
total_down = 0
total_up = 0
for handle in state.active_downloads.values():
try:
s = handle.status()
total_down += s.download_rate
total_up += s.upload_rate
except Exception:
pass
down_kb = total_down / 1024
up_kb = total_up / 1024
down_text = f"{down_kb / 1024:.1f} MB/s" if down_kb > 1024 else f"{down_kb:.1f} kB/s"
up_text = f"{up_kb / 1024:.1f} MB/s" if up_kb > 1024 else f"{up_kb:.1f} kB/s"
self.speed_label.setText(f"{down_text}{up_text}")
def mousePressEvent(self, event):
self.rutrackerlist.clearSelection()
self.uztrackerlist.clearSelection()
self.monkruslist.clearSelection()
self.downloadList.clearSelection()
super().mousePressEvent(event)
def closeEvent(self, event: QCloseEvent):
closehelper()
event.accept()
def eventFilter(self, obj, event):
for tbl in (self.rutrackerlist, self.uztrackerlist, self.monkruslist):
if obj == tbl.viewport():
if event.type() == QEvent.Type.MouseMove:
pos = event.position().toPoint() if hasattr(event, 'position') else event.pos()
idx = tbl.indexAt(pos)
new_row = idx.row() if idx.isValid() else -1
if new_row != self._tracker_hovered_row or tbl is not self._tracker_hovered_table:
self._tracker_hovered_row = new_row
self._tracker_hovered_table = tbl
tbl.viewport().update()
elif event.type() == QEvent.Type.Leave:
self._tracker_hovered_row = -1
self._tracker_hovered_table = None
tbl.viewport().update()
break
if obj == self.downloadList.viewport():
if event.type() == QEvent.Type.MouseMove:
pos = event.position().toPoint() if hasattr(event, 'position') else event.pos()
idx = self.downloadList.indexAt(pos)
new_row = idx.row() if idx.isValid() else -1
if new_row != self._hovered_row:
old_row = self._hovered_row
self._hovered_row = new_row
self._invalidate_hover_row(old_row)
self._invalidate_hover_row(new_row)
elif event.type() == QEvent.Type.Leave:
old_row = self._hovered_row
self._hovered_row = -1
self._invalidate_hover_row(old_row)
return super().eventFilter(obj, event)
def _invalidate_hover_row(self, row):
if row >= 0 and self.download_model:
self.downloadList.viewport().update()
def set_tracker(self, _):
old_tracker = state.tracker
state.tracker = self.tracker_list.currentText()
old_widget = state.tracker_list[old_tracker]
self.horizontal_layout.removeWidget(old_widget)
old_widget.setParent(None)
tracker_widget = state.tracker_list[state.tracker]
self.horizontal_layout.addWidget(tracker_widget)
def show_empty_results(self, show: bool):
if show:
state.tracker_list[state.tracker].hide()
self.emptyResults.show()
else:
state.tracker_list[state.tracker].show()
self.emptyResults.hide()
def show_empty_downloads(self):
if len(state.active_downloads) > 0:
self.emptyDownload.hide()
self.downloadList.show()
else:
self.emptyDownload.show()
self.downloadList.hide()
# thank you claude
def resizeEvent(self, event):
super().resizeEvent(event)
table_width = state.tracker_list[state.tracker].viewport().width()
state.tracker_list[state.tracker].setColumnWidth(1, int(table_width * 0.3))
def contextMenuEvent(self, event: QContextMenuEvent):
if self.downloadList.underMouse():
pos = self.downloadList.viewport().mapFromGlobal(event.globalPos())
index = self.downloadList.indexAt(pos)
if index.isValid():
row = index.row()
self._context_menu_row = row
self.context_menu.exec(event.globalPos())
else:
super().contextMenuEvent(event)
def openFolderAction(self):
if not hasattr(self, '_context_menu_row'):
return
row = self._context_menu_row
if row < 0 or row >= len(state.active_downloads):
return
magnet_link = list(state.active_downloads.keys())[row]
magnetdl = state.active_downloads[magnet_link]
download_path = magnetdl.save_path()
if download_path and os.path.exists(download_path):
if platform.system() == "Windows":
os.startfile(os.path.normpath(download_path))
elif platform.system() == "Linux":
subprocess.Popen(["xdg-open", download_path])
elif platform.system() == "Darwin":
subprocess.Popen(["open", download_path])
def copyMagnetURIAction(self):
if not hasattr(self, '_context_menu_row'):
return
row = self._context_menu_row
if row < 0 or row >= len(state.active_downloads):
return
magnet_link = list(state.active_downloads.keys())[row]
clipboard = QtWidgets.QApplication.clipboard()
clipboard.setText(magnet_link)
def cancelDownloadAction(self):
if not hasattr(self, '_context_menu_row'):
return
row = self._context_menu_row
if row < 0 or row >= len(state.active_downloads):
return
magnet_link = list(state.active_downloads.keys())[row]
magnetdl = state.active_downloads[magnet_link]
confirm = QMessageBox.question(self, "Cancel Download", f"Are you sure you want to cancel the download of '{magnetdl.status().name}'?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
if confirm == QMessageBox.StandardButton.Yes:
del state.active_downloads[magnet_link]
remove_download_log(magnet_link)
consoleLog(f"Cancelled download: {magnetdl.status().name}", True)
def deleteFileAction(self):
if not hasattr(self, '_context_menu_row'):
return
row = self._context_menu_row
if row < 0 or row >= len(state.active_downloads):
return
magnet_link = list(state.active_downloads.keys())[row]
magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status()
save_path = status.save_path
torrent_name = status.name
download_path = os.path.join(save_path, torrent_name)
confirm = QMessageBox.question(self, "Delete Files", f"Are you sure you want to delete the downloaded files of '{magnetdl.status().name}'? This action cannot be undone.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
if confirm == QMessageBox.StandardButton.Yes:
if download_path and os.path.exists(download_path):
try:
if os.path.isfile(download_path):
os.remove(download_path)
del state.active_downloads[magnet_link]
remove_download_log(magnet_link)
consoleLog(f"Deleted files for: {magnetdl.status().name}", True)
else:
import shutil
shutil.rmtree(download_path)
del state.active_downloads[magnet_link]
remove_download_log(magnet_link)
consoleLog(f"Deleted files for: {magnetdl.status().name}", True)
except Exception as e:
consoleLog(f"Error deleting files: {e}", True)
+66
View File
@@ -0,0 +1,66 @@
from PySide6.QtWidgets import QTableWidgetItem
from core.utils.logging.logs import consoleLog
from core.data.scrapers.uztracker import scrape_uztracker
from core.data.scrapers.rutracker import scrape_rutracker
from core.data.scrapers.monkrus import scrape_m0nkrus
from core.utils.network.jsonhandler import split_data, format_data, format_data_minimal
from core.utils.data.state import state
scrapers = {
"uztracker": scrape_uztracker,
"rutracker": scrape_rutracker,
"m0nkrus": scrape_m0nkrus
}
def return_pressed(self):
search_text = self.searchbar.text()
if search_text == "":
consoleLog("Error: Can't search for nothing")
return
consoleLog(f"User searched for: {search_text}")
if state.tracker == "rutracker":
response = scrape_rutracker(search_text)
if response:
_, state.posts, _, _, cached = split_data(response)
if state.posts == []:
consoleLog(f"No Results for {search_text}")
state.tracker_list[state.tracker].clear()
self.show_empty_results(True)
else:
state.post_titles, _, state.post_author, state.post_seeders, state.post_leechers = format_data(state.posts)
self.show_empty_results(False)
state.tracker_list[state.tracker].clear()
state.tracker_list[state.tracker].setHorizontalHeaderLabels(["Post Title", "Author", "Seeders", "Leechers"])
state.tracker_list[state.tracker].setRowCount(len(state.post_titles))
for i, author in enumerate(state.post_author):
state.tracker_list[state.tracker].setItem(i, 1, QTableWidgetItem(author))
for i, title in enumerate(state.post_titles):
state.tracker_list[state.tracker].setItem(i, 0, QTableWidgetItem(title))
for i, seeders in enumerate(state.post_seeders):
state.tracker_list[state.tracker].setItem(i, 2, QTableWidgetItem(seeders))
for i, leechers in enumerate(state.post_leechers):
state.tracker_list[state.tracker].setItem(i, 3, QTableWidgetItem(leechers))
if state.debug == True:
consoleLog(f"Response Cached: {cached}")
else:
consoleLog(f"No response from rutracker")
state.tracker_list[state.tracker].clear()
self.show_empty_results(True)
elif state.tracker is not None:
state.posts = scrapers[state.tracker](search_text)
if state.posts == []:
consoleLog(f"No Results for {search_text}")
state.tracker_list[state.tracker].clear()
self.show_empty_results(True)
else:
state.post_author, state.post_titles, state.post_urls = format_data_minimal(state.posts)
self.show_empty_results(False)
state.tracker_list[state.tracker].clear()
state.tracker_list[state.tracker].setHorizontalHeaderLabels(["Post Title", "Author"])
state.tracker_list[state.tracker].setRowCount(len(state.post_titles))
for i, author in enumerate(state.post_author):
state.tracker_list[state.tracker].setItem(i, 1, QTableWidgetItem(author))
for i, title in enumerate(state.post_titles):
state.tracker_list[state.tracker].setItem(i, 0, QTableWidgetItem(title))
+56
View File
@@ -0,0 +1,56 @@
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
+52
View File
@@ -0,0 +1,52 @@
from core.utils.logging.logs import consoleLog
from core.utils.data.state import state
import psutil
addrs = psutil.net_if_addrs()
stats = psutil.net_if_stats()
def get_net_interfaces():
for interface in addrs.keys():
consoleLog(f"Found Interface: {interface}")
return addrs.keys()
def get_active_interfaces():
active = []
for interface, addr_list in addrs.items():
up = stats[interface].isup
for addr in addr_list:
if addr.family == 2: # ipv4
ipv4 = addr.address
if not ipv4.startswith("127.") and not ipv4.startswith("169.254") and up:
active.append(interface)
consoleLog(f"Found Active: {interface}")
return active
def list_interfaces() -> None:
for interface, addr_list in addrs.items():
up = stats[interface].isup
for addr in addr_list:
if addr.family == 2: # ipv4
ipv4 = addr.address
if not ipv4.startswith("127.") and not ipv4.startswith("169.254") and up:
status = "ACTIVE"
else:
status = "INACTIVE"
consoleLog(f"Found Interface: {interface} [{status}]")
def init_interfaces():
state.interfaces = list(addrs.keys())
state.active_interfaces = get_active_interfaces()
def get_interface_ip(interface_name):
if interface_name in addrs:
for addr in addrs[interface_name]:
if addr.family == 2: # ipv4
if not addr.address.startswith("127.") and not addr.address.startswith("169.254"):
return addr.address
return None
+164
View File
@@ -0,0 +1,164 @@
import time
import os
from core.utils.general.wrappers import run_thread
from core.network.interface import get_interface_ip
import threading
import libtorrent as lt
from core.utils.data.state import state
from core.utils.logging.logs import consoleLog
global loop_running
loop_running = False
def init_session():
if state.dl_session is not None:
return
state.dl_session = lt.session()
settings = {
"upload_rate_limit": state.up_speed_limit,
"download_rate_limit": state.down_speed_limit,
"enable_dht": True,
"enable_lsd": True,
"enable_upnp": True,
"enable_natpmp": True,
"dht_bootstrap_nodes": "router.bittorrent.com:6881,dht.transmissionbt.com:6881",
"connections_limit": state.max_connections,
"active_downloads": state.max_downloads
}
if state.bound_interface:
interface_ip = get_interface_ip(state.bound_interface)
if interface_ip:
settings["outgoing_interfaces"] = interface_ip
settings["listen_interfaces"] = f"{interface_ip}:6881"
consoleLog(f"Binding to Interface IP: {interface_ip}")
else:
consoleLog(f"Skipping Binding, no Interface set. ({state.bound_interface})")
state.dl_session.apply_settings(settings)
consoleLog("Initialized Session")
def add_download(magnet_uri, dl_path=state.download_path):
if state.active_downloads is None:
state.active_downloads = {}
init_session()
if magnet_uri in state.active_downloads:
try:
handle = state.active_downloads[magnet_uri]
status = handle.status()
except RuntimeError as e:
consoleLog(f"Error in LibTorrent Handle: {e}")
if status.has_metadata:
filepath = os.path.join(status.save_path, status.name)
if not os.path.exists(filepath):
consoleLog(f"File Deleted, redownloading: {status.name}")
state.dl_session.remove_torrent(handle)
del state.active_downloads[magnet_uri]
else:
consoleLog("Skipping Downloading, download already running...")
return False
else:
consoleLog("Skipping Downloading, download already running... ")
return False
magnetdl = lt.parse_magnet_uri(magnet_uri)
magnetdl.save_path = dl_path
download = state.dl_session.add_torrent(magnetdl)
state.active_downloads[magnet_uri] = download
consoleLog(f"Added {magnet_uri} to downloads")
run_thread(threading.Thread(target=dl_status_loop))
return True
def add_seed(magnet_uri, file_path):
if state.active_downloads is None:
state.active_downloads = {}
init_session()
if magnet_uri in state.active_downloads:
consoleLog("Already seeding this torrent")
return False
magnetdl = lt.parse_magnet_uri(magnet_uri)
magnetdl.save_path = os.path.dirname(file_path)
handle = state.dl_session.add_torrent(magnetdl)
state.active_downloads[magnet_uri] = handle
state.seeded_magnets.add(magnet_uri)
return True
def dl_status_loop():
global loop_running
if loop_running == True:
return
loop_running = True
completed_set = set()
if not state.active_downloads:
consoleLog("No active downloads")
loop_running = False
return
while state.active_downloads:
for magnet_uri, magnetdl in list(state.active_downloads.items()):
status = magnetdl.status()
if status.state == lt.torrent_status.seeding and magnet_uri not in completed_set:
consoleLog(f"Download completed: {status.name}")
completed_set.add(magnet_uri)
if not state.active_downloads:
loop_running = False
break
time.sleep(1)
loop_running = False
def update_settings():
if state.dl_session is None:
return
settings = {
"upload_rate_limit": state.up_speed_limit,
"download_rate_limit": state.down_speed_limit,
"connections_limit": state.max_connections,
"active_downloads": state.max_downloads
}
if state.bound_interface:
interface_ip = get_interface_ip(state.bound_interface)
if interface_ip:
settings["outgoing_interfaces"] = interface_ip
settings["listen_interfaces"] = f"{interface_ip}:6881"
consoleLog(f"Binding to Interface IP: {interface_ip}")
else:
consoleLog(f"Skipping Binding, no Interface set. ({state.bound_interface})")
state.dl_session.apply_settings(settings)
def update_bound_interface():
settings = { "outgoing_interfaces": state.bound_interface }
state.dl_session.apply_settings(settings)
+81
View File
@@ -0,0 +1,81 @@
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 plyer import notification
import libtorrent as lt
import os
import time
def cleanup_session():
if state.dl_session is not None:
for magnetdl in state.active_downloads.values():
if hasattr(magnetdl, 'pause'): # Check it's a handle
magnetdl.pause()
del state.dl_session
state.dl_session = None
state.active_downloads.clear()
def send_notification(shutdown_event):
notified = set()
while not shutdown_event.is_set():
try:
for magnet_uri, magnetdl in list(state.active_downloads.items()):
if isinstance(magnetdl, dict):
continue
status = magnetdl.status()
if status.state == lt.torrent_status.seeding and magnet_uri not in notified:
notification.notify(
title="Download finished",
message=f"{status.name} has finished downloading.",
timeout=4
)
notified.add(magnet_uri)
except Exception:
pass
time.sleep(5)
def update_log(shutdown_event):
updated = set()
while not shutdown_event.is_set():
try:
for magnet_uri, magnetdl in list(state.active_downloads.items()):
if isinstance(magnetdl, dict):
continue
status = magnetdl.status()
if status.state == lt.torrent_status.seeding and magnet_uri not in updated and magnet_uri not in state.seeded_magnets:
consoleLog(f"Marking {status.name} as completed")
info_hash = str(status.info_hash)
update_download_completed_by_hash(info_hash, True)
updated.add(magnet_uri)
except Exception:
pass
time.sleep(5)
def check_deleted_files(shutdown_event):
while not shutdown_event.is_set():
try:
for magnet_uri, magnetdl in list(state.active_downloads.items()):
if isinstance(magnetdl, dict):
continue
status = magnetdl.status()
if status.state == lt.torrent_status.seeding and status.has_metadata:
file_path = os.path.join(status.save_path, status.name)
if not os.path.exists(file_path):
consoleLog(f"Registered File Deletion: {status.name}")
state.dl_session.remove_torrent(magnetdl)
del state.active_downloads[magnet_uri]
except Exception:
pass
time.sleep(5)
+9
View File
@@ -0,0 +1,9 @@
from core.utils.logging.logs import consoleLog
from core.network.libtorrent_int import add_download
def add_magnet(uri):
if uri is not None and uri.startswith("magnet:?"):
add_download(uri)
consoleLog("Magnet URI added to LibTorrent")
else:
consoleLog(f"Invalid Magnet Link: {uri}")
+77
View File
@@ -0,0 +1,77 @@
import os
import platform
import configparser
from core.utils.data.state import state
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}",
"download_path": f"{state.download_path}",
"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"] = {
"bound_interface": f"{state.bound_interface}",
"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.download_path = config.get("Network", "download_path", fallback=state.download_path)
state.down_speed_limit = config.getint("Network", "download_speed_limit", fallback=state.down_speed_limit)
state.up_speed_limit = config.getint("Network", "upload_speed_limit", fallback=state.up_speed_limit)
state.max_connections = config.getint("Network", "max_connections", fallback=state.max_connections)
state.max_downloads = config.getint("Network", "max_downloads", fallback=state.max_downloads)
# Paths
state.bound_interface = config.get("Paths", "bound_interface", fallback=state.bound_interface)
state.image_path = config.get("Paths", "image_path", fallback=state.image_path)
create_config()
+31
View File
@@ -0,0 +1,31 @@
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
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):
if apiurl is not None:
state.api_url = apiurl
if download_path is not None:
state.download_path = download_path
if down_speed_limit is not None:
state.down_speed_limit = down_speed_limit
if up_speed_limit is not None:
state.up_speed_limit = up_speed_limit
if image_path is not None:
state.image_path = image_path
if autoresume is not None:
state.autoresume = autoresume
if max_connections is not None:
state.max_connections = max_connections
if max_downloads is not None:
state.max_downloads = max_downloads
if bound_interface is not None:
state.bound_interface = None if bound_interface == "None" else bound_interface
update_settings()
consoleLog("Saved Settings")
create_config()
close()
+16
View File
@@ -0,0 +1,16 @@
from dataclasses import dataclass
from typing import List
@dataclass
class Download:
title: str
url: str
magnet_uri: str
path: str
completed: bool
@dataclass
class DownloadList:
count: int
data: List[Download]
+49
View File
@@ -0,0 +1,49 @@
from PySide6.QtCore import QObject, Signal
from PySide6.QtWidgets import QTableWidget
from typing import Optional, Any, List, Dict
from pathlib import Path
class AppState(QObject):
image_changed = Signal(str)
def __init__(self):
super().__init__()
self.posts: list[Any] | None = None
self.post_titles: Optional[List] = None
self.post_urls: Optional[List] = None
self.post_author: List[str] = []
self.post_seeders: List[str] = []
self.post_leechers: List[str] = []
self.version: str = "dev"
self._image_path: str = ""
self.ignore_updates: bool = False
self.debug: bool = False
self.autoresume: bool = True
self.tracker: str = "rutracker"
self.tracker_list: dict[str, QTableWidget] = {}
self.api_url: str = "https://api.michijackson.xyz"
self.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.seeded_magnets: set = set()
self.window_transparency: bool = False
self.interfaces: List = []
self.active_interfaces: List = []
self.bound_interface: Any = None
@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()
@@ -1,6 +1,7 @@
import requests
from bs4 import BeautifulSoup
from core.utils.data.state import state
from core.utils.logging.logs import consoleLog
from core.utils.network.jsonhandler import format_data
@@ -10,20 +11,17 @@ def get_item_url(item, posts, post_titles): # softwarelist currentitem, post lis
if 0 <= post_index < len(post_titles):
if state.tracker == "uztracker":
item = "https://uztracker.net/" + state.post_urls[post_index].lstrip("./")
if state.debug:
print("Found post URL: ", item)
consoleLog(f"Found post URL: {item}")
return item
if state.tracker == "rutracker":
item_dict = posts[post_index]
_, post_links, _ = format_data([item_dict])
if state.debug:
print("Found post URL: ", post_links[0])
_, post_links, _, _, _ = format_data([item_dict])
consoleLog(f"Found post URL: {post_links[0]}")
return post_links[0]
if state.tracker == "m0nkrus":
item_dict = posts[post_index]
_, post_links, _ = format_data([item_dict])
if state.debug:
print("Found post URL: ", post_links[0])
_, post_links, _, _, _,= format_data([item_dict])
consoleLog(f"Found post URL: {post_links[0]}")
return post_links[0]
return None
@@ -33,20 +31,16 @@ def get_item_url(item, posts, post_titles): # softwarelist currentitem, post lis
def get_magnet_link(post_url):
try:
response = requests.get(post_url) # eventually impl. cloudscraper
if state.debug:
print("Sent Request to retrieve Magnet Link...")
consoleLog("Sent Request to retrieve Magnet Link...")
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
magnet_link = soup.find('a', href=lambda x: x and x.startswith('magnet:'))
if magnet_link:
if state.debug:
print("Magnet Link Retrieved: ", magnet_link['href'])
consoleLog(f"Magnet Link Retrieved: {magnet_link['href']}")
return magnet_link['href']
else:
if state.debug:
print("Magnet Link not Found!")
consoleLog("Magnet Link not Found!")
except requests.RequestException as e:
if state.debug:
print(f"Failed to fetch {post_url}: {e}")
consoleLog(f"Failed to fetch {post_url}: {e}")
return None
+9
View File
@@ -0,0 +1,9 @@
import threading
from core.network.libtorrent_misc import cleanup_session
from core.utils.general.wrappers import run_thread
shutdown_event = threading.Event()
def closehelper():
run_thread(threading.Thread(target=cleanup_session))
shutdown_event.set()
+29
View File
@@ -0,0 +1,29 @@
from core.utils.logging.logs import consoleLog, remove_download_log
from core.utils.network.download import run_download_direct, seed_magnet
import os
def split_data(data):
count = data.count
downloads = data.data
return count, downloads
def check_completed(downloads, resume):
for download in downloads:
if download.completed == False:
consoleLog(f"Found unfinished download: {download.title}")
if resume == True:
run_download_direct(download.magnet_uri)
consoleLog(f"Resuming {download.title}")
def check_downloads(downloads):
for download in downloads:
if os.path.exists(download.path):
consoleLog(f"Existing Download: {download.title}")
seed_magnet(download.magnet_uri, download.path)
else:
consoleLog(f"Inexistent Download: {download.title}")
remove_download_log(download.magnet_uri)
+241
View File
@@ -0,0 +1,241 @@
from core.utils.data.models import Download, DownloadList
from core.utils.data.state import state
from dataclasses import asdict
from datetime import datetime
import json
import os
import re
import time
_log_buffer = []
def add_download_log(title, url, magnet_uri, completed) -> DownloadList:
downloads_file = os.path.join(state.settings_path, "downloads.json")
if os.path.exists(downloads_file) and os.path.getsize(downloads_file) > 0:
try:
with open(downloads_file, "r") as file:
existing_data = json.load(file)
downloads = [Download(**d) for d in existing_data.get("data", [])]
except json.JSONDecodeError:
downloads = []
else:
downloads = []
magnetdl = state.active_downloads.get(magnet_uri)
if magnetdl:
status = magnetdl.status()
timeout = 60
start = time.time()
while not status.has_metadata and (time.time() - start) < timeout:
time.sleep(0.5)
status = magnetdl.status()
torrent_name = status.name
save_path = status.save_path
consoleLog(f"Torrent name (has_metadata={status.has_metadata}): {torrent_name}")
path = os.path.join(save_path, torrent_name)
else:
path = os.path.join(state.download_path, title)
if any(d.magnet_uri == magnet_uri or d.url == url for d in downloads):
if magnet_uri in state.active_downloads:
consoleLog("Skipping Logging, download already running...")
return
consoleLog("File already in Log, updating Download State...")
hash = extract_hash_from_magnet(magnet_uri)
update_download_completed_by_hash(hash, False)
return DownloadList(data=downloads, count=len(downloads)) # thanks again claude (im stupid)
downloads.append(Download(
title=title,
url=url,
magnet_uri=magnet_uri,
path=path,
completed=completed
))
consoleLog(f"Added {title} to Log File")
download_list = DownloadList(data=downloads, count=len(downloads))
with open(downloads_file, "w") as file:
json.dump(asdict(download_list), file, indent=4)
return download_list
def remove_download_log(magnet_uri) -> DownloadList:
downloads_file = os.path.join(state.settings_path, "downloads.json")
if os.path.exists(downloads_file) and os.path.getsize(downloads_file) > 0:
try:
with open(downloads_file, "r") as file:
existing_data = json.load(file)
downloads = [Download(**d) for d in existing_data.get("data", [])]
except json.JSONDecodeError:
downloads = []
else:
downloads = []
magnet_link = (magnet_uri or "").strip()
title = next((getattr(d, 'title', 'Unknown') for d in downloads if (getattr(d, 'magnet_uri', None) or '').strip() == magnet_link or (getattr(d, 'url', None) or '').strip() == magnet_link))
downloads = [d for d in downloads if (getattr(d, 'magnet_uri', None) or "").strip() != magnet_link and (getattr(d, 'url', None) or "").strip() != magnet_link]
consoleLog(f"Removed {title} from Log File")
download_list = DownloadList(data=downloads, count=len(downloads))
with open(downloads_file, "w") as file:
json.dump(asdict(download_list), file, indent=4)
return download_list
def update_download_completed(magnet_uri, completed) -> DownloadList:
downloads_file = os.path.join(state.settings_path, "downloads.json")
if os.path.exists(downloads_file) and os.path.getsize(downloads_file) > 0:
try:
with open(downloads_file, "r") as file:
existing_data = json.load(file)
downloads = [Download(**d) for d in existing_data.get("data", [])]
except (json.JSONDecodeError, TypeError) as e:
consoleLog(f"Error loading downloads.json: {e}")
downloads = []
else:
downloads = []
identifier = (magnet_uri or "").strip()
if state.debug:
consoleLog(f"Updating download completed for identifier: {identifier!r}")
consoleLog(f"Total downloads in file: {len(downloads)}")
for i, d in enumerate(downloads):
try:
mag = (getattr(d, 'magnet_uri', None) or '')[:50]
url = (getattr(d, 'url', None) or '')[:50]
consoleLog(f" [{i}] magnet: {mag}... url: {url}...")
except Exception as e:
consoleLog(f" [{i}] Error reading download: {e}")
found = False
for download in downloads:
try:
stored_magnet = (getattr(download, 'magnet_uri', None) or "").strip()
stored_url = (getattr(download, 'url', None) or "").strip()
consoleLog(f"Comparing with magnet: {stored_magnet[:50] if stored_magnet else 'None'}...")
if identifier and (stored_magnet == identifier or stored_url == identifier):
consoleLog(f"Match found! Setting completed={completed}")
download.completed = completed
found = True
except Exception as e:
consoleLog(f"Error comparing download: {e}")
if not found:
consoleLog("No matching download found to update")
return DownloadList(data=downloads, count=len(downloads))
download_list = DownloadList(data=downloads, count=len(downloads))
with open(downloads_file, "w") as file:
json.dump(asdict(download_list), file, indent=4)
consoleLog("Updated download log")
return download_list
def get_download_logs() -> DownloadList:
downloads_file = os.path.join(state.settings_path, "downloads.json")
if os.path.exists(downloads_file) and os.path.getsize(downloads_file) > 0:
try:
with open(downloads_file, "r") as file:
existing_data = json.load(file)
downloads = [Download(**d) for d in existing_data.get("data", [])]
except json.JSONDecodeError:
downloads = []
else:
downloads = []
return DownloadList(data=downloads, count=len(downloads))
def extract_hash_from_magnet(magnet_uri): # full credits to claude for this
match = re.search(r'urn:btih:([A-F0-9]+)', magnet_uri, re.IGNORECASE)
if match:
return match.group(1).upper()
return None
def update_download_completed_by_hash(info_hash, completed) -> DownloadList:
downloads_file = os.path.join(state.settings_path, "downloads.json")
if os.path.exists(downloads_file) and os.path.getsize(downloads_file) > 0:
try:
with open(downloads_file, "r") as file:
existing_data = json.load(file)
downloads = [Download(**d) for d in existing_data.get("data", [])]
except (json.JSONDecodeError, TypeError) as e:
consoleLog(f"Error loading downloads.json: {e}")
downloads = []
else:
downloads = []
info_hash_upper = (info_hash or "").upper().strip()
consoleLog(f"Updating download by hash: {info_hash_upper}")
consoleLog(f"Total downloads in file: {len(downloads)}")
found = False
for download in downloads:
try:
magnet_uri = getattr(download, 'magnet_uri', None)
if magnet_uri:
stored_hash = extract_hash_from_magnet(magnet_uri)
if stored_hash and stored_hash == info_hash_upper:
download.completed = completed
found = True
except Exception as e:
consoleLog(f"Error comparing download: {e}")
if not found:
consoleLog("No matching download found to update")
return DownloadList(data=downloads, count=len(downloads))
download_list = DownloadList(data=downloads, count=len(downloads))
with open(downloads_file, "w") as file:
json.dump(asdict(download_list), file, indent=4)
consoleLog("Updated download log")
return download_list
_main_window = None
def set_main_window(window):
global _main_window
_main_window = window
def flush_log_buffer(): # credits to claude
global _log_buffer
if _log_buffer:
try:
from core.interface.gui import MainWindow
for log_entry in _log_buffer:
MainWindow.add_log(log_entry)
_log_buffer = []
except Exception:
pass
def consoleLog(text, printAnyways = False):
now = datetime.now()
current_time = now.strftime("%H:%M:%S")
formatted_text = f"[{current_time}] {text}"
try:
from core.interface.gui import MainWindow
MainWindow.add_log(formatted_text)
except Exception:
global _log_buffer
_log_buffer.append(formatted_text)
if state.debug or printAnyways:
print(formatted_text)
+40
View File
@@ -0,0 +1,40 @@
from core.utils.logging.logs import consoleLog
from core.utils.data.tracker import get_item_url
from core.utils.data.tracker import get_magnet_link
from core.network.libtorrent_wrapper import add_download
from core.utils.general.wrappers import run_thread
from core.utils.logging.logs import add_download_log
from core.network.libtorrent_int import add_seed
import threading
def download_selected(items, posts, post_titles):
if not items:
consoleLog("No item selected for download.")
return
seen = set()
for item in items:
if item.column() != 0:
continue
text = item.text()
if text and text not in seen:
seen.add(text)
consoleLog(f"Downloading {text}")
run_thread(threading.Thread(target=run_download, args=(text, posts, post_titles)))
def run_download(item, posts, post_titles):
post_url = get_item_url(item, posts, post_titles)
consoleLog(f"Selected URL: {post_url}")
magnet_uri = get_magnet_link(post_url)
if add_download(magnet_uri):
add_download_log(item, post_url, magnet_uri, False)
def run_download_direct(magnet_uri):
consoleLog(f"Direct download: {magnet_uri[:60]}")
add_download(magnet_uri)
add_download_log("Direct Download", "", magnet_uri, False)
def seed_magnet(magnet_uri, file_path):
consoleLog(f"Seeding: {magnet_uri[:60]}")
add_seed(magnet_uri, file_path)
+35
View File
@@ -0,0 +1,35 @@
import json
def split_data(data):
p_json = json.loads(data)
count = p_json["count"]
posts = p_json["data"]
query = p_json["query"]
success = p_json["success"]
cached = p_json["cached"]
return count, posts, query, success, cached
def format_data(data):
post_author = [post["author"] for post in data]
post_titles = [post["title"] for post in data]
post_links = [post["url"] for post in data]
post_seeders = [post["seeders"] for post in data]
post_leechers = [post["leechers"] for post in data]
return post_titles, post_links, post_author, post_seeders, post_leechers
def format_data_minimal(data):
post_author = [post["author"] for post in data]
post_titles = [post["title"] for post in data]
post_links = [post["url"] for post in data]
return post_author, post_titles, post_links
# https://open.spotify.com/track/0F0fMcIdL2FmcC9eYqwazX
@@ -1,12 +1,13 @@
import requests
from core.utils.data.state import state
from core.utils.logging.logs import consoleLog
def check_for_updates():
url = f"https://api.github.com/repos/KeksPirates/SoftwareManager/releases"
response = requests.get(url)
if response.status_code != 200:
print(f"Failed to fetch releases: {response.status_code}")
consoleLog(f"Failed to fetch releases: {response.status_code}")
exit(1)
releases = response.json()
@@ -16,21 +17,15 @@ def check_for_updates():
latest_version = latest_release["name"]
assets = latest_release["assets"]
if state.version == "dev":
if state.debug:
print("Dev release detected, skipping version check")
return None, None
elif latest_version != state.version:
if state.debug:
print(f"New release available: {latest_version}")
if latest_version != state.version:
consoleLog(f"New release available: {latest_version}")
if assets:
if state.debug:
print("Assets:")
for asset in assets:
print(f" - {asset['name']}: {asset['browser_download_url']}")
consoleLog("Assets:")
for asset in assets:
consoleLog(f" - {asset['name']}: {asset['browser_download_url']}")
return assets, latest_version
else:
return None, None
else:
if state.debug:
print("Already up-to-date.")
consoleLog("Already up-to-date.")
return None, None
+64
View File
@@ -0,0 +1,64 @@
from core.interface.gui import MainWindow, windowCloseHelper
from core.utils.data.state import state
from core.utils.logging.logs import consoleLog
from core.network.libtorrent_misc import send_notification, update_log, check_deleted_files
from core.utils.logging.logs import get_download_logs
from core.utils.general.shutdown import closehelper, shutdown_event
from core.utils.general.wrappers import run_thread
from core.utils.logging.loghandler import split_data, check_completed, check_downloads
from core.network.interface import list_interfaces, init_interfaces
from core.utils.config.config import read_config
from PySide6 import QtWidgets
from PySide6.QtCore import Qt
import qdarktheme
import threading
import platform
import signal
import sys
import time
def run_gui():
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv)
qdarktheme.setup_theme("auto")
widget = MainWindow()
if state.window_transparency and platform.system() != "Windows":
widget.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
qdarktheme.setup_theme("auto", custom_colors={"background": "#00000000"})
from core.utils.logging.logs import set_main_window
set_main_window(widget)
widget.show()
sys.exit(app.exec())
def keyboardinterrupthandler(signum, frame):
closehelper()
windowCloseHelper()
def main():
start_time = time.perf_counter()
read_config()
logs = get_download_logs()
_, downloads = split_data(logs)
signal.signal(signal.SIGINT, keyboardinterrupthandler)
consoleLog("Starting SoftwareManager...")
consoleLog("Fetching Network Interfaces...")
list_interfaces()
consoleLog("Initializing Interface variables...")
init_interfaces()
consoleLog(f"Current Bound: {state.bound_interface}")
run_thread(threading.Thread(target=send_notification, args=(shutdown_event,), daemon=True))
consoleLog("Started Thread: send_notification")
run_thread(threading.Thread(target=update_log, args=(shutdown_event,), daemon=True))
consoleLog("Started Thread: update_log")
run_thread(threading.Thread(target=check_completed, args=(downloads, state.autoresume)))
consoleLog("Started Thread: check_completed")
run_thread(threading.Thread(target=check_deleted_files, args=(shutdown_event,), daemon=True))
consoleLog("Started Thread: check_deleted_files")
check_downloads(downloads)
elapsed = time.perf_counter() - start_time
consoleLog(f"Initialization completed in {elapsed:.2f}s. Launching GUI")
run_gui()
if __name__ == "__main__":
main()