Merge pull request #41 from KeksPirates/merge_candidate_noschxl

Merge candidate noschxl
This commit is contained in:
shayaa
2026-03-10 18:42:23 +01:00
committed by GitHub
27 changed files with 1408 additions and 484 deletions
+19 -2
View File
@@ -1,4 +1,7 @@
from core.utils.data.state import state
from core.utils.data.tracker import get_magnet_link
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from typing import Dict
import requests import requests
import time import time
@@ -31,7 +34,7 @@ def _get_telegram_posts():
for bubble in bubbles: for bubble in bubbles:
post_txt = bubble.find("div", class_="tgme_widget_message_text js-message_text") post_txt = bubble.find("div", class_="tgme_widget_message_text js-message_text")
if not post_txt or not post_txt.b: if post_txt is None or post_txt.b is None:
continue continue
title = post_txt.b.text title = post_txt.b.text
@@ -42,9 +45,9 @@ def _get_telegram_posts():
if post_url not in added: if post_url not in added:
added.add(post_url) added.add(post_url)
posts.append(dict( posts.append(dict(
title=title,
author="m0nkrus", author="m0nkrus",
id=len(posts) + 1, id=len(posts) + 1,
title=title,
url=post_url url=post_url
)) ))
@@ -68,3 +71,17 @@ def scrape_m0nkrus(query):
filtered_posts.append(filtered_post) filtered_posts.append(filtered_post)
return filtered_posts return filtered_posts
def get_magnet(post: Dict):
return get_magnet_link(post["url"])
Metadata = {
"name" : "m0nkrus",
"headers" : ["Post Title", "Author"],
"scrapeFunc" : scrape_m0nkrus,
"linkFunc" : get_magnet,
"isMagnet" : True,
}
def init_m0nkrus():
state.trackers.update({Metadata["name"] : Metadata})
@@ -0,0 +1,18 @@
import requests
def scrape_buzzheavier(url):
response = requests.get(url)
response.raise_for_status()
download_url = url + '/download'
headers = {
'hx-current-url': url,
'hx-request': 'true',
'referer': url
}
head_response = requests.head(download_url, headers=headers, allow_redirects=False)
hx_redirect = head_response.headers.get('hx-redirect')
return hx_redirect
+31
View File
@@ -0,0 +1,31 @@
from core.utils.logging.loghandler import consoleLog
import requests
import re
def scrape_gofile(url):
filetoken = re.findall(r"(?<=https...gofile.io\/d\/).*", url)[0]
session = requests.Session()
acc = session.post("https://api.gofile.io/accounts")
token = acc.json()["data"]["token"]
headers = {
"Authorization": f"Bearer {token}",
"X-Website-Token": "4fd6sg89d7s6", # Maybe make this dynamic in the future
}
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()]
return temp[child[0]]["link"]
except:
consoleLog("gofile didnt auth you, either the api has changed\n or you sent to many requests, please try again later.\n If it still doesnt work please open an issue on Github.")
+41 -8
View File
@@ -1,19 +1,52 @@
import requests import requests
from core.utils.data.state import state from core.utils.data.state import state
from core.utils.logging.logs import consoleLog from core.utils.logging.logs import consoleLog
from core.utils.data.tracker import get_magnet_link
from core.utils.network.jsonhandler import split_data, format_data
from typing import Dict
def scrape_rutracker(query): def scrape_rutracker(query):
search = requests.get(f"{state.api_url}/search?q={query}") search = requests.get(f"{state.api_url}/search?q={query}", timeout=15)
consoleLog("Sent request to server") consoleLog("Sent request to server")
if search: if search:
try: _, data, _, success, cached = split_data(search.text)
return search.text if cached:
except Exception: consoleLog("Server response cached")
consoleLog("No results found / No response from server") if success:
return None sorted_data = []
for entry in data:
sorted_data.append(
{
"title" : entry["title"],
"author" : entry["author"],
"seeders" : entry["seeders"],
"leechers" : entry["leechers"],
"url" : entry["url"],
"id" : entry["id"],
}
)
return sorted_data
else:
consoleLog("Scraping failed server-side, unable to fetch posts from rutracker")
return []
else: else:
return None consoleLog("No response from server, returning nothing")
return []
def get_magnet(post: Dict):
_, post_links, _, _, _ = format_data([post])
return get_magnet_link(post_links[0])
# This function utilizes the SoftwareManager server - source code can be found under the SoftwareManager-Server repository. Metadata = {
"name" : "rutracker",
"headers" : ["Post Title", "Author", "Seeders", "Leechers"],
"scrapeFunc" : scrape_rutracker,
"linkFunc" : get_magnet,
"isMagnet" : True,
}
def init_rutracker():
state.trackers.update({Metadata["name"] : Metadata})
+123
View File
@@ -0,0 +1,123 @@
from core.data.scrapers.provider.buzzheavier import scrape_buzzheavier
from core.data.scrapers.provider.gofile import scrape_gofile
from core.utils.logging.logs import consoleLog
from core.utils.data.state import state
from bs4 import BeautifulSoup
from typing import Dict
import webbrowser
import requests
import re
import time
cache = {
"data": [],
"last_fetched": 0
}
cache_expiry = 300
def get_Metadata():
return Metadata
def scrape_steamrip_links():
current_time = time.time()
if cache["data"] != [] and (current_time - cache["last_fetched"] < cache_expiry):
return cache["data"]
url = f"https://steamrip.com/games-list-page/"
response = requests.get(url)
text = response.text
soup = BeautifulSoup(text, "html.parser")
games = soup.find_all("li", class_="az-list-item")
if len(games) == 0:
return []
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]]
ret = []
for i in range(len(names)):
ret.append({"title" : names[i], "url" : links[i]})
return ret
def scrape_steamrip_game_downloads(gamelink):
url = "https://steamrip.com" + gamelink
response = requests.get(url)
soup = BeautifulSoup(response.text, "html.parser")
download_link_elements = soup.find_all("a",class_="shortc-button")
download_links = ["buzzheavier", "gofile"]
for download_link in download_link_elements:
pure = download_link.attrs.get("href")
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:
ret.append(link)
cache["data"] = ret
return ret
def get_download_link(post: Dict):
url = post["url"]
links = scrape_steamrip_game_downloads(url)
best = ""
for link in links:
if link[0] == "h":
best = link
break
if links.index(best) == 0:
return scrape_buzzheavier(best)
elif links.index(best) == 1:
return scrape_gofile(best)
else:
consoleLog("Unable to retrieve download link due to captcha, launching browser...")
webbrowser.open(best)
return None
def filter_steamrip(query: str):
games = scrape_steamrip_links()
filtered_games = [
game for game in games
if query.lower() in game["title"].lower()
]
return filtered_games
Metadata = {
"name" : "steamrip",
"headers" : ["Game"],
"scrapeFunc" : filter_steamrip,
"linkFunc" : get_download_link,
"isMagnet" : False,
}
def init_steamrip():
state.trackers.update({Metadata["name"] : Metadata})
+19 -1
View File
@@ -2,6 +2,10 @@ import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from urllib.parse import urljoin from urllib.parse import urljoin
from core.utils.logging.logs import consoleLog from core.utils.logging.logs import consoleLog
from core.utils.data.state import state
from core.utils.data.tracker import get_magnet_link
from typing import Dict
def scrape_uztracker(query): def scrape_uztracker(query):
base_url="https://uztracker.net/" base_url="https://uztracker.net/"
@@ -26,8 +30,8 @@ def scrape_uztracker(query):
posts.append(dict( posts.append(dict(
title=title, title=title,
author=author,
url=url, url=url,
author=author
)) ))
return posts return posts
@@ -35,3 +39,17 @@ def scrape_uztracker(query):
except requests.RequestException as e: except requests.RequestException as e:
consoleLog(f"Failed to fetch {search_url}: {e}") consoleLog(f"Failed to fetch {search_url}: {e}")
return None return None
def get_magnet(post: Dict):
return get_magnet_link(post["url"])
Metadata = {
"name" : "uztracker",
"headers" : ["Post Title", "Author"],
"scrapeFunc" : scrape_uztracker,
"linkFunc" : get_magnet,
"isMagnet" : True,
}
def init_uztracker():
state.trackers.update({Metadata["name"] : Metadata})
-1
View File
@@ -54,7 +54,6 @@ def settings_dialog(self):
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))
update_checkbox_layout.addWidget(update_checkbox) update_checkbox_layout.addWidget(update_checkbox)
dialog.layout().addWidget(update_checkbox_container)
# auto-resume downloads checkbox # auto-resume downloads checkbox
+345 -266
View File
@@ -1,34 +1,3 @@
from PySide6 import QtWidgets
from PySide6.QtCore import QPersistentModelIndex, Qt, QTimer, QModelIndex, QAbstractTableModel, Signal, QEvent, QSize, QByteArray
from PySide6.QtWidgets import (
QLineEdit,
QTableView,
QWidget,
QVBoxLayout,
QListWidget,
QLabel,
QHBoxLayout,
QComboBox,
QTabWidget,
QHeaderView,
QMessageBox,
QTableWidget,
QTextEdit,
QStyledItemDelegate,
QStatusBar
)
from PySide6.QtGui import QIcon, QCloseEvent, QImage, QPixmap, QContextMenuEvent, QGuiApplication, QColor, QBrush
import darkdetect
import threading
import platform
import requests as r
import os
import subprocess
import libtorrent as lt
import sys
import json
import base64
from core.utils.logging.logs import consoleLog, remove_download_log, flush_log_buffer from core.utils.logging.logs import consoleLog, remove_download_log, flush_log_buffer
from core.utils.general.wrappers import run_thread from core.utils.general.wrappers import run_thread
from core.utils.data.state import state from core.utils.data.state import state
@@ -43,6 +12,57 @@ from core.interface.assets.base64_icons import settings_white_base64
from core.interface.assets.base64_icons import logo_base64 from core.interface.assets.base64_icons import logo_base64
from core.utils.general.shutdown import closehelper from core.utils.general.shutdown import closehelper
from PySide6 import QtWidgets
from PySide6.QtCore import (
Qt,
QTimer,
QModelIndex,
QAbstractTableModel,
Signal,
QEvent,
QSize
)
from PySide6.QtWidgets import (
QLineEdit,
QTableView,
QWidget,
QVBoxLayout,
QListWidget,
QLabel,
QHBoxLayout,
QComboBox,
QTabWidget,
QHeaderView,
QMessageBox,
QTableWidget,
QTableWidgetItem,
QTextEdit,
QStyledItemDelegate,
QStatusBar
)
from PySide6.QtGui import (
QIcon,
QCloseEvent,
QImage,
QPixmap,
QContextMenuEvent,
QGuiApplication,
QColor,
)
import darkdetect
import threading
import platform
import requests as r
import os
import subprocess
import libtorrent as lt
import sys
import json
import base64
def _is_dark_mode(): def _is_dark_mode():
return darkdetect.isDark() return darkdetect.isDark()
@@ -121,21 +141,19 @@ SVG_PLAY = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><polygon
SVG_PAUSE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect x="5" y="3" width="4" height="18" rx="1" fill="{color}"/><rect x="15" y="3" width="4" height="18" rx="1" fill="{color}"/></svg>' SVG_PAUSE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect x="5" y="3" width="4" height="18" rx="1" fill="{color}"/><rect x="15" y="3" width="4" height="18" rx="1" fill="{color}"/></svg>'
SVG_FOLDER = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M2 6c0-1.1.9-2 2-2h5l2 2h7c1.1 0 2 .9 2 2v10c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6z" fill="{color}"/></svg>' SVG_FOLDER = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M2 6c0-1.1.9-2 2-2h5l2 2h7c1.1 0 2 .9 2 2v10c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6z" fill="{color}"/></svg>'
def _verify_hash(file_path, expected_hash): def _verify_hash(file_path, expected_hash):
import hashlib import hashlib
sha256 = hashlib.sha256() sha256 = hashlib.sha256()
with open(file_path, "rb") as f: with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""): for chunk in iter(lambda: f.read(8192), b""):
sha256.update(chunk) sha256.update(chunk)
return f"sha256:{sha256.hexdigest()}" == expected_hash return f"sha256:{sha256.hexdigest()}" == expected_hash
def _download_update(assets): def _download_update(assets):
import tempfile import tempfile
import time import time
for asset in assets: for asset in assets:
if "-windows-setup.exe" in asset["name"]: if "-windows-setup.exe" in asset["name"]:
filename = asset["name"] filename = asset["name"]
@@ -148,7 +166,6 @@ def _download_update(assets):
return return
installer_path = os.path.join(tempfile.gettempdir(), filename) installer_path = os.path.join(tempfile.gettempdir(), filename)
progress = QtWidgets.QProgressDialog("Downloading installer...", None, 0, 0) progress = QtWidgets.QProgressDialog("Downloading installer...", None, 0, 0)
progress.setWindowTitle("Updating") progress.setWindowTitle("Updating")
progress.setWindowModality(Qt.WindowModality.ApplicationModal) progress.setWindowModality(Qt.WindowModality.ApplicationModal)
@@ -160,7 +177,6 @@ def _download_update(assets):
progress.setValue(0) progress.setValue(0)
progress.show() progress.show()
QtWidgets.QApplication.processEvents() QtWidgets.QApplication.processEvents()
response = r.get(url, allow_redirects=True, stream=True) response = r.get(url, allow_redirects=True, stream=True)
total = int(response.headers.get("content-length", 0)) total = int(response.headers.get("content-length", 0))
downloaded = 0 downloaded = 0
@@ -171,21 +187,20 @@ def _download_update(assets):
if total > 0: if total > 0:
progress.setValue(int(downloaded * 100 / total)) progress.setValue(int(downloaded * 100 / total))
QtWidgets.QApplication.processEvents() QtWidgets.QApplication.processEvents()
if not os.path.exists(installer_path): if not os.path.exists(installer_path):
progress.close() progress.close()
raise FileNotFoundError("Executable not found") raise FileNotFoundError("Executable not found")
if setup_hash:
if _verify_hash(installer_path, setup_hash): if _verify_hash(installer_path, setup_hash):
consoleLog(f"Sucessfully validated installer hash ({setup_hash})") consoleLog(f"Sucessfully validated installer hash ({setup_hash})")
else:
consoleLog("Error: Invalid Filehash, file may be corrupted")
sys.exit(0)
else: else:
consoleLog("Error: Invalid Filehash, file may be corrupted") consoleLog("Skipping Hash Verification (no hash found for release)")
sys.exit(0)
progress.setLabelText("Installing update...") progress.setLabelText("Installing update...")
progress.setValue(100) progress.setValue(100)
QtWidgets.QApplication.processEvents() QtWidgets.QApplication.processEvents()
subprocess.Popen([installer_path, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/SP-", "/CLOSEAPPLICATIONS"]) subprocess.Popen([installer_path, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/SP-", "/CLOSEAPPLICATIONS"])
time.sleep(1) time.sleep(1)
sys.exit(0) sys.exit(0)
@@ -194,15 +209,50 @@ def _download_update(assets):
def windowCloseHelper(): def windowCloseHelper():
QGuiApplication.quit() QGuiApplication.quit()
class ElidedItemDelegate(QStyledItemDelegate):
def __init__(self, get_hovered_row, parent=None):
super().__init__(parent)
self._get_hovered_row = get_hovered_row
def initStyleOption(self, option, index):
super().initStyleOption(option, index)
if option.text:
metrics = option.fontMetrics
text_rect = option.rect.adjusted(14, 0, -14, 0)
option.text = metrics.elidedText(option.text, Qt.TextElideMode.ElideMiddle, text_rect.width())
def paint(self, painter, option, index):
if index.row() == self._get_hovered_row():
painter.save()
painter.fillRect(option.rect, _theme_colors()["hover"])
painter.restore()
super().paint(painter, option, index)
class TrackerHoverDelegate(QStyledItemDelegate):
def __init__(self, get_hovered_row, parent=None):
super().__init__(parent)
self._get_hovered_row = get_hovered_row
def paint(self, painter, option, index):
if index.row() == self._get_hovered_row():
painter.save()
painter.fillRect(option.rect, _theme_colors()["hover"])
painter.restore()
super().paint(painter, option, index)
class MainWindow(QtWidgets.QMainWindow, QWidget): class MainWindow(QtWidgets.QMainWindow, QWidget):
log_signal = Signal(str) # Thread-safe signal for logging log_signal = Signal(str)
search_finished_signal = Signal() # Thread-safe signal for search completion search_results_signal = Signal(list)
_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.log_signal.connect(self._on_log_signal)
self.search_finished_signal.connect(self._on_search_finished) self.search_results_signal.connect(self._on_search_results)
pixmap = QPixmap() pixmap = QPixmap()
image_data = base64.b64decode(logo_base64) image_data = base64.b64decode(logo_base64)
@@ -223,18 +273,15 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
build_info = json.load(f) build_info = json.load(f)
state.version = build_info.get("version") state.version = build_info.get("version")
# Check for updates on Windows
if state.ignore_updates is False and platform.system() == "Windows": if state.ignore_updates is False and platform.system() == "Windows":
assets = get_updates() assets = get_updates()
if assets != None: if assets != None:
msg = QMessageBox() msg = QMessageBox()
msg.setIcon(QMessageBox.Icon.Information) msg.setIcon(QMessageBox.Icon.Information)
msg.setWindowTitle("Update Available") msg.setWindowTitle("Update Available")
msg.setText("A new version is available.") msg.setText("A new version is available.")
msg.setInformativeText("Press Ok to download the update.") msg.setInformativeText("Press Ok to download the update.")
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Ignore) msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Ignore)
response = msg.exec_() response = msg.exec_()
if response == QMessageBox.StandardButton.Ok: if response == QMessageBox.StandardButton.Ok:
_download_update(assets) _download_update(assets)
@@ -245,7 +292,6 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
self.controls = QWidget() self.controls = QWidget()
self.controlsLayout = QVBoxLayout() self.controlsLayout = QVBoxLayout()
# Widgets
self.searchbar = QLineEdit() self.searchbar = QLineEdit()
self.searchbar.setPlaceholderText("Search for software...") self.searchbar.setPlaceholderText("Search for software...")
self.searchbar.setClearButtonEnabled(True) self.searchbar.setClearButtonEnabled(True)
@@ -266,6 +312,7 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
self._hovered_row = -1 self._hovered_row = -1
self._tracker_hovered_row = -1 self._tracker_hovered_row = -1
self._tracker_hovered_table = None self._tracker_hovered_table = None
self._last_dl_row_count = 0
self.downloadList.viewport().installEventFilter(self) self.downloadList.viewport().installEventFilter(self)
self.emptyLibrary = QLabel("No items in library.") self.emptyLibrary = QLabel("No items in library.")
self.emptyDownload = QLabel("No items in downloads.") self.emptyDownload = QLabel("No items in downloads.")
@@ -276,109 +323,64 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
flush_log_buffer() flush_log_buffer()
class TrackerHoverDelegate(QStyledItemDelegate): self._tracker_elided_delegate = ElidedItemDelegate(lambda: self._tracker_hovered_row, self)
def paint(self, painter, option, index): self._tracker_hover_delegate = TrackerHoverDelegate(lambda: self._tracker_hovered_row, self)
if index.row() == MainWindow._instance._tracker_hovered_row:
painter.save()
painter.fillRect(option.rect, _theme_colors()["hover"])
painter.restore()
super().paint(painter, option, index)
self._tracker_hover_delegate = TrackerHoverDelegate(self) state.trackertable = self._create_tracker_table()
def create_tracker_table(headers):
table = QTableWidget()
table.setColumnCount(len(headers))
table.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers)
table.verticalHeader().setVisible(False)
table.setHorizontalHeaderLabels(headers)
table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows)
table.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection)
header = table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
header.resizeSection(1, 500)
header.setStretchLastSection(False)
header.setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
header.setHighlightSections(False)
table.setShowGrid(False)
table.setStyleSheet(_table_stylesheet("QTableWidget"))
table.setMouseTracking(True)
table.viewport().setMouseTracking(True)
table.setItemDelegate(self._tracker_hover_delegate)
return table
self.rutrackerlist = create_tracker_table(["Post Title", "Author", "Seeders", "Leechers"])
self.uztrackerlist = create_tracker_table(["Post Title", "Author"])
self.monkruslist = create_tracker_table(["Post Title", "Author"])
state.tracker_list.update({"rutracker": self.rutrackerlist, "uztracker": self.uztrackerlist, "m0nkrus": self.monkruslist})
self.rutrackerlist.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.rutrackerlist.viewport().setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.uztrackerlist.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.uztrackerlist.viewport().setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.monkruslist.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.monkruslist.viewport().setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
for tbl in (self.rutrackerlist, self.uztrackerlist, self.monkruslist):
tbl.viewport().installEventFilter(self)
container = QWidget() container = QWidget()
containerLayout = QVBoxLayout() containerLayout = QVBoxLayout()
containerLayout.addWidget(self.searchbar)
search_row = QHBoxLayout() containerLayout.addWidget(state.trackertable)
search_row.addWidget(self.searchbar)
containerLayout.addLayout(search_row)
containerLayout.addWidget(state.tracker_list[state.tracker])
class DownloadModel(QAbstractTableModel): class DownloadModel(QAbstractTableModel):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.headers = ["Action", "Name", "Status", "Progress", "Speed", "Size", "Total Size"] self.headers = ["Action", "Name", "Status", "Progress", "Speed", "Size", "Total Size"]
def rowCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int: def rowCount(self, parent=QModelIndex()):
return len(state.active_downloads) if parent.isValid():
return 0
with state.downloads_lock:
return len(state.active_downloads)
def columnCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()): def columnCount(self, parent=QModelIndex()):
return len(self.headers) return len(self.headers)
def headerData(self, section, orientation, role: int = Qt.ItemDataRole.DisplayRole): def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal: if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
return self.headers[section] return self.headers[section]
return None return None
def data(self, index: QModelIndex | QPersistentModelIndex, role: int = Qt.ItemDataRole.DisplayRole): def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if role == Qt.ItemDataRole.DisplayRole: if not index.isValid():
col = index.column() return None
with state.downloads_lock:
if index.row() >= len(state.active_downloads) or index.row() < 0: if index.row() >= len(state.active_downloads) or index.row() < 0:
return None return None
magnet_link = list(state.active_downloads.keys())[index.row()] try:
magnetdl = state.active_downloads[magnet_link] magnet_link = list(state.active_downloads.keys())[index.row()]
magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status() status = magnetdl.status()
except (IndexError, KeyError, RuntimeError):
return None
if role == Qt.ItemDataRole.DisplayRole:
col = index.column()
if col == 0: if col == 0:
pass pass
elif col == 1: elif col == 1:
return status.name if status.has_metadata else "Fetching metadata..." return status.name if status.has_metadata else "Fetching metadata..."
elif col == 2: elif col == 2:
if status.state == lt.torrent_status.downloading: if status.paused:
is_auto = getattr(status, 'auto_managed', True)
return "Queued" if is_auto else "Paused"
elif status.state == lt.torrent_status.downloading:
return "Downloading" return "Downloading"
elif status.state == lt.torrent_status.seeding: elif status.state == lt.torrent_status.seeding:
return "Seeding" return "Seeding"
elif status.paused:
is_auto = bool(magnetdl.flags() & lt.torrent_flags.auto_managed)
return "Queued" if is_auto else "Paused"
else: else:
return "Queued" return "Queued"
elif col == 3: elif col == 3:
@@ -405,7 +407,6 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
if status.download_rate > 0: if status.download_rate > 0:
bytes_left = status.total_wanted - status.total_wanted_done bytes_left = status.total_wanted - status.total_wanted_done
eta_seconds = bytes_left / status.download_rate eta_seconds = bytes_left / status.download_rate
if eta_seconds < 60: if eta_seconds < 60:
return f"{int(eta_seconds)}s" return f"{int(eta_seconds)}s"
elif eta_seconds < 3600: elif eta_seconds < 3600:
@@ -418,44 +419,46 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
return f"{hours}h {minutes}m" return f"{hours}h {minutes}m"
else: else:
return "" if status.paused else "Stalled" return "" if status.paused else "Stalled"
if role == Qt.ItemDataRole.UserRole and index.column() == 0: if role == Qt.ItemDataRole.UserRole and index.column() == 0:
magnet_link = list(state.active_downloads.keys())[index.row()] return status.paused
magnetdl = state.active_downloads[magnet_link]
return magnetdl.status().paused
return None return None
def toggle_pause_resume(self, row): def toggle_pause_resume(self, row):
with state.downloads_lock:
if row >= len(state.active_downloads) or row < 0: if row >= len(state.active_downloads) or row < 0:
return return
try:
magnet_link = list(state.active_downloads.keys())[row] magnet_link = list(state.active_downloads.keys())[row]
magnetdl = state.active_downloads[magnet_link] magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status() status = magnetdl.status()
except (IndexError, KeyError, RuntimeError):
return
if status.state == lt.torrent_status.seeding: if status.state == lt.torrent_status.seeding:
save_path = magnetdl.save_path() try:
if save_path and os.path.exists(save_path): save_path = magnetdl.save_path()
if platform.system() == "Windows": if save_path and os.path.exists(save_path):
os.startfile(os.path.normpath(save_path)) if platform.system() == "Windows":
elif platform.system() == "Linux": os.startfile(os.path.normpath(save_path))
subprocess.Popen(["xdg-open", save_path]) elif platform.system() == "Linux":
elif platform.system() == "Darwin": subprocess.Popen(["xdg-open", save_path])
subprocess.Popen(["open", save_path]) elif platform.system() == "Darwin":
subprocess.Popen(["open", save_path])
except Exception:
pass
return return
is_paused = bool(magnetdl.flags() & lt.torrent_flags.paused) is_paused = status.paused
if is_paused: if is_paused:
magnetdl.set_flags(lt.torrent_flags.auto_managed) if hasattr(magnetdl, 'set_flags'):
magnetdl.set_flags(lt.torrent_flags.auto_managed)
magnetdl.resume() magnetdl.resume()
consoleLog(f"Resumed download: {status.name}", True) consoleLog(f"Resumed download: {status.name}", True)
else: else:
magnetdl.unset_flags(lt.torrent_flags.auto_managed) if hasattr(magnetdl, 'unset_flags'):
magnetdl.unset_flags(lt.torrent_flags.auto_managed)
magnetdl.pause() magnetdl.pause()
consoleLog(f"Paused download: {status.name}", True) consoleLog(f"Paused download: {status.name}", True)
idx = self.index(row, 0) idx = self.index(row, 0)
self.dataChanged.emit(idx, idx, [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.UserRole]) self.dataChanged.emit(idx, idx, [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.UserRole])
@@ -485,17 +488,15 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
def setEditorData(self, editor, index): def setEditorData(self, editor, index):
button = editor.findChild(QtWidgets.QPushButton) button = editor.findChild(QtWidgets.QPushButton)
magnet_link = list(state.active_downloads.keys())[index.row()] magnet_link = list(state.active_downloads.keys())[index.row()]
magnetdl = state.active_downloads[magnet_link] magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status() status = magnetdl.status()
if button: if button:
if status.state == lt.torrent_status.seeding: if status.state == lt.torrent_status.seeding:
button.setIcon(svg_icon(SVG_FOLDER, 18)) button.setIcon(svg_icon(SVG_FOLDER, 18))
button.setText("") button.setText("")
else: else:
is_user_paused = status.paused and not bool(magnetdl.flags() & lt.torrent_flags.auto_managed) is_user_paused = status.paused and not status.auto_managed
button.setIcon(svg_icon(SVG_PLAY if is_user_paused else SVG_PAUSE, 18)) button.setIcon(svg_icon(SVG_PLAY if is_user_paused else SVG_PAUSE, 18))
button.setText("") button.setText("")
@@ -503,7 +504,6 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
magnet_link = list(state.active_downloads.keys())[index.row()] magnet_link = list(state.active_downloads.keys())[index.row()]
magnetdl = state.active_downloads[magnet_link] magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status() status = magnetdl.status()
widget = QWidget(parent) widget = QWidget(parent)
widget.setStyleSheet("border: none; background: transparent;") widget.setStyleSheet("border: none; background: transparent;")
layout = QHBoxLayout(widget) layout = QHBoxLayout(widget)
@@ -513,24 +513,19 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
btnPause.setIcon(svg_icon(SVG_FOLDER, 18)) btnPause.setIcon(svg_icon(SVG_FOLDER, 18))
else: else:
btnPause = QtWidgets.QPushButton() btnPause = QtWidgets.QPushButton()
is_user_paused = status.paused and not bool(magnetdl.flags() & lt.torrent_flags.auto_managed) is_user_paused = status.paused and not status.auto_managed
btnPause.setIcon(svg_icon(SVG_PLAY if is_user_paused else SVG_PAUSE, 18)) btnPause.setIcon(svg_icon(SVG_PLAY if is_user_paused else SVG_PAUSE, 18))
btnPause.setIconSize(QSize(18, 18)) btnPause.setIconSize(QSize(18, 18))
btnPause.setFixedSize(30, 30) btnPause.setFixedSize(30, 30)
btnPause.setCursor(Qt.CursorShape.PointingHandCursor) btnPause.setCursor(Qt.CursorShape.PointingHandCursor)
btnPause.setStyleSheet(""" btnPause.setStyleSheet("QPushButton { border: none; background: transparent; padding: 0px; }")
QPushButton {
border: none;
background: transparent;
padding: 0px;
}
""")
btnPause.clicked.connect(lambda: self.clicked.emit(index.row())) btnPause.clicked.connect(lambda: self.clicked.emit(index.row()))
layout.addStretch() layout.addStretch()
layout.addWidget(btnPause) layout.addWidget(btnPause)
layout.addStretch() layout.addStretch()
widget.setLayout(layout) widget.setLayout(layout)
return widget return widget
def editorEvent(self, event, model, option, index): def editorEvent(self, event, model, option, index):
return False return False
@@ -571,13 +566,11 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
self.downloadList.setItemDelegateForColumn(0, delegate) self.downloadList.setItemDelegateForColumn(0, delegate)
delegate.clicked.connect(on_pause_resume_clicked) delegate.clicked.connect(on_pause_resume_clicked)
# download button triggers self.dlbutton.clicked.connect(lambda: run_thread(threading.Thread(target=download_selected, args=(state.trackertable.selectedItems(),))))
self.dlbutton.clicked.connect(lambda: run_thread(threading.Thread(target=download_selected, args=(state.tracker_list[state.tracker].selectedItems(), state.posts, state.post_titles))))
container.setLayout(containerLayout) container.setLayout(containerLayout)
self.setCentralWidget(container) self.setCentralWidget(container)
# Tabs
self.tabs = QTabWidget() self.tabs = QTabWidget()
self.tabs.setStyleSheet(""" self.tabs.setStyleSheet("""
QTabBar::tab { QTabBar::tab {
@@ -592,11 +585,9 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
self.horizontal_layout = QHBoxLayout() self.horizontal_layout = QHBoxLayout()
self.horizontal_layout.addWidget(self.emptyResults, stretch=3) self.horizontal_layout.addWidget(self.emptyResults, stretch=3)
self.tracker_widget = state.tracker_list[state.tracker] self.horizontal_layout.addWidget(state.trackertable)
self.horizontal_layout.addWidget(self.tracker_widget)
self.tab1 = create_tab("Search", self.searchbar, state.tracker_list[state.tracker], self.tabs, self.dlbutton, self.horizontal_layout) self.tab1 = create_tab("Search", self.searchbar, state.trackertable, self.tabs, self.dlbutton, self.horizontal_layout)
# self.tab2 = create_tab("Library", self.emptyLibrary, self.libraryList, self.tabs, None, None)
self.tab3 = create_tab("Downloads", self.emptyDownload, self.downloadList, self.tabs, None, None) self.tab3 = create_tab("Downloads", self.emptyDownload, self.downloadList, self.tabs, None, None)
if state.image_path is not None and os.path.exists(state.image_path): if state.image_path is not None and os.path.exists(state.image_path):
@@ -607,24 +598,16 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
self.overlay_label.setPixmap(self.pixmap) self.overlay_label.setPixmap(self.pixmap)
self.overlay_label.adjustSize() self.overlay_label.adjustSize()
self.overlay_label.raise_() self.overlay_label.raise_()
x = self.width() - self.overlay_label.width()
# offset_x = -1350 y = self.height() - self.overlay_label.height()
# offset_y = -550
x = self.width() - self.overlay_label.width() # - offset_x
y = self.height() - self.overlay_label.height() # - offset_y
self.overlay_label.move(x, y) self.overlay_label.move(x, y)
# temporarily disabled
# state.image_changed.connect(self.update_image_overlay)
self.corner_widget = QWidget() self.corner_widget = QWidget()
self.corner_layout = QHBoxLayout(self.corner_widget) self.corner_layout = QHBoxLayout(self.corner_widget)
self.corner_layout.setContentsMargins(0, 0, 0, 0) self.corner_layout.setContentsMargins(0, 0, 0, 0)
self.tracker_list = QComboBox() self.tracker_list = QComboBox()
self.tracker_list.addItems(["rutracker", "uztracker", "m0nkrus"]) self.tracker_list.addItems(list(state.trackers.keys()))
self.tracker_list.setCursor(Qt.CursorShape.PointingHandCursor) self.tracker_list.setCursor(Qt.CursorShape.PointingHandCursor)
self.tracker_list.activated.connect(self.set_tracker) self.tracker_list.activated.connect(self.set_tracker)
self.corner_layout.addWidget(self.tracker_list) self.corner_layout.addWidget(self.tracker_list)
@@ -647,7 +630,6 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
self.settings_btn.setToolTip("Settings") self.settings_btn.setToolTip("Settings")
self.settings_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.settings_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self.settings_btn.clicked.connect(lambda: settings_dialog(self)) self.settings_btn.clicked.connect(lambda: settings_dialog(self))
self.corner_layout.addWidget(self.settings_btn) self.corner_layout.addWidget(self.settings_btn)
self.tab_wrapper = QWidget() self.tab_wrapper = QWidget()
@@ -655,7 +637,6 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
self.tab_layout.setContentsMargins(0, 0, 0, 0) self.tab_layout.setContentsMargins(0, 0, 0, 0)
self.tab_layout.setSpacing(0) self.tab_layout.setSpacing(0)
self.tab_bar_layout = QHBoxLayout() self.tab_bar_layout = QHBoxLayout()
self.tab_bar_layout.setContentsMargins(0, 0, 0, 0) self.tab_bar_layout.setContentsMargins(0, 0, 0, 0)
self.tab_bar_layout.setSpacing(0) self.tab_bar_layout.setSpacing(0)
@@ -666,11 +647,8 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
self.tab_layout.addLayout(self.tab_bar_layout) self.tab_layout.addLayout(self.tab_bar_layout)
self.tab_layout.addWidget(self.tabs) self.tab_layout.addWidget(self.tabs)
self.tab_wrapper.setLayout(self.tab_layout) self.tab_wrapper.setLayout(self.tab_layout)
containerLayout.addWidget(self.tab_wrapper) containerLayout.addWidget(self.tab_wrapper)
containerLayout.setAlignment(Qt.AlignmentFlag.AlignTop) containerLayout.setAlignment(Qt.AlignmentFlag.AlignTop)
@@ -705,23 +683,86 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
self.context_menu.addAction("Cancel Download", self.cancelDownloadAction) self.context_menu.addAction("Cancel Download", self.cancelDownloadAction)
self.context_menu.addAction("Delete File", self.deleteFileAction) self.context_menu.addAction("Delete File", self.deleteFileAction)
def _create_tracker_table(self):
table = QTableWidget()
table.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers)
table.verticalHeader().setVisible(False)
table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows)
table.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection)
table.setShowGrid(False)
table.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
table.viewport().setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
table.horizontalHeader().setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
table.horizontalHeader().setHighlightSections(False)
table.horizontalHeader().setStretchLastSection(False)
table.setStyleSheet(_table_stylesheet("QTableWidget"))
table.setMouseTracking(True)
table.viewport().setMouseTracking(True)
table.viewport().installEventFilter(self)
table.setItemDelegateForColumn(0, self._tracker_elided_delegate)
self._apply_default_headers(table)
return table
def _apply_default_headers(self, table):
tracker_name = state.currenttracker if hasattr(state, 'currenttracker') and state.currenttracker else None
if tracker_name is None:
keys = list(state.trackers.keys())
tracker_name = keys[0] if keys else None
headers = []
if tracker_name and tracker_name in state.trackers:
tracker_mod = state.trackers[tracker_name]
if hasattr(tracker_mod, 'headers'):
headers = tracker_mod.headers
elif hasattr(tracker_mod, 'HEADERS'):
headers = tracker_mod.HEADERS
if not headers:
if state.currenttracker == "rutracker":
headers = ["Post Title", "Author", "Seeders", "Leechers"]
elif state.currenttracker == "steamrip":
headers = ["Game"]
else:
headers = ["Post Title", "Author"] # i know this makes it less "modular", but its qol
table.setRowCount(0)
col_count = table.columnCount()
if col_count != len(headers):
for i in range(col_count):
table.setItemDelegateForColumn(i, None)
table.setColumnCount(len(headers))
table.setHorizontalHeaderLabels(headers)
self._apply_tracker_column_modes(table, len(headers))
def _apply_tracker_column_modes(self, table, col_count):
header = table.horizontalHeader()
if col_count > 0:
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
table.setItemDelegateForColumn(0, self._tracker_elided_delegate)
for i in range(1, col_count):
header.setSectionResizeMode(i, QHeaderView.ResizeMode.ResizeToContents)
table.setItemDelegateForColumn(i, self._tracker_hover_delegate)
def _start_search(self): def _start_search(self):
self.searchbar.setEnabled(False) self.searchbar.setEnabled(False)
def _search_thread(): def _search_thread():
try: try:
return_pressed(self) return_pressed(self)
finally: except Exception as e:
self.search_finished_signal.emit() consoleLog(f"Search error: {e}", True)
self.search_results_signal.emit([])
run_thread(threading.Thread(target=_search_thread)) run_thread(threading.Thread(target=_search_thread))
def _on_search_finished(self):
self.searchbar.setEnabled(True)
self.searchbar.setFocus()
@staticmethod @staticmethod
def add_log(text): def add_log(text):
if MainWindow._instance: if hasattr(MainWindow, "_instance") and MainWindow._instance:
MainWindow._instance.log_signal.emit(text) 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'):
@@ -730,49 +771,92 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
self.consoleLog.verticalScrollBar().maximum() self.consoleLog.verticalScrollBar().maximum()
) )
def _on_search_results(self, headers):
if state.posts is None or state.posts == []:
self.show_empty_results(True)
self.searchbar.setEnabled(True)
self.searchbar.setFocus()
return
table = state.trackertable
table.setRowCount(0)
col_count = table.columnCount()
need_reconfig = col_count != len(headers)
if need_reconfig:
for i in range(col_count):
table.setItemDelegateForColumn(i, None)
table.setColumnCount(len(headers))
table.setHorizontalHeaderLabels(headers)
self._apply_tracker_column_modes(table, len(headers))
else:
table.setHorizontalHeaderLabels(headers)
table.setRowCount(len(state.posts))
for x, rowdata in enumerate(state.posts):
for y, (key, data) in enumerate(rowdata.items()):
item = QTableWidgetItem(str(data))
item.setData(Qt.ItemDataRole.UserRole, x)
table.setItem(x, y, item)
self.show_empty_results(False)
self.searchbar.setEnabled(True)
self.searchbar.setFocus()
def update_image_overlay(self, new_image_path): def update_image_overlay(self, new_image_path):
self.image = QImage(new_image_path) self.image = QImage(new_image_path)
self.pixmap = QPixmap.fromImage(self.image) self.pixmap = QPixmap.fromImage(self.image)
self.overlay_label.setPixmap(self.pixmap) self.overlay_label.setPixmap(self.pixmap)
self.overlay_label.adjustSize() self.overlay_label.adjustSize()
def download_list_update(self): def download_list_update(self):
if self.download_model: if not self.download_model:
row_count = self.download_model.rowCount() self._update_speed_label()
if row_count > 0: return
top_left = self.download_model.index(0, 0)
bottom_right = self.download_model.index(row_count - 1, self.download_model.columnCount() - 1) row_count = self.download_model.rowCount()
col_count = self.download_model.columnCount()
if row_count > 0 and col_count > 0:
top_left = self.download_model.index(0, 0)
bottom_right = self.download_model.index(row_count - 1, col_count - 1)
if top_left.isValid() and bottom_right.isValid():
self.download_model.dataChanged.emit(top_left, bottom_right, [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.UserRole]) self.download_model.dataChanged.emit(top_left, bottom_right, [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.UserRole])
if not hasattr(self, '_last_dl_row_count'): if row_count != self._last_dl_row_count:
self._last_dl_row_count = 0 old_count = self._last_dl_row_count
if row_count != self._last_dl_row_count: self._last_dl_row_count = row_count
if row_count > 0:
for row in range(row_count, self._last_dl_row_count):
idx = self.download_model.index(row, 0)
self.downloadList.closePersistentEditor(idx)
self._last_dl_row_count = row_count
self.download_model.layoutAboutToBeChanged.emit() self.download_model.layoutAboutToBeChanged.emit()
self.download_model.layoutChanged.emit() self.download_model.layoutChanged.emit()
for row in range(row_count): for row in range(row_count):
idx = self.download_model.index(row, 0) idx = self.download_model.index(row, 0)
self.downloadList.closePersistentEditor(idx) if idx.isValid():
self.downloadList.openPersistentEditor(idx) self.downloadList.closePersistentEditor(idx)
else: self.downloadList.openPersistentEditor(idx)
elif old_count > 0:
self.download_model.layoutAboutToBeChanged.emit()
self.download_model.layoutChanged.emit()
else:
if row_count > 0:
delegate = self.downloadList.itemDelegateForColumn(0) delegate = self.downloadList.itemDelegateForColumn(0)
for row in range(row_count): for row in range(row_count):
idx = self.download_model.index(row, 0) idx = self.download_model.index(row, 0)
editor = self.downloadList.indexWidget(idx) if idx.isValid():
if editor and delegate: editor = self.downloadList.indexWidget(idx)
delegate.setEditorData(editor, idx) if editor and delegate:
delegate.setEditorData(editor, idx)
self._update_speed_label() self._update_speed_label()
def _update_speed_label(self): def _update_speed_label(self):
total_down = 0 total_down = 0
total_up = 0 total_up = 0
for handle in state.active_downloads.values(): with state.downloads_lock:
active_items = list(state.active_downloads.values())
for handle in active_items:
try: try:
s = handle.status() s = handle.status()
total_down += s.download_rate total_down += s.download_rate
@@ -783,13 +867,13 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
up_kb = total_up / 1024 up_kb = total_up / 1024
down_text = f"{down_kb / 1024:.1f} MB/s" if down_kb > 1024 else f"{down_kb:.1f} kB/s" down_text = f"{down_kb / 1024:.1f} MB/s" if down_kb > 1024 else f"{down_kb:.1f} kB/s"
up_text = f"{up_kb / 1024:.1f} MB/s" if up_kb > 1024 else f"{up_kb:.1f} kB/s" up_text = f"{up_kb / 1024:.1f} MB/s" if up_kb > 1024 else f"{up_kb:.1f} kB/s"
self.speed_label.setText(f"{down_text}{up_text}")
if hasattr(self, 'speed_label'):
self.speed_label.setText(f"{down_text}{up_text}")
def mousePressEvent(self, event): def mousePressEvent(self, event):
self.rutrackerlist.clearSelection() state.trackertable.clearSelection()
self.uztrackerlist.clearSelection()
self.monkruslist.clearSelection()
self.downloadList.clearSelection() self.downloadList.clearSelection()
super().mousePressEvent(event) super().mousePressEvent(event)
@@ -804,21 +888,23 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
def eventFilter(self, obj, event): def eventFilter(self, obj, event):
try: try:
for tbl in (self.rutrackerlist, self.uztrackerlist, self.monkruslist): if hasattr(self, 'speed_label') and obj == self.speed_label:
if obj == tbl.viewport(): if event.type() == QEvent.Type.MouseButtonRelease:
if event.type() == QEvent.Type.MouseMove: settings_dialog(self)
pos = event.position().toPoint() if hasattr(event, 'position') else event.pos() return True
idx = tbl.indexAt(pos) if obj == state.trackertable.viewport():
new_row = idx.row() if idx.isValid() else -1 if event.type() == QEvent.Type.MouseMove:
if new_row != self._tracker_hovered_row or tbl is not self._tracker_hovered_table: pos = event.position().toPoint() if hasattr(event, 'position') else event.pos()
self._tracker_hovered_row = new_row idx = state.trackertable.indexAt(pos)
self._tracker_hovered_table = tbl new_row = idx.row() if idx.isValid() else -1
tbl.viewport().update() if new_row != self._tracker_hovered_row or state.trackertable is not self._tracker_hovered_table:
elif event.type() == QEvent.Type.Leave: self._tracker_hovered_row = new_row
self._tracker_hovered_row = -1 self._tracker_hovered_table = state.trackertable
self._tracker_hovered_table = None state.trackertable.viewport().update()
tbl.viewport().update() elif event.type() == QEvent.Type.Leave:
break self._tracker_hovered_row = -1
self._tracker_hovered_table = None
state.trackertable.viewport().update()
if obj == self.downloadList.viewport(): if obj == self.downloadList.viewport():
if event.type() == QEvent.Type.MouseMove: if event.type() == QEvent.Type.MouseMove:
pos = event.position().toPoint() if hasattr(event, 'position') else event.pos() pos = event.position().toPoint() if hasattr(event, 'position') else event.pos()
@@ -833,7 +919,7 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
old_row = self._hovered_row old_row = self._hovered_row
self._hovered_row = -1 self._hovered_row = -1
self._invalidate_hover_row(old_row) self._invalidate_hover_row(old_row)
except RuntimeError: except (RuntimeError, AttributeError):
pass pass
return super().eventFilter(obj, event) return super().eventFilter(obj, event)
@@ -842,43 +928,31 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
self.downloadList.viewport().update() self.downloadList.viewport().update()
def set_tracker(self, _): def set_tracker(self, _):
old_tracker = state.tracker state.currenttracker = self.tracker_list.currentText()
state.tracker = self.tracker_list.currentText() self._apply_default_headers(state.trackertable)
old_widget = state.tracker_list[old_tracker]
self.horizontal_layout.removeWidget(old_widget)
old_widget.setParent(None)
tracker_widget = state.tracker_list[state.tracker]
self.horizontal_layout.addWidget(tracker_widget)
def show_empty_results(self, show: bool): def show_empty_results(self, show: bool):
if show: if show:
state.tracker_list[state.tracker].hide() state.trackertable.hide()
self.emptyResults.show() self.emptyResults.show()
else: else:
state.tracker_list[state.tracker].show() state.trackertable.show()
self.emptyResults.hide() self.emptyResults.hide()
def show_empty_downloads(self): def show_empty_downloads(self):
if len(state.active_downloads) > 0: with state.downloads_lock:
has_downloads = len(state.active_downloads) > 0
if has_downloads:
self.emptyDownload.hide() self.emptyDownload.hide()
self.downloadList.show() self.downloadList.show()
else: else:
self.emptyDownload.show() self.emptyDownload.show()
self.downloadList.hide() self.downloadList.hide()
# thank you claude
def resizeEvent(self, event):
super().resizeEvent(event)
table_width = state.tracker_list[state.tracker].viewport().width()
state.tracker_list[state.tracker].setColumnWidth(1, int(table_width * 0.3))
def contextMenuEvent(self, event: QContextMenuEvent): def contextMenuEvent(self, event: QContextMenuEvent):
if self.downloadList.underMouse(): if self.downloadList.underMouse():
pos = self.downloadList.viewport().mapFromGlobal(event.globalPos()) pos = self.downloadList.viewport().mapFromGlobal(event.globalPos())
index = self.downloadList.indexAt(pos) index = self.downloadList.indexAt(pos)
if index.isValid(): if index.isValid():
row = index.row() row = index.row()
self._context_menu_row = row self._context_menu_row = row
@@ -889,16 +963,12 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
def openFolderAction(self): def openFolderAction(self):
if not hasattr(self, '_context_menu_row'): if not hasattr(self, '_context_menu_row'):
return return
row = self._context_menu_row row = self._context_menu_row
if row < 0 or row >= len(state.active_downloads): if row < 0 or row >= len(state.active_downloads):
return return
magnet_link = list(state.active_downloads.keys())[row] magnet_link = list(state.active_downloads.keys())[row]
magnetdl = state.active_downloads[magnet_link] magnetdl = state.active_downloads[magnet_link]
download_path = magnetdl.save_path() download_path = magnetdl.save_path()
if download_path and os.path.exists(download_path): if download_path and os.path.exists(download_path):
if platform.system() == "Windows": if platform.system() == "Windows":
os.startfile(os.path.normpath(download_path)) os.startfile(os.path.normpath(download_path))
@@ -910,29 +980,30 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
def copyMagnetURIAction(self): def copyMagnetURIAction(self):
if not hasattr(self, '_context_menu_row'): if not hasattr(self, '_context_menu_row'):
return return
row = self._context_menu_row row = self._context_menu_row
if row < 0 or row >= len(state.active_downloads): if row < 0 or row >= len(state.active_downloads):
return return
magnet_link = list(state.active_downloads.keys())[row] magnet_link = list(state.active_downloads.keys())[row]
clipboard = QtWidgets.QApplication.clipboard() clipboard = QtWidgets.QApplication.clipboard()
clipboard.setText(magnet_link) clipboard.setText(magnet_link)
def cancelDownloadAction(self): def cancelDownloadAction(self):
if not hasattr(self, '_context_menu_row'): if not hasattr(self, '_context_menu_row'):
return return
row = self._context_menu_row row = self._context_menu_row
if row < 0 or row >= len(state.active_downloads): if row < 0 or row >= len(state.active_downloads):
return return
magnet_link = list(state.active_downloads.keys())[row] magnet_link = list(state.active_downloads.keys())[row]
magnetdl = state.active_downloads[magnet_link] magnetdl = state.active_downloads[magnet_link]
confirm = QMessageBox.question(self, "Cancel Download", f"Are you sure you want to cancel the download of '{magnetdl.status().name}'?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) confirm = QMessageBox.question(self, "Cancel Download", f"Are you sure you want to cancel the download of '{magnetdl.status().name}'?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
if confirm == QMessageBox.StandardButton.Yes: if confirm == QMessageBox.StandardButton.Yes:
if hasattr(magnetdl, 'stop'):
magnetdl.stop()
elif state.dl_session:
try:
state.dl_session.remove_torrent(magnetdl)
except Exception:
pass
del state.active_downloads[magnet_link] del state.active_downloads[magnet_link]
remove_download_log(magnet_link) remove_download_log(magnet_link)
consoleLog(f"Cancelled download: {magnetdl.status().name}", True) consoleLog(f"Cancelled download: {magnetdl.status().name}", True)
@@ -940,20 +1011,24 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
def deleteFileAction(self): def deleteFileAction(self):
if not hasattr(self, '_context_menu_row'): if not hasattr(self, '_context_menu_row'):
return return
row = self._context_menu_row row = self._context_menu_row
if row < 0 or row >= len(state.active_downloads): if row < 0 or row >= len(state.active_downloads):
return return
magnet_link = list(state.active_downloads.keys())[row] magnet_link = list(state.active_downloads.keys())[row]
magnetdl = state.active_downloads[magnet_link] magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status() status = magnetdl.status()
save_path = status.save_path save_path = status.save_path
torrent_name = status.name torrent_name = status.name
download_path = os.path.join(save_path, torrent_name) download_path = os.path.join(save_path, torrent_name)
confirm = QMessageBox.question(self, "Delete Files", f"Are you sure you want to delete the downloaded files of '{magnetdl.status().name}'? This action cannot be undone.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) confirm = QMessageBox.question(self, "Delete Files", f"Are you sure you want to delete the downloaded files of '{magnetdl.status().name}'? This action cannot be undone.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
if confirm == QMessageBox.StandardButton.Yes: if confirm == QMessageBox.StandardButton.Yes:
if hasattr(magnetdl, 'stop'):
magnetdl.stop()
elif state.dl_session:
try:
state.dl_session.remove_torrent(magnetdl)
except Exception:
pass
if download_path and os.path.exists(download_path): if download_path and os.path.exists(download_path):
try: try:
if os.path.isfile(download_path): if os.path.isfile(download_path):
@@ -969,3 +1044,7 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
consoleLog(f"Deleted files for: {magnetdl.status().name}", True) consoleLog(f"Deleted files for: {magnetdl.status().name}", True)
except Exception as e: except Exception as e:
consoleLog(f"Error deleting files: {e}", True) consoleLog(f"Error deleting files: {e}", True)
else:
del state.active_downloads[magnet_link]
remove_download_log(magnet_link)
consoleLog(f"Removed entry (files not found): {magnetdl.status().name}", True)
+15 -54
View File
@@ -1,66 +1,27 @@
from PySide6.QtWidgets import QTableWidgetItem from PySide6.QtWidgets import QTableWidgetItem, QHeaderView
from core.utils.logging.logs import consoleLog from core.utils.logging.logs import consoleLog
from core.data.scrapers.uztracker import scrape_uztracker from core.data.scrapers.rutracker import init_rutracker
from core.data.scrapers.rutracker import scrape_rutracker from core.data.scrapers.uztracker import init_uztracker
from core.data.scrapers.monkrus import scrape_m0nkrus from core.data.scrapers.monkrus import init_m0nkrus
from core.utils.network.jsonhandler import split_data, format_data, format_data_minimal from core.data.scrapers.steamrip import init_steamrip
from core.utils.data.state import state from core.utils.data.state import state
scrapers = { init_rutracker()
"uztracker": scrape_uztracker, init_uztracker()
"rutracker": scrape_rutracker, init_m0nkrus()
"m0nkrus": scrape_m0nkrus init_steamrip()
}
def return_pressed(self): def return_pressed(self):
self.show_empty_results(False)
search_text = self.searchbar.text() search_text = self.searchbar.text()
if search_text == "": if search_text == "":
consoleLog("Error: Can't search for nothing") consoleLog("Error: Can't search for nothing")
return return
consoleLog(f"User searched for: {search_text}") consoleLog(f"User searched for: {search_text}")
if state.tracker == "rutracker": tracker = state.trackers[state.currenttracker]
response = scrape_rutracker(search_text) scrapefunc = tracker["scrapeFunc"]
if response: state.posts = scrapefunc(search_text)
_, state.posts, _, _, cached = split_data(response)
if state.posts == []:
consoleLog(f"No Results for {search_text}")
state.tracker_list[state.tracker].clear()
self.show_empty_results(True)
else:
state.post_titles, _, state.post_author, state.post_seeders, state.post_leechers = format_data(state.posts)
self.show_empty_results(False)
state.tracker_list[state.tracker].clear()
state.tracker_list[state.tracker].setHorizontalHeaderLabels(["Post Title", "Author", "Seeders", "Leechers"])
state.tracker_list[state.tracker].setRowCount(len(state.post_titles))
for i, author in enumerate(state.post_author):
state.tracker_list[state.tracker].setItem(i, 1, QTableWidgetItem(author))
for i, title in enumerate(state.post_titles):
state.tracker_list[state.tracker].setItem(i, 0, QTableWidgetItem(title))
for i, seeders in enumerate(state.post_seeders):
state.tracker_list[state.tracker].setItem(i, 2, QTableWidgetItem(seeders))
for i, leechers in enumerate(state.post_leechers):
state.tracker_list[state.tracker].setItem(i, 3, QTableWidgetItem(leechers))
if state.debug == True:
consoleLog(f"Response Cached: {cached}")
else:
consoleLog(f"No response from rutracker")
state.tracker_list[state.tracker].clear()
self.show_empty_results(True)
elif state.tracker is not None: from core.interface.gui import MainWindow
state.posts = scrapers[state.tracker](search_text) MainWindow._instance.search_results_signal.emit(tracker["headers"])
if not state.posts:
consoleLog(f"No Results for {search_text}")
state.tracker_list[state.tracker].clear()
self.show_empty_results(True)
else:
state.post_author, state.post_titles, state.post_urls = format_data_minimal(state.posts)
self.show_empty_results(False)
state.tracker_list[state.tracker].clear()
state.tracker_list[state.tracker].setHorizontalHeaderLabels(["Post Title", "Author"])
state.tracker_list[state.tracker].setRowCount(len(state.post_titles))
for i, author in enumerate(state.post_author):
state.tracker_list[state.tracker].setItem(i, 1, QTableWidgetItem(author))
for i, title in enumerate(state.post_titles):
state.tracker_list[state.tracker].setItem(i, 0, QTableWidgetItem(title))
@@ -0,0 +1,31 @@
from typing import Optional
from core.utils.data.state import state
from core.utils.logging.logs import consoleLog, add_download_log
from .handle import DirectDownloadHandle
from .utils import (
sanitize_filename,
extract_filename_from_url,
detect_filename_from_headers,
)
def add_direct_download(url: str, title: str, dl_path: Optional[str] = None):
if dl_path is None:
dl_path = state.download_path
if url in state.active_downloads:
consoleLog(f"Download already active: {title}")
return
filename = (
detect_filename_from_headers(url, DirectDownloadHandle.USER_AGENT)
or extract_filename_from_url(url)
or sanitize_filename(title) + ".zip"
)
handle = DirectDownloadHandle(url, filename, dl_path)
state.active_downloads[url] = handle
add_download_log(title, url, "", False)
handle.start()
consoleLog(f"Started direct download: {filename}")
+443
View File
@@ -0,0 +1,443 @@
import os
import threading
import requests
import json
import hashlib
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Optional
import libtorrent as lt
from core.utils.logging.logs import consoleLog, update_download_completed
from core.utils.data.state import state
from .status import DirectDownloadStatus, ChunkSpec
from .utils import format_size
class SimpleThrottler:
def __init__(self):
self._lock = threading.Lock()
self._last_time = time.monotonic()
self._allowance = 0.0
def throttle(self, bytes_count: int, limit_kbps: int):
if limit_kbps <= 0:
return
limit_bps = limit_kbps * 1024
wait_time = 0
with self._lock:
now = time.monotonic()
elapsed = now - self._last_time
self._last_time = now
self._allowance += elapsed * limit_bps
if self._allowance > 2 * limit_bps:
self._allowance = 2 * limit_bps
self._allowance -= bytes_count
if self._allowance < 0:
wait_time = -self._allowance / limit_bps
self._allowance = 0
if wait_time > 0:
time.sleep(min(wait_time, 1.0))
_throttler = SimpleThrottler()
class DirectDownloadHandle:
STREAM_BLOCK_SIZE = 1 << 17 # 128 KiB per read
MAX_RETRIES = 5
RETRY_BACKOFF_BASE = 2.0
NUM_THREADS = 16
MIN_CHUNK_SIZE = 1 << 20 # 1 MiB minimum per chunk
REQUEST_TIMEOUT = (15, 60) # (connect, read) timeouts
USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/125.0.0.0 Safari/537.36"
)
def __init__(self, url: str, name: str, save_path: str):
self.url = url
self._name = name
self._save_path = save_path
self._file_path = os.path.join(save_path, name)
self._status = DirectDownloadStatus(name, save_path, 0)
self._stop_event = threading.Event()
self._pause_event = threading.Event() # SET = paused
self._thread: Optional[threading.Thread] = None
self._session: Optional[requests.Session] = None
self._supports_range = False
state_dir = os.path.join(state.settings_path, "direct_downloads")
os.makedirs(state_dir, exist_ok=True)
url_hash = hashlib.sha256(url.encode()).hexdigest()
self._state_file = os.path.join(state_dir, f"{url_hash}.json")
# Load state to initialize progress
state_data = self._load_state()
if state_data:
total_wanted = state_data.get("total_wanted", 0)
chunks_progress = state_data.get("chunks", {})
chunks_progress = {int(k): int(v) for k, v in chunks_progress.items()}
self._status.initialize_progress(total_wanted, chunks_progress)
def status(self) -> DirectDownloadStatus:
return self._status
def pause(self):
self._status.paused = True
self._pause_event.set()
def resume(self):
self._status.paused = False
self._pause_event.clear()
def set_flags(self, flags):
if flags & lt.torrent_flags.auto_managed:
self._status.auto_managed = True
def unset_flags(self, flags):
if flags & lt.torrent_flags.auto_managed:
self._status.auto_managed = False
def save_path(self) -> str:
return self._save_path
def stop(self):
self._stop_event.set()
if self._thread and self._thread.is_alive():
self._thread.join(timeout=10)
def _load_state(self) -> dict:
if os.path.exists(self._state_file):
try:
with open(self._state_file, "r") as f:
return json.load(f)
except Exception as e:
consoleLog(f"Failed to load state for {self._name}: {e}")
return {}
def _save_state(self, chunks_done: dict[int, int]):
try:
with open(self._state_file, "w") as f:
json.dump({
"url": self.url,
"name": self._name,
"save_path": self._save_path,
"total_wanted": self._status.total_wanted,
"chunks": chunks_done
}, f)
except Exception as e:
pass
def _clear_state(self):
if os.path.exists(self._state_file):
try:
os.remove(self._state_file)
except Exception:
pass
def start(self):
self._thread = threading.Thread(
target=self._download_orchestrator, name=f"dl-{self._name}", daemon=True
)
self._thread.start()
def _build_session(self) -> requests.Session:
session = requests.Session()
session.headers.update({"User-Agent": self.USER_AGENT})
adapter = requests.adapters.HTTPAdapter(
max_retries=0,
pool_connections=self.NUM_THREADS,
pool_maxsize=self.NUM_THREADS + 2,
)
session.mount("https://", adapter)
session.mount("http://", adapter)
return session
def _probe_url(self, session: requests.Session) -> tuple[int, bool]:
resp = session.head(self.url, allow_redirects=True, timeout=self.REQUEST_TIMEOUT)
resp.raise_for_status()
total_size = int(resp.headers.get("content-length", 0))
supports_range = (total_size > 0)
# Double-check range support with a small range request
if supports_range:
try:
test = session.get(
self.url,
headers={"Range": "bytes=0-0"},
timeout=self.REQUEST_TIMEOUT,
stream=True,
)
supports_range = test.status_code == 206
test.close()
except Exception:
supports_range = False
return total_size, supports_range
def _download_orchestrator(self):
try:
self._session = self._build_session()
total_size, self._supports_range = self._probe_url(self._session)
self._status.total_wanted = total_size
os.makedirs(self._save_path, exist_ok=True)
use_multithreaded = (
self._supports_range
and total_size > self.MIN_CHUNK_SIZE * 2
)
if use_multithreaded:
consoleLog(
f"Multi-threaded download ({self.NUM_THREADS} threads): {self._name} "
f"({format_size(total_size)})"
)
self._preallocate_file(total_size)
self._multithreaded_download(total_size)
else:
reason = "no range support" if not self._supports_range else "file too small"
consoleLog(f"Single-threaded download ({reason}): {self._name}")
self._single_threaded_download()
if not self._stop_event.is_set() and self._status.error is None:
self._verify_download(total_size)
self._status.mark_completed()
update_download_completed(self.url, True)
self._clear_state()
consoleLog(f"✓ Finished downloading {self._name}")
except Exception as e:
self._status.mark_error(str(e))
consoleLog(f"✗ Download failed for {self._name}: {e}")
finally:
if self._session:
self._session.close()
def _preallocate_file(self, total_size: int):
if os.path.exists(self._file_path) and os.path.getsize(self._file_path) == total_size:
return
with open(self._file_path, "wb") as f:
f.truncate(total_size)
def _compute_chunks(self, total_size: int) -> list[ChunkSpec]:
num_chunks = min(self.NUM_THREADS, max(1, total_size // self.MIN_CHUNK_SIZE))
chunk_size = total_size // num_chunks
chunks = []
for i in range(num_chunks):
start = i * chunk_size
end = (i + 1) * chunk_size - 1 if i < num_chunks - 1 else total_size - 1
chunks.append(ChunkSpec(chunk_id=i, start=start, end=end))
return chunks
def _multithreaded_download(self, total_size: int):
chunks = self._compute_chunks(total_size)
state_data = self._load_state()
chunks_progress = state_data.get("chunks", {})
chunks_progress = {int(k): int(v) for k, v in chunks_progress.items()}
# Initialize progress in status
for chunk_id, bytes_done in chunks_progress.items():
self._status.update_chunk_progress(chunk_id, bytes_done)
with ThreadPoolExecutor(
max_workers=len(chunks), thread_name_prefix="dl-chunk"
) as executor:
futures = {
executor.submit(self._download_chunk_with_retry, chunk, chunks_progress.get(chunk.chunk_id, 0)): chunk
for chunk in chunks
}
for future in as_completed(futures):
chunk = futures[future]
try:
future.result()
except Exception as e:
consoleLog(
f"Chunk {chunk.chunk_id} ({chunk.start}-{chunk.end}) "
f"failed permanently: {e}"
)
self._status.mark_error(
f"Chunk {chunk.chunk_id} failed: {e}"
)
self._stop_event.set()
def _download_chunk_with_retry(self, chunk: ChunkSpec, initial_bytes: int):
bytes_written = initial_bytes
for attempt in range(1, self.MAX_RETRIES + 1):
if self._stop_event.is_set():
return
current_start = chunk.start + bytes_written
if current_start > chunk.end:
return
try:
# _download_range returns new bytes written
bytes_written += self._download_range(
chunk.chunk_id, current_start, chunk.end, bytes_written
)
return
except Exception as e:
if self._stop_event.is_set():
return
if attempt < self.MAX_RETRIES:
wait = self.RETRY_BACKOFF_BASE ** attempt
consoleLog(
f"Chunk {chunk.chunk_id} attempt {attempt} failed: {e}. "
f"Retrying in {wait:.0f}s (resuming from byte {current_start})..."
)
if self._stop_event.wait(timeout=wait):
return
else:
raise RuntimeError(
f"Chunk {chunk.chunk_id} failed after {self.MAX_RETRIES} attempts: {e}"
) from e
def _download_range(
self, chunk_id: int, start: int, end: int, prior_bytes: int
) -> int:
headers = {"Range": f"bytes={start}-{end}"}
new_bytes = 0
with self._session.get(
self.url, headers=headers, stream=True, timeout=self.REQUEST_TIMEOUT
) as resp:
resp.raise_for_status()
if resp.status_code not in (200, 206):
raise RuntimeError(f"Unexpected status {resp.status_code}")
with open(self._file_path, "r+b") as f:
f.seek(start)
for block in resp.iter_content(chunk_size=self.STREAM_BLOCK_SIZE):
if self._stop_event.is_set():
return new_bytes
self._wait_if_paused()
if self._stop_event.is_set():
return new_bytes
_throttler.throttle(len(block), state.down_speed_limit)
f.write(block)
new_bytes += len(block)
self._status.update_chunk_progress(
chunk_id, prior_bytes + new_bytes
)
if new_bytes % (1024 * 1024) < len(block):
with self._status._lock:
self._save_state(self._status._chunk_bytes)
with self._status._lock:
self._save_state(self._status._chunk_bytes)
return new_bytes
def _single_threaded_download(self):
chunk_id = 0
bytes_written = 0
if os.path.exists(self._file_path):
bytes_written = os.path.getsize(self._file_path)
if bytes_written > 0:
self._status.update_chunk_progress(chunk_id, bytes_written)
for attempt in range(1, self.MAX_RETRIES + 1):
if self._stop_event.is_set():
return
try:
headers = {}
mode = "wb"
if bytes_written > 0 and self._supports_range:
headers["Range"] = f"bytes={bytes_written}-"
mode = "r+b"
elif bytes_written > 0:
bytes_written = 0
mode = "wb"
with self._session.get(
self.url, headers=headers, stream=True, timeout=self.REQUEST_TIMEOUT
) as resp:
resp.raise_for_status()
if self._status.total_wanted == 0:
content_length = int(resp.headers.get("content-length", 0))
self._status.total_wanted = content_length + bytes_written
with open(self._file_path, mode) as f:
if mode == "r+b":
f.seek(bytes_written)
for block in resp.iter_content(
chunk_size=self.STREAM_BLOCK_SIZE
):
if self._stop_event.is_set():
return
self._wait_if_paused()
if self._stop_event.is_set():
return
_throttler.throttle(len(block), state.down_speed_limit)
f.write(block)
bytes_written += len(block)
self._status.update_chunk_progress(chunk_id, bytes_written)
# Periodic state save (single thread, chunk_id=0)
if bytes_written % (1024 * 1024) < len(block):
self._save_state({0: bytes_written})
self._save_state({0: bytes_written})
return
except Exception as e:
if self._stop_event.is_set():
return
if attempt < self.MAX_RETRIES:
wait = self.RETRY_BACKOFF_BASE ** attempt
consoleLog(
f"Download attempt {attempt} failed: {e}. "
f"Retrying in {wait:.0f}s..."
)
if self._stop_event.wait(timeout=wait):
return
else:
raise RuntimeError(
f"Download failed after {self.MAX_RETRIES} attempts: {e}"
) from e
def _wait_if_paused(self):
while self._pause_event.is_set():
if self._stop_event.wait(timeout=0.5):
return
def _verify_download(self, expected_size: int):
if expected_size <= 0:
return
actual_size = os.path.getsize(self._file_path)
if actual_size != expected_size:
raise RuntimeError(
f"Size mismatch: expected {format_size(expected_size)}, "
f"got {format_size(actual_size)}"
)
@@ -0,0 +1,96 @@
import threading
import time
from dataclasses import dataclass
from typing import Optional
import libtorrent as lt
@dataclass
class ChunkSpec:
chunk_id: int
start: int
end: int # inclusive
class DirectDownloadStatus:
def __init__(self, name: str, save_path: str, total_size: int = 0):
self.name = name
self.save_path = save_path
self.total_wanted = total_size
self.paused = False
self.auto_managed = True
self.has_metadata = True
self.state = lt.torrent_status.downloading
self.error: Optional[str] = None
self._lock = threading.Lock()
self._total_wanted_done = 0
self._progress = 0.0
self._download_rate = 0
self._upload_rate = 0
self._chunk_bytes: dict[int, int] = {}
self._speed_window: list[tuple[float, int]] = []
self._speed_window_size = 3.0
@property
def total_wanted_done(self) -> int:
with self._lock:
return self._total_wanted_done
@property
def progress(self) -> float:
with self._lock:
return self._progress
@property
def download_rate(self) -> int:
with self._lock:
return self._download_rate
@property
def upload_rate(self) -> int:
return 0
def update_chunk_progress(self, chunk_id: int, bytes_downloaded: int):
with self._lock:
self._chunk_bytes[chunk_id] = bytes_downloaded
self._total_wanted_done = sum(self._chunk_bytes.values())
if self.total_wanted > 0:
self._progress = min(self._total_wanted_done / self.total_wanted, 1.0)
now = time.monotonic()
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
]
if len(self._speed_window) >= 2:
oldest_time, oldest_bytes = self._speed_window[0]
dt = now - oldest_time
if dt > 0:
self._download_rate = int(
(self._total_wanted_done - oldest_bytes) / dt
)
def initialize_progress(self, total_wanted: int, chunk_bytes: dict[int, int]):
with self._lock:
self.total_wanted = total_wanted
self._chunk_bytes = chunk_bytes.copy()
self._total_wanted_done = sum(self._chunk_bytes.values())
if self.total_wanted > 0:
self._progress = min(self._total_wanted_done / self.total_wanted, 1.0)
def mark_completed(self):
with self._lock:
self.state = lt.torrent_status.seeding
self._progress = 1.0
self._download_rate = 0
def mark_error(self, error: str):
with self._lock:
self.error = error
self._download_rate = 0
+48
View File
@@ -0,0 +1,48 @@
import os
import requests
from typing import Optional
from urllib.parse import urlparse, unquote
def sanitize_filename(name: str) -> str:
# Remove or replace dangerous characters
keepchars = (" ", ".", "_", "-")
cleaned = "".join(c for c in name if c.isalnum() or c in keepchars).strip()
while " " in cleaned:
cleaned = cleaned.replace(" ", " ")
return cleaned or "download"
def extract_filename_from_url(url: str) -> Optional[str]:
parsed = urlparse(url)
path = unquote(parsed.path)
basename = os.path.basename(path)
if basename and "." in basename and len(basename) < 256:
return basename
return None
def detect_filename_from_headers(url: str, user_agent: str) -> Optional[str]:
try:
resp = requests.head(
url,
headers={"User-Agent": user_agent},
allow_redirects=True,
timeout=15,
)
cd = resp.headers.get("content-disposition", "")
if "filename=" in cd:
parts = cd.split("filename=")
if len(parts) > 1:
fname = parts[-1].strip().strip('"').strip("'")
if fname:
return fname
except Exception:
pass
return None
def format_size(size_bytes: int) -> str:
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
if abs(size_bytes) < 1024:
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024
return f"{size_bytes:.1f} PiB"
+12 -5
View File
@@ -2,19 +2,21 @@ from core.utils.logging.logs import consoleLog
from core.utils.data.state import state from core.utils.data.state import state
import psutil import psutil
addrs = psutil.net_if_addrs()
stats = psutil.net_if_stats()
def get_net_interfaces(): def get_net_interfaces():
addrs = psutil.net_if_addrs()
for interface in addrs.keys(): for interface in addrs.keys():
consoleLog(f"Found Interface: {interface}") consoleLog(f"Found Interface: {interface}")
return addrs.keys() return addrs.keys()
def get_active_interfaces(): def get_active_interfaces():
addrs = psutil.net_if_addrs()
stats = psutil.net_if_stats()
active = [] active = []
for interface, addr_list in addrs.items(): for interface, addr_list in addrs.items():
if interface not in stats:
continue
up = stats[interface].isup up = stats[interface].isup
for addr in addr_list: for addr in addr_list:
if addr.family == 2: # ipv4 if addr.family == 2: # ipv4
@@ -27,23 +29,28 @@ def get_active_interfaces():
def list_interfaces() -> None: def list_interfaces() -> None:
addrs = psutil.net_if_addrs()
stats = psutil.net_if_stats()
for interface, addr_list in addrs.items(): for interface, addr_list in addrs.items():
if interface not in stats:
continue
up = stats[interface].isup up = stats[interface].isup
status = "INACTIVE"
for addr in addr_list: for addr in addr_list:
if addr.family == 2: # ipv4 if addr.family == 2: # ipv4
ipv4 = addr.address ipv4 = addr.address
if not ipv4.startswith("127.") and not ipv4.startswith("169.254") and up: if not ipv4.startswith("127.") and not ipv4.startswith("169.254") and up:
status = "ACTIVE" status = "ACTIVE"
else:
status = "INACTIVE"
consoleLog(f"Found Interface: {interface} [{status}]") consoleLog(f"Found Interface: {interface} [{status}]")
def init_interfaces(): def init_interfaces():
addrs = psutil.net_if_addrs()
state.interfaces = list(addrs.keys()) state.interfaces = list(addrs.keys())
state.active_interfaces = get_active_interfaces() state.active_interfaces = get_active_interfaces()
def get_interface_ip(interface_name): def get_interface_ip(interface_name):
addrs = psutil.net_if_addrs()
if interface_name in addrs: if interface_name in addrs:
for addr in addrs[interface_name]: for addr in addrs[interface_name]:
if addr.family == 2: # ipv4 if addr.family == 2: # ipv4
+21 -21
View File
@@ -6,7 +6,6 @@ import threading
import libtorrent as lt import libtorrent as lt
from core.utils.data.state import state from core.utils.data.state import state
from core.utils.logging.logs import consoleLog from core.utils.logging.logs import consoleLog
from core.utils.general.shutdown import shutdown_event
global loop_running global loop_running
@@ -20,8 +19,8 @@ def init_session():
settings = { settings = {
"upload_rate_limit": state.up_speed_limit, "upload_rate_limit": state.up_speed_limit * 1024,
"download_rate_limit": state.down_speed_limit, "download_rate_limit": state.down_speed_limit * 1024,
"enable_dht": True, "enable_dht": True,
"enable_lsd": True, "enable_lsd": True,
"enable_upnp": True, "enable_upnp": True,
@@ -56,23 +55,24 @@ def add_download(magnet_uri, dl_path=state.download_path):
try: try:
handle = state.active_downloads[magnet_uri] handle = state.active_downloads[magnet_uri]
status = handle.status() status = handle.status()
if status.has_metadata:
filepath = os.path.join(status.save_path, status.name)
if not os.path.exists(filepath):
consoleLog(f"File Deleted, redownloading: {status.name}")
state.dl_session.remove_torrent(handle)
del state.active_downloads[magnet_uri]
else:
consoleLog("Skipping Downloading, download already running...")
return False
else:
consoleLog("Skipping Downloading, download already running... ")
return False
except RuntimeError as e: except RuntimeError as e:
consoleLog(f"Error in LibTorrent Handle: {e}") consoleLog(f"Error in LibTorrent Handle: {e}")
del state.active_downloads[magnet_uri]
if status.has_metadata:
filepath = os.path.join(status.save_path, status.name)
if not os.path.exists(filepath):
consoleLog(f"File Deleted, redownloading: {status.name}")
state.dl_session.remove_torrent(handle)
del state.active_downloads[magnet_uri]
else:
consoleLog("Skipping Downloading, download already running...")
return False
else:
consoleLog("Skipping Downloading, download already running... ")
return False
try: try:
magnetdl = lt.parse_magnet_uri(magnet_uri) magnetdl = lt.parse_magnet_uri(magnet_uri)
@@ -123,7 +123,7 @@ def dl_status_loop():
loop_running = False loop_running = False
return return
while state.active_downloads and not shutdown_event.is_set(): while state.active_downloads and not state.shutdown_event.is_set():
for magnet_uri, magnetdl in list(state.active_downloads.items()): for magnet_uri, magnetdl in list(state.active_downloads.items()):
try: try:
status = magnetdl.status() status = magnetdl.status()
@@ -149,8 +149,8 @@ def update_settings():
return return
settings = { settings = {
"upload_rate_limit": state.up_speed_limit, "upload_rate_limit": state.up_speed_limit * 1024,
"download_rate_limit": state.down_speed_limit, "download_rate_limit": state.down_speed_limit * 1024,
"connections_limit": state.max_connections, "connections_limit": state.max_connections,
"active_downloads": state.max_downloads "active_downloads": state.max_downloads
} }
+4 -1
View File
@@ -53,7 +53,10 @@ def update_log(shutdown_event):
if status.state == lt.torrent_status.seeding and magnet_uri not in updated and magnet_uri not in state.seeded_magnets: 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") consoleLog(f"Marking {status.name} as completed")
info_hash = str(status.info_hash) 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) update_download_completed_by_hash(info_hash, True)
updated.add(magnet_uri) updated.add(magnet_uri)
except Exception: except Exception:
+5 -2
View File
@@ -1,9 +1,12 @@
from core.utils.logging.logs import consoleLog from core.utils.logging.logs import consoleLog
from core.network.libtorrent_int import add_download from core.network.libtorrent_int import add_download
def add_magnet(uri): def add_magnet(uri, dl_path=None):
if uri is not None and uri.startswith("magnet:?"): if uri is not None and uri.startswith("magnet:?"):
add_download(uri) if dl_path:
add_download(uri, dl_path)
else:
add_download(uri)
consoleLog("Magnet URI added to LibTorrent") consoleLog("Magnet URI added to LibTorrent")
else: else:
consoleLog(f"Invalid Magnet Link: {uri}") consoleLog(f"Invalid Magnet Link: {uri}")
+3 -1
View File
@@ -23,7 +23,7 @@ def create_config():
} }
config["Paths"] = { config["Paths"] = {
"bound_interface": f"{state.bound_interface}", "bound_interface": f"{state.bound_interface}" if state.bound_interface is not None else "None",
"image_path": f"{state.image_path}" "image_path": f"{state.image_path}"
} }
@@ -72,6 +72,8 @@ def read_config():
# Paths # Paths
state.bound_interface = config.get("Paths", "bound_interface", fallback=state.bound_interface) state.bound_interface = config.get("Paths", "bound_interface", fallback=state.bound_interface)
if state.bound_interface == "None":
state.bound_interface = None
state.image_path = config.get("Paths", "image_path", fallback=state.image_path) state.image_path = config.get("Paths", "image_path", fallback=state.image_path)
create_config() create_config()
+22 -10
View File
@@ -1,6 +1,7 @@
import threading
from PySide6.QtCore import QObject, Signal from PySide6.QtCore import QObject, Signal
from PySide6.QtWidgets import QTableWidget from PySide6.QtWidgets import QTableWidget
from typing import Optional, Any, List, Dict from typing import Any, List, Dict
from pathlib import Path from pathlib import Path
class AppState(QObject): class AppState(QObject):
@@ -8,20 +9,26 @@ class AppState(QObject):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.posts: list[Any] | None = None self.posts: list[Dict[str,str]] | None = None # titles, urls, author, seederm leecher
self.post_titles: Optional[List] = None
self.post_urls: Optional[List] = None
self.post_author: List[str] = []
self.post_seeders: List[str] = []
self.post_leechers: List[str] = []
self.version: str = "dev" self.version: str = "dev"
self._image_path: str = "" self._image_path: str = ""
self.ignore_updates: bool = False self.ignore_updates: bool = False
self.debug: bool = False self.debug: bool = False
self.autoresume: bool = True self.autoresume: bool = True
self.tracker: str = "rutracker"
self.tracker_list: dict[str, QTableWidget] = {} self.currenttracker: str = "rutracker"
self.trackertable: QTableWidget
self.trackers: Dict[str,Dict[str,Any]] = {}# each tracker should add itself here
'''
an example:
"rutracker" : {
"name" : "rutracker", # name of the tracker
"headers" : ["author", "title"], # keys shown in the table
"scrapeFunc" : function,
}
'''
self.api_url: str = "https://api.michijackson.xyz" self.api_url: str = "https://api.michijackson.xyz"
self.seeded_magnets: set = set()
self.download_path: str = str(Path.home() / "Downloads") self.download_path: str = str(Path.home() / "Downloads")
self.up_speed_limit: int = 0 self.up_speed_limit: int = 0
self.down_speed_limit: int = 0 self.down_speed_limit: int = 0
@@ -30,12 +37,17 @@ class AppState(QObject):
self.settings_path: str = "" self.settings_path: str = ""
self.dl_session: Any = None self.dl_session: Any = None
self.active_downloads: Dict = {} self.active_downloads: Dict = {}
self.seeded_magnets: set = set()
self.window_transparency: bool = False self.window_transparency: bool = False
self.interfaces: List = [] self.interfaces: List = []
self.active_interfaces: List = [] self.active_interfaces: List = []
self.bound_interface: Any = None self.bound_interface: Any = None
self.log_buffer: List[str] = []
self.downloads_lock = threading.RLock()
self.main_window: Any = None
self.loop_running: bool = False
self.shutdown_event = threading.Event()
@property @property
def image_path(self) -> str: def image_path(self) -> str:
return self._image_path return self._image_path
-25
View File
@@ -1,31 +1,6 @@
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from core.utils.data.state import state
from core.utils.logging.logs import consoleLog from core.utils.logging.logs import consoleLog
from core.utils.network.jsonhandler import format_data
def get_item_url(item, posts, post_titles): # softwarelist currentitem, post list (dict), post titles list
post_index = post_titles.index(item)
if 0 <= post_index < len(post_titles):
if state.tracker == "uztracker":
item = "https://uztracker.net/" + state.post_urls[post_index].lstrip("./")
consoleLog(f"Found post URL: {item}")
return item
if state.tracker == "rutracker":
item_dict = posts[post_index]
_, post_links, _, _, _ = format_data([item_dict])
consoleLog(f"Found post URL: {post_links[0]}")
return post_links[0]
if state.tracker == "m0nkrus":
item_dict = posts[post_index]
_, post_links, _, _, _,= format_data([item_dict])
consoleLog(f"Found post URL: {post_links[0]}")
return post_links[0]
return None
def get_magnet_link(post_url): def get_magnet_link(post_url):
+7 -4
View File
@@ -1,10 +1,13 @@
import threading from core.utils.data.state import state
import os import os
shutdown_event = threading.Event()
def closehelper(): def closehelper():
shutdown_event.set() state.shutdown_event.set()
try:
from core.network.libtorrent_misc import cleanup_session
cleanup_session()
except Exception:
pass
def force_exit(): def force_exit():
os._exit(0) os._exit(0)
+10 -4
View File
@@ -15,18 +15,24 @@ def check_completed(downloads, resume):
if download.completed == False: if download.completed == False:
consoleLog(f"Found unfinished download: {download.title}") consoleLog(f"Found unfinished download: {download.title}")
if resume == True: if resume == True:
run_download_direct(download.magnet_uri) dl_dir = os.path.dirname(download.path)
consoleLog(f"Resuming {download.title}") if download.magnet_uri:
run_download_direct(download.magnet_uri, dl_dir, download.title)
consoleLog(f"Resuming Magnet: {download.title}")
elif download.url:
from core.network.direct_download import add_direct_download
add_direct_download(download.url, download.title, dl_dir)
consoleLog(f"Resuming Direct Download: {download.title}")
def check_downloads(downloads): def check_downloads(downloads):
for download in downloads: for download in downloads:
if os.path.exists(download.path): if download.completed == True and os.path.exists(download.path):
consoleLog(f"Existing Download: {download.title}") consoleLog(f"Existing Download: {download.title}")
try: try:
seed_magnet(download.magnet_uri, download.path) seed_magnet(download.magnet_uri, download.path)
except Exception as e: except Exception as e:
consoleLog(f"Failed to seed {download.title}: {e}") consoleLog(f"Failed to seed {download.title}: {e}")
else: elif download.completed == True and not os.path.exists(download.path):
consoleLog(f"Inexistent Download: {download.title}") consoleLog(f"Inexistent Download: {download.title}")
remove_download_log(download.magnet_uri) remove_download_log(download.magnet_uri)
+35 -30
View File
@@ -8,10 +8,6 @@ import re
import time import time
import threading import threading
_log_buffer = []
_downloads_lock = threading.RLock()
def add_download_log(title, url, magnet_uri, completed) -> DownloadList: def add_download_log(title, url, magnet_uri, completed) -> DownloadList:
# wait for metadata outside the lock to avoid blocking other threads # wait for metadata outside the lock to avoid blocking other threads
magnetdl = state.active_downloads.get(magnet_uri) magnetdl = state.active_downloads.get(magnet_uri)
@@ -28,7 +24,7 @@ def add_download_log(title, url, magnet_uri, completed) -> DownloadList:
path = os.path.join(save_path, torrent_name) path = os.path.join(save_path, torrent_name)
else: else:
path = os.path.join(state.download_path, title) path = os.path.join(state.download_path, title)
with _downloads_lock: with state.downloads_lock:
return _add_download_log_inner(title, url, magnet_uri, completed, path) return _add_download_log_inner(title, url, magnet_uri, completed, path)
def _add_download_log_inner(title, url, magnet_uri, completed, path) -> DownloadList: def _add_download_log_inner(title, url, magnet_uri, completed, path) -> DownloadList:
@@ -44,14 +40,15 @@ def _add_download_log_inner(title, url, magnet_uri, completed, path) -> Download
else: else:
downloads = [] downloads = []
if any(d.magnet_uri == magnet_uri or d.url == url for d in downloads): if any((magnet_uri and d.magnet_uri == magnet_uri) or (url and d.url == url) for d in downloads):
if magnet_uri in state.active_downloads: if magnet_uri and magnet_uri in state.active_downloads:
consoleLog("Skipping Logging, download already running...") consoleLog("Skipping Logging, download already running...")
return return DownloadList(data=downloads, count=len(downloads))
consoleLog("File already in Log, updating Download State...") consoleLog("File already in Log, updating Download State...")
hash = extract_hash_from_magnet(magnet_uri) hash = extract_hash_from_magnet(magnet_uri)
update_download_completed_by_hash(hash, False) if hash:
return DownloadList(data=downloads, count=len(downloads)) # thanks again claude (im stupid) update_download_completed_by_hash(hash, False)
return DownloadList(data=downloads, count=len(downloads))
downloads.append(Download( downloads.append(Download(
@@ -71,7 +68,7 @@ def _add_download_log_inner(title, url, magnet_uri, completed, path) -> Download
return download_list return download_list
def remove_download_log(magnet_uri) -> DownloadList: def remove_download_log(magnet_uri) -> DownloadList:
with _downloads_lock: with state.downloads_lock:
return _remove_download_log_inner(magnet_uri) return _remove_download_log_inner(magnet_uri)
def _remove_download_log_inner(magnet_uri) -> DownloadList: def _remove_download_log_inner(magnet_uri) -> DownloadList:
@@ -89,6 +86,9 @@ def _remove_download_log_inner(magnet_uri) -> DownloadList:
magnet_link = (magnet_uri or "").strip() magnet_link = (magnet_uri or "").strip()
if not magnet_link:
return DownloadList(data=downloads, count=len(downloads))
title = next((getattr(d, 'title', 'Unknown') for d in downloads if (getattr(d, 'magnet_uri', None) or '').strip() == magnet_link or (getattr(d, 'url', None) or '').strip() == magnet_link), 'Unknown') title = next((getattr(d, 'title', 'Unknown') for d in downloads if (getattr(d, 'magnet_uri', None) or '').strip() == magnet_link or (getattr(d, 'url', None) or '').strip() == magnet_link), 'Unknown')
downloads = [d for d in downloads if (getattr(d, 'magnet_uri', None) or "").strip() != magnet_link and (getattr(d, 'url', None) or "").strip() != magnet_link] downloads = [d for d in downloads if (getattr(d, 'magnet_uri', None) or "").strip() != magnet_link and (getattr(d, 'url', None) or "").strip() != magnet_link]
@@ -101,7 +101,7 @@ def _remove_download_log_inner(magnet_uri) -> DownloadList:
return download_list return download_list
def update_download_completed(magnet_uri, completed) -> DownloadList: def update_download_completed(magnet_uri, completed) -> DownloadList:
with _downloads_lock: with state.downloads_lock:
return _update_download_completed_inner(magnet_uri, completed) return _update_download_completed_inner(magnet_uri, completed)
def _update_download_completed_inner(magnet_uri, completed) -> DownloadList: def _update_download_completed_inner(magnet_uri, completed) -> DownloadList:
@@ -157,7 +157,7 @@ def _update_download_completed_inner(magnet_uri, completed) -> DownloadList:
def get_download_logs() -> DownloadList: def get_download_logs() -> DownloadList:
with _downloads_lock: with state.downloads_lock:
return _get_download_logs_inner() return _get_download_logs_inner()
def _get_download_logs_inner() -> DownloadList: def _get_download_logs_inner() -> DownloadList:
@@ -176,15 +176,24 @@ def _get_download_logs_inner() -> DownloadList:
return DownloadList(data=downloads, count=len(downloads)) return DownloadList(data=downloads, count=len(downloads))
def extract_hash_from_magnet(magnet_uri): # full credits to claude for this def extract_hash_from_magnet(magnet_uri):
match = re.search(r'urn:btih:([A-F0-9]+)', magnet_uri, re.IGNORECASE) if not magnet_uri:
if match: return None
return match.group(1).upper() try:
return None import libtorrent as lt
params = lt.parse_magnet_uri(magnet_uri)
if hasattr(params, 'info_hashes'): # lt 2.0+
return str(params.info_hashes.v1).upper()
return str(params.info_hash).upper()
except Exception:
match = re.search(r'urn:btih:([a-zA-Z0-9]+)', magnet_uri)
if match:
return match.group(1).upper()
return None
def update_download_completed_by_hash(info_hash, completed) -> DownloadList: def update_download_completed_by_hash(info_hash, completed) -> DownloadList:
with _downloads_lock: with state.downloads_lock:
return _update_download_completed_by_hash_inner(info_hash, completed) return _update_download_completed_by_hash_inner(info_hash, completed)
def _update_download_completed_by_hash_inner(info_hash, completed) -> DownloadList: def _update_download_completed_by_hash_inner(info_hash, completed) -> DownloadList:
@@ -230,20 +239,16 @@ def _update_download_completed_by_hash_inner(info_hash, completed) -> DownloadLi
return download_list return download_list
_main_window = None
def set_main_window(window): def set_main_window(window):
global _main_window state.main_window = window
_main_window = window
def flush_log_buffer(): # credits to claude def flush_log_buffer(): # credits to claude
global _log_buffer if state.log_buffer:
if _log_buffer:
try: try:
from core.interface.gui import MainWindow from core.interface.gui import MainWindow
for log_entry in _log_buffer: for log_entry in state.log_buffer:
MainWindow.add_log(log_entry) MainWindow.add_log(log_entry)
_log_buffer = [] state.log_buffer = []
except Exception: except Exception:
pass pass
@@ -254,10 +259,10 @@ def consoleLog(text, printAnyways = False):
try: try:
from core.interface.gui import MainWindow from core.interface.gui import MainWindow
MainWindow.add_log(formatted_text) if not MainWindow.add_log(formatted_text):
state.log_buffer.append(formatted_text)
except Exception: except Exception:
global _log_buffer state.log_buffer.append(formatted_text)
_log_buffer.append(formatted_text)
if state.debug or printAnyways: if state.debug or printAnyways:
print(formatted_text) print(formatted_text)
+30 -19
View File
@@ -1,14 +1,18 @@
from PySide6.QtWidgets import QTableWidgetItem
from PySide6.QtCore import Qt
from core.utils.logging.logs import consoleLog from core.utils.logging.logs import consoleLog
from core.utils.data.tracker import get_item_url from core.network.libtorrent_wrapper import add_magnet
from core.utils.data.tracker import get_magnet_link
from core.network.libtorrent_wrapper import add_download
from core.utils.general.wrappers import run_thread from core.utils.general.wrappers import run_thread
from core.utils.logging.logs import add_download_log from core.utils.logging.logs import add_download_log
from core.network.libtorrent_int import add_seed from core.network.libtorrent_int import add_seed
from core.utils.data.state import state
from core.network.direct_download import add_direct_download
import threading import threading
def download_selected(items, posts, post_titles):
def download_selected(items: list[QTableWidgetItem]):
if not items: if not items:
consoleLog("No item selected for download.") consoleLog("No item selected for download.")
return return
@@ -16,24 +20,31 @@ def download_selected(items, posts, post_titles):
for item in items: for item in items:
if item.column() != 0: if item.column() != 0:
continue continue
text = item.text() post_idx = item.data(Qt.ItemDataRole.UserRole)
if text and text not in seen: if post_idx is None:
seen.add(text) post_idx = item.row()
consoleLog(f"Downloading {text}")
run_thread(threading.Thread(target=run_download, args=(text, posts, post_titles)))
def run_download(item, posts, post_titles): if post_idx not in seen:
post_url = get_item_url(item, posts, post_titles) seen.add(post_idx)
consoleLog(f"Selected URL: {post_url}") post = state.posts[post_idx]
magnet_uri = get_magnet_link(post_url) consoleLog(f"Downloading {post.get('title', 'Unknown')}")
run_thread(threading.Thread(target=run_download, args=(post,)))
if add_download(magnet_uri): def run_download(post):
add_download_log(item, post_url, magnet_uri, False) linkfunc = state.trackers[state.currenttracker]["linkFunc"]
ismagnet = state.trackers[state.currenttracker]["isMagnet"]
link = linkfunc(post)
def run_download_direct(magnet_uri): if ismagnet:
consoleLog(f"Direct download: {magnet_uri[:60]}") add_magnet(link)
add_download(magnet_uri) add_download_log(post.get("title", "Unknown"), "", link, False)
add_download_log("Direct Download", "", magnet_uri, False) else:
add_direct_download(link, post.get("title", "Unknown"))
def run_download_direct(magnet_uri, dl_path=None, title="Direct Download"):
consoleLog(f"Magnet: {title}")
add_magnet(magnet_uri, dl_path)
add_download_log(title, "", magnet_uri, False)
def seed_magnet(magnet_uri, file_path): def seed_magnet(magnet_uri, file_path):
consoleLog(f"Seeding: {magnet_uri[:60]}") consoleLog(f"Seeding: {magnet_uri[:60]}")
+3 -3
View File
@@ -17,8 +17,8 @@ def get_updates():
release = response.json() release = response.json()
latest_version = release["name"] latest_version = release.get("name") or release.get("tag_name")
assets = release["assets"] assets = release.get("assets", [])
release_assets = [] release_assets = []
@@ -31,7 +31,7 @@ def get_updates():
release_assets.append(dict( release_assets.append(dict(
name=asset['name'], name=asset['name'],
url=asset['browser_download_url'], url=asset['browser_download_url'],
hash=asset['digest'] hash=asset.get('digest')
)) ))
return release_assets return release_assets
else: else:
+5 -5
View File
@@ -1,9 +1,9 @@
from core.interface.gui import MainWindow, windowCloseHelper from core.interface.gui import MainWindow
from core.utils.data.state import state from core.utils.data.state import state
from core.utils.logging.logs import consoleLog from core.utils.logging.logs import consoleLog
from core.network.libtorrent_misc import send_notification, update_log, check_deleted_files from core.network.libtorrent_misc import send_notification, update_log, check_deleted_files
from core.utils.logging.logs import get_download_logs from core.utils.logging.logs import get_download_logs
from core.utils.general.shutdown import closehelper, shutdown_event from core.utils.general.shutdown import closehelper
from core.utils.general.wrappers import run_thread from core.utils.general.wrappers import run_thread
from core.utils.logging.loghandler import split_data, check_completed, check_downloads from core.utils.logging.loghandler import split_data, check_completed, check_downloads
from core.network.interface import list_interfaces, init_interfaces from core.network.interface import list_interfaces, init_interfaces
@@ -48,13 +48,13 @@ def main():
consoleLog("Initializing Interface variables...") consoleLog("Initializing Interface variables...")
init_interfaces() init_interfaces()
consoleLog(f"Current Bound: {state.bound_interface}") consoleLog(f"Current Bound: {state.bound_interface}")
run_thread(threading.Thread(target=send_notification, args=(shutdown_event,), daemon=True)) run_thread(threading.Thread(target=send_notification, args=(state.shutdown_event,), daemon=True))
consoleLog("Started Thread: send_notification") consoleLog("Started Thread: send_notification")
run_thread(threading.Thread(target=update_log, args=(shutdown_event,), daemon=True)) run_thread(threading.Thread(target=update_log, args=(state.shutdown_event,), daemon=True))
consoleLog("Started Thread: update_log") consoleLog("Started Thread: update_log")
run_thread(threading.Thread(target=check_completed, args=(downloads, state.autoresume))) run_thread(threading.Thread(target=check_completed, args=(downloads, state.autoresume)))
consoleLog("Started Thread: check_completed") consoleLog("Started Thread: check_completed")
run_thread(threading.Thread(target=check_deleted_files, args=(shutdown_event,), daemon=True)) run_thread(threading.Thread(target=check_deleted_files, args=(state.shutdown_event,), daemon=True))
consoleLog("Started Thread: check_deleted_files") consoleLog("Started Thread: check_deleted_files")
run_thread(threading.Thread(target=check_downloads, args=(downloads,))) run_thread(threading.Thread(target=check_downloads, args=(downloads,)))
consoleLog("Started Thread: check_downloads") consoleLog("Started Thread: check_downloads")