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
+[](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