diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..67b5f16 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,44 @@ +name: Bug report +description: Submit a bug report +title: '[BUG]: ' +labels: ['bug'] +assignees: 'bbtufty' + +body: + - type: textarea + id: description + attributes: + label: Describe the Bug + description: A clear and concise description of the bug. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: A clear and concise description of what you expected to happen. + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce the behavior + description: If not present under all circumstances, give a step-by-step on how to reproduce the bug. + value: | + 1. + 2. + ... + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: Attach any applicable screenshots that illustrate your problem. + - type: textarea + id: preferences + attributes: + label: Preference File + description: Paste your config file (likely config.yml), with any sensitive info redacted + render: yaml + - type: textarea + id: log + attributes: + label: Log + description: Attach the relevant log file(s) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..0cdce9d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,33 @@ +name: Feature +description: Suggest a new feature for this project +title: '[FEAT]: ' +labels: ['feature'] +assignees: 'bbtufty' + +body: + - type: textarea + id: problem + attributes: + label: Problem + description: Is your feature request related to a problem? Please describe + placeholder: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + - type: textarea + id: solution + attributes: + label: Solution + description: Describe the solution you'd like + placeholder: A clear and concise description of what you want to happen. + - type: textarea + id: alternatives + attributes: + label: Alternatives + description: Describe alternatives you've considered + placeholder: A clear and concise description of any alternative solutions or features you've considered. + - type: textarea + id: context + attributes: + label: Context + description: Additional context + placeholder: Add any other context or screenshots about the feature request here. + + diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..eeaf743 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,42 @@ +name: Build + +on: + push: + branches: + - '*' + pull_request: + branches: + - master + +jobs: + build_sdist_and_wheel: + name: Build source distribution + runs-on: ubuntu-latest + strategy: + matrix: + # Versions listed at https://raw.githubusercontent.com/actions/python-versions/main/versions-manifest.json + python-version: [ + "3.11", + "3.12", + ] + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build +# - name: Run pytest +# run: | +# pip install -e . +# pip install pytest +# cd tests +# pytest tests_romparser.py +# pytest tests_romchooser.py diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..5683bb4 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,50 @@ +name: Publish + +on: [push, pull_request] + +jobs: + build_sdist_and_wheel: + name: Build source distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + name: Install Python + with: + python-version: "3.11" + - name: Install build + run: python -m pip install build + - name: Build sdist + run: python -m build --sdist --wheel --outdir dist/ . + - uses: actions/upload-artifact@v4 + with: + name: artifact-source + path: dist/* + + build_and_upload_executable: + name: Build and upload executable + runs-on: windows-latest + if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/v') + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: actions/setup-python@v5 + with: + python-version: 3.12 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pyinstaller + pip install -e . + - name: Build package + run: > + pyinstaller nxbrew_dl_gui.py + --copy-metadata nxbrew_dl + --collect-data nxbrew_dl + --onefile + -n nxbrew_dl.exe + - uses: softprops/action-gh-release@v2 + with: + files: dist/* + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 82f9275..fb84f73 100644 --- a/.gitignore +++ b/.gitignore @@ -159,4 +159,8 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ + +# Don't include cache/config +config.yml +cache.json diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..7174da0 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,30 @@ +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +# Build documentation in the docs/ directory with Sphinx +sphinx: + builder: html + configuration: docs/conf.py + fail_on_warning: true + +# Optionally build your docs in additional formats such as PDF and ePub +formats: + - htmlzip + - pdf + +# Optionally set the version of Python and requirements required to build your docs +python: + install: + - method: pip + path: . + extra_requirements: + - docs diff --git a/CHANGES.rst b/CHANGES.rst new file mode 100644 index 0000000..99efa7b --- /dev/null +++ b/CHANGES.rst @@ -0,0 +1,4 @@ +0.0.1 (Unreleased) +================== + +- Initial release \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..ad6d0b0 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,17 @@ +include README.md +include CHANGES.rst +include LICENSE +include pyproject.toml + +recursive-include *.pyx *.c *.pxd +recursive-include docs * +recursive-include licenses * +recursive-include cextern * +recursive-include scripts * + +prune build +prune docs/_build +prune docs/api +prune */__pycache__ + +global-exclude *.pyc *.o \ No newline at end of file diff --git a/README.md b/README.md index 84d26ee..0498f6a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,15 @@ -# nxbrew-scraper -Scrape nxbrew to automatically download Switch games +# NXBrew-dl + +[![Docs](https://readthedocs.org/projects/nxbrew-dl/badge/?version=latest&style=flat-square)](https://nxbrew-dl.readthedocs.io/en/latest/) +[![Actions](https://img.shields.io/github/actions/workflow/status/bbtufty/nxbrew-dl/build.yaml?branch=main&style=flat-square)](https://github.com/bbtufty/nxbrew-dl/actions) +[![License](https://img.shields.io/badge/license-GNUv3-blue.svg?label=License&style=flat-square)](LICENSE) + +NXBrew-dl is intended to be an easy-to-user interface to download ROMs, DLC and update files for NSP. It does so via +a GUI interface, allowing users to download items in bulk and keeping things up-to-date. + +As of now, this is in extremely early development, and only currently acts as an interactive browser for the ROM +list on NXBrew. Downloads do not yet work! + +To get started, see the [documentation](https://nxbrew-dl.readthedocs.io/en/latest/). + +We encourage users to open [issues](https://github.com/bbtufty/nxbrew-dl/issues>) as and where they find them. \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/changelog.rst b/docs/changelog.rst new file mode 100644 index 0000000..54be413 --- /dev/null +++ b/docs/changelog.rst @@ -0,0 +1,5 @@ +######### +Changelog +######### + +.. include:: ../CHANGES.rst \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..513589a --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,51 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'nxbrew_dl' +copyright = '2024, bbtufty' +author = 'bbtufty' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.coverage", + "sphinx.ext.napoleon", + "sphinx.ext.todo", + "sphinx.ext.viewcode", + "sphinx_automodapi.automodapi", +] + +templates_path = ["_templates"] +exclude_patterns = [ + "_build", + "Thumbs.db", + ".DS_Store", +] + +master_doc = "index" + +todo_include_todos = True + +html_theme_options = { + "collapse_navigation": False, + "navigation_depth": 4, + "globaltoc_collapse": False, + "globaltoc_includehidden": False, + "version_selector": True, +} + +autoclass_content = "both" + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = "sphinx_rtd_theme" +html_static_path = [] diff --git a/docs/img/gui.png b/docs/img/gui.png new file mode 100644 index 0000000..103899d Binary files /dev/null and b/docs/img/gui.png differ diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..20ce053 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,23 @@ +.. nxbrew_dl documentation master file, created by + sphinx-quickstart on Fri Oct 11 20:13:17 2024. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +.. include:: intro.rst + +.. toctree:: + :titlesonly: + :maxdepth: 2 + :caption: Documentation + + installation + usage + changelog + reference_api + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..ac25397 --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,14 @@ +############ +Installation +############ + +NXBrew-dl currently can only be installed via GitHub: :: + + git clone https://github.com/bbtufty/nxbrew-dl.git + pip install -e . + +After this, run the script in the main directory: :: + + cd nxbrew-dl + python nxbrew_dl_gui.py + diff --git a/docs/intro.rst b/docs/intro.rst new file mode 100644 index 0000000..db1fee8 --- /dev/null +++ b/docs/intro.rst @@ -0,0 +1,21 @@ +######### +NXBrew-dl +######### + +.. image:: https://img.shields.io/github/actions/workflow/status/bbtufty/nxbrew-dl/build.yaml?branch=main&style=flat-square + :target: https://github.com/bbtufty/nxbrew-dl/actions +.. image:: https://readthedocs.org/projects/nxbrew-dl/badge/?version=latest&style=flat-square + :target: https://nxbrew-dl.readthedocs.io/en/latest/ +.. image:: https://img.shields.io/badge/license-GNUv3-blue.svg?label=License&style=flat-square + +NXBrew-dl is intended to be an easy-to-user interface to download ROMs, DLC and update files for NSP. It does so via +a GUI interface, allowing users to download items in bulk and keeping things up-to-date. + +As of now, this is in extremely early development, and only currently acts as an interactive browser for the ROM +list on NXBrew. Downloads do not yet work! + +For details on installation, see :doc:`installation `. + +For how to use NXBrew-dl, see :doc:`usage `. + +We encourage users to open `issues `_ as and where they find them. diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..954237b --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/reference_api.rst b/docs/reference_api.rst new file mode 100644 index 0000000..01a9c3a --- /dev/null +++ b/docs/reference_api.rst @@ -0,0 +1,23 @@ +############# +Reference/API +############# + +=== +GUI +=== + +.. autoclass:: nxbrew_dl.gui.MainWindow + :members: + :undoc-members: + +.. autoclass:: nxbrew_dl.gui.AboutWindow + :members: + :undoc-members: + +========= +Utilities +========= + +.. automodule:: nxbrew_dl.nxbrew_dl + :members: + :undoc-members: \ No newline at end of file diff --git a/docs/usage.rst b/docs/usage.rst new file mode 100644 index 0000000..2505c19 --- /dev/null +++ b/docs/usage.rst @@ -0,0 +1,15 @@ +##### +Usage +##### + +NXBrew-dl is designed to be simple to use. After loading (and entering a URL for NXBrew, which we **will** not provide), +you will see an interface like this: + +.. image:: img/gui.png + +On the left is the config. This includes where downloads will be stored, whether you would like to prefer NSP or XCI +files, and whether you would like to download associated updates and DLC, if available. + +The right shows the NXBrew index. Each is flagged with various properties, such as whether it has an NSP or XCI file, +updates, and DLC. By clicking the "DL?" button, you add to the list. You can filter using the search bar at the top. +By clicking run, you will queue up downloads. diff --git a/nxbrew_dl/__init__.py b/nxbrew_dl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nxbrew_dl/configs/general.yml b/nxbrew_dl/configs/general.yml new file mode 100644 index 0000000..6a11c4c --- /dev/null +++ b/nxbrew_dl/configs/general.yml @@ -0,0 +1,2 @@ +forbidden_titles: + - "Latest RAW Game Updates [17th April 2024][47 New Updates] [DISCONTINUED]" \ No newline at end of file diff --git a/nxbrew_dl/configs/regex.yml b/nxbrew_dl/configs/regex.yml new file mode 100644 index 0000000..54eeb59 --- /dev/null +++ b/nxbrew_dl/configs/regex.yml @@ -0,0 +1,14 @@ +nsp_variations: + - "N\\|?S" + - "N\\|?P" + - "N\\|?S\\|?P" + - "N\\|?P\\|?S" + +xci_variations: + - "X\\|?C\\|?I" + +update_variations: + - "Update" + +dlc_variations: + - "DLC" \ No newline at end of file diff --git a/nxbrew_dl/gui/__init__.py b/nxbrew_dl/gui/__init__.py new file mode 100644 index 0000000..68fc491 --- /dev/null +++ b/nxbrew_dl/gui/__init__.py @@ -0,0 +1,7 @@ +from .gui_nxbrew_dl import MainWindow +from .gui_about import AboutWindow + +__all__ = [ + "MainWindow", + "AboutWindow", +] diff --git a/nxbrew_dl/gui/custom_widgets.py b/nxbrew_dl/gui/custom_widgets.py new file mode 100644 index 0000000..638f009 --- /dev/null +++ b/nxbrew_dl/gui/custom_widgets.py @@ -0,0 +1,156 @@ +from PySide6.QtCore import Qt +from PySide6.QtGui import QBrush, QColor +from PySide6.QtWidgets import QTableWidgetItem, QHeaderView + +COLOURS = { + "green": QColor(0, 175, 0, 255), + "orange": QColor(255, 170, 0, 255), + "red": QColor(175, 0, 0, 255), +} + + +def set_dl( + table, + row_position, +): + """Set downloaded state for each row + + Args: + table (QTableWidget): QTableWidget + row_position (int): Row position + """ + + dl = QTableWidgetItem() + dl.setTextAlignment(Qt.AlignmentFlag.AlignHCenter) + + # TODO: If this is in the cache, then auto-check it + dl.setCheckState(Qt.CheckState.Unchecked) + + table.setItem(row_position, 1, dl) + + +class TableRowWidget(QTableWidgetItem): + + def __init__(self, row_dict, row_name_key="long_name"): + """Custom table rows, that include pretty colour and useful columns + + Args: + row_dict (dict): Dictionary of row details + row_name_key (str): Column name to define the name for the row + + TODO: + Some names don't have NSP or XCI. In which case, we probably want + to set a ??? for them + """ + super(TableRowWidget, self).__init__() + + self.name = row_dict[row_name_key] + self.url = row_dict["url"] + + self.row_dict = row_dict + self.row_name_key = row_name_key + + def setup_row(self, table, row_position): + """Create a row for a ROM with all the relevant info + + Args: + table (QTableWidget): QTableWidget + row_position (int): Row position + """ + + self.set_name( + table=table, + row_position=row_position, + ) + + set_dl( + table=table, + row_position=row_position, + ) + + # Set NSP/XCI + self.set_filetype( + table=table, + key="has_nsp", + row_position=row_position, + column_position=2, + ) + self.set_filetype( + table=table, + key="has_xci", + row_position=row_position, + column_position=3, + ) + self.set_filetype( + table=table, + key="has_update", + row_position=row_position, + column_position=4, + ) + self.set_filetype( + table=table, + key="has_dlc", + row_position=row_position, + column_position=5, + ) + + # Finally, resize the table. Shrink everything but title to minimum + header = table.horizontalHeader() + header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + + # And stretch out the title to fill the rest + header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + + def set_name( + self, + table, + row_position, + ): + """Set the name for the row + + Args: + table (QTableWidget): Table widget + row_position (int): Position of the row to set name for + """ + + item = QTableWidgetItem(self.name) + item.setToolTip(self.url) + + table.setItem(row_position, 0, item) + + def set_filetype( + self, + table, + key, + row_position, + column_position, + ): + """Set the status of a filetype + + If a key evaluates to true in the row_dict, it will set + a text box that says "Yes" and is green. Otherwise, will + be a red "No" + + Args: + table (QTableWidget): Table widget + key (str): Key to check + row_position (int): Row position + column_position (int): Column position for the key + """ + + has_filetype = QTableWidgetItem() + has_filetype.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + + # Set text and colour + if self.row_dict[key]: + has_filetype.setText("Yes") + colour = QBrush(COLOURS["green"]) + else: + has_filetype.setText("No") + colour = QBrush(COLOURS["red"]) + colour.setStyle(Qt.BrushStyle.SolidPattern) + has_filetype.setBackground(colour) + + has_filetype.setFlags(Qt.ItemFlag.ItemIsEnabled) + + table.setItem(row_position, column_position, has_filetype) diff --git a/nxbrew_dl/gui/gui_about.py b/nxbrew_dl/gui/gui_about.py new file mode 100644 index 0000000..fcb14cb --- /dev/null +++ b/nxbrew_dl/gui/gui_about.py @@ -0,0 +1,14 @@ +from PySide6.QtWidgets import QDialog + +from .layout_about import Ui_About + + +class AboutWindow(QDialog): + + def __init__(self, parent=None): + """NXBrew-dl About window""" + + super().__init__() + + self.ui = Ui_About() + self.ui.setupUi(self) diff --git a/nxbrew_dl/gui/gui_nxbrew_dl.py b/nxbrew_dl/gui/gui_nxbrew_dl.py new file mode 100644 index 0000000..af152cd --- /dev/null +++ b/nxbrew_dl/gui/gui_nxbrew_dl.py @@ -0,0 +1,267 @@ +import os +from functools import partial + +from PySide6.QtCore import Slot, QSize +from PySide6.QtGui import QIcon +from PySide6.QtWidgets import ( + QApplication, + QMainWindow, + QFileDialog, +) + +import nxbrew_dl +from .gui_about import AboutWindow +from .gui_utils import open_url, get_gui_logger, add_row_to_table +from .layout_nxbrew_dl import Ui_nxbrew_dl +from ..nxbrew_dl import load_yml, save_yml, get_game_dict + + +def open_game_url(item): + """If a row title is clicked, open the associated URL""" + + column = item.column() + + # If we're not clicking the name, don't do anything + if column != 0: + return + + # Search by URL, so pull that out here + url = item.toolTip() + open_url(url) + + +class MainWindow(QMainWindow): + + def __init__(self): + """NXBrew-dl Main Window + + TODO: + - Actually download stuff + - Region/language priorities + """ + + super().__init__() + + self.ui = Ui_nxbrew_dl() + self.ui.setupUi(self) + + # Set the window icon + icon_path = os.path.join(os.path.dirname(__file__), "img", "logo.svg") + icon = QIcon() + icon.addFile(icon_path, QSize(), QIcon.Mode.Normal, QIcon.State.Off) + self.setWindowIcon(icon) + + # Load in various config files + self.mod_dir = os.path.dirname(nxbrew_dl.__file__) + self.general_config = load_yml( + os.path.join(self.mod_dir, "configs", "general.yml") + ) + self.regex_config = load_yml(os.path.join(self.mod_dir, "configs", "regex.yml")) + + # Read in the user config, keeping the filename around so we can save it out later + self.user_config_file = os.path.join(os.getcwd(), "config.yml") + if os.path.exists(self.user_config_file): + self.user_config = load_yml(self.user_config_file) + else: + self.user_config = {} + self.load_config() + + self.logger = get_gui_logger(log_level="INFO") + self.logger.warning("Do not close this window!") + + # Set up the worker threads for later + self.nxbrew_thread = None + self.nxbrew_worker = None + + # Help menu buttons + documentation = self.ui.actionDocumentation + documentation.triggered.connect( + lambda: open_url("https://nxbrew-dl.readthedocs.io") + ) + + issues = self.ui.actionIssues + issues.triggered.connect( + lambda: open_url("https://github.com/bbtufty/nxbrew-dl/issues") + ) + + about = self.ui.actionAbout + about.triggered.connect(lambda: AboutWindow(self).exec()) + + # Main window buttons + run_nxbrew_dl = self.ui.pushButtonRun + run_nxbrew_dl.clicked.connect(self.run_nxbrew_dl) + + exit_button = self.ui.pushButtonExit + exit_button.clicked.connect(self.close_all) + + # Directory browing for the download directory + self.ui.pushButtonDownloadDir.clicked.connect( + partial(self.set_directory_name, line_edit=self.ui.lineEditDownloadDir) + ) + + self.game_table = self.ui.tableGames + self.game_dict = {} + + # Add in refresh option + refresh_button = self.ui.pushButtonRefresh + refresh_button.clicked.connect(self.load_table) + + # Set up the table so links will open the webpages + self.game_table.itemDoubleClicked.connect(open_game_url) + + # Set up the search bar + self.search_bar = self.ui.lineEditSearch + self.search_bar.textChanged.connect(self.update_display) + + self.load_table() + + def get_game_dict(self): + """Get game dictionary from NXBrew A-Z page""" + + if "nxbrew" not in self.user_config.get("nxbrew_url", ""): + self.logger.warning( + "NXBrew URL not found. Enter one and refresh the game list!" + ) + return False + + self.game_dict = get_game_dict( + general_config=self.general_config, + regex_config=self.regex_config, + nxbrew_url=self.user_config["nxbrew_url"], + ) + + def update_display(self, text): + """When using the search bar, show/hide rows + + Args: + text (str): Text to filter out rows + """ + + for r in range(self.game_table.rowCount()): + r_text = self.game_table.item(r, 0).text() + if text.lower() in r_text.lower(): + self.game_table.showRow(r) + else: + self.game_table.hideRow(r) + + def load_table(self): + """Load the game table, disable things until we're done""" + + self.ui.centralwidget.setEnabled(False) + + # Save and load the config + self.save_config() + self.load_config() + + self.game_dict = {} + self.get_game_dict() + + # Clear out the old table and search bar + self.search_bar.clear() + self.game_table.setRowCount(0) + + # Add rows to the game dict + for name in self.game_dict: + row = add_row_to_table(self.game_table, self.game_dict[name]) + self.game_dict[name].update( + { + "row": row, + } + ) + + self.ui.centralwidget.setEnabled(True) + + def load_config( + self, + ): + """Apply read in config to the GUI""" + + if "nxbrew_url" in self.user_config: + self.ui.lineEditNXBrewURL.setText(self.user_config["nxbrew_url"]) + + if "download_dir" in self.user_config: + self.ui.lineEditDownloadDir.setText(self.user_config["download_dir"]) + + if "prefer_filetype" in self.user_config: + + prefer_filetype = self.user_config["prefer_filetype"] + + if prefer_filetype == "NSP": + button = self.ui.radioButtonPreferNSP + elif prefer_filetype == "XCI": + button = self.ui.radioButtonPreferXCI + else: + raise ValueError( + f"Do not understand preferred filetype {prefer_filetype}" + ) + + button.setChecked(True) + + if "download_updates" in self.user_config: + dl_updates = self.user_config["download_updates"] + self.ui.checkBoxDownloadUpdates.setChecked(dl_updates) + + if "download_dlc" in self.user_config: + dl_dlc = self.user_config["download_dlc"] + self.ui.checkBoxDownloadDLC.setChecked(dl_dlc) + + def save_config( + self, + ): + """Save config to file""" + + self.user_config["nxbrew_url"] = self.ui.lineEditNXBrewURL.text() + self.user_config["download_dir"] = self.ui.lineEditDownloadDir.text() + + prefer_filetype = self.ui.buttonGroupPreferNSPXCI.checkedButton().text() + if prefer_filetype == "Prefer NSPs": + self.user_config["prefer_filetype"] = "NSP" + elif prefer_filetype == "Prefer XCIs": + self.user_config["prefer_filetype"] = "XCI" + else: + raise ValueError(f"Button {prefer_filetype} not understood") + + self.user_config["download_updates"] = ( + self.ui.checkBoxDownloadUpdates.isChecked() + ) + self.user_config["download_dlc"] = self.ui.checkBoxDownloadDLC.isChecked() + + save_yml(self.user_config_file, self.user_config) + + return True + + def set_directory_name( + self, + line_edit, + ): + """Make a button set a directory name + + Args: + line_edit (QLineEdit): The QLineEdit widget to set the text for + """ + + filename = QFileDialog.getExistingDirectory( + self, + caption=self.tr("Select directory"), + dir=os.getcwd(), + ) + if filename != "": + line_edit.setText(filename) + + @Slot() + def run_nxbrew_dl(self): + """Run NXBrew-dl""" + + # Start out by saving the config + self.save_config() + + raise NotImplementedError("Not yet implemented!") + + @Slot() + def close_all(self): + """Close the application""" + + self.logger.info("Closing down. Will save config") + self.save_config() + + QApplication.closeAllWindows() diff --git a/nxbrew_dl/gui/gui_utils.py b/nxbrew_dl/gui/gui_utils.py new file mode 100644 index 0000000..4d1ef51 --- /dev/null +++ b/nxbrew_dl/gui/gui_utils.py @@ -0,0 +1,73 @@ +import logging +import sys + +import colorlog +from PySide6.QtCore import Slot +from PySide6.QtGui import QDesktopServices + +from ..gui.custom_widgets import TableRowWidget + + +@Slot() +def open_url(url): + """Opens a URL""" + + QDesktopServices.openUrl(url) + + +def add_row_to_table( + table, + row_dict, + row_name_key="long_name", +): + """Add row to table, using a dictionary of important info + + Args: + table (QTableWidget): Table to add row to + row_dict (dict): Dictionary of data for the row + row_name_key (str): Key used to identify the name for the row. + Defaults to "long_name" + """ + + row_position = table.rowCount() + + table.insertRow(row_position) + + row = TableRowWidget( + row_dict, + row_name_key=row_name_key, + ) + row.setup_row( + table=table, + row_position=row_position, + ) + + return row + + +def get_gui_logger(log_level="info"): + """Get the logger for the GUI + + Args: + log_level (str, optional): Logging level. Defaults to "info". + """ + logger = logging.getLogger() + + # Set the log level based on the provided parameter + log_level = log_level.upper() + if log_level == "DEBUG": + logger.setLevel(logging.DEBUG) + elif log_level == "INFO": + logger.setLevel(logging.INFO) + elif log_level == "CRITICAL": + logger.setLevel(logging.CRITICAL) + else: + logger.critical(f"Invalid log level '{log_level}', defaulting to 'INFO'") + logger.setLevel(logging.INFO) + log_handler = colorlog.StreamHandler(sys.stdout) + log_handler.setFormatter( + colorlog.ColoredFormatter("%(log_color)s%(levelname)s: %(message)s") + ) + logger.addHandler(log_handler) + + return logger diff --git a/nxbrew_dl/gui/img/logo.svg b/nxbrew_dl/gui/img/logo.svg new file mode 100644 index 0000000..06878af --- /dev/null +++ b/nxbrew_dl/gui/img/logo.svg @@ -0,0 +1,62 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/nxbrew_dl/gui/layout_about.py b/nxbrew_dl/gui/layout_about.py new file mode 100644 index 0000000..c2f6a7c --- /dev/null +++ b/nxbrew_dl/gui/layout_about.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- + +################################################################################ +## Form generated from reading UI file 'layout_about.ui' +## +## Created by: Qt User Interface Compiler version 6.7.3 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, + QMetaObject, QObject, QPoint, QRect, + QSize, QTime, QUrl, Qt) +from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, + QFont, QFontDatabase, QGradient, QIcon, + QImage, QKeySequence, QLinearGradient, QPainter, + QPalette, QPixmap, QRadialGradient, QTransform) +from PySide6.QtWidgets import (QApplication, QDialog, QLabel, QSizePolicy, + QVBoxLayout, QWidget) + +class Ui_About(object): + def setupUi(self, About): + if not About.objectName(): + About.setObjectName(u"About") + About.resize(332, 191) + sizePolicy = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(About.sizePolicy().hasHeightForWidth()) + About.setSizePolicy(sizePolicy) + About.setMinimumSize(QSize(332, 191)) + About.setMaximumSize(QSize(332, 191)) + About.setModal(True) + self.verticalLayout = QVBoxLayout(About) + self.verticalLayout.setObjectName(u"verticalLayout") + self.aboutLargeTitle = QLabel(About) + self.aboutLargeTitle.setObjectName(u"aboutLargeTitle") + font = QFont() + font.setPointSize(16) + self.aboutLargeTitle.setFont(font) + self.aboutLargeTitle.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self.verticalLayout.addWidget(self.aboutLargeTitle) + + self.aboutURL = QLabel(About) + self.aboutURL.setObjectName(u"aboutURL") + font1 = QFont() + font1.setPointSize(12) + self.aboutURL.setFont(font1) + self.aboutURL.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.aboutURL.setOpenExternalLinks(True) + + self.verticalLayout.addWidget(self.aboutURL) + + + self.retranslateUi(About) + + QMetaObject.connectSlotsByName(About) + # setupUi + + def retranslateUi(self, About): + About.setWindowTitle(QCoreApplication.translate("About", u"About", None)) + self.aboutLargeTitle.setText(QCoreApplication.translate("About", u"NXBrew DL", None)) + self.aboutURL.setText(QCoreApplication.translate("About", u"https://github.com/bbtufty/nxbrew-dl", None)) + # retranslateUi + diff --git a/nxbrew_dl/gui/layout_about.ui b/nxbrew_dl/gui/layout_about.ui new file mode 100644 index 0000000..1b73f12 --- /dev/null +++ b/nxbrew_dl/gui/layout_about.ui @@ -0,0 +1,92 @@ + + + About + + + + 0 + 0 + 332 + 191 + + + + + 0 + 0 + + + + + 332 + 191 + + + + + 332 + 191 + + + + About + + + true + + + + + + + 16 + + + + NXBrew DL + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 12 + + + + <a href="https://github.com/bbtufty/nxbrew-dl">https://github.com/bbtufty/nxbrew-dl</a> + + + Qt::AlignmentFlag::AlignCenter + + + true + + + + + + + + + + 10 + + + 10 + + + true + + + true + + + true + + + diff --git a/nxbrew_dl/gui/layout_nxbrew_dl.py b/nxbrew_dl/gui/layout_nxbrew_dl.py new file mode 100644 index 0000000..c32f28f --- /dev/null +++ b/nxbrew_dl/gui/layout_nxbrew_dl.py @@ -0,0 +1,356 @@ +# -*- coding: utf-8 -*- + +################################################################################ +## Form generated from reading UI file 'layout_nxbrew_dl.ui' +## +## Created by: Qt User Interface Compiler version 6.7.3 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, + QMetaObject, QObject, QPoint, QRect, + QSize, QTime, QUrl, Qt) +from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient, + QCursor, QFont, QFontDatabase, QGradient, + QIcon, QImage, QKeySequence, QLinearGradient, + QPainter, QPalette, QPixmap, QRadialGradient, + QTransform) +from PySide6.QtWidgets import (QAbstractItemView, QAbstractScrollArea, QApplication, QButtonGroup, + QCheckBox, QFrame, QHBoxLayout, QHeaderView, + QLabel, QLineEdit, QMainWindow, QMenu, + QMenuBar, QPushButton, QRadioButton, QSizePolicy, + QSpacerItem, QStatusBar, QTableWidget, QTableWidgetItem, + QVBoxLayout, QWidget) + +class Ui_nxbrew_dl(object): + def setupUi(self, nxbrew_dl): + if not nxbrew_dl.objectName(): + nxbrew_dl.setObjectName(u"nxbrew_dl") + nxbrew_dl.resize(1187, 721) + self.actionDocumentation = QAction(nxbrew_dl) + self.actionDocumentation.setObjectName(u"actionDocumentation") + self.actionIssues = QAction(nxbrew_dl) + self.actionIssues.setObjectName(u"actionIssues") + self.actionAbout = QAction(nxbrew_dl) + self.actionAbout.setObjectName(u"actionAbout") + self.centralwidget = QWidget(nxbrew_dl) + self.centralwidget.setObjectName(u"centralwidget") + self.verticalLayout = QVBoxLayout(self.centralwidget) + self.verticalLayout.setObjectName(u"verticalLayout") + self.horizontalLayoutConfigGames = QHBoxLayout() + self.horizontalLayoutConfigGames.setObjectName(u"horizontalLayoutConfigGames") + self.verticalLayoutConfig = QVBoxLayout() + self.verticalLayoutConfig.setObjectName(u"verticalLayoutConfig") + self.verticalSpacerConfigTop = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) + + self.verticalLayoutConfig.addItem(self.verticalSpacerConfigTop) + + self.labelNXBrewURL = QLabel(self.centralwidget) + self.labelNXBrewURL.setObjectName(u"labelNXBrewURL") + + self.verticalLayoutConfig.addWidget(self.labelNXBrewURL) + + self.lineEditNXBrewURL = QLineEdit(self.centralwidget) + self.lineEditNXBrewURL.setObjectName(u"lineEditNXBrewURL") + sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.lineEditNXBrewURL.sizePolicy().hasHeightForWidth()) + self.lineEditNXBrewURL.setSizePolicy(sizePolicy) + + self.verticalLayoutConfig.addWidget(self.lineEditNXBrewURL) + + self.verticalSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) + + self.verticalLayoutConfig.addItem(self.verticalSpacer) + + self.labelDownloadDir = QLabel(self.centralwidget) + self.labelDownloadDir.setObjectName(u"labelDownloadDir") + + self.verticalLayoutConfig.addWidget(self.labelDownloadDir) + + self.horizontalLayout = QHBoxLayout() + self.horizontalLayout.setObjectName(u"horizontalLayout") + self.lineEditDownloadDir = QLineEdit(self.centralwidget) + self.lineEditDownloadDir.setObjectName(u"lineEditDownloadDir") + sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + sizePolicy1.setHorizontalStretch(0) + sizePolicy1.setVerticalStretch(0) + sizePolicy1.setHeightForWidth(self.lineEditDownloadDir.sizePolicy().hasHeightForWidth()) + self.lineEditDownloadDir.setSizePolicy(sizePolicy1) + self.lineEditDownloadDir.setMinimumSize(QSize(250, 0)) + + self.horizontalLayout.addWidget(self.lineEditDownloadDir) + + self.pushButtonDownloadDir = QPushButton(self.centralwidget) + self.pushButtonDownloadDir.setObjectName(u"pushButtonDownloadDir") + + self.horizontalLayout.addWidget(self.pushButtonDownloadDir) + + + self.verticalLayoutConfig.addLayout(self.horizontalLayout) + + self.verticalSpacer_3 = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) + + self.verticalLayoutConfig.addItem(self.verticalSpacer_3) + + self.labelGameDLOptions = QLabel(self.centralwidget) + self.labelGameDLOptions.setObjectName(u"labelGameDLOptions") + + self.verticalLayoutConfig.addWidget(self.labelGameDLOptions) + + self.radioButtonPreferNSP = QRadioButton(self.centralwidget) + self.buttonGroupPreferNSPXCI = QButtonGroup(nxbrew_dl) + self.buttonGroupPreferNSPXCI.setObjectName(u"buttonGroupPreferNSPXCI") + self.buttonGroupPreferNSPXCI.addButton(self.radioButtonPreferNSP) + self.radioButtonPreferNSP.setObjectName(u"radioButtonPreferNSP") + self.radioButtonPreferNSP.setChecked(True) + + self.verticalLayoutConfig.addWidget(self.radioButtonPreferNSP) + + self.radioButtonPreferXCI = QRadioButton(self.centralwidget) + self.buttonGroupPreferNSPXCI.addButton(self.radioButtonPreferXCI) + self.radioButtonPreferXCI.setObjectName(u"radioButtonPreferXCI") + + self.verticalLayoutConfig.addWidget(self.radioButtonPreferXCI) + + self.verticalSpacer_2 = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) + + self.verticalLayoutConfig.addItem(self.verticalSpacer_2) + + self.labelGameAdditionalFiles = QLabel(self.centralwidget) + self.labelGameAdditionalFiles.setObjectName(u"labelGameAdditionalFiles") + + self.verticalLayoutConfig.addWidget(self.labelGameAdditionalFiles) + + self.checkBoxDownloadUpdates = QCheckBox(self.centralwidget) + self.checkBoxDownloadUpdates.setObjectName(u"checkBoxDownloadUpdates") + self.checkBoxDownloadUpdates.setChecked(True) + + self.verticalLayoutConfig.addWidget(self.checkBoxDownloadUpdates) + + self.checkBoxDownloadDLC = QCheckBox(self.centralwidget) + self.checkBoxDownloadDLC.setObjectName(u"checkBoxDownloadDLC") + self.checkBoxDownloadDLC.setChecked(True) + + self.verticalLayoutConfig.addWidget(self.checkBoxDownloadDLC) + + self.verticalSpacerConfigBottom = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) + + self.verticalLayoutConfig.addItem(self.verticalSpacerConfigBottom) + + + self.horizontalLayoutConfigGames.addLayout(self.verticalLayoutConfig) + + self.horizontalSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) + + self.horizontalLayoutConfigGames.addItem(self.horizontalSpacer) + + self.verticalLayoutGames = QVBoxLayout() + self.verticalLayoutGames.setObjectName(u"verticalLayoutGames") + self.horizontalLayoutSearch = QHBoxLayout() + self.horizontalLayoutSearch.setObjectName(u"horizontalLayoutSearch") + self.labelSearch = QLabel(self.centralwidget) + self.labelSearch.setObjectName(u"labelSearch") + + self.horizontalLayoutSearch.addWidget(self.labelSearch) + + self.horizontalSpacerSearch = QSpacerItem(20, 20, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) + + self.horizontalLayoutSearch.addItem(self.horizontalSpacerSearch) + + self.lineEditSearch = QLineEdit(self.centralwidget) + self.lineEditSearch.setObjectName(u"lineEditSearch") + sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + sizePolicy2.setHorizontalStretch(0) + sizePolicy2.setVerticalStretch(0) + sizePolicy2.setHeightForWidth(self.lineEditSearch.sizePolicy().hasHeightForWidth()) + self.lineEditSearch.setSizePolicy(sizePolicy2) + + self.horizontalLayoutSearch.addWidget(self.lineEditSearch) + + self.horizontalSpacer_2 = QSpacerItem(20, 20, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) + + self.horizontalLayoutSearch.addItem(self.horizontalSpacer_2) + + self.pushButtonRefresh = QPushButton(self.centralwidget) + self.pushButtonRefresh.setObjectName(u"pushButtonRefresh") + icon = QIcon(QIcon.fromTheme(u"view-refresh")) + self.pushButtonRefresh.setIcon(icon) + + self.horizontalLayoutSearch.addWidget(self.pushButtonRefresh) + + + self.verticalLayoutGames.addLayout(self.horizontalLayoutSearch) + + self.tableGames = QTableWidget(self.centralwidget) + if (self.tableGames.columnCount() < 6): + self.tableGames.setColumnCount(6) + __qtablewidgetitem = QTableWidgetItem() + self.tableGames.setHorizontalHeaderItem(0, __qtablewidgetitem) + __qtablewidgetitem1 = QTableWidgetItem() + self.tableGames.setHorizontalHeaderItem(1, __qtablewidgetitem1) + __qtablewidgetitem2 = QTableWidgetItem() + self.tableGames.setHorizontalHeaderItem(2, __qtablewidgetitem2) + __qtablewidgetitem3 = QTableWidgetItem() + self.tableGames.setHorizontalHeaderItem(3, __qtablewidgetitem3) + __qtablewidgetitem4 = QTableWidgetItem() + self.tableGames.setHorizontalHeaderItem(4, __qtablewidgetitem4) + __qtablewidgetitem5 = QTableWidgetItem() + self.tableGames.setHorizontalHeaderItem(5, __qtablewidgetitem5) + self.tableGames.setObjectName(u"tableGames") + sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + sizePolicy3.setHorizontalStretch(0) + sizePolicy3.setVerticalStretch(0) + sizePolicy3.setHeightForWidth(self.tableGames.sizePolicy().hasHeightForWidth()) + self.tableGames.setSizePolicy(sizePolicy3) + self.tableGames.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.tableGames.setFrameShadow(QFrame.Shadow.Sunken) + self.tableGames.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.tableGames.setSizeAdjustPolicy(QAbstractScrollArea.SizeAdjustPolicy.AdjustToContents) + self.tableGames.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.tableGames.setProperty(u"showDropIndicator", False) + self.tableGames.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + self.tableGames.setSortingEnabled(True) + + self.verticalLayoutGames.addWidget(self.tableGames) + + + self.horizontalLayoutConfigGames.addLayout(self.verticalLayoutGames) + + + self.verticalLayout.addLayout(self.horizontalLayoutConfigGames) + + self.verticalSpacerConfigButtons = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) + + self.verticalLayout.addItem(self.verticalSpacerConfigButtons) + + self.verticalLayoutBottomButtons = QVBoxLayout() + self.verticalLayoutBottomButtons.setObjectName(u"verticalLayoutBottomButtons") + self.horizontalLayoutBottomButtons = QHBoxLayout() + self.horizontalLayoutBottomButtons.setObjectName(u"horizontalLayoutBottomButtons") + self.pushButtonExit = QPushButton(self.centralwidget) + self.pushButtonExit.setObjectName(u"pushButtonExit") + self.pushButtonExit.setMinimumSize(QSize(130, 30)) + + self.horizontalLayoutBottomButtons.addWidget(self.pushButtonExit) + + self.horizontalSpacerBottomButtons = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + + self.horizontalLayoutBottomButtons.addItem(self.horizontalSpacerBottomButtons) + + self.pushButtonRun = QPushButton(self.centralwidget) + self.pushButtonRun.setObjectName(u"pushButtonRun") + self.pushButtonRun.setMinimumSize(QSize(130, 30)) + + self.horizontalLayoutBottomButtons.addWidget(self.pushButtonRun) + + + self.verticalLayoutBottomButtons.addLayout(self.horizontalLayoutBottomButtons) + + + self.verticalLayout.addLayout(self.verticalLayoutBottomButtons) + + nxbrew_dl.setCentralWidget(self.centralwidget) + self.menubar = QMenuBar(nxbrew_dl) + self.menubar.setObjectName(u"menubar") + self.menubar.setGeometry(QRect(0, 0, 1187, 33)) + self.menuHelp = QMenu(self.menubar) + self.menuHelp.setObjectName(u"menuHelp") + nxbrew_dl.setMenuBar(self.menubar) + self.statusbar = QStatusBar(nxbrew_dl) + self.statusbar.setObjectName(u"statusbar") + nxbrew_dl.setStatusBar(self.statusbar) + + self.menubar.addAction(self.menuHelp.menuAction()) + self.menuHelp.addAction(self.actionDocumentation) + self.menuHelp.addAction(self.actionIssues) + self.menuHelp.addAction(self.actionAbout) + + self.retranslateUi(nxbrew_dl) + + QMetaObject.connectSlotsByName(nxbrew_dl) + # setupUi + + def retranslateUi(self, nxbrew_dl): + nxbrew_dl.setWindowTitle(QCoreApplication.translate("nxbrew_dl", u"NXBrew-dl", None)) + self.actionDocumentation.setText(QCoreApplication.translate("nxbrew_dl", u"Documentation", None)) +#if QT_CONFIG(statustip) + self.actionDocumentation.setStatusTip(QCoreApplication.translate("nxbrew_dl", u"View online documentation", None)) +#endif // QT_CONFIG(statustip) + self.actionIssues.setText(QCoreApplication.translate("nxbrew_dl", u"Issues", None)) +#if QT_CONFIG(statustip) + self.actionIssues.setStatusTip(QCoreApplication.translate("nxbrew_dl", u"Open a GitHub issue", None)) +#endif // QT_CONFIG(statustip) + self.actionAbout.setText(QCoreApplication.translate("nxbrew_dl", u"About", None)) +#if QT_CONFIG(statustip) + self.actionAbout.setStatusTip(QCoreApplication.translate("nxbrew_dl", u"See About page", None)) +#endif // QT_CONFIG(statustip) + self.labelNXBrewURL.setText(QCoreApplication.translate("nxbrew_dl", u"NXBrew URL:", None)) +#if QT_CONFIG(statustip) + self.lineEditNXBrewURL.setStatusTip(QCoreApplication.translate("nxbrew_dl", u"Input NXBrew URL here. This program will not provide this.", None)) +#endif // QT_CONFIG(statustip) + self.lineEditNXBrewURL.setInputMask("") + self.lineEditNXBrewURL.setText("") + self.labelDownloadDir.setText(QCoreApplication.translate("nxbrew_dl", u"Download directory:", None)) +#if QT_CONFIG(statustip) + self.lineEditDownloadDir.setStatusTip(QCoreApplication.translate("nxbrew_dl", u"Where to download files to", None)) +#endif // QT_CONFIG(statustip) + self.lineEditDownloadDir.setPlaceholderText(QCoreApplication.translate("nxbrew_dl", u"/path/to/downloads", None)) + self.pushButtonDownloadDir.setText(QCoreApplication.translate("nxbrew_dl", u"Browse", None)) + self.labelGameDLOptions.setText(QCoreApplication.translate("nxbrew_dl", u"Base download options:", None)) + self.radioButtonPreferNSP.setText(QCoreApplication.translate("nxbrew_dl", u"Prefer NSPs", None)) + self.radioButtonPreferXCI.setText(QCoreApplication.translate("nxbrew_dl", u"Prefer XCIs", None)) + self.labelGameAdditionalFiles.setText(QCoreApplication.translate("nxbrew_dl", u"Additional files:", None)) +#if QT_CONFIG(statustip) + self.checkBoxDownloadUpdates.setStatusTip(QCoreApplication.translate("nxbrew_dl", u"If available, will download update files", None)) +#endif // QT_CONFIG(statustip) + self.checkBoxDownloadUpdates.setText(QCoreApplication.translate("nxbrew_dl", u"Download Updates", None)) +#if QT_CONFIG(statustip) + self.checkBoxDownloadDLC.setStatusTip(QCoreApplication.translate("nxbrew_dl", u"If available, will download DLCs", None)) +#endif // QT_CONFIG(statustip) + self.checkBoxDownloadDLC.setText(QCoreApplication.translate("nxbrew_dl", u"Download DLCs", None)) + self.labelSearch.setText(QCoreApplication.translate("nxbrew_dl", u"Search:", None)) + self.pushButtonRefresh.setText(QCoreApplication.translate("nxbrew_dl", u"Refresh", None)) + ___qtablewidgetitem = self.tableGames.horizontalHeaderItem(0) + ___qtablewidgetitem.setText(QCoreApplication.translate("nxbrew_dl", u"Name", None)); +#if QT_CONFIG(tooltip) + ___qtablewidgetitem.setToolTip(QCoreApplication.translate("nxbrew_dl", u"Game Name (double-click to open URL)", None)); +#endif // QT_CONFIG(tooltip) + ___qtablewidgetitem1 = self.tableGames.horizontalHeaderItem(1) + ___qtablewidgetitem1.setText(QCoreApplication.translate("nxbrew_dl", u"DL?", None)); +#if QT_CONFIG(tooltip) + ___qtablewidgetitem1.setToolTip(QCoreApplication.translate("nxbrew_dl", u"Download Game?", None)); +#endif // QT_CONFIG(tooltip) + ___qtablewidgetitem2 = self.tableGames.horizontalHeaderItem(2) + ___qtablewidgetitem2.setText(QCoreApplication.translate("nxbrew_dl", u"NSP", None)); +#if QT_CONFIG(tooltip) + ___qtablewidgetitem2.setToolTip(QCoreApplication.translate("nxbrew_dl", u"Game has NSP", None)); +#endif // QT_CONFIG(tooltip) + ___qtablewidgetitem3 = self.tableGames.horizontalHeaderItem(3) + ___qtablewidgetitem3.setText(QCoreApplication.translate("nxbrew_dl", u"XCI", None)); +#if QT_CONFIG(tooltip) + ___qtablewidgetitem3.setToolTip(QCoreApplication.translate("nxbrew_dl", u"Game has XCI", None)); +#endif // QT_CONFIG(tooltip) + ___qtablewidgetitem4 = self.tableGames.horizontalHeaderItem(4) + ___qtablewidgetitem4.setText(QCoreApplication.translate("nxbrew_dl", u"Updates", None)); +#if QT_CONFIG(tooltip) + ___qtablewidgetitem4.setToolTip(QCoreApplication.translate("nxbrew_dl", u"Game has Updates", None)); +#endif // QT_CONFIG(tooltip) + ___qtablewidgetitem5 = self.tableGames.horizontalHeaderItem(5) + ___qtablewidgetitem5.setText(QCoreApplication.translate("nxbrew_dl", u"DLC", None)); +#if QT_CONFIG(tooltip) + ___qtablewidgetitem5.setToolTip(QCoreApplication.translate("nxbrew_dl", u"Game has DLC", None)); +#endif // QT_CONFIG(tooltip) +#if QT_CONFIG(statustip) + self.pushButtonExit.setStatusTip(QCoreApplication.translate("nxbrew_dl", u"Exit NXBrew-dl", None)) +#endif // QT_CONFIG(statustip) + self.pushButtonExit.setText(QCoreApplication.translate("nxbrew_dl", u"Exit", None)) +#if QT_CONFIG(statustip) + self.pushButtonRun.setStatusTip(QCoreApplication.translate("nxbrew_dl", u"Run NXBrew-dl", None)) +#endif // QT_CONFIG(statustip) + self.pushButtonRun.setText(QCoreApplication.translate("nxbrew_dl", u"Run", None)) + self.menuHelp.setTitle(QCoreApplication.translate("nxbrew_dl", u"Help", None)) + # retranslateUi + diff --git a/nxbrew_dl/gui/layout_nxbrew_dl.ui b/nxbrew_dl/gui/layout_nxbrew_dl.ui new file mode 100644 index 0000000..ea1377d --- /dev/null +++ b/nxbrew_dl/gui/layout_nxbrew_dl.ui @@ -0,0 +1,509 @@ + + + nxbrew_dl + + + + 0 + 0 + 1187 + 721 + + + + NXBrew-dl + + + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + NXBrew URL: + + + + + + + + 0 + 0 + + + + Input NXBrew URL here. This program will not provide this. + + + + + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + Download directory: + + + + + + + + + + 0 + 0 + + + + + 250 + 0 + + + + Where to download files to + + + /path/to/downloads + + + + + + + Browse + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + Base download options: + + + + + + + Prefer NSPs + + + true + + + buttonGroupPreferNSPXCI + + + + + + + Prefer XCIs + + + buttonGroupPreferNSPXCI + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + Additional files: + + + + + + + If available, will download update files + + + Download Updates + + + true + + + + + + + If available, will download DLCs + + + Download DLCs + + + true + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Minimum + + + + 40 + 20 + + + + + + + + + + + + Search: + + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Minimum + + + + 20 + 20 + + + + + + + + + 0 + 0 + + + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Minimum + + + + 20 + 20 + + + + + + + + Refresh + + + + + + + + + + + + + 0 + 0 + + + + Qt::FocusPolicy::NoFocus + + + QFrame::Shadow::Sunken + + + Qt::ScrollBarPolicy::ScrollBarAsNeeded + + + QAbstractScrollArea::SizeAdjustPolicy::AdjustToContents + + + QAbstractItemView::EditTrigger::NoEditTriggers + + + false + + + QAbstractItemView::SelectionMode::NoSelection + + + true + + + + Name + + + Game Name (double-click to open URL) + + + + + DL? + + + Download Game? + + + + + NSP + + + Game has NSP + + + + + XCI + + + Game has XCI + + + + + Updates + + + Game has Updates + + + + + DLC + + + Game has DLC + + + + + + + + + + + + Qt::Orientation::Vertical + + + QSizePolicy::Policy::Minimum + + + + 20 + 40 + + + + + + + + + + + + + 130 + 30 + + + + Exit NXBrew-dl + + + Exit + + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Expanding + + + + 40 + 20 + + + + + + + + + 130 + 30 + + + + Run NXBrew-dl + + + Run + + + + + + + + + + + + + 0 + 0 + 1187 + 33 + + + + + Help + + + + + + + + + + + Documentation + + + View online documentation + + + + + Issues + + + Open a GitHub issue + + + + + About + + + See About page + + + + + + + + + diff --git a/nxbrew_dl/nxbrew_dl/__init__.py b/nxbrew_dl/nxbrew_dl/__init__.py new file mode 100644 index 0000000..98a0f9f --- /dev/null +++ b/nxbrew_dl/nxbrew_dl/__init__.py @@ -0,0 +1,12 @@ +from .html_tools import get_game_index, get_game_dict +from .io_tools import load_yml, save_yml +from .regex_tools import check_has_filetype, get_game_name + +__all__ = [ + "get_game_index", + "get_game_dict", + "check_has_filetype", + "get_game_name", + "load_yml", + "save_yml", +] diff --git a/nxbrew_dl/nxbrew_dl/html_tools.py b/nxbrew_dl/nxbrew_dl/html_tools.py new file mode 100644 index 0000000..02fd5dd --- /dev/null +++ b/nxbrew_dl/nxbrew_dl/html_tools.py @@ -0,0 +1,95 @@ +import os +from urllib.parse import urljoin + +import requests +from bs4 import BeautifulSoup + +from .regex_tools import get_game_name, check_has_filetype + + +def get_game_index( + nxbrew_url, + cache=True, +): + """Get the NXBrew index as a soup + + Args: + nxbrew_url (string): NXBrew URL + cache (bool): If True, will save the game index as a cache. Defaults to True FIXME + """ + + game_index_name = "game_index.html" + url = urljoin(nxbrew_url, "Index/game-index/games/") + + if not cache: + r = requests.get(url) + soup = BeautifulSoup(r.content, "html.parser") + else: + if not os.path.exists(game_index_name): + r = requests.get(url) + with open(game_index_name, mode="wb") as f: + f.write(r.content) + r = r.content + else: + with open(game_index_name, mode="rb") as f: + r = f.read() + soup = BeautifulSoup(r, "html.parser") + + return soup + + +def get_game_dict( + general_config, + regex_config, + nxbrew_url, +): + """Download the game index, and parse relevant info out of it + + Args: + general_config (dict): General configuration + regex_config (dict): Regex configuration + nxbrew_url (string): NXBrew URL + """ + + game_dict = {} + + # Load in the HTML + game_html = get_game_index(nxbrew_url) + index = game_html.find("div", {"id": "easyindex-index"}) + + nsp_xci_variations = regex_config["nsp_variations"] + regex_config["xci_variations"] + for item in index.find_all("li"): + + # Get the long name, the short name, and the URL + long_name = item.text + + # If there are any forbidden titles, skip them here + if long_name in general_config["forbidden_titles"]: + continue + + short_name = get_game_name(long_name, nsp_xci_variations=nsp_xci_variations) + url = item.find("a").get("href") + + if url in game_dict: + raise ValueError(f"Duplicate URLs found: {url}") + + # Pull out whether NSP/XCI, and whether it has updates/DLCs + remaining_name = long_name.replace(short_name, "") + has_nsp = check_has_filetype(remaining_name, regex_config["nsp_variations"]) + has_xci = check_has_filetype(remaining_name, regex_config["xci_variations"]) + has_update = check_has_filetype( + remaining_name, regex_config["update_variations"] + ) + has_dlc = check_has_filetype(remaining_name, regex_config["dlc_variations"]) + + game_dict[url] = { + "long_name": long_name, + "short_name": short_name, + "url": url, + "has_nsp": has_nsp, + "has_xci": has_xci, + "has_update": has_update, + "has_dlc": has_dlc, + } + + return game_dict diff --git a/nxbrew_dl/nxbrew_dl/io_tools.py b/nxbrew_dl/nxbrew_dl/io_tools.py new file mode 100644 index 0000000..746a69c --- /dev/null +++ b/nxbrew_dl/nxbrew_dl/io_tools.py @@ -0,0 +1,36 @@ +import yaml + + +class DumperEdit(yaml.Dumper): + + def increase_indent(self, flow=False, indentless=False): + return super(DumperEdit, self).increase_indent(flow, False) + + def write_line_break(self, data=None): + super().write_line_break(data) + + if len(self.indents) == 1: + super().write_line_break() + + +def load_yml(f): + """Load YAML file""" + + with open(f, "r") as file: + config = yaml.safe_load(file) + + return config + + +def save_yml(f, data): + """Save YAML file""" + + with open(f, "w") as file: + yaml.dump( + data, + file, + Dumper=DumperEdit, + default_flow_style=False, + sort_keys=False, + indent=2, + ) diff --git a/nxbrew_dl/nxbrew_dl/regex_tools.py b/nxbrew_dl/nxbrew_dl/regex_tools.py new file mode 100644 index 0000000..4db33bc --- /dev/null +++ b/nxbrew_dl/nxbrew_dl/regex_tools.py @@ -0,0 +1,62 @@ +import re + + +def get_game_name( + f, + nsp_xci_variations, +): + """Get game name, which is normally up to "Switch NSP", but there are some edge cases + + Args: + f (str): Name + nsp_xci_variations (list): List of potential NSP/XCI name variations + """ + + # This is a little fiddly, the default is something like [Name] Switch NSP/XCI or whatever, but there's also + # various other possibilities. Search for "Switch" (with optional NSP/XCI variations), "Cloud Version", "eShop", + # "Switch +, "+ Update", and "+ DLC" + regex_str = ( + "^.*?" + "(?=" + f"(?:\\s?Swi(?:tc|ct)h)?\\s(?:\\(?{'|'.join(nsp_xci_variations)})\\)?" + "|" + "(?:\\s[-|–]\\sCloud Version)" + "|" + "(?:\(eShop\))" + "|" + "(?:\\s?Switch\\s\+)" + "|" + "(?:\\s?\+\\sUpdate)" + "|" + "(?:\\s?\+\\sDLC)" + ")" + ) + + reg = re.findall(regex_str, f) + + # If we find something, then pull that out + if len(reg) > 0: + f = reg[0] + + return f + + +def check_has_filetype( + f, + search_str, +): + """Check whether the game has an associated filetype + + Args: + f (str): Name of the file + search_str (list): List of potential values to check for + """ + + regex_str = "|".join(search_str) + + reg = re.findall(regex_str, f) + + if len(reg) > 0: + return True + else: + return False diff --git a/nxbrew_dl_gui.py b/nxbrew_dl_gui.py new file mode 100644 index 0000000..f4b72db --- /dev/null +++ b/nxbrew_dl_gui.py @@ -0,0 +1,18 @@ +import sys + +from PySide6.QtWidgets import QApplication + +from nxbrew_dl.gui import MainWindow + + +def main(): + app = QApplication(sys.argv) + + window = MainWindow() + window.show() + + app.exec() + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d175172 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,60 @@ +[project] + +name = "nxbrew_dl" +version = "0.0.1" +description = "NXBrew Downloader" +readme = "README.md" +requires-python = ">=3.11" +license = {file = "LICENSE"} + +authors = [ + {name = "bbtufty"}, +] +maintainers = [ + {name = "bbtufty"}, +] + +classifiers = [ + # 3 - Alpha + # 4 - Beta + # 5 - Production/Stable + "Development Status :: 3 - Alpha", + + # License + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3 :: Only", +] + +dependencies = [ + "beautifulsoup4>=4.12.3", + "colorlog>=6.8.2", + "PySide6>=6.7.3", + "requests>=2.32.3", + "PyYAML>=6.0.2", +] + +[project.optional-dependencies] +docs = [ + "sphinx>=8.1.0", + "sphinx-automodapi>=0.18.0", + "sphinx-rtd-theme>=3.0.1", +] + +[project.urls] +"Homepage" = "https://github.com/bbtufty/nxbrew-dl" +"Bug Reports" = "https://github.com/bbtufty/nxbrew-dl/issues" +"Source" = "https://github.com/bbtufty/nxbrew-dl" + +[build-system] +requires = [ + "setuptools>=43.0.0", + "wheel>=0.43.0", + "setuptools_scm>=8.1.0", +] + +build-backend = "setuptools.build_meta" \ No newline at end of file