Compare commits

..

1 Commits

Author SHA1 Message Date
Vxrtrauter 2d8a1d76bc feat: add (opus-generated) mobile layout (will all be replaced, temp change) 2026-04-13 09:50:25 +02:00
24 changed files with 445 additions and 352 deletions
+7 -22
View File
@@ -141,7 +141,7 @@ jobs:
if: runner.os == 'macOS'
shell: bash
run: |
pyinstaller --windowed \
pyinstaller --onefile \
--add-data "build_info.json:." \
--exclude-module PyQt6 \
--exclude-module PyQt5 \
@@ -153,10 +153,6 @@ jobs:
--name ${{ env.APP_NAME }} \
src/main.py
- name: Install create-dmg (macOS)
if: runner.os == 'macOS'
run: brew install create-dmg
- name: Rename artifact
shell: bash
run: |
@@ -164,20 +160,9 @@ jobs:
if [ "$RUNNER_OS" == "Windows" ]; then
mv nuitka-build/main.dist dist-final/${{ env.APP_NAME }}
ls -la dist-final/${{ env.APP_NAME }}/
elif [ "$RUNNER_OS" == "Linux" ]; then
else
chmod +x dist/${{ env.APP_NAME }}
mv dist/${{ env.APP_NAME }} dist-final/${{ matrix.artifact_name }}
elif [ "$RUNNER_OS" == "macOS" ]; then
create-dmg \
--volname "${{ env.APP_NAME }}" \
--window-pos 200 120 \
--window-size 600 300 \
--icon-size 100 \
--icon "${{ env.APP_NAME }}.app" 150 150 \
--hide-extension "${{ env.APP_NAME }}.app" \
--app-drop-link 450 150 \
"dist-final/${{ matrix.artifact_name }}.dmg" \
"dist/"
fi
ls -la dist-final/
@@ -218,7 +203,7 @@ jobs:
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.os }}-build
path: dist-final/${{ matrix.artifact_name }}*
path: dist-final/${{ matrix.artifact_name }}
retention-days: 1
release:
@@ -240,8 +225,8 @@ jobs:
cp artifacts/ubuntu-22.04-build/SoftwareManager-stable-*-linux release/SoftwareManager-stable-${SHORT_SHA}-linux
cp artifacts/windows-latest-build/SoftwareManager-stable-*-windows.zip release/SoftwareManager-stable-${SHORT_SHA}-windows.zip
cp artifacts/windows-latest-build/SoftwareManager-stable-*-windows-setup.exe release/SoftwareManager-stable-${SHORT_SHA}-windows-setup.exe
cp artifacts/macos-latest-build/SoftwareManager-stable-*-macos.dmg release/SoftwareManager-stable-${SHORT_SHA}-macos.dmg
chmod +x release/SoftwareManager-stable-*-linux
cp artifacts/macos-latest-build/SoftwareManager-stable-*-macos release/SoftwareManager-stable-${SHORT_SHA}-macos
chmod +x release/SoftwareManager-stable-*-linux release/SoftwareManager-stable-*-macos
ls -la release/
- name: Generate checksums
@@ -251,7 +236,7 @@ jobs:
cat SHA256SUMS.txt
- name: Create Release (Public)
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
with:
token: ${{ secrets.RELEASE_TOKEN }}
tag_name: ${{ needs.build.outputs.version }}
@@ -261,5 +246,5 @@ jobs:
release/SoftwareManager-stable-${{ needs.build.outputs.short_sha }}-linux
release/SoftwareManager-stable-${{ needs.build.outputs.short_sha }}-windows.zip
release/SoftwareManager-stable-${{ needs.build.outputs.short_sha }}-windows-setup.exe
release/SoftwareManager-stable-${{ needs.build.outputs.short_sha }}-macos.dmg
release/SoftwareManager-stable-${{ needs.build.outputs.short_sha }}-macos
release/SHA256SUMS.txt
+2 -11
View File
@@ -58,15 +58,6 @@ jobs:
env:
UV_SYSTEM_PYTHON: 1
- name: Install dependencies (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libxcb-cursor0 libxcb-render-util0 libxcb-xkb1 \
libxcb-icccm4 libxcb-keysyms1 libxcb-image0 \
libxcb-shape0 libxcb-util1 libxkbcommon-x11-0
- name: Install Nuitka (Windows only)
if: runner.os == 'Windows'
run: uv pip install nuitka
@@ -275,7 +266,7 @@ jobs:
cat release_notes.md
- name: Create Release (Public)
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.build.outputs.version }}
name: ${{ needs.build.outputs.version }}
@@ -287,4 +278,4 @@ jobs:
release/SoftwareManager-dev-${{ needs.build.outputs.short_sha }}-macos
release/SHA256SUMS.txt
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+4 -19
View File
@@ -14,7 +14,7 @@ jobs:
build:
name: Build ${{ matrix.os }}
strategy:
fail-fast: false
fail-fast: false # keep other platforms building even if one fails
matrix:
include:
- os: ubuntu-22.04
@@ -138,7 +138,7 @@ jobs:
if: runner.os == 'macOS'
shell: bash
run: |
pyinstaller --windowed \
pyinstaller --onefile \
--add-data "build_info.json:." \
--exclude-module PyQt6 \
--exclude-module PyQt5 \
@@ -150,30 +150,15 @@ jobs:
--name ${{ env.APP_NAME }} \
src/main.py
- name: Install create-dmg (macOS)
if: runner.os == 'macOS'
run: brew install create-dmg
- name: Stage artifact
shell: bash
run: |
mkdir -p dist-final
if [ "$RUNNER_OS" == "Windows" ]; then
mv nuitka-build/main.dist dist-final/${{ env.APP_NAME }}
elif [ "$RUNNER_OS" == "Linux" ]; then
else
chmod +x dist/${{ env.APP_NAME }}
mv dist/${{ env.APP_NAME }} dist-final/${{ matrix.artifact_name }}
elif [ "$RUNNER_OS" == "macOS" ]; then
create-dmg \
--volname "${{ env.APP_NAME }} Test" \
--window-pos 200 120 \
--window-size 600 300 \
--icon-size 100 \
--icon "${{ env.APP_NAME }}.app" 150 150 \
--hide-extension "${{ env.APP_NAME }}.app" \
--app-drop-link 450 150 \
"dist-final/${{ matrix.artifact_name }}.dmg" \
"dist/"
fi
ls -la dist-final/
@@ -196,5 +181,5 @@ jobs:
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.os }}-build
path: dist-final/${{ matrix.artifact_name }}*
path: dist-final/${{ matrix.artifact_name }}
retention-days: 7
-1
View File
@@ -143,7 +143,6 @@ venv/
ENV/
env.bak/
venv.bak/
.python-version
# Spyder project settings
.spyderproject
+4
View File
@@ -55,6 +55,10 @@ SoftwareManager is a Python-based GUI tool that simplifies searching and downloa
```
<!-- python-dependencies:end -->
## Activity
![Alt](https://repobeats.axiom.co/api/embed/3a61ea02ffdeb9ed5dd948d974a21e0d6cfae36c.svg "Repobeats analytics image")
## Star History
<a href="https://www.star-history.com/#KeksPirates/SoftwareManager&type=date&legend=top-left">
+35
View File
@@ -44,7 +44,18 @@
<a class="tablinks" onclick="openTab(event, 'docs')">Docs</a>
<!-- <a class="tablinks" onclick="openTab(event, 'notfound')">404</a> -->
</nav>
<button class="hamburger" onclick="toggleMobileMenu()" aria-label="Toggle menu">
<span></span>
<span></span>
<span></span>
</button>
</header>
<div class="mobile-nav-overlay" onclick="closeMobileMenu()"></div>
<nav class="mobile-nav">
<a class="mobile-tablinks" onclick="openTab(event, 'start'); closeMobileMenu();">Home</a>
<a class="mobile-tablinks" onclick="openTab(event, 'api'); closeMobileMenu();">API</a>
<a class="mobile-tablinks" onclick="openTab(event, 'docs'); closeMobileMenu();">Docs</a>
</nav>
@@ -203,6 +214,30 @@
</footer>
<script>
function toggleMobileMenu() {
document.querySelector('.hamburger').classList.toggle('active');
document.querySelector('.mobile-nav').classList.toggle('active');
document.querySelector('.mobile-nav-overlay').classList.toggle('active');
document.body.classList.toggle('menu-open');
}
function closeMobileMenu() {
document.querySelector('.hamburger').classList.remove('active');
document.querySelector('.mobile-nav').classList.remove('active');
document.querySelector('.mobile-nav-overlay').classList.remove('active');
document.body.classList.remove('menu-open');
}
function syncMobileActiveTab(tabName) {
document.querySelectorAll('.mobile-tablinks').forEach(function(link) {
link.classList.remove('active');
if (link.textContent.trim().toLowerCase() === tabName ||
(tabName === 'start' && link.textContent.trim().toLowerCase() === 'home')) {
link.classList.add('active');
}
});
}
function openTab(evt, tabName, pushToHistory = true) {
var i, tabcontent, tablinks;
+213 -28
View File
@@ -15,6 +15,10 @@ body {
opacity: 1 !important;
}
body.menu-open {
overflow: hidden;
}
header {
height: 65px;
background: #1a1c1d;
@@ -198,6 +202,93 @@ main .introduction-text p {
display: none;
}
/* Hamburger button - hidden on desktop */
.hamburger {
display: none;
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
z-index: 1001;
flex-direction: column;
gap: 5px;
}
.hamburger span {
display: block;
width: 22px;
height: 2px;
background: #fafafa;
border-radius: 2px;
transition: transform 0.3s, opacity 0.3s;
}
.hamburger.active span:nth-child(1) {
transform: translateY(7px) rotate(45deg);
}
.hamburger.active span:nth-child(2) {
opacity: 0;
}
.hamburger.active span:nth-child(3) {
transform: translateY(-7px) rotate(-45deg);
}
/* Mobile nav overlay */
.mobile-nav-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
opacity: 0;
transition: opacity 0.3s;
}
.mobile-nav-overlay.active {
display: block;
opacity: 1;
}
/* Mobile slide-in nav */
.mobile-nav {
display: none;
position: fixed;
top: 0;
right: -280px;
width: 280px;
height: 100%;
background: #1a1c1d;
z-index: 1000;
flex-direction: column;
padding: 5rem 1.5rem 2rem;
transition: right 0.3s ease;
overflow-y: auto;
}
.mobile-nav.active {
right: 0;
}
.mobile-nav a {
display: block;
padding: 1rem 1rem;
color: #fafafa;
text-decoration: none;
font-family: var(--main-font);
font-size: 1.1rem;
border-radius: 10px;
transition: background 0.3s;
cursor: pointer;
}
.mobile-nav a:hover,
.mobile-nav a.active {
background: #db65d51a;
color: #fdd2fc;
}
footer {
color: #585858;
font-family: var(--main-font);
@@ -379,42 +470,60 @@ pre.api-response {
}
@media(max-width: 560px) {
@media(max-width: 768px) {
/* Show hamburger, hide desktop nav */
.hamburger {
display: flex;
}
.mobile-nav {
display: flex;
}
.tab {
display: none;
}
header {
padding: 1rem;
position: sticky;
top: 0;
z-index: 100;
}
main {
margin: 0rem;
margin: 0;
padding: 1rem;
}
main .introduction {
display: block;
margin-top: -1rem;
padding: 1rem;
main .introduction-frame {
text-align: center;
}
main .introduction-text {
position: relative;
text-align: center;
margin-top: 0rem;
margin-top: 0;
margin-right: 0;
z-index: 2;
user-select: none;
margin-right: 0rem;
padding-bottom: 2rem;
padding-bottom: 1.5rem;
}
main h1.hometitle {
font-size: 2rem;
line-height: 2.2rem;
text-align: left;
font-size: 2.2rem;
line-height: 2.4rem;
text-align: center;
}
main h1.hometitle br {
display: none;
}
main p.title_subtext {
text-align: left;
text-align: center;
font-size: 1rem;
}
.introduction-image {
@@ -422,25 +531,103 @@ pre.api-response {
}
main .btn_big {
display: block;
margin: 1rem auto;
display: inline-flex;
margin-top: 1rem;
width: 100%;
justify-content: center;
padding: 0.9rem 1.4rem;
}
main .btn_big.github{
margin-right: 0rem;
main .btn_big.github {
margin-right: 0;
}
/* Responsive tables / grids */
.endpoint-parameters-header {
display: none;
}
.endpoint-parameters-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem 0;
border-top: 1px solid #ffffff0a;
}
.endpoint-parameters-content p {
font-size: 0.95rem;
}
.endpoint-parameters-content p::before {
font-size: 0.75rem;
color: #8f8d8d;
display: block;
margin-bottom: 0.1rem;
}
.docs-grid .endpoint-parameters-content {
grid-template-columns: 1fr;
}
.docs-grid .endpoint-parameters-content p:nth-child(1)::before { content: "Source"; }
.docs-grid .endpoint-parameters-content p:nth-child(2)::before { content: "Category"; }
.docs-grid .endpoint-parameters-content p:nth-child(3)::before { content: "Notes"; }
/* API page: stack param fields with labels */
#api .endpoint-parameters-content p:nth-child(1)::before { content: "Parameter"; }
#api .endpoint-parameters-content p:nth-child(2)::before { content: "Type"; }
#api .endpoint-parameters-content p:nth-child(3)::before { content: "Required"; }
#api .endpoint-parameters-content p:nth-child(4)::before { content: "Description"; }
.endpoint-type-container {
word-break: break-all;
}
pre.api-response {
font-size: 0.8rem;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.api-url,
.endpoint-url,
.request-type-subcontainer {
word-break: break-all;
}
.title {
font-size: 1.5rem;
}
.base-information-frame {
padding: 0.75rem;
}
footer {
position: relative;
padding: 1rem;
}
.notfound-title {
font-size: 3rem;
line-height: 3rem;
}
.notfound-subtext {
font-size: 1.4rem;
}
}
@media (min-width: 561px) and (max-width: 1393px) {
@media (min-width: 769px) and (max-width: 1393px) {
header {
padding: 1rem;
}
main {
margin: 0rem;
padding: 1rem;
margin: 0;
padding: 1.5rem;
}
main .introduction {
@@ -454,7 +641,7 @@ pre.api-response {
main .introduction-text {
position: relative;
text-align: center;
margin-top: 0rem;
margin-top: 0;
z-index: 2;
user-select: none;
margin-left: 2rem;
@@ -463,8 +650,8 @@ pre.api-response {
}
main h1.hometitle {
font-size: 2rem;
line-height: 2.2rem;
font-size: 2.5rem;
line-height: 2.7rem;
text-align: center;
}
@@ -491,8 +678,6 @@ pre.api-response {
}
main .introduction .inline-block {
display: block;
display: block;
}
}
-17
View File
@@ -1,17 +0,0 @@
<?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 -15
View File
@@ -1,13 +1,9 @@
from utils.logging.logs import consoleLog
import requests
def scrape_buzzheavier(url):
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
response = requests.get(url)
response.raise_for_status()
download_url = url + '/download'
@@ -16,11 +12,7 @@ def scrape_buzzheavier(url):
'hx-request': 'true',
'referer': url
}
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
head_response = requests.head(download_url, headers=headers, allow_redirects=False)
hx_redirect = head_response.headers.get('hx-redirect')
return hx_redirect
+33 -35
View File
@@ -23,44 +23,42 @@ def scrape_gofile(url):
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)
# Create a guest account
r = session.post(f"{_API_BASE}/accounts", headers={"User-Agent": _USER_AGENT})
data = r.json()
if data.get("status") != "ok":
consoleLog("GoFile: Failed to create guest account")
return None
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",
}
account_token = data["data"]["token"]
website_token = _generate_website_token(account_token)
# 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
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",
}
children = data["data"].get("children", {})
if not children:
consoleLog("GoFile: No files found")
return None
# Fetch folder contents
r = session.get(f"{_API_BASE}/contents/{content_id}", headers=headers)
data = r.json()
if data.get("status") != "ok":
consoleLog(f"GoFile: API error - {data.get('status')}")
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
children = data["data"].get("children", {})
if not children:
consoleLog("GoFile: No files found")
return None
return link, headers
finally:
session.close()
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
+2 -11
View File
@@ -2,7 +2,6 @@ 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
@@ -12,18 +11,10 @@ class RutrackerScraper:
is_magnet = True
def search(self, query):
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 []
search = requests.get(f"{state.api_url}/search?q={query}", timeout=15)
consoleLog("Sent request to server")
if search:
try:
_, data, _, success, cached = split_data(search.text)
except (KeyError, ValueError) as e:
consoleLog(f"Failed to parse server response: {e}")
return []
_, data, _, success, cached = split_data(search.text)
if cached:
consoleLog("Server response cached")
if success:
+11 -7
View File
@@ -27,7 +27,7 @@ class SteamripScraper:
try:
url = f"https://steamrip.com/games-list-page/"
response = requests.get(url, timeout=15)
response = requests.get(url)
response.raise_for_status()
text = response.text
except requests.RequestException as e:
@@ -61,7 +61,7 @@ class SteamripScraper:
def scrape_steamrip_game_downloads(self, url):
try:
response = requests.get(url, timeout=15)
response = requests.get(url)
response.raise_for_status()
except requests.RequestException as e:
consoleLog(f"SteamRip: Failed to fetch game page - {e}")
@@ -110,19 +110,23 @@ class SteamripScraper:
consoleLog("SteamRip: No valid download links found")
return None, None
try:
idx = links.index(best)
except ValueError:
consoleLog("SteamRip: Failed to resolve download host")
return None, None
try:
consoleLog(f"Found download link: {best}")
if "buzzheavier" in best:
if idx == 0:
link = scrape_buzzheavier(best)
return link
elif "gofile" in best:
elif idx == 1:
link, headers = scrape_gofile(best)
return link, headers
elif "vikingfile" in best:
elif idx == 2:
scrape_vikingfile(best)
return None, None
elif "megadb" in best:
elif idx == 3:
scrape_megadb(best)
return None, None
else:
+1 -1
View File
@@ -19,7 +19,7 @@ class UztrackerScraper:
posts = []
try:
response = requests.get(search_url, timeout=15)
response = requests.get(search_url)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
links = soup.find_all('tr', class_="tCenter hl-tr", id=lambda x: x and x.startswith('tor_'))
+18 -19
View File
@@ -1,5 +1,4 @@
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
@@ -122,12 +121,7 @@ class ContextMenu_Downloads:
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:
if state.dl_session:
try:
state.dl_session.remove_torrent(magnetdl)
except Exception as e:
@@ -185,24 +179,29 @@ class ContextMenu_Downloads:
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:
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):
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)
last_error = None
for attempt in range(3):
try:
if os.path.isfile(download_path) or os.path.isdir(download_path):
send2trash(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)
+3 -2
View File
@@ -79,8 +79,9 @@ def settings_dialog(self):
# Ignore Updates checkbox
update_checkbox_container, update_checkbox = create_widget(QCheckBox, "Ignore Updates: ")
update_checkbox.setChecked(state.ignore_updates)
update_checkbox.toggled.connect(lambda checked: setattr(state, 'ignore_updates', checked))
if platform.system() == "Windows":
update_checkbox.setChecked(state.ignore_updates)
update_checkbox.toggled.connect(lambda checked: setattr(state, 'ignore_updates', checked))
# Autoresume Container Checkbox
autoresume_container, autoresume_checkbox = create_widget(QCheckBox, "Auto-Resume Downloads: ")
+13 -47
View File
@@ -1,17 +1,13 @@
from PySide6.QtWidgets import QDialog, QMessageBox, QCheckBox
from utils.network.update_checker import get_updates
from PySide6.QtWidgets import QDialog, QMessageBox
from utils.network.updater import download_update
from utils.config.config import create_config
from utils.logging.logs import consoleLog
from utils.data.state import state
from pathlib import Path
import webbrowser
import platform
import json
import os
RELEASES_URL = "https://github.com/KeksPirates/SoftwareManager/releases/latest"
def _get_build_info_path() -> (Path | None):
current_dir = Path(__file__).resolve().parent
@@ -35,49 +31,19 @@ def get_version() -> None:
else:
consoleLog(f"Could not find build info file (Path: {build_info_path})")
def _show_notify_dialog(latest: str) -> None:
msg = QMessageBox()
msg.setIcon(QMessageBox.Icon.Information)
msg.setWindowTitle("Update Available")
msg.setText(f"A new version is available: <b>{latest}</b>")
msg.setInformativeText("Visit the releases page to download it.")
dont_show = QCheckBox("Don't show again")
msg.setCheckBox(dont_show)
open_btn = msg.addButton("Open Releases Page", QMessageBox.ButtonRole.AcceptRole)
msg.addButton("Dismiss", QMessageBox.ButtonRole.RejectRole)
msg.exec()
if dont_show.isChecked():
state.ignore_updates = True
create_config()
if msg.clickedButton() == open_btn:
webbrowser.open(RELEASES_URL)
class UpdateDialog(QDialog):
def __init__(self, parent=None):
if state.ignore_updates:
return
if state.ignore_updates is False and platform.system() == "Windows":
assets, latest = get_updates()
if assets is None:
return
if platform.system() == "Windows":
msg = QMessageBox()
msg.setIcon(QMessageBox.Icon.Information)
msg.setWindowTitle("Update Available")
msg.setText(f"A new version is available\n({latest})")
msg.setInformativeText("Press Ok to download.")
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Ignore)
response = msg.exec_()
if response == QMessageBox.StandardButton.Ok:
download_update(assets)
else:
_show_notify_dialog(latest)
assets, latest = get_updates()
if assets != None:
msg = QMessageBox()
msg.setIcon(QMessageBox.Icon.Information)
msg.setWindowTitle("Update Available")
msg.setText(f"A new version is available\n({latest})")
msg.setInformativeText("Press Ok to download.")
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Ignore)
response = msg.exec_()
if response == QMessageBox.StandardButton.Ok:
download_update(assets)
+9 -6
View File
@@ -17,7 +17,6 @@ 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
@@ -69,12 +68,14 @@ class CenteredDelegate(QStyledItemDelegate):
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()
@@ -131,7 +132,6 @@ 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)
@@ -307,10 +307,6 @@ 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)
@@ -319,6 +315,13 @@ 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)
+6 -2
View File
@@ -6,6 +6,9 @@ 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 == "":
@@ -13,11 +16,12 @@ 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
scraper = state.trackers[state.currenttracker]
state.posts = scraper.search(search_text)
# Update GUI headers to match the current scraper
self.search_results_signal.emit(scraper.headers)
+6 -8
View File
@@ -1,3 +1,4 @@
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
@@ -8,13 +9,12 @@ 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 PySide6.QtCore import Qt, QObject
from utils.data.state import state
from PySide6 import QtWidgets
import qdarktheme
@@ -45,6 +45,7 @@ class SingleInstance(QObject):
self.server.listen(SERVER_NAME)
self.server.newConnection.connect(self.handle_connection)
self.is_running = False
self.is_running = False
def handle_connection(self):
socket = self.server.nextPendingConnection()
@@ -142,12 +143,9 @@ def main():
init_interfaces()
consoleLog(f"Current Bound: {state.bound_interface}")
# Start background daemon threads
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()
run_thread(threading.Thread(target=send_notification, args=(state.shutdown_event,), daemon=True))
run_thread(threading.Thread(target=update_log, args=(state.shutdown_event,), daemon=True))
run_thread(threading.Thread(target=check_deleted_files, args=(state.shutdown_event,), daemon=True))
# Start background non-daemon threads
run_thread(threading.Thread(target=check_completed, args=(downloads, state.autoresume)))
run_thread(threading.Thread(target=check_downloads, args=(downloads,)))
+1 -1
View File
@@ -163,7 +163,7 @@ def add_seed(magnet_uri, file_path):
return False
with state.downloads_lock:
state.active_downloads[magnet_uri] = handle
state.seeded_magnets.add(magnet_uri)
state.seeded_magnets.add(magnet_uri)
return True
+48 -54
View File
@@ -1,6 +1,5 @@
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
@@ -20,28 +19,55 @@ def cleanup_session():
with state.downloads_lock:
state.active_downloads.clear()
class AppDaemons(QObject):
def __init__(self):
super().__init__()
def send_notification(shutdown_event):
notified = set()
while not shutdown_event.is_set():
try:
with state.downloads_lock:
items = list(state.active_downloads.items())
for magnet_uri, magnetdl in 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)
self.notified_magnets = set()
self.updated_magnets = set()
def update_log(shutdown_event):
updated = set()
while not shutdown_event.is_set():
try:
with state.downloads_lock:
items = list(state.active_downloads.items())
for magnet_uri, magnetdl in 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)
# 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):
def check_deleted_files(shutdown_event):
while not shutdown_event.is_set():
try:
with state.downloads_lock:
items = list(state.active_downloads.items())
@@ -61,36 +87,4 @@ class AppDaemons(QObject):
del state.active_downloads[magnet_uri]
except Exception as e:
consoleLog(f"Exception while checking for file deletions: {e}")
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}")
time.sleep(5)
-1
View File
@@ -61,7 +61,6 @@ 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:
+14 -14
View File
@@ -1,5 +1,4 @@
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
@@ -8,10 +7,6 @@ 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")
@@ -192,22 +187,27 @@ def _update_download_completed_by_hash_inner(info_hash, completed) -> DownloadLi
def set_main_window(window):
state.main_window = window
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 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 consoleLog(text, printAnyways = False):
now = datetime.now()
current_time = now.strftime("%H:%M:%S")
formatted_text = f"[{current_time}] {text}"
with state._log_lock:
try:
from interface.gui import MainWindow
if not MainWindow.add_log(formatted_text):
state.log_buffer.append(formatted_text)
except Exception:
state.log_buffer.append(formatted_text)
log_emitter.new_log.emit(formatted_text)
if state.debug or printAnyways:
print(formatted_text)
+8 -31
View File
@@ -2,12 +2,13 @@ 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, QTimer, QEventLoop
from PySide6.QtCore import Qt
from PySide6 import QtWidgets
import libtorrent as lt
import subprocess
import tempfile
import hashlib
import time
import sys
import os
@@ -41,6 +42,7 @@ 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)
@@ -53,19 +55,14 @@ def download_update(assets: list):
handle = DirectDownloadHandle(url, filename, tempfile.gettempdir())
handle.start()
loop = QEventLoop()
timer = QTimer()
timer.setInterval(100)
def poll_progress():
while True:
QtWidgets.QApplication.processEvents()
status = handle.status()
if status.error:
state.down_speed_limit = original_limit
progress.close()
consoleLog(f"Update download failed: {status.error}")
timer.stop()
loop.quit()
return
if status.total_wanted > 0:
@@ -75,25 +72,9 @@ def download_update(assets: list):
progress.setLabelText(f"Downloading update... ({speed_mb:.1f} MB/s)")
if status.state == lt.torrent_status.seeding:
timer.stop()
loop.quit()
break
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
time.sleep(0.1)
state.down_speed_limit = original_limit
@@ -103,11 +84,6 @@ 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)")
@@ -118,4 +94,5 @@ def download_update(assets: list):
closehelper()
subprocess.Popen([installer_path, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/SP-", "/CLOSEAPPLICATIONS"])
time.sleep(1)
os._exit(0)