From 404b6d55e4c1d9eb5a40dc4908ad79b2e9275911 Mon Sep 17 00:00:00 2001 From: p0358 Date: Thu, 8 May 2025 18:37:09 +0200 Subject: [PATCH 01/15] basic Linux support (fix notifications and config) --- main.pyw | 6 ++++-- util/gui.py | 44 +++++++++++++++++++++++++++++++------------- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/main.pyw b/main.pyw index ce9f60f..c79aaed 100644 --- a/main.pyw +++ b/main.pyw @@ -4,6 +4,7 @@ from util import gui from PyQt5 import QtWidgets +from PyQt5.QtCore import QStandardPaths import sys import configparser @@ -48,8 +49,9 @@ def main() -> None: ui.setupUi(MainWindow) MainWindow.show() - config_dir = os.path.join(os.environ["APPDATA"], "NyaaDownloader") - config_path = os.path.join(config_dir, "config.ini") + config_filename = "config.ini" + config_dir = QStandardPaths.writableLocation(QStandardPaths.AppDataLocation) + config_path = os.path.join(config_dir, config_filename) config = configparser.ConfigParser() config.read(config_path) diff --git a/util/gui.py b/util/gui.py index beb519a..830894a 100644 --- a/util/gui.py +++ b/util/gui.py @@ -5,21 +5,22 @@ from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtWidgets import QMessageBox, QDialog, QInputDialog, QLineEdit -from PyQt5.QtCore import Qt, QThread, pyqtSignal -from winotify import Notification, audio +from PyQt5.QtCore import Qt, QThread, QStandardPaths, pyqtSignal from shutil import move +import platform import configparser import textwrap import os import webbrowser as wb +import urllib # ------------------------------GLOBAL VARIABLES------------------------------ unhandled_characters = ["\\", "/", ":", "*", "?", '"', "<", ">", "|"] -ICON_PATH = "ico\\nyaa.ico" +ICON_PATH = os.path.join("ico", "nyaa.ico") # ------------------------------CLASSES AND METHODS------------------------------ @@ -31,9 +32,10 @@ def update_config(key: str, value: str) -> None: key (str): Key to update value (str): Value to set for the key """ - config_dir = os.path.join(os.environ["APPDATA"], "NyaaDownloader") + config_filename = "config.ini" + config_dir = QStandardPaths.writableLocation(QStandardPaths.AppDataLocation) os.makedirs(config_dir, exist_ok=True) - config_path = os.path.join(config_dir, "config.ini") + config_path = os.path.join(config_dir, config_filename) config = configparser.ConfigParser() config.read(config_path) @@ -48,7 +50,7 @@ def update_config(key: str, value: str) -> None: # Generated with Qt Designer (first time using this one) class Ui_MainWindow(QDialog): - def setupUi(self, MainWindow) -> None: + def setupUi(self, MainWindow: QtWidgets.QMainWindow) -> None: """Build skeleton of the GUI Args: @@ -398,13 +400,29 @@ def notify(self, message: str) -> None: message (str): Message to be displayed """ - toast = Notification( - app_id="NyaaDownloader", - title="NyaaDownloader", - msg=message, - ) - toast.set_audio(audio.Default, loop=False) - toast.build().show() + system = platform.system() + if system == "Linux": + import gi + gi.require_version("Notify", "0.7") + from gi.repository import Notify + Notify.init("NyaaDownloader") + Notify.Notification.new("NyaaDownloader", message).show() + elif system == "Windows": + from winotify import Notification, audio + toast = Notification( + app_id="NyaaDownloader", + title="NyaaDownloader", + msg=message, + ) + toast.set_audio(audio.Default, loop=False) + toast.build().show() + elif system == "Darwin": + from Foundation import NSUserNotification, NSUserNotificationCenter + notification = NSUserNotification.alloc().init() + notification.setTitle("NyaaDownloader") + notification.setInformativeText(message) + NSUserNotificationCenter.defaultUserNotificationCenter().deliverNotification(notification) + def save_logs(self) -> None: """Saves the logs to a .txt file.""" From 01d121aea82391ec9e252adb406c0d0ba76823a1 Mon Sep 17 00:00:00 2001 From: p0358 Date: Thu, 8 May 2025 18:39:02 +0200 Subject: [PATCH 02/15] fix for current NyaaPy --- util/gui.py | 6 +++--- util/nyaa.py | 33 +++++++++++++++++---------------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/util/gui.py b/util/gui.py index 830894a..67ca634 100644 --- a/util/gui.py +++ b/util/gui.py @@ -566,7 +566,7 @@ def run(self) -> None: ) else: - self.error_popup.emit("No Internet connection available") + self.error_popup.emit(f"Failed downloading: {torrent.name}") unexpected_end = True break @@ -581,7 +581,7 @@ def run(self) -> None: self.update_logs.emit(f"Found: {anime_name} - Episode {episode}") # Erai-raws add "END" in the torrent name when an anime has finished airing - if uploader == "Erai-raws" and torrent["name"].find(" END [") != -1: + if uploader == "Erai-raws" and torrent.name.find(" END [") != -1: self.update_logs.emit( f"Note: {anime_name} has no more than {episode} episodes" ) @@ -593,7 +593,7 @@ def run(self) -> None: else: if uploader == uploaders[-1]: self.update_logs.emit( - f"Failed: {anime_name} - Episode {episode}" + f"Failed finding: {anime_name} - Episode {episode}" ) fails_in_a_row += 1 diff --git a/util/nyaa.py b/util/nyaa.py index 3f8e520..d92c387 100644 --- a/util/nyaa.py +++ b/util/nyaa.py @@ -1,7 +1,7 @@ # ------------------------------IMPORTS------------------------------ -import NyaaPy +import nyaapy.nyaasi.nyaa as NyaaPy import requests import webbrowser as wb @@ -15,7 +15,7 @@ def is_in_database(anime_name: str) -> bool: anime_name (str): Name of the anime to check. Raises: - Exception: If the anime is not found in the database, the only possible reason is that user has no Internet connection. + Exception: If the underlying module throws one. Returns: bool: True if check was successful, False otherwise. @@ -30,8 +30,8 @@ def is_in_database(anime_name: str) -> bool: == 0 ): return False - except: - raise Exception("No Internet connection available.") + except Exception as e: + raise e return True @@ -45,8 +45,8 @@ def download(torrent: dict) -> bool: bool: True if the transfer was successful, False otherwise. """ try: - with requests.get(torrent["download_url"]) as response, open( - torrent["name"] + ".torrent", "wb" + with requests.get(torrent.download_url) as response, open( + torrent.name + ".torrent", "wb" ) as out_file: out_file.write(response.content) @@ -65,7 +65,7 @@ def transfer(torrent: dict) -> bool: bool: True if the transfer was successful, False otherwise. """ try: - wb.open(torrent["magnet"]) + wb.open(torrent.magnet) except: return False @@ -102,14 +102,17 @@ def find_torrent(uploader: str, anime_name: str, episode: int, quality: int, unt subcategory=2, filters=0 if untrusted_option else 2, ) + + if not isinstance(found_torrents, list) or len(found_torrents) == 0: + return {} try: # We take the very closest title to what we are looking for. torrent = None for t in found_torrents: # (break if found, so we get the most recent one) if ( - t["name"].lower().find(f"{anime_name} - {episode}".lower()) != -1 - and t["name"].lower().find("~") == -1 + t.name.lower().find(f"{anime_name} - {episode}".lower()) != -1 + and t.name.lower().find("~") == -1 ): # we want to avoid ~ because Erai-Raws use it for already packed episodes torrent = t break @@ -118,16 +121,14 @@ def find_torrent(uploader: str, anime_name: str, episode: int, quality: int, unt if torrent is None: for t in found_torrents: if ( - t["name"].lower().find(f"{anime_name}".lower()) != -1 - and t["name"].lower().find(f" {episode} ") != -1 - and t["name"].lower().find("~") == -1 + t.name.lower().find(f"{anime_name}".lower()) != -1 + and t.name.lower().find(f" {episode} ") != -1 + and t.name.lower().find("~") == -1 ): # we want to avoid ~ because Erai-Raws use it for already packed episodes torrent = t break - # The only exception possible is that no torrent have been found when NyaaPy.Nyaa.search() - # (we are doing dict operations on a None object => raise an exception) - except: - return {} + except Exception as e: + raise e return torrent From 74f6662410c65b38c22d69645f87f515a0c03b1f Mon Sep 17 00:00:00 2001 From: p0358 Date: Thu, 8 May 2025 18:48:52 +0200 Subject: [PATCH 03/15] fix saving .torrent files (btw can't use signal to create a folder and use move immediately, this creates a race condition, on my system the folder would simply always not exist, maybe on Windows trying to move a file is so slow that a directory would have existed by that time LOL) --- util/gui.py | 42 +++++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/util/gui.py b/util/gui.py index 67ca634..d6969d9 100644 --- a/util/gui.py +++ b/util/gui.py @@ -369,18 +369,25 @@ def set_widget_after_check(self) -> None: self.pushButton_4.setVisible(False) # Disabling Stop button self.pushButton_3.setEnabled(True) # Enabling Save logs button - def generate_download_folder(self, anime_name: str) -> None: + @staticmethod + def get_download_folder(anime_name: str) -> str: + anime_name = " ".join(anime_name.strip().split()) + + for s in unhandled_characters: + anime_name = anime_name.replace(s, "") + + downloads = QStandardPaths.writableLocation(QStandardPaths.DownloadLocation) + return os.path.join(downloads, "DownloadedTorrents", anime_name) + + @classmethod + def generate_download_folder(cls, anime_name: str) -> None: """Generates a folder name for the .torrents download. Args: anime_name (str): Name of the anime. """ - - for s in unhandled_characters: - anime_name = anime_name.replace(s, "") - try: - os.makedirs(f"DownloadedTorrents\\{anime_name}") + os.makedirs(cls.get_download_folder(anime_name), exist_ok=True) except FileExistsError: pass @@ -388,7 +395,14 @@ def generate_download_folder(self, anime_name: str) -> None: def open_download_folder(self) -> None: """Open the DownloadedTorrents folder""" try: - os.startfile(f"{os.getcwd()}\DownloadedTorrents") + global anime_name + path = None + if anime_name is not None and len(anime_name) > 0: + path = self.get_download_folder(anime_name) + if path is None or not os.path.exists(path): + path = self.get_download_folder("") + if not QtGui.QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(path)): + self.show_error_popup("Failed opening: " + path) except Exception as e: self.show_error_popup("DownloadedTorrents folder not found because: " + str(e)) @@ -485,11 +499,7 @@ def is_everything_good(self) -> None: option = 1 if self.radioButton.isChecked() else 2 untrusted_option = True if self.radioButton_2.isChecked() else False - folder_name = anime_name - for c in unhandled_characters: - folder_name = folder_name.replace(c, "") - - path = f"DownloadedTorrents\\{folder_name}" + path = self.get_download_folder(anime_name) self.start_checking() else: @@ -508,7 +518,6 @@ def start_checking(self) -> None: self.worker.finished.connect(lambda: self.worker_finished()) self.worker.update_logs.connect(self.append_to_logs) self.worker.error_popup.connect(self.show_error_popup) - self.worker.gen_folder.connect(self.generate_download_folder) def worker_finished(self) -> None: """When the thread has finished processing, enable all widgets again and notify the user @@ -536,7 +545,6 @@ class WorkerThread(QThread): update_logs = pyqtSignal(str) error_popup = pyqtSignal(str) - gen_folder = pyqtSignal(str) def run(self) -> None: """The "almost main" function of that program. Will download/transfer every found torrent. Will also handle logs update, etc.""" @@ -559,10 +567,10 @@ def run(self) -> None: if option == 1: if nyaa.download(torrent): - self.gen_folder.emit(anime_name) + Ui_MainWindow.generate_download_folder(anime_name) move( - f"{torrent['name']}.torrent", - f"{path}\\{torrent['name']}.torrent", + f"{torrent.name}.torrent", + os.path.join(path, f"{torrent.name}.torrent"), ) else: From f3370783834c77805de3b3713bb6782a246dcf69 Mon Sep 17 00:00:00 2001 From: p0358 Date: Thu, 8 May 2025 18:49:00 +0200 Subject: [PATCH 04/15] cosmetics --- main.pyw | 2 +- util/gui.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/main.pyw b/main.pyw index c79aaed..465e6ef 100644 --- a/main.pyw +++ b/main.pyw @@ -40,7 +40,7 @@ def main() -> None: Only depends if the uploaders uploaded them episode by episode or not.

If you find any bug, please let me know on my GitHub:
- https://github.com/marcpinet.

""" + https://github.com/marcpinet/nyaadownloader.

""" app = QtWidgets.QApplication([]) app.setApplicationName("NyaaDownloader") diff --git a/util/gui.py b/util/gui.py index d6969d9..2e96178 100644 --- a/util/gui.py +++ b/util/gui.py @@ -273,9 +273,10 @@ def retranslateUi(self, MainWindow) -> None: def ask_anime_to_translate(self) -> None: """Asking anime title to translate by opening a link to MyAnimeList""" text, okPressed = QInputDialog.getText( - self, "Title Translator", "Anime Title:", QLineEdit.Normal, "" + self, "Search in MyAnimeList", "Anime title to find in MyAnimeList:", QLineEdit.Normal ) if okPressed and text != "": + text = urllib.quote(text) wb.open(f"https://myanimelist.net/anime.php?q={text}&cat=anime") def cancel_process(self) -> None: @@ -408,7 +409,7 @@ def open_download_folder(self) -> None: self.show_error_popup("DownloadedTorrents folder not found because: " + str(e)) def notify(self, message: str) -> None: - """Generate a windows 10 notifcation with a message + """Generate a notifcation with a message Args: message (str): Message to be displayed From fe0e22d8b5bb46e8dad823d743675a1a9d77203f Mon Sep 17 00:00:00 2001 From: p0358 Date: Thu, 8 May 2025 18:58:24 +0200 Subject: [PATCH 05/15] update to Qt6 cause we're in 2025 (also that makes icon setting work wow) --- main.pyw | 8 ++++---- util/gui.py | 26 +++++++++++++------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/main.pyw b/main.pyw index 465e6ef..da29acc 100644 --- a/main.pyw +++ b/main.pyw @@ -3,8 +3,8 @@ from util import gui -from PyQt5 import QtWidgets -from PyQt5.QtCore import QStandardPaths +from PyQt6 import QtWidgets +from PyQt6.QtCore import QStandardPaths import sys import configparser @@ -50,7 +50,7 @@ def main() -> None: MainWindow.show() config_filename = "config.ini" - config_dir = QStandardPaths.writableLocation(QStandardPaths.AppDataLocation) + config_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation) config_path = os.path.join(config_dir, config_filename) config = configparser.ConfigParser() @@ -59,7 +59,7 @@ def main() -> None: if not config.has_option("Settings", "ShowPopup") or config.getboolean("Settings", "ShowPopup"): gui.Ui_MainWindow.show_info_popup(MainWindow, message, never_show_again=True) - sys.exit(app.exec_()) + sys.exit(app.exec()) # ------------------------------MAIN CALL------------------------------ diff --git a/util/gui.py b/util/gui.py index 2e96178..d702d96 100644 --- a/util/gui.py +++ b/util/gui.py @@ -3,9 +3,9 @@ from . import nyaa -from PyQt5 import QtCore, QtGui, QtWidgets -from PyQt5.QtWidgets import QMessageBox, QDialog, QInputDialog, QLineEdit -from PyQt5.QtCore import Qt, QThread, QStandardPaths, pyqtSignal +from PyQt6 import QtCore, QtGui, QtWidgets +from PyQt6.QtWidgets import QMessageBox, QDialog, QInputDialog, QLineEdit +from PyQt6.QtCore import Qt, QThread, QStandardPaths, pyqtSignal from shutil import move import platform @@ -33,7 +33,7 @@ def update_config(key: str, value: str) -> None: value (str): Value to set for the key """ config_filename = "config.ini" - config_dir = QStandardPaths.writableLocation(QStandardPaths.AppDataLocation) + config_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation) os.makedirs(config_dir, exist_ok=True) config_path = os.path.join(config_dir, config_filename) @@ -59,9 +59,9 @@ def setupUi(self, MainWindow: QtWidgets.QMainWindow) -> None: MainWindow.setObjectName("NyaaDownloader") MainWindow.setWindowIcon(QtGui.QIcon(ICON_PATH)) - MainWindow.resize(800, 341) - MainWindow.setMinimumSize(QtCore.QSize(800, 341)) - MainWindow.setLocale(QtCore.QLocale(QtCore.QLocale.English, QtCore.QLocale.UnitedStates)) + MainWindow.resize(800, 450) + MainWindow.setMinimumSize(QtCore.QSize(800, 450)) + MainWindow.setLocale(QtCore.QLocale(QtCore.QLocale.Language.English, QtCore.QLocale.Country.UnitedStates)) self.centralwidget = QtWidgets.QWidget(MainWindow) self.centralwidget.setObjectName("centralwidget") @@ -195,7 +195,7 @@ def setupUi(self, MainWindow: QtWidgets.QMainWindow) -> None: self.statusbar.setObjectName("statusbar") MainWindow.setStatusBar(self.statusbar) - self.actionGet_translation_of_an_anime_title = QtWidgets.QAction(MainWindow) + self.actionGet_translation_of_an_anime_title = QtWidgets.QWidgetAction(MainWindow) self.actionGet_translation_of_an_anime_title.setObjectName("actionGet_translation_of_an_anime_title") self.menuTranslator.addAction(self.actionGet_translation_of_an_anime_title) self.menubar.addAction(self.menuTranslator.menuAction()) @@ -273,10 +273,10 @@ def retranslateUi(self, MainWindow) -> None: def ask_anime_to_translate(self) -> None: """Asking anime title to translate by opening a link to MyAnimeList""" text, okPressed = QInputDialog.getText( - self, "Search in MyAnimeList", "Anime title to find in MyAnimeList:", QLineEdit.Normal + self, "Search in MyAnimeList", "Anime title to find in MyAnimeList:", QLineEdit.EchoMode.Normal ) if okPressed and text != "": - text = urllib.quote(text) + text = urllib.parse.quote(text) wb.open(f"https://myanimelist.net/anime.php?q={text}&cat=anime") def cancel_process(self) -> None: @@ -377,7 +377,7 @@ def get_download_folder(anime_name: str) -> str: for s in unhandled_characters: anime_name = anime_name.replace(s, "") - downloads = QStandardPaths.writableLocation(QStandardPaths.DownloadLocation) + downloads = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DownloadLocation) return os.path.join(downloads, "DownloadedTorrents", anime_name) @classmethod @@ -450,8 +450,8 @@ def save_logs(self) -> None: if name != "": with open(f"{name}.txt", "w") as f: f.write(self.textBrowser.toPlainText()) - except Exception: - pass + except Exception as e: + self.show_error_popup(e) def is_everything_good(self) -> None: """Check if every input values are correct and if yes, will call the start_checking method""" From 70ddddc5f56d17b4580671b66f57a9ae61dfafbd Mon Sep 17 00:00:00 2001 From: p0358 Date: Thu, 8 May 2025 19:05:01 +0200 Subject: [PATCH 06/15] Someone forgot disabling this checkbox during operations --- util/gui.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/util/gui.py b/util/gui.py index d702d96..44cf21e 100644 --- a/util/gui.py +++ b/util/gui.py @@ -348,6 +348,7 @@ def set_widget_while_check(self) -> None: self.radioButton.setEnabled(False) self.radioButton_2.setEnabled(False) self.pushButton_3.setEnabled(False) + self.checkBox_2.setEnabled(False) self.pushButton_4.setVisible(True) @@ -364,6 +365,7 @@ def set_widget_after_check(self) -> None: self.checkBox.setEnabled(True) self.radioButton.setEnabled(True) self.radioButton_2.setEnabled(True) + self.checkBox_2.setEnabled(True) self.checkBox.setChecked(False) # Reseting checkbox self.pushButton_2.setEnabled(True) # Enabling Open Folder button From 7c25417de727ab4cbab966e993ecd54136f5c61a Mon Sep 17 00:00:00 2001 From: p0358 Date: Thu, 8 May 2025 19:10:29 +0200 Subject: [PATCH 07/15] enable checkbox "Until last released one" by default and don't reset it after completion, change fails_in_a_row to 3 --- util/gui.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/util/gui.py b/util/gui.py index 44cf21e..5ca5afb 100644 --- a/util/gui.py +++ b/util/gui.py @@ -105,10 +105,12 @@ def setupUi(self, MainWindow: QtWidgets.QMainWindow) -> None: self.spinBox_2.setMinimum(1) self.spinBox_2.setMaximum(10000) self.spinBox_2.setObjectName("spinBox_2") + self.spinBox_2.setEnabled(False) mid_layout.addWidget(self.spinBox_2) self.checkBox = QtWidgets.QCheckBox(self.centralwidget) - self.checkBox.setObjectName("checkBox") + self.checkBox.setObjectName("checkBox") + self.checkBox.setChecked(True) mid_layout.addWidget(self.checkBox) left_layout.addLayout(mid_layout) @@ -361,13 +363,13 @@ def set_widget_after_check(self) -> None: self.lineEdit_2.setEnabled(True) self.comboBox.setEnabled(True) self.spinBox.setEnabled(True) - self.spinBox_2.setEnabled(True) + if not self.checkBox.isChecked(): + self.spinBox_2.setEnabled(True) self.checkBox.setEnabled(True) self.radioButton.setEnabled(True) self.radioButton_2.setEnabled(True) self.checkBox_2.setEnabled(True) - self.checkBox.setChecked(False) # Reseting checkbox self.pushButton_2.setEnabled(True) # Enabling Open Folder button self.pushButton_4.setVisible(False) # Disabling Stop button self.pushButton_3.setEnabled(True) # Enabling Save logs button @@ -560,7 +562,7 @@ def run(self) -> None: unexpected_end = False # Will break if "END" found in title (Erai-raws) - while not unexpected_end and episode <= start_end[1] and fails_in_a_row < 10: + while not unexpected_end and episode <= start_end[1] and fails_in_a_row < 3: for uploader in uploaders: torrent = nyaa.find_torrent(uploader, anime_name, episode, quality, untrusted_option) @@ -611,7 +613,7 @@ def run(self) -> None: episode += 1 - if fails_in_a_row >= 10: + if fails_in_a_row >= 3: self.update_logs.emit( - f"Note: {anime_name} seems to only have {episode - 11} episodes" + f"Note: {anime_name} seems to only have {episode - 4} episodes" ) From cba9931b298d0ddbf3e582e1726b573151b3237c Mon Sep 17 00:00:00 2001 From: p0358 Date: Thu, 8 May 2025 19:27:01 +0200 Subject: [PATCH 08/15] fix untrusted_option --- util/gui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/util/gui.py b/util/gui.py index 5ca5afb..ef6401a 100644 --- a/util/gui.py +++ b/util/gui.py @@ -502,7 +502,7 @@ def is_everything_good(self) -> None: ) quality = int(self.comboBox.currentText()[:-1]) option = 1 if self.radioButton.isChecked() else 2 - untrusted_option = True if self.radioButton_2.isChecked() else False + untrusted_option = True if self.checkBox_2.isChecked() else False path = self.get_download_folder(anime_name) self.start_checking() From 0297dd5686303651043a29de20c15601a43c34df Mon Sep 17 00:00:00 2001 From: p0358 Date: Thu, 8 May 2025 20:04:02 +0200 Subject: [PATCH 09/15] allow saving magnets to a text file --- util/gui.py | 35 +++++++++++++++++++++++++++-------- util/nyaa.py | 4 +--- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/util/gui.py b/util/gui.py index ef6401a..02989d0 100644 --- a/util/gui.py +++ b/util/gui.py @@ -59,9 +59,9 @@ def setupUi(self, MainWindow: QtWidgets.QMainWindow) -> None: MainWindow.setObjectName("NyaaDownloader") MainWindow.setWindowIcon(QtGui.QIcon(ICON_PATH)) - MainWindow.resize(800, 450) - MainWindow.setMinimumSize(QtCore.QSize(800, 450)) - MainWindow.setLocale(QtCore.QLocale(QtCore.QLocale.Language.English, QtCore.QLocale.Country.UnitedStates)) + MainWindow.resize(800, 470) + MainWindow.setMinimumSize(QtCore.QSize(800, 470)) + MainWindow.setLocale(QtCore.QLocale(QtCore.QLocale.Language.English, QtCore.QLocale.Country.World)) self.centralwidget = QtWidgets.QWidget(MainWindow) self.centralwidget.setObjectName("centralwidget") @@ -141,6 +141,10 @@ def setupUi(self, MainWindow: QtWidgets.QMainWindow) -> None: self.radioButton_2.setObjectName("radioButton_2") bottom_left_layout.addWidget(self.radioButton_2) + self.radioButton_3 = QtWidgets.QRadioButton(self.centralwidget) + self.radioButton_3.setObjectName("radioButton_3") + bottom_left_layout.addWidget(self.radioButton_3) + btn_layout = QtWidgets.QHBoxLayout() self.pushButton = QtWidgets.QPushButton(self.centralwidget) self.pushButton.setMinimumSize(QtCore.QSize(70, 30)) @@ -241,15 +245,16 @@ def retranslateUi(self, MainWindow) -> None: "Download .torrent files or open magnet links directly in your torrent client?", ) ) - self.radioButton.setText(_translate("MainWindow", "Torrent")) - self.radioButton_2.setText(_translate("MainWindow", "Magnet")) + self.radioButton.setText(_translate("MainWindow", "Download .torrent files")) + self.radioButton_2.setText(_translate("MainWindow", "Magnet (launch torrent client)")) + self.radioButton_3.setText(_translate("MainWindow", "Magnet (write to a text file)")) self.checkBox.setText(_translate("MainWindow", "Until last released one")) self.pushButton.setText(_translate("MainWindow", "Check")) self.label_7.setText(_translate("MainWindow", "Logs:")) self.pushButton_2.setText(_translate("MainWindow", "Open folder")) self.pushButton_3.setText(_translate("MainWindow", "Save logs as .txt")) self.pushButton_4.setText(_translate("MainWindow", "Stop")) - self.checkBox_2.setText(_translate("MainWindow", "Allow untrusted")) + self.checkBox_2.setText(_translate("MainWindow", "Allow untrusted (torrents not uploaded by trusted users)")) self.menuTranslator.setTitle(_translate("MainWindow", "Translator")) self.actionGet_translation_of_an_anime_title.setText( _translate("MainWindow", "Get translation of an anime title") @@ -349,6 +354,7 @@ def set_widget_while_check(self) -> None: self.checkBox.setEnabled(False) self.radioButton.setEnabled(False) self.radioButton_2.setEnabled(False) + self.radioButton_3.setEnabled(False) self.pushButton_3.setEnabled(False) self.checkBox_2.setEnabled(False) @@ -368,6 +374,7 @@ def set_widget_after_check(self) -> None: self.checkBox.setEnabled(True) self.radioButton.setEnabled(True) self.radioButton_2.setEnabled(True) + self.radioButton_3.setEnabled(True) self.checkBox_2.setEnabled(True) self.pushButton_2.setEnabled(True) # Enabling Open Folder button @@ -501,7 +508,12 @@ def is_everything_good(self) -> None: else (int(self.spinBox.text()), int(self.spinBox_2.text())) ) quality = int(self.comboBox.currentText()[:-1]) - option = 1 if self.radioButton.isChecked() else 2 + if self.radioButton_3.isChecked(): + option = 3 + elif self.radioButton_2.isChecked(): + option = 2 + else: #self.radioButton.isChecked(): + option = 1 untrusted_option = True if self.checkBox_2.isChecked() else False path = self.get_download_folder(anime_name) @@ -583,7 +595,6 @@ def run(self) -> None: unexpected_end = True break - # I prefer this rather than a simple else because it's cleaner elif option == 2 and not nyaa.transfer(torrent): self.error_popup.emit( "No bittorrent client or web browser (that supports magnet links) found." @@ -591,6 +602,14 @@ def run(self) -> None: unexpected_end = True break + elif option == 3: + Ui_MainWindow.generate_download_folder(anime_name) + with open(os.path.join(path, "magnets.txt"), "a") as magnetsfile: + magnetsfile.write(torrent.magnet) + magnetsfile.write("\n") + magnetsfile.close() + + self.update_logs.emit(f"Found: {anime_name} - Episode {episode}") # Erai-raws add "END" in the torrent name when an anime has finished airing diff --git a/util/nyaa.py b/util/nyaa.py index d92c387..3064190 100644 --- a/util/nyaa.py +++ b/util/nyaa.py @@ -65,13 +65,11 @@ def transfer(torrent: dict) -> bool: bool: True if the transfer was successful, False otherwise. """ try: - wb.open(torrent.magnet) + return wb.open(torrent.magnet) except: return False - return True - def find_torrent(uploader: str, anime_name: str, episode: int, quality: int, untrusted_option: bool) -> dict: """Find if the torrent is already in the database. If not, download it. From c6354c228c0ed33098622387a7b77d5289082312 Mon Sep 17 00:00:00 2001 From: p0358 Date: Thu, 8 May 2025 21:35:22 +0200 Subject: [PATCH 10/15] add HEVC and make torrent matching code more readable --- util/gui.py | 40 ++++++++++++++++++++++++++++----------- util/nyaa.py | 53 ++++++++++++++++++++++++++++++++-------------------- 2 files changed, 62 insertions(+), 31 deletions(-) diff --git a/util/gui.py b/util/gui.py index 02989d0..dbc04c3 100644 --- a/util/gui.py +++ b/util/gui.py @@ -14,6 +14,7 @@ import os import webbrowser as wb import urllib +import re # ------------------------------GLOBAL VARIABLES------------------------------ @@ -123,8 +124,17 @@ def setupUi(self, MainWindow: QtWidgets.QMainWindow) -> None: self.comboBox = QtWidgets.QComboBox(self.centralwidget) self.comboBox.setObjectName("comboBox") - self.comboBox.addItems(["2160p", "1080p", "720p", "480p"]) - self.comboBox.setCurrentIndex(1) + self.comboBox.addItems([ + "2160p", + "2160p HEVC", + "1080p", + "1080p HEVC", + "720p", + "720p HEVC", + "480p", + "480p HEVC", + ]) + self.comboBox.setCurrentIndex(2) bottom_left_layout.addWidget(self.comboBox) self.label_6 = QtWidgets.QLabel(self.centralwidget) @@ -224,7 +234,7 @@ def retranslateUi(self, MainWindow) -> None: self.label.setText(_translate("MainWindow", "Uploaders:")) self.lineEdit.setPlaceholderText( _translate( - "MainWindow", "Empty=all, else use semicolon like Erai-raws;SubsPlease" + "MainWindow", "Empty = all, otherwise separate with semicolon like Erai-raws;SubsPlease" ) ) self.label_2.setText(_translate("MainWindow", "Anime Title:")) @@ -235,10 +245,14 @@ def retranslateUi(self, MainWindow) -> None: self.label_4.setText(_translate("MainWindow", "Until episode:")) self.label_5.setText(_translate("MainWindow", "Quality:")) self.comboBox.setItemText(0, _translate("MainWindow", "2160p")) - self.comboBox.setItemText(1, _translate("MainWindow", "1080p")) - self.comboBox.setItemText(2, _translate("MainWindow", "720p")) - self.comboBox.setItemText(3, _translate("MainWindow", "480p")) - self.comboBox.setCurrentIndex(1) + self.comboBox.setItemText(1, _translate("MainWindow", "2160p HEVC")) + self.comboBox.setItemText(2, _translate("MainWindow", "1080p")) + self.comboBox.setItemText(3, _translate("MainWindow", "1080p HEVC")) + self.comboBox.setItemText(4, _translate("MainWindow", "720p")) + self.comboBox.setItemText(5, _translate("MainWindow", "720p HEVC")) + self.comboBox.setItemText(6, _translate("MainWindow", "480p")) + self.comboBox.setItemText(7, _translate("MainWindow", "480p HEVC")) + self.comboBox.setCurrentIndex(2) self.label_6.setText( _translate( "MainWindow", @@ -492,7 +506,7 @@ def is_everything_good(self) -> None: # Setting proper values if everything_good: - global uploaders, anime_name, start_end, quality, option, untrusted_option, path + global uploaders, anime_name, start_end, quality, hevc, option, untrusted_option, path uploaders = [ u.strip() for u in self.lineEdit.text().strip().split(";") if u != "" @@ -507,7 +521,8 @@ def is_everything_good(self) -> None: if self.checkBox.isChecked() else (int(self.spinBox.text()), int(self.spinBox_2.text())) ) - quality = int(self.comboBox.currentText()[:-1]) + quality = int(re.search(r'\d+', self.comboBox.currentText()).group()) + hevc = "HEVC" in self.comboBox.currentText() if self.radioButton_3.isChecked(): option = 3 elif self.radioButton_2.isChecked(): @@ -576,7 +591,7 @@ def run(self) -> None: # Will break if "END" found in title (Erai-raws) while not unexpected_end and episode <= start_end[1] and fails_in_a_row < 3: for uploader in uploaders: - torrent = nyaa.find_torrent(uploader, anime_name, episode, quality, untrusted_option) + torrent = nyaa.find_torrent(uploader, anime_name, episode, quality, hevc, untrusted_option) if torrent: fails_in_a_row = 0 @@ -611,9 +626,12 @@ def run(self) -> None: self.update_logs.emit(f"Found: {anime_name} - Episode {episode}") + self.update_logs.emit("") + self.update_logs.emit(torrent.name) + self.update_logs.emit("") # Erai-raws add "END" in the torrent name when an anime has finished airing - if uploader == "Erai-raws" and torrent.name.find(" END [") != -1: + if uploader == "Erai-raws" and " END [" in torrent.name: self.update_logs.emit( f"Note: {anime_name} has no more than {episode} episodes" ) diff --git a/util/nyaa.py b/util/nyaa.py index 3064190..86c91b1 100644 --- a/util/nyaa.py +++ b/util/nyaa.py @@ -71,7 +71,7 @@ def transfer(torrent: dict) -> bool: return False -def find_torrent(uploader: str, anime_name: str, episode: int, quality: int, untrusted_option: bool) -> dict: +def find_torrent(uploader: str, anime_name: str, episode: int, quality: int, hevc: bool, untrusted_option: bool) -> dict: """Find if the torrent is already in the database. If not, download it. Args: uploader (str): The name of the uploader. @@ -89,28 +89,40 @@ def find_torrent(uploader: str, anime_name: str, episode: int, quality: int, unt else: episode = "0" + str(episode) - found_torrents = NyaaPy.Nyaa.search( - keyword=f"[{uploader}] {anime_name} - {episode} [{quality}p]", - category=1, - subcategory=2, - filters=0 if untrusted_option else 2, - ) + NyaaPy.Nyaa.search( - keyword=f"[{uploader}] {anime_name} - {episode} ({quality}p)", - category=1, - subcategory=2, - filters=0 if untrusted_option else 2, - ) - - if not isinstance(found_torrents, list) or len(found_torrents) == 0: + hevc_keywords = ["HEVC", "x265", "H.265"] + + queries = [] + + # Explicitly either HEVC or not + if hevc: + queries.append(f"[{uploader}] ({anime_name}) {episode} [{quality}p] " + "|".join(map(lambda k: f'"{k}"', hevc_keywords))) + else: + queries.append(f"[{uploader}] ({anime_name}) {episode} [{quality}p] " + " ".join(map(lambda k: f'-"{k}"', hevc_keywords))) + + found_torrents = [] + for query in queries: + chunk = NyaaPy.Nyaa.search( + keyword=query, + category=1, + subcategory=2, + filters=0 if untrusted_option else 2, + ) + print(query, found_torrents) + if not isinstance(found_torrents, list) or len(found_torrents) == 0: + found_torrents = found_torrents + chunk + + if len(found_torrents) == 0: return {} try: # We take the very closest title to what we are looking for. torrent = None - for t in found_torrents: # (break if found, so we get the most recent one) + a_name = anime_name.lower() + for t in found_torrents: # (break if found, so we get the most recent one) + t_name = t.name.lower() if ( - t.name.lower().find(f"{anime_name} - {episode}".lower()) != -1 - and t.name.lower().find("~") == -1 + f"{a_name} - {episode}" in t_name + and "~" not in t_name ): # we want to avoid ~ because Erai-Raws use it for already packed episodes torrent = t break @@ -118,10 +130,11 @@ def find_torrent(uploader: str, anime_name: str, episode: int, quality: int, unt # Else, we take try to get a close title to the one we are looking for. if torrent is None: for t in found_torrents: + t_name = t.name.lower() if ( - t.name.lower().find(f"{anime_name}".lower()) != -1 - and t.name.lower().find(f" {episode} ") != -1 - and t.name.lower().find("~") == -1 + a_name in t_name + and f" {episode} " in t_name + and "~" not in t_name ): # we want to avoid ~ because Erai-Raws use it for already packed episodes torrent = t break From d24e82d7dc1725ee4c9d82ae51580e3caff96eb5 Mon Sep 17 00:00:00 2001 From: p0358 Date: Thu, 8 May 2025 22:01:41 +0200 Subject: [PATCH 11/15] add AV1 and Best quality settings --- util/gui.py | 47 +++++++++++++++++++++++++++++++---------------- util/nyaa.py | 37 +++++++++++++++++++++++++++---------- 2 files changed, 58 insertions(+), 26 deletions(-) diff --git a/util/gui.py b/util/gui.py index dbc04c3..256aa59 100644 --- a/util/gui.py +++ b/util/gui.py @@ -125,13 +125,18 @@ def setupUi(self, MainWindow: QtWidgets.QMainWindow) -> None: self.comboBox = QtWidgets.QComboBox(self.centralwidget) self.comboBox.setObjectName("comboBox") self.comboBox.addItems([ + "Best", "2160p", + "2160p AV1", "2160p HEVC", "1080p", + "1080p AV1", "1080p HEVC", "720p", + "720p AV1", "720p HEVC", "480p", + "480p AV1", "480p HEVC", ]) self.comboBox.setCurrentIndex(2) @@ -244,15 +249,20 @@ def retranslateUi(self, MainWindow) -> None: self.label_3.setText(_translate("MainWindow", "Starting from:")) self.label_4.setText(_translate("MainWindow", "Until episode:")) self.label_5.setText(_translate("MainWindow", "Quality:")) - self.comboBox.setItemText(0, _translate("MainWindow", "2160p")) - self.comboBox.setItemText(1, _translate("MainWindow", "2160p HEVC")) - self.comboBox.setItemText(2, _translate("MainWindow", "1080p")) - self.comboBox.setItemText(3, _translate("MainWindow", "1080p HEVC")) - self.comboBox.setItemText(4, _translate("MainWindow", "720p")) - self.comboBox.setItemText(5, _translate("MainWindow", "720p HEVC")) - self.comboBox.setItemText(6, _translate("MainWindow", "480p")) - self.comboBox.setItemText(7, _translate("MainWindow", "480p HEVC")) - self.comboBox.setCurrentIndex(2) + self.comboBox.setItemText(0, _translate("MainWindow", "Best")) + self.comboBox.setItemText(1, _translate("MainWindow", "2160p")) + self.comboBox.setItemText(2, _translate("MainWindow", "2160p AV1")) + self.comboBox.setItemText(3, _translate("MainWindow", "2160p HEVC")) + self.comboBox.setItemText(4, _translate("MainWindow", "1080p")) + self.comboBox.setItemText(5, _translate("MainWindow", "1080p AV1")) + self.comboBox.setItemText(6, _translate("MainWindow", "1080p HEVC")) + self.comboBox.setItemText(7, _translate("MainWindow", "720p")) + self.comboBox.setItemText(8, _translate("MainWindow", "720p AV1")) + self.comboBox.setItemText(9, _translate("MainWindow", "720p HEVC")) + self.comboBox.setItemText(10, _translate("MainWindow", "480p")) + self.comboBox.setItemText(11, _translate("MainWindow", "480p AV1")) + self.comboBox.setItemText(12, _translate("MainWindow", "480p HEVC")) + self.comboBox.setCurrentIndex(0) self.label_6.setText( _translate( "MainWindow", @@ -506,7 +516,7 @@ def is_everything_good(self) -> None: # Setting proper values if everything_good: - global uploaders, anime_name, start_end, quality, hevc, option, untrusted_option, path + global uploaders, anime_name, start_end, quality, codec, option, untrusted_option, path uploaders = [ u.strip() for u in self.lineEdit.text().strip().split(";") if u != "" @@ -521,8 +531,13 @@ def is_everything_good(self) -> None: if self.checkBox.isChecked() else (int(self.spinBox.text()), int(self.spinBox_2.text())) ) - quality = int(re.search(r'\d+', self.comboBox.currentText()).group()) - hevc = "HEVC" in self.comboBox.currentText() + quality_text = self.comboBox.currentText() + quality = None if quality_text == "Best" else int(re.search(r'\d+', quality_text).group()) + codec = None + if "AV1" in quality_text: + codec = "AV1" + elif "HEVC" in quality_text: + codec = "HEVC" if self.radioButton_3.isChecked(): option = 3 elif self.radioButton_2.isChecked(): @@ -589,9 +604,9 @@ def run(self) -> None: unexpected_end = False # Will break if "END" found in title (Erai-raws) - while not unexpected_end and episode <= start_end[1] and fails_in_a_row < 3: + while not unexpected_end and episode <= start_end[1] and fails_in_a_row < 2: for uploader in uploaders: - torrent = nyaa.find_torrent(uploader, anime_name, episode, quality, hevc, untrusted_option) + torrent = nyaa.find_torrent(uploader, anime_name, episode, quality, codec, untrusted_option) if torrent: fails_in_a_row = 0 @@ -650,7 +665,7 @@ def run(self) -> None: episode += 1 - if fails_in_a_row >= 3: + if fails_in_a_row >= 2: self.update_logs.emit( - f"Note: {anime_name} seems to only have {episode - 4} episodes" + f"\nNote: {anime_name} seems to only have {episode - 3} episodes" ) diff --git a/util/nyaa.py b/util/nyaa.py index 86c91b1..73d3f4d 100644 --- a/util/nyaa.py +++ b/util/nyaa.py @@ -2,6 +2,7 @@ import nyaapy.nyaasi.nyaa as NyaaPy +from nyaapy.torrent import Torrent import requests import webbrowser as wb @@ -70,8 +71,7 @@ def transfer(torrent: dict) -> bool: except: return False - -def find_torrent(uploader: str, anime_name: str, episode: int, quality: int, hevc: bool, untrusted_option: bool) -> dict: +def find_torrent(uploader: str, anime_name: str, episode_num: int, quality: int, codec: str | None, untrusted_option: bool) -> Torrent | None: """Find if the torrent is already in the database. If not, download it. Args: uploader (str): The name of the uploader. @@ -83,21 +83,39 @@ def find_torrent(uploader: str, anime_name: str, episode: int, quality: int, hev dict: Returns torrent if found, else None. """ + if quality is None: + resolutions = [2160, 1080, 720, 480] + codecs = ["AV1", "HEVC", None] + qualities = [(res, codec) for res in resolutions for codec in codecs] + for quality in qualities: + resolution = quality[0] + codec = quality[1] + result = find_torrent(uploader, anime_name, episode_num, resolution, codec, untrusted_option) + if result is not None and isinstance(result, Torrent): + return result + return None + # Because anime title usually have their episode number like '0X' when X < 10, we need to add a 0 to the episode number. - if episode >= 10: - episode = str(episode) + if episode_num >= 10: + episode = str(episode_num) else: - episode = "0" + str(episode) + episode = "0" + str(episode_num) hevc_keywords = ["HEVC", "x265", "H.265"] + hevc_keyword_positive = "|".join(map(lambda k: f'"{k}"', hevc_keywords)) + hevc_keyword_negative = " ".join(map(lambda k: f'-"{k}"', hevc_keywords)) + av1_keyword_positive = "AV1" + av1_keyword_negative = "-AV1" queries = [] # Explicitly either HEVC or not - if hevc: - queries.append(f"[{uploader}] ({anime_name}) {episode} [{quality}p] " + "|".join(map(lambda k: f'"{k}"', hevc_keywords))) + if codec is "AV1": + queries.append(f"[{uploader}] ({anime_name}) {episode} [{quality}p] " + av1_keyword_positive + " " + hevc_keyword_negative) + elif codec is "HEVC": + queries.append(f"[{uploader}] ({anime_name}) {episode} [{quality}p] " + av1_keyword_negative + " " + hevc_keyword_positive) else: - queries.append(f"[{uploader}] ({anime_name}) {episode} [{quality}p] " + " ".join(map(lambda k: f'-"{k}"', hevc_keywords))) + queries.append(f"[{uploader}] ({anime_name}) {episode} [{quality}p] " + av1_keyword_negative + " " + hevc_keyword_negative) found_torrents = [] for query in queries: @@ -107,12 +125,11 @@ def find_torrent(uploader: str, anime_name: str, episode: int, quality: int, hev subcategory=2, filters=0 if untrusted_option else 2, ) - print(query, found_torrents) if not isinstance(found_torrents, list) or len(found_torrents) == 0: found_torrents = found_torrents + chunk if len(found_torrents) == 0: - return {} + return None try: # We take the very closest title to what we are looking for. From 33f9f3d41df93fa4aad95531466c80b26075c308 Mon Sep 17 00:00:00 2001 From: p0358 Date: Thu, 8 May 2025 22:38:02 +0200 Subject: [PATCH 12/15] allow batches + statusbar text --- util/gui.py | 31 +++++++++++---- util/nyaa.py | 104 ++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 103 insertions(+), 32 deletions(-) diff --git a/util/gui.py b/util/gui.py index 256aa59..05bc544 100644 --- a/util/gui.py +++ b/util/gui.py @@ -51,6 +51,7 @@ def update_config(key: str, value: str) -> None: # Generated with Qt Designer (first time using this one) class Ui_MainWindow(QDialog): + def setupUi(self, MainWindow: QtWidgets.QMainWindow) -> None: """Build skeleton of the GUI @@ -60,8 +61,8 @@ def setupUi(self, MainWindow: QtWidgets.QMainWindow) -> None: MainWindow.setObjectName("NyaaDownloader") MainWindow.setWindowIcon(QtGui.QIcon(ICON_PATH)) - MainWindow.resize(800, 470) - MainWindow.setMinimumSize(QtCore.QSize(800, 470)) + MainWindow.resize(800, 490) + MainWindow.setMinimumSize(QtCore.QSize(800, 490)) MainWindow.setLocale(QtCore.QLocale(QtCore.QLocale.Language.English, QtCore.QLocale.Country.World)) self.centralwidget = QtWidgets.QWidget(MainWindow) @@ -178,6 +179,11 @@ def setupUi(self, MainWindow: QtWidgets.QMainWindow) -> None: self.checkBox_2.setObjectName("checkBox_2") bottom_left_layout.addWidget(self.checkBox_2) + self.checkBox_3 = QtWidgets.QCheckBox(self.centralwidget) + self.checkBox_3.setObjectName("checkBox_3") + self.checkBox_3.setChecked(True) + bottom_left_layout.addWidget(self.checkBox_3) + left_layout.addLayout(bottom_left_layout) main_layout.addLayout(left_layout) @@ -219,7 +225,7 @@ def setupUi(self, MainWindow: QtWidgets.QMainWindow) -> None: self.actionGet_translation_of_an_anime_title = QtWidgets.QWidgetAction(MainWindow) self.actionGet_translation_of_an_anime_title.setObjectName("actionGet_translation_of_an_anime_title") self.menuTranslator.addAction(self.actionGet_translation_of_an_anime_title) - self.menubar.addAction(self.menuTranslator.menuAction()) + self.menubar.addAction(self.menuTranslator.menuAction()) self.retranslateUi(MainWindow) QtCore.QMetaObject.connectSlotsByName(MainWindow) @@ -279,6 +285,7 @@ def retranslateUi(self, MainWindow) -> None: self.pushButton_3.setText(_translate("MainWindow", "Save logs as .txt")) self.pushButton_4.setText(_translate("MainWindow", "Stop")) self.checkBox_2.setText(_translate("MainWindow", "Allow untrusted (torrents not uploaded by trusted users)")) + self.checkBox_3.setText(_translate("MainWindow", "Allow batches (multiple episodes in a single torrent)")) self.menuTranslator.setTitle(_translate("MainWindow", "Translator")) self.actionGet_translation_of_an_anime_title.setText( _translate("MainWindow", "Get translation of an anime title") @@ -381,6 +388,7 @@ def set_widget_while_check(self) -> None: self.radioButton_3.setEnabled(False) self.pushButton_3.setEnabled(False) self.checkBox_2.setEnabled(False) + self.checkBox_3.setEnabled(False) self.pushButton_4.setVisible(True) @@ -400,6 +408,7 @@ def set_widget_after_check(self) -> None: self.radioButton_2.setEnabled(True) self.radioButton_3.setEnabled(True) self.checkBox_2.setEnabled(True) + self.checkBox_3.setEnabled(True) self.pushButton_2.setEnabled(True) # Enabling Open Folder button self.pushButton_4.setVisible(False) # Disabling Stop button @@ -516,7 +525,7 @@ def is_everything_good(self) -> None: # Setting proper values if everything_good: - global uploaders, anime_name, start_end, quality, codec, option, untrusted_option, path + global uploaders, anime_name, start_end, quality, codec, option, untrusted_option, allow_batch, path uploaders = [ u.strip() for u in self.lineEdit.text().strip().split(";") if u != "" @@ -544,7 +553,8 @@ def is_everything_good(self) -> None: option = 2 else: #self.radioButton.isChecked(): option = 1 - untrusted_option = True if self.checkBox_2.isChecked() else False + untrusted_option = self.checkBox_2.isChecked() + allow_batch = self.checkBox_3.isChecked() path = self.get_download_folder(anime_name) self.start_checking() @@ -565,6 +575,7 @@ def start_checking(self) -> None: self.worker.finished.connect(lambda: self.worker_finished()) self.worker.update_logs.connect(self.append_to_logs) self.worker.error_popup.connect(self.show_error_popup) + self.worker.statusbar_signal.connect(lambda text: self.statusbar.showMessage(text)) def worker_finished(self) -> None: """When the thread has finished processing, enable all widgets again and notify the user @@ -592,6 +603,7 @@ class WorkerThread(QThread): update_logs = pyqtSignal(str) error_popup = pyqtSignal(str) + statusbar_signal = pyqtSignal(str) def run(self) -> None: """The "almost main" function of that program. Will download/transfer every found torrent. Will also handle logs update, etc.""" @@ -606,7 +618,7 @@ def run(self) -> None: # Will break if "END" found in title (Erai-raws) while not unexpected_end and episode <= start_end[1] and fails_in_a_row < 2: for uploader in uploaders: - torrent = nyaa.find_torrent(uploader, anime_name, episode, quality, codec, untrusted_option) + torrent = nyaa.find_torrent(uploader, anime_name, episode, quality, codec, untrusted_option, allow_batch, start_end, self.statusbar_signal) if torrent: fails_in_a_row = 0 @@ -639,8 +651,13 @@ def run(self) -> None: magnetsfile.write("\n") magnetsfile.close() + torrent_batch_info = nyaa.parse_batch_info(torrent.name) - self.update_logs.emit(f"Found: {anime_name} - Episode {episode}") + if torrent_batch_info is not None: + self.update_logs.emit(f"Found: {anime_name} - Episode {torrent_batch_info[0]} ~ {torrent_batch_info[1]}") + episode = torrent_batch_info[1] + else: + self.update_logs.emit(f"Found: {anime_name} - Episode {episode}") self.update_logs.emit("") self.update_logs.emit(torrent.name) self.update_logs.emit("") diff --git a/util/nyaa.py b/util/nyaa.py index 73d3f4d..a347b52 100644 --- a/util/nyaa.py +++ b/util/nyaa.py @@ -3,6 +3,8 @@ import nyaapy.nyaasi.nyaa as NyaaPy from nyaapy.torrent import Torrent + +import re import requests import webbrowser as wb @@ -71,13 +73,16 @@ def transfer(torrent: dict) -> bool: except: return False -def find_torrent(uploader: str, anime_name: str, episode_num: int, quality: int, codec: str | None, untrusted_option: bool) -> Torrent | None: +def parse_batch_info(torrent_name: str) -> None | tuple: + #if "batch" not in anime_name.lower(): # oh it will not always have the word "batch" in name + # return None + m = re.search(r"[\s(\[]([0-9]+)\s*[-~]\s*([0-9]+)[\s)\]]", torrent_name) + if m is None: + return None + return (int(m[1]), int(m[2])) + +def find_torrent(uploader: str, anime_name: str, episode_num: int, quality: int, codec: str | None, untrusted_option: bool, allow_batch: bool, start_end: tuple[int, int], statusbar_signal) -> Torrent | None: """Find if the torrent is already in the database. If not, download it. - Args: - uploader (str): The name of the uploader. - anime_name (str): The name of the anime. - episode (int): The episode number. - quality (str): The quality of the torrent. Returns: dict: Returns torrent if found, else None. @@ -90,16 +95,19 @@ def find_torrent(uploader: str, anime_name: str, episode_num: int, quality: int, for quality in qualities: resolution = quality[0] codec = quality[1] - result = find_torrent(uploader, anime_name, episode_num, resolution, codec, untrusted_option) + result = find_torrent(uploader, anime_name, episode_num, resolution, codec, untrusted_option, allow_batch, start_end, statusbar_signal) if result is not None and isinstance(result, Torrent): return result return None # Because anime title usually have their episode number like '0X' when X < 10, we need to add a 0 to the episode number. - if episode_num >= 10: - episode = str(episode_num) - else: - episode = "0" + str(episode_num) + def get_episode_str(episode_num: int): + if episode_num >= 10: + episode = str(episode_num) + else: + episode = "0" + str(episode_num) + return episode + episode = get_episode_str(episode_num) hevc_keywords = ["HEVC", "x265", "H.265"] hevc_keyword_positive = "|".join(map(lambda k: f'"{k}"', hevc_keywords)) @@ -109,22 +117,26 @@ def find_torrent(uploader: str, anime_name: str, episode_num: int, quality: int, queries = [] - # Explicitly either HEVC or not - if codec is "AV1": + # Explicitly match only the exact codec we're looking for and nothing else (or none of supported codecs as fallback) + if codec == "AV1": queries.append(f"[{uploader}] ({anime_name}) {episode} [{quality}p] " + av1_keyword_positive + " " + hevc_keyword_negative) - elif codec is "HEVC": + elif codec == "HEVC": queries.append(f"[{uploader}] ({anime_name}) {episode} [{quality}p] " + av1_keyword_negative + " " + hevc_keyword_positive) else: queries.append(f"[{uploader}] ({anime_name}) {episode} [{quality}p] " + av1_keyword_negative + " " + hevc_keyword_negative) found_torrents = [] for query in queries: + if statusbar_signal: + statusbar_signal.emit(query) chunk = NyaaPy.Nyaa.search( keyword=query, category=1, subcategory=2, filters=0 if untrusted_option else 2, ) + if statusbar_signal: + statusbar_signal.emit("") if not isinstance(found_torrents, list) or len(found_torrents) == 0: found_torrents = found_torrents + chunk @@ -135,24 +147,66 @@ def find_torrent(uploader: str, anime_name: str, episode_num: int, quality: int, # We take the very closest title to what we are looking for. torrent = None a_name = anime_name.lower() - for t in found_torrents: # (break if found, so we get the most recent one) - t_name = t.name.lower() - if ( - f"{a_name} - {episode}" in t_name - and "~" not in t_name - ): # we want to avoid ~ because Erai-Raws use it for already packed episodes - torrent = t - break + + ep = episode_num + ep_start = start_end[0] + ep_end = start_end[1] + #ep_start_str = get_episode_str(ep_start) + #ep_end_str = get_episode_str(ep_end) + + if allow_batch: + for t in found_torrents: + t_name = t.name.lower() + batch_start_end = parse_batch_info(t_name) + if ( + batch_start_end is not None + and batch_start_end[0] == ep # the batch starts with the episode we're currently trying to download + and batch_start_end[1] <= ep_end # the batch ends before or exactly where we want to end + and f"{a_name} - " in t_name # (prefer this name form first, so that we hopefully avoid matching sequels) + ): + torrent = t + break + + if allow_batch and torrent is None: + for t in found_torrents: + t_name = t.name.lower() + batch_start_end = parse_batch_info(t_name) + if ( + batch_start_end is not None + and batch_start_end[0] == ep # the batch starts with the episode we're currently trying to download + and batch_start_end[1] <= ep_end # the batch ends before or exactly where we want to end + and a_name in t_name # of course should have our anime name in the title as well + ): + torrent = t + break + + if torrent is None: + for t in found_torrents: + t_name = t.name.lower() + batch_start_end = parse_batch_info(t_name) + if ( + f"{a_name} - {episode}" in t_name + and batch_start_end is None + ): + torrent = t + break # Else, we take try to get a close title to the one we are looking for. if torrent is None: for t in found_torrents: t_name = t.name.lower() + batch_start_end = parse_batch_info(t_name) if ( a_name in t_name - and f" {episode} " in t_name - and "~" not in t_name - ): # we want to avoid ~ because Erai-Raws use it for already packed episodes + and ( + f" {episode} " in t_name + or f"({episode})" in t_name + or f"[{episode}]" in t_name + or f"E{episode} " in t_name + or f"E{episode}]" in t_name + ) + and batch_start_end is None + ): torrent = t break From e01de718e5855c07b2495dbc43f4db6cd5ccdd68 Mon Sep 17 00:00:00 2001 From: p0358 Date: Thu, 8 May 2025 23:38:25 +0200 Subject: [PATCH 13/15] change directory structure to allow packing --- .gitignore | 1 + main.pyw => NyaaDownloader/__main__.py | 5 ++- {util => NyaaDownloader}/gui.py | 2 +- .../icons/nyaadownloader.ico | Bin NyaaDownloader/icons/nyaadownloader.png | Bin 0 -> 52278 bytes {util => NyaaDownloader}/nyaa.py | 0 data/NyaaDownloader.desktop | 10 +++++ pyproject.toml | 41 ++++++++++++++++++ requirements.txt | 4 +- 9 files changed, 58 insertions(+), 5 deletions(-) rename main.pyw => NyaaDownloader/__main__.py (95%) rename {util => NyaaDownloader}/gui.py (99%) rename ico/nyaa.ico => NyaaDownloader/icons/nyaadownloader.ico (100%) create mode 100644 NyaaDownloader/icons/nyaadownloader.png rename {util => NyaaDownloader}/nyaa.py (100%) create mode 100644 data/NyaaDownloader.desktop create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore index 587a297..3d756a2 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ main.spec # PyInstaller .bat script that allows me to create the .exe from the release binary_gen.bat +NyaaDownloader.egg-info diff --git a/main.pyw b/NyaaDownloader/__main__.py similarity index 95% rename from main.pyw rename to NyaaDownloader/__main__.py index da29acc..db43d31 100644 --- a/main.pyw +++ b/NyaaDownloader/__main__.py @@ -1,7 +1,7 @@ # ------------------------------IMPORTS------------------------------ -from util import gui +from . import gui from PyQt6 import QtWidgets from PyQt6.QtCore import QStandardPaths @@ -42,8 +42,9 @@ def main() -> None: If you find any bug, please let me know on my GitHub:
https://github.com/marcpinet/nyaadownloader.

""" - app = QtWidgets.QApplication([]) + app = QtWidgets.QApplication(sys.argv) app.setApplicationName("NyaaDownloader") + app.setDesktopFileName("NyaaDownloader") MainWindow = QtWidgets.QMainWindow() ui = gui.Ui_MainWindow() ui.setupUi(MainWindow) diff --git a/util/gui.py b/NyaaDownloader/gui.py similarity index 99% rename from util/gui.py rename to NyaaDownloader/gui.py index 05bc544..2e18175 100644 --- a/util/gui.py +++ b/NyaaDownloader/gui.py @@ -21,7 +21,7 @@ unhandled_characters = ["\\", "/", ":", "*", "?", '"', "<", ">", "|"] -ICON_PATH = os.path.join("ico", "nyaa.ico") +ICON_PATH = os.path.join(os.path.dirname(__file__), "icons", "nyaadownloader.ico") # ------------------------------CLASSES AND METHODS------------------------------ diff --git a/ico/nyaa.ico b/NyaaDownloader/icons/nyaadownloader.ico similarity index 100% rename from ico/nyaa.ico rename to NyaaDownloader/icons/nyaadownloader.ico diff --git a/NyaaDownloader/icons/nyaadownloader.png b/NyaaDownloader/icons/nyaadownloader.png new file mode 100644 index 0000000000000000000000000000000000000000..38cb2d4e0e1c6cd21aebf3a48cfeea1c79ac7e7b GIT binary patch literal 52278 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 literal 0 HcmV?d00001 diff --git a/util/nyaa.py b/NyaaDownloader/nyaa.py similarity index 100% rename from util/nyaa.py rename to NyaaDownloader/nyaa.py diff --git a/data/NyaaDownloader.desktop b/data/NyaaDownloader.desktop new file mode 100644 index 0000000..7975717 --- /dev/null +++ b/data/NyaaDownloader.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Version=1.0 +Name=NyaaDownloader +Comment=A tool to download multiple torrents or transfer magnets from Nyaa.si +Categories=Internet +Exec=nyaadownloader %F +Type=Application +Icon=nyaadownloader +StartupWMClass=NyaaDownloader +Keywords=Nyaa;Batch Downloader;Torrent;Magnet;Anime diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bb5d90d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,41 @@ +[build-system] +requires = ["setuptools>=62.4"] +build-backend = "setuptools.build_meta" + +[project] +name = "NyaaDownloader" +version = "4.0.0" +description = "A tool to download multiple torrents or transfer magnets from Nyaa.si" +readme = "README.md" +authors = [{ name = "Marc Pinet" }, { name = "p0358" }] +license = { text = "MIT" } +classifiers = [ + "Environment :: X11 Applications :: Qt", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3 :: Only", +] +requires-python = ">=3.9" +dependencies = [ + "PyQt6", + "nyaapy>=0.7", + "requests>=2.28.1", + #"winotify>=1.1.0", +] + +[project.gui-scripts] +nyaadownloader = "NyaaDownloader.__main__:main" + +[project.urls] +Homepage = "https://github.com/marcpinet/nyaadownloader" +"Issue Tracker" = "https://github.com/marcpinet/nyaadownloader/issues" + +[tool.setuptools] +packages = ["NyaaDownloader"] +include-package-data = false + +[tool.setuptools.data-files] +"share/applications" = ["data/NyaaDownloader.desktop"] +"share/icons" = ["NyaaDownloader/icons/nyaadownloader.*"] + +[tool.setuptools.package-data] +NyaaDownloader = ["icons/*.ico", "icons/*.png"] diff --git a/requirements.txt b/requirements.txt index fcbe668..0ad3a11 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -nyaapy==0.6.1 +nyaapy>=0.7 winotify>=1.1.0 requests>=2.28.1 -PyQt5==5.15.7 \ No newline at end of file +PyQt6 \ No newline at end of file From 268890d8f265c41121129561c735d46fed1e5270 Mon Sep 17 00:00:00 2001 From: p0358 Date: Fri, 9 May 2025 00:09:47 +0200 Subject: [PATCH 14/15] prefer newer versions of episodes (01v2 > 01) --- NyaaDownloader/nyaa.py | 60 ++++++++++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/NyaaDownloader/nyaa.py b/NyaaDownloader/nyaa.py index a347b52..d16fb27 100644 --- a/NyaaDownloader/nyaa.py +++ b/NyaaDownloader/nyaa.py @@ -79,6 +79,8 @@ def parse_batch_info(torrent_name: str) -> None | tuple: m = re.search(r"[\s(\[]([0-9]+)\s*[-~]\s*([0-9]+)[\s)\]]", torrent_name) if m is None: return None + if m[1] == m[2]: + return None return (int(m[1]), int(m[2])) def find_torrent(uploader: str, anime_name: str, episode_num: int, quality: int, codec: str | None, untrusted_option: bool, allow_batch: bool, start_end: tuple[int, int], statusbar_signal) -> Torrent | None: @@ -181,33 +183,45 @@ def get_episode_str(episode_num: int): break if torrent is None: - for t in found_torrents: - t_name = t.name.lower() - batch_start_end = parse_batch_info(t_name) - if ( - f"{a_name} - {episode}" in t_name - and batch_start_end is None - ): - torrent = t + for v in range(9, -1, -1): + episode = get_episode_str(episode_num) + if v > 0: + episode = episode + "v" + str(v) + for t in found_torrents: + t_name = t.name.lower() + batch_start_end = parse_batch_info(t_name) + if ( + f"{a_name} - {episode}" in t_name + and batch_start_end is None + ): + torrent = t + break + if torrent is not None: break # Else, we take try to get a close title to the one we are looking for. if torrent is None: - for t in found_torrents: - t_name = t.name.lower() - batch_start_end = parse_batch_info(t_name) - if ( - a_name in t_name - and ( - f" {episode} " in t_name - or f"({episode})" in t_name - or f"[{episode}]" in t_name - or f"E{episode} " in t_name - or f"E{episode}]" in t_name - ) - and batch_start_end is None - ): - torrent = t + for v in range(9, -1, -1): + episode = get_episode_str(episode_num) + if v > 0: + episode = episode + "v" + str(v) + for t in found_torrents: + t_name = t.name.lower() + batch_start_end = parse_batch_info(t_name) + if ( + a_name in t_name + and ( + f" {episode} " in t_name + or f"({episode})" in t_name + or f"[{episode}]" in t_name + or f"E{episode} " in t_name + or f"E{episode}]" in t_name + ) + and batch_start_end is None + ): + torrent = t + break + if torrent is not None: break except Exception as e: From 0bc7bfef927d1e599a72f8d5eafcc2dc24f94da7 Mon Sep 17 00:00:00 2001 From: p0358 Date: Fri, 9 May 2025 00:20:04 +0200 Subject: [PATCH 15/15] update readme --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d00551b..dd30231 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # NyaaDownloader +[![AUR Version](https://img.shields.io/aur/version/nyaadownloader-git)](https://aur.archlinux.org/packages/nyaadownloader-git) + 🚀 Download many .torrent from Nyaa.si at a time! 🚀 🔌 Instantly transfer them into your Bittorrent client 🔌 @@ -13,7 +15,7 @@ ## Features * Integrated Graphical User Interface (GUI) 🖥 -* Enter uploaders name (defaults are [Erai-raws](https://www.erai-raws.info/) and [Subsplease](https://subsplease.org/)) 🤖 +* Enter uploaders name (defaults are any, popular choices are [Erai-raws](https://www.erai-raws.info/) and [SubsPlease](https://subsplease.org/)) 🤖 * Enter the anime title you want to download ✏️ * Choose the quality 🎞 * Retrieve them either as .torrent or directly transfer them into your Bittorrent client ⚙️ @@ -73,7 +75,7 @@ To run this script open your Terminal in the project directory. To start the script, enter: ```bash -pythonw main.pyw +python -m NyaaDownloader.__main__ ``` You can then close the Terminal. @@ -95,6 +97,7 @@ However, they don't always name them like that. For instance, it can be named *J ## Authors * **Marc Pinet** - *Initial work* - [marcpinet](https://github.com/marcpinet) +* **p0358** - *Various enhancements* - [p0358](https://github.com/p0358) ## License @@ -108,6 +111,3 @@ This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md You can find what I plan to do for the project [here](https://github.com/marcpinet/nyaadownloader/projects). Also, you can find what I already implemented [here](https://github.com/marcpinet/nyaadownloader/projects?query=is%3Aclosed). - - -