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' if: runner.os == 'macOS'
shell: bash shell: bash
run: | run: |
pyinstaller --windowed \ pyinstaller --onefile \
--add-data "build_info.json:." \ --add-data "build_info.json:." \
--exclude-module PyQt6 \ --exclude-module PyQt6 \
--exclude-module PyQt5 \ --exclude-module PyQt5 \
@@ -153,10 +153,6 @@ jobs:
--name ${{ env.APP_NAME }} \ --name ${{ env.APP_NAME }} \
src/main.py src/main.py
- name: Install create-dmg (macOS)
if: runner.os == 'macOS'
run: brew install create-dmg
- name: Rename artifact - name: Rename artifact
shell: bash shell: bash
run: | run: |
@@ -164,20 +160,9 @@ jobs:
if [ "$RUNNER_OS" == "Windows" ]; then if [ "$RUNNER_OS" == "Windows" ]; then
mv nuitka-build/main.dist dist-final/${{ env.APP_NAME }} mv nuitka-build/main.dist dist-final/${{ env.APP_NAME }}
ls -la dist-final/${{ env.APP_NAME }}/ ls -la dist-final/${{ env.APP_NAME }}/
elif [ "$RUNNER_OS" == "Linux" ]; then else
chmod +x dist/${{ env.APP_NAME }} chmod +x dist/${{ env.APP_NAME }}
mv dist/${{ env.APP_NAME }} dist-final/${{ matrix.artifact_name }} mv dist/${{ env.APP_NAME }} dist-final/${{ matrix.artifact_name }}
elif [ "$RUNNER_OS" == "macOS" ]; then
create-dmg \
--volname "${{ env.APP_NAME }}" \
--window-pos 200 120 \
--window-size 600 300 \
--icon-size 100 \
--icon "${{ env.APP_NAME }}.app" 150 150 \
--hide-extension "${{ env.APP_NAME }}.app" \
--app-drop-link 450 150 \
"dist-final/${{ matrix.artifact_name }}.dmg" \
"dist/"
fi fi
ls -la dist-final/ ls -la dist-final/
@@ -218,7 +203,7 @@ jobs:
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: ${{ matrix.os }}-build name: ${{ matrix.os }}-build
path: dist-final/${{ matrix.artifact_name }}* path: dist-final/${{ matrix.artifact_name }}
retention-days: 1 retention-days: 1
release: release:
@@ -240,8 +225,8 @@ jobs:
cp artifacts/ubuntu-22.04-build/SoftwareManager-stable-*-linux release/SoftwareManager-stable-${SHORT_SHA}-linux 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.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/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 cp artifacts/macos-latest-build/SoftwareManager-stable-*-macos release/SoftwareManager-stable-${SHORT_SHA}-macos
chmod +x release/SoftwareManager-stable-*-linux chmod +x release/SoftwareManager-stable-*-linux release/SoftwareManager-stable-*-macos
ls -la release/ ls -la release/
- name: Generate checksums - name: Generate checksums
@@ -251,7 +236,7 @@ jobs:
cat SHA256SUMS.txt cat SHA256SUMS.txt
- name: Create Release (Public) - name: Create Release (Public)
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v2
with: with:
token: ${{ secrets.RELEASE_TOKEN }} token: ${{ secrets.RELEASE_TOKEN }}
tag_name: ${{ needs.build.outputs.version }} 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 }}-linux
release/SoftwareManager-stable-${{ needs.build.outputs.short_sha }}-windows.zip 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 }}-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 release/SHA256SUMS.txt
+1 -10
View File
@@ -58,15 +58,6 @@ jobs:
env: env:
UV_SYSTEM_PYTHON: 1 UV_SYSTEM_PYTHON: 1
- name: Install dependencies (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libxcb-cursor0 libxcb-render-util0 libxcb-xkb1 \
libxcb-icccm4 libxcb-keysyms1 libxcb-image0 \
libxcb-shape0 libxcb-util1 libxkbcommon-x11-0
- name: Install Nuitka (Windows only) - name: Install Nuitka (Windows only)
if: runner.os == 'Windows' if: runner.os == 'Windows'
run: uv pip install nuitka run: uv pip install nuitka
@@ -275,7 +266,7 @@ jobs:
cat release_notes.md cat release_notes.md
- name: Create Release (Public) - name: Create Release (Public)
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ needs.build.outputs.version }} tag_name: ${{ needs.build.outputs.version }}
name: ${{ needs.build.outputs.version }} name: ${{ needs.build.outputs.version }}
+4 -19
View File
@@ -14,7 +14,7 @@ jobs:
build: build:
name: Build ${{ matrix.os }} name: Build ${{ matrix.os }}
strategy: strategy:
fail-fast: false fail-fast: false # keep other platforms building even if one fails
matrix: matrix:
include: include:
- os: ubuntu-22.04 - os: ubuntu-22.04
@@ -138,7 +138,7 @@ jobs:
if: runner.os == 'macOS' if: runner.os == 'macOS'
shell: bash shell: bash
run: | run: |
pyinstaller --windowed \ pyinstaller --onefile \
--add-data "build_info.json:." \ --add-data "build_info.json:." \
--exclude-module PyQt6 \ --exclude-module PyQt6 \
--exclude-module PyQt5 \ --exclude-module PyQt5 \
@@ -150,30 +150,15 @@ jobs:
--name ${{ env.APP_NAME }} \ --name ${{ env.APP_NAME }} \
src/main.py src/main.py
- name: Install create-dmg (macOS)
if: runner.os == 'macOS'
run: brew install create-dmg
- name: Stage artifact - name: Stage artifact
shell: bash shell: bash
run: | run: |
mkdir -p dist-final mkdir -p dist-final
if [ "$RUNNER_OS" == "Windows" ]; then if [ "$RUNNER_OS" == "Windows" ]; then
mv nuitka-build/main.dist dist-final/${{ env.APP_NAME }} mv nuitka-build/main.dist dist-final/${{ env.APP_NAME }}
elif [ "$RUNNER_OS" == "Linux" ]; then else
chmod +x dist/${{ env.APP_NAME }} chmod +x dist/${{ env.APP_NAME }}
mv dist/${{ env.APP_NAME }} dist-final/${{ matrix.artifact_name }} mv dist/${{ env.APP_NAME }} dist-final/${{ matrix.artifact_name }}
elif [ "$RUNNER_OS" == "macOS" ]; then
create-dmg \
--volname "${{ env.APP_NAME }} Test" \
--window-pos 200 120 \
--window-size 600 300 \
--icon-size 100 \
--icon "${{ env.APP_NAME }}.app" 150 150 \
--hide-extension "${{ env.APP_NAME }}.app" \
--app-drop-link 450 150 \
"dist-final/${{ matrix.artifact_name }}.dmg" \
"dist/"
fi fi
ls -la dist-final/ ls -la dist-final/
@@ -196,5 +181,5 @@ jobs:
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: ${{ matrix.os }}-build name: ${{ matrix.os }}-build
path: dist-final/${{ matrix.artifact_name }}* path: dist-final/${{ matrix.artifact_name }}
retention-days: 7 retention-days: 7
-1
View File
@@ -143,7 +143,6 @@ venv/
ENV/ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
.python-version
# Spyder project settings # Spyder project settings
.spyderproject .spyderproject
+4
View File
@@ -55,6 +55,10 @@ SoftwareManager is a Python-based GUI tool that simplifies searching and downloa
``` ```
<!-- python-dependencies:end --> <!-- python-dependencies:end -->
## Activity
![Alt](https://repobeats.axiom.co/api/embed/3a61ea02ffdeb9ed5dd948d974a21e0d6cfae36c.svg "Repobeats analytics image")
## Star History ## Star History
<a href="https://www.star-history.com/#KeksPirates/SoftwareManager&type=date&legend=top-left"> <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, 'docs')">Docs</a>
<!-- <a class="tablinks" onclick="openTab(event, 'notfound')">404</a> --> <!-- <a class="tablinks" onclick="openTab(event, 'notfound')">404</a> -->
</nav> </nav>
<button class="hamburger" onclick="toggleMobileMenu()" aria-label="Toggle menu">
<span></span>
<span></span>
<span></span>
</button>
</header> </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> </footer>
<script> <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) { function openTab(evt, tabName, pushToHistory = true) {
var i, tabcontent, tablinks; var i, tabcontent, tablinks;
+211 -26
View File
@@ -15,6 +15,10 @@ body {
opacity: 1 !important; opacity: 1 !important;
} }
body.menu-open {
overflow: hidden;
}
header { header {
height: 65px; height: 65px;
background: #1a1c1d; background: #1a1c1d;
@@ -198,6 +202,93 @@ main .introduction-text p {
display: none; 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 { footer {
color: #585858; color: #585858;
font-family: var(--main-font); 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 { header {
padding: 1rem; padding: 1rem;
position: sticky;
top: 0;
z-index: 100;
} }
main { main {
margin: 0rem; margin: 0;
padding: 1rem; padding: 1rem;
} }
main .introduction { main .introduction-frame {
display: block; text-align: center;
margin-top: -1rem;
padding: 1rem;
} }
main .introduction-text { main .introduction-text {
position: relative; position: relative;
text-align: center; text-align: center;
margin-top: 0rem; margin-top: 0;
margin-right: 0;
z-index: 2; z-index: 2;
user-select: none; user-select: none;
margin-right: 0rem; padding-bottom: 1.5rem;
padding-bottom: 2rem;
} }
main h1.hometitle { main h1.hometitle {
font-size: 2rem; font-size: 2.2rem;
line-height: 2.2rem; line-height: 2.4rem;
text-align: left; text-align: center;
}
main h1.hometitle br {
display: none;
} }
main p.title_subtext { main p.title_subtext {
text-align: left; text-align: center;
font-size: 1rem;
} }
.introduction-image { .introduction-image {
@@ -422,25 +531,103 @@ pre.api-response {
} }
main .btn_big { main .btn_big {
display: block; display: inline-flex;
margin: 1rem auto; margin-top: 1rem;
width: 100%;
justify-content: center;
padding: 0.9rem 1.4rem;
} }
main .btn_big.github { main .btn_big.github {
margin-right: 0rem; 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 { header {
padding: 1rem; padding: 1rem;
} }
main { main {
margin: 0rem; margin: 0;
padding: 1rem; padding: 1.5rem;
} }
main .introduction { main .introduction {
@@ -454,7 +641,7 @@ pre.api-response {
main .introduction-text { main .introduction-text {
position: relative; position: relative;
text-align: center; text-align: center;
margin-top: 0rem; margin-top: 0;
z-index: 2; z-index: 2;
user-select: none; user-select: none;
margin-left: 2rem; margin-left: 2rem;
@@ -463,8 +650,8 @@ pre.api-response {
} }
main h1.hometitle { main h1.hometitle {
font-size: 2rem; font-size: 2.5rem;
line-height: 2.2rem; line-height: 2.7rem;
text-align: center; text-align: center;
} }
@@ -493,6 +680,4 @@ pre.api-response {
main .introduction .inline-block { 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>
+3 -11
View File
@@ -1,13 +1,9 @@
from utils.logging.logs import consoleLog
import requests import requests
def scrape_buzzheavier(url): def scrape_buzzheavier(url):
try:
response = requests.get(url, timeout=15) response = requests.get(url)
response.raise_for_status() response.raise_for_status()
except requests.RequestException as e:
consoleLog(f"Buzzheavier: Failed to fetch page - {e}")
return None
download_url = url + '/download' download_url = url + '/download'
@@ -17,10 +13,6 @@ def scrape_buzzheavier(url):
'referer': url 'referer': url
} }
try: head_response = requests.head(download_url, headers=headers, allow_redirects=False)
head_response = requests.head(download_url, headers=headers, allow_redirects=False, timeout=15)
hx_redirect = head_response.headers.get('hx-redirect') hx_redirect = head_response.headers.get('hx-redirect')
return hx_redirect return hx_redirect
except requests.RequestException as e:
consoleLog(f"Buzzheavier: Failed to fetch download URL - {e}")
return None
+3 -5
View File
@@ -23,9 +23,9 @@ def scrape_gofile(url):
content_id = match.group(1) content_id = match.group(1)
session = requests.Session() session = requests.Session()
try:
# Create a guest account # Create a guest account
r = session.post(f"{_API_BASE}/accounts", headers={"User-Agent": _USER_AGENT}, timeout=15) r = session.post(f"{_API_BASE}/accounts", headers={"User-Agent": _USER_AGENT})
data = r.json() data = r.json()
if data.get("status") != "ok": if data.get("status") != "ok":
consoleLog("GoFile: Failed to create guest account") consoleLog("GoFile: Failed to create guest account")
@@ -44,7 +44,7 @@ def scrape_gofile(url):
} }
# Fetch folder contents # Fetch folder contents
r = session.get(f"{_API_BASE}/contents/{content_id}", headers=headers, timeout=15) r = session.get(f"{_API_BASE}/contents/{content_id}", headers=headers)
data = r.json() data = r.json()
if data.get("status") != "ok": if data.get("status") != "ok":
consoleLog(f"GoFile: API error - {data.get('status')}") consoleLog(f"GoFile: API error - {data.get('status')}")
@@ -62,5 +62,3 @@ def scrape_gofile(url):
return None return None
return link, headers return link, headers
finally:
session.close()
+1 -10
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.data.tracker import get_magnet_link
from utils.logging.logs import consoleLog from utils.logging.logs import consoleLog
from utils.data.state import state from utils.data.state import state
from urllib.parse import quote
from typing import Dict from typing import Dict
import requests import requests
@@ -12,18 +11,10 @@ class RutrackerScraper:
is_magnet = True is_magnet = True
def search(self, query): def search(self, query):
try: search = requests.get(f"{state.api_url}/search?q={query}", timeout=15)
search = requests.get(f"{state.api_url}/search?q={quote(query)}", timeout=15)
except requests.RequestException as e:
consoleLog(f"Failed to reach server: {e}")
return []
consoleLog("Sent request to server") consoleLog("Sent request to server")
if search: if search:
try:
_, data, _, success, cached = split_data(search.text) _, data, _, success, cached = split_data(search.text)
except (KeyError, ValueError) as e:
consoleLog(f"Failed to parse server response: {e}")
return []
if cached: if cached:
consoleLog("Server response cached") consoleLog("Server response cached")
if success: if success:
+11 -7
View File
@@ -27,7 +27,7 @@ class SteamripScraper:
try: try:
url = f"https://steamrip.com/games-list-page/" url = f"https://steamrip.com/games-list-page/"
response = requests.get(url, timeout=15) response = requests.get(url)
response.raise_for_status() response.raise_for_status()
text = response.text text = response.text
except requests.RequestException as e: except requests.RequestException as e:
@@ -61,7 +61,7 @@ class SteamripScraper:
def scrape_steamrip_game_downloads(self, url): def scrape_steamrip_game_downloads(self, url):
try: try:
response = requests.get(url, timeout=15) response = requests.get(url)
response.raise_for_status() response.raise_for_status()
except requests.RequestException as e: except requests.RequestException as e:
consoleLog(f"SteamRip: Failed to fetch game page - {e}") consoleLog(f"SteamRip: Failed to fetch game page - {e}")
@@ -110,19 +110,23 @@ class SteamripScraper:
consoleLog("SteamRip: No valid download links found") consoleLog("SteamRip: No valid download links found")
return None, None return None, None
try:
idx = links.index(best)
except ValueError:
consoleLog("SteamRip: Failed to resolve download host")
return None, None
try: try:
consoleLog(f"Found download link: {best}") if idx == 0:
if "buzzheavier" in best:
link = scrape_buzzheavier(best) link = scrape_buzzheavier(best)
return link return link
elif "gofile" in best: elif idx == 1:
link, headers = scrape_gofile(best) link, headers = scrape_gofile(best)
return link, headers return link, headers
elif "vikingfile" in best: elif idx == 2:
scrape_vikingfile(best) scrape_vikingfile(best)
return None, None return None, None
elif "megadb" in best: elif idx == 3:
scrape_megadb(best) scrape_megadb(best)
return None, None return None, None
else: else:
+1 -1
View File
@@ -19,7 +19,7 @@ class UztrackerScraper:
posts = [] posts = []
try: try:
response = requests.get(search_url, timeout=15) response = requests.get(search_url)
response.raise_for_status() response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser') soup = BeautifulSoup(response.text, 'html.parser')
links = soup.find_all('tr', class_="tCenter hl-tr", id=lambda x: x and x.startswith('tor_')) links = soup.find_all('tr', class_="tCenter hl-tr", id=lambda x: x and x.startswith('tor_'))
+13 -14
View File
@@ -1,5 +1,4 @@
from PySide6.QtCore import Qt, QPoint, Signal, QThread from PySide6.QtCore import Qt, QPoint, Signal, QThread
from network.direct_download.handle import DirectDownloadHandle
from utils.logging.logs import consoleLog, remove_download_log from utils.logging.logs import consoleLog, remove_download_log
from utils.network.download import download_selected from utils.network.download import download_selected
from utils.data.tracker import get_magnet_link from utils.data.tracker import get_magnet_link
@@ -122,12 +121,7 @@ class ContextMenu_Downloads:
remove_download_log(magnet_link) remove_download_log(magnet_link)
if isinstance(magnetdl, DirectDownloadHandle): if state.dl_session:
try:
magnetdl.stop()
except Exception as e:
consoleLog(f"Exception while stopping direct download: {e}")
elif state.dl_session:
try: try:
state.dl_session.remove_torrent(magnetdl) state.dl_session.remove_torrent(magnetdl)
except Exception as e: except Exception as e:
@@ -185,24 +179,29 @@ class ContextMenu_Downloads:
remove_download_log(magnet_link) remove_download_log(magnet_link)
if isinstance(magnetdl, DirectDownloadHandle): if state.dl_session:
try:
magnetdl.stop()
except Exception as e:
consoleLog(f"Exception while stopping direct download: {e}")
elif state.dl_session:
try: try:
state.dl_session.remove_torrent(magnetdl) state.dl_session.remove_torrent(magnetdl)
time.sleep(0.5)
except Exception as e: except Exception as e:
consoleLog(f"Exception while removing download from LibTorrent: {e}") consoleLog(f"Exception while removing download from LibTorrent: {e}")
if download_path and os.path.exists(download_path): if download_path and os.path.exists(download_path):
last_error = None
for attempt in range(3):
try: try:
if os.path.isfile(download_path) or os.path.isdir(download_path): if os.path.isfile(download_path) or os.path.isdir(download_path):
send2trash(download_path) send2trash(download_path)
consoleLog(f"Deleted files for: {torrent_name}", True) consoleLog(f"Deleted files for: {torrent_name}", True)
last_error = None
break
except Exception as e: except Exception as e:
consoleLog(f"Error deleting files: {e}", True) last_error = e
if attempt < 2:
time.sleep(0.5)
if last_error:
consoleLog(f"Error deleting files: {last_error}", True)
else: else:
consoleLog(f"Removed entry (files not found): {torrent_name}", True) consoleLog(f"Removed entry (files not found): {torrent_name}", True)
+1
View File
@@ -79,6 +79,7 @@ def settings_dialog(self):
# Ignore Updates checkbox # Ignore Updates checkbox
update_checkbox_container, update_checkbox = create_widget(QCheckBox, "Ignore Updates: ") update_checkbox_container, update_checkbox = create_widget(QCheckBox, "Ignore Updates: ")
if platform.system() == "Windows":
update_checkbox.setChecked(state.ignore_updates) update_checkbox.setChecked(state.ignore_updates)
update_checkbox.toggled.connect(lambda checked: setattr(state, 'ignore_updates', checked)) update_checkbox.toggled.connect(lambda checked: setattr(state, 'ignore_updates', checked))
+3 -37
View File
@@ -1,17 +1,13 @@
from PySide6.QtWidgets import QDialog, QMessageBox, QCheckBox
from utils.network.update_checker import get_updates from utils.network.update_checker import get_updates
from PySide6.QtWidgets import QDialog, QMessageBox
from utils.network.updater import download_update from utils.network.updater import download_update
from utils.config.config import create_config
from utils.logging.logs import consoleLog from utils.logging.logs import consoleLog
from utils.data.state import state from utils.data.state import state
from pathlib import Path from pathlib import Path
import webbrowser
import platform import platform
import json import json
import os import os
RELEASES_URL = "https://github.com/KeksPirates/SoftwareManager/releases/latest"
def _get_build_info_path() -> (Path | None): def _get_build_info_path() -> (Path | None):
current_dir = Path(__file__).resolve().parent current_dir = Path(__file__).resolve().parent
@@ -35,40 +31,12 @@ def get_version() -> None:
else: else:
consoleLog(f"Could not find build info file (Path: {build_info_path})") consoleLog(f"Could not find build info file (Path: {build_info_path})")
def _show_notify_dialog(latest: str) -> None:
msg = QMessageBox()
msg.setIcon(QMessageBox.Icon.Information)
msg.setWindowTitle("Update Available")
msg.setText(f"A new version is available: <b>{latest}</b>")
msg.setInformativeText("Visit the releases page to download it.")
dont_show = QCheckBox("Don't show again")
msg.setCheckBox(dont_show)
open_btn = msg.addButton("Open Releases Page", QMessageBox.ButtonRole.AcceptRole)
msg.addButton("Dismiss", QMessageBox.ButtonRole.RejectRole)
msg.exec()
if dont_show.isChecked():
state.ignore_updates = True
create_config()
if msg.clickedButton() == open_btn:
webbrowser.open(RELEASES_URL)
class UpdateDialog(QDialog): class UpdateDialog(QDialog):
def __init__(self, parent=None): def __init__(self, parent=None):
if state.ignore_updates: if state.ignore_updates is False and platform.system() == "Windows":
return
assets, latest = get_updates() assets, latest = get_updates()
if assets is None: if assets != None:
return
if platform.system() == "Windows":
msg = QMessageBox() msg = QMessageBox()
msg.setIcon(QMessageBox.Icon.Information) msg.setIcon(QMessageBox.Icon.Information)
msg.setWindowTitle("Update Available") msg.setWindowTitle("Update Available")
@@ -78,6 +46,4 @@ class UpdateDialog(QDialog):
response = msg.exec_() response = msg.exec_()
if response == QMessageBox.StandardButton.Ok: if response == QMessageBox.StandardButton.Ok:
download_update(assets) download_update(assets)
else:
_show_notify_dialog(latest)
+9 -6
View File
@@ -17,7 +17,6 @@ from interface.utils.searchhelper import run_search
from interface.utils.tabhelper import create_tab from interface.utils.tabhelper import create_tab
from utils.general.shutdown import closehelper from utils.general.shutdown import closehelper
from utils.general.wrappers import run_thread from utils.general.wrappers import run_thread
from utils.logging.logs import log_emitter
from interface.dialogs.image import Image from interface.dialogs.image import Image
from utils.data.state import state from utils.data.state import state
@@ -69,12 +68,14 @@ class CenteredDelegate(QStyledItemDelegate):
class MainWindow(QtWidgets.QMainWindow, QWidget): class MainWindow(QtWidgets.QMainWindow, QWidget):
eventFilter = eventFilter eventFilter = eventFilter
log_signal = Signal(str)
search_results_signal = Signal(list) search_results_signal = Signal(list)
_instance = None _instance = None
def __init__(self): def __init__(self):
super().__init__() super().__init__()
MainWindow._instance = self MainWindow._instance = self
self.log_signal.connect(self._on_log_signal)
self.search_results_signal.connect(self._on_search_results) self.search_results_signal.connect(self._on_search_results)
pixmap = QPixmap() pixmap = QPixmap()
@@ -131,7 +132,6 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
self.consoleLog = QTextEdit() self.consoleLog = QTextEdit()
self.statusbar = QStatusBar() self.statusbar = QStatusBar()
log_emitter.new_log.connect(self._on_log_signal)
flush_log_buffer() flush_log_buffer()
self._tracker_elided_delegate = ElidedItemDelegate(lambda: self._tracker_hovered_row, self) self._tracker_elided_delegate = ElidedItemDelegate(lambda: self._tracker_hovered_row, self)
@@ -307,10 +307,6 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
table.setItemDelegateForColumn(i, self._tracker_hover_delegate) table.setItemDelegateForColumn(i, self._tracker_hover_delegate)
def _start_search(self): def _start_search(self):
self.searchbar.setEnabled(False)
state.trackertable.setRowCount(0)
self.show_empty_results(False)
def _search_thread(): def _search_thread():
try: try:
run_search(self) run_search(self)
@@ -319,6 +315,13 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
self.search_results_signal.emit([]) self.search_results_signal.emit([])
run_thread(threading.Thread(target=_search_thread)) run_thread(threading.Thread(target=_search_thread))
@staticmethod
def add_log(text):
if hasattr(MainWindow, "_instance") and MainWindow._instance:
MainWindow._instance.log_signal.emit(text)
return True
return False
def _on_log_signal(self, text): def _on_log_signal(self, text):
if hasattr(self, 'consoleLog'): if hasattr(self, 'consoleLog'):
self.consoleLog.append(text) self.consoleLog.append(text)
+4
View File
@@ -6,6 +6,9 @@ for scraper in SCRAPERS:
state.trackers[scraper.name] = scraper state.trackers[scraper.name] = scraper
def run_search(self) -> None: def run_search(self) -> None:
state.trackertable.setRowCount(0)
self.show_empty_results(False)
search_text = self.searchbar.text() search_text = self.searchbar.text()
# Check for empty search # Check for empty search
if search_text == "": if search_text == "":
@@ -13,6 +16,7 @@ def run_search(self) -> None:
return return
state.posts = [] state.posts = []
self.searchbar.setEnabled(False)
consoleLog(f"User searched for: {search_text}") consoleLog(f"User searched for: {search_text}")
# Get current tracker and call its search function # Get current tracker and call its search function
+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 utils.logging.loghandler import split_data, check_completed, check_downloads
from network.interface import list_interfaces, init_interfaces from network.interface import list_interfaces, init_interfaces
from PySide6.QtNetwork import QLocalServer, QLocalSocket from PySide6.QtNetwork import QLocalServer, QLocalSocket
@@ -8,13 +9,12 @@ from utils.general.shutdown import closehelper
from utils.general.wrappers import run_thread from utils.general.wrappers import run_thread
from interface.assets.base64_icons import logo_base64 from interface.assets.base64_icons import logo_base64
from PySide6.QtWidgets import QSystemTrayIcon, QMenu from PySide6.QtWidgets import QSystemTrayIcon, QMenu
from network.libtorrent_misc import AppDaemons
from utils.general.shutdown import force_exit from utils.general.shutdown import force_exit
from utils.config.config import read_config from utils.config.config import read_config
from PySide6.QtGui import QAction, QPixmap from PySide6.QtGui import QAction, QPixmap
from utils.logging.logs import consoleLog from utils.logging.logs import consoleLog
from interface.gui import MainWindow from interface.gui import MainWindow
from PySide6.QtCore import Qt, QObject, QThread from PySide6.QtCore import Qt, QObject
from utils.data.state import state from utils.data.state import state
from PySide6 import QtWidgets from PySide6 import QtWidgets
import qdarktheme import qdarktheme
@@ -45,6 +45,7 @@ class SingleInstance(QObject):
self.server.listen(SERVER_NAME) self.server.listen(SERVER_NAME)
self.server.newConnection.connect(self.handle_connection) self.server.newConnection.connect(self.handle_connection)
self.is_running = False self.is_running = False
self.is_running = False
def handle_connection(self): def handle_connection(self):
socket = self.server.nextPendingConnection() socket = self.server.nextPendingConnection()
@@ -142,12 +143,9 @@ def main():
init_interfaces() init_interfaces()
consoleLog(f"Current Bound: {state.bound_interface}") consoleLog(f"Current Bound: {state.bound_interface}")
# Start background daemon threads # Start background daemon threads
app._daemon_thread = QThread() run_thread(threading.Thread(target=send_notification, args=(state.shutdown_event,), daemon=True))
app._daemons = AppDaemons() run_thread(threading.Thread(target=update_log, args=(state.shutdown_event,), daemon=True))
app._daemons.moveToThread(app._daemon_thread) run_thread(threading.Thread(target=check_deleted_files, args=(state.shutdown_event,), daemon=True))
app._daemon_thread.started.connect(app._daemons.start_all)
app.aboutToQuit.connect(app._daemon_thread.quit)
app._daemon_thread.start()
# Start background non-daemon threads # Start background non-daemon threads
run_thread(threading.Thread(target=check_completed, args=(downloads, state.autoresume))) run_thread(threading.Thread(target=check_completed, args=(downloads, state.autoresume)))
run_thread(threading.Thread(target=check_downloads, args=(downloads,))) run_thread(threading.Thread(target=check_downloads, args=(downloads,)))
+45 -51
View File
@@ -1,6 +1,5 @@
from utils.logging.logs import update_download_completed_by_hash from utils.logging.logs import update_download_completed_by_hash
from utils.logging.logs import consoleLog from utils.logging.logs import consoleLog
from PySide6.QtCore import QObject, QTimer
from utils.data.state import state from utils.data.state import state
from plyer import notification from plyer import notification
import libtorrent as lt import libtorrent as lt
@@ -20,28 +19,55 @@ def cleanup_session():
with state.downloads_lock: with state.downloads_lock:
state.active_downloads.clear() state.active_downloads.clear()
class AppDaemons(QObject): def send_notification(shutdown_event):
def __init__(self): notified = set()
super().__init__() 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
self.notified_magnets = set() status = magnetdl.status()
self.updated_magnets = set()
# Create Timers if status.state == lt.torrent_status.seeding and magnet_uri not in notified:
self.deleted_files_timer = QTimer(self) notification.notify(
self.completed_downloads_timer = QTimer(self) 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)
# Connect Timer Signals def update_log(shutdown_event):
self.deleted_files_timer.timeout.connect(self.check_deleted_files) updated = set()
self.completed_downloads_timer.timeout.connect(self.check_completed_downloads) 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
def start_all(self): status = magnetdl.status()
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): 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: try:
with state.downloads_lock: with state.downloads_lock:
items = list(state.active_downloads.items()) items = list(state.active_downloads.items())
@@ -61,36 +87,4 @@ class AppDaemons(QObject):
del state.active_downloads[magnet_uri] del state.active_downloads[magnet_uri]
except Exception as e: except Exception as e:
consoleLog(f"Exception while checking for file deletions: {e}") consoleLog(f"Exception while checking for file deletions: {e}")
time.sleep(5)
def check_completed_downloads(self):
try:
with state.downloads_lock:
items = list(state.active_downloads.items())
seeded = set(state.seeded_magnets)
for magnet_uri, magnetdl in items:
if isinstance(magnetdl, dict):
continue
status = magnetdl.status()
if status.state == lt.torrent_status.seeding:
if magnet_uri not in self.updated_magnets and magnet_uri not in seeded:
consoleLog(f"Marking {status.name} as completed")
if hasattr(status, 'info_hashes'):
info_hash = str(status.info_hashes.v1)
else:
info_hash = str(status.info_hash)
update_download_completed_by_hash(info_hash, True)
self.updated_magnets.add(magnet_uri)
if magnet_uri not in self.notified_magnets:
notification.notify(
title="Download finished",
message=f"{status.name} has finished downloading.",
timeout=4
)
self.notified_magnets.add(magnet_uri)
except Exception as e:
consoleLog(f"Exception while checking downloads: {e}")
-1
View File
@@ -61,7 +61,6 @@ class AppState(QObject):
self.max_connections: int = 200 self.max_connections: int = 200
self.max_downloads: int = 10 self.max_downloads: int = 10
self.downloads_lock = threading.RLock() self.downloads_lock = threading.RLock()
self._log_lock = threading.Lock()
@property @property
def image_path(self) -> str: def image_path(self) -> str:
+14 -14
View File
@@ -1,5 +1,4 @@
from utils.data.models import Download, DownloadList from utils.data.models import Download, DownloadList
from PySide6.QtCore import QObject, Signal
from utils.data.state import state from utils.data.state import state
from dataclasses import asdict from dataclasses import asdict
from datetime import datetime from datetime import datetime
@@ -8,10 +7,6 @@ import time
import os import os
import re import re
class LogSignal(QObject):
new_log = Signal(str)
log_emitter = LogSignal()
def _downloads_file_path() -> str: def _downloads_file_path() -> str:
return os.path.join(state.settings_path, "downloads.json") 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): def set_main_window(window):
state.main_window = window state.main_window = window
def flush_log_buffer(): def flush_log_buffer(): # credits to claude
with state._log_lock: if state.log_buffer:
for text in state.log_buffer: try:
log_emitter.new_log.emit(text) from interface.gui import MainWindow
for log_entry in state.log_buffer:
state.log_buffer.clear() 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): def consoleLog(text, printAnyways = False):
now = datetime.now() now = datetime.now()
current_time = now.strftime("%H:%M:%S") current_time = now.strftime("%H:%M:%S")
formatted_text = f"[{current_time}] {text}" formatted_text = f"[{current_time}] {text}"
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) state.log_buffer.append(formatted_text)
log_emitter.new_log.emit(formatted_text)
if state.debug or printAnyways: if state.debug or printAnyways:
print(formatted_text) print(formatted_text)
+8 -31
View File
@@ -2,12 +2,13 @@ from network.direct_download.handle import DirectDownloadHandle
from utils.general.shutdown import closehelper from utils.general.shutdown import closehelper
from utils.logging.logs import consoleLog from utils.logging.logs import consoleLog
from utils.data.state import state from utils.data.state import state
from PySide6.QtCore import Qt, QTimer, QEventLoop from PySide6.QtCore import Qt
from PySide6 import QtWidgets from PySide6 import QtWidgets
import libtorrent as lt import libtorrent as lt
import subprocess import subprocess
import tempfile import tempfile
import hashlib import hashlib
import time
import sys import sys
import os import os
@@ -41,6 +42,7 @@ def download_update(assets: list):
progress = QtWidgets.QProgressDialog("Downloading update... (0.0 MB/s)", None, 0, 100) progress = QtWidgets.QProgressDialog("Downloading update... (0.0 MB/s)", None, 0, 100)
progress.setWindowTitle("Updating") progress.setWindowTitle("Updating")
progress.setWindowModality(Qt.WindowModality.ApplicationModal) progress.setWindowModality(Qt.WindowModality.ApplicationModal)
progress.setCancelButton(None)
progress.setMinimumDuration(0) progress.setMinimumDuration(0)
progress.setAutoClose(False) progress.setAutoClose(False)
progress.setAutoReset(False) progress.setAutoReset(False)
@@ -53,19 +55,14 @@ def download_update(assets: list):
handle = DirectDownloadHandle(url, filename, tempfile.gettempdir()) handle = DirectDownloadHandle(url, filename, tempfile.gettempdir())
handle.start() handle.start()
loop = QEventLoop() while True:
timer = QTimer() QtWidgets.QApplication.processEvents()
timer.setInterval(100)
def poll_progress():
status = handle.status() status = handle.status()
if status.error: if status.error:
state.down_speed_limit = original_limit state.down_speed_limit = original_limit
progress.close() progress.close()
consoleLog(f"Update download failed: {status.error}") consoleLog(f"Update download failed: {status.error}")
timer.stop()
loop.quit()
return return
if status.total_wanted > 0: if status.total_wanted > 0:
@@ -75,25 +72,9 @@ def download_update(assets: list):
progress.setLabelText(f"Downloading update... ({speed_mb:.1f} MB/s)") progress.setLabelText(f"Downloading update... ({speed_mb:.1f} MB/s)")
if status.state == lt.torrent_status.seeding: if status.state == lt.torrent_status.seeding:
timer.stop() break
loop.quit()
if progress.wasCanceled(): time.sleep(0.1)
state.down_speed_limit = original_limit
consoleLog("Update download cancelled by user.")
timer.stop()
loop.quit()
return
timer.timeout.connect(poll_progress)
timer.start()
loop.exec()
if progress.wasCanceled():
return
if handle.status().error:
return
state.down_speed_limit = original_limit state.down_speed_limit = original_limit
@@ -103,11 +84,6 @@ def download_update(assets: list):
else: else:
progress.close() progress.close()
consoleLog("Error: Invalid file hash, file may be corrupted") consoleLog("Error: Invalid file hash, file may be corrupted")
QtWidgets.QMessageBox.critical(
None,
"Update Failed",
"Error: Invalid file hash, file may be corrupted"
)
sys.exit(0) sys.exit(0)
else: else:
consoleLog("Skipping hash verification (no hash found for release)") consoleLog("Skipping hash verification (no hash found for release)")
@@ -118,4 +94,5 @@ def download_update(assets: list):
closehelper() closehelper()
subprocess.Popen([installer_path, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/SP-", "/CLOSEAPPLICATIONS"]) subprocess.Popen([installer_path, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/SP-", "/CLOSEAPPLICATIONS"])
time.sleep(1)
os._exit(0) os._exit(0)