Compare commits

...

4 Commits

Author SHA1 Message Date
398654609b
Merge changes from upstream 2024-12-18 16:43:35 -05:00
31f47f2acd
Fixed checkmarks/Xs for showing if a ticket is available 2024-12-18 16:38:14 -05:00
87da32d62e
Improved WAD name formatting for batch downloads
Also started working on a fix for the checkmark/X icons denoting a ticket not appearing anymore.
2024-12-18 09:08:21 -05:00
6660e129a8
Added WIP JSON-based script support, removed old format
The old script code was already broken from the previous commits anyway, since they changed too much internally. This new format is much easier to understand and will allow for creating more powerful scripts.
2024-12-17 21:55:01 -05:00
7 changed files with 134 additions and 104 deletions

129
NUSGet.py
View File

@ -34,8 +34,9 @@ from qt.py.ui_MainMenu import Ui_MainWindow
from modules.core import * from modules.core import *
from modules.tree import NUSGetTreeModel from modules.tree import NUSGetTreeModel
from modules.download_wii import run_nus_download_wii, run_nus_download_wii_batch from modules.download_batch import run_nus_download_batch
from modules.download_dsi import run_nus_download_dsi, run_nus_download_dsi_batch from modules.download_wii import run_nus_download_wii
from modules.download_dsi import run_nus_download_dsi
nusget_version = "1.3.0" nusget_version = "1.3.0"
@ -333,86 +334,74 @@ class MainWindow(QMainWindow, Ui_MainWindow):
msg_box.setStandardButtons(QMessageBox.StandardButton.Ok) msg_box.setStandardButtons(QMessageBox.StandardButton.Ok)
msg_box.setDefaultButton(QMessageBox.StandardButton.Ok) msg_box.setDefaultButton(QMessageBox.StandardButton.Ok)
msg_box.setWindowTitle(app.translate("MainWindow", "Script Download Failed")) msg_box.setWindowTitle(app.translate("MainWindow", "Script Download Failed"))
file_name = QFileDialog.getOpenFileName(self, caption=app.translate("MainWindow", "Open NUS script"), file_name = QFileDialog.getOpenFileName(self, caption=app.translate("MainWindow", "Open NUS Script"),
filter=app.translate("MainWindow", "NUS Scripts (*.nus *.txt)"), filter=app.translate("MainWindow", "NUS Scripts (*.nus *.json)"),
options=QFileDialog.Option.ReadOnly) options=QFileDialog.Option.ReadOnly)
# The old plaintext script format is no longer supported in NUSGet v1.3.0 and later. This script parsing code
# is for the new JSON script format, which is much easier to use and is cleaner.
if len(file_name[0]) == 0: if len(file_name[0]) == 0:
return return
try: try:
content = open(file_name[0], "r").readlines() with open(file_name[0]) as script_file:
except os.error: script_data = json.load(script_file)
msg_box.setText(app.translate("MainWindow", "The script could not be opened.")) except json.JSONDecodeError as e:
msg_box.setText(app.translate("MainWindow", "An error occurred while parsing the script file!"))
msg_box.setInformativeText(app.translate("MainWindow", f"Error encountered at line {e.lineno}, column {e.colno}. Please double-check the script and try again."))
msg_box.exec() msg_box.exec()
return return
# Build a list of the titles we need to download.
# NUS Scripts are plaintext UTF-8 files that list a title per line, terminated with newlines.
# Every title is its u64 TID, a space and its u16 version, *both* written in hexadecimal.
# NUS itself expects versions as decimal notation, so they need to be decoded first, but TIDs are always written
# in hexadecimal notation.
titles = [] titles = []
for index, title in enumerate(content): for title in script_data:
decoded = title.replace("\n", "").split(" ", 1)
if len(decoded[0]) != 16:
msg_box.setText(app.translate("MainWindow", "The TID for title #%n is not valid.", "", index + 1))
msg_box.exec()
return
elif len(decoded[1]) != 4:
msg_box.setText(app.translate("MainWindow", "The version for title #%n is not valid.", "", index + 1))
msg_box.exec()
return
tid = decoded[0]
try: try:
target_version = int(decoded[1], 16) tid = title["Title ID"]
except ValueError: except KeyError:
msg_box.setText(app.translate("MainWindow", "The version for title #%n is not valid.", "", index + 1)) msg_box.setText(app.translate("MainWindow", "An error occurred while parsing Title IDs!"))
msg_box.setInformativeText(app.translate("MainWindow", f"The title at index {script_data.index(title)} does not have a Title ID!"))
msg_box.exec() msg_box.exec()
return return
# No version key is acceptable, just treat it as latest.
title = None try:
for category in self.trees[self.ui.platform_tabs.currentIndex()][1]: title_version = int(title["Version"])
for title_ in self.trees[self.ui.platform_tabs.currentIndex()][1][category]: except KeyError:
# The last two digits are either identifying the title type (IOS slot, BC type, etc.) or a region code; in case of the latter, skip the region here to match it title_version = -1
if not ((title_["TID"][-2:] == "XX" and title_["TID"][:-2] == tid[:-2]) or title_["TID"] == tid): # If no console was specified, assume Wii.
continue try:
console = title["Console"]
found_ver = False except KeyError:
for region in title_["Versions"]: console = "Wii"
for db_version in title_["Versions"][region]: # Look up the title, and load the archive name for it if one can be found.
if db_version == target_version: archive_name = ""
found_ver = True if console == "vWii":
target_database = vwii_database
elif console == "DSi":
target_database = dsi_database
else:
target_database = wii_database
for category in target_database:
for t in target_database[category]:
if t["TID"][-2:] == "XX":
for r in regions:
if f"{t['TID'][:-2]}{regions[r][0]}" == tid:
try:
archive_name = t["Archive Name"]
break
except KeyError:
archive_name = ""
break
else:
if t["TID"] == tid:
try:
archive_name = t["Archive Name"]
break break
except KeyError:
if not found_ver: archive_name = ""
msg_box.setText(app.translate("MainWindow", "The version for title #%n could not be discovered in the database.", "", index + 1)) break
msg_box.exec() titles.append(BatchTitleData(tid, title_version, console, archive_name))
return
title = title_
break
if title is None:
msg_box.setText(app.translate("MainWindow", "Title #%n could not be discovered in the database.", "", index + 1))
msg_box.exec()
return
titles.append((title["TID"], str(target_version), title["Archive Name"]))
self.lock_ui_for_download() self.lock_ui_for_download()
worker = Worker(run_nus_download_batch, out_folder, titles, self.ui.pack_archive_chkbox.isChecked(),
self.update_log_text(f"Found {len(titles)} titles, starting batch download.") self.ui.keep_enc_chkbox.isChecked(), self.ui.create_dec_chkbox.isChecked(),
self.ui.use_wiiu_nus_chkbox.isChecked(), self.ui.use_local_chkbox.isChecked(),
if self.ui.console_select_dropdown.currentText() == "DSi": self.ui.pack_vwii_mode_chkbox.isChecked(), self.ui.patch_ios_chkbox.isChecked())
worker = Worker(run_nus_download_dsi_batch, out_folder, titles, self.ui.pack_archive_chkbox.isChecked(),
self.ui.keep_enc_chkbox.isChecked(), self.ui.create_dec_chkbox.isChecked(),
self.ui.use_local_chkbox.isChecked(), self.ui.archive_file_entry.text())
else:
worker = Worker(run_nus_download_wii_batch, out_folder, titles, self.ui.pack_archive_chkbox.isChecked(),
self.ui.keep_enc_chkbox.isChecked(), self.ui.create_dec_chkbox.isChecked(),
self.ui.use_wiiu_nus_chkbox.isChecked(), self.ui.use_local_chkbox.isChecked(),
self.ui.pack_vwii_mode_chkbox.isChecked(), self.ui.patch_ios_chkbox.isChecked())
worker.signals.result.connect(self.check_download_result) worker.signals.result.connect(self.check_download_result)
worker.signals.progress.connect(self.update_log_text) worker.signals.progress.connect(self.update_log_text)
self.threadpool.start(worker) self.threadpool.start(worker)

View File

@ -859,5 +859,17 @@
}, },
"Ticket": false "Ticket": false
} }
],
"Virtual Console - NES": [
{
"Name": "Super Mario Bros.",
"TID": "00010001464147XX",
"Versions": {
"USA/NTSC": [2],
"Europe/PAL": [2],
"Japan": [2]
},
"Ticket": false
}
] ]
} }

View File

@ -18,6 +18,15 @@ class TitleData:
danger: str danger: str
@dataclass
class BatchTitleData:
# Class to store all data for a Title in a batch operation.
tid: str
version: int
console: str
archive_name: str
def check_nusget_updates(app, current_version: str, progress_callback=None) -> str | None: def check_nusget_updates(app, current_version: str, progress_callback=None) -> str | None:
# Simple function to make a request to the GitHub API and then check if the latest available version is newer. # Simple function to make a request to the GitHub API and then check if the latest available version is newer.
gh_api_request = requests.get(url="https://api.github.com/repos/NinjaCheetah/NUSGet/releases/latest", stream=True) gh_api_request = requests.get(url="https://api.github.com/repos/NinjaCheetah/NUSGet/releases/latest", stream=True)

40
modules/download_batch.py Normal file
View File

@ -0,0 +1,40 @@
# "modules/download_batch.py", licensed under the MIT license
# Copyright 2024 NinjaCheetah
import pathlib
from typing import List
from modules.core import BatchTitleData
from modules.download_dsi import run_nus_download_dsi
from modules.download_wii import run_nus_download_wii
def run_nus_download_batch(out_folder: pathlib.Path, titles: List[BatchTitleData], pack_wad_chkbox: bool,
keep_enc_chkbox: bool, decrypt_contents_chkbox: bool, wiiu_nus_chkbox: bool,
use_local_chkbox: bool, repack_vwii_chkbox: bool, patch_ios: bool,
progress_callback=None):
for title in titles:
if title.version == -1:
version_str = "Latest"
else:
version_str = str(title.version)
if title.console == "Wii" or title.console == "vWii":
if title.archive_name != "":
archive_name = f"{title.archive_name}-v{version_str}-{title.console}.wad"
else:
archive_name = f"{title.tid}-v{version_str}-{title.console}.wad"
result = run_nus_download_wii(out_folder, title.tid, version_str, pack_wad_chkbox, keep_enc_chkbox,
decrypt_contents_chkbox, wiiu_nus_chkbox, use_local_chkbox, repack_vwii_chkbox,
patch_ios, archive_name, progress_callback)
if result != 0:
return result
elif title.console == "DSi":
if title.archive_name != "":
archive_name = f"{title.archive_name}-v{version_str}-{title.console}.tad"
else:
archive_name = f"{title.tid}-v{version_str}-{title.console}.tad"
result = run_nus_download_dsi(out_folder, title.tid, version_str, pack_wad_chkbox, keep_enc_chkbox,
decrypt_contents_chkbox, use_local_chkbox, archive_name, progress_callback)
if result != 0:
return result
progress_callback.emit(f"Batch download finished.")
return 0

View File

@ -99,6 +99,8 @@ def run_nus_download_dsi(out_folder: pathlib.Path, tid: str, version: str, pack_
# Use a typed TAD name if there is one, and auto generate one based on the TID and version if there isn't. # Use a typed TAD name if there is one, and auto generate one based on the TID and version if there isn't.
progress_callback.emit("Packing TAD...") progress_callback.emit("Packing TAD...")
if tad_file_name != "" and tad_file_name is not None: if tad_file_name != "" and tad_file_name is not None:
# Batch downloads may insert -vLatest, so if it did we can fill in the real number now.
tad_file_name = tad_file_name.replace("-vLatest", f"-v{title_version}")
if tad_file_name[-4:].lower() != ".tad": if tad_file_name[-4:].lower() != ".tad":
tad_file_name += ".tad" tad_file_name += ".tad"
else: else:
@ -112,15 +114,3 @@ def run_nus_download_dsi(out_folder: pathlib.Path, tid: str, version: str, pack_
if (not pack_tad_enabled and pack_tad_chkbox) or (not decrypt_contents_enabled and decrypt_contents_chkbox): if (not pack_tad_enabled and pack_tad_chkbox) or (not decrypt_contents_enabled and decrypt_contents_chkbox):
return 1 return 1
return 0 return 0
def run_nus_download_dsi_batch(out_folder: pathlib.Path, titles: List[Tuple[str, str, str]], pack_tad_chkbox: bool,
keep_enc_chkbox: bool, decrypt_contents_chkbox: bool, use_local_chkbox: bool,
progress_callback=None):
for title in titles:
result = run_nus_download_dsi(out_folder, title[0], title[1], pack_tad_chkbox, keep_enc_chkbox,
decrypt_contents_chkbox, use_local_chkbox, f"{title[2]}-{title[1]}.tad",
progress_callback)
if result != 0:
return result
progress_callback.emit(f"Batch download finished.")
return 0

View File

@ -150,6 +150,8 @@ def run_nus_download_wii(out_folder: pathlib.Path, tid: str, version: str, pack_
# Use a typed WAD name if there is one, and auto generate one based on the TID and version if there isn't. # Use a typed WAD name if there is one, and auto generate one based on the TID and version if there isn't.
progress_callback.emit(" - Packing WAD...") progress_callback.emit(" - Packing WAD...")
if wad_file_name != "" and wad_file_name is not None: if wad_file_name != "" and wad_file_name is not None:
# Batch downloads may insert -vLatest, so if it did we can fill in the real number now.
wad_file_name = wad_file_name.replace("-vLatest", f"-v{title_version}")
if wad_file_name[-4:].lower() != ".wad": if wad_file_name[-4:].lower() != ".wad":
wad_file_name += ".wad" wad_file_name += ".wad"
else: else:
@ -174,16 +176,3 @@ def run_nus_download_wii(out_folder: pathlib.Path, tid: str, version: str, pack_
if (not pack_wad_enabled and pack_wad_chkbox) or (not decrypt_contents_enabled and decrypt_contents_chkbox): if (not pack_wad_enabled and pack_wad_chkbox) or (not decrypt_contents_enabled and decrypt_contents_chkbox):
return 1 return 1
return 0 return 0
def run_nus_download_wii_batch(out_folder: pathlib.Path, titles: List[Tuple[str, str, str]], pack_wad_chkbox: bool,
keep_enc_chkbox: bool, decrypt_contents_chkbox: bool, wiiu_nus_chkbox: bool,
use_local_chkbox: bool, repack_vwii_chkbox: bool, patch_ios: bool,
progress_callback=None):
for title in titles:
result = run_nus_download_wii(out_folder, title[0], title[1], pack_wad_chkbox, keep_enc_chkbox,
decrypt_contents_chkbox, wiiu_nus_chkbox, use_local_chkbox, repack_vwii_chkbox,
patch_ios, f"{title[2]}-{title[1]}.wad", progress_callback)
if result != 0:
return result
progress_callback.emit(f"Batch download finished.")
return 0

View File

@ -11,7 +11,7 @@ class TreeItem:
self.data = data self.data = data
self.parent = parent self.parent = parent
self.children = [] self.children = []
self.metadata = metadata # Store hidden metadata self.metadata = metadata
def add_child(self, item): def add_child(self, item):
self.children.append(item) self.children.append(item)
@ -53,14 +53,16 @@ class NUSGetTreeModel(QAbstractItemModel):
name = entry.get("Name") name = entry.get("Name")
versions = entry.get("Versions", {}) versions = entry.get("Versions", {})
if tid: if tid:
tid_item = TreeItem([f"{tid} - {name}", ""], key_item) tid_item = TreeItem([f"{tid} - {name}", ""], key_item, entry.get("Ticket"))
key_item.add_child(tid_item) key_item.add_child(tid_item)
for region, version_list in versions.items(): for region, version_list in versions.items():
region_item = TreeItem([region, ""], tid_item) region_item = TreeItem([region, ""], tid_item)
tid_item.add_child(region_item) tid_item.add_child(region_item)
for version in version_list: for version in version_list:
danger = entry.get("Danger") if entry.get("Danger") is not None else "" danger = entry.get("Danger") if entry.get("Danger") is not None else ""
metadata = TitleData(entry.get("TID"), entry.get("Name"), entry.get("Archive Name"), archive_name = (entry.get("Archive Name") if entry.get("Archive Name") is not None
else entry.get("Name").replace(" ", "-"))
metadata = TitleData(entry.get("TID"), entry.get("Name"), archive_name,
version, entry.get("Ticket"), region, key, danger) version, entry.get("Ticket"), region, key, danger)
public_versions = entry.get("Public Versions") public_versions = entry.get("Public Versions")
if public_versions is not None: if public_versions is not None:
@ -96,12 +98,11 @@ class NUSGetTreeModel(QAbstractItemModel):
if role == Qt.DecorationRole and index.column() == 0: if role == Qt.DecorationRole and index.column() == 0:
# Check for icons based on the "Ticket" metadata only at the TID level # Check for icons based on the "Ticket" metadata only at the TID level
if item.parent and item.parent.data_at(0) == "System": if item.metadata is not None and isinstance(item.metadata, bool):
if item.metadata and "Ticket" in item.metadata: if item.metadata is True:
if item.metadata["Ticket"]: return QIcon.fromTheme("dialog-ok")
return QIcon.fromTheme("dialog-ok") # Checkmark icon else:
else: return QIcon.fromTheme("dialog-cancel")
return QIcon.fromTheme("dialog-cancel") # X icon
return None return None
def headerData(self, section, orientation, role=Qt.DisplayRole): def headerData(self, section, orientation, role=Qt.DisplayRole):