Compare commits

...

109 Commits

Author SHA1 Message Date
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
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
52 changed files with 1340 additions and 442 deletions
+16
View File
@@ -0,0 +1,16 @@
version: 2
updates:
# Keeps your GitHub Actions (CI/CD) up to date daily
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
# Keeps your Python dependencies (requirements.txt / pyproject.toml) up to date daily
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "daily"
ignore:
- dependency-name: "darkdetect"
versions: [">=0.8.0"]
+6 -7
View File
@@ -33,7 +33,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
@@ -190,7 +190,7 @@ jobs:
- name: Upload build artifact (Windows)
if: runner.os == 'Windows'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.os }}-build
path: |
@@ -200,7 +200,7 @@ jobs:
- name: Upload build artifact (Linux/macOS)
if: runner.os != 'Windows'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.os }}-build
path: dist-final/${{ matrix.artifact_name }}
@@ -213,8 +213,8 @@ jobs:
if: success()
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- uses: actions/checkout@v6
- uses: actions/download-artifact@v8
with:
path: artifacts
@@ -238,6 +238,7 @@ jobs:
- name: Create Release (Public)
uses: softprops/action-gh-release@v2
with:
token: ${{ secrets.RELEASE_TOKEN }}
tag_name: ${{ needs.build.outputs.version }}
name: ${{ needs.build.outputs.version }}
generate_release_notes: true
@@ -247,5 +248,3 @@ jobs:
release/SoftwareManager-stable-${{ needs.build.outputs.short_sha }}-windows-setup.exe
release/SoftwareManager-stable-${{ needs.build.outputs.short_sha }}-macos
release/SHA256SUMS.txt
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+5 -5
View File
@@ -38,7 +38,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
@@ -195,7 +195,7 @@ jobs:
- name: Upload build artifact (Windows)
if: runner.os == 'Windows'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.os }}-build
path: |
@@ -205,7 +205,7 @@ jobs:
- name: Upload build artifact (Linux/macOS)
if: runner.os != 'Windows'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.os }}-build
path: dist-final/${{ matrix.artifact_name }}
@@ -218,8 +218,8 @@ jobs:
if: success()
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- uses: actions/checkout@v6
- uses: actions/download-artifact@v8
with:
path: artifacts
@@ -0,0 +1,39 @@
name: Sync README Dependencies
on:
push:
branches:
- main
paths:
- requirements.txt
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
sync:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Update README dependencies
run: python scripts/sync_readme_dependencies.py
- name: Create pull request
uses: peter-evans/create-pull-request@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 }}
+3 -3
View File
@@ -28,7 +28,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
@@ -170,7 +170,7 @@ jobs:
- name: Upload artifact (Windows)
if: runner.os == 'Windows'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.os }}-build
path: dist-final/${{ matrix.artifact_name }}.zip
@@ -178,7 +178,7 @@ jobs:
- name: Upload artifact (Linux/macOS)
if: runner.os != 'Windows'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.os }}-build
path: dist-final/${{ matrix.artifact_name }}
+1
View File
@@ -143,6 +143,7 @@ venv/
ENV/
env.bak/
venv.bak/
.python-version
# Spyder project settings
.spyderproject
+15 -9
View File
@@ -1,5 +1,9 @@
# 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
- Searching & Downloading Software from various pages
@@ -35,19 +39,21 @@ SoftwareManager is a Python-based GUI tool that simplifies searching and downloa
## Python Dependencies
(also included in requirements.txt)
```bash
requests (2.32.2)
PySide6 (6.10.1)
beautifulsoup (44.13.5)
<!-- python-dependencies:start -->
```text
requests (2.33.1)
PySide6-Essentials (6.11.0)
beautifulsoup4 (4.14.3)
darkdetect (0.7.1)
pyinstaller (6.15.0)
PyQtDarkTheme-fork (2.3.4)
aiohttp (3.13.0)
pyinstaller (6.19.0)
PyQtDarkTheme-fork (2.3.6)
plyer (2.1.0)
psutil (7.1.0)
psutil (7.2.2)
libtorrent (2.0.11)
libtorrent-windows-dll (0.0.3)
send2trash (2.1.0)
```
<!-- python-dependencies:end -->
## Activity
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

+46 -6
View File
@@ -1,17 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" style="background:#0f1011">
<head>
<meta charset="UTF-8">
<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"/>
<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 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>
<body>
<body style="opacity:0">
<header>
<div class="logo-text">
<h1>KeksPirates</h1>
@@ -159,7 +181,7 @@
<div class="endpoint-parameters-content">
<p class="sources-text">SteamRip</p>
<p class="sources-text">Games</p>
<p class="sources-text">Currently very experimental due to download link retrieveal. May not always work as expected.</p>
<p class="sources-text">Currently very experimental due to download link retrieval. May not always work as expected.</p>
</div>
</div>
</div>
@@ -194,7 +216,12 @@
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}"]`);
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() {
let currentPath = window.location.pathname;
@@ -225,9 +263,11 @@
openTab(null, 'api', false);
} else if (currentPath === '/docs') {
openTab(null, 'docs', false);
} else {
} else if (currentPath === '/') {
history.replaceState({ tab: 'start' }, "", "/");
openTab(null, 'start', false);
} else {
openTab(null, 'notfound', false);
}
};
</script>
+22
View File
@@ -12,6 +12,7 @@ body {
flex-direction: column;
min-height: 100vh;
background: #0f1011;
opacity: 1 !important;
}
header {
@@ -65,6 +66,27 @@ main {
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 {
font-size: 4.0rem;
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>
+7 -7
View File
@@ -1,11 +1,11 @@
requests==2.32.2
PySide6-Essentials==6.10.1
beautifulsoup4==4.13.5
requests==2.33.1
PySide6-Essentials==6.11.0
beautifulsoup4==4.14.3
darkdetect==0.7.1
pyinstaller==6.15.0
PyQtDarkTheme-fork==2.3.4
aiohttp==3.13.0
pyinstaller==6.19.0
PyQtDarkTheme-fork==2.3.6
plyer==2.1.0
psutil==7.1.0
psutil==7.2.2
libtorrent==2.0.11
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()
+14 -6
View File
@@ -1,9 +1,13 @@
from utils.logging.logs import consoleLog
import requests
def scrape_buzzheavier(url):
response = requests.get(url)
response.raise_for_status()
try:
response = requests.get(url, timeout=15)
response.raise_for_status()
except requests.RequestException as e:
consoleLog(f"Buzzheavier: Failed to fetch page - {e}")
return None
download_url = url + '/download'
@@ -13,6 +17,10 @@ def scrape_buzzheavier(url):
'referer': url
}
head_response = requests.head(download_url, headers=headers, allow_redirects=False)
hx_redirect = head_response.headers.get('hx-redirect')
return hx_redirect
try:
head_response = requests.head(download_url, headers=headers, allow_redirects=False, timeout=15)
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
import requests
import hashlib
import time
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):
filetoken = re.findall(r"(?<=https...gofile.io\/d\/).*", url)[0]
session = requests.Session()
headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0",
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Referer": "https://gofile.io/",
"Authorization": f"Bearer lUUtRAccOuRUoZhelNSslDFSlpOgKLDj",
"X-Website-Token": "ffd9ffa831c50b9e68ca5e65cbbbd1a50e9a32e63022e17c7c6748388a1ed73b",
"X-BL": "en-US",
"Origin": "https://gofile.io",
"Sec-GPC": "1",
"Connection": "keep-alive",
"TE": "trailers"
}
r = session.get(
f"https://api.gofile.io/contents/{filetoken}",
headers=headers,
)
try:
temp: dict = r.json()["data"]["children"]
child = [key for key in temp.keys()]
link = temp[child[0]]["link"]
return link, headers
except Exception:
consoleLog("GoFile Authorization failed, if the Issue persists, please open an Issue on GitHub")
match = re.search(r"gofile\.io/d/([a-zA-Z0-9]+)", url)
if not match:
consoleLog("GoFile: Invalid URL")
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.logging.logs import consoleLog
from utils.data.state import state
from urllib.parse import quote
from typing import Dict
import requests
@@ -11,10 +12,18 @@ class RutrackerScraper:
is_magnet = True
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")
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:
consoleLog("Server response cached")
if success:
+66 -29
View File
@@ -1,33 +1,38 @@
from data.hosts.buzzheavier import scrape_buzzheavier
from data.hosts.vikingfile import scrape_vikingfile
from data.hosts.gofile import scrape_gofile
from data.hosts.megadb import scrape_megadb
from utils.logging.logs import consoleLog
from bs4 import BeautifulSoup
from typing import Dict
import webbrowser
import requests
import time
import re
import copy
class SteamripScraper:
name = "steamrip"
headers = ["Game"]
is_magnet = False
def __init__(self):
self.cache = { "data": [], "last_fetched": 0 }
self.cache_expiry = 300
def scrape_steamrip_links(self):
current_time = time.time()
if self.cache["data"] != [] and (current_time - self.cache["last_fetched"] < self.cache_expiry):
return self.cache["data"]
if self.cache["data"] and (current_time - self.cache["last_fetched"] < self.cache_expiry):
return copy.deepcopy(self.cache["data"])
url = f"https://steamrip.com/games-list-page/"
response = requests.get(url)
text = response.text
try:
url = f"https://steamrip.com/games-list-page/"
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")
games = soup.find_all("li", class_="az-list-item")
@@ -37,63 +42,95 @@ class SteamripScraper:
links = []
names = []
for gamehtml in games:
link = gamehtml.find("a", href=lambda x: x and x.startswith("/"))
links += re.findall(r'(?<=href=")[^"]*', link.__str__())
name = gamehtml.find("a", href=lambda x: x and x.startswith("/"))
names += re.findall(r'(?<=\/">)[^<]*', name.__str__())
# construct the list[dict[str,str]]
if link:
links.append("https://steamrip.com" + link["href"])
names.append(link.get_text())
ret = []
for i in range(len(names)):
ret.append({"title" : names[i], "url" : links[i]})
self.cache["data"] = ret
self.cache["last_fetched"] = current_time
return ret
def scrape_steamrip_game_downloads(self, gamelink):
url = "https://steamrip.com" + gamelink
response = requests.get(url)
def scrape_steamrip_game_downloads(self, url):
try:
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")
download_link_elements = soup.find_all("a",class_="shortc-button")
download_links = ["buzzheavier", "gofile"]
download_links = ["", "", "", ""]
for download_link in download_link_elements:
pure = download_link.attrs.get("href")
if not pure or len(pure) < 3:
continue
if pure[2] == "b":
download_links[0] = "https:" + pure
if pure[2] == "g":
download_links[1] = "https:" + pure
if pure[2] == "v":
download_links[2] = "https:" + pure
if pure[2] == "m":
download_links[3] = "https:" + pure
ret = []
for link in download_links:
if len(link) != 1:
if link != "":
ret.append(link)
self.cache["data"] = ret
return ret
def get_download_link(self, post: Dict):
url = post["url"]
links = self.scrape_steamrip_game_downloads(url)
if not links:
consoleLog("SteamRip: No download links found")
return None, None
best = ""
for link in links:
if link[0] == "h":
best = link
break
if links.index(best) == 0:
link = scrape_buzzheavier(best)
return link
elif links.index(best) == 1:
link, headers = scrape_gofile(best)
return link, headers
else:
consoleLog("Unable to retrieve download link due to captcha, launching browser...")
webbrowser.open(best)
if not best:
consoleLog("SteamRip: No valid download links found")
return None, None
try:
consoleLog(f"Found download link: {best}")
if "buzzheavier" in 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
def search(self, query: str):
+2 -2
View File
@@ -19,7 +19,7 @@ class UztrackerScraper:
posts = []
try:
response = requests.get(search_url)
response = requests.get(search_url, timeout=15)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
links = soup.find_all('tr', class_="tCenter hl-tr", id=lambda x: x and x.startswith('tor_'))
@@ -43,7 +43,7 @@ class UztrackerScraper:
except requests.RequestException as e:
consoleLog(f"Failed to fetch {search_url}: {e}")
return None
return []
def get_download_link(self, post: Dict):
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 -78
View File
@@ -1,19 +1,39 @@
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.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.QtCore import Qt, QPoint
from utils.data.state import state
from send2trash import send2trash
from PySide6 import QtWidgets
import threading
import webbrowser
import subprocess
import platform
import time
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):
self.main_window = main_window
self.context_menu = QtWidgets.QMenu(main_window)
self.context_menu.addAction("Open Containing Folder", self.openFolderAction)
self.context_menu.addAction("Copy Magnet URI", self.copyMagnetURIAction)
self.context_menu.addAction("Copy Download Link", self.copyMagnetURIAction)
self.context_menu.addAction("Remove from list", self.cancelDownloadAction)
self.context_menu.addAction("Delete File", self.deleteFileAction)
@@ -34,10 +54,12 @@ class ContextMenu:
if not hasattr(self, '_context_menu_row'):
return
row = self._context_menu_row
if row < 0 or row >= len(state.active_downloads):
return
magnet_link = list(state.active_downloads.keys())[row]
magnetdl = state.active_downloads[magnet_link]
with state.downloads_lock:
if row < 0 or row >= len(state.active_downloads):
return
keys = list(state.active_downloads.keys())
magnet_link = keys[row]
magnetdl = state.active_downloads[magnet_link]
download_path = magnetdl.save_path()
if download_path and os.path.exists(download_path):
if platform.system() == "Windows":
@@ -51,99 +73,202 @@ class ContextMenu:
if not hasattr(self, '_context_menu_row'):
return
row = self._context_menu_row
if row < 0 or row >= len(state.active_downloads):
return
magnet_link = list(state.active_downloads.keys())[row]
with state.downloads_lock:
if row < 0 or row >= len(state.active_downloads):
return
keys = list(state.active_downloads.keys())
magnet_link = keys[row]
clipboard = QtWidgets.QApplication.clipboard()
clipboard.setText(magnet_link)
def cancelDownloadAction(self):
if not hasattr(self, '_context_menu_row'):
return
row = self._context_menu_row
with state.downloads_lock:
if row < 0 or row >= len(state.active_downloads):
return
magnet_link = list(state.active_downloads.keys())[row]
magnetdl = state.active_downloads[magnet_link]
selected_rows = sorted(set(index.row() for index in self.downloadList.selectedIndexes()), reverse=True)
# Cache the name BEFORE removing from session
try:
torrent_name = magnetdl.status().name
except RuntimeError:
torrent_name = "Unknown"
if not selected_rows:
return
names = []
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(
self.main_window, "Cancel Download",
f"Are you sure you want to cancel the download of '{torrent_name}'?",
self.main_window,
"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
)
if confirm == QMessageBox.StandardButton.Yes:
if confirm != QMessageBox.StandardButton.Yes:
return
for magnet_link, magnetdl, name in items_to_remove:
with state.downloads_lock:
state.active_downloads.pop(magnet_link, None)
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:
state.dl_session.remove_torrent(magnetdl)
except Exception as e:
consoleLog(f"Exception while removing download from LibTorrent: {e}")
consoleLog(f"Cancelled download: {torrent_name}", True)
consoleLog(f"Cancelled download: {name}", True)
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'):
return
row = self._context_menu_row
with state.downloads_lock:
if row < 0 or row >= len(state.active_downloads):
return
magnet_link = list(state.active_downloads.keys())[row]
magnetdl = state.active_downloads[magnet_link]
try:
status = magnetdl.status()
save_path = status.save_path
torrent_name = status.name
except RuntimeError:
consoleLog("Error: torrent handle already invalid", True)
with state.downloads_lock:
state.active_downloads.pop(magnet_link, None)
remove_download_log(magnet_link)
if not state.posts or row < 0 or row >= len(state.posts):
return
download_path = os.path.join(save_path, torrent_name)
confirm = QMessageBox.question(
self.main_window, "Delete Files",
f"Are you sure you want to delete the downloaded files of '{torrent_name}'? This action cannot be undone.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if confirm == QMessageBox.StandardButton.Yes:
with state.downloads_lock:
state.active_downloads.pop(magnet_link, None)
remove_download_log(magnet_link)
if state.dl_session:
try:
state.dl_session.remove_torrent(magnetdl)
time.sleep(0.5)
except Exception as e:
consoleLog(f"Exception while removing download from LibTorrent: {e}")
if download_path and os.path.exists(download_path):
import shutil
last_error = None
for attempt in range(3):
try:
if os.path.isfile(download_path):
os.remove(download_path)
else:
shutil.rmtree(download_path)
consoleLog(f"Deleted files for: {torrent_name}", True)
last_error = None
break
except Exception as e:
last_error = e
if attempt < 2:
time.sleep(0.5)
if last_error:
consoleLog(f"Error deleting files: {last_error}", True)
else:
consoleLog(f"Removed entry (files not found): {torrent_name}", True)
post = state.posts[row]
url = post.get("url")
if not url:
return
worker = MagnetWorker(url, parent=self.main_window)
worker.finished.connect(self._on_magnet_fetched)
worker.finished.connect(worker.deleteLater)
worker.start()
def _on_magnet_fetched(self, magnet):
clipboard = QtWidgets.QApplication.clipboard()
clipboard.setText(magnet)
consoleLog("Magnet URI copied to clipboard!", True)
def openInBrowserAction(self):
if not hasattr(self, '_context_menu_row'):
return
row = self._context_menu_row
if not state.posts or row < 0 or row >= len(state.posts):
return
post = state.posts[row]
url = post.get("url")
if url:
try: webbrowser.open(url)
except Exception as e: consoleLog(f"Failed to open URL: {e}", True)
def downloadItemAction(self):
if not hasattr(self, '_context_menu_row'):
return
row = self._context_menu_row
if not state.posts or row < 0 or row >= len(state.posts):
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
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]
status = magnetdl.status()
except (IndexError, KeyError, RuntimeError):
@@ -101,7 +102,8 @@ class DownloadModel(QAbstractTableModel):
if row >= len(state.active_downloads) or row < 0:
return
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]
status = magnetdl.status()
except (IndexError, KeyError, RuntimeError):
+68 -4
View File
@@ -1,5 +1,5 @@
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 utils.data.state import state
import darkdetect
@@ -17,8 +17,15 @@ class Image(QObject):
self.overlay_label.setGraphicsEffect(self.opacity_effect)
self._current_image_path = None
self._cached_qimage = None
self._wallpaper_active = False
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)
state.image_changed.connect(self.update_image_overlay)
@@ -28,9 +35,14 @@ class Image(QObject):
def eventFilter(self, obj, event):
if obj == self.application and event.type() == QEvent.Type.Resize:
if self._current_image_path:
self._load_and_display(self._current_image_path)
self._apply_layout_fast()
self._resize_timer.start()
return False
def _on_resize_finished(self):
if self._current_image_path:
self._apply_layout()
def _set_wallpaper_transparency(self, enabled):
if self._wallpaper_active == enabled:
return
@@ -117,12 +129,62 @@ class Image(QObject):
self._set_wallpaper_transparency(False)
return
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
image = QImage(image_path)
if image.isNull():
image = self._cached_qimage
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()
return
parent = self.application
image = self._cached_qimage
if getattr(state, "image_as_wallpaper", False):
scaled_image = image.scaled(
parent.size(),
@@ -184,4 +246,6 @@ class Image(QObject):
self.overlay_label.show()
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)
+7 -2
View File
@@ -1,11 +1,16 @@
from PySide6.QtWidgets import QMessageBox
from utils.data.state import state
import qdarktheme
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)
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.setWindowTitle(title)
+48 -4
View File
@@ -1,9 +1,11 @@
from interface.dialogs.theme import _accent_selection_color
from interface.utils.tabhelper import create_tab
from utils.config.settings import save_settings
from interface.utils.svghelper import svg_icon
from utils.logging.logs import consoleLog
from PySide6.QtCore import Qt, QSize
from utils.data.state import state
from PySide6.QtGui import QColor
from PySide6 import QtWidgets
from PySide6.QtWidgets import (
QLineEdit,
@@ -13,9 +15,10 @@ from PySide6.QtWidgets import (
QDialog,
QLabel,
QHBoxLayout,
QSpinBox, QSlider, QCheckBox,
QSpinBox, QSlider, QCheckBox,
QFileDialog,
QComboBox,
QColorDialog
)
import platform
@@ -91,9 +94,31 @@ def settings_dialog(self):
transparent_window_checkbox.toggled.connect(lambda checked: setattr(state, 'window_transparency', checked))
# Accent Color
accent_color_container, accent_color_input = create_widget(QLineEdit, "Accent Color (requires restart): ", width=180, height=30)
accent_color_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.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_container, api_url = create_widget(QLineEdit, "API Server URL: ", width=180, height=30)
@@ -272,6 +297,16 @@ def settings_dialog(self):
interface_layout.addWidget(interface_select)
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
layout = QHBoxLayout()
@@ -297,15 +332,24 @@ def settings_dialog(self):
image_opacity.value(),
image_as_wallpaper=image_mode_checkbox.isChecked(),
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)
layout.addWidget(cancel_btn)
layout.addWidget(save_btn)
tabs = QtWidgets.QTabWidget()
create_tab("General", [autoresume_container, update_checkbox_container, transparent_window_container, accent_color_container], tabs=tabs, stretch=True)
create_tab("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("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)
+36 -12
View File
@@ -17,6 +17,7 @@ from interface.utils.searchhelper import run_search
from interface.utils.tabhelper import create_tab
from utils.general.shutdown import closehelper
from utils.general.wrappers import run_thread
from utils.logging.logs import log_emitter
from interface.dialogs.image import Image
from utils.data.state import state
@@ -42,7 +43,8 @@ from PySide6.QtWidgets import (
QHeaderView,
QTableWidgetItem,
QTextEdit,
QStatusBar
QStatusBar,
QStyledItemDelegate,
)
from PySide6.QtGui import (
@@ -60,16 +62,19 @@ import base64
def windowCloseHelper():
QGuiApplication.quit()
class CenteredDelegate(QStyledItemDelegate):
def initStyleOption(self, option, index):
super().initStyleOption(option, index)
option.displayAlignment = Qt.AlignmentFlag.AlignVCenter
class MainWindow(QtWidgets.QMainWindow, QWidget):
eventFilter = eventFilter
log_signal = Signal(str)
search_results_signal = Signal(list)
_instance = None
def __init__(self):
super().__init__()
MainWindow._instance = self
self.log_signal.connect(self._on_log_signal)
self.search_results_signal.connect(self._on_search_results)
pixmap = QPixmap()
@@ -126,12 +131,14 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
self.consoleLog = QTextEdit()
self.statusbar = QStatusBar()
log_emitter.new_log.connect(self._on_log_signal)
flush_log_buffer()
self._tracker_elided_delegate = ElidedItemDelegate(lambda: self._tracker_hovered_row, self)
self._tracker_hover_delegate = TrackerHoverDelegate(lambda: self._tracker_hovered_row, self)
state.trackertable = _create_tracker_table(self)
state.trackertable.cellDoubleClicked.connect(lambda: run_thread(threading.Thread(target=download_selected, args=(state.trackertable.selectedItems(),))))
container = QWidget()
containerLayout = QVBoxLayout()
@@ -175,6 +182,8 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
# Tracker list
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.setCursor(Qt.CursorShape.PointingHandCursor)
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.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)
def _apply_default_headers(self, table):
@@ -297,6 +307,10 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
table.setItemDelegateForColumn(i, self._tracker_hover_delegate)
def _start_search(self):
self.searchbar.setEnabled(False)
state.trackertable.setRowCount(0)
self.show_empty_results(False)
def _search_thread():
try:
run_search(self)
@@ -305,16 +319,18 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
self.search_results_signal.emit([])
run_thread(threading.Thread(target=_search_thread))
@staticmethod
def add_log(text):
if hasattr(MainWindow, "_instance") and MainWindow._instance:
MainWindow._instance.log_signal.emit(text)
return True
return False
def _on_log_signal(self, text):
if hasattr(self, 'consoleLog'):
self.consoleLog.append(text)
# 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().maximum()
)
@@ -343,7 +359,7 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
table.setRowCount(len(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.setData(Qt.ItemDataRole.UserRole, x)
table.setItem(x, y, item)
@@ -386,6 +402,14 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
super().changeEvent(event)
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()
event.accept()
from utils.general.shutdown import force_exit
-4
View File
@@ -6,9 +6,6 @@ for scraper in SCRAPERS:
state.trackers[scraper.name] = scraper
def run_search(self) -> None:
state.trackertable.setRowCount(0)
self.show_empty_results(False)
search_text = self.searchbar.text()
# Check for empty search
if search_text == "":
@@ -16,7 +13,6 @@ def run_search(self) -> None:
return
state.posts = []
self.searchbar.setEnabled(False)
consoleLog(f"User searched for: {search_text}")
# Get current tracker and call its search function
+91 -7
View File
@@ -1,26 +1,75 @@
from network.libtorrent_misc import send_notification, update_log, check_deleted_files
from utils.logging.loghandler import split_data, check_completed, check_downloads
from network.interface import list_interfaces, init_interfaces
from PySide6.QtNetwork import QLocalServer, QLocalSocket
from utils.logging.logs import get_download_logs
from utils.logging.logs import set_main_window
from network.libtorrent_int import check_space
from utils.general.shutdown import closehelper
from utils.general.wrappers import run_thread
from 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.config.config import read_config
from PySide6.QtGui import QAction, QPixmap
from utils.logging.logs import consoleLog
from interface.gui import MainWindow
from PySide6.QtCore import Qt, QObject, QThread
from utils.data.state import state
from PySide6 import QtWidgets
from PySide6.QtCore import Qt
import qdarktheme
import threading
import platform
import base64
import signal
import time
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):
single = app._single_instance
custom_colors = {}
if state.accent_color:
custom_colors["primary"] = state.accent_color
@@ -33,8 +82,40 @@ def run_gui(app):
transparent_colors = {"background": "#00000000", **custom_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)
widget.show()
signal.signal(signal.SIGINT, signal.SIG_DFL)
sys.exit(app.exec())
def keyboardinterrupthandler(signum, frame):
@@ -45,10 +126,10 @@ def keyboardinterrupthandler(signum, frame):
def main():
# Begin counting startup time
start_time = time.perf_counter()
# Initialize UI Engine
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv)
# Check if SoftwareManager is already running
check_running(app)
# Parse saved files
read_config()
logs = get_download_logs()
@@ -61,9 +142,12 @@ def main():
init_interfaces()
consoleLog(f"Current Bound: {state.bound_interface}")
# Start background daemon threads
run_thread(threading.Thread(target=send_notification, args=(state.shutdown_event,), daemon=True))
run_thread(threading.Thread(target=update_log, args=(state.shutdown_event,), daemon=True))
run_thread(threading.Thread(target=check_deleted_files, args=(state.shutdown_event,), daemon=True))
app._daemon_thread = QThread()
app._daemons = AppDaemons()
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
run_thread(threading.Thread(target=check_completed, args=(downloads, state.autoresume)))
run_thread(threading.Thread(target=check_downloads, args=(downloads,)))
+6 -4
View File
@@ -15,9 +15,10 @@ def add_direct_download(url: str, title: str, dl_path: Optional[str] = None, hea
if dl_path is None:
dl_path = state.download_path
if url in state.active_downloads:
consoleLog(f"Download already active: {title}")
return
with state.downloads_lock:
if url in state.active_downloads:
consoleLog(f"Download already active: {title}")
return
filename = (
detect_filename_from_headers(url, DirectDownloadHandle.USER_AGENT)
@@ -26,7 +27,8 @@ def add_direct_download(url: str, title: str, dl_path: Optional[str] = None, hea
)
handle = DirectDownloadHandle(url, filename, dl_path, headers, single_threaded)
state.active_downloads[url] = handle
with state.downloads_lock:
state.active_downloads[url] = handle
add_download_log(title, url, "", False)
handle.start()
consoleLog(f"Started direct download: {filename}")
+4 -4
View File
@@ -1,4 +1,5 @@
from dataclasses import dataclass
from collections import deque
from typing import Optional
import libtorrent as lt
import threading
@@ -31,7 +32,7 @@ class DirectDownloadStatus:
self._upload_rate = 0
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
@property
@@ -65,9 +66,8 @@ class DirectDownloadStatus:
self._speed_window.append((now, self._total_wanted_done))
cutoff = now - self._speed_window_size
self._speed_window = [
(t, b) for t, b in self._speed_window if t >= cutoff
]
while self._speed_window and self._speed_window[0][0] < cutoff:
self._speed_window.popleft()
if len(self._speed_window) >= 2:
oldest_time, oldest_bytes = self._speed_window[0]
dt = now - oldest_time
+53 -43
View File
@@ -10,7 +10,7 @@ import time
import os
loop_running = False
_loop_lock = threading.Lock()
def get_free_space_mb(dirname):
if platform.system() == 'Windows':
@@ -23,7 +23,9 @@ def get_free_space_mb(dirname):
def check_space():
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:
status = magnetdl.status()
except RuntimeError:
@@ -81,9 +83,11 @@ def add_download(magnet_uri):
init_session()
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:
handle = state.active_downloads[magnet_uri]
status = handle.status()
if status.has_metadata:
@@ -93,7 +97,8 @@ def add_download(magnet_uri):
consoleLog(f"File Deleted, redownloading: {status.name}")
state.dl_session.remove_torrent(handle)
del state.active_downloads[magnet_uri]
with state.downloads_lock:
del state.active_downloads[magnet_uri]
else:
consoleLog("Skipping Downloading, download already running...")
return False
@@ -102,23 +107,26 @@ def add_download(magnet_uri):
return False
except RuntimeError as 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:
params = lt.parse_magnet_uri(magnet_uri)
params.save_path = state.download_path
handle = state.dl_session.add_torrent(params)
metadata_timeout = 60
metadata_start = time.time()
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)
total_size = handle.get_torrent_info().total_size()
if free_space > total_size:
magnetdl = lt.parse_magnet_uri(magnet_uri)
magnetdl.save_path = state.download_path
download = state.dl_session.add_torrent(magnetdl)
else:
if free_space <= total_size:
state.dl_session.remove_torrent(handle)
consoleLog("Not enough free space to download this item.")
return False
@@ -126,8 +134,9 @@ def add_download(magnet_uri):
except Exception as e:
consoleLog(f"Failed to add torrent or fetch info: {e}")
return False
if download:
state.active_downloads[magnet_uri] = download
if handle:
with state.downloads_lock:
state.active_downloads[magnet_uri] = handle
consoleLog(f"Added {magnet_uri} to downloads")
run_thread(threading.Thread(target=dl_status_loop))
@@ -140,9 +149,10 @@ def add_seed(magnet_uri, file_path):
init_session()
if magnet_uri in state.active_downloads:
consoleLog("Already seeding this torrent")
return False
with state.downloads_lock:
if magnet_uri in state.active_downloads:
consoleLog("Already seeding this torrent")
return False
try:
magnetdl = lt.parse_magnet_uri(magnet_uri)
@@ -151,43 +161,43 @@ def add_seed(magnet_uri, file_path):
except Exception as e:
consoleLog(f"Failed to add seed: {e}")
return False
state.active_downloads[magnet_uri] = handle
state.seeded_magnets.add(magnet_uri)
with state.downloads_lock:
state.active_downloads[magnet_uri] = handle
state.seeded_magnets.add(magnet_uri)
return True
def dl_status_loop():
global loop_running
if loop_running == True:
if not _loop_lock.acquire(blocking=False):
return
loop_running = True
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)
try:
completed_set = set()
if not state.active_downloads:
loop_running = False
break
consoleLog("No active downloads")
return
time.sleep(1)
while state.active_downloads and not state.shutdown_event.is_set():
with state.downloads_lock:
items = list(state.active_downloads.items())
for magnet_uri, magnetdl in items:
try:
status = magnetdl.status()
except RuntimeError:
continue
loop_running = False
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():
+67 -52
View File
@@ -1,5 +1,6 @@
from utils.logging.logs import update_download_completed_by_hash
from utils.logging.logs import consoleLog
from PySide6.QtCore import QObject, QTimer
from utils.data.state import state
from plyer import notification
import libtorrent as lt
@@ -9,61 +10,42 @@ import os
def cleanup_session():
if state.dl_session is not None:
for magnetdl in state.active_downloads.values():
if hasattr(magnetdl, 'pause'): # Check it's a handle
magnetdl.pause()
with state.downloads_lock:
for magnetdl in state.active_downloads.values():
if hasattr(magnetdl, 'pause'): # check it's a handle
magnetdl.pause()
del state.dl_session
state.dl_session = None
state.active_downloads.clear()
with state.downloads_lock:
state.active_downloads.clear()
def send_notification(shutdown_event):
notified = set()
while not shutdown_event.is_set():
class AppDaemons(QObject):
def __init__(self):
super().__init__()
self.notified_magnets = set()
self.updated_magnets = set()
# Create Timers
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:
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):
updated = set()
while not shutdown_event.is_set():
try:
for magnet_uri, magnetdl in list(state.active_downloads.items()):
if isinstance(magnetdl, dict):
continue
status = magnetdl.status()
if status.state == lt.torrent_status.seeding and magnet_uri not in updated and magnet_uri not in state.seeded_magnets:
consoleLog(f"Marking {status.name} as completed")
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):
while not shutdown_event.is_set():
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):
continue
@@ -75,7 +57,40 @@ def check_deleted_files(shutdown_event):
if not os.path.exists(file_path):
consoleLog(f"Registered File Deletion: {status.name}")
state.dl_session.remove_torrent(magnetdl)
del state.active_downloads[magnet_uri]
with state.downloads_lock:
del state.active_downloads[magnet_uri]
except Exception as 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}")
+5 -1
View File
@@ -15,7 +15,9 @@ def create_config():
"ignore_updates": str(state.ignore_updates),
"autoresume": str(state.autoresume),
"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"] = {
@@ -86,6 +88,8 @@ def read_config():
state.autoresume = config.getboolean("General", "autoresume", fallback=state.autoresume)
state.window_transparency = config.getboolean("General", "window_transparency", fallback=state.window_transparency)
state.accent_color = config.get("General", "accent_color", fallback=state.accent_color)
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
state.api_url = config.get("Network", "api_url", fallback=state.api_url)
+12 -1
View File
@@ -2,10 +2,13 @@ from network.libtorrent_int import update_settings
from utils.config.config import create_config
from utils.logging.logs import consoleLog
from utils.data.state import state
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:
state.api_url = apiurl
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
if accent_color is not None:
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
consoleLog("Saved Settings")
+7 -1
View File
@@ -25,11 +25,13 @@ class AppState(QObject):
# GUI
self.window_transparency: bool = False
self.accent_color: str = ""
self.accent_color: str = "#fda7d7"
self.trackertable: QTableWidget
self.interfaces: List = []
self.active_interfaces: List = []
self.bound_interface: Any = None
self.close_to_tray: bool = True
self.show_megadb_notification: bool = True
# Image
self._image_enabled: bool = False
@@ -38,6 +40,9 @@ class AppState(QObject):
self._image_opacity: int = 100
self._image_as_wallpaper: bool = False
self._image_position: str = "bottom-right" # top-left, top-right, bottom-left, bottom-right, center
self._image_custom_position: bool = False
self._image_x: int = 0
self._image_y: int = 0
# Trackers / Scraping
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_downloads: int = 10
self.downloads_lock = threading.RLock()
self._log_lock = threading.Lock()
@property
def image_path(self) -> str:
+44 -97
View File
@@ -1,4 +1,5 @@
from utils.data.models import Download, DownloadList
from PySide6.QtCore import QObject, Signal
from utils.data.state import state
from dataclasses import asdict
from datetime import datetime
@@ -7,6 +8,31 @@ import time
import os
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:
# 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)
def _add_download_log_inner(title, url, magnet_uri, completed, path) -> DownloadList:
downloads_file = os.path.join(state.settings_path, "downloads.json")
if os.path.exists(downloads_file) and os.path.getsize(downloads_file) > 0:
try:
with open(downloads_file, "r") as file:
existing_data = json.load(file)
downloads = [Download(**d) for d in existing_data.get("data", [])]
except json.JSONDecodeError:
downloads = []
else:
downloads = []
downloads = _load_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:
@@ -60,30 +76,14 @@ def _add_download_log_inner(title, url, magnet_uri, completed, path) -> Download
))
consoleLog(f"Added {title} to Log File")
download_list = DownloadList(data=downloads, count=len(downloads))
with open(downloads_file, "w") as file:
json.dump(asdict(download_list), file, indent=4)
return download_list
return _save_downloads(downloads)
def remove_download_log(magnet_uri) -> DownloadList:
with state.downloads_lock:
return _remove_download_log_inner(magnet_uri)
def _remove_download_log_inner(magnet_uri) -> DownloadList:
downloads_file = os.path.join(state.settings_path, "downloads.json")
if os.path.exists(downloads_file) and os.path.getsize(downloads_file) > 0:
try:
with open(downloads_file, "r") as file:
existing_data = json.load(file)
downloads = [Download(**d) for d in existing_data.get("data", [])]
except json.JSONDecodeError:
downloads = []
else:
downloads = []
downloads = _load_downloads()
magnet_link = (magnet_uri or "").strip()
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]
consoleLog(f"Removed {title} from Log File")
download_list = DownloadList(data=downloads, count=len(downloads))
with open(downloads_file, "w") as file:
json.dump(asdict(download_list), file, indent=4)
return download_list
return _save_downloads(downloads)
def update_download_completed(magnet_uri, completed) -> DownloadList:
with state.downloads_lock:
return _update_download_completed_inner(magnet_uri, completed)
def _update_download_completed_inner(magnet_uri, completed) -> DownloadList:
downloads_file = os.path.join(state.settings_path, "downloads.json")
if os.path.exists(downloads_file) and os.path.getsize(downloads_file) > 0:
try:
with open(downloads_file, "r") as file:
existing_data = json.load(file)
downloads = [Download(**d) for d in existing_data.get("data", [])]
except (json.JSONDecodeError, TypeError) as e:
consoleLog(f"Error loading downloads.json: {e}")
downloads = []
else:
downloads = []
downloads = _load_downloads()
identifier = (magnet_uri or "").strip()
if state.debug:
@@ -145,13 +129,8 @@ def _update_download_completed_inner(magnet_uri, completed) -> DownloadList:
consoleLog("No matching download found to update")
return DownloadList(data=downloads, count=len(downloads))
download_list = DownloadList(data=downloads, count=len(downloads))
with open(downloads_file, "w") as file:
json.dump(asdict(download_list), file, indent=4)
consoleLog("Updated download log")
return download_list
return _save_downloads(downloads)
def get_download_logs() -> DownloadList:
@@ -159,18 +138,7 @@ def get_download_logs() -> DownloadList:
return _get_download_logs_inner()
def _get_download_logs_inner() -> DownloadList:
downloads_file = os.path.join(state.settings_path, "downloads.json")
if os.path.exists(downloads_file) and os.path.getsize(downloads_file) > 0:
try:
with open(downloads_file, "r") as file:
existing_data = json.load(file)
downloads = [Download(**d) for d in existing_data.get("data", [])]
except json.JSONDecodeError:
downloads = []
else:
downloads = []
downloads = _load_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)
def _update_download_completed_by_hash_inner(info_hash, completed) -> DownloadList:
downloads_file = os.path.join(state.settings_path, "downloads.json")
if os.path.exists(downloads_file) and os.path.getsize(downloads_file) > 0:
try:
with open(downloads_file, "r") as file:
existing_data = json.load(file)
downloads = [Download(**d) for d in existing_data.get("data", [])]
except (json.JSONDecodeError, TypeError) as e:
consoleLog(f"Error loading downloads.json: {e}")
downloads = []
else:
downloads = []
downloads = _load_downloads()
info_hash_upper = (info_hash or "").upper().strip()
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")
return DownloadList(data=downloads, count=len(downloads))
download_list = DownloadList(data=downloads, count=len(downloads))
with open(downloads_file, "w") as file:
json.dump(asdict(download_list), file, indent=4)
consoleLog("Updated download log")
return download_list
return _save_downloads(downloads)
def set_main_window(window):
state.main_window = window
def flush_log_buffer(): # credits to claude
if state.log_buffer:
try:
from interface.gui import MainWindow
for log_entry in state.log_buffer:
MainWindow.add_log(log_entry)
state.log_buffer = []
except Exception as e:
consoleLog(f"Exception while flushing log buffer: {e}")
def flush_log_buffer():
with state._log_lock:
for text in state.log_buffer:
log_emitter.new_log.emit(text)
state.log_buffer.clear()
def consoleLog(text, printAnyways = False):
now = datetime.now()
current_time = now.strftime("%H:%M:%S")
formatted_text = f"[{current_time}] {text}"
try:
from interface.gui import MainWindow
if not MainWindow.add_log(formatted_text):
state.log_buffer.append(formatted_text)
except Exception:
with state._log_lock:
state.log_buffer.append(formatted_text)
log_emitter.new_log.emit(formatted_text)
if state.debug or printAnyways:
print(formatted_text)
+6
View File
@@ -37,10 +37,16 @@ def run_download(post, headers: Optional[dict] = None):
if ismagnet:
link = result
if not link:
consoleLog("Failed to retrieve magnet link")
return
add_magnet(link)
add_download_log(post.get("title", "Unknown"), "", link, False)
else:
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
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.logging.logs import consoleLog
from utils.data.state import state
from PySide6.QtCore import Qt
from PySide6.QtCore import Qt, QTimer, QEventLoop
from PySide6 import QtWidgets
import libtorrent as lt
import subprocess
import tempfile
import hashlib
import time
import sys
import os
@@ -42,7 +41,6 @@ def download_update(assets: list):
progress = QtWidgets.QProgressDialog("Downloading update... (0.0 MB/s)", None, 0, 100)
progress.setWindowTitle("Updating")
progress.setWindowModality(Qt.WindowModality.ApplicationModal)
progress.setCancelButton(None)
progress.setMinimumDuration(0)
progress.setAutoClose(False)
progress.setAutoReset(False)
@@ -55,14 +53,19 @@ def download_update(assets: list):
handle = DirectDownloadHandle(url, filename, tempfile.gettempdir())
handle.start()
while True:
QtWidgets.QApplication.processEvents()
loop = QEventLoop()
timer = QTimer()
timer.setInterval(100)
def poll_progress():
status = handle.status()
if status.error:
state.down_speed_limit = original_limit
progress.close()
consoleLog(f"Update download failed: {status.error}")
timer.stop()
loop.quit()
return
if status.total_wanted > 0:
@@ -72,9 +75,25 @@ def download_update(assets: list):
progress.setLabelText(f"Downloading update... ({speed_mb:.1f} MB/s)")
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
@@ -84,6 +103,11 @@ def download_update(assets: list):
else:
progress.close()
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)
else:
consoleLog("Skipping hash verification (no hash found for release)")
@@ -94,5 +118,4 @@ def download_update(assets: list):
closehelper()
subprocess.Popen([installer_path, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/SP-", "/CLOSEAPPLICATIONS"])
time.sleep(1)
os._exit(0)