diff --git a/.gitignore b/.gitignore index 7e7ea14..e8ea928 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,218 @@ -/__temp__/ -token.txt +__temp__ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# 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/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml \ No newline at end of file diff --git a/QuestAssetDL.py b/QuestAssetDL.py new file mode 100644 index 0000000..500226e --- /dev/null +++ b/QuestAssetDL.py @@ -0,0 +1,310 @@ +import json, os, queue, threading, requests, sqlite3, zipfile, argparse +from enum import Enum +from pathlib import Path + + +def main(platform="quest", locale="en_US", action="assets", output_dir_param="."): + global forced_locale + global hmd_type + global target_path + global output_dir + + output_dir = Path(output_dir_param) + hmd_type = HMDTypes[platform.upper()] + forced_locale = locale + target_path = Path(output_dir) / hmd_type.value["name"] + target_path.mkdir(parents=True, exist_ok=True) + + print("Retrieving Appinfo") + retrieve_app_info() + + print("Resolving package names") + retrieve_all_package_names() + + if action == "assets": + print("Downloading images") + download_images() + + json_filename = ( + "appnames_" + hmd_type.value["name"] + "_" + forced_locale + ".json" + ) + print("Creating " + json_filename) + create_app_names_json(json_filename) + + zip_filename = "iconpack_" + hmd_type.value["name"] + ".zip" + print("Creating " + zip_filename) + create_iconpack(zip_filename) + + elif action == "free": + json_filename = "free_apps_" + hmd_type.value["name"] + ".json" + print("Creating " + json_filename) + create_free_apps_json(json_filename) + + connection.close() + + +class HMDTypes(Enum): + QUEST = {"sectionID": "1888816384764129", "hmdType": "MONTEREY", "name": "quest"} + RIFT = {"sectionID": "1736210353282450", "hmdType": "RIFT", "name": "rift"} + GO = {"sectionID": "174868819587665", "hmdType": "PACIFIC", "name": "go"} + GEARVR = {"sectionID": "174868819587665", "hmdType": "GEARVR", "name": "gearvr"} + + +hmd_type = None +forced_locale = None +target_path = None +url = "https://graph.oculus.com/graphql" +app_list = dict() +connection = sqlite3.connect("packages.db") +cursor = connection.cursor() +cursor.execute( + "CREATE TABLE IF NOT EXISTS packages (app_id TEXT PRIMARY_KEY, package_name TEXT)" +) +uri_queue = queue.Queue() + + +def retrieve_app_info(): + sectionCursor = None + has_next_page = True + + while has_next_page: + sectionID = hmd_type.value["sectionID"] + hmdType = hmd_type.value["hmdType"] + variables = { + "sectionId": sectionID, + "sortOrder": None, + "sectionItemCount": 100, + "hmdType": hmdType, + } + if sectionCursor is not None: + variables["sectionCursor"] = sectionCursor + + parameters = { + "forced_locale": forced_locale, + "access_token": "OC|1317831034909742|", + "variables": json.dumps(variables), + "doc_id": "1934814353250664", + } + response = requests.get(url, params=parameters) + json_response = response.json() + if "data" not in json_response: + print("API ERROR:", json_response) + break + + all_items = json_response["data"]["node"]["all_items"] + + edges = all_items["edges"] + page_info = all_items["page_info"] + has_next_page = page_info["has_next_page"] + sectionCursor = page_info.get("end_cursor") + + for edge in edges: + app_id = edge["node"]["id"] + app_name = edge["node"]["display_name"] + app_image_uri = edge["node"]["cover_landscape_image"]["uri"] + node = edge["node"] + offer = node.get("current_offer") or {} + price_info = offer.get("price") or {} + price = price_info.get("offset_amount") + app_list[app_id] = { + "name": app_name, + "uri": app_image_uri, + "price": price, + "package_name": None, + } + + print(f"\rFetched {len(app_list)} apps...", end="", flush=True) + + # Safety: if cursor didn't advance, bail to avoid infinite loop + if not sectionCursor: + break + + print() + print("Retrieved: " + str(len(app_list))) + + +def retrieve_all_package_names(): + """Resolve all package names upfront and store them in app_list.""" + resolved = 0 + skipped = 0 + for app_id in list(app_list.keys()): + package_name = retrieve_package_name(app_id) + app_list[app_id]["package_name"] = package_name + if package_name: + resolved += 1 + else: + skipped += 1 + print(f"Resolved: {resolved}, Skipped: {skipped}") + + +def retrieve_package_name(app_id): + cursor.execute("SELECT package_name FROM packages WHERE app_id=?", (app_id,)) + result = cursor.fetchone() + + if result is not None: + return result[0] + + print("Fetching package_name for app_id " + app_id) + + parameters = { + "access_token": "OC|1317831034909742|", + "variables": json.dumps({"id": str(app_id)}), + "doc_id": "3326435794041206", + } + + request = requests.get(url, params=parameters) + response = request.json() + + if "data" not in response: + print("API ERROR (package lookup):", response) + return None + + node = response["data"].get("node") or {} + binary = node.get("latestBinary") or {} + package_name = binary.get("package_name") + + if not package_name: + print(f"Skipping app_id {app_id} (no package_name)") + return None + + cursor.execute( + "INSERT INTO packages (app_id, package_name) VALUES (?,?)", + (app_id, package_name), + ) + connection.commit() + + return package_name + + +def download_file(file_url, filename): + os.makedirs((os.path.dirname(filename)), exist_ok=True) + if not os.path.isfile(filename): + response = requests.get(file_url, stream=True) + print("Downloading:" + str(filename) + " from " + file_url) + with open(filename, "wb") as out_file: + for chunk in response.iter_content(chunk_size=1048576): + out_file.write(chunk) + + +def download_images(): + for app_id, app_info in app_list.items(): + package_name = app_info["package_name"] + if not package_name: + continue + uri_queue.put( + { + "package_name": target_path / (package_name + ".jpg"), + "uri": app_info["uri"], + } + ) + + num_worker_threads = 8 + threads = [] + for i in range(num_worker_threads): + t = threading.Thread(target=worker) + t.start() + threads.append(t) + + uri_queue.join() + for t in threads: + t.join() + + +def worker(): + while 1: + try: + queue_item = uri_queue.get(block=False) + download_file(queue_item["uri"], queue_item["package_name"]) + uri_queue.task_done() + except queue.Empty: + break + + +def create_app_names_json(json_filename): + app_names = dict() + for app_id, app_info in app_list.items(): + package_name = app_info["package_name"] + if not package_name: + continue + app_names[package_name] = {"name": app_info["name"]} + + with open(Path(output_dir) / json_filename, "w", encoding="utf-8") as file: + json.dump(app_names, file, sort_keys=True, indent=0, ensure_ascii=False) + + +def create_free_apps_json(json_filename): + free_apps = dict() + json_path = output_dir / json_filename + + if json_path.is_file(): + with open(json_path) as file: + free_apps = json.load(file) + for app_id, app_info in app_list.items(): + if app_info["price"] == 0: + package_name = app_info["package_name"] + if not package_name: + continue + free_apps[app_id] = { + "name": app_info["name"], + "package": package_name, + } + + with open(json_path, "w", encoding="utf-8") as file: + json.dump(free_apps, file, sort_keys=True, indent=0, ensure_ascii=False) + + +def create_iconpack(zip_filename): + with zipfile.ZipFile(Path(output_dir) / zip_filename, "w") as iconpack: + for app_id, app_info in app_list.items(): + package_name = app_info["package_name"] + if not package_name: + continue + iconpack.write(target_path / (package_name + ".jpg"), package_name + ".jpg") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Download assets for QuestAppLauncher") + parser.add_argument( + "-p", "--platform", default="quest", choices=["quest", "rift", "go", "gearvr"] + ) + parser.add_argument( + "-l", + "--locale", + default="en_US", + choices=[ + "en_US", + "en_GB", + "de_DE", + "cs_CZ", + "da_DK", + "el_GR", + "es_ES", + "es_LA", + "fi_FI", + "fr_FR", + "it_IT", + "ja_JP", + "ko_KR", + "nb_NO", + "nl_NL", + "pl_PL", + "pt_BR", + "pt_PT", + "ro_RO", + "ru_RU", + "sv_SE", + "tr_TR", + "zh_CN", + "zh_HK", + "zh_TW", + ], + ) + parser.add_argument( + "-a", + "--action", + default="assets", + choices=["assets", "free"], + ) + + args = parser.parse_args() + main(args.platform, args.locale, args.action) diff --git a/QuestAssetGenerator.py b/QuestAssetGenerator.py index 1735552..abe50f7 100644 --- a/QuestAssetGenerator.py +++ b/QuestAssetGenerator.py @@ -1,74 +1,102 @@ import argparse, datetime, os, requests, shutil, subprocess, sys, zipfile -from github import Github # pip install PyGithub +from QuestAssetDL import main as QuestAssetDL +from github import Github, Auth # Temporary directory for scratch space -TEMP_DIR = '__temp__' -LATEST_RELASE_DIR = 'latest_release' -QALAG_OUTPUT_DIR = 'qalag' +TEMP_DIR = "__temp__" +LATEST_RELASE_DIR = "latest_release" +QALAG_OUTPUT_DIR = "qalag" -QALAG_EXE_PATH = 'bin\\QALAG.exe' -WINDIFF_EXE_PATH = 'bin\\WinDiff.exe' +WINDIFF_EXE_PATH = "bin\\WinDiff.exe" + +QUEST_DIR = "quest" +ICONPACK_QUEST = "iconpack_quest.zip" +APPNAMES_QUEST = "appnames_quest.json" +ICONPACK_OTHER = "iconpack_others.zip" +APPNAMES_OTHER = "appnames_other.json" +APPNAMES_GALAG_NAME = "appnames_quest_en_US.json" -QUEST_DIR = 'quest' -ICONPACK_QUEST = 'iconpack_quest.zip' -APPNAMES_QUEST = 'appnames_quest.json' -ICONPACK_OTHER = 'iconpack_others.zip' -APPNAMES_OTHER = 'appnames_other.json' -APPNAMES_GALAG_NAME = 'appnames_quest_en_US.json' def main(): - parser = argparse.ArgumentParser(description='Quest Asset Generator') - parser.add_argument('-a', '--access-token', required=True, help='GitHub acess token') - parser.add_argument('-dr', '--download-release', action='store_true', help='Download latest asset release from github') - parser.add_argument('-da', '--download-assets', action='store_true',help='Download assets from Oculus') - parser.add_argument('-c', '--compare', action='store_true', help='Compare assets') - parser.add_argument('-r', '--release', action='store_true', help='Draft a github release') + parser = argparse.ArgumentParser(description="Quest Asset Generator") + parser.add_argument( + "-a", "--access-token", required=True, help="GitHub acess token" + ) + parser.add_argument( + "-dr", + "--download-release", + action="store_true", + help="Download latest asset release from github", + ) + parser.add_argument( + "-da", + "--download-assets", + action="store_true", + help="Download assets from Oculus", + ) + parser.add_argument("-c", "--compare", action="store_true", help="Compare assets") + parser.add_argument( + "-r", "--release", action="store_true", help="Draft a github release" + ) args = parser.parse_args() # If nothing is specified, perform all actions - if (not args.download_release and not args.download_assets and not args.compare and not args.release): + if ( + not args.download_release + and not args.download_assets + and not args.compare + and not args.release + ): args.download_release = True args.download_assets = True args.compare = True args.release = True # Instantiate github client - g = Github(args.access_token) - repo = g.get_user("HooverHigh").get_repo('QuestAppLauncher_Assets') + ga = Auth.Token(args.access_token) + g = Github(auth=ga) + repo = g.get_user("HooverHigh").get_repo("QuestAppLauncher_Assets") # Set up temp dir - folder_path_temp = os.path.abspath(TEMP_DIR); - if (not os.path.isdir(folder_path_temp)): - os.mkdir(folder_path_temp) + folder_path_temp = os.path.abspath(TEMP_DIR) + if not os.path.isdir(folder_path_temp): + os.mkdir(folder_path_temp) # Download latest assets - if (args.download_assets): + if args.download_assets: download_latest_assets() # Download latest release - if (args.download_release): + if args.download_release: download_release_assets(repo) - if (args.compare): + if args.compare: compare() - if (args.release): + if args.release: create_release(repo) + def get_release_download_path(): return os.path.join(os.path.abspath(TEMP_DIR), LATEST_RELASE_DIR) + def get_galag_download_path(): return os.path.abspath(os.path.join(os.path.abspath(TEMP_DIR), QALAG_OUTPUT_DIR)) + # Download latest Github release def download_release_assets(repo): - release = repo.get_latest_release() + try: + release = repo.get_latest_release() + except Exception: + print("No published releases found, skipping download") + return print("Downloading release: '%s'" % (release.title)) - download_release_path = get_release_download_path(); - if (os.path.isdir(download_release_path)): + download_release_path = get_release_download_path() + if os.path.isdir(download_release_path): shutil.rmtree(download_release_path) os.mkdir(download_release_path) @@ -76,9 +104,13 @@ def download_release_assets(repo): for asset in assets: print("\tAsset: %s [%s]" % (asset.name, asset.url)) - r = requests.get(asset.url, allow_redirects=True, headers={'Accept': 'application/octet-stream'}) + r = requests.get( + asset.url, + allow_redirects=True, + headers={"Accept": "application/octet-stream"}, + ) file_path = os.path.join(download_release_path, asset.name) - open(file_path, 'wb').write(r.content) + open(file_path, "wb").write(r.content) # Extract quest icons iconpack_quest_release_zip = os.path.join(download_release_path, ICONPACK_QUEST) @@ -86,46 +118,105 @@ def download_release_assets(repo): with zipfile.ZipFile(iconpack_quest_release_zip) as zip: zip.extractall(iconpack_quest_release_ext_path) + # Download latest assets def download_latest_assets(): - galag_exe_full_path = os.path.abspath(QALAG_EXE_PATH) galag_output_dir = get_galag_download_path() - if (os.path.isdir(galag_output_dir)): + if os.path.isdir(galag_output_dir): shutil.rmtree(galag_output_dir) os.mkdir(galag_output_dir) - # Launch exe, temporarily changing cwd to land results in proper place - cur_dir = os.path.abspath('.') - os.chdir(galag_output_dir) - launch_executable([], galag_exe_full_path) - os.chdir(cur_dir) + QuestAssetDL( + platform="quest", + locale="en_US", + action="assets", + output_dir_param=galag_output_dir, + ) # Rename file - os.rename(os.path.join(galag_output_dir, APPNAMES_GALAG_NAME), os.path.join(galag_output_dir, APPNAMES_QUEST)) + for f in os.listdir(galag_output_dir): + if f.startswith("appnames_quest") and f.endswith(".json"): + os.rename( + os.path.join(galag_output_dir, f), + os.path.join(galag_output_dir, APPNAMES_QUEST), + ) + break + else: + raise FileNotFoundError("No appnames_quest JSON found") + # Compare def compare(): - iconpack_quest_release_ext_path = os.path.join(get_release_download_path(), QUEST_DIR) - iconpack_quest_generated_ext_path = os.path.join(get_galag_download_path(), QUEST_DIR) - launch_executable(['-t', iconpack_quest_release_ext_path, iconpack_quest_generated_ext_path], bin_path=WINDIFF_EXE_PATH) + # Check if we have a release to compare against + release_path = get_release_download_path() + if not os.path.isdir(release_path) or not os.path.exists( + os.path.join(release_path, ICONPACK_QUEST) + ): + print("No previous release assets found, skipping comparison") + return + + # Proceed with comparison + iconpack_quest_release_ext_path = os.path.join(release_path, QUEST_DIR) + iconpack_quest_generated_ext_path = os.path.join( + get_galag_download_path(), QUEST_DIR + ) + launch_executable( + ["-t", iconpack_quest_release_ext_path, iconpack_quest_generated_ext_path], + bin_path=WINDIFF_EXE_PATH, + ) + + appnames_quest_release_path = os.path.join(release_path, APPNAMES_QUEST) + appnames_quest_generated_path = os.path.join( + get_galag_download_path(), APPNAMES_QUEST + ) + launch_executable( + [appnames_quest_release_path, appnames_quest_generated_path], + bin_path=WINDIFF_EXE_PATH, + ) - appnames_quest_release_path = os.path.join(get_release_download_path(), APPNAMES_QUEST) - appnames_quest_generated_path = os.path.join(get_galag_download_path(), APPNAMES_QUEST) - launch_executable([appnames_quest_release_path, appnames_quest_generated_path], bin_path=WINDIFF_EXE_PATH) # Create release def create_release(repo): - tag = 'v' + datetime.date.today().strftime('%m.%d.%Y') - name = tag + ': Update quest assets' - print(str.format('Creating release: tag: %s, name: %s' % (tag, name))) - release = repo.create_git_release(tag=tag, name=name, message='Updating quest assets', draft=True) + tag = "v" + datetime.date.today().strftime("%m.%d.%Y") + name = tag + ": Update quest assets" + print(f"Creating release: tag: {tag}, name: {name}") + release = repo.create_git_release( + tag=tag, name=name, message="Updating quest assets", draft=True + ) + + # Paths for "other" assets (from previous release) + other_appnames = os.path.join(get_release_download_path(), APPNAMES_OTHER) + other_iconpack = os.path.join(get_release_download_path(), ICONPACK_OTHER) + + # Upload other assets only if they exist (first release will skip) + if os.path.exists(other_appnames): + release.upload_asset(other_appnames) + print(f"Uploaded {APPNAMES_OTHER}") + else: + print(f"Warning: {APPNAMES_OTHER} not found, skipping upload") + + if os.path.exists(other_iconpack): + release.upload_asset(other_iconpack) + print(f"Uploaded {ICONPACK_OTHER}") + else: + print(f"Warning: {ICONPACK_OTHER} not found, skipping upload") + + # Quest assets are always uploaded from the freshly downloaded data + quest_appnames = os.path.join(get_galag_download_path(), APPNAMES_QUEST) + quest_iconpack = os.path.join(get_galag_download_path(), ICONPACK_QUEST) + + # Ensure these files exist (they should, from download_latest_assets) + if os.path.exists(quest_appnames): + release.upload_asset(quest_appnames) + else: + raise FileNotFoundError(f"Missing generated Quest appnames: {quest_appnames}") + + if os.path.exists(quest_iconpack): + release.upload_asset(quest_iconpack) + else: + raise FileNotFoundError(f"Missing generated Quest iconpack: {quest_iconpack}") - # Upload the assets, refreshing quest from generated path - release.upload_asset(os.path.join(get_release_download_path(), APPNAMES_OTHER)) - release.upload_asset(os.path.join(get_release_download_path(), ICONPACK_OTHER)) - release.upload_asset(os.path.join(get_galag_download_path(), APPNAMES_QUEST)) - release.upload_asset(os.path.join(get_galag_download_path(), ICONPACK_QUEST)) # Launch executable and return output def launch_executable(args, bin_path): @@ -133,8 +224,14 @@ def launch_executable(args, bin_path): output = subprocess.check_output([bin_path] + args, stderr=subprocess.STDOUT) return output.decode(sys.stdout.encoding) except subprocess.CalledProcessError as e: - raise Exception(str.format(str(e) + ". Output: '%s'" % (e.output.decode(sys.stdout.encoding).rstrip()))) from e + raise Exception( + str.format( + str(e) + + ". Output: '%s'" % (e.output.decode(sys.stdout.encoding).rstrip()) + ) + ) from e except Exception as e: raise type(e)(str.format(str(e) + " when calling '%s'" % (bin_path))) + main() diff --git a/bin/QALAG.exe b/bin/QALAG.exe deleted file mode 100644 index 5ae0647..0000000 Binary files a/bin/QALAG.exe and /dev/null differ diff --git a/packages.db b/packages.db new file mode 100644 index 0000000..b8d359e Binary files /dev/null and b/packages.db differ