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 79% rename from main.pyw rename to NyaaDownloader/__main__.py index ce9f60f..db43d31 100644 --- a/main.pyw +++ b/NyaaDownloader/__main__.py @@ -1,9 +1,10 @@ # ------------------------------IMPORTS------------------------------ -from util import gui +from . import gui -from PyQt5 import QtWidgets +from PyQt6 import QtWidgets +from PyQt6.QtCore import QStandardPaths import sys import configparser @@ -39,17 +40,19 @@ 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 = QtWidgets.QApplication(sys.argv) app.setApplicationName("NyaaDownloader") + app.setDesktopFileName("NyaaDownloader") MainWindow = QtWidgets.QMainWindow() ui = gui.Ui_MainWindow() 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.StandardLocation.AppDataLocation) + config_path = os.path.join(config_dir, config_filename) config = configparser.ConfigParser() config.read(config_path) @@ -57,7 +60,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/NyaaDownloader/gui.py similarity index 69% rename from util/gui.py rename to NyaaDownloader/gui.py index beb519a..2e18175 100644 --- a/util/gui.py +++ b/NyaaDownloader/gui.py @@ -3,23 +3,25 @@ from . import nyaa -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 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 import configparser import textwrap import os import webbrowser as wb +import urllib +import re # ------------------------------GLOBAL VARIABLES------------------------------ unhandled_characters = ["\\", "/", ":", "*", "?", '"', "<", ">", "|"] -ICON_PATH = "ico\\nyaa.ico" +ICON_PATH = os.path.join(os.path.dirname(__file__), "icons", "nyaadownloader.ico") # ------------------------------CLASSES AND METHODS------------------------------ @@ -31,9 +33,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.StandardLocation.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 +51,8 @@ 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: @@ -57,9 +61,9 @@ def setupUi(self, MainWindow) -> 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, 490) + MainWindow.setMinimumSize(QtCore.QSize(800, 490)) + MainWindow.setLocale(QtCore.QLocale(QtCore.QLocale.Language.English, QtCore.QLocale.Country.World)) self.centralwidget = QtWidgets.QWidget(MainWindow) self.centralwidget.setObjectName("centralwidget") @@ -103,10 +107,12 @@ def setupUi(self, MainWindow) -> 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) @@ -119,8 +125,22 @@ def setupUi(self, MainWindow) -> 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([ + "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) bottom_left_layout.addWidget(self.comboBox) self.label_6 = QtWidgets.QLabel(self.centralwidget) @@ -137,6 +157,10 @@ def setupUi(self, MainWindow) -> 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)) @@ -155,6 +179,11 @@ def setupUi(self, MainWindow) -> 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) @@ -193,10 +222,10 @@ def setupUi(self, MainWindow) -> 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()) + self.menubar.addAction(self.menuTranslator.menuAction()) self.retranslateUi(MainWindow) QtCore.QMetaObject.connectSlotsByName(MainWindow) @@ -216,7 +245,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:")) @@ -226,26 +255,37 @@ 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", "1080p")) - self.comboBox.setItemText(2, _translate("MainWindow", "720p")) - self.comboBox.setItemText(3, _translate("MainWindow", "480p")) - self.comboBox.setCurrentIndex(1) + 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", "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.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") @@ -271,9 +311,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.EchoMode.Normal ) if okPressed and text != "": + text = urllib.parse.quote(text) wb.open(f"https://myanimelist.net/anime.php?q={text}&cat=anime") def cancel_process(self) -> None: @@ -344,7 +385,10 @@ 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) + self.checkBox_3.setEnabled(False) self.pushButton_4.setVisible(True) @@ -357,28 +401,38 @@ 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.radioButton_3.setEnabled(True) + self.checkBox_2.setEnabled(True) + self.checkBox_3.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 - 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.StandardLocation.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 @@ -386,25 +440,48 @@ 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)) 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 """ - 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.""" @@ -417,8 +494,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""" @@ -448,7 +525,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, codec, option, untrusted_option, allow_batch, path uploaders = [ u.strip() for u in self.lineEdit.text().strip().split(";") if u != "" @@ -463,15 +540,23 @@ 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]) - option = 1 if self.radioButton.isChecked() else 2 - untrusted_option = True if self.radioButton_2.isChecked() else False + 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(): + option = 2 + else: #self.radioButton.isChecked(): + option = 1 + untrusted_option = self.checkBox_2.isChecked() + allow_batch = self.checkBox_3.isChecked() - 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: @@ -490,7 +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.gen_folder.connect(self.generate_download_folder) + 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 @@ -518,7 +603,7 @@ class WorkerThread(QThread): update_logs = pyqtSignal(str) error_popup = pyqtSignal(str) - gen_folder = 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.""" @@ -531,9 +616,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 < 10: + 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, 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 @@ -541,18 +626,17 @@ 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: - self.error_popup.emit("No Internet connection available") + self.error_popup.emit(f"Failed downloading: {torrent.name}") 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." @@ -560,10 +644,26 @@ def run(self) -> None: unexpected_end = True break - self.update_logs.emit(f"Found: {anime_name} - Episode {episode}") + 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() + + torrent_batch_info = nyaa.parse_batch_info(torrent.name) + + 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("") # 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" ) @@ -575,14 +675,14 @@ 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 episode += 1 - if fails_in_a_row >= 10: + if fails_in_a_row >= 2: self.update_logs.emit( - f"Note: {anime_name} seems to only have {episode - 11} episodes" + f"\nNote: {anime_name} seems to only have {episode - 3} episodes" ) 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 0000000..38cb2d4 Binary files /dev/null and b/NyaaDownloader/icons/nyaadownloader.png differ diff --git a/NyaaDownloader/nyaa.py b/NyaaDownloader/nyaa.py new file mode 100644 index 0000000..d16fb27 --- /dev/null +++ b/NyaaDownloader/nyaa.py @@ -0,0 +1,230 @@ +# ------------------------------IMPORTS------------------------------ + + +import nyaapy.nyaasi.nyaa as NyaaPy +from nyaapy.torrent import Torrent + +import re +import requests +import webbrowser as wb + + +# ------------------------------FUNCTIONS------------------------------ + + +def is_in_database(anime_name: str) -> bool: + """Check if anime exists in Nyaa database + Args: + anime_name (str): Name of the anime to check. + + Raises: + Exception: If the underlying module throws one. + + Returns: + bool: True if check was successful, False otherwise. + """ + try: + if ( + len( + NyaaPy.Nyaa.search( + keyword=anime_name, category=1, subcategory=2, filters=0 + ) + ) + == 0 + ): + return False + except Exception as e: + raise e + return True + + +def download(torrent: dict) -> bool: + """Download a nyaa.si torrent from the web (also retrives its original name) + + Args: + torrent (dict): The dictionary returned by the NyaaPy.search() method. + + Returns: + bool: True if the transfer was successful, False otherwise. + """ + try: + with requests.get(torrent.download_url) as response, open( + torrent.name + ".torrent", "wb" + ) as out_file: + out_file.write(response.content) + + except requests.Timeout: + return False + + return True + + +def transfer(torrent: dict) -> bool: + """Open the user's torrent client and transfers the file to it. + Args: + torrent (dict): The dictionary returned by the NyaaPy.search() method. + + Returns: + bool: True if the transfer was successful, False otherwise. + """ + try: + return wb.open(torrent.magnet) + + except: + return False + +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 + 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: + """Find if the torrent is already in the database. If not, download it. + + Returns: + 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, 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. + 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)) + hevc_keyword_negative = " ".join(map(lambda k: f'-"{k}"', hevc_keywords)) + av1_keyword_positive = "AV1" + av1_keyword_negative = "-AV1" + + queries = [] + + # 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 == "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 + + if len(found_torrents) == 0: + return None + + try: + # We take the very closest title to what we are looking for. + torrent = None + a_name = anime_name.lower() + + 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 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 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: + raise e + + return torrent 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). - - - 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 diff --git a/util/nyaa.py b/util/nyaa.py deleted file mode 100644 index 3f8e520..0000000 --- a/util/nyaa.py +++ /dev/null @@ -1,133 +0,0 @@ -# ------------------------------IMPORTS------------------------------ - - -import NyaaPy -import requests -import webbrowser as wb - - -# ------------------------------FUNCTIONS------------------------------ - - -def is_in_database(anime_name: str) -> bool: - """Check if anime exists in Nyaa database - Args: - 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. - - Returns: - bool: True if check was successful, False otherwise. - """ - try: - if ( - len( - NyaaPy.Nyaa.search( - keyword=anime_name, category=1, subcategory=2, filters=0 - ) - ) - == 0 - ): - return False - except: - raise Exception("No Internet connection available.") - return True - - -def download(torrent: dict) -> bool: - """Download a nyaa.si torrent from the web (also retrives its original name) - - Args: - torrent (dict): The dictionary returned by the NyaaPy.search() method. - - Returns: - bool: True if the transfer was successful, False otherwise. - """ - try: - with requests.get(torrent["download_url"]) as response, open( - torrent["name"] + ".torrent", "wb" - ) as out_file: - out_file.write(response.content) - - except requests.Timeout: - return False - - return True - - -def transfer(torrent: dict) -> bool: - """Open the user's torrent client and transfers the file to it. - Args: - torrent (dict): The dictionary returned by the NyaaPy.search() method. - - Returns: - bool: True if the transfer was successful, False otherwise. - """ - try: - 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. - 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. - """ - - # 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) - 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, - ) - - 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 - ): # we want to avoid ~ because Erai-Raws use it for already packed episodes - 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: - if ( - 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 {} - - return torrent