diff --git a/requirements.txt b/requirements.txt index 0fecfdd..a5fea62 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,4 @@ aiohttp==3.13.0 plyer==2.1.0 psutil==7.1.0 libtorrent==2.0.11 -libtorrent-windows-dll==0.0.3 +libtorrent-windows-dll==0.0.3 \ No newline at end of file diff --git a/src/core/data/scrapers/monkrus.py b/src/core/data/scrapers/monkrus.py index 67e64f3..3b40c9f 100644 --- a/src/core/data/scrapers/monkrus.py +++ b/src/core/data/scrapers/monkrus.py @@ -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 typing import Dict import requests import time @@ -31,7 +34,7 @@ def _get_telegram_posts(): for bubble in bubbles: 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 title = post_txt.b.text @@ -42,9 +45,9 @@ def _get_telegram_posts(): if post_url not in added: added.add(post_url) posts.append(dict( + title=title, author="m0nkrus", id=len(posts) + 1, - title=title, url=post_url )) @@ -67,4 +70,18 @@ def scrape_m0nkrus(query): filtered_post["id"] = len(filtered_posts) + 1 filtered_posts.append(filtered_post) - return filtered_posts \ No newline at end of file + 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}) diff --git a/src/core/data/scrapers/provider/buzzheavier.py b/src/core/data/scrapers/provider/buzzheavier.py new file mode 100644 index 0000000..c0d8251 --- /dev/null +++ b/src/core/data/scrapers/provider/buzzheavier.py @@ -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 \ No newline at end of file diff --git a/src/core/data/scrapers/provider/gofile.py b/src/core/data/scrapers/provider/gofile.py new file mode 100644 index 0000000..4a101d1 --- /dev/null +++ b/src/core/data/scrapers/provider/gofile.py @@ -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.") + diff --git a/src/core/data/scrapers/rutracker.py b/src/core/data/scrapers/rutracker.py index bf67bb5..783866a 100644 --- a/src/core/data/scrapers/rutracker.py +++ b/src/core/data/scrapers/rutracker.py @@ -1,19 +1,52 @@ import requests from core.utils.data.state import state 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): - 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") if search: - try: - return search.text - except Exception: - consoleLog("No results found / No response from server") - return None + _, data, _, success, cached = split_data(search.text) + if cached: + consoleLog("Server response cached") + if success: + 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: - return None - + consoleLog("No response from server, returning nothing") + return [] -# This function utilizes the SoftwareManager server - source code can be found under the SoftwareManager-Server repository. +def get_magnet(post: Dict): + _, post_links, _, _, _ = format_data([post]) + return get_magnet_link(post_links[0]) + +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}) diff --git a/src/core/data/scrapers/steamrip.py b/src/core/data/scrapers/steamrip.py new file mode 100644 index 0000000..29e8233 --- /dev/null +++ b/src/core/data/scrapers/steamrip.py @@ -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}) \ No newline at end of file diff --git a/src/core/data/scrapers/uztracker.py b/src/core/data/scrapers/uztracker.py index ac2aa23..621632d 100644 --- a/src/core/data/scrapers/uztracker.py +++ b/src/core/data/scrapers/uztracker.py @@ -2,6 +2,10 @@ import requests from bs4 import BeautifulSoup from urllib.parse import urljoin 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): base_url="https://uztracker.net/" @@ -26,12 +30,26 @@ def scrape_uztracker(query): posts.append(dict( title=title, + author=author, url=url, - author=author )) return posts except requests.RequestException as e: consoleLog(f"Failed to fetch {search_url}: {e}") - return None \ No newline at end of file + 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}) diff --git a/src/core/interface/dialogs/settings.py b/src/core/interface/dialogs/settings.py index 6d311f5..d6de321 100644 --- a/src/core/interface/dialogs/settings.py +++ b/src/core/interface/dialogs/settings.py @@ -54,7 +54,6 @@ def settings_dialog(self): update_checkbox.setChecked(state.ignore_updates) update_checkbox.toggled.connect(lambda checked: setattr(state, 'ignore_updates', checked)) update_checkbox_layout.addWidget(update_checkbox) - dialog.layout().addWidget(update_checkbox_container) # auto-resume downloads checkbox diff --git a/src/core/interface/gui.py b/src/core/interface/gui.py index 1d0d421..06b3c77 100644 --- a/src/core/interface/gui.py +++ b/src/core/interface/gui.py @@ -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.general.wrappers import run_thread 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.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(): return darkdetect.isDark() @@ -121,21 +141,19 @@ SVG_PLAY = ' 0: progress.setValue(int(downloaded * 100 / total)) QtWidgets.QApplication.processEvents() - if not os.path.exists(installer_path): progress.close() raise FileNotFoundError("Executable not found") - - if _verify_hash(installer_path, setup_hash): - consoleLog(f"Sucessfully validated installer hash ({setup_hash})") + if setup_hash: + if _verify_hash(installer_path, setup_hash): + consoleLog(f"Sucessfully validated installer hash ({setup_hash})") + else: + consoleLog("Error: Invalid Filehash, file may be corrupted") + sys.exit(0) else: - consoleLog("Error: Invalid Filehash, file may be corrupted") - sys.exit(0) - + consoleLog("Skipping Hash Verification (no hash found for release)") progress.setLabelText("Installing update...") progress.setValue(100) QtWidgets.QApplication.processEvents() - subprocess.Popen([installer_path, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/SP-", "/CLOSEAPPLICATIONS"]) time.sleep(1) sys.exit(0) @@ -193,16 +208,51 @@ def _download_update(assets): def windowCloseHelper(): 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): - log_signal = Signal(str) # Thread-safe signal for logging - search_finished_signal = Signal() # Thread-safe signal for search completion - + log_signal = Signal(str) + search_results_signal = Signal(list) + _instance = None + def __init__(self): super().__init__() MainWindow._instance = self self.log_signal.connect(self._on_log_signal) - self.search_finished_signal.connect(self._on_search_finished) + self.search_results_signal.connect(self._on_search_results) pixmap = QPixmap() image_data = base64.b64decode(logo_base64) @@ -223,18 +273,15 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): build_info = json.load(f) state.version = build_info.get("version") - # Check for updates on Windows if state.ignore_updates is False and platform.system() == "Windows": assets = get_updates() if assets != None: - msg = QMessageBox() msg.setIcon(QMessageBox.Icon.Information) msg.setWindowTitle("Update Available") msg.setText("A new version is available.") msg.setInformativeText("Press Ok to download the update.") msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Ignore) - response = msg.exec_() if response == QMessageBox.StandardButton.Ok: _download_update(assets) @@ -245,7 +292,6 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): self.controls = QWidget() self.controlsLayout = QVBoxLayout() - # Widgets self.searchbar = QLineEdit() self.searchbar.setPlaceholderText("Search for software...") self.searchbar.setClearButtonEnabled(True) @@ -266,6 +312,7 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): self._hovered_row = -1 self._tracker_hovered_row = -1 self._tracker_hovered_table = None + self._last_dl_row_count = 0 self.downloadList.viewport().installEventFilter(self) self.emptyLibrary = QLabel("No items in library.") self.emptyDownload = QLabel("No items in downloads.") @@ -276,109 +323,64 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): flush_log_buffer() - class TrackerHoverDelegate(QStyledItemDelegate): - def paint(self, painter, option, index): - 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_elided_delegate = ElidedItemDelegate(lambda: self._tracker_hovered_row, self) + self._tracker_hover_delegate = TrackerHoverDelegate(lambda: self._tracker_hovered_row, self) - self._tracker_hover_delegate = TrackerHoverDelegate(self) - - 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) + state.trackertable = self._create_tracker_table() container = QWidget() containerLayout = QVBoxLayout() - - search_row = QHBoxLayout() - search_row.addWidget(self.searchbar) - containerLayout.addLayout(search_row) - containerLayout.addWidget(state.tracker_list[state.tracker]) + containerLayout.addWidget(self.searchbar) + containerLayout.addWidget(state.trackertable) class DownloadModel(QAbstractTableModel): def __init__(self): super().__init__() self.headers = ["Action", "Name", "Status", "Progress", "Speed", "Size", "Total Size"] - def rowCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int: - return len(state.active_downloads) - - def columnCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()): + def rowCount(self, parent=QModelIndex()): + if parent.isValid(): + return 0 + with state.downloads_lock: + return len(state.active_downloads) + + def columnCount(self, parent=QModelIndex()): return len(self.headers) - def headerData(self, section, orientation, role: int = Qt.ItemDataRole.DisplayRole): - if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal: + def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): + if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal: return self.headers[section] return None - def data(self, index: QModelIndex | QPersistentModelIndex, role: int = Qt.ItemDataRole.DisplayRole): - if role == Qt.ItemDataRole.DisplayRole: - col = index.column() + def data(self, index, role=Qt.ItemDataRole.DisplayRole): + if not index.isValid(): + return None + with state.downloads_lock: if index.row() >= len(state.active_downloads) or index.row() < 0: return None - magnet_link = list(state.active_downloads.keys())[index.row()] - magnetdl = state.active_downloads[magnet_link] - - status = magnetdl.status() - + try: + magnet_link = list(state.active_downloads.keys())[index.row()] + magnetdl = state.active_downloads[magnet_link] + status = magnetdl.status() + except (IndexError, KeyError, RuntimeError): + return None + + if role == Qt.ItemDataRole.DisplayRole: + col = index.column() if col == 0: pass elif col == 1: return status.name if status.has_metadata else "Fetching metadata..." 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" elif status.state == lt.torrent_status.seeding: return "Seeding" - elif status.paused: - is_auto = bool(magnetdl.flags() & lt.torrent_flags.auto_managed) - return "Queued" if is_auto else "Paused" else: return "Queued" elif col == 3: @@ -405,7 +407,6 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): if status.download_rate > 0: bytes_left = status.total_wanted - status.total_wanted_done eta_seconds = bytes_left / status.download_rate - if eta_seconds < 60: return f"{int(eta_seconds)}s" elif eta_seconds < 3600: @@ -418,44 +419,46 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): return f"{hours}h {minutes}m" else: return "∞" if status.paused else "Stalled" - - if role == Qt.ItemDataRole.UserRole and index.column() == 0: - magnet_link = list(state.active_downloads.keys())[index.row()] - magnetdl = state.active_downloads[magnet_link] - return magnetdl.status().paused - + if role == Qt.ItemDataRole.UserRole and index.column() == 0: + return status.paused return None def toggle_pause_resume(self, row): + with state.downloads_lock: + if row >= len(state.active_downloads) or row < 0: + return + try: + magnet_link = list(state.active_downloads.keys())[row] + magnetdl = state.active_downloads[magnet_link] + status = magnetdl.status() + except (IndexError, KeyError, RuntimeError): + return - if row >= len(state.active_downloads) or row < 0: + if status.state == lt.torrent_status.seeding: + try: + save_path = magnetdl.save_path() + if save_path and os.path.exists(save_path): + if platform.system() == "Windows": + os.startfile(os.path.normpath(save_path)) + elif platform.system() == "Linux": + subprocess.Popen(["xdg-open", save_path]) + elif platform.system() == "Darwin": + subprocess.Popen(["open", save_path]) + except Exception: + pass return - - magnet_link = list(state.active_downloads.keys())[row] - magnetdl = state.active_downloads[magnet_link] - status = magnetdl.status() - - if status.state == lt.torrent_status.seeding: - save_path = magnetdl.save_path() - if save_path and os.path.exists(save_path): - if platform.system() == "Windows": - os.startfile(os.path.normpath(save_path)) - elif platform.system() == "Linux": - subprocess.Popen(["xdg-open", save_path]) - elif platform.system() == "Darwin": - subprocess.Popen(["open", save_path]) - return - - is_paused = bool(magnetdl.flags() & lt.torrent_flags.paused) + + is_paused = status.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() consoleLog(f"Resumed download: {status.name}", True) else: - magnetdl.unset_flags(lt.torrent_flags.auto_managed) + if hasattr(magnetdl, 'unset_flags'): + magnetdl.unset_flags(lt.torrent_flags.auto_managed) magnetdl.pause() consoleLog(f"Paused download: {status.name}", True) - idx = self.index(row, 0) self.dataChanged.emit(idx, idx, [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.UserRole]) @@ -485,52 +488,44 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): def setEditorData(self, editor, index): 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] status = magnetdl.status() - if button: - if status.state == lt.torrent_status.seeding: + if status.state == lt.torrent_status.seeding: button.setIcon(svg_icon(SVG_FOLDER, 18)) button.setText("") 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.setText("") def createEditor(self, parent, option, index): - magnet_link = list(state.active_downloads.keys())[index.row()] + magnet_link = list(state.active_downloads.keys())[index.row()] magnetdl = state.active_downloads[magnet_link] status = magnetdl.status() - widget = QWidget(parent) widget.setStyleSheet("border: none; background: transparent;") layout = QHBoxLayout(widget) layout.setContentsMargins(0, 0, 0, 0) - if status.state == lt.torrent_status.seeding: + if status.state == lt.torrent_status.seeding: btnPause = QtWidgets.QPushButton() btnPause.setIcon(svg_icon(SVG_FOLDER, 18)) else: 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.setIconSize(QSize(18, 18)) btnPause.setFixedSize(30, 30) btnPause.setCursor(Qt.CursorShape.PointingHandCursor) - btnPause.setStyleSheet(""" - QPushButton { - border: none; - background: transparent; - padding: 0px; - } - """) + btnPause.setStyleSheet("QPushButton { border: none; background: transparent; padding: 0px; }") btnPause.clicked.connect(lambda: self.clicked.emit(index.row())) layout.addStretch() layout.addWidget(btnPause) layout.addStretch() widget.setLayout(layout) return widget + def editorEvent(self, event, model, option, index): return False @@ -571,13 +566,11 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): self.downloadList.setItemDelegateForColumn(0, delegate) delegate.clicked.connect(on_pause_resume_clicked) - # download button triggers - self.dlbutton.clicked.connect(lambda: run_thread(threading.Thread(target=download_selected, args=(state.tracker_list[state.tracker].selectedItems(), state.posts, state.post_titles)))) + self.dlbutton.clicked.connect(lambda: run_thread(threading.Thread(target=download_selected, args=(state.trackertable.selectedItems(),)))) container.setLayout(containerLayout) self.setCentralWidget(container) - # Tabs self.tabs = QTabWidget() self.tabs.setStyleSheet(""" QTabBar::tab { @@ -592,11 +585,9 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): self.horizontal_layout = QHBoxLayout() self.horizontal_layout.addWidget(self.emptyResults, stretch=3) - self.tracker_widget = state.tracker_list[state.tracker] - self.horizontal_layout.addWidget(self.tracker_widget) + self.horizontal_layout.addWidget(state.trackertable) - self.tab1 = create_tab("Search", self.searchbar, state.tracker_list[state.tracker], self.tabs, self.dlbutton, self.horizontal_layout) - # self.tab2 = create_tab("Library", self.emptyLibrary, self.libraryList, self.tabs, None, None) + self.tab1 = create_tab("Search", self.searchbar, state.trackertable, self.tabs, self.dlbutton, self.horizontal_layout) 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): @@ -607,24 +598,16 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): self.overlay_label.setPixmap(self.pixmap) self.overlay_label.adjustSize() self.overlay_label.raise_() - - # offset_x = -1350 - # offset_y = -550 - x = self.width() - self.overlay_label.width() # - offset_x - y = self.height() - self.overlay_label.height() # - offset_y + x = self.width() - self.overlay_label.width() + y = self.height() - self.overlay_label.height() self.overlay_label.move(x, y) - - # temporarily disabled - # state.image_changed.connect(self.update_image_overlay) - self.corner_widget = QWidget() self.corner_layout = QHBoxLayout(self.corner_widget) self.corner_layout.setContentsMargins(0, 0, 0, 0) - 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.activated.connect(self.set_tracker) self.corner_layout.addWidget(self.tracker_list) @@ -647,14 +630,12 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): self.settings_btn.setToolTip("Settings") self.settings_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.settings_btn.clicked.connect(lambda: settings_dialog(self)) - self.corner_layout.addWidget(self.settings_btn) self.tab_wrapper = QWidget() self.tab_layout = QVBoxLayout() self.tab_layout.setContentsMargins(0, 0, 0, 0) self.tab_layout.setSpacing(0) - self.tab_bar_layout = QHBoxLayout() self.tab_bar_layout.setContentsMargins(0, 0, 0, 0) @@ -666,10 +647,7 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): self.tab_layout.addLayout(self.tab_bar_layout) self.tab_layout.addWidget(self.tabs) - - self.tab_wrapper.setLayout(self.tab_layout) - containerLayout.addWidget(self.tab_wrapper) containerLayout.setAlignment(Qt.AlignmentFlag.AlignTop) @@ -705,24 +683,87 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): self.context_menu.addAction("Cancel Download", self.cancelDownloadAction) 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): self.searchbar.setEnabled(False) def _search_thread(): try: return_pressed(self) - finally: - self.search_finished_signal.emit() + except Exception as e: + consoleLog(f"Search error: {e}", True) + self.search_results_signal.emit([]) run_thread(threading.Thread(target=_search_thread)) - def _on_search_finished(self): - self.searchbar.setEnabled(True) - self.searchbar.setFocus() - @staticmethod def add_log(text): - if MainWindow._instance: + if hasattr(MainWindow, "_instance") and MainWindow._instance: MainWindow._instance.log_signal.emit(text) - + return True + return False + def _on_log_signal(self, text): if hasattr(self, 'consoleLog'): self.consoleLog.append(text) @@ -730,49 +771,92 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): 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): self.image = QImage(new_image_path) self.pixmap = QPixmap.fromImage(self.image) - self.overlay_label.setPixmap(self.pixmap) self.overlay_label.adjustSize() def download_list_update(self): - if self.download_model: - row_count = self.download_model.rowCount() - if row_count > 0: - top_left = self.download_model.index(0, 0) - bottom_right = self.download_model.index(row_count - 1, self.download_model.columnCount() - 1) + if not self.download_model: + self._update_speed_label() + return + + 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]) - if not hasattr(self, '_last_dl_row_count'): - self._last_dl_row_count = 0 - if row_count != self._last_dl_row_count: - - 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 + if row_count != self._last_dl_row_count: + old_count = self._last_dl_row_count + self._last_dl_row_count = row_count + if row_count > 0: self.download_model.layoutAboutToBeChanged.emit() self.download_model.layoutChanged.emit() for row in range(row_count): idx = self.download_model.index(row, 0) - self.downloadList.closePersistentEditor(idx) - self.downloadList.openPersistentEditor(idx) - else: - + if idx.isValid(): + self.downloadList.closePersistentEditor(idx) + 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) for row in range(row_count): idx = self.download_model.index(row, 0) - editor = self.downloadList.indexWidget(idx) - if editor and delegate: - delegate.setEditorData(editor, idx) + if idx.isValid(): + editor = self.downloadList.indexWidget(idx) + if editor and delegate: + delegate.setEditorData(editor, idx) + self._update_speed_label() def _update_speed_label(self): total_down = 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: s = handle.status() total_down += s.download_rate @@ -783,13 +867,13 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): 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" 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): - self.rutrackerlist.clearSelection() - self.uztrackerlist.clearSelection() - self.monkruslist.clearSelection() + state.trackertable.clearSelection() self.downloadList.clearSelection() super().mousePressEvent(event) @@ -804,21 +888,23 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): def eventFilter(self, obj, event): try: - for tbl in (self.rutrackerlist, self.uztrackerlist, self.monkruslist): - if obj == tbl.viewport(): - if event.type() == QEvent.Type.MouseMove: - pos = event.position().toPoint() if hasattr(event, 'position') else event.pos() - idx = tbl.indexAt(pos) - new_row = idx.row() if idx.isValid() else -1 - if new_row != self._tracker_hovered_row or tbl is not self._tracker_hovered_table: - self._tracker_hovered_row = new_row - self._tracker_hovered_table = tbl - tbl.viewport().update() - elif event.type() == QEvent.Type.Leave: - self._tracker_hovered_row = -1 - self._tracker_hovered_table = None - tbl.viewport().update() - break + if hasattr(self, 'speed_label') and obj == self.speed_label: + if event.type() == QEvent.Type.MouseButtonRelease: + settings_dialog(self) + return True + if obj == state.trackertable.viewport(): + if event.type() == QEvent.Type.MouseMove: + pos = event.position().toPoint() if hasattr(event, 'position') else event.pos() + idx = state.trackertable.indexAt(pos) + new_row = idx.row() if idx.isValid() else -1 + if new_row != self._tracker_hovered_row or state.trackertable is not self._tracker_hovered_table: + self._tracker_hovered_row = new_row + self._tracker_hovered_table = state.trackertable + state.trackertable.viewport().update() + elif event.type() == QEvent.Type.Leave: + self._tracker_hovered_row = -1 + self._tracker_hovered_table = None + state.trackertable.viewport().update() if obj == self.downloadList.viewport(): if event.type() == QEvent.Type.MouseMove: 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 self._hovered_row = -1 self._invalidate_hover_row(old_row) - except RuntimeError: + except (RuntimeError, AttributeError): pass return super().eventFilter(obj, event) @@ -842,43 +928,31 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): self.downloadList.viewport().update() def set_tracker(self, _): - old_tracker = state.tracker - state.tracker = self.tracker_list.currentText() - - 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) + state.currenttracker = self.tracker_list.currentText() + self._apply_default_headers(state.trackertable) def show_empty_results(self, show: bool): if show: - state.tracker_list[state.tracker].hide() + state.trackertable.hide() self.emptyResults.show() else: - state.tracker_list[state.tracker].show() + state.trackertable.show() self.emptyResults.hide() 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.downloadList.show() else: self.emptyDownload.show() 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): if self.downloadList.underMouse(): pos = self.downloadList.viewport().mapFromGlobal(event.globalPos()) index = self.downloadList.indexAt(pos) - if index.isValid(): row = index.row() self._context_menu_row = row @@ -889,16 +963,12 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): def openFolderAction(self): if not hasattr(self, '_context_menu_row'): return - row = self._context_menu_row if row < 0 or row >= len(state.active_downloads): return - - magnet_link = list(state.active_downloads.keys())[row] + magnet_link = list(state.active_downloads.keys())[row] magnetdl = state.active_downloads[magnet_link] - download_path = magnetdl.save_path() - if download_path and os.path.exists(download_path): if platform.system() == "Windows": os.startfile(os.path.normpath(download_path)) @@ -910,29 +980,30 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): def copyMagnetURIAction(self): if not hasattr(self, '_context_menu_row'): return - row = self._context_menu_row if row < 0 or row >= len(state.active_downloads): return - - magnet_link = list(state.active_downloads.keys())[row] - + magnet_link = list(state.active_downloads.keys())[row] clipboard = QtWidgets.QApplication.clipboard() clipboard.setText(magnet_link) def cancelDownloadAction(self): if not hasattr(self, '_context_menu_row'): return - row = self._context_menu_row if row < 0 or row >= len(state.active_downloads): return - - magnet_link = list(state.active_downloads.keys())[row] + magnet_link = list(state.active_downloads.keys())[row] 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) 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] remove_download_log(magnet_link) consoleLog(f"Cancelled download: {magnetdl.status().name}", True) @@ -940,20 +1011,24 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): def deleteFileAction(self): if not hasattr(self, '_context_menu_row'): return - row = self._context_menu_row if row < 0 or row >= len(state.active_downloads): return - magnet_link = list(state.active_downloads.keys())[row] magnetdl = state.active_downloads[magnet_link] status = magnetdl.status() save_path = status.save_path torrent_name = status.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) 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): try: if os.path.isfile(download_path): @@ -969,3 +1044,7 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): consoleLog(f"Deleted files for: {magnetdl.status().name}", True) except Exception as e: 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) diff --git a/src/core/interface/utils/searchhelper.py b/src/core/interface/utils/searchhelper.py index eda868a..83a91b7 100644 --- a/src/core/interface/utils/searchhelper.py +++ b/src/core/interface/utils/searchhelper.py @@ -1,66 +1,27 @@ -from PySide6.QtWidgets import QTableWidgetItem +from PySide6.QtWidgets import QTableWidgetItem, QHeaderView from core.utils.logging.logs import consoleLog -from core.data.scrapers.uztracker import scrape_uztracker -from core.data.scrapers.rutracker import scrape_rutracker -from core.data.scrapers.monkrus import scrape_m0nkrus -from core.utils.network.jsonhandler import split_data, format_data, format_data_minimal +from core.data.scrapers.rutracker import init_rutracker +from core.data.scrapers.uztracker import init_uztracker +from core.data.scrapers.monkrus import init_m0nkrus +from core.data.scrapers.steamrip import init_steamrip from core.utils.data.state import state -scrapers = { - "uztracker": scrape_uztracker, - "rutracker": scrape_rutracker, - "m0nkrus": scrape_m0nkrus -} +init_rutracker() +init_uztracker() +init_m0nkrus() +init_steamrip() def return_pressed(self): + self.show_empty_results(False) search_text = self.searchbar.text() if search_text == "": consoleLog("Error: Can't search for nothing") return consoleLog(f"User searched for: {search_text}") - if state.tracker == "rutracker": - response = scrape_rutracker(search_text) - if response: - _, 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) + tracker = state.trackers[state.currenttracker] + scrapefunc = tracker["scrapeFunc"] + state.posts = scrapefunc(search_text) - elif state.tracker is not None: - state.posts = scrapers[state.tracker](search_text) - 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)) + from core.interface.gui import MainWindow + MainWindow._instance.search_results_signal.emit(tracker["headers"]) diff --git a/src/core/network/direct_download/__init__.py b/src/core/network/direct_download/__init__.py new file mode 100644 index 0000000..18d1916 --- /dev/null +++ b/src/core/network/direct_download/__init__.py @@ -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}") diff --git a/src/core/network/direct_download/handle.py b/src/core/network/direct_download/handle.py new file mode 100644 index 0000000..22468f1 --- /dev/null +++ b/src/core/network/direct_download/handle.py @@ -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)}" + ) + diff --git a/src/core/network/direct_download/status.py b/src/core/network/direct_download/status.py new file mode 100644 index 0000000..fa9ca44 --- /dev/null +++ b/src/core/network/direct_download/status.py @@ -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 diff --git a/src/core/network/direct_download/utils.py b/src/core/network/direct_download/utils.py new file mode 100644 index 0000000..fbd4e7d --- /dev/null +++ b/src/core/network/direct_download/utils.py @@ -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" diff --git a/src/core/network/interface.py b/src/core/network/interface.py index 1a09eea..8f63e68 100644 --- a/src/core/network/interface.py +++ b/src/core/network/interface.py @@ -2,19 +2,21 @@ from core.utils.logging.logs import consoleLog from core.utils.data.state import state import psutil -addrs = psutil.net_if_addrs() -stats = psutil.net_if_stats() - def get_net_interfaces(): + addrs = psutil.net_if_addrs() for interface in addrs.keys(): consoleLog(f"Found Interface: {interface}") return addrs.keys() def get_active_interfaces(): + addrs = psutil.net_if_addrs() + stats = psutil.net_if_stats() active = [] for interface, addr_list in addrs.items(): + if interface not in stats: + continue up = stats[interface].isup for addr in addr_list: if addr.family == 2: # ipv4 @@ -27,26 +29,31 @@ def get_active_interfaces(): def list_interfaces() -> None: + addrs = psutil.net_if_addrs() + stats = psutil.net_if_stats() for interface, addr_list in addrs.items(): + if interface not in stats: + continue up = stats[interface].isup + status = "INACTIVE" for addr in addr_list: if addr.family == 2: # ipv4 ipv4 = addr.address if not ipv4.startswith("127.") and not ipv4.startswith("169.254") and up: status = "ACTIVE" - else: - status = "INACTIVE" consoleLog(f"Found Interface: {interface} [{status}]") def init_interfaces(): + addrs = psutil.net_if_addrs() state.interfaces = list(addrs.keys()) state.active_interfaces = get_active_interfaces() def get_interface_ip(interface_name): + addrs = psutil.net_if_addrs() if interface_name in addrs: for addr in addrs[interface_name]: if addr.family == 2: # ipv4 if not addr.address.startswith("127.") and not addr.address.startswith("169.254"): return addr.address - return None \ No newline at end of file + return None diff --git a/src/core/network/libtorrent_int.py b/src/core/network/libtorrent_int.py index 9160b66..ba794ef 100644 --- a/src/core/network/libtorrent_int.py +++ b/src/core/network/libtorrent_int.py @@ -6,7 +6,6 @@ import threading import libtorrent as lt from core.utils.data.state import state from core.utils.logging.logs import consoleLog -from core.utils.general.shutdown import shutdown_event global loop_running @@ -20,8 +19,8 @@ def init_session(): settings = { - "upload_rate_limit": state.up_speed_limit, - "download_rate_limit": state.down_speed_limit, + "upload_rate_limit": state.up_speed_limit * 1024, + "download_rate_limit": state.down_speed_limit * 1024, "enable_dht": True, "enable_lsd": True, "enable_upnp": True, @@ -56,23 +55,24 @@ def add_download(magnet_uri, dl_path=state.download_path): try: handle = state.active_downloads[magnet_uri] 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: consoleLog(f"Error in LibTorrent Handle: {e}") - - 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 + del state.active_downloads[magnet_uri] try: magnetdl = lt.parse_magnet_uri(magnet_uri) @@ -123,7 +123,7 @@ def dl_status_loop(): loop_running = False 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()): try: status = magnetdl.status() @@ -149,8 +149,8 @@ def update_settings(): return settings = { - "upload_rate_limit": state.up_speed_limit, - "download_rate_limit": state.down_speed_limit, + "upload_rate_limit": state.up_speed_limit * 1024, + "download_rate_limit": state.down_speed_limit * 1024, "connections_limit": state.max_connections, "active_downloads": state.max_downloads } diff --git a/src/core/network/libtorrent_misc.py b/src/core/network/libtorrent_misc.py index 6a2fa6d..411e1d7 100644 --- a/src/core/network/libtorrent_misc.py +++ b/src/core/network/libtorrent_misc.py @@ -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: 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) updated.add(magnet_uri) except Exception: diff --git a/src/core/network/libtorrent_wrapper.py b/src/core/network/libtorrent_wrapper.py index 3720353..c6465df 100644 --- a/src/core/network/libtorrent_wrapper.py +++ b/src/core/network/libtorrent_wrapper.py @@ -1,9 +1,12 @@ from core.utils.logging.logs import consoleLog 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:?"): - add_download(uri) + if dl_path: + add_download(uri, dl_path) + else: + add_download(uri) consoleLog("Magnet URI added to LibTorrent") else: consoleLog(f"Invalid Magnet Link: {uri}") \ No newline at end of file diff --git a/src/core/utils/config/config.py b/src/core/utils/config/config.py index f026b2d..59826ec 100644 --- a/src/core/utils/config/config.py +++ b/src/core/utils/config/config.py @@ -23,7 +23,7 @@ def create_config(): } 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}" } @@ -72,6 +72,8 @@ def read_config(): # Paths 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) create_config() diff --git a/src/core/utils/data/state.py b/src/core/utils/data/state.py index 1e33a70..270a95f 100644 --- a/src/core/utils/data/state.py +++ b/src/core/utils/data/state.py @@ -1,6 +1,7 @@ +import threading from PySide6.QtCore import QObject, Signal from PySide6.QtWidgets import QTableWidget -from typing import Optional, Any, List, Dict +from typing import Any, List, Dict from pathlib import Path class AppState(QObject): @@ -8,20 +9,26 @@ class AppState(QObject): def __init__(self): super().__init__() - self.posts: list[Any] | None = None - 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.posts: list[Dict[str,str]] | None = None # titles, urls, author, seederm leecher self.version: str = "dev" self._image_path: str = "" self.ignore_updates: bool = False self.debug: bool = False 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.seeded_magnets: set = set() self.download_path: str = str(Path.home() / "Downloads") self.up_speed_limit: int = 0 self.down_speed_limit: int = 0 @@ -30,11 +37,16 @@ class AppState(QObject): self.settings_path: str = "" self.dl_session: Any = None self.active_downloads: Dict = {} - self.seeded_magnets: set = set() self.window_transparency: bool = False self.interfaces: List = [] self.active_interfaces: List = [] 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 def image_path(self) -> str: diff --git a/src/core/utils/data/tracker.py b/src/core/utils/data/tracker.py index 79ed98f..f70756b 100644 --- a/src/core/utils/data/tracker.py +++ b/src/core/utils/data/tracker.py @@ -1,31 +1,6 @@ import requests from bs4 import BeautifulSoup -from core.utils.data.state import state 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): diff --git a/src/core/utils/general/shutdown.py b/src/core/utils/general/shutdown.py index 1834f45..333cb4b 100644 --- a/src/core/utils/general/shutdown.py +++ b/src/core/utils/general/shutdown.py @@ -1,10 +1,13 @@ -import threading +from core.utils.data.state import state import os -shutdown_event = threading.Event() - 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(): os._exit(0) \ No newline at end of file diff --git a/src/core/utils/logging/loghandler.py b/src/core/utils/logging/loghandler.py index 5d48d10..a58305a 100644 --- a/src/core/utils/logging/loghandler.py +++ b/src/core/utils/logging/loghandler.py @@ -15,18 +15,24 @@ def check_completed(downloads, resume): if download.completed == False: consoleLog(f"Found unfinished download: {download.title}") if resume == True: - run_download_direct(download.magnet_uri) - consoleLog(f"Resuming {download.title}") + dl_dir = os.path.dirname(download.path) + 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): 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}") try: seed_magnet(download.magnet_uri, download.path) except Exception as 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}") remove_download_log(download.magnet_uri) \ No newline at end of file diff --git a/src/core/utils/logging/logs.py b/src/core/utils/logging/logs.py index c78fd7c..f176a1c 100644 --- a/src/core/utils/logging/logs.py +++ b/src/core/utils/logging/logs.py @@ -8,10 +8,6 @@ import re import time import threading -_log_buffer = [] -_downloads_lock = threading.RLock() - - def add_download_log(title, url, magnet_uri, completed) -> DownloadList: # wait for metadata outside the lock to avoid blocking other threads 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) else: 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) 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: downloads = [] - if any(d.magnet_uri == magnet_uri or d.url == url for d in downloads): - if magnet_uri in state.active_downloads: + if any((magnet_uri and d.magnet_uri == magnet_uri) or (url and d.url == url) for d in downloads): + if magnet_uri and magnet_uri in state.active_downloads: consoleLog("Skipping Logging, download already running...") - return + return DownloadList(data=downloads, count=len(downloads)) consoleLog("File already in Log, updating Download State...") hash = extract_hash_from_magnet(magnet_uri) - update_download_completed_by_hash(hash, False) - return DownloadList(data=downloads, count=len(downloads)) # thanks again claude (im stupid) + if hash: + update_download_completed_by_hash(hash, False) + return DownloadList(data=downloads, count=len(downloads)) downloads.append(Download( @@ -71,7 +68,7 @@ def _add_download_log_inner(title, url, magnet_uri, completed, path) -> Download return download_list def remove_download_log(magnet_uri) -> DownloadList: - with _downloads_lock: + with state.downloads_lock: return _remove_download_log_inner(magnet_uri) 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() + 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') 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 def update_download_completed(magnet_uri, completed) -> DownloadList: - with _downloads_lock: + with state.downloads_lock: return _update_download_completed_inner(magnet_uri, completed) 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: - with _downloads_lock: + with state.downloads_lock: return _get_download_logs_inner() def _get_download_logs_inner() -> DownloadList: @@ -176,15 +176,24 @@ def _get_download_logs_inner() -> DownloadList: return DownloadList(data=downloads, count=len(downloads)) -def extract_hash_from_magnet(magnet_uri): # full credits to claude for this - match = re.search(r'urn:btih:([A-F0-9]+)', magnet_uri, re.IGNORECASE) - if match: - return match.group(1).upper() - return None +def extract_hash_from_magnet(magnet_uri): + if not magnet_uri: + return None + try: + 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: - with _downloads_lock: + with state.downloads_lock: return _update_download_completed_by_hash_inner(info_hash, completed) 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 -_main_window = None - def set_main_window(window): - global _main_window - _main_window = window + state.main_window = window def flush_log_buffer(): # credits to claude - global _log_buffer - if _log_buffer: + if state.log_buffer: try: from core.interface.gui import MainWindow - for log_entry in _log_buffer: + for log_entry in state.log_buffer: MainWindow.add_log(log_entry) - _log_buffer = [] + state.log_buffer = [] except Exception: pass @@ -254,10 +259,10 @@ def consoleLog(text, printAnyways = False): try: 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: - global _log_buffer - _log_buffer.append(formatted_text) + state.log_buffer.append(formatted_text) if state.debug or printAnyways: print(formatted_text) diff --git a/src/core/utils/network/download.py b/src/core/utils/network/download.py index 69082b0..9ea6e96 100644 --- a/src/core/utils/network/download.py +++ b/src/core/utils/network/download.py @@ -1,14 +1,18 @@ +from PySide6.QtWidgets import QTableWidgetItem +from PySide6.QtCore import Qt from core.utils.logging.logs import consoleLog -from core.utils.data.tracker import get_item_url -from core.utils.data.tracker import get_magnet_link -from core.network.libtorrent_wrapper import add_download +from core.network.libtorrent_wrapper import add_magnet from core.utils.general.wrappers import run_thread from core.utils.logging.logs import add_download_log 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 -def download_selected(items, posts, post_titles): + + +def download_selected(items: list[QTableWidgetItem]): if not items: consoleLog("No item selected for download.") return @@ -16,25 +20,32 @@ def download_selected(items, posts, post_titles): for item in items: if item.column() != 0: continue - text = item.text() - if text and text not in seen: - seen.add(text) - consoleLog(f"Downloading {text}") - run_thread(threading.Thread(target=run_download, args=(text, posts, post_titles))) + post_idx = item.data(Qt.ItemDataRole.UserRole) + if post_idx is None: + post_idx = item.row() + + if post_idx not in seen: + seen.add(post_idx) + post = state.posts[post_idx] + consoleLog(f"Downloading {post.get('title', 'Unknown')}") + run_thread(threading.Thread(target=run_download, args=(post,))) -def run_download(item, posts, post_titles): - post_url = get_item_url(item, posts, post_titles) - consoleLog(f"Selected URL: {post_url}") - magnet_uri = get_magnet_link(post_url) +def run_download(post): + linkfunc = state.trackers[state.currenttracker]["linkFunc"] + ismagnet = state.trackers[state.currenttracker]["isMagnet"] + link = linkfunc(post) - if add_download(magnet_uri): - add_download_log(item, post_url, magnet_uri, False) + if ismagnet: + add_magnet(link) + add_download_log(post.get("title", "Unknown"), "", link, False) + else: + add_direct_download(link, post.get("title", "Unknown")) -def run_download_direct(magnet_uri): - consoleLog(f"Direct download: {magnet_uri[:60]}") - add_download(magnet_uri) - add_download_log("Direct Download", "", magnet_uri, False) +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): consoleLog(f"Seeding: {magnet_uri[:60]}") - add_seed(magnet_uri, file_path) \ No newline at end of file + add_seed(magnet_uri, file_path) diff --git a/src/core/utils/network/update_checker.py b/src/core/utils/network/update_checker.py index 954a691..700af59 100644 --- a/src/core/utils/network/update_checker.py +++ b/src/core/utils/network/update_checker.py @@ -17,8 +17,8 @@ def get_updates(): release = response.json() - latest_version = release["name"] - assets = release["assets"] + latest_version = release.get("name") or release.get("tag_name") + assets = release.get("assets", []) release_assets = [] @@ -31,7 +31,7 @@ def get_updates(): release_assets.append(dict( name=asset['name'], url=asset['browser_download_url'], - hash=asset['digest'] + hash=asset.get('digest') )) return release_assets else: diff --git a/src/main.py b/src/main.py index 8101f87..08ca924 100644 --- a/src/main.py +++ b/src/main.py @@ -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.logging.logs import consoleLog 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.general.shutdown import closehelper, shutdown_event +from core.utils.general.shutdown import closehelper from core.utils.general.wrappers import run_thread from core.utils.logging.loghandler import split_data, check_completed, check_downloads from core.network.interface import list_interfaces, init_interfaces @@ -48,13 +48,13 @@ def main(): consoleLog("Initializing Interface variables...") init_interfaces() 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") - 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") run_thread(threading.Thread(target=check_completed, args=(downloads, state.autoresume))) 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") run_thread(threading.Thread(target=check_downloads, args=(downloads,))) consoleLog("Started Thread: check_downloads")