Compare commits

...

144 Commits

Author SHA1 Message Date
dependabot[bot] 08ae3ee8df build(deps): bump pyinstaller from 6.20.0 to 6.21.0
Bumps [pyinstaller](https://github.com/pyinstaller/pyinstaller) from 6.20.0 to 6.21.0.
- [Release notes](https://github.com/pyinstaller/pyinstaller/releases)
- [Changelog](https://github.com/pyinstaller/pyinstaller/blob/develop/doc/CHANGES.rst)
- [Commits](https://github.com/pyinstaller/pyinstaller/compare/v6.20.0...v6.21.0)

---
updated-dependencies:
- dependency-name: pyinstaller
  dependency-version: 6.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-15 19:36:40 +00:00
shayaa 1ffa1cacb9 Merge pull request #99 from KeksPirates/auto/sync-readme-dependencies
Sync README dependencies
2026-05-14 23:36:19 +02:00
Vxrtrauter 207eb70f11 Sync README dependencies 2026-05-14 21:10:34 +00:00
shayaa 64144e5ed7 Merge pull request #98 from KeksPirates/dependabot/pip/requests-2.34.2
build(deps): bump requests from 2.34.1 to 2.34.2
2026-05-14 23:10:21 +02:00
dependabot[bot] d05fae7e87 build(deps): bump requests from 2.34.1 to 2.34.2
Bumps [requests](https://github.com/psf/requests) from 2.34.1 to 2.34.2.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.34.1...v2.34.2)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.34.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-14 19:37:56 +00:00
shayaa 9abdec6c33 Merge pull request #97 from KeksPirates/auto/sync-readme-dependencies
Sync README dependencies
2026-05-13 22:08:47 +02:00
Vxrtrauter fb5eb61d05 Sync README dependencies 2026-05-13 20:07:34 +00:00
shayaa eff2164ecc Merge pull request #89 from KeksPirates/dependabot/pip/pyinstaller-6.20.0
build(deps): bump pyinstaller from 6.19.0 to 6.20.0
2026-05-13 22:07:23 +02:00
shayaa d8aa206ea2 Merge pull request #94 from KeksPirates/dependabot/pip/pyside6-essentials-6.11.1
build(deps): bump pyside6-essentials from 6.11.0 to 6.11.1
2026-05-13 22:07:00 +02:00
dependabot[bot] 6de74303ff build(deps): bump pyside6-essentials from 6.11.0 to 6.11.1
Bumps [pyside6-essentials](https://pyside.org) from 6.11.0 to 6.11.1.

---
updated-dependencies:
- dependency-name: pyside6-essentials
  dependency-version: 6.11.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-13 20:06:46 +00:00
shayaa 5eb6314cb2 Merge pull request #96 from KeksPirates/auto/sync-readme-dependencies
Sync README dependencies
2026-05-13 22:05:49 +02:00
Vxrtrauter 179dd0af83 Sync README dependencies 2026-05-13 20:05:32 +00:00
shayaa 365700900f Merge pull request #95 from KeksPirates/dependabot/pip/requests-2.34.1
build(deps): bump requests from 2.34.0 to 2.34.1
2026-05-13 22:05:03 +02:00
dependabot[bot] babbccdc9a build(deps): bump requests from 2.34.0 to 2.34.1
Bumps [requests](https://github.com/psf/requests) from 2.34.0 to 2.34.1.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.34.0...v2.34.1)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.34.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-13 19:47:10 +00:00
shayaa 367ffab86a Merge pull request #93 from KeksPirates/auto/sync-readme-dependencies
Sync README dependencies
2026-05-12 10:57:57 +02:00
Vxrtrauter 71bad60617 Sync README dependencies 2026-05-12 08:46:59 +00:00
shayaa 27758f794c Merge pull request #92 from KeksPirates/dependabot/pip/requests-2.34.0
build(deps): bump requests from 2.33.1 to 2.34.0
2026-05-12 10:46:50 +02:00
dependabot[bot] d82afe8577 build(deps): bump requests from 2.33.1 to 2.34.0
Bumps [requests](https://github.com/psf/requests) from 2.33.1 to 2.34.0.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.33.1...v2.34.0)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.34.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-12 02:25:02 +00:00
Vxrtrauter 8592b3058e Merge branch 'main' of https://github.com/KeksPirates/SoftwareManager 2026-05-04 21:51:20 +02:00
Vxrtrauter 7a82e948b9 refactor: remove unused imports 2026-05-04 21:51:18 +02:00
shayaa 782b0b6bc9 Merge pull request #91 from KeksPirates/feat/bzzhr-impl
Add bzzhr implementation and improve file preallocation on Windows
2026-05-04 21:49:51 +02:00
KeksNino 71cc0d9f1f fix: workflows (forgot to git add again) 2026-05-04 20:58:38 +02:00
KeksNino 675dead1d9 fix: readd dependency installation step for linux in workflow
gets rid of pyinstaller warnings and is needed for future appimages
2026-05-04 20:47:05 +02:00
Vxrtrauter 64ca3c76a2 Fix: update buzzheavier link detection logic and improve filename extraction from headers 2026-05-04 19:07:42 +02:00
shayaa d378ee8258 Enhance macOS build process in create-release.yml
Added installation step for create-dmg on macOS and updated artifact handling for macOS builds.
2026-04-24 11:28:56 +02:00
shayaa e9b01426f8 Merge pull request #90 from KeksPirates/feat/crossplatform-updatecheck
Feat/crossplatform updatecheck
2026-04-24 10:37:40 +02:00
Vxrtrauter 631f6592ac Refactor: remove unused imports from update dialog 2026-04-24 10:36:14 +02:00
Vxrtrauter 468faff16b Refactor: streamline update notification logic and enhance settings dialog for cross-platform compatibility 2026-04-24 10:20:26 +02:00
shayaa 6b45c40aad Update GitHub Actions workflow for macOS builds 2026-04-24 09:17:42 +02:00
dependabot[bot] d6302fc43f build(deps): bump pyinstaller from 6.19.0 to 6.20.0
Bumps [pyinstaller](https://github.com/pyinstaller/pyinstaller) from 6.19.0 to 6.20.0.
- [Release notes](https://github.com/pyinstaller/pyinstaller/releases)
- [Changelog](https://github.com/pyinstaller/pyinstaller/blob/develop/doc/CHANGES.rst)
- [Commits](https://github.com/pyinstaller/pyinstaller/compare/v6.19.0...v6.20.0)

---
updated-dependencies:
- dependency-name: pyinstaller
  dependency-version: 6.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-23 19:37:36 +00:00
shayaa e768c7518b Remove Activity section from README
Removed the Activity section and its associated analytics image from the README.
2026-04-18 19:01:34 +02:00
shayaa 49c390131a Merge pull request #87 from KeksPirates/dependabot/github_actions/softprops/action-gh-release-3
build(deps): bump softprops/action-gh-release from 2 to 3
2026-04-18 03:28:01 +02:00
shayaa 9bc0e22ff7 Merge pull request #88 from KeksPirates/refactor/stability
fix/refactor/Improve Stability
2026-04-17 14:57:48 +02:00
Vxrtrauter 982be09f6e Refactor: implement log emitter for improved logging and signal handling 2026-04-16 11:51:11 +02:00
Vxrtrauter d5582c5c38 Refactor: enhance download_update function with cancellation handling and error messaging 2026-04-15 14:18:36 +02:00
Vxrtrauter 392d2a6f03 Refactor: enhance AppDaemons with QThread for improved background task management 2026-04-14 14:40:19 +02:00
Vxrtrauter 7310049246 Refactor: notification handling and introduce AppDaemons for background tasks 2026-04-14 14:33:21 +02:00
dependabot[bot] 04aaff3b29 build(deps): bump softprops/action-gh-release from 2 to 3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 20:41:00 +00:00
Vxrtrauter f5809c466a Add timeouts, error handling & logging
Add network timeouts and more robust error handling across scrapers/hosts, improve logging concurrency, and refine download handling:

- Hosts/sources: add request timeouts (15s) and exception handling to buzzheavier, gofile, steamrip, uztracker and rutracker; use urllib.parse.quote for search queries and better parse error checks.
- GoFile: use Session with try/finally, create guest account with timeout, validate API responses and close session.
- Context menu: import DirectDownloadHandle and handle stopping direct downloads separately from libtorrent; simplify file deletion with single try/catch; improve logging on exceptions.
- GUI/search flow: move UI state changes (clear table, disable searchbar, hide empty results) into MainWindow._start_search and reduce duplicated UI changes in run_search.
- Main: remove duplicate is_running assignment.
- Libtorrent: snapshot seeded magnets in update loop to avoid concurrent access; minor indentation change in add_seed.
- State/logging: add _log_lock to AppState; protect state.log_buffer with lock in consoleLog and flush_log_buffer and fix flush semantics.
- Updater: replace busy-wait with QEventLoop/QTimer polling for DirectDownloadHandle progress, stop timer and quit loop on completion/error, and remove unnecessary sleeps.

These changes aim to make network operations more reliable, prevent UI freezes, avoid race conditions when logging, and handle direct vs libtorrent downloads more cleanly.
2026-04-13 11:03:25 +02:00
Vxrtrauter 8f19703314 Merge branch 'main' of https://github.com/KeksPirates/SoftwareManager 2026-04-11 10:17:35 +02:00
Vxrtrauter 7d1f249797 Add sitemap.xml for improved SEO and site indexing 2026-04-11 10:17:34 +02:00
shayaa c20dc32de5 Merge pull request #85 from KeksPirates/enhance/docs
Add meta description and Android favicon
2026-04-11 10:09:54 +02:00
Vxrtrauter b2bc97a6af Add meta description and Android favicon
Add a meta description tag to improve SEO and provide a brief site summary, and add a 192x192 Android favicon link to support Android/home screen icons. This clarifies site metadata and improves mobile presentation.
2026-04-11 10:09:33 +02:00
shayaa 15a771fe9f Change runner to use matrix OS in GitHub Actions 2026-04-11 09:55:36 +02:00
shayaa 976d610baf Change runner to self-hosted for test workflow 2026-04-11 09:47:22 +02:00
shayaa 42616f996d Merge pull request #84 from KeksPirates/fix/steamrip-indexing
fix: steamrip provider indexing
2026-04-11 09:26:46 +02:00
Vxrtrauter 8601a1ed50 fix: Detect SteamRip hosts by URL
Replace brittle index-based host resolution in SteamripScraper with substring checks on the chosen download URL (buzzheavier, gofile, vikingfile, megadb). Add a console log for the resolved link and adjust returned values for gofile. Remove the unnecessary links.index lookup and its exception handling.
2026-04-11 09:25:32 +02:00
shayaa a885576d8a Merge pull request #83 from KeksPirates/auto/sync-readme-dependencies
Sync README dependencies
2026-04-11 00:04:32 +02:00
Vxrtrauter a8de8461bd Sync README dependencies 2026-04-10 22:03:40 +00:00
shayaa c1e4f3a688 Merge pull request #76 from KeksPirates/dependabot/pip/pyqtdarktheme-fork-2.3.6
build(deps): bump pyqtdarktheme-fork from 2.3.4 to 2.3.6
2026-04-11 00:03:30 +02:00
dependabot[bot] ca4020970a build(deps): bump pyqtdarktheme-fork from 2.3.4 to 2.3.6
Bumps [pyqtdarktheme-fork](https://github.com/5yutan5/PyQtDarkTheme) from 2.3.4 to 2.3.6.
- [Release notes](https://github.com/5yutan5/PyQtDarkTheme/releases)
- [Commits](https://github.com/5yutan5/PyQtDarkTheme/commits)

---
updated-dependencies:
- dependency-name: pyqtdarktheme-fork
  dependency-version: 2.3.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-10 21:06:14 +00:00
shayaa 0956317eb2 Merge pull request #75 from KeksPirates/dependabot/pip/psutil-7.2.2
build(deps): bump psutil from 7.1.0 to 7.2.2
2026-04-10 23:05:07 +02:00
shayaa eed4af7705 Merge pull request #74 from KeksPirates/dependabot/github_actions/peter-evans/create-pull-request-8
build(deps): bump peter-evans/create-pull-request from 7 to 8
2026-04-10 23:04:16 +02:00
Vxrtrauter 02bb77ea1e fix: set default accent color in AppState to #fda7d7 2026-04-10 20:15:58 +02:00
shayaa 204ef0f233 Fix Workflow 2026-04-10 20:00:04 +02:00
Vxrtrauter aeaaeb6917 fix: correct spelling of "retrieval" in SteamRip description 2026-04-10 19:37:21 +02:00
Vxrtrauter cb9e147f19 fix: clean up imports in steamrip, contextmenu, and settings modules 2026-04-10 19:34:27 +02:00
KeksNino 9501f0e158 Merge pull request #82 from KeksPirates/feat/change-icon
feat: change logo
2026-04-10 17:30:31 +00:00
shayaa c7b49f079f Merge pull request #81 from KeksPirates/fix/steamrip-scraper-crash
(bug)fix: steamrip scraper freezing whole manager on second search and update notification
2026-04-10 19:27:14 +02:00
shayaa 478bf1c394 Merge branch 'main' into fix/steamrip-scraper-crash 2026-04-10 19:27:03 +02:00
Vxrtrauter e54b3362f9 fix: steamrip scraper freezing whole manager on second search and update notification popup settings and improve megadb notification handling 2026-04-10 19:25:18 +02:00
KeksNino e0993ac5b7 fix: save_settings function call when pressing button in notification popup 2026-04-10 19:17:44 +02:00
KeksNino 9715043936 fix: notification popup for megadb being black on windows 2026-04-10 19:02:51 +02:00
KeksNino 761158746f fix: qdarktheme setup on some notification popups 2026-04-10 18:47:21 +02:00
KeksNino 8d5b76e054 fix: background not transparent anymore after opening megadb notification poup 2026-04-10 18:06:10 +02:00
Vxrtrauter 4eedf620bf fix: correct og:image tag syntax and update twitter card type for consistency 2026-04-10 17:25:45 +02:00
KeksNino e016695f83 feat: change logo 2026-04-10 17:24:30 +02:00
shayaa 416920e660 Merge pull request #80 from KeksPirates/enhance/docs
Enhance metadata, improve loading effects, and refactor code
2026-04-10 17:15:26 +02:00
Vxrtrauter 39aad600ed fix: set initial body opacity to 0 for improved loading effect 2026-04-10 17:13:16 +02:00
Vxrtrauter f051275659 Add Software Manager logo SVG asset 2026-04-10 17:11:31 +02:00
KeksNino 14afc821b1 feat: add notification popup to inform users of malicious ads on megadb 2026-04-10 16:27:53 +02:00
Vxrtrauter b913a82aef fix: update page title for clarity and improved SEO 2026-04-10 15:15:17 +02:00
Vxrtrauter 38b03526e3 fix: update Open Graph image URL and remove body opacity style 2026-04-10 13:47:11 +02:00
Vxrtrauter a954609958 Refactor code structure for improved readability and maintainability 2026-04-10 13:38:20 +02:00
Vxrtrauter 5bd3c94e87 fix: set body opacity to 1 in main.css and adjust index.html for initial visibility 2026-04-10 13:32:38 +02:00
Vxrtrauter 7c456d4aef fix: set background color for HTML document 2026-04-10 13:30:56 +02:00
Vxrtrauter 1f5e33f975 feat: enhance Open Graph and Twitter Card metadata; improve tab display logic and animations 2026-04-10 13:29:41 +02:00
shayaa 4203969051 Merge pull request #79 from KeksPirates/fix/steamrip
Refactor GoFile scraper and add VikingFile and MegaDB support
2026-04-10 01:44:26 +02:00
Vxrtrauter f4cde66748 feat: add vikingfile and megadb scraping functionality to SteamripScraper 2026-04-10 01:31:02 +02:00
Vxrtrauter 040a8ad728 refactor: improve gofile scraper with token generation and error handling 2026-04-10 01:06:28 +02:00
KeksNino bf97907600 fix: tracker drop down menu text being incorrectly aligned 2026-04-10 00:20:35 +02:00
dependabot[bot] 82a2f2d4d7 build(deps): bump psutil from 7.1.0 to 7.2.2
Bumps [psutil](https://github.com/giampaolo/psutil) from 7.1.0 to 7.2.2.
- [Changelog](https://github.com/giampaolo/psutil/blob/master/docs/changelog.rst)
- [Commits](https://github.com/giampaolo/psutil/compare/v7.1.0...v7.2.2)

---
updated-dependencies:
- dependency-name: psutil
  dependency-version: 7.2.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-09 22:11:56 +00:00
shayaa 8944ed3e37 Merge pull request #78 from KeksPirates/feat/contextmenu
Enhance download management with context menus and tray icon features
2026-04-10 00:10:48 +02:00
shayaa a4797c6fe5 Merge branch 'main' into feat/contextmenu 2026-04-10 00:10:36 +02:00
shayaa e52f9301d8 Enhance README with Steamrip details and warning
Updated the description to include Steamrip and added a note about its experimental status.
2026-04-10 00:03:22 +02:00
Vxrtrauter 4f943759f4 Refactor: steamrip scraper & update menu labels
Simplify SteamripScraper link/name extraction by using BeautifulSoup results directly (append absolute URLs and use link.get_text()) and avoid fragile regex; adjust scrape_steamrip_game_downloads to accept a full URL argument. Update context menu text: replace "Copy Magnet URI" with "Copy Download Link" in two places and rename "Download Item" to "Download" for clearer UI wording.
2026-04-09 23:56:14 +02:00
Vxrtrauter eb9d5d60d1 fix: Centralize single-instance check and refactor startup
Introduce check_running(app) to create and check SingleInstance early in main(), store it on app._single_instance, and reuse that instance in run_gui() instead of constructing a new one. Move single-instance exit logic (print, closehelper, force_exit) into the new helper and wire single.on_raise to show_from_tray. Also replace the previous consoleLog call with print and add a comment indicating the check is for SoftwareManager already running. This centralizes startup single-instance handling and avoids creating multiple SingleInstance objects.
2026-04-09 23:31:09 +02:00
Vxrtrauter 7aea5c882a fix: tracker context menu logic and server name
contextmenu.py: Harden context-menu handlers by validating row bounds against state.posts, avoid relying on selectedItems() in places where _context_menu_row is used, and handle directory paths when deleting downloads (os.path.isdir or isfile) before send2trash. Also streamline URL opening with exception handling. main.py: Update SERVER_NAME to "SoftwareManager_KeksPirates". These changes prevent out-of-range/selection errors and ensure directories are trashed safely; also update server identification.
2026-04-09 23:13:23 +02:00
KeksNino b4ba9ea4d1 fix: fixed absolutely nothing by changing some qt worker stuff 2026-04-09 22:27:26 +02:00
KeksNino dcba60c695 fix: cant use copy magnet uri contextmenu widget more than once 2026-04-09 02:15:36 +02:00
dependabot[bot] be1c9bfe45 build(deps): bump peter-evans/create-pull-request from 7 to 8
Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 7 to 8.
- [Release notes](https://github.com/peter-evans/create-pull-request/releases)
- [Commits](https://github.com/peter-evans/create-pull-request/compare/v7...v8)

---
updated-dependencies:
- dependency-name: peter-evans/create-pull-request
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-08 23:10:17 +00:00
shayaa b3cd6e0e5a Merge pull request #73 from KeksPirates/auto/sync-readme-dependencies
Sync README dependencies
2026-04-09 01:10:05 +02:00
Vxrtrauter 41e418b9a8 Sync README dependencies 2026-04-08 23:09:49 +00:00
shayaa c2122a9074 Merge pull request #72 from KeksPirates/fix/broken-dependencies
Downgrade darkdetect to 0.7.1 and update Dependabot configuration
2026-04-09 01:09:41 +02:00
Vxrtrauter 54bf6598ab fix(deps): downgrade darkdetect to version 0.7.1 and update dependabot config 2026-04-09 01:07:48 +02:00
KeksNino 810bed347b fix: copy magnet uri to clipboard in context menu 2026-04-09 01:03:13 +02:00
shayaa 0be76550cf Merge pull request #71 from KeksPirates/auto/sync-readme-dependencies
Sync README dependencies
2026-04-09 00:53:12 +02:00
shayaa 0e2054dbb9 Merge pull request #63 from KeksPirates/dependabot/github_actions/actions/upload-artifact-7
build(deps): bump actions/upload-artifact from 4 to 7
2026-04-09 00:43:30 +02:00
shayaa 8e2013326b Merge pull request #60 from KeksPirates/dependabot/github_actions/actions/checkout-6
build(deps): bump actions/checkout from 4 to 6
2026-04-09 00:43:13 +02:00
dependabot[bot] 21828e3a48 build(deps): bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-08 22:42:20 +00:00
shayaa d12b0dfbaa Merge pull request #62 from KeksPirates/dependabot/github_actions/actions/download-artifact-8
build(deps): bump actions/download-artifact from 4 to 8
2026-04-09 00:41:24 +02:00
Vxrtrauter 477d595db2 Sync README dependencies 2026-04-08 22:40:56 +00:00
shayaa d276a03949 Merge pull request #61 from KeksPirates/dependabot/pip/requests-2.33.1
build(deps): bump requests from 2.33.0 to 2.33.1
2026-04-09 00:40:47 +02:00
dependabot[bot] cf19fa1c3d build(deps): bump requests from 2.32.2 to 2.33.1
Bumps [requests](https://github.com/psf/requests) from 2.32.2 to 2.33.1.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.2...v2.33.1)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.33.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-08 22:39:34 +00:00
shayaa e3ac085b00 Merge pull request #70 from KeksPirates/auto/sync-readme-dependencies
Sync README dependencies
2026-04-09 00:39:09 +02:00
Vxrtrauter 3d11447e22 Sync README dependencies 2026-04-08 22:38:37 +00:00
shayaa a84b29f1f6 Merge pull request #64 from KeksPirates/dependabot/pip/pyside6-essentials-6.11.0
build(deps): bump pyside6-essentials from 6.10.1 to 6.11.0
2026-04-09 00:38:28 +02:00
dependabot[bot] 2d292d4796 build(deps): bump pyside6-essentials from 6.10.1 to 6.11.0
Bumps [pyside6-essentials](https://pyside.org) from 6.10.1 to 6.11.0.

---
updated-dependencies:
- dependency-name: pyside6-essentials
  dependency-version: 6.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-08 22:32:07 +00:00
shayaa ec36717c6a Merge pull request #65 from KeksPirates/dependabot/pip/pyinstaller-6.19.0
build(deps): bump pyinstaller from 6.15.0 to 6.19.0
2026-04-09 00:31:00 +02:00
shayaa 1f0404dd65 Merge pull request #69 from KeksPirates/dependabot/pip/pip-aa7cb66ac2
build(deps): bump requests from 2.32.2 to 2.33.0 in the pip group across 1 directory
2026-04-09 00:30:11 +02:00
shayaa 5a63131777 Change token from RELEASE_TOKEN to PR_TOKEN 2026-04-09 00:29:51 +02:00
dependabot[bot] ee8c770db2 build(deps): bump requests in the pip group across 1 directory
Bumps the pip group with 1 update in the / directory: [requests](https://github.com/psf/requests).


Updates `requests` from 2.32.2 to 2.33.0
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.2...v2.33.0)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.33.0
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-08 22:28:04 +00:00
dependabot[bot] 49c89249c6 build(deps): bump pyinstaller from 6.15.0 to 6.19.0
Bumps [pyinstaller](https://github.com/pyinstaller/pyinstaller) from 6.15.0 to 6.19.0.
- [Release notes](https://github.com/pyinstaller/pyinstaller/releases)
- [Changelog](https://github.com/pyinstaller/pyinstaller/blob/develop/doc/CHANGES.rst)
- [Commits](https://github.com/pyinstaller/pyinstaller/compare/v6.15.0...v6.19.0)

---
updated-dependencies:
- dependency-name: pyinstaller
  dependency-version: 6.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-08 22:26:22 +00:00
shayaa 64953cb969 Merge pull request #66 from KeksPirates/dependabot/pip/beautifulsoup4-4.14.3
build(deps): bump beautifulsoup4 from 4.13.5 to 4.14.3
2026-04-09 00:25:12 +02:00
shayaa 32bacd1211 Add token to sync README dependencies workflow
Added a token to the sync README dependencies workflow.
2026-04-09 00:24:50 +02:00
dependabot[bot] 87b5d63e1b build(deps): bump beautifulsoup4 from 4.13.5 to 4.14.3
Bumps [beautifulsoup4](https://www.crummy.com/software/BeautifulSoup/bs4/) from 4.13.5 to 4.14.3.

---
updated-dependencies:
- dependency-name: beautifulsoup4
  dependency-version: 4.14.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-08 22:23:50 +00:00
shayaa 9744777747 Merge pull request #67 from KeksPirates/dependabot/pip/darkdetect-0.8.0
build(deps): bump darkdetect from 0.7.1 to 0.8.0
2026-04-09 00:22:42 +02:00
dependabot[bot] 5a7f2e1501 build(deps): bump darkdetect from 0.7.1 to 0.8.0
Bumps [darkdetect](https://github.com/albertosottile/darkdetect) from 0.7.1 to 0.8.0.
- [Release notes](https://github.com/albertosottile/darkdetect/releases)
- [Commits](https://github.com/albertosottile/darkdetect/compare/v0.7.1...v0.8.0)

---
updated-dependencies:
- dependency-name: darkdetect
  dependency-version: 0.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-08 22:17:25 +00:00
shayaa 3519985b83 Merge pull request #68 from KeksPirates/fix/remove-aiohttp
Sync README dependencies with requirements and update workflows
2026-04-09 00:16:13 +02:00
Vxrtrauter fd3a0390df feat: Add sync README dependencies workflow and script
Introduce an automated workflow to keep the README's Python Dependencies section in sync with requirements.txt. Adds .github/workflows/sync-readme-dependencies.yml which runs scripts/sync_readme_dependencies.py on changes to requirements.txt (or manually), then opens a PR via peter-evans/create-pull-request. Adds the sync_readme_dependencies.py script that parses requirements.txt and replaces a managed block in README.md delimited by <!-- python-dependencies:start --> / <!-- python-dependencies:end -->. Also updates README.md to include those markers and correct dependency names/formatting. Additionally, change create-release.yml to use RELEASE_TOKEN (secrets.RELEASE_TOKEN) for release uploads instead of the repository GITHUB_TOKEN.
2026-04-09 00:13:14 +02:00
Vxrtrauter a31d9c10cc fix: remove aiohttp from requirements and README 2026-04-09 00:05:27 +02:00
dependabot[bot] 5486e75ced build(deps): bump actions/upload-artifact from 4 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-08 22:01:45 +00:00
dependabot[bot] 348c524453 build(deps): bump actions/download-artifact from 4 to 8
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-08 22:01:40 +00:00
shayaa 6c148647d8 Configure Dependabot for daily updates
Updated Dependabot configuration to include daily updates for GitHub Actions and Python dependencies.
2026-04-09 00:00:46 +02:00
shayaa 030ab8d51a Merge pull request #59 from KeksPirates/feat/improve-website
feat: Add darkreader lock and 404 tab handling
2026-04-08 23:31:09 +02:00
Vxrtrauter ea2470587d feat: Add darkreader lock and 404 tab handling
Add a <meta name="darkreader-lock"> tag to prevent Dark Reader from altering the docs styling. Adjust client-side routing so only '/' is replaced with the 'start' tab; unknown paths now open the 'notfound' tab instead of being redirected to start.
2026-04-08 23:30:27 +02:00
KeksNino d1df23f4e3 fix: application not closing fully when doing ctrl + c 2026-04-08 02:14:05 +02:00
KeksNino df5ebc2bc6 feat: multi select, delete and cancel downloads 2026-04-08 02:10:30 +02:00
Vxrtrauter 2568c7307c feat: add send2trash dependency to README 2026-04-08 02:08:18 +02:00
shayaa a6589df44d Merge pull request #58 from KeksPirates/feat/improve-accent-color-options
Enhance accent color options and save functionality in settings
2026-04-08 02:05:09 +02:00
shayaa 4c586477ab Merge pull request #57 from KeksPirates/fix/improve-memory-and-quality
Enhance thread-safety, logging, and image handling performance
2026-04-08 02:03:35 +02:00
Vxrtrauter a29f58c023 Add fast layout update for image overlay
Introduce _apply_layout_fast to update the image overlay quickly on window resize using the cached QImage and FastTransformation. Called from the Resize event handler before the resize timer, it handles wallpaper mode (fast scale, center-crop to parent) and non-wallpaper mode (positioning by state.image_position and state.image_offset) to provide immediate visual feedback without running the heavier _apply_layout.
2026-04-08 01:52:08 +02:00
Vxrtrauter 49ba699155 fix: Debounce resize and cache loaded images
Add a 100ms single-shot QTimer to debounce Resize events and avoid reloading/layout on every resize. Cache the loaded QImage in _cached_qimage/_cached_path and only reload from disk when the image path changes. Extract layout/apply logic into _apply_layout and clear cache in update_image_overlay when a new path is provided. These changes reduce disk I/O and repeated layout work during rapid resizes.
2026-04-08 01:44:21 +02:00
Vxrtrauter cc51f57e18 fix: Add locks, logging refactor, and bugfixes
Improve thread-safety, logging, and various bug fixes across the app.

Key changes:
- Add and use state.downloads_lock around accesses to state.active_downloads in multiple modules (UI, libtorrent, misc, direct downloads) to prevent race conditions.
- Prevent concurrent dl_status_loop runs using a non-blocking _loop_lock and ensure proper iteration over a snapshot of downloads.
- Add metadata timeout when waiting for torrent metadata and fix free-space check logic to remove or reject downloads appropriately.
- Refactor download log persistence: add _load_downloads and _save_downloads helpers and centralize JSON read/write logic in utils/logging/logs.py.
- Optimize DirectDownloadStatus speed window by switching to deque and using popleft to trim older samples.
- Trim console log widget to 500 lines to avoid unbounded growth.
- Add small state fields for custom image positioning (x/y and enabled flag).
- Fix Steamrip scraper caching behavior and make Uztracker return an empty list on fetch failures.
- Add defensive checks in run_download to handle missing links.

These changes focus on stability and correctness for concurrent download handling and durable logging.
2026-04-08 01:41:32 +02:00
Vxrtrauter cf9185e9e7 feat: Enhance accent color options in settings dialog and save functionality 2026-04-08 01:06:41 +02:00
KeksNino fc67ff11c9 feat: add show widget to tray icon and show the gui on left click 2026-04-08 00:23:24 +02:00
KeksNino 6592cd4ec1 feat: properly utilize close to tray and quit action 2026-04-08 00:15:46 +02:00
KeksNino 767955e008 feat: add tray icon 2026-04-08 00:00:15 +02:00
KeksNino 75c649afda feat: move to trash instead of deleting permanently when deleting files 2026-04-07 23:44:38 +02:00
KeksNino 7526eb5d30 feat: add download item context menu widget 2026-04-07 23:42:50 +02:00
KeksNino 1f16a58f21 feat: double click to download in search results 2026-04-07 23:30:18 +02:00
KeksNino 5b42d8d607 feat: add context menu to search list to copy magnet uri and open in browser 2026-04-07 23:23:40 +02:00
Vxrtrauter dde4288a2d fix: Rename workflow from "Build Executables (Dev)" to "Create Release" 2026-04-07 22:19:55 +02:00
Vxrtrauter ac0d64edd3 feat: Update release workflow and versioning to stable 2026-04-07 22:15:51 +02:00
57 changed files with 1529 additions and 501 deletions
+16
View File
@@ -0,0 +1,16 @@
version: 2
updates:
# Keeps your GitHub Actions (CI/CD) up to date daily
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
# Keeps your Python dependencies (requirements.txt / pyproject.toml) up to date daily
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "daily"
ignore:
- dependency-name: "darkdetect"
versions: [">=0.8.0"]
+31
View File
@@ -0,0 +1,31 @@
changelog:
exclude:
labels:
- ignore-for-release
authors:
- dependabot[bot]
- github-actions[bot]
categories:
- title: "New Features"
labels:
- enhancement
- feature
- title: "Bug Fixes"
labels:
- bug
- fix
- title: "Documentation"
labels:
- documentation
- title: "Maintenance"
labels:
- chore
- refactor
- testing
- build
- title: "Dependencies"
labels:
- dependencies
- title: "Other Changes"
labels:
- "*"
@@ -1,9 +1,7 @@
name: Release name: Create Release
on: on:
push: workflow_dispatch:
tags:
- "*"
permissions: permissions:
contents: write contents: write
@@ -19,19 +17,23 @@ jobs:
matrix: matrix:
include: include:
- os: ubuntu-22.04 - os: ubuntu-22.04
artifact_name: SoftwareManager-${{ github.sha }}-linux artifact_name: SoftwareManager-stable-${{ github.sha }}-linux
asset_name: SoftwareManager-stable-${{ github.sha }}-linux
- os: windows-latest - os: windows-latest
artifact_name: SoftwareManager-${{ github.sha }}-windows artifact_name: SoftwareManager-stable-${{ github.sha }}-windows
asset_name: SoftwareManager-stable-${{ github.sha }}-windows
- os: macos-latest - os: macos-latest
artifact_name: SoftwareManager-${{ github.sha }}-macos artifact_name: SoftwareManager-stable-${{ github.sha }}-macos
asset_name: SoftwareManager-stable-${{ github.sha }}-macos
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
outputs: outputs:
version: ${{ steps.version.outputs.version }}
short_sha: ${{ steps.version.outputs.short_sha }} short_sha: ${{ steps.version.outputs.short_sha }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
@@ -61,10 +63,13 @@ jobs:
id: version id: version
shell: bash shell: bash
run: | run: |
SHORT_SHA="${{ github.sha }}" COMMIT_SHA=${{ github.sha }}
SHORT_SHA="${SHORT_SHA:0:7}" SHORT_SHA=${COMMIT_SHA:0:7}
echo "VERSION=$SHORT_SHA" >> $GITHUB_ENV VERSION="${SHORT_SHA}-stable"
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
echo "Generated version: $VERSION"
- name: Inject commit hash into code - name: Inject commit hash into code
shell: bash shell: bash
@@ -136,7 +141,7 @@ jobs:
if: runner.os == 'macOS' if: runner.os == 'macOS'
shell: bash shell: bash
run: | run: |
pyinstaller --onefile \ pyinstaller --windowed \
--add-data "build_info.json:." \ --add-data "build_info.json:." \
--exclude-module PyQt6 \ --exclude-module PyQt6 \
--exclude-module PyQt5 \ --exclude-module PyQt5 \
@@ -148,38 +153,59 @@ jobs:
--name ${{ env.APP_NAME }} \ --name ${{ env.APP_NAME }} \
src/main.py src/main.py
- name: Install create-dmg (macOS)
if: runner.os == 'macOS'
run: brew install create-dmg
- name: Rename artifact - name: Rename artifact
shell: bash shell: bash
run: | run: |
mkdir -p dist-final mkdir -p dist-final
if [ "$RUNNER_OS" == "Windows" ]; then if [ "$RUNNER_OS" == "Windows" ]; then
mv nuitka-build/main.dist dist-final/${{ env.APP_NAME }} mv nuitka-build/main.dist dist-final/${{ env.APP_NAME }}
else ls -la dist-final/${{ env.APP_NAME }}/
elif [ "$RUNNER_OS" == "Linux" ]; then
chmod +x dist/${{ env.APP_NAME }} chmod +x dist/${{ env.APP_NAME }}
mv dist/${{ env.APP_NAME }} dist-final/${{ matrix.artifact_name }} mv dist/${{ env.APP_NAME }} dist-final/${{ matrix.artifact_name }}
elif [ "$RUNNER_OS" == "macOS" ]; then
create-dmg \
--volname "${{ env.APP_NAME }}" \
--window-pos 200 120 \
--window-size 600 300 \
--icon-size 100 \
--icon "${{ env.APP_NAME }}.app" 150 150 \
--hide-extension "${{ env.APP_NAME }}.app" \
--app-drop-link 450 150 \
"dist-final/${{ matrix.artifact_name }}.dmg" \
"dist/"
fi fi
ls -la dist-final/
- name: Create ZIP (Windows) - name: Create ZIP (Windows)
if: runner.os == 'Windows' if: runner.os == 'Windows'
shell: pwsh shell: pwsh
run: | run: |
Compress-Archive -Path "dist-final/${{ env.APP_NAME }}/*" -DestinationPath "dist-final/${{ matrix.artifact_name }}.zip" Compress-Archive -Path "dist-final/${{ env.APP_NAME }}/*" -DestinationPath "dist-final/${{ matrix.artifact_name }}.zip"
Write-Host "Created ZIP: ${{ matrix.artifact_name }}.zip"
- name: Build Installer (Windows) - name: Build Installer (Windows)
if: runner.os == 'Windows' if: runner.os == 'Windows'
shell: bash shell: bash
run: | run: |
SETUP_NAME="SoftwareManager-${{ env.VERSION }}-windows-setup" SHORT_SHA="${{ github.sha }}"
SHORT_SHA="${SHORT_SHA:0:7}"
SETUP_NAME="SoftwareManager-stable-${SHORT_SHA}-windows-setup"
iscc //DMyAppVersion="${{ env.VERSION }}" \ iscc //DMyAppVersion="${{ env.VERSION }}" \
//DMySourceDir="dist-final\\${{ env.APP_NAME }}" \ //DMySourceDir="dist-final\\${{ env.APP_NAME }}" \
//DMyOutputDir="dist-final" \ //DMyOutputDir="dist-final" \
//DMyOutputFilename="${SETUP_NAME}" \ //DMyOutputFilename="${SETUP_NAME}" \
installer.iss installer.iss
echo "SETUP_NAME=${SETUP_NAME}" >> $GITHUB_ENV echo "SETUP_NAME=${SETUP_NAME}" >> $GITHUB_ENV
ls -la dist-final/
- name: Upload build artifact (Windows) - name: Upload build artifact (Windows)
if: runner.os == 'Windows' if: runner.os == 'Windows'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: ${{ matrix.os }}-build name: ${{ matrix.os }}-build
path: | path: |
@@ -189,21 +215,21 @@ jobs:
- name: Upload build artifact (Linux/macOS) - name: Upload build artifact (Linux/macOS)
if: runner.os != 'Windows' if: runner.os != 'Windows'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: ${{ matrix.os }}-build name: ${{ matrix.os }}-build
path: dist-final/${{ matrix.artifact_name }} path: dist-final/${{ matrix.artifact_name }}*
retention-days: 1 retention-days: 1
release: release:
name: Create Release name: Create Release
needs: build needs: build
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
if: success()
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@v4
with: with:
path: artifacts path: artifacts
@@ -211,27 +237,29 @@ jobs:
run: | run: |
mkdir -p release mkdir -p release
SHORT_SHA="${{ needs.build.outputs.short_sha }}" SHORT_SHA="${{ needs.build.outputs.short_sha }}"
cp artifacts/ubuntu-22.04-build/SoftwareManager-*-linux release/SoftwareManager-${SHORT_SHA}-linux cp artifacts/ubuntu-22.04-build/SoftwareManager-stable-*-linux release/SoftwareManager-stable-${SHORT_SHA}-linux
cp artifacts/windows-latest-build/SoftwareManager-*-windows.zip release/SoftwareManager-${SHORT_SHA}-windows.zip cp artifacts/windows-latest-build/SoftwareManager-stable-*-windows.zip release/SoftwareManager-stable-${SHORT_SHA}-windows.zip
cp artifacts/windows-latest-build/SoftwareManager-*-windows-setup.exe release/SoftwareManager-${SHORT_SHA}-windows-setup.exe cp artifacts/windows-latest-build/SoftwareManager-stable-*-windows-setup.exe release/SoftwareManager-stable-${SHORT_SHA}-windows-setup.exe
cp artifacts/macos-latest-build/SoftwareManager-*-macos release/SoftwareManager-${SHORT_SHA}-macos cp artifacts/macos-latest-build/SoftwareManager-stable-*-macos.dmg release/SoftwareManager-stable-${SHORT_SHA}-macos.dmg
chmod +x release/SoftwareManager-*-linux release/SoftwareManager-*-macos chmod +x release/SoftwareManager-stable-*-linux
ls -la release/
- name: Generate checksums - name: Generate checksums
run: | run: |
cd release cd release
sha256sum * > SHA256SUMS.txt sha256sum * > SHA256SUMS.txt
cat SHA256SUMS.txt
- name: Create Release - name: Create Release (Public)
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v3
with: with:
name: ${{ needs.build.outputs.short_sha }} token: ${{ secrets.RELEASE_TOKEN }}
tag_name: ${{ needs.build.outputs.version }}
name: ${{ needs.build.outputs.version }}
generate_release_notes: true generate_release_notes: true
files: | files: |
release/SoftwareManager-${{ needs.build.outputs.short_sha }}-linux release/SoftwareManager-stable-${{ needs.build.outputs.short_sha }}-linux
release/SoftwareManager-${{ needs.build.outputs.short_sha }}-windows.zip release/SoftwareManager-stable-${{ needs.build.outputs.short_sha }}-windows.zip
release/SoftwareManager-${{ needs.build.outputs.short_sha }}-windows-setup.exe release/SoftwareManager-stable-${{ needs.build.outputs.short_sha }}-windows-setup.exe
release/SoftwareManager-${{ needs.build.outputs.short_sha }}-macos release/SoftwareManager-stable-${{ needs.build.outputs.short_sha }}-macos.dmg
release/SHA256SUMS.txt release/SHA256SUMS.txt
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
+16 -7
View File
@@ -38,7 +38,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
@@ -58,6 +58,15 @@ jobs:
env: env:
UV_SYSTEM_PYTHON: 1 UV_SYSTEM_PYTHON: 1
- name: Install dependencies (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libxcb-cursor0 libxcb-render-util0 libxcb-xkb1 \
libxcb-icccm4 libxcb-keysyms1 libxcb-image0 \
libxcb-shape0 libxcb-util1 libxkbcommon-x11-0
- name: Install Nuitka (Windows only) - name: Install Nuitka (Windows only)
if: runner.os == 'Windows' if: runner.os == 'Windows'
run: uv pip install nuitka run: uv pip install nuitka
@@ -195,7 +204,7 @@ jobs:
- name: Upload build artifact (Windows) - name: Upload build artifact (Windows)
if: runner.os == 'Windows' if: runner.os == 'Windows'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: ${{ matrix.os }}-build name: ${{ matrix.os }}-build
path: | path: |
@@ -205,7 +214,7 @@ jobs:
- name: Upload build artifact (Linux/macOS) - name: Upload build artifact (Linux/macOS)
if: runner.os != 'Windows' if: runner.os != 'Windows'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: ${{ matrix.os }}-build name: ${{ matrix.os }}-build
path: dist-final/${{ matrix.artifact_name }} path: dist-final/${{ matrix.artifact_name }}
@@ -218,8 +227,8 @@ jobs:
if: success() if: success()
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v8
with: with:
path: artifacts path: artifacts
@@ -266,7 +275,7 @@ jobs:
cat release_notes.md cat release_notes.md
- name: Create Release (Public) - name: Create Release (Public)
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v3
with: with:
tag_name: ${{ needs.build.outputs.version }} tag_name: ${{ needs.build.outputs.version }}
name: ${{ needs.build.outputs.version }} name: ${{ needs.build.outputs.version }}
@@ -278,4 +287,4 @@ jobs:
release/SoftwareManager-dev-${{ needs.build.outputs.short_sha }}-macos release/SoftwareManager-dev-${{ needs.build.outputs.short_sha }}-macos
release/SHA256SUMS.txt release/SHA256SUMS.txt
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,39 @@
name: Sync README Dependencies
on:
push:
branches:
- main
paths:
- requirements.txt
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
sync:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Update README dependencies
run: python scripts/sync_readme_dependencies.py
- name: Create pull request
uses: peter-evans/create-pull-request@v8
with:
commit-message: "Sync README dependencies"
branch: auto/sync-readme-dependencies
title: "Sync README dependencies"
body: "Automated update of the Python Dependencies section in README.md to match `requirements.txt`."
labels: documentation
token: ${{ secrets.PR_TOKEN }}
+22 -7
View File
@@ -14,7 +14,7 @@ jobs:
build: build:
name: Build ${{ matrix.os }} name: Build ${{ matrix.os }}
strategy: strategy:
fail-fast: false # keep other platforms building even if one fails fail-fast: false
matrix: matrix:
include: include:
- os: ubuntu-22.04 - os: ubuntu-22.04
@@ -28,7 +28,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
@@ -138,7 +138,7 @@ jobs:
if: runner.os == 'macOS' if: runner.os == 'macOS'
shell: bash shell: bash
run: | run: |
pyinstaller --onefile \ pyinstaller --windowed \
--add-data "build_info.json:." \ --add-data "build_info.json:." \
--exclude-module PyQt6 \ --exclude-module PyQt6 \
--exclude-module PyQt5 \ --exclude-module PyQt5 \
@@ -150,15 +150,30 @@ jobs:
--name ${{ env.APP_NAME }} \ --name ${{ env.APP_NAME }} \
src/main.py src/main.py
- name: Install create-dmg (macOS)
if: runner.os == 'macOS'
run: brew install create-dmg
- name: Stage artifact - name: Stage artifact
shell: bash shell: bash
run: | run: |
mkdir -p dist-final mkdir -p dist-final
if [ "$RUNNER_OS" == "Windows" ]; then if [ "$RUNNER_OS" == "Windows" ]; then
mv nuitka-build/main.dist dist-final/${{ env.APP_NAME }} mv nuitka-build/main.dist dist-final/${{ env.APP_NAME }}
else elif [ "$RUNNER_OS" == "Linux" ]; then
chmod +x dist/${{ env.APP_NAME }} chmod +x dist/${{ env.APP_NAME }}
mv dist/${{ env.APP_NAME }} dist-final/${{ matrix.artifact_name }} mv dist/${{ env.APP_NAME }} dist-final/${{ matrix.artifact_name }}
elif [ "$RUNNER_OS" == "macOS" ]; then
create-dmg \
--volname "${{ env.APP_NAME }} Test" \
--window-pos 200 120 \
--window-size 600 300 \
--icon-size 100 \
--icon "${{ env.APP_NAME }}.app" 150 150 \
--hide-extension "${{ env.APP_NAME }}.app" \
--app-drop-link 450 150 \
"dist-final/${{ matrix.artifact_name }}.dmg" \
"dist/"
fi fi
ls -la dist-final/ ls -la dist-final/
@@ -170,7 +185,7 @@ jobs:
- name: Upload artifact (Windows) - name: Upload artifact (Windows)
if: runner.os == 'Windows' if: runner.os == 'Windows'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: ${{ matrix.os }}-build name: ${{ matrix.os }}-build
path: dist-final/${{ matrix.artifact_name }}.zip path: dist-final/${{ matrix.artifact_name }}.zip
@@ -178,8 +193,8 @@ jobs:
- name: Upload artifact (Linux/macOS) - name: Upload artifact (Linux/macOS)
if: runner.os != 'Windows' if: runner.os != 'Windows'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: ${{ matrix.os }}-build name: ${{ matrix.os }}-build
path: dist-final/${{ matrix.artifact_name }} path: dist-final/${{ matrix.artifact_name }}*
retention-days: 7 retention-days: 7
+1
View File
@@ -143,6 +143,7 @@ venv/
ENV/ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
.python-version
# Spyder project settings # Spyder project settings
.spyderproject .spyderproject
+15 -13
View File
@@ -1,5 +1,9 @@
# SoftwareManager # SoftwareManager
SoftwareManager is a Python-based GUI tool that simplifies searching and downloading software from various sources, including Rutracker, Uztracker and the official M0nkrus Telegram channel. SoftwareManager is a Python-based GUI tool that simplifies searching and downloading software from various sources, including Rutracker, Uztracker, Steamrip and the official M0nkrus Telegram channel.
> [!NOTE]
> Our Steamrip implementation is currently very experimental and potentially unstable.<br>
> Downloads will fail if there's no BuzzHeavier download available. We're working on this.
## Features ## Features
- Searching & Downloading Software from various pages - Searching & Downloading Software from various pages
@@ -35,23 +39,21 @@ SoftwareManager is a Python-based GUI tool that simplifies searching and downloa
## Python Dependencies ## Python Dependencies
(also included in requirements.txt) (also included in requirements.txt)
```bash <!-- python-dependencies:start -->
requests (2.32.2) ```text
PySide6 (6.10.1) requests (2.34.2)
beautifulsoup (44.13.5) PySide6-Essentials (6.11.1)
beautifulsoup4 (4.14.3)
darkdetect (0.7.1) darkdetect (0.7.1)
pyinstaller (6.15.0) pyinstaller (6.20.0)
PyQtDarkTheme-fork (2.3.4) PyQtDarkTheme-fork (2.3.6)
aiohttp (3.13.0)
plyer (2.1.0) plyer (2.1.0)
psutil (7.1.0) psutil (7.2.2)
libtorrent (2.0.11) libtorrent (2.0.11)
libtorrent-windows-dll (0.0.3) libtorrent-windows-dll (0.0.3)
send2trash (2.1.0)
``` ```
<!-- python-dependencies:end -->
## Activity
![Alt](https://repobeats.axiom.co/api/embed/3a61ea02ffdeb9ed5dd948d974a21e0d6cfae36c.svg "Repobeats analytics image")
## Star History ## Star History
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+164
View File
@@ -0,0 +1,164 @@
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100%" viewBox="0 0 1080 1080" enable-background="new 0 0 1080 1080" xml:space="preserve">
<path fill="#0F1011" opacity="1.000000" stroke="none"
d="
M521.000000,1081.000000
C347.353302,1081.000000 174.206635,1081.000000 1.029974,1081.000000
C1.029974,721.061523 1.029974,361.123047 1.029974,1.092287
C360.896912,1.092287 720.793823,1.092287 1080.845459,1.092287
C1080.845459,360.999939 1080.845459,720.999939 1080.845459,1081.000000
C894.455872,1081.000000 707.977966,1081.000000 521.000000,1081.000000
M771.071899,705.499756
C771.071899,712.255066 771.071899,719.010376 771.071899,725.787720
C805.189453,725.787720 838.433228,725.787720 871.982117,725.787720
C871.982117,724.198059 871.977478,722.893738 871.982788,721.589417
C872.346375,632.439392 872.695801,543.289307 873.087097,454.139404
C873.170105,435.232727 871.615662,416.554352 865.875427,398.414978
C858.416626,374.845428 844.322693,357.740814 819.539062,351.179382
C805.065430,347.347504 790.354187,346.688232 775.438110,347.469025
C750.713196,348.763275 732.227295,360.350342 718.676453,380.598389
C718.278503,381.193024 717.723083,381.682220 716.937561,382.559540
C709.229492,367.323395 697.124634,357.854065 681.894958,352.343658
C665.384827,346.369873 648.342102,345.949188 631.127563,348.544525
C609.201660,351.850128 593.294556,363.158142 584.295227,383.689392
C584.085876,384.167084 583.666626,384.552765 582.629456,385.936188
C582.629456,375.268921 582.432129,365.801147 582.718811,356.348022
C582.848083,352.084778 581.346741,351.035767 577.272949,351.057037
C547.943848,351.210297 518.613159,351.214417 489.284210,351.052094
C485.226227,351.029633 484.222809,352.337158 484.225586,356.220917
C484.313141,477.873199 484.316132,599.525513 484.215637,721.177795
C484.212280,725.235718 485.524109,726.174438 489.373871,726.155457
C518.869690,726.010193 548.366516,726.078369 577.863037,726.066772
C579.177673,726.066284 580.492310,725.937317 582.294312,725.842590
C582.411072,723.590027 582.615479,721.483276 582.615906,719.376587
C582.632019,635.053223 582.624939,550.729797 582.642700,466.406403
C582.643616,461.909119 582.601562,457.391998 582.984192,452.918732
C583.783752,443.573364 585.594788,434.427887 590.720276,426.362396
C595.334778,419.101013 603.568298,416.113373 612.510742,417.573242
C621.158325,418.984924 623.718262,425.447479 624.800415,432.537415
C625.976135,440.240509 626.601562,448.102905 626.612244,455.896973
C626.732483,544.053101 626.723267,632.209473 626.589661,720.365601
C626.582764,724.927246 627.708862,726.224487 632.373047,726.189392
C662.034485,725.965820 691.698547,726.080139 721.361694,726.064453
C723.113525,726.063538 724.865234,725.900635 726.957336,725.796082
C726.957336,723.243530 726.957275,721.268250 726.957275,719.292969
C726.957458,632.803162 726.968445,546.313354 726.944214,459.823578
C726.941223,449.239441 728.039978,438.887451 732.563599,429.153198
C736.859009,419.910126 744.395264,415.290436 753.561279,416.237335
C761.562256,417.063873 766.880310,422.073700 768.930908,432.236359
C770.302734,439.034576 770.973572,446.088562 770.984680,453.029663
C771.119019,536.852905 771.071838,620.676392 771.071899,705.499756
M449.075165,578.388550
C444.612366,569.698914 440.983704,560.440002 435.519684,552.433716
C422.667145,533.601379 405.276215,519.323669 386.430817,506.766174
C366.624786,493.568573 346.812805,480.379791 326.987457,467.211212
C314.718567,459.061859 309.375153,447.642090 310.893677,433.049744
C311.652100,425.761383 316.614258,420.448883 323.722198,418.931366
C336.829163,416.133026 345.032623,421.241791 347.777405,435.303497
C349.323212,443.222870 349.245270,451.509369 349.353851,459.637756
C349.407593,463.659760 350.698944,464.717834 354.566010,464.701935
C385.891083,464.572968 417.217163,464.573364 448.542236,464.702332
C452.329407,464.717926 453.572510,463.488739 453.266174,459.803009
C452.384766,449.198700 452.532043,438.436707 450.768219,427.991089
C444.826935,392.805817 425.563995,368.149658 391.467346,356.014587
C368.493378,347.838104 344.665100,346.136963 320.550812,347.517151
C298.078491,348.803314 276.322418,353.354095 256.360260,364.197845
C234.918915,375.845093 218.772064,392.192810 212.586060,416.625000
C208.628387,432.256226 209.851181,448.018677 211.980621,463.773712
C214.293564,480.886322 220.373398,496.450378 232.494080,508.880341
C240.825806,517.424622 249.784256,525.541809 259.346588,532.662842
C277.899567,546.479004 297.228271,559.249084 315.920227,572.884949
C323.821167,578.648682 331.598694,584.875366 338.252686,591.994324
C352.762024,607.517578 353.500397,626.342834 348.726654,645.805786
C347.404053,651.198242 343.621399,655.271973 337.821289,656.412048
C326.377319,658.661499 313.006104,655.729919 310.746490,639.654663
C310.376984,637.025940 310.029877,634.371582 309.983185,631.723572
C309.739258,617.897644 309.606567,604.069824 309.398712,590.243225
C309.379883,588.990479 309.101746,587.741699 308.962982,586.641174
C275.449921,586.641174 242.367004,586.641174 209.156891,586.641174
C208.767090,607.270203 207.222519,627.571594 211.066406,647.744202
C218.104706,684.681030 238.296249,710.625732 274.680664,722.063477
C311.032318,733.490845 347.949768,733.707153 384.363068,722.701538
C417.519196,712.680542 441.096252,691.563110 450.703888,657.418823
C458.037323,631.356812 457.330383,605.098694 449.075165,578.388550
z"/>
<path fill="#F7AAF8" opacity="1.000000" stroke="none"
d="
M771.071899,704.999817
C771.071838,620.676392 771.119019,536.852905 770.984680,453.029663
C770.973572,446.088562 770.302734,439.034576 768.930908,432.236359
C766.880310,422.073700 761.562256,417.063873 753.561279,416.237335
C744.395264,415.290436 736.859009,419.910126 732.563599,429.153198
C728.039978,438.887451 726.941223,449.239441 726.944214,459.823578
C726.968445,546.313354 726.957458,632.803162 726.957275,719.292969
C726.957275,721.268250 726.957336,723.243530 726.957336,725.796082
C724.865234,725.900635 723.113525,726.063538 721.361694,726.064453
C691.698547,726.080139 662.034485,725.965820 632.373047,726.189392
C627.708862,726.224487 626.582764,724.927246 626.589661,720.365601
C626.723267,632.209473 626.732483,544.053101 626.612244,455.896973
C626.601562,448.102905 625.976135,440.240509 624.800415,432.537415
C623.718262,425.447479 621.158325,418.984924 612.510742,417.573242
C603.568298,416.113373 595.334778,419.101013 590.720276,426.362396
C585.594788,434.427887 583.783752,443.573364 582.984192,452.918732
C582.601562,457.391998 582.643616,461.909119 582.642700,466.406403
C582.624939,550.729797 582.632019,635.053223 582.615906,719.376587
C582.615479,721.483276 582.411072,723.590027 582.294312,725.842590
C580.492310,725.937317 579.177673,726.066284 577.863037,726.066772
C548.366516,726.078369 518.869690,726.010193 489.373871,726.155457
C485.524109,726.174438 484.212280,725.235718 484.215637,721.177795
C484.316132,599.525513 484.313141,477.873199 484.225586,356.220917
C484.222809,352.337158 485.226227,351.029633 489.284210,351.052094
C518.613159,351.214417 547.943848,351.210297 577.272949,351.057037
C581.346741,351.035767 582.848083,352.084778 582.718811,356.348022
C582.432129,365.801147 582.629456,375.268921 582.629456,385.936188
C583.666626,384.552765 584.085876,384.167084 584.295227,383.689392
C593.294556,363.158142 609.201660,351.850128 631.127563,348.544525
C648.342102,345.949188 665.384827,346.369873 681.894958,352.343658
C697.124634,357.854065 709.229492,367.323395 716.937561,382.559540
C717.723083,381.682220 718.278503,381.193024 718.676453,380.598389
C732.227295,360.350342 750.713196,348.763275 775.438110,347.469025
C790.354187,346.688232 805.065430,347.347504 819.539062,351.179382
C844.322693,357.740814 858.416626,374.845428 865.875427,398.414978
C871.615662,416.554352 873.170105,435.232727 873.087097,454.139404
C872.695801,543.289307 872.346375,632.439392 871.982788,721.589417
C871.977478,722.893738 871.982117,724.198059 871.982117,725.787720
C838.433228,725.787720 805.189453,725.787720 771.071899,725.787720
C771.071899,719.010376 771.071899,712.255066 771.071899,704.999817
z"/>
<path fill="#F7AAF8" opacity="1.000000" stroke="none"
d="
M449.203979,578.762207
C457.330383,605.098694 458.037323,631.356812 450.703888,657.418823
C441.096252,691.563110 417.519196,712.680542 384.363068,722.701538
C347.949768,733.707153 311.032318,733.490845 274.680664,722.063477
C238.296249,710.625732 218.104706,684.681030 211.066406,647.744202
C207.222519,627.571594 208.767090,607.270203 209.156891,586.641174
C242.367004,586.641174 275.449921,586.641174 308.962982,586.641174
C309.101746,587.741699 309.379883,588.990479 309.398712,590.243225
C309.606567,604.069824 309.739258,617.897644 309.983185,631.723572
C310.029877,634.371582 310.376984,637.025940 310.746490,639.654663
C313.006104,655.729919 326.377319,658.661499 337.821289,656.412048
C343.621399,655.271973 347.404053,651.198242 348.726654,645.805786
C353.500397,626.342834 352.762024,607.517578 338.252686,591.994324
C331.598694,584.875366 323.821167,578.648682 315.920227,572.884949
C297.228271,559.249084 277.899567,546.479004 259.346588,532.662842
C249.784256,525.541809 240.825806,517.424622 232.494080,508.880341
C220.373398,496.450378 214.293564,480.886322 211.980621,463.773712
C209.851181,448.018677 208.628387,432.256226 212.586060,416.625000
C218.772064,392.192810 234.918915,375.845093 256.360260,364.197845
C276.322418,353.354095 298.078491,348.803314 320.550812,347.517151
C344.665100,346.136963 368.493378,347.838104 391.467346,356.014587
C425.563995,368.149658 444.826935,392.805817 450.768219,427.991089
C452.532043,438.436707 452.384766,449.198700 453.266174,459.803009
C453.572510,463.488739 452.329407,464.717926 448.542236,464.702332
C417.217163,464.573364 385.891083,464.572968 354.566010,464.701935
C350.698944,464.717834 349.407593,463.659760 349.353851,459.637756
C349.245270,451.509369 349.323212,443.222870 347.777405,435.303497
C345.032623,421.241791 336.829163,416.133026 323.722198,418.931366
C316.614258,420.448883 311.652100,425.761383 310.893677,433.049744
C309.375153,447.642090 314.718567,459.061859 326.987457,467.211212
C346.812805,480.379791 366.624786,493.568573 386.430817,506.766174
C405.276215,519.323669 422.667145,533.601379 435.519684,552.433716
C440.983704,560.440002 444.612366,569.698914 449.203979,578.762207
z"/>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

+47 -7
View File
@@ -1,17 +1,39 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" style="background:#0f1011">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Simply search and download software and games from various sources. Developed with ❤︎ by KeksPirates.">
<meta name="darkreader-lock">
<link rel="stylesheet" href="main.css"/> <link rel="stylesheet" href="main.css"/>
<title>SoftwareManager - Main</title> <title>SoftwareManager - Simply search and download software from various sources</title>
<!-- <link rel="icon" type="image/x-icon" href="/assets/icons/favicon.ico"> --> <!-- <link rel="icon" type="image/x-icon" href="/assets/icons/favicon.ico"> -->
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;700&display=swap" rel="stylesheet">
<link rel="apple-touch-icon" sizes="180x180" href="assets/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="192x192" href="assets/favicon/android-chrome-192x192.png">
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="assets/favicon/favicon-16x16.png">
<!-- Open Graph / Discord Embed -->
<meta property="og:type" content="website">
<meta property="og:title" content="SoftwareManager">
<meta property="og:description" content="Simply search and download software.">
<meta property="og:image" content="https://softwaremanager.xyz/assets/logos/softwaremanager-logo-200px.png">
<meta property="og:url" content="https://softwaremanager.xyz/">
<meta property="og:site_name" content="KeksPirates">
<meta name="theme-color" content="#db65d5">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="SoftwareManager">
<meta name="twitter:description" content="Simply search and download software from various sources">
<meta name="twitter:image" content="https://softwaremanager.xyz/assets/logos/softwaremanager-logo-200px.png">
</head> </head>
<body> <body style="opacity:0">
<header> <header>
<div class="logo-text"> <div class="logo-text">
<h1>KeksPirates</h1> <h1>KeksPirates</h1>
@@ -159,7 +181,7 @@
<div class="endpoint-parameters-content"> <div class="endpoint-parameters-content">
<p class="sources-text">SteamRip</p> <p class="sources-text">SteamRip</p>
<p class="sources-text">Games</p> <p class="sources-text">Games</p>
<p class="sources-text">Currently very experimental due to download link retrieveal. May not always work as expected.</p> <p class="sources-text">Currently very experimental due to download link retrieval. May not always work as expected.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -194,7 +216,12 @@
tablinks[i].className = tablinks[i].className.replace(" active", ""); tablinks[i].className = tablinks[i].className.replace(" active", "");
} }
document.getElementById(tabName).style.display = "block"; var target = document.getElementById(tabName);
target.style.display = "block";
// restart animation
target.classList.remove('tab-animate');
void target.offsetWidth;
target.classList.add('tab-animate');
let activeButton = document.querySelector(`.tablinks[onclick*="${tabName}"]`); let activeButton = document.querySelector(`.tablinks[onclick*="${tabName}"]`);
if (activeButton) { if (activeButton) {
@@ -218,6 +245,17 @@
} }
}); });
// Determine the correct tab immediately (before full load) to prevent flash
(function() {
let currentPath = window.location.pathname;
let tabName = 'start';
if (currentPath === '/api') tabName = 'api';
else if (currentPath === '/docs') tabName = 'docs';
else if (currentPath !== '/') tabName = 'notfound';
// Show only the correct tab right away
document.getElementById(tabName).style.display = 'block';
})();
window.onload = function() { window.onload = function() {
let currentPath = window.location.pathname; let currentPath = window.location.pathname;
@@ -225,9 +263,11 @@
openTab(null, 'api', false); openTab(null, 'api', false);
} else if (currentPath === '/docs') { } else if (currentPath === '/docs') {
openTab(null, 'docs', false); openTab(null, 'docs', false);
} else { } else if (currentPath === '/') {
history.replaceState({ tab: 'start' }, "", "/"); history.replaceState({ tab: 'start' }, "", "/");
openTab(null, 'start', false); openTab(null, 'start', false);
} else {
openTab(null, 'notfound', false);
} }
}; };
</script> </script>
+22
View File
@@ -12,6 +12,7 @@ body {
flex-direction: column; flex-direction: column;
min-height: 100vh; min-height: 100vh;
background: #0f1011; background: #0f1011;
opacity: 1 !important;
} }
header { header {
@@ -65,6 +66,27 @@ main {
padding: 3rem; padding: 3rem;
} }
/* Hide all tabs by default to prevent flash on page load */
.tabcontent {
display: none;
}
/* Tab switch animation */
@keyframes tabFadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.tabcontent.tab-animate {
animation: tabFadeIn 0.3s ease-out forwards;
}
main h1.hometitle { main h1.hometitle {
font-size: 4.0rem; font-size: 4.0rem;
line-height: 4rem; line-height: 4rem;
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://softwaremanager.xyz/</loc>
<priority>1.0</priority>
</url>
<url>
<loc>https://softwaremanager.xyz/api</loc>
<priority>0.8</priority>
</url>
<url>
<loc>https://softwaremanager.xyz/docs</loc>
<priority>0.8</priority>
</url>
</urlset>
+1 -1
View File
@@ -5,7 +5,7 @@
; Version is passed via /D on the command line: iscc /DMyAppVersion=... ; Version is passed via /D on the command line: iscc /DMyAppVersion=...
#ifndef MyAppVersion #ifndef MyAppVersion
#define MyAppVersion "dev" #define MyAppVersion "stable"
#endif #endif
; SourceDir is passed via /D on the command line ; SourceDir is passed via /D on the command line
+8 -8
View File
@@ -1,11 +1,11 @@
requests==2.32.2 requests==2.34.2
PySide6-Essentials==6.10.1 PySide6-Essentials==6.11.1
beautifulsoup4==4.13.5 beautifulsoup4==4.14.3
darkdetect==0.7.1 darkdetect==0.7.1
pyinstaller==6.15.0 pyinstaller==6.21.0
PyQtDarkTheme-fork==2.3.4 PyQtDarkTheme-fork==2.3.6
aiohttp==3.13.0
plyer==2.1.0 plyer==2.1.0
psutil==7.1.0 psutil==7.2.2
libtorrent==2.0.11 libtorrent==2.0.11
libtorrent-windows-dll==0.0.3 libtorrent-windows-dll==0.0.3
send2trash==2.1.0
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
from pathlib import Path
import re
ROOT_DIR = Path(__file__).resolve().parent.parent
README_PATH = ROOT_DIR / "README.md"
REQUIREMENTS_PATH = ROOT_DIR / "requirements.txt"
START_MARKER = "<!-- python-dependencies:start -->"
END_MARKER = "<!-- python-dependencies:end -->"
REQUIREMENT_PATTERN = re.compile(
r"^(?P<name>[A-Za-z0-9_.\-\[\]]+)\s*(?P<specifier>(==|~=|>=|<=|!=|>|<).+)?$"
)
def iter_requirements(requirements_text: str) -> list[str]:
formatted_lines: list[str] = []
for raw_line in requirements_text.splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
match = REQUIREMENT_PATTERN.match(line)
if not match:
formatted_lines.append(f" {line}")
continue
name = match.group("name")
specifier = (match.group("specifier") or "").strip()
if specifier.startswith("=="):
formatted_lines.append(f" {name} ({specifier[2:]})")
elif specifier:
formatted_lines.append(f" {name} {specifier}")
else:
formatted_lines.append(f" {name}")
return formatted_lines
def render_dependency_block(requirements_text: str) -> str:
dependency_lines = iter_requirements(requirements_text)
return "\n".join(
[
START_MARKER,
" ```text",
*dependency_lines,
" ```",
END_MARKER,
]
)
def replace_managed_block(readme_text: str, rendered_block: str) -> str:
start_index = readme_text.find(START_MARKER)
end_index = readme_text.find(END_MARKER)
if start_index == -1 or end_index == -1 or end_index < start_index:
raise RuntimeError("README dependency markers are missing or invalid.")
end_index += len(END_MARKER)
return readme_text[:start_index] + rendered_block + readme_text[end_index:]
def main() -> None:
requirements_text = REQUIREMENTS_PATH.read_text(encoding="utf-8")
readme_text = README_PATH.read_text(encoding="utf-8")
rendered_block = render_dependency_block(requirements_text)
updated_readme = replace_managed_block(readme_text, rendered_block)
if updated_readme != readme_text:
README_PATH.write_text(updated_readme, encoding="utf-8")
if __name__ == "__main__":
main()
+15 -7
View File
@@ -1,9 +1,13 @@
from utils.logging.logs import consoleLog
import requests import requests
def scrape_buzzheavier(url): def scrape_buzzheavier(url):
try:
response = requests.get(url) response = requests.get(url, timeout=15)
response.raise_for_status() response.raise_for_status()
except requests.RequestException as e:
consoleLog(f"Buzzheavier: Failed to fetch page - {e}")
return None
download_url = url + '/download' download_url = url + '/download'
@@ -12,7 +16,11 @@ def scrape_buzzheavier(url):
'hx-request': 'true', 'hx-request': 'true',
'referer': url 'referer': url
} }
head_response = requests.head(download_url, headers=headers, allow_redirects=False) try:
hx_redirect = head_response.headers.get('hx-redirect') head_response = requests.head(download_url, headers=headers, allow_redirects=False, timeout=15)
return hx_redirect hx_redirect = head_response.headers.get('hx-redirect')
return hx_redirect
except requests.RequestException as e:
consoleLog(f"Buzzheavier: Failed to fetch download URL - {e}")
return None
+58 -33
View File
@@ -1,41 +1,66 @@
from utils.logging.logs import consoleLog from utils.logging.logs import consoleLog
import requests import requests
import hashlib
import time
import re import re
_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
_WT_SECRET = "5d4f7g8sd45fsd"
_API_BASE = "https://api.gofile.io"
def _generate_website_token(account_token):
time_bucket = str(int(time.time() / 14400))
raw = f"{_USER_AGENT}::en-US::{account_token}::{time_bucket}::{_WT_SECRET}"
return hashlib.sha256(raw.encode()).hexdigest()
def scrape_gofile(url): def scrape_gofile(url):
match = re.search(r"gofile\.io/d/([a-zA-Z0-9]+)", url)
filetoken = re.findall(r"(?<=https...gofile.io\/d\/).*", url)[0] if not match:
session = requests.Session() consoleLog("GoFile: Invalid URL")
headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0",
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Referer": "https://gofile.io/",
"Authorization": f"Bearer lUUtRAccOuRUoZhelNSslDFSlpOgKLDj",
"X-Website-Token": "ffd9ffa831c50b9e68ca5e65cbbbd1a50e9a32e63022e17c7c6748388a1ed73b",
"X-BL": "en-US",
"Origin": "https://gofile.io",
"Sec-GPC": "1",
"Connection": "keep-alive",
"TE": "trailers"
}
r = session.get(
f"https://api.gofile.io/contents/{filetoken}",
headers=headers,
)
try:
temp: dict = r.json()["data"]["children"]
child = [key for key in temp.keys()]
link = temp[child[0]]["link"]
return link, headers
except Exception:
consoleLog("GoFile Authorization failed, if the Issue persists, please open an Issue on GitHub")
return None return None
content_id = match.group(1)
session = requests.Session()
try:
# Create a guest account
r = session.post(f"{_API_BASE}/accounts", headers={"User-Agent": _USER_AGENT}, timeout=15)
data = r.json()
if data.get("status") != "ok":
consoleLog("GoFile: Failed to create guest account")
return None
account_token = data["data"]["token"]
website_token = _generate_website_token(account_token)
headers = {
"User-Agent": _USER_AGENT,
"Authorization": f"Bearer {account_token}",
"X-Website-Token": website_token,
"X-BL": "en-US",
"Referer": "https://gofile.io/",
"Origin": "https://gofile.io",
}
# Fetch folder contents
r = session.get(f"{_API_BASE}/contents/{content_id}", headers=headers, timeout=15)
data = r.json()
if data.get("status") != "ok":
consoleLog(f"GoFile: API error - {data.get('status')}")
return None
children = data["data"].get("children", {})
if not children:
consoleLog("GoFile: No files found")
return None
first_child = next(iter(children.values()))
link = first_child.get("link")
if not link:
consoleLog("GoFile: No download link in response")
return None
return link, headers
finally:
session.close()
+43
View File
@@ -0,0 +1,43 @@
from interface.dialogs.notificationpopup import NotificationPopup
from utils.config.settings import save_settings
from PySide6.QtCore import QObject, Signal, Qt
from PySide6.QtWidgets import QMessageBox
from utils.logging.logs import consoleLog
from utils.data.state import state
import webbrowser
import re
class UIBridge(QObject):
show_notification = Signal()
ui_bridge = UIBridge()
def show_megadb_notification():
notification_popup = NotificationPopup(title="Opened MegaDB link in browser",
text="Beware of malicious Ads. Make sure to have an adblocker installed.\n" '<br><a href="https://ublockorigin.com">Visit uBlock Origin</a>',
darkmode=False if state.window_transparency else True
)
notification_popup.setTextFormat(Qt.TextFormat.RichText)
notification_popup.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
if state.window_transparency is True:
notification_popup.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
button = notification_popup.addButton("Don't show again", QMessageBox.ButtonRole.AcceptRole)
button.clicked.connect(lambda: setattr(state, "show_megadb_notification", False))
save_settings(show_megadb_notification=state.show_megadb_notification)
notification_popup.exec()
def scrape_megadb(url):
match = re.search(r"megadb\.net/([a-zA-Z0-9]+)", url)
if not match:
consoleLog("MegaDB: Invalid URL")
return None
consoleLog("MegaDB: Captcha required, launching browser...")
webbrowser.open(url)
if state.show_megadb_notification is True:
ui_bridge.show_notification.emit()
return None
+14
View File
@@ -0,0 +1,14 @@
from utils.logging.logs import consoleLog
import webbrowser
import re
def scrape_vikingfile(url):
match = re.search(r"vikingfile\.com/f/([a-zA-Z0-9]+)", url)
if not match:
consoleLog("VikingFile: Invalid URL")
return None
consoleLog("VikingFile: Captcha required, launching browser...")
webbrowser.open(url)
return None
+11 -2
View File
@@ -2,6 +2,7 @@ from utils.network.jsonhandler import split_data, format_data
from utils.data.tracker import get_magnet_link from utils.data.tracker import get_magnet_link
from utils.logging.logs import consoleLog from utils.logging.logs import consoleLog
from utils.data.state import state from utils.data.state import state
from urllib.parse import quote
from typing import Dict from typing import Dict
import requests import requests
@@ -11,10 +12,18 @@ class RutrackerScraper:
is_magnet = True is_magnet = True
def search(self, query): def search(self, query):
search = requests.get(f"{state.api_url}/search?q={query}", timeout=15) try:
search = requests.get(f"{state.api_url}/search?q={quote(query)}", timeout=15)
except requests.RequestException as e:
consoleLog(f"Failed to reach server: {e}")
return []
consoleLog("Sent request to server") consoleLog("Sent request to server")
if search: if search:
_, data, _, success, cached = split_data(search.text) try:
_, data, _, success, cached = split_data(search.text)
except (KeyError, ValueError) as e:
consoleLog(f"Failed to parse server response: {e}")
return []
if cached: if cached:
consoleLog("Server response cached") consoleLog("Server response cached")
if success: if success:
+66 -29
View File
@@ -1,33 +1,38 @@
from data.hosts.buzzheavier import scrape_buzzheavier from data.hosts.buzzheavier import scrape_buzzheavier
from data.hosts.vikingfile import scrape_vikingfile
from data.hosts.gofile import scrape_gofile from data.hosts.gofile import scrape_gofile
from data.hosts.megadb import scrape_megadb
from utils.logging.logs import consoleLog from utils.logging.logs import consoleLog
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from typing import Dict from typing import Dict
import webbrowser import webbrowser
import requests import requests
import time import time
import re import copy
class SteamripScraper: class SteamripScraper:
name = "steamrip" name = "steamrip"
headers = ["Game"] headers = ["Game"]
is_magnet = False is_magnet = False
def __init__(self): def __init__(self):
self.cache = { "data": [], "last_fetched": 0 } self.cache = { "data": [], "last_fetched": 0 }
self.cache_expiry = 300 self.cache_expiry = 300
def scrape_steamrip_links(self): def scrape_steamrip_links(self):
current_time = time.time() current_time = time.time()
if self.cache["data"] != [] and (current_time - self.cache["last_fetched"] < self.cache_expiry): if self.cache["data"] and (current_time - self.cache["last_fetched"] < self.cache_expiry):
return self.cache["data"] return copy.deepcopy(self.cache["data"])
url = f"https://steamrip.com/games-list-page/" try:
response = requests.get(url) url = f"https://steamrip.com/games-list-page/"
text = response.text response = requests.get(url, timeout=15)
response.raise_for_status()
text = response.text
except requests.RequestException as e:
consoleLog(f"SteamRip: Failed to fetch games list - {e}")
return []
soup = BeautifulSoup(text, "html.parser") soup = BeautifulSoup(text, "html.parser")
games = soup.find_all("li", class_="az-list-item") games = soup.find_all("li", class_="az-list-item")
@@ -37,63 +42,95 @@ class SteamripScraper:
links = [] links = []
names = [] names = []
for gamehtml in games: for gamehtml in games:
link = gamehtml.find("a", href=lambda x: x and x.startswith("/")) link = gamehtml.find("a", href=lambda x: x and x.startswith("/"))
links += re.findall(r'(?<=href=")[^"]*', link.__str__()) if link:
name = gamehtml.find("a", href=lambda x: x and x.startswith("/")) links.append("https://steamrip.com" + link["href"])
names += re.findall(r'(?<=\/">)[^<]*', name.__str__()) names.append(link.get_text())
# construct the list[dict[str,str]]
ret = [] ret = []
for i in range(len(names)): for i in range(len(names)):
ret.append({"title" : names[i], "url" : links[i]}) ret.append({"title" : names[i], "url" : links[i]})
self.cache["data"] = ret
self.cache["last_fetched"] = current_time
return ret return ret
def scrape_steamrip_game_downloads(self, gamelink): def scrape_steamrip_game_downloads(self, url):
url = "https://steamrip.com" + gamelink try:
response = requests.get(url) response = requests.get(url, timeout=15)
response.raise_for_status()
except requests.RequestException as e:
consoleLog(f"SteamRip: Failed to fetch game page - {e}")
return []
soup = BeautifulSoup(response.text, "html.parser") soup = BeautifulSoup(response.text, "html.parser")
download_link_elements = soup.find_all("a",class_="shortc-button") download_link_elements = soup.find_all("a",class_="shortc-button")
download_links = ["buzzheavier", "gofile"] download_links = ["", "", "", ""]
for download_link in download_link_elements: for download_link in download_link_elements:
pure = download_link.attrs.get("href") pure = download_link.attrs.get("href")
if not pure or len(pure) < 3:
continue
if pure[2] == "b": if pure[2] == "b":
download_links[0] = "https:" + pure download_links[0] = "https:" + pure
if pure[2] == "g": if pure[2] == "g":
download_links[1] = "https:" + pure download_links[1] = "https:" + pure
if pure[2] == "v":
download_links[2] = "https:" + pure
if pure[2] == "m":
download_links[3] = "https:" + pure
ret = [] ret = []
for link in download_links: for link in download_links:
if len(link) != 1: if link != "":
ret.append(link) ret.append(link)
self.cache["data"] = ret
return ret return ret
def get_download_link(self, post: Dict): def get_download_link(self, post: Dict):
url = post["url"] url = post["url"]
links = self.scrape_steamrip_game_downloads(url) links = self.scrape_steamrip_game_downloads(url)
if not links:
consoleLog("SteamRip: No download links found")
return None, None
best = "" best = ""
for link in links: for link in links:
if link[0] == "h": if link[0] == "h":
best = link best = link
break break
if links.index(best) == 0: if not best:
link = scrape_buzzheavier(best) consoleLog("SteamRip: No valid download links found")
return link return None, None
elif links.index(best) == 1:
link, headers = scrape_gofile(best)
return link, headers try:
else: consoleLog(f"Found download link: {best}")
consoleLog("Unable to retrieve download link due to captcha, launching browser...") if "buzzheavier" or "bzzhr" in best:
webbrowser.open(best) link = scrape_buzzheavier(best)
return link
elif "gofile" in best:
link, headers = scrape_gofile(best)
return link, headers
elif "vikingfile" in best:
scrape_vikingfile(best)
return None, None
elif "megadb" in best:
scrape_megadb(best)
return None, None
else:
consoleLog("SteamRip: Unknown host, launching browser...")
webbrowser.open(best)
return None, None
except Exception as e:
consoleLog(f"SteamRip: Scraper failed - {e}")
return None, None return None, None
def search(self, query: str): def search(self, query: str):
+2 -2
View File
@@ -19,7 +19,7 @@ class UztrackerScraper:
posts = [] posts = []
try: try:
response = requests.get(search_url) response = requests.get(search_url, timeout=15)
response.raise_for_status() response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser') soup = BeautifulSoup(response.text, 'html.parser')
links = soup.find_all('tr', class_="tCenter hl-tr", id=lambda x: x and x.startswith('tor_')) links = soup.find_all('tr', class_="tCenter hl-tr", id=lambda x: x and x.startswith('tor_'))
@@ -43,7 +43,7 @@ class UztrackerScraper:
except requests.RequestException as e: except requests.RequestException as e:
consoleLog(f"Failed to fetch {search_url}: {e}") consoleLog(f"Failed to fetch {search_url}: {e}")
return None return []
def get_download_link(self, post: Dict): def get_download_link(self, post: Dict):
return get_magnet_link(post["url"]) return get_magnet_link(post["url"])
File diff suppressed because one or more lines are too long
+11
View File
@@ -0,0 +1,11 @@
import base64
import sys
if sys.argv[1] is None:
print("Usage: python encode_icon.py <path_to_image>")
sys.exit(1)
else:
with open(sys.argv[1], "rb") as image_file:
encoded_string = base64.b64encode(image_file.read())
print(encoded_string.decode("utf-8"))
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 56 KiB

+203 -79
View File
@@ -1,19 +1,38 @@
from PySide6.QtCore import Qt, QPoint, Signal, QThread
from network.direct_download.handle import DirectDownloadHandle
from utils.logging.logs import consoleLog, remove_download_log from utils.logging.logs import consoleLog, remove_download_log
from utils.network.download import download_selected
from utils.data.tracker import get_magnet_link
from utils.general.wrappers import run_thread
from PySide6.QtWidgets import QMessageBox from PySide6.QtWidgets import QMessageBox
from PySide6.QtCore import Qt, QPoint
from utils.data.state import state from utils.data.state import state
from send2trash import send2trash
from PySide6 import QtWidgets from PySide6 import QtWidgets
import threading
import webbrowser
import subprocess import subprocess
import platform import platform
import time
import os import os
class ContextMenu:
class MagnetWorker(QThread):
finished = Signal(str)
def __init__(self, url, parent=None):
super().__init__(parent)
self.url = url
def run(self):
magnet = get_magnet_link(self.url)
if magnet:
self.finished.emit(magnet)
class ContextMenu_Downloads:
def __init__(self, main_window): def __init__(self, main_window):
self.main_window = main_window self.main_window = main_window
self.context_menu = QtWidgets.QMenu(main_window) self.context_menu = QtWidgets.QMenu(main_window)
self.context_menu.addAction("Open Containing Folder", self.openFolderAction) self.context_menu.addAction("Open Containing Folder", self.openFolderAction)
self.context_menu.addAction("Copy Magnet URI", self.copyMagnetURIAction) self.context_menu.addAction("Copy Download Link", self.copyMagnetURIAction)
self.context_menu.addAction("Remove from list", self.cancelDownloadAction) self.context_menu.addAction("Remove from list", self.cancelDownloadAction)
self.context_menu.addAction("Delete File", self.deleteFileAction) self.context_menu.addAction("Delete File", self.deleteFileAction)
@@ -34,10 +53,12 @@ class ContextMenu:
if not hasattr(self, '_context_menu_row'): if not hasattr(self, '_context_menu_row'):
return return
row = self._context_menu_row row = self._context_menu_row
if row < 0 or row >= len(state.active_downloads): with state.downloads_lock:
return if row < 0 or row >= len(state.active_downloads):
magnet_link = list(state.active_downloads.keys())[row] return
magnetdl = state.active_downloads[magnet_link] keys = list(state.active_downloads.keys())
magnet_link = keys[row]
magnetdl = state.active_downloads[magnet_link]
download_path = magnetdl.save_path() download_path = magnetdl.save_path()
if download_path and os.path.exists(download_path): if download_path and os.path.exists(download_path):
if platform.system() == "Windows": if platform.system() == "Windows":
@@ -51,99 +72,202 @@ class ContextMenu:
if not hasattr(self, '_context_menu_row'): if not hasattr(self, '_context_menu_row'):
return return
row = self._context_menu_row row = self._context_menu_row
if row < 0 or row >= len(state.active_downloads): with state.downloads_lock:
return if row < 0 or row >= len(state.active_downloads):
magnet_link = list(state.active_downloads.keys())[row] return
keys = list(state.active_downloads.keys())
magnet_link = keys[row]
clipboard = QtWidgets.QApplication.clipboard() clipboard = QtWidgets.QApplication.clipboard()
clipboard.setText(magnet_link) clipboard.setText(magnet_link)
def cancelDownloadAction(self): def cancelDownloadAction(self):
if not hasattr(self, '_context_menu_row'): selected_rows = sorted(set(index.row() for index in self.downloadList.selectedIndexes()), reverse=True)
return
row = self._context_menu_row
with state.downloads_lock:
if row < 0 or row >= len(state.active_downloads):
return
magnet_link = list(state.active_downloads.keys())[row]
magnetdl = state.active_downloads[magnet_link]
# Cache the name BEFORE removing from session if not selected_rows:
try: return
torrent_name = magnetdl.status().name
except RuntimeError: names = []
torrent_name = "Unknown" items_to_remove = []
with state.downloads_lock:
keys = list(state.active_downloads.keys())
for row in selected_rows:
if 0 <= row < len(keys):
magnet_link = keys[row]
magnetdl = state.active_downloads[magnet_link]
try:
name = magnetdl.status().name
except RuntimeError:
name = "Unknown"
names.append(name)
items_to_remove.append((magnet_link, magnetdl, name))
confirm = QMessageBox.question( confirm = QMessageBox.question(
self.main_window, "Cancel Download", self.main_window,
f"Are you sure you want to cancel the download of '{torrent_name}'?", "Cancel Downloads",
f"Are you sure you want to cancel {len(names)} download(s)?\n\n" + "\n".join(names),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
) )
if confirm == QMessageBox.StandardButton.Yes:
if confirm != QMessageBox.StandardButton.Yes:
return
for magnet_link, magnetdl, name in items_to_remove:
with state.downloads_lock: with state.downloads_lock:
state.active_downloads.pop(magnet_link, None) state.active_downloads.pop(magnet_link, None)
remove_download_log(magnet_link) remove_download_log(magnet_link)
if state.dl_session: if isinstance(magnetdl, DirectDownloadHandle):
try:
magnetdl.stop()
except Exception as e:
consoleLog(f"Exception while stopping direct download: {e}")
elif state.dl_session:
try: try:
state.dl_session.remove_torrent(magnetdl) state.dl_session.remove_torrent(magnetdl)
except Exception as e: except Exception as e:
consoleLog(f"Exception while removing download from LibTorrent: {e}") consoleLog(f"Exception while removing download from LibTorrent: {e}")
consoleLog(f"Cancelled download: {torrent_name}", True) consoleLog(f"Cancelled download: {name}", True)
def deleteFileAction(self): def deleteFileAction(self):
selected_rows = sorted(set(index.row() for index in self.downloadList.selectedIndexes()), reverse=True)
if not selected_rows:
return
items_to_remove = []
with state.downloads_lock:
keys = list(state.active_downloads.keys())
for row in selected_rows:
if 0 <= row < len(keys):
magnet_link = keys[row]
magnetdl = state.active_downloads[magnet_link]
try:
status = magnetdl.status()
save_path = status.save_path
torrent_name = status.name
except RuntimeError:
consoleLog("Error: torrent handle already invalid", True)
continue
download_path = os.path.join(save_path, torrent_name)
items_to_remove.append((magnet_link, magnetdl, torrent_name, download_path))
if not items_to_remove:
return
names = [item[2] for item in items_to_remove]
confirm = QMessageBox.question(
self.main_window,
"Delete Files",
f"Are you sure you want to delete {len(names)} download(s)?\n\n" + "\n".join(names),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if confirm != QMessageBox.StandardButton.Yes:
return
for magnet_link, magnetdl, torrent_name, download_path in items_to_remove:
with state.downloads_lock:
state.active_downloads.pop(magnet_link, None)
remove_download_log(magnet_link)
if isinstance(magnetdl, DirectDownloadHandle):
try:
magnetdl.stop()
except Exception as e:
consoleLog(f"Exception while stopping direct download: {e}")
elif state.dl_session:
try:
state.dl_session.remove_torrent(magnetdl)
except Exception as e:
consoleLog(f"Exception while removing download from LibTorrent: {e}")
if download_path and os.path.exists(download_path):
try:
if os.path.isfile(download_path) or os.path.isdir(download_path):
send2trash(download_path)
consoleLog(f"Deleted files for: {torrent_name}", True)
except Exception as e:
consoleLog(f"Error deleting files: {e}", True)
else:
consoleLog(f"Removed entry (files not found): {torrent_name}", True)
class ContextMenu_TrackerTable:
def __init__(self, main_window):
self.main_window = main_window
self.context_menu = QtWidgets.QMenu(main_window)
self.context_menu.addAction("Copy Download Link", self.copyMagnetURIAction)
self.context_menu.addAction("Open in Browser", self.openInBrowserAction)
self.context_menu.addAction("Download", self.downloadItemAction)
state.trackertable.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
state.trackertable.customContextMenuRequested.connect(self._show_context_menu)
@property
def TrackerTable(self):
return state.trackertable
def _show_context_menu(self, pos: QPoint):
index = self.TrackerTable.indexAt(pos)
if index.isValid():
self._context_menu_row = index.row()
self.context_menu.exec(self.TrackerTable.viewport().mapToGlobal(pos))
def copyMagnetURIAction(self):
if not hasattr(self, '_context_menu_row'): if not hasattr(self, '_context_menu_row'):
return return
row = self._context_menu_row row = self._context_menu_row
with state.downloads_lock:
if row < 0 or row >= len(state.active_downloads): if not state.posts or row < 0 or row >= len(state.posts):
return
magnet_link = list(state.active_downloads.keys())[row]
magnetdl = state.active_downloads[magnet_link]
try:
status = magnetdl.status()
save_path = status.save_path
torrent_name = status.name
except RuntimeError:
consoleLog("Error: torrent handle already invalid", True)
with state.downloads_lock:
state.active_downloads.pop(magnet_link, None)
remove_download_log(magnet_link)
return return
download_path = os.path.join(save_path, torrent_name)
confirm = QMessageBox.question( post = state.posts[row]
self.main_window, "Delete Files", url = post.get("url")
f"Are you sure you want to delete the downloaded files of '{torrent_name}'? This action cannot be undone.", if not url:
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No return
)
if confirm == QMessageBox.StandardButton.Yes: worker = MagnetWorker(url, parent=self.main_window)
with state.downloads_lock: worker.finished.connect(self._on_magnet_fetched)
state.active_downloads.pop(magnet_link, None) worker.finished.connect(worker.deleteLater)
remove_download_log(magnet_link) worker.start()
if state.dl_session:
try: def _on_magnet_fetched(self, magnet):
state.dl_session.remove_torrent(magnetdl) clipboard = QtWidgets.QApplication.clipboard()
time.sleep(0.5) clipboard.setText(magnet)
except Exception as e: consoleLog("Magnet URI copied to clipboard!", True)
consoleLog(f"Exception while removing download from LibTorrent: {e}")
if download_path and os.path.exists(download_path): def openInBrowserAction(self):
import shutil if not hasattr(self, '_context_menu_row'):
last_error = None return
for attempt in range(3): row = self._context_menu_row
try:
if os.path.isfile(download_path): if not state.posts or row < 0 or row >= len(state.posts):
os.remove(download_path) return
else:
shutil.rmtree(download_path) post = state.posts[row]
consoleLog(f"Deleted files for: {torrent_name}", True) url = post.get("url")
last_error = None if url:
break try: webbrowser.open(url)
except Exception as e: except Exception as e: consoleLog(f"Failed to open URL: {e}", True)
last_error = e
if attempt < 2: def downloadItemAction(self):
time.sleep(0.5) if not hasattr(self, '_context_menu_row'):
if last_error: return
consoleLog(f"Error deleting files: {last_error}", True) row = self._context_menu_row
else: if not state.posts or row < 0 or row >= len(state.posts):
consoleLog(f"Removed entry (files not found): {torrent_name}", True) return
run_thread(threading.Thread(target=download_selected, args=(state.trackertable.selectedItems(),)))
+4 -2
View File
@@ -34,7 +34,8 @@ class DownloadModel(QAbstractTableModel):
return None return None
try: try:
magnet_link = list(state.active_downloads.keys())[index.row()] keys = list(state.active_downloads.keys())
magnet_link = keys[index.row()]
magnetdl = state.active_downloads[magnet_link] magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status() status = magnetdl.status()
except (IndexError, KeyError, RuntimeError): except (IndexError, KeyError, RuntimeError):
@@ -101,7 +102,8 @@ class DownloadModel(QAbstractTableModel):
if row >= len(state.active_downloads) or row < 0: if row >= len(state.active_downloads) or row < 0:
return return
try: try:
magnet_link = list(state.active_downloads.keys())[row] keys = list(state.active_downloads.keys())
magnet_link = keys[row]
magnetdl = state.active_downloads[magnet_link] magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status() status = magnetdl.status()
except (IndexError, KeyError, RuntimeError): except (IndexError, KeyError, RuntimeError):
+68 -4
View File
@@ -1,5 +1,5 @@
from PySide6.QtWidgets import QLabel, QGraphicsOpacityEffect, QStackedWidget from PySide6.QtWidgets import QLabel, QGraphicsOpacityEffect, QStackedWidget
from PySide6.QtCore import Qt, QSize, QEvent, QObject from PySide6.QtCore import Qt, QSize, QEvent, QObject, QTimer
from PySide6.QtGui import QImage, QPixmap from PySide6.QtGui import QImage, QPixmap
from utils.data.state import state from utils.data.state import state
import darkdetect import darkdetect
@@ -17,8 +17,15 @@ class Image(QObject):
self.overlay_label.setGraphicsEffect(self.opacity_effect) self.overlay_label.setGraphicsEffect(self.opacity_effect)
self._current_image_path = None self._current_image_path = None
self._cached_qimage = None
self._wallpaper_active = False self._wallpaper_active = False
self._original_stylesheets = {} self._original_stylesheets = {}
self._resize_timer = QTimer(self)
self._resize_timer.setSingleShot(True)
self._resize_timer.setInterval(100)
self._resize_timer.timeout.connect(self._on_resize_finished)
parent.installEventFilter(self) parent.installEventFilter(self)
state.image_changed.connect(self.update_image_overlay) state.image_changed.connect(self.update_image_overlay)
@@ -28,9 +35,14 @@ class Image(QObject):
def eventFilter(self, obj, event): def eventFilter(self, obj, event):
if obj == self.application and event.type() == QEvent.Type.Resize: if obj == self.application and event.type() == QEvent.Type.Resize:
if self._current_image_path: if self._current_image_path:
self._load_and_display(self._current_image_path) self._apply_layout_fast()
self._resize_timer.start()
return False return False
def _on_resize_finished(self):
if self._current_image_path:
self._apply_layout()
def _set_wallpaper_transparency(self, enabled): def _set_wallpaper_transparency(self, enabled):
if self._wallpaper_active == enabled: if self._wallpaper_active == enabled:
return return
@@ -117,12 +129,62 @@ class Image(QObject):
self._set_wallpaper_transparency(False) self._set_wallpaper_transparency(False)
return return
self._current_image_path = image_path self._current_image_path = image_path
# Only reload from disk when the path actually changed
if self._cached_qimage is None or image_path != getattr(self, '_cached_path', None):
image = QImage(image_path)
if image.isNull():
self.overlay_label.hide()
self._cached_qimage = None
return
self._cached_qimage = image
self._cached_path = image_path
self._apply_layout()
def _apply_layout_fast(self):
if self._cached_qimage is None or not state.image_enabled:
return
parent = self.application parent = self.application
image = QImage(image_path) image = self._cached_qimage
if image.isNull():
if getattr(state, "image_as_wallpaper", False):
scaled = image.scaled(
parent.size(),
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
Qt.TransformationMode.FastTransformation
)
cx = (scaled.width() - parent.width()) // 2
cy = (scaled.height() - parent.height()) // 2
cropped = scaled.copy(cx, cy, parent.width(), parent.height())
self.overlay_label.setPixmap(QPixmap.fromImage(cropped))
self.overlay_label.setGeometry(0, 0, parent.width(), parent.height())
else:
w = self.overlay_label.width()
h = self.overlay_label.height()
pos = state.image_position
off = int(state.image_offset)
if pos == "top-left":
x, y = off, off
elif pos == "top-right":
x, y = parent.width() - w - off, off
elif pos == "bottom-left":
x, y = off, parent.height() - h - off
elif pos == "center":
x, y = (parent.width() - w) // 2, (parent.height() - h) // 2
else:
x, y = parent.width() - w - off, parent.height() - h - off
self.overlay_label.move(x, y)
def _apply_layout(self):
if self._cached_qimage is None or not state.image_enabled:
self.overlay_label.hide() self.overlay_label.hide()
return return
parent = self.application
image = self._cached_qimage
if getattr(state, "image_as_wallpaper", False): if getattr(state, "image_as_wallpaper", False):
scaled_image = image.scaled( scaled_image = image.scaled(
parent.size(), parent.size(),
@@ -184,4 +246,6 @@ class Image(QObject):
self.overlay_label.show() self.overlay_label.show()
def update_image_overlay(self, new_image_path): def update_image_overlay(self, new_image_path):
if new_image_path != getattr(self, '_cached_path', None):
self._cached_qimage = None
self._load_and_display(new_image_path) self._load_and_display(new_image_path)
+8 -3
View File
@@ -1,11 +1,16 @@
from PySide6.QtWidgets import QMessageBox from PySide6.QtWidgets import QMessageBox
from utils.data.state import state
import qdarktheme import qdarktheme
class NotificationPopup(QMessageBox): class NotificationPopup(QMessageBox):
def __init__(self, title, text, infotext=None, parent=None): def __init__(self, title, text, infotext=None, parent=None, darkmode=True):
super().__init__(parent) super().__init__(parent)
qdarktheme.setup_theme("auto") if darkmode is True:
custom_colors = {}
if state.accent_color:
custom_colors["primary"] = state.accent_color
qdarktheme.setup_theme("auto", custom_colors=custom_colors if custom_colors else None)
self.setIcon(QMessageBox.Icon.Information) self.setIcon(QMessageBox.Icon.Information)
self.setWindowTitle(title) self.setWindowTitle(title)
@@ -14,4 +19,4 @@ class NotificationPopup(QMessageBox):
if infotext is not None: if infotext is not None:
self.setInformativeText(infotext) self.setInformativeText(infotext)
self.setStandardButtons(QMessageBox.StandardButton.Ok) self.setStandardButtons(QMessageBox.StandardButton.Ok)
+50 -7
View File
@@ -1,9 +1,11 @@
from interface.dialogs.theme import _accent_selection_color
from interface.utils.tabhelper import create_tab from interface.utils.tabhelper import create_tab
from utils.config.settings import save_settings from utils.config.settings import save_settings
from interface.utils.svghelper import svg_icon from interface.utils.svghelper import svg_icon
from utils.logging.logs import consoleLog from utils.logging.logs import consoleLog
from PySide6.QtCore import Qt, QSize from PySide6.QtCore import Qt, QSize
from utils.data.state import state from utils.data.state import state
from PySide6.QtGui import QColor
from PySide6 import QtWidgets from PySide6 import QtWidgets
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QLineEdit, QLineEdit,
@@ -13,9 +15,10 @@ from PySide6.QtWidgets import (
QDialog, QDialog,
QLabel, QLabel,
QHBoxLayout, QHBoxLayout,
QSpinBox, QSlider, QCheckBox, QSpinBox, QSlider, QCheckBox,
QFileDialog, QFileDialog,
QComboBox, QComboBox,
QColorDialog
) )
import platform import platform
@@ -76,9 +79,8 @@ def settings_dialog(self):
# Ignore Updates checkbox # Ignore Updates checkbox
update_checkbox_container, update_checkbox = create_widget(QCheckBox, "Ignore Updates: ") update_checkbox_container, update_checkbox = create_widget(QCheckBox, "Ignore Updates: ")
if platform.system() == "Windows": update_checkbox.setChecked(state.ignore_updates)
update_checkbox.setChecked(state.ignore_updates) update_checkbox.toggled.connect(lambda checked: setattr(state, 'ignore_updates', checked))
update_checkbox.toggled.connect(lambda checked: setattr(state, 'ignore_updates', checked))
# Autoresume Container Checkbox # Autoresume Container Checkbox
autoresume_container, autoresume_checkbox = create_widget(QCheckBox, "Auto-Resume Downloads: ") autoresume_container, autoresume_checkbox = create_widget(QCheckBox, "Auto-Resume Downloads: ")
@@ -91,9 +93,31 @@ def settings_dialog(self):
transparent_window_checkbox.toggled.connect(lambda checked: setattr(state, 'window_transparency', checked)) transparent_window_checkbox.toggled.connect(lambda checked: setattr(state, 'window_transparency', checked))
# Accent Color # Accent Color
accent_color_container, accent_color_input = create_widget(QLineEdit, "Accent Color (requires restart): ", width=180, height=30) accent_color_container, accent_color_input = create_widget(QLineEdit, "Accent Color: ", width=180, height=30)
accent_color_button = QPushButton("")
accent_color_button.setFixedSize(21, 21)
accent_color_button.setCursor(Qt.CursorShape.PointingHandCursor)
def update_accent_button_color(color_str):
c = color_str.strip()
accent_color_button.setStyleSheet(
f"QPushButton {{ background-color: {c if c else 'transparent'}; border: 1px solid palette(mid); border-radius: 3px; }}"
)
def browse_accent_color():
current = accent_color_input.text().strip()
initial = QColor(current) if current else QColor()
color = QColorDialog.getColor(initial, dialog, "Select Accent Color")
if color.isValid():
accent_color_input.setText(color.name())
accent_color_input.textChanged.connect(update_accent_button_color)
accent_color_button.clicked.connect(browse_accent_color)
accent_color_input.setPlaceholderText("e.g. #fca7d7") accent_color_input.setPlaceholderText("e.g. #fca7d7")
accent_color_input.setText(state.accent_color) accent_color_input.setText(state.accent_color)
update_accent_button_color(state.accent_color)
accent_color_container.layout().insertWidget(1, accent_color_button)
# API URL Widget # API URL Widget
api_url_container, api_url = create_widget(QLineEdit, "API Server URL: ", width=180, height=30) api_url_container, api_url = create_widget(QLineEdit, "API Server URL: ", width=180, height=30)
@@ -272,6 +296,16 @@ def settings_dialog(self):
interface_layout.addWidget(interface_select) interface_layout.addWidget(interface_select)
interface_container.setLayout(interface_layout) interface_container.setLayout(interface_layout)
# Close To Tray
close_to_tray_container, close_to_tray_checkbox = create_widget(QCheckBox, "Close to Tray: ")
close_to_tray_checkbox.setChecked(state.close_to_tray)
close_to_tray_checkbox.toggled.connect(lambda checked: setattr(state, 'close_to_tray', checked))
# Show MegaDB Notification
show_megadb_notification_container, show_megadb_notification_checkbox = create_widget(QCheckBox, "Show MegaDB Notifications: ")
show_megadb_notification_checkbox.setChecked(state.show_megadb_notification)
show_megadb_notification_checkbox.toggled.connect(lambda checked: setattr(state, 'show_megadb_notification', checked))
# Save / Cancel buttons # Save / Cancel buttons
layout = QHBoxLayout() layout = QHBoxLayout()
@@ -297,15 +331,24 @@ def settings_dialog(self):
image_opacity.value(), image_opacity.value(),
image_as_wallpaper=image_mode_checkbox.isChecked(), image_as_wallpaper=image_mode_checkbox.isChecked(),
image_position=image_position_combo.currentText(), image_position=image_position_combo.currentText(),
accent_color=accent_color_input.text().strip() accent_color=accent_color_input.text().strip(),
close_to_tray=close_to_tray_checkbox.isChecked(),
show_megadb_notification=show_megadb_notification_checkbox.isChecked()
) )
color = _accent_selection_color()
if color:
self.tracker_list.setStyleSheet(
f"QComboBox QAbstractItemView::item:selected {{ background: {color}; }}"
)
else:
self.tracker_list.setStyleSheet("")
cancel_btn.clicked.connect(dialog.reject) cancel_btn.clicked.connect(dialog.reject)
layout.addWidget(cancel_btn) layout.addWidget(cancel_btn)
layout.addWidget(save_btn) layout.addWidget(save_btn)
tabs = QtWidgets.QTabWidget() tabs = QtWidgets.QTabWidget()
create_tab("General", [autoresume_container, update_checkbox_container, transparent_window_container, accent_color_container], tabs=tabs, stretch=True) create_tab("General", [autoresume_container, update_checkbox_container, transparent_window_container, accent_color_container, close_to_tray_container, show_megadb_notification_container], tabs=tabs, stretch=True)
create_tab("Image", [enable_image_container, image_mode_container, image_position_container, image_width_container, image_offset_container, image_opacity_container], tabs=tabs, stretch=True) create_tab("Image", [enable_image_container, image_mode_container, image_position_container, image_width_container, image_offset_container, image_opacity_container], tabs=tabs, stretch=True)
create_tab("Paths", [download_path_container, image_path_container], tabs=tabs, stretch=True) create_tab("Paths", [download_path_container, image_path_container], tabs=tabs, stretch=True)
create_tab("Network", [interface_container, max_connections_container, max_downloads_container, up_speed_limit_container, down_speed_limit_container, api_url_container], tabs=tabs, stretch=True) create_tab("Network", [interface_container, max_connections_container, max_downloads_container, up_speed_limit_container, down_speed_limit_container, api_url_container], tabs=tabs, stretch=True)
+47 -13
View File
@@ -1,13 +1,17 @@
from PySide6.QtWidgets import QDialog, QMessageBox, QCheckBox
from utils.network.update_checker import get_updates from utils.network.update_checker import get_updates
from PySide6.QtWidgets import QDialog, QMessageBox
from utils.network.updater import download_update from utils.network.updater import download_update
from utils.config.config import create_config
from utils.logging.logs import consoleLog from utils.logging.logs import consoleLog
from utils.data.state import state from utils.data.state import state
from pathlib import Path from pathlib import Path
import webbrowser
import platform import platform
import json import json
import os import os
RELEASES_URL = "https://github.com/KeksPirates/SoftwareManager/releases/latest"
def _get_build_info_path() -> (Path | None): def _get_build_info_path() -> (Path | None):
current_dir = Path(__file__).resolve().parent current_dir = Path(__file__).resolve().parent
@@ -31,19 +35,49 @@ def get_version() -> None:
else: else:
consoleLog(f"Could not find build info file (Path: {build_info_path})") consoleLog(f"Could not find build info file (Path: {build_info_path})")
def _show_notify_dialog(latest: str) -> None:
msg = QMessageBox()
msg.setIcon(QMessageBox.Icon.Information)
msg.setWindowTitle("Update Available")
msg.setText(f"A new version is available: <b>{latest}</b>")
msg.setInformativeText("Visit the releases page to download it.")
dont_show = QCheckBox("Don't show again")
msg.setCheckBox(dont_show)
open_btn = msg.addButton("Open Releases Page", QMessageBox.ButtonRole.AcceptRole)
msg.addButton("Dismiss", QMessageBox.ButtonRole.RejectRole)
msg.exec()
if dont_show.isChecked():
state.ignore_updates = True
create_config()
if msg.clickedButton() == open_btn:
webbrowser.open(RELEASES_URL)
class UpdateDialog(QDialog): class UpdateDialog(QDialog):
def __init__(self, parent=None): def __init__(self, parent=None):
if state.ignore_updates is False and platform.system() == "Windows": if state.ignore_updates:
return
assets, latest = get_updates() assets, latest = get_updates()
if assets != None: if assets is None:
msg = QMessageBox() return
msg.setIcon(QMessageBox.Icon.Information)
msg.setWindowTitle("Update Available") if platform.system() == "Windows":
msg.setText(f"A new version is available\n({latest})") msg = QMessageBox()
msg.setInformativeText("Press Ok to download.") msg.setIcon(QMessageBox.Icon.Information)
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Ignore) msg.setWindowTitle("Update Available")
response = msg.exec_() msg.setText(f"A new version is available\n({latest})")
if response == QMessageBox.StandardButton.Ok: msg.setInformativeText("Press Ok to download.")
download_update(assets) msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Ignore)
response = msg.exec_()
if response == QMessageBox.StandardButton.Ok:
download_update(assets)
else:
_show_notify_dialog(latest)
+36 -12
View File
@@ -17,6 +17,7 @@ from interface.utils.searchhelper import run_search
from interface.utils.tabhelper import create_tab from interface.utils.tabhelper import create_tab
from utils.general.shutdown import closehelper from utils.general.shutdown import closehelper
from utils.general.wrappers import run_thread from utils.general.wrappers import run_thread
from utils.logging.logs import log_emitter
from interface.dialogs.image import Image from interface.dialogs.image import Image
from utils.data.state import state from utils.data.state import state
@@ -42,7 +43,8 @@ from PySide6.QtWidgets import (
QHeaderView, QHeaderView,
QTableWidgetItem, QTableWidgetItem,
QTextEdit, QTextEdit,
QStatusBar QStatusBar,
QStyledItemDelegate,
) )
from PySide6.QtGui import ( from PySide6.QtGui import (
@@ -60,16 +62,19 @@ import base64
def windowCloseHelper(): def windowCloseHelper():
QGuiApplication.quit() QGuiApplication.quit()
class CenteredDelegate(QStyledItemDelegate):
def initStyleOption(self, option, index):
super().initStyleOption(option, index)
option.displayAlignment = Qt.AlignmentFlag.AlignVCenter
class MainWindow(QtWidgets.QMainWindow, QWidget): class MainWindow(QtWidgets.QMainWindow, QWidget):
eventFilter = eventFilter eventFilter = eventFilter
log_signal = Signal(str)
search_results_signal = Signal(list) search_results_signal = Signal(list)
_instance = None _instance = None
def __init__(self): def __init__(self):
super().__init__() super().__init__()
MainWindow._instance = self MainWindow._instance = self
self.log_signal.connect(self._on_log_signal)
self.search_results_signal.connect(self._on_search_results) self.search_results_signal.connect(self._on_search_results)
pixmap = QPixmap() pixmap = QPixmap()
@@ -126,12 +131,14 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
self.consoleLog = QTextEdit() self.consoleLog = QTextEdit()
self.statusbar = QStatusBar() self.statusbar = QStatusBar()
log_emitter.new_log.connect(self._on_log_signal)
flush_log_buffer() flush_log_buffer()
self._tracker_elided_delegate = ElidedItemDelegate(lambda: self._tracker_hovered_row, self) self._tracker_elided_delegate = ElidedItemDelegate(lambda: self._tracker_hovered_row, self)
self._tracker_hover_delegate = TrackerHoverDelegate(lambda: self._tracker_hovered_row, self) self._tracker_hover_delegate = TrackerHoverDelegate(lambda: self._tracker_hovered_row, self)
state.trackertable = _create_tracker_table(self) state.trackertable = _create_tracker_table(self)
state.trackertable.cellDoubleClicked.connect(lambda: run_thread(threading.Thread(target=download_selected, args=(state.trackertable.selectedItems(),))))
container = QWidget() container = QWidget()
containerLayout = QVBoxLayout() containerLayout = QVBoxLayout()
@@ -175,6 +182,8 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
# Tracker list # Tracker list
self.tracker_list = QComboBox() self.tracker_list = QComboBox()
self.tracker_list.setContentsMargins(0, 0, 0, 0)
self.tracker_list.setItemDelegate(CenteredDelegate(self.tracker_list))
self.tracker_list.addItems(list(state.trackers.keys())) self.tracker_list.addItems(list(state.trackers.keys()))
self.tracker_list.setCursor(Qt.CursorShape.PointingHandCursor) self.tracker_list.setCursor(Qt.CursorShape.PointingHandCursor)
self.tracker_list.activated.connect(self.set_tracker) self.tracker_list.activated.connect(self.set_tracker)
@@ -251,7 +260,8 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
self.active_timer.timeout.connect(self.show_empty_downloads) self.active_timer.timeout.connect(self.show_empty_downloads)
self.active_timer.start(500) self.active_timer.start(500)
self._context_menu = interface.dialogs.contextmenu.ContextMenu(self) self._context_menu_downloads = interface.dialogs.contextmenu.ContextMenu_Downloads(self)
self._context_menu_trackertable = interface.dialogs.contextmenu.ContextMenu_TrackerTable(self)
self.image_overlay = Image(self) self.image_overlay = Image(self)
def _apply_default_headers(self, table): def _apply_default_headers(self, table):
@@ -297,6 +307,10 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
table.setItemDelegateForColumn(i, self._tracker_hover_delegate) table.setItemDelegateForColumn(i, self._tracker_hover_delegate)
def _start_search(self): def _start_search(self):
self.searchbar.setEnabled(False)
state.trackertable.setRowCount(0)
self.show_empty_results(False)
def _search_thread(): def _search_thread():
try: try:
run_search(self) run_search(self)
@@ -305,16 +319,18 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
self.search_results_signal.emit([]) self.search_results_signal.emit([])
run_thread(threading.Thread(target=_search_thread)) run_thread(threading.Thread(target=_search_thread))
@staticmethod
def add_log(text):
if hasattr(MainWindow, "_instance") and MainWindow._instance:
MainWindow._instance.log_signal.emit(text)
return True
return False
def _on_log_signal(self, text): def _on_log_signal(self, text):
if hasattr(self, 'consoleLog'): if hasattr(self, 'consoleLog'):
self.consoleLog.append(text) self.consoleLog.append(text)
# Delete lines if exceeding 500
doc = self.consoleLog.document()
if doc.blockCount() > 500:
cursor = self.consoleLog.textCursor()
cursor.movePosition(cursor.MoveOperation.Start)
cursor.movePosition(cursor.MoveOperation.Down, cursor.MoveMode.KeepAnchor, doc.blockCount() - 500)
cursor.removeSelectedText()
cursor.deleteChar()
self.consoleLog.verticalScrollBar().setValue( self.consoleLog.verticalScrollBar().setValue(
self.consoleLog.verticalScrollBar().maximum() self.consoleLog.verticalScrollBar().maximum()
) )
@@ -343,7 +359,7 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
table.setRowCount(len(state.posts)) table.setRowCount(len(state.posts))
for x, rowdata in enumerate(state.posts): for x, rowdata in enumerate(state.posts):
for y, (key, data) in enumerate(rowdata.items()): for y, (_, data) in enumerate(rowdata.items()):
item = QTableWidgetItem(str(data)) item = QTableWidgetItem(str(data))
item.setData(Qt.ItemDataRole.UserRole, x) item.setData(Qt.ItemDataRole.UserRole, x)
table.setItem(x, y, item) table.setItem(x, y, item)
@@ -386,6 +402,14 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
super().changeEvent(event) super().changeEvent(event)
def closeEvent(self, event: QCloseEvent): def closeEvent(self, event: QCloseEvent):
if state.close_to_tray is True:
event.ignore()
self.hide()
return
else:
self.shutdown(event)
def shutdown(self, event: QCloseEvent):
closehelper() closehelper()
event.accept() event.accept()
from utils.general.shutdown import force_exit from utils.general.shutdown import force_exit
+2 -6
View File
@@ -6,9 +6,6 @@ for scraper in SCRAPERS:
state.trackers[scraper.name] = scraper state.trackers[scraper.name] = scraper
def run_search(self) -> None: def run_search(self) -> None:
state.trackertable.setRowCount(0)
self.show_empty_results(False)
search_text = self.searchbar.text() search_text = self.searchbar.text()
# Check for empty search # Check for empty search
if search_text == "": if search_text == "":
@@ -16,12 +13,11 @@ def run_search(self) -> None:
return return
state.posts = [] state.posts = []
self.searchbar.setEnabled(False)
consoleLog(f"User searched for: {search_text}") consoleLog(f"User searched for: {search_text}")
# Get current tracker and call its search function # Get current tracker and call its search function
scraper = state.trackers[state.currenttracker] scraper = state.trackers[state.currenttracker]
state.posts = scraper.search(search_text) state.posts = scraper.search(search_text)
# Update GUI headers to match the current scraper # Update GUI headers to match the current scraper
self.search_results_signal.emit(scraper.headers) self.search_results_signal.emit(scraper.headers)
+92 -8
View File
@@ -1,40 +1,121 @@
from network.libtorrent_misc import send_notification, update_log, check_deleted_files
from utils.logging.loghandler import split_data, check_completed, check_downloads from utils.logging.loghandler import split_data, check_completed, check_downloads
from network.interface import list_interfaces, init_interfaces from network.interface import list_interfaces, init_interfaces
from PySide6.QtNetwork import QLocalServer, QLocalSocket
from utils.logging.logs import get_download_logs from utils.logging.logs import get_download_logs
from utils.logging.logs import set_main_window from utils.logging.logs import set_main_window
from network.libtorrent_int import check_space from network.libtorrent_int import check_space
from utils.general.shutdown import closehelper from utils.general.shutdown import closehelper
from utils.general.wrappers import run_thread from utils.general.wrappers import run_thread
from interface.assets.base64_icons import logo_base64
from PySide6.QtWidgets import QSystemTrayIcon, QMenu
from network.libtorrent_misc import AppDaemons
from utils.general.shutdown import force_exit from utils.general.shutdown import force_exit
from utils.config.config import read_config from utils.config.config import read_config
from PySide6.QtGui import QAction, QPixmap
from utils.logging.logs import consoleLog from utils.logging.logs import consoleLog
from interface.gui import MainWindow from interface.gui import MainWindow
from PySide6.QtCore import Qt, QObject, QThread
from utils.data.state import state from utils.data.state import state
from PySide6 import QtWidgets from PySide6 import QtWidgets
from PySide6.QtCore import Qt
import qdarktheme import qdarktheme
import threading import threading
import platform import platform
import base64
import signal import signal
import time import time
import sys import sys
SERVER_NAME = "SoftwareManager_KeksPirates"
class SingleInstance(QObject):
def __init__(self):
super().__init__()
self.server = QLocalServer()
socket = QLocalSocket()
socket.connectToServer(SERVER_NAME)
if socket.waitForConnected(100):
socket.write(b"raise")
socket.flush()
socket.waitForBytesWritten(100)
self.is_running = True
else:
QLocalServer.removeServer(SERVER_NAME)
self.server.listen(SERVER_NAME)
self.server.newConnection.connect(self.handle_connection)
self.is_running = False
def handle_connection(self):
socket = self.server.nextPendingConnection()
socket.readyRead.connect(lambda: self.read_socket(socket))
def read_socket(self, socket):
msg = socket.readAll().data()
if msg == b"raise":
self.on_raise()
def on_raise(self):
pass
def check_running(app):
single = SingleInstance()
if single.is_running:
print("Another instance is already running. Exiting this instance.")
closehelper()
force_exit()
app._single_instance = single
def run_gui(app): def run_gui(app):
single = app._single_instance
custom_colors = {} custom_colors = {}
if state.accent_color: if state.accent_color:
custom_colors["primary"] = state.accent_color custom_colors["primary"] = state.accent_color
qdarktheme.setup_theme("auto", custom_colors=custom_colors if custom_colors else None) qdarktheme.setup_theme("auto", custom_colors=custom_colors if custom_colors else None)
widget = MainWindow() widget = MainWindow()
# Check OS for window transparency compatibility and apply # Check OS for window transparency compatibility and apply
if state.window_transparency and platform.system() != "Windows": if state.window_transparency and platform.system() != "Windows":
widget.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) widget.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
transparent_colors = {"background": "#00000000", **custom_colors} transparent_colors = {"background": "#00000000", **custom_colors}
qdarktheme.setup_theme("auto", custom_colors=transparent_colors) qdarktheme.setup_theme("auto", custom_colors=transparent_colors)
pixmap = QPixmap()
image_data = base64.b64decode(logo_base64)
pixmap.loadFromData(image_data)
tray = QSystemTrayIcon()
tray.setIcon(pixmap)
tray.setVisible(True)
menu = QMenu()
show = QAction("Show")
show.triggered.connect(lambda: widget.show())
menu.addAction(show)
quit = QAction("Quit")
quit.triggered.connect(lambda: (closehelper(), force_exit()))
menu.addAction(quit)
tray.activated.connect(lambda reason: widget.show() if reason == QSystemTrayIcon.ActivationReason.Trigger else None)
tray.setContextMenu(menu)
def show_from_tray():
widget.show()
widget.raise_()
widget.activateWindow()
single.on_raise = show_from_tray
from data.hosts.megadb import ui_bridge, show_megadb_notification
ui_bridge.show_notification.connect(show_megadb_notification)
set_main_window(widget) set_main_window(widget)
widget.show() widget.show()
signal.signal(signal.SIGINT, signal.SIG_DFL)
sys.exit(app.exec()) sys.exit(app.exec())
def keyboardinterrupthandler(signum, frame): def keyboardinterrupthandler(signum, frame):
@@ -45,10 +126,10 @@ def keyboardinterrupthandler(signum, frame):
def main(): def main():
# Begin counting startup time # Begin counting startup time
start_time = time.perf_counter() start_time = time.perf_counter()
# Initialize UI Engine # Initialize UI Engine
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv) app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv)
# Check if SoftwareManager is already running
check_running(app)
# Parse saved files # Parse saved files
read_config() read_config()
logs = get_download_logs() logs = get_download_logs()
@@ -61,9 +142,12 @@ def main():
init_interfaces() init_interfaces()
consoleLog(f"Current Bound: {state.bound_interface}") consoleLog(f"Current Bound: {state.bound_interface}")
# Start background daemon threads # Start background daemon threads
run_thread(threading.Thread(target=send_notification, args=(state.shutdown_event,), daemon=True)) app._daemon_thread = QThread()
run_thread(threading.Thread(target=update_log, args=(state.shutdown_event,), daemon=True)) app._daemons = AppDaemons()
run_thread(threading.Thread(target=check_deleted_files, args=(state.shutdown_event,), daemon=True)) app._daemons.moveToThread(app._daemon_thread)
app._daemon_thread.started.connect(app._daemons.start_all)
app.aboutToQuit.connect(app._daemon_thread.quit)
app._daemon_thread.start()
# Start background non-daemon threads # Start background non-daemon threads
run_thread(threading.Thread(target=check_completed, args=(downloads, state.autoresume))) run_thread(threading.Thread(target=check_completed, args=(downloads, state.autoresume)))
run_thread(threading.Thread(target=check_downloads, args=(downloads,))) run_thread(threading.Thread(target=check_downloads, args=(downloads,)))
+9 -5
View File
@@ -15,18 +15,22 @@ def add_direct_download(url: str, title: str, dl_path: Optional[str] = None, hea
if dl_path is None: if dl_path is None:
dl_path = state.download_path dl_path = state.download_path
if url in state.active_downloads: with state.downloads_lock:
consoleLog(f"Download already active: {title}") if url in state.active_downloads:
return consoleLog(f"Download already active: {title}")
return
filename = ( raw_filename = (
detect_filename_from_headers(url, DirectDownloadHandle.USER_AGENT) detect_filename_from_headers(url, DirectDownloadHandle.USER_AGENT)
or extract_filename_from_url(url) or extract_filename_from_url(url)
or sanitize_filename(title) + ".zip" or sanitize_filename(title) + ".zip"
) )
filename = sanitize_filename(raw_filename)
handle = DirectDownloadHandle(url, filename, dl_path, headers, single_threaded) handle = DirectDownloadHandle(url, filename, dl_path, headers, single_threaded)
state.active_downloads[url] = handle with state.downloads_lock:
state.active_downloads[url] = handle
add_download_log(title, url, "", False) add_download_log(title, url, "", False)
handle.start() handle.start()
consoleLog(f"Started direct download: {filename}") consoleLog(f"Started direct download: {filename}")
+18 -1
View File
@@ -10,6 +10,7 @@ import requests
import hashlib import hashlib
import json import json
import time import time
import sys
import os import os
@@ -231,8 +232,24 @@ class DirectDownloadHandle:
def _preallocate_file(self, total_size: int): def _preallocate_file(self, total_size: int):
if os.path.exists(self._file_path) and os.path.getsize(self._file_path) == total_size: if os.path.exists(self._file_path) and os.path.getsize(self._file_path) == total_size:
return return
with open(self._file_path, "wb") as f: with open(self._file_path, "wb") as f:
f.truncate(total_size) if sys.platform == "win32":
import msvcrt
import ctypes
from ctypes.wintypes import DWORD
handle = msvcrt.get_osfhandle(f.fileno())
kernel32 = ctypes.windll.kernel32
bytes_returned = DWORD()
kernel32.DeviceIoControl(
handle, 590020, None, 0, None, 0, ctypes.byref(bytes_returned), None
)
kernel32.SetFilePointerEx(handle, ctypes.c_int64(total_size), None, 0)
kernel32.SetEndOfFile(handle)
else:
f.truncate(total_size)
def _compute_chunks(self, total_size: int) -> list[ChunkSpec]: def _compute_chunks(self, total_size: int) -> list[ChunkSpec]:
num_chunks = min(self.NUM_THREADS, max(1, total_size // self.MIN_CHUNK_SIZE)) num_chunks = min(self.NUM_THREADS, max(1, total_size // self.MIN_CHUNK_SIZE))
+4 -4
View File
@@ -1,4 +1,5 @@
from dataclasses import dataclass from dataclasses import dataclass
from collections import deque
from typing import Optional from typing import Optional
import libtorrent as lt import libtorrent as lt
import threading import threading
@@ -31,7 +32,7 @@ class DirectDownloadStatus:
self._upload_rate = 0 self._upload_rate = 0
self._chunk_bytes: dict[int, int] = {} self._chunk_bytes: dict[int, int] = {}
self._speed_window: list[tuple[float, int]] = [] self._speed_window: deque[tuple[float, int]] = deque()
self._speed_window_size = 3.0 self._speed_window_size = 3.0
@property @property
@@ -65,9 +66,8 @@ class DirectDownloadStatus:
self._speed_window.append((now, self._total_wanted_done)) self._speed_window.append((now, self._total_wanted_done))
cutoff = now - self._speed_window_size cutoff = now - self._speed_window_size
self._speed_window = [ while self._speed_window and self._speed_window[0][0] < cutoff:
(t, b) for t, b in self._speed_window if t >= cutoff self._speed_window.popleft()
]
if len(self._speed_window) >= 2: if len(self._speed_window) >= 2:
oldest_time, oldest_bytes = self._speed_window[0] oldest_time, oldest_bytes = self._speed_window[0]
dt = now - oldest_time dt = now - oldest_time
+1 -1
View File
@@ -34,7 +34,7 @@ def detect_filename_from_headers(url: str, user_agent: str) -> Optional[str]:
if "filename=" in cd: if "filename=" in cd:
parts = cd.split("filename=") parts = cd.split("filename=")
if len(parts) > 1: if len(parts) > 1:
fname = parts[-1].strip().strip('"').strip("'") fname = parts[1].split(";")[0].strip().strip('"').strip("'")
if fname: if fname:
return fname return fname
except Exception as e: except Exception as e:
+54 -44
View File
@@ -10,7 +10,7 @@ import time
import os import os
loop_running = False _loop_lock = threading.Lock()
def get_free_space_mb(dirname): def get_free_space_mb(dirname):
if platform.system() == 'Windows': if platform.system() == 'Windows':
@@ -23,7 +23,9 @@ def get_free_space_mb(dirname):
def check_space(): def check_space():
while not state.shutdown_event.is_set(): while not state.shutdown_event.is_set():
for _, magnetdl in list(state.active_downloads.items()): with state.downloads_lock:
items = list(state.active_downloads.items())
for _, magnetdl in items:
try: try:
status = magnetdl.status() status = magnetdl.status()
except RuntimeError: except RuntimeError:
@@ -81,9 +83,11 @@ def add_download(magnet_uri):
init_session() init_session()
free_space = get_free_space_mb(state.download_path) free_space = get_free_space_mb(state.download_path)
if magnet_uri in state.active_downloads: with state.downloads_lock:
already_active = magnet_uri in state.active_downloads
handle = state.active_downloads.get(magnet_uri) if already_active else None
if already_active:
try: try:
handle = state.active_downloads[magnet_uri]
status = handle.status() status = handle.status()
if status.has_metadata: if status.has_metadata:
@@ -93,7 +97,8 @@ def add_download(magnet_uri):
consoleLog(f"File Deleted, redownloading: {status.name}") consoleLog(f"File Deleted, redownloading: {status.name}")
state.dl_session.remove_torrent(handle) state.dl_session.remove_torrent(handle)
del state.active_downloads[magnet_uri] with state.downloads_lock:
del state.active_downloads[magnet_uri]
else: else:
consoleLog("Skipping Downloading, download already running...") consoleLog("Skipping Downloading, download already running...")
return False return False
@@ -102,23 +107,26 @@ def add_download(magnet_uri):
return False return False
except RuntimeError as e: except RuntimeError as e:
consoleLog(f"Error in LibTorrent Handle: {e}") consoleLog(f"Error in LibTorrent Handle: {e}")
del state.active_downloads[magnet_uri] with state.downloads_lock:
del state.active_downloads[magnet_uri]
try: try:
params = lt.parse_magnet_uri(magnet_uri) params = lt.parse_magnet_uri(magnet_uri)
params.save_path = state.download_path params.save_path = state.download_path
handle = state.dl_session.add_torrent(params) handle = state.dl_session.add_torrent(params)
metadata_timeout = 60
metadata_start = time.time()
while not handle.has_metadata(): while not handle.has_metadata():
if time.time() - metadata_start > metadata_timeout:
state.dl_session.remove_torrent(handle)
consoleLog("Timed out waiting for torrent metadata")
return False
time.sleep(1) time.sleep(1)
total_size = handle.get_torrent_info().total_size() total_size = handle.get_torrent_info().total_size()
if free_space > total_size: if free_space <= total_size:
magnetdl = lt.parse_magnet_uri(magnet_uri)
magnetdl.save_path = state.download_path
download = state.dl_session.add_torrent(magnetdl)
else:
state.dl_session.remove_torrent(handle) state.dl_session.remove_torrent(handle)
consoleLog("Not enough free space to download this item.") consoleLog("Not enough free space to download this item.")
return False return False
@@ -126,8 +134,9 @@ def add_download(magnet_uri):
except Exception as e: except Exception as e:
consoleLog(f"Failed to add torrent or fetch info: {e}") consoleLog(f"Failed to add torrent or fetch info: {e}")
return False return False
if download: if handle:
state.active_downloads[magnet_uri] = download with state.downloads_lock:
state.active_downloads[magnet_uri] = handle
consoleLog(f"Added {magnet_uri} to downloads") consoleLog(f"Added {magnet_uri} to downloads")
run_thread(threading.Thread(target=dl_status_loop)) run_thread(threading.Thread(target=dl_status_loop))
@@ -140,9 +149,10 @@ def add_seed(magnet_uri, file_path):
init_session() init_session()
if magnet_uri in state.active_downloads: with state.downloads_lock:
consoleLog("Already seeding this torrent") if magnet_uri in state.active_downloads:
return False consoleLog("Already seeding this torrent")
return False
try: try:
magnetdl = lt.parse_magnet_uri(magnet_uri) magnetdl = lt.parse_magnet_uri(magnet_uri)
@@ -151,43 +161,43 @@ def add_seed(magnet_uri, file_path):
except Exception as e: except Exception as e:
consoleLog(f"Failed to add seed: {e}") consoleLog(f"Failed to add seed: {e}")
return False return False
state.active_downloads[magnet_uri] = handle with state.downloads_lock:
state.seeded_magnets.add(magnet_uri) state.active_downloads[magnet_uri] = handle
state.seeded_magnets.add(magnet_uri)
return True return True
def dl_status_loop(): def dl_status_loop():
global loop_running if not _loop_lock.acquire(blocking=False):
if loop_running == True:
return return
loop_running = True try:
completed_set = set() completed_set = set()
if not state.active_downloads:
consoleLog("No active downloads")
loop_running = False
return
while state.active_downloads and not state.shutdown_event.is_set():
for magnet_uri, magnetdl in list(state.active_downloads.items()):
try:
status = magnetdl.status()
except RuntimeError:
continue
if status.state == lt.torrent_status.seeding and magnet_uri not in completed_set:
consoleLog(f"Download completed: {status.name}")
completed_set.add(magnet_uri)
if not state.active_downloads: if not state.active_downloads:
loop_running = False consoleLog("No active downloads")
break return
time.sleep(1) while state.active_downloads and not state.shutdown_event.is_set():
with state.downloads_lock:
loop_running = False items = list(state.active_downloads.items())
for magnet_uri, magnetdl in items:
try:
status = magnetdl.status()
except RuntimeError:
continue
if status.state == lt.torrent_status.seeding and magnet_uri not in completed_set:
consoleLog(f"Download completed: {status.name}")
completed_set.add(magnet_uri)
if not state.active_downloads:
break
time.sleep(1)
finally:
_loop_lock.release()
def update_settings(): def update_settings():
+65 -51
View File
@@ -1,69 +1,50 @@
from utils.logging.logs import update_download_completed_by_hash from utils.logging.logs import update_download_completed_by_hash
from utils.logging.logs import consoleLog from utils.logging.logs import consoleLog
from PySide6.QtCore import QObject, QTimer
from utils.data.state import state from utils.data.state import state
from plyer import notification from plyer import notification
import libtorrent as lt import libtorrent as lt
import time
import os import os
def cleanup_session(): def cleanup_session():
if state.dl_session is not None: if state.dl_session is not None:
for magnetdl in state.active_downloads.values(): with state.downloads_lock:
if hasattr(magnetdl, 'pause'): # Check it's a handle for magnetdl in state.active_downloads.values():
magnetdl.pause() if hasattr(magnetdl, 'pause'): # check it's a handle
magnetdl.pause()
del state.dl_session del state.dl_session
state.dl_session = None state.dl_session = None
state.active_downloads.clear() with state.downloads_lock:
state.active_downloads.clear()
def send_notification(shutdown_event): class AppDaemons(QObject):
notified = set() def __init__(self):
while not shutdown_event.is_set(): super().__init__()
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 as e:
consoleLog(f"Exception while sending notification: {e}")
time.sleep(5)
def update_log(shutdown_event): self.notified_magnets = set()
updated = set() self.updated_magnets = 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")
if hasattr(status, 'info_hashes'):
info_hash = str(status.info_hashes.v1)
else:
info_hash = str(status.info_hash)
update_download_completed_by_hash(info_hash, True)
updated.add(magnet_uri)
except Exception as e:
consoleLog(f"Exception while updating log file: {e}")
time.sleep(5)
def check_deleted_files(shutdown_event): # Create Timers
while not shutdown_event.is_set(): self.deleted_files_timer = QTimer(self)
self.completed_downloads_timer = QTimer(self)
# Connect Timer Signals
self.deleted_files_timer.timeout.connect(self.check_deleted_files)
self.completed_downloads_timer.timeout.connect(self.check_completed_downloads)
def start_all(self):
consoleLog("Starting Timer: deleted_files")
self.deleted_files_timer.start(2000)
consoleLog("Starting Timer: completed_downloads")
self.completed_downloads_timer.start(5000)
def check_deleted_files(self):
try: try:
for magnet_uri, magnetdl in list(state.active_downloads.items()): with state.downloads_lock:
items = list(state.active_downloads.items())
for magnet_uri, magnetdl in items:
if isinstance(magnetdl, dict): if isinstance(magnetdl, dict):
continue continue
@@ -75,7 +56,40 @@ def check_deleted_files(shutdown_event):
if not os.path.exists(file_path): if not os.path.exists(file_path):
consoleLog(f"Registered File Deletion: {status.name}") consoleLog(f"Registered File Deletion: {status.name}")
state.dl_session.remove_torrent(magnetdl) state.dl_session.remove_torrent(magnetdl)
del state.active_downloads[magnet_uri] with state.downloads_lock:
del state.active_downloads[magnet_uri]
except Exception as e: except Exception as e:
consoleLog(f"Exception while checking for file deletions: {e}") consoleLog(f"Exception while checking for file deletions: {e}")
time.sleep(5)
def check_completed_downloads(self):
try:
with state.downloads_lock:
items = list(state.active_downloads.items())
seeded = set(state.seeded_magnets)
for magnet_uri, magnetdl in items:
if isinstance(magnetdl, dict):
continue
status = magnetdl.status()
if status.state == lt.torrent_status.seeding:
if magnet_uri not in self.updated_magnets and magnet_uri not in seeded:
consoleLog(f"Marking {status.name} as completed")
if hasattr(status, 'info_hashes'):
info_hash = str(status.info_hashes.v1)
else:
info_hash = str(status.info_hash)
update_download_completed_by_hash(info_hash, True)
self.updated_magnets.add(magnet_uri)
if magnet_uri not in self.notified_magnets:
notification.notify(
title="Download finished",
message=f"{status.name} has finished downloading.",
timeout=4
)
self.notified_magnets.add(magnet_uri)
except Exception as e:
consoleLog(f"Exception while checking downloads: {e}")
+6 -2
View File
@@ -15,7 +15,9 @@ def create_config():
"ignore_updates": str(state.ignore_updates), "ignore_updates": str(state.ignore_updates),
"autoresume": str(state.autoresume), "autoresume": str(state.autoresume),
"window_transparency": str(state.window_transparency), "window_transparency": str(state.window_transparency),
"accent_color": str(state.accent_color) "accent_color": str(state.accent_color),
"close_to_tray": str(state.close_to_tray),
"show_megadb_notification": str(state.show_megadb_notification)
} }
config["Network"] = { config["Network"] = {
@@ -86,6 +88,8 @@ def read_config():
state.autoresume = config.getboolean("General", "autoresume", fallback=state.autoresume) state.autoresume = config.getboolean("General", "autoresume", fallback=state.autoresume)
state.window_transparency = config.getboolean("General", "window_transparency", fallback=state.window_transparency) state.window_transparency = config.getboolean("General", "window_transparency", fallback=state.window_transparency)
state.accent_color = config.get("General", "accent_color", fallback=state.accent_color) state.accent_color = config.get("General", "accent_color", fallback=state.accent_color)
state.close_to_tray = config.getboolean("General", "close_to_tray", fallback=state.close_to_tray)
state.show_megadb_notification = config.getboolean("General", "show_megadb_notification", fallback=state.show_megadb_notification)
# Network # Network
state.api_url = config.get("Network", "api_url", fallback=state.api_url) state.api_url = config.get("Network", "api_url", fallback=state.api_url)
@@ -147,4 +151,4 @@ def backup_config(config_path):
os.replace(config_path, backup_path) os.replace(config_path, backup_path)
consoleLog(f"Successfully created backup of corrupted config: {backup_path}") consoleLog(f"Successfully created backup of corrupted config: {backup_path}")
except Exception as e: except Exception as e:
consoleLog(f"Failed to create backup of corrupted config: {e}") consoleLog(f"Failed to create backup of corrupted config: {e}")
+12 -1
View File
@@ -2,10 +2,13 @@ from network.libtorrent_int import update_settings
from utils.config.config import create_config from utils.config.config import create_config
from utils.logging.logs import consoleLog from utils.logging.logs import consoleLog
from utils.data.state import state from utils.data.state import state
import qdarktheme
import platform
def save_settings(close=lambda: None, apiurl=None, download_path=None, down_speed_limit=None, up_speed_limit=None, image_path=None, autoresume=None, max_connections=None, max_downloads=None, bound_interface=None, image_width=None, image_offset=None, image_opacity=None, image_as_wallpaper=None, image_position=None, accent_color=None):
def save_settings(close=lambda: None, apiurl=None, download_path=None, down_speed_limit=None, up_speed_limit=None, image_path=None, autoresume=None, max_connections=None, max_downloads=None, bound_interface=None, image_width=None, image_offset=None, image_opacity=None, image_as_wallpaper=None, image_position=None, accent_color=None, close_to_tray=None, show_megadb_notification=None):
if apiurl is not None: if apiurl is not None:
state.api_url = apiurl state.api_url = apiurl
if download_path is not None: if download_path is not None:
@@ -36,6 +39,14 @@ def save_settings(close=lambda: None, apiurl=None, download_path=None, down_spee
state.image_position = image_position state.image_position = image_position
if accent_color is not None: if accent_color is not None:
state.accent_color = accent_color state.accent_color = accent_color
custom_colors = {"primary": state.accent_color} if state.accent_color else {}
if state.window_transparency and platform.system() != "Windows":
custom_colors["background"] = "#00000000"
qdarktheme.setup_theme("auto", custom_colors=custom_colors if custom_colors else None)
if close_to_tray is not None:
state.close_to_tray = close_to_tray
if show_megadb_notification is not None:
state.show_megadb_notification = show_megadb_notification
update_settings() # Update LibTorrent Session Settings update_settings() # Update LibTorrent Session Settings
consoleLog("Saved Settings") consoleLog("Saved Settings")
+7 -1
View File
@@ -25,11 +25,13 @@ class AppState(QObject):
# GUI # GUI
self.window_transparency: bool = False self.window_transparency: bool = False
self.accent_color: str = "" self.accent_color: str = "#fda7d7"
self.trackertable: QTableWidget self.trackertable: QTableWidget
self.interfaces: List = [] self.interfaces: List = []
self.active_interfaces: List = [] self.active_interfaces: List = []
self.bound_interface: Any = None self.bound_interface: Any = None
self.close_to_tray: bool = True
self.show_megadb_notification: bool = True
# Image # Image
self._image_enabled: bool = False self._image_enabled: bool = False
@@ -38,6 +40,9 @@ class AppState(QObject):
self._image_opacity: int = 100 self._image_opacity: int = 100
self._image_as_wallpaper: bool = False self._image_as_wallpaper: bool = False
self._image_position: str = "bottom-right" # top-left, top-right, bottom-left, bottom-right, center self._image_position: str = "bottom-right" # top-left, top-right, bottom-left, bottom-right, center
self._image_custom_position: bool = False
self._image_x: int = 0
self._image_y: int = 0
# Trackers / Scraping # Trackers / Scraping
self.posts: list[Dict[str,str]] | None = None # titles, urls, author, seeders, leechers self.posts: list[Dict[str,str]] | None = None # titles, urls, author, seeders, leechers
@@ -56,6 +61,7 @@ class AppState(QObject):
self.max_connections: int = 200 self.max_connections: int = 200
self.max_downloads: int = 10 self.max_downloads: int = 10
self.downloads_lock = threading.RLock() self.downloads_lock = threading.RLock()
self._log_lock = threading.Lock()
@property @property
def image_path(self) -> str: def image_path(self) -> str:
+1 -1
View File
@@ -32,4 +32,4 @@ def check_downloads(downloads):
elif download.completed == True and not os.path.exists(download.path): elif download.completed == True and not os.path.exists(download.path):
consoleLog(f"Inexistent Download: {download.title}") consoleLog(f"Inexistent Download: {download.title}")
remove_download_log(download.magnet_uri) remove_download_log(download.magnet_uri)
+44 -97
View File
@@ -1,4 +1,5 @@
from utils.data.models import Download, DownloadList from utils.data.models import Download, DownloadList
from PySide6.QtCore import QObject, Signal
from utils.data.state import state from utils.data.state import state
from dataclasses import asdict from dataclasses import asdict
from datetime import datetime from datetime import datetime
@@ -7,6 +8,31 @@ import time
import os import os
import re import re
class LogSignal(QObject):
new_log = Signal(str)
log_emitter = LogSignal()
def _downloads_file_path() -> str:
return os.path.join(state.settings_path, "downloads.json")
def _load_downloads() -> list[Download]:
downloads_file = _downloads_file_path()
if os.path.exists(downloads_file) and os.path.getsize(downloads_file) > 0:
try:
with open(downloads_file, "r") as file:
existing_data = json.load(file)
return [Download(**d) for d in existing_data.get("data", [])]
except (json.JSONDecodeError, TypeError) as e:
consoleLog(f"Error loading downloads.json: {e}")
return []
def _save_downloads(downloads: list[Download]) -> DownloadList:
download_list = DownloadList(data=downloads, count=len(downloads))
with open(_downloads_file_path(), "w") as file:
json.dump(asdict(download_list), file, indent=4)
return download_list
def add_download_log(title, url, magnet_uri, completed) -> DownloadList: def add_download_log(title, url, magnet_uri, completed) -> DownloadList:
# wait for metadata outside the lock to avoid blocking other threads # wait for metadata outside the lock to avoid blocking other threads
@@ -28,17 +54,7 @@ def add_download_log(title, url, magnet_uri, completed) -> DownloadList:
return _add_download_log_inner(title, url, magnet_uri, completed, path) return _add_download_log_inner(title, url, magnet_uri, completed, path)
def _add_download_log_inner(title, url, magnet_uri, completed, path) -> DownloadList: def _add_download_log_inner(title, url, magnet_uri, completed, path) -> DownloadList:
downloads_file = os.path.join(state.settings_path, "downloads.json") downloads = _load_downloads()
if os.path.exists(downloads_file) and os.path.getsize(downloads_file) > 0:
try:
with open(downloads_file, "r") as file:
existing_data = json.load(file)
downloads = [Download(**d) for d in existing_data.get("data", [])]
except json.JSONDecodeError:
downloads = []
else:
downloads = []
if any((magnet_uri and d.magnet_uri == magnet_uri) or (url and d.url == url) for d in downloads): if any((magnet_uri and d.magnet_uri == magnet_uri) or (url and d.url == url) for d in downloads):
if magnet_uri and magnet_uri in state.active_downloads: if magnet_uri and magnet_uri in state.active_downloads:
@@ -60,30 +76,14 @@ def _add_download_log_inner(title, url, magnet_uri, completed, path) -> Download
)) ))
consoleLog(f"Added {title} to Log File") consoleLog(f"Added {title} to Log File")
return _save_downloads(downloads)
download_list = DownloadList(data=downloads, count=len(downloads))
with open(downloads_file, "w") as file:
json.dump(asdict(download_list), file, indent=4)
return download_list
def remove_download_log(magnet_uri) -> DownloadList: def remove_download_log(magnet_uri) -> DownloadList:
with state.downloads_lock: with state.downloads_lock:
return _remove_download_log_inner(magnet_uri) return _remove_download_log_inner(magnet_uri)
def _remove_download_log_inner(magnet_uri) -> DownloadList: def _remove_download_log_inner(magnet_uri) -> DownloadList:
downloads_file = os.path.join(state.settings_path, "downloads.json") downloads = _load_downloads()
if os.path.exists(downloads_file) and os.path.getsize(downloads_file) > 0:
try:
with open(downloads_file, "r") as file:
existing_data = json.load(file)
downloads = [Download(**d) for d in existing_data.get("data", [])]
except json.JSONDecodeError:
downloads = []
else:
downloads = []
magnet_link = (magnet_uri or "").strip() magnet_link = (magnet_uri or "").strip()
if not magnet_link: if not magnet_link:
@@ -93,30 +93,14 @@ def _remove_download_log_inner(magnet_uri) -> DownloadList:
downloads = [d for d in downloads if (getattr(d, 'magnet_uri', None) or "").strip() != magnet_link and (getattr(d, 'url', None) or "").strip() != magnet_link] downloads = [d for d in downloads if (getattr(d, 'magnet_uri', None) or "").strip() != magnet_link and (getattr(d, 'url', None) or "").strip() != magnet_link]
consoleLog(f"Removed {title} from Log File") consoleLog(f"Removed {title} from Log File")
return _save_downloads(downloads)
download_list = DownloadList(data=downloads, count=len(downloads))
with open(downloads_file, "w") as file:
json.dump(asdict(download_list), file, indent=4)
return download_list
def update_download_completed(magnet_uri, completed) -> DownloadList: def update_download_completed(magnet_uri, completed) -> DownloadList:
with state.downloads_lock: with state.downloads_lock:
return _update_download_completed_inner(magnet_uri, completed) return _update_download_completed_inner(magnet_uri, completed)
def _update_download_completed_inner(magnet_uri, completed) -> DownloadList: def _update_download_completed_inner(magnet_uri, completed) -> DownloadList:
downloads_file = os.path.join(state.settings_path, "downloads.json") downloads = _load_downloads()
if os.path.exists(downloads_file) and os.path.getsize(downloads_file) > 0:
try:
with open(downloads_file, "r") as file:
existing_data = json.load(file)
downloads = [Download(**d) for d in existing_data.get("data", [])]
except (json.JSONDecodeError, TypeError) as e:
consoleLog(f"Error loading downloads.json: {e}")
downloads = []
else:
downloads = []
identifier = (magnet_uri or "").strip() identifier = (magnet_uri or "").strip()
if state.debug: if state.debug:
@@ -145,13 +129,8 @@ def _update_download_completed_inner(magnet_uri, completed) -> DownloadList:
consoleLog("No matching download found to update") consoleLog("No matching download found to update")
return DownloadList(data=downloads, count=len(downloads)) return DownloadList(data=downloads, count=len(downloads))
download_list = DownloadList(data=downloads, count=len(downloads))
with open(downloads_file, "w") as file:
json.dump(asdict(download_list), file, indent=4)
consoleLog("Updated download log") consoleLog("Updated download log")
return _save_downloads(downloads)
return download_list
def get_download_logs() -> DownloadList: def get_download_logs() -> DownloadList:
@@ -159,18 +138,7 @@ def get_download_logs() -> DownloadList:
return _get_download_logs_inner() return _get_download_logs_inner()
def _get_download_logs_inner() -> DownloadList: def _get_download_logs_inner() -> DownloadList:
downloads_file = os.path.join(state.settings_path, "downloads.json") downloads = _load_downloads()
if os.path.exists(downloads_file) and os.path.getsize(downloads_file) > 0:
try:
with open(downloads_file, "r") as file:
existing_data = json.load(file)
downloads = [Download(**d) for d in existing_data.get("data", [])]
except json.JSONDecodeError:
downloads = []
else:
downloads = []
return DownloadList(data=downloads, count=len(downloads)) return DownloadList(data=downloads, count=len(downloads))
@@ -195,18 +163,7 @@ def update_download_completed_by_hash(info_hash, completed) -> DownloadList:
return _update_download_completed_by_hash_inner(info_hash, completed) return _update_download_completed_by_hash_inner(info_hash, completed)
def _update_download_completed_by_hash_inner(info_hash, completed) -> DownloadList: def _update_download_completed_by_hash_inner(info_hash, completed) -> DownloadList:
downloads_file = os.path.join(state.settings_path, "downloads.json") downloads = _load_downloads()
if os.path.exists(downloads_file) and os.path.getsize(downloads_file) > 0:
try:
with open(downloads_file, "r") as file:
existing_data = json.load(file)
downloads = [Download(**d) for d in existing_data.get("data", [])]
except (json.JSONDecodeError, TypeError) as e:
consoleLog(f"Error loading downloads.json: {e}")
downloads = []
else:
downloads = []
info_hash_upper = (info_hash or "").upper().strip() info_hash_upper = (info_hash or "").upper().strip()
consoleLog(f"Updating download by hash: {info_hash_upper}") consoleLog(f"Updating download by hash: {info_hash_upper}")
@@ -228,39 +185,29 @@ def _update_download_completed_by_hash_inner(info_hash, completed) -> DownloadLi
consoleLog("No matching download found to update") consoleLog("No matching download found to update")
return DownloadList(data=downloads, count=len(downloads)) return DownloadList(data=downloads, count=len(downloads))
download_list = DownloadList(data=downloads, count=len(downloads))
with open(downloads_file, "w") as file:
json.dump(asdict(download_list), file, indent=4)
consoleLog("Updated download log") consoleLog("Updated download log")
return _save_downloads(downloads)
return download_list
def set_main_window(window): def set_main_window(window):
state.main_window = window state.main_window = window
def flush_log_buffer(): # credits to claude def flush_log_buffer():
if state.log_buffer: with state._log_lock:
try: for text in state.log_buffer:
from interface.gui import MainWindow log_emitter.new_log.emit(text)
for log_entry in state.log_buffer:
MainWindow.add_log(log_entry) state.log_buffer.clear()
state.log_buffer = []
except Exception as e:
consoleLog(f"Exception while flushing log buffer: {e}")
def consoleLog(text, printAnyways = False): def consoleLog(text, printAnyways = False):
now = datetime.now() now = datetime.now()
current_time = now.strftime("%H:%M:%S") current_time = now.strftime("%H:%M:%S")
formatted_text = f"[{current_time}] {text}" formatted_text = f"[{current_time}] {text}"
try: with state._log_lock:
from interface.gui import MainWindow
if not MainWindow.add_log(formatted_text):
state.log_buffer.append(formatted_text)
except Exception:
state.log_buffer.append(formatted_text) state.log_buffer.append(formatted_text)
log_emitter.new_log.emit(formatted_text)
if state.debug or printAnyways: if state.debug or printAnyways:
print(formatted_text) print(formatted_text)
+6
View File
@@ -37,10 +37,16 @@ def run_download(post, headers: Optional[dict] = None):
if ismagnet: if ismagnet:
link = result link = result
if not link:
consoleLog("Failed to retrieve magnet link")
return
add_magnet(link) add_magnet(link)
add_download_log(post.get("title", "Unknown"), "", link, False) add_download_log(post.get("title", "Unknown"), "", link, False)
else: else:
link, link_headers = result if isinstance(result, tuple) else (result, None) link, link_headers = result if isinstance(result, tuple) else (result, None)
if not link:
consoleLog("Failed to retrieve download link")
return
final_headers = headers or link_headers final_headers = headers or link_headers
add_direct_download(link, post.get("title", "Unknown"), headers=final_headers, single_threaded=final_headers is not None) add_direct_download(link, post.get("title", "Unknown"), headers=final_headers, single_threaded=final_headers is not None)
+31 -8
View File
@@ -2,13 +2,12 @@ from network.direct_download.handle import DirectDownloadHandle
from utils.general.shutdown import closehelper from utils.general.shutdown import closehelper
from utils.logging.logs import consoleLog from utils.logging.logs import consoleLog
from utils.data.state import state from utils.data.state import state
from PySide6.QtCore import Qt from PySide6.QtCore import Qt, QTimer, QEventLoop
from PySide6 import QtWidgets from PySide6 import QtWidgets
import libtorrent as lt import libtorrent as lt
import subprocess import subprocess
import tempfile import tempfile
import hashlib import hashlib
import time
import sys import sys
import os import os
@@ -42,7 +41,6 @@ def download_update(assets: list):
progress = QtWidgets.QProgressDialog("Downloading update... (0.0 MB/s)", None, 0, 100) progress = QtWidgets.QProgressDialog("Downloading update... (0.0 MB/s)", None, 0, 100)
progress.setWindowTitle("Updating") progress.setWindowTitle("Updating")
progress.setWindowModality(Qt.WindowModality.ApplicationModal) progress.setWindowModality(Qt.WindowModality.ApplicationModal)
progress.setCancelButton(None)
progress.setMinimumDuration(0) progress.setMinimumDuration(0)
progress.setAutoClose(False) progress.setAutoClose(False)
progress.setAutoReset(False) progress.setAutoReset(False)
@@ -55,14 +53,19 @@ def download_update(assets: list):
handle = DirectDownloadHandle(url, filename, tempfile.gettempdir()) handle = DirectDownloadHandle(url, filename, tempfile.gettempdir())
handle.start() handle.start()
while True: loop = QEventLoop()
QtWidgets.QApplication.processEvents() timer = QTimer()
timer.setInterval(100)
def poll_progress():
status = handle.status() status = handle.status()
if status.error: if status.error:
state.down_speed_limit = original_limit state.down_speed_limit = original_limit
progress.close() progress.close()
consoleLog(f"Update download failed: {status.error}") consoleLog(f"Update download failed: {status.error}")
timer.stop()
loop.quit()
return return
if status.total_wanted > 0: if status.total_wanted > 0:
@@ -72,9 +75,25 @@ def download_update(assets: list):
progress.setLabelText(f"Downloading update... ({speed_mb:.1f} MB/s)") progress.setLabelText(f"Downloading update... ({speed_mb:.1f} MB/s)")
if status.state == lt.torrent_status.seeding: if status.state == lt.torrent_status.seeding:
break timer.stop()
loop.quit()
time.sleep(0.1) if progress.wasCanceled():
state.down_speed_limit = original_limit
consoleLog("Update download cancelled by user.")
timer.stop()
loop.quit()
return
timer.timeout.connect(poll_progress)
timer.start()
loop.exec()
if progress.wasCanceled():
return
if handle.status().error:
return
state.down_speed_limit = original_limit state.down_speed_limit = original_limit
@@ -84,6 +103,11 @@ def download_update(assets: list):
else: else:
progress.close() progress.close()
consoleLog("Error: Invalid file hash, file may be corrupted") consoleLog("Error: Invalid file hash, file may be corrupted")
QtWidgets.QMessageBox.critical(
None,
"Update Failed",
"Error: Invalid file hash, file may be corrupted"
)
sys.exit(0) sys.exit(0)
else: else:
consoleLog("Skipping hash verification (no hash found for release)") consoleLog("Skipping hash verification (no hash found for release)")
@@ -94,5 +118,4 @@ def download_update(assets: list):
closehelper() closehelper()
subprocess.Popen([installer_path, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/SP-", "/CLOSEAPPLICATIONS"]) subprocess.Popen([installer_path, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/SP-", "/CLOSEAPPLICATIONS"])
time.sleep(1)
os._exit(0) os._exit(0)