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..3892089 100644 --- a/src/core/data/scrapers/monkrus.py +++ b/src/core/data/scrapers/monkrus.py @@ -1,4 +1,8 @@ +from core.utils.data.state import state +from core.utils.network.jsonhandler import format_data +from core.utils.data.tracker import get_magnet_link from bs4 import BeautifulSoup +from typing import Dict import requests import time @@ -31,7 +35,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 +46,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 +71,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 c331166..3e19cd1 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,29 +141,25 @@ 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) @@ -188,16 +202,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) @@ -218,18 +267,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) @@ -240,7 +286,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) @@ -261,6 +306,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.") @@ -271,109 +317,52 @@ 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: + def rowCount(self, parent=QModelIndex()): return len(state.active_downloads) - - def columnCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()): + + 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: + def data(self, index, role=Qt.ItemDataRole.DisplayRole): + if role == Qt.ItemDataRole.DisplayRole: col = index.column() - if index.row() >= len(state.active_downloads) or index.row() < 0: return None - - 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 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 = status.auto_managed + 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: @@ -400,7 +389,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: @@ -413,24 +401,19 @@ 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()] + 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 - return None def toggle_pause_resume(self, row): - if row >= len(state.active_downloads) or row < 0: return - - magnet_link = list(state.active_downloads.keys())[row] + magnet_link = list(state.active_downloads.keys())[row] magnetdl = state.active_downloads[magnet_link] status = magnetdl.status() - - if status.state == lt.torrent_status.seeding: + 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": @@ -440,8 +423,7 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): 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) magnetdl.resume() @@ -450,7 +432,6 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): 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]) @@ -480,52 +461,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 @@ -566,13 +539,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 { @@ -587,11 +558,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): @@ -602,24 +571,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) @@ -642,14 +603,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) @@ -661,10 +620,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) @@ -700,24 +656,82 @@ 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: + headers = ["Post Title", "Author", "Seeders", "Leechers"] + + 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) @@ -725,43 +739,83 @@ 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): @@ -779,12 +833,9 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): 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}") - def mousePressEvent(self, event): - self.rutrackerlist.clearSelection() - self.uztrackerlist.clearSelection() - self.monkruslist.clearSelection() + state.trackertable.clearSelection() self.downloadList.clearSelection() super().mousePressEvent(event) @@ -799,21 +850,19 @@ 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 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() @@ -837,22 +886,15 @@ 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): @@ -863,17 +905,10 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): 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 @@ -884,16 +919,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)) @@ -905,29 +936,25 @@ 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 state.dl_session: + state.dl_session.remove_torrent(magnetdl) del state.active_downloads[magnet_link] remove_download_log(magnet_link) consoleLog(f"Cancelled download: {magnetdl.status().name}", True) @@ -935,20 +962,19 @@ 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 state.dl_session: + state.dl_session.remove_torrent(magnetdl) if download_path and os.path.exists(download_path): try: if os.path.isfile(download_path): @@ -964,3 +990,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/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..06db9e5 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 @@ -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() 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..1dd6b2b 100644 --- a/src/core/utils/logging/loghandler.py +++ b/src/core/utils/logging/loghandler.py @@ -15,18 +15,19 @@ 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) + dl_dir = os.path.dirname(download.path) + run_download_direct(download.magnet_uri, dl_dir) consoleLog(f"Resuming {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..c65f041 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: @@ -47,11 +43,11 @@ def _add_download_log_inner(title, url, magnet_uri, completed, path) -> Download if any(d.magnet_uri == magnet_uri or d.url == url for d in downloads): if 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) + return DownloadList(data=downloads, count=len(downloads)) downloads.append(Download( @@ -71,7 +67,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: @@ -101,7 +97,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 +153,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 +172,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 +235,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 +255,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..3d1d66c 100644 --- a/src/core/utils/network/download.py +++ b/src/core/utils/network/download.py @@ -1,14 +1,19 @@ +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 urllib.parse import urlparse, unquote import threading +import re +import requests +import random -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 +21,63 @@ 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 get_direct_filename(url: str, headers) -> str: + cd = headers.get('content-disposition', '') + if cd: + match = re.search(r'filename\*=UTF-8\'\'(.+)', cd) # RFC 5987 encoded + if match: + return unquote(match.group(1)) + match = re.search(r'filename="?([^";\n]+)"?', cd) + if match: + return match.group(1).strip() - if add_download(magnet_uri): - add_download_log(item, post_url, magnet_uri, False) + path = urlparse(url).path + name = path.split('/')[-1] + if name: + return unquote(name) -def run_download_direct(magnet_uri): + return f"download{random.randint(1,9999)}" #avoid collision + +def filedownload(url): + try: + with requests.get(url, stream=True, timeout=30) as r: + r.raise_for_status() + name = get_direct_filename(url, r.headers) + + with open(state.download_path + f"/{name}", 'wb') as f: + for chunk in r.iter_content(chunk_size=8192): + f.write(chunk) + + consoleLog(f"Finished downloading {name}") + except Exception as e: + consoleLog(f"Error downloading: {e}") + +def run_download(post): + linkfunc = state.trackers[state.currenttracker]["linkFunc"] + ismagnet = state.trackers[state.currenttracker]["isMagnet"] + link = linkfunc(post) + + if ismagnet: + add_magnet(link) + add_download_log(post.get("title", "Unknown"), "", link, False) + else: + filedownload(link) + +def run_download_direct(magnet_uri, dl_path=None): consoleLog(f"Direct download: {magnet_uri[:60]}") - add_download(magnet_uri) + add_magnet(magnet_uri, dl_path) add_download_log("Direct Download", "", 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 ea61754..6c38519 100644 --- a/src/core/utils/network/update_checker.py +++ b/src/core/utils/network/update_checker.py @@ -8,12 +8,12 @@ def get_updates(): if response.status_code != 200: consoleLog(f"Failed to fetch releases: {response.status_code}") - return None, None + return None 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 = [] @@ -26,11 +26,11 @@ 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: - return None, None + return None else: consoleLog("Already up-to-date.") - return None, None + return None 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")