spring cleaning and refactoring
This commit is contained in:
@@ -7,6 +7,8 @@ import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from utils import load_dotenv
|
||||
|
||||
# Default Obtainium settings for GitHub apps
|
||||
DEFAULT_ADDITIONAL_SETTINGS = {
|
||||
"includePrereleases": False,
|
||||
@@ -46,10 +48,10 @@ CATEGORIES = [
|
||||
]
|
||||
|
||||
VARIANT_OPTIONS = [
|
||||
"Both", # Include in both standard and dual-screen
|
||||
"Standard only", # Include in standard only
|
||||
"Dual-screen only", # Include in dual-screen only
|
||||
"README only", # Exclude from export, show in table only
|
||||
"Both",
|
||||
"Standard only",
|
||||
"Dual-screen only",
|
||||
"README only",
|
||||
]
|
||||
|
||||
SOURCE_DETECTION = {
|
||||
@@ -61,7 +63,6 @@ SOURCE_DETECTION = {
|
||||
|
||||
|
||||
def detect_source(url: str):
|
||||
"""Detect the source type from URL."""
|
||||
parsed = urlparse(url)
|
||||
host = parsed.netloc.lower()
|
||||
for domain, source in SOURCE_DETECTION.items():
|
||||
@@ -71,7 +72,6 @@ def detect_source(url: str):
|
||||
|
||||
|
||||
def extract_github_info(url: str) -> tuple[str, str] | None:
|
||||
"""Extract author and repo name from GitHub URL."""
|
||||
match = re.match(r"https?://github\.com/([^/]+)/([^/]+)", url)
|
||||
if match:
|
||||
return match.group(1), match.group(2)
|
||||
@@ -79,7 +79,6 @@ def extract_github_info(url: str) -> tuple[str, str] | None:
|
||||
|
||||
|
||||
def prompt(message: str, default: str = "") -> str:
|
||||
"""Prompt user for input with optional default."""
|
||||
if default:
|
||||
result = input(f"{message} [{default}]: ").strip()
|
||||
return result if result else default
|
||||
@@ -87,7 +86,6 @@ def prompt(message: str, default: str = "") -> str:
|
||||
|
||||
|
||||
def prompt_yes_no(message: str, default: bool = True) -> bool:
|
||||
"""Prompt user for yes/no."""
|
||||
default_str = "Y/n" if default else "y/N"
|
||||
result = input(f"{message} [{default_str}]: ").strip().lower()
|
||||
if not result:
|
||||
@@ -96,7 +94,6 @@ def prompt_yes_no(message: str, default: bool = True) -> bool:
|
||||
|
||||
|
||||
def select_menu(title: str, choices: list[str], default: int = 0) -> str:
|
||||
"""Interactive menu with arrow key navigation."""
|
||||
# Only use curses if we have a real terminal
|
||||
if not sys.stdin.isatty():
|
||||
return _select_menu_fallback(title, choices, default)
|
||||
@@ -111,10 +108,9 @@ def select_menu(title: str, choices: list[str], default: int = 0) -> str:
|
||||
|
||||
|
||||
def _select_menu_curses(title: str, choices: list[str], default: int = 0) -> str:
|
||||
"""Curses-based interactive menu."""
|
||||
|
||||
def menu(stdscr):
|
||||
curses.curs_set(0) # Hide cursor
|
||||
curses.curs_set(0)
|
||||
current = default
|
||||
|
||||
while True:
|
||||
@@ -140,7 +136,7 @@ def _select_menu_curses(title: str, choices: list[str], default: int = 0) -> str
|
||||
current += 1
|
||||
elif key in (curses.KEY_ENTER, 10, 13):
|
||||
return choices[current]
|
||||
elif key == 27: # ESC
|
||||
elif key == 27:
|
||||
return choices[default]
|
||||
|
||||
import curses
|
||||
@@ -149,7 +145,6 @@ def _select_menu_curses(title: str, choices: list[str], default: int = 0) -> str
|
||||
|
||||
|
||||
def _select_menu_fallback(title: str, choices: list[str], default: int = 0) -> str:
|
||||
"""Fallback menu using simple input."""
|
||||
print(f"\n{title}")
|
||||
for i, choice in enumerate(choices):
|
||||
marker = ">" if i == default else " "
|
||||
@@ -182,7 +177,6 @@ def generate_app_entry(
|
||||
app_name_override: str | None = None,
|
||||
url_override: str | None = None,
|
||||
) -> dict:
|
||||
"""Generate a complete app entry."""
|
||||
settings = DEFAULT_ADDITIONAL_SETTINGS.copy()
|
||||
if "Track Only" in categories:
|
||||
settings["trackOnly"] = True
|
||||
@@ -205,7 +199,6 @@ def generate_app_entry(
|
||||
"overrideSource": source,
|
||||
}
|
||||
|
||||
# Build meta based on variant selection
|
||||
meta = {}
|
||||
if variant == "Standard only":
|
||||
meta["includeInDualScreen"] = False
|
||||
@@ -213,7 +206,6 @@ def generate_app_entry(
|
||||
meta["includeInStandard"] = False
|
||||
elif variant == "README only":
|
||||
meta["excludeFromExport"] = True
|
||||
# "Both" = no meta needed (default behavior)
|
||||
|
||||
if app_name_override:
|
||||
meta["nameOverride"] = app_name_override
|
||||
@@ -227,7 +219,6 @@ def generate_app_entry(
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Interactive CLI to add a new app."""
|
||||
print("=" * 50)
|
||||
print(" Add New App to Obtainium Emulation Pack")
|
||||
print("=" * 50)
|
||||
@@ -349,7 +340,23 @@ def main() -> int:
|
||||
f.write("\n")
|
||||
|
||||
print(f"\nApp added to {apps_file}")
|
||||
print("\nNext steps:")
|
||||
|
||||
# Offer to live-test the new app config
|
||||
if prompt_yes_no("\nRun live test on this app?", True):
|
||||
load_dotenv()
|
||||
# Import here to avoid circular deps and keep startup fast
|
||||
from importlib import import_module
|
||||
|
||||
test_mod = import_module("test-apps")
|
||||
print()
|
||||
result = test_mod.test_app(app_entry)
|
||||
test_mod.print_result(result, verbose=True)
|
||||
if not result.passed:
|
||||
print("\nThe app config failed the live test.")
|
||||
print("The entry has been saved - you may want to fix the config and re-test.")
|
||||
print()
|
||||
|
||||
print("Next steps:")
|
||||
print(" 1. Run 'make release' to regenerate all files")
|
||||
print(" 2. Review the diff before committing")
|
||||
|
||||
|
||||
@@ -1,13 +1,168 @@
|
||||
"""Shared constants for Obtainium Emulation Pack scripts."""
|
||||
|
||||
# File paths
|
||||
SRC_FILE = "src/applications.json"
|
||||
PAGES_DIR = "pages"
|
||||
TABLE_FILE = "pages/table.md"
|
||||
|
||||
# Obtainium settings
|
||||
REDIRECT_URL = "http://apps.obtainium.imranr.dev/redirect.html"
|
||||
OBTAINIUM_SCHEME = "obtainium://app/"
|
||||
|
||||
# Supported variants
|
||||
VARIANTS = ("standard", "dual-screen")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Obtainium source types and settings schema
|
||||
# Derived from Obtainium source code: lib/app_sources/*.dart
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Valid overrideSource values (runtime type names from Obtainium)
|
||||
VALID_SOURCES = {
|
||||
"GitHub",
|
||||
"GitLab",
|
||||
"Codeberg",
|
||||
"FDroid",
|
||||
"FDroidRepo",
|
||||
"IzzyOnDroid",
|
||||
"SourceHut",
|
||||
"APKPure",
|
||||
"Aptoide",
|
||||
"Uptodown",
|
||||
"HuaweiAppGallery",
|
||||
"Tencent",
|
||||
"VivoAppStore",
|
||||
"RuStore",
|
||||
"Farsroid",
|
||||
"CoolApk",
|
||||
"RockMods",
|
||||
"LiteAPKs",
|
||||
"Jenkins",
|
||||
"APKMirror",
|
||||
"TelegramApp",
|
||||
"NeutronCode",
|
||||
"SourceForge",
|
||||
"DirectAPKLink",
|
||||
"HTML",
|
||||
}
|
||||
|
||||
# URL host-to-source mapping for auto-detection
|
||||
SOURCE_HOST_MAP = {
|
||||
"github.com": "GitHub",
|
||||
"gitlab.com": "GitLab",
|
||||
"codeberg.org": "Codeberg",
|
||||
"f-droid.org": "FDroid",
|
||||
"android.izzysoft.de": "IzzyOnDroid",
|
||||
"git.sr.ht": "SourceHut",
|
||||
"apkpure.net": "APKPure",
|
||||
"aptoide.com": "Aptoide",
|
||||
"uptodown.com": "Uptodown",
|
||||
"appgallery.huawei.com": "HuaweiAppGallery",
|
||||
"sj.qq.com": "Tencent",
|
||||
"h5.appstore.vivo.com.cn": "VivoAppStore",
|
||||
"rustore.ru": "RuStore",
|
||||
"farsroid.com": "Farsroid",
|
||||
"coolapk.com": "CoolApk",
|
||||
"rockmods.net": "RockMods",
|
||||
"liteapks.com": "LiteAPKs",
|
||||
"apkmirror.com": "APKMirror",
|
||||
"telegram.org": "TelegramApp",
|
||||
"neutroncode.com": "NeutronCode",
|
||||
"sourceforge.net": "SourceForge",
|
||||
}
|
||||
|
||||
# Common additionalSettings keys valid for all source types
|
||||
COMMON_SETTINGS_KEYS = {
|
||||
"trackOnly",
|
||||
"versionExtractionRegEx",
|
||||
"matchGroupToUse",
|
||||
"versionDetection",
|
||||
"useVersionCodeAsOSVersion",
|
||||
"apkFilterRegEx",
|
||||
"invertAPKFilter",
|
||||
"autoApkFilterByArch",
|
||||
"appName",
|
||||
"appAuthor",
|
||||
"shizukuPretendToBeGooglePlay",
|
||||
"allowInsecure",
|
||||
"exemptFromBackgroundUpdates",
|
||||
"skipUpdateNotifications",
|
||||
"about",
|
||||
"refreshBeforeDownload",
|
||||
"releaseDateAsVersion",
|
||||
"includeZips",
|
||||
"zippedApkFilterRegEx",
|
||||
}
|
||||
|
||||
# Source-specific additionalSettings keys
|
||||
SOURCE_SPECIFIC_KEYS: dict[str, set[str]] = {
|
||||
"GitHub": {
|
||||
"includePrereleases",
|
||||
"fallbackToOlderReleases",
|
||||
"filterReleaseTitlesByRegEx",
|
||||
"filterReleaseNotesByRegEx",
|
||||
"verifyLatestTag",
|
||||
"sortMethodChoice",
|
||||
"useLatestAssetDateAsReleaseDate",
|
||||
"releaseTitleAsVersion",
|
||||
"dontSortReleasesList",
|
||||
"github-creds",
|
||||
},
|
||||
"GitLab": {
|
||||
"fallbackToOlderReleases",
|
||||
},
|
||||
"Codeberg": {
|
||||
# Inherits GitHub's settings
|
||||
"includePrereleases",
|
||||
"fallbackToOlderReleases",
|
||||
"filterReleaseTitlesByRegEx",
|
||||
"filterReleaseNotesByRegEx",
|
||||
"verifyLatestTag",
|
||||
"sortMethodChoice",
|
||||
"useLatestAssetDateAsReleaseDate",
|
||||
"releaseTitleAsVersion",
|
||||
"dontSortReleasesList",
|
||||
},
|
||||
"FDroid": {
|
||||
"filterVersionsByRegEx",
|
||||
"trySelectingSuggestedVersionCode",
|
||||
"autoSelectHighestVersionCode",
|
||||
},
|
||||
"FDroidRepo": {
|
||||
"appIdOrName",
|
||||
"pickHighestVersionCode",
|
||||
"trySelectingSuggestedVersionCode",
|
||||
},
|
||||
"HTML": {
|
||||
"intermediateLink",
|
||||
"customLinkFilterRegex",
|
||||
"filterByLinkText",
|
||||
"matchLinksOutsideATags",
|
||||
"skipSort",
|
||||
"reverseSort",
|
||||
"sortByLastLinkSegment",
|
||||
"versionExtractWholePage",
|
||||
"requestHeader",
|
||||
"defaultPseudoVersioningMethod",
|
||||
"supportFixedAPKURL",
|
||||
"sortByFileNamesNotLinks",
|
||||
},
|
||||
"DirectAPKLink": {
|
||||
"intermediateLink",
|
||||
"customLinkFilterRegex",
|
||||
"filterByLinkText",
|
||||
"skipSort",
|
||||
"reverseSort",
|
||||
"sortByLastLinkSegment",
|
||||
"requestHeader",
|
||||
"defaultPseudoVersioningMethod",
|
||||
},
|
||||
}
|
||||
|
||||
# Settings keys that contain regex patterns (should be validated)
|
||||
REGEX_SETTINGS_KEYS = {
|
||||
"apkFilterRegEx",
|
||||
"versionExtractionRegEx",
|
||||
"filterReleaseTitlesByRegEx",
|
||||
"filterReleaseNotesByRegEx",
|
||||
"customLinkFilterRegex",
|
||||
"filterVersionsByRegEx",
|
||||
"zippedApkFilterRegEx",
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Generate click-to-install Obtainium URLs for all apps."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.parse
|
||||
from typing import Any
|
||||
|
||||
from constants import OBTAINIUM_SCHEME, REDIRECT_URL
|
||||
|
||||
|
||||
def generate_obtainium_url(app: dict[str, Any]) -> str:
|
||||
"""Generate an Obtainium deep-link URL for an app."""
|
||||
payload = {
|
||||
"id": app["id"],
|
||||
"url": app["url"],
|
||||
"author": app["author"],
|
||||
"name": app["name"],
|
||||
"otherAssetUrls": app.get("otherAssetUrls"),
|
||||
"apkUrls": app.get("apkUrls"),
|
||||
"preferredApkIndex": app.get("preferredApkIndex"),
|
||||
"additionalSettings": app.get("additionalSettings"),
|
||||
"categories": app.get("categories"),
|
||||
"overrideSource": app.get("overrideSource"),
|
||||
"allowIdChange": app.get("allowIdChange"),
|
||||
}
|
||||
encoded = urllib.parse.quote(json.dumps(payload, separators=(",", ":")), safe="")
|
||||
return f"{REDIRECT_URL}?r={OBTAINIUM_SCHEME}{encoded}"
|
||||
|
||||
|
||||
def main(json_file: str) -> None:
|
||||
"""Print Obtainium URLs for all apps in the JSON file."""
|
||||
with open(json_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
if "apps" not in data:
|
||||
print("Invalid JSON format: Missing 'apps' key.")
|
||||
sys.exit(1)
|
||||
|
||||
for app in data["apps"]:
|
||||
obtainium_url = generate_obtainium_url(app)
|
||||
print(f"{app['name']}: {obtainium_url}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: python generate_obtainium_urls.py <json_file>")
|
||||
sys.exit(1)
|
||||
|
||||
main(sys.argv[1])
|
||||
@@ -5,12 +5,6 @@ from pathlib import Path
|
||||
|
||||
|
||||
def stitch_markdown_files(markdown_files: list[str], output_file: str = "README.md") -> None:
|
||||
"""Concatenate markdown files with double newlines between sections.
|
||||
|
||||
Args:
|
||||
markdown_files: List of paths to markdown files to combine
|
||||
output_file: Path to write the combined output
|
||||
"""
|
||||
combined_content = []
|
||||
|
||||
for file in markdown_files:
|
||||
|
||||
@@ -2,36 +2,13 @@
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.parse
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
from constants import OBTAINIUM_SCHEME, REDIRECT_URL
|
||||
from utils import get_application_url, get_display_name, should_include_app
|
||||
|
||||
|
||||
def make_obtainium_link(app: dict[str, Any]) -> str:
|
||||
"""Generate an Obtainium deep-link URL for an app."""
|
||||
payload = {
|
||||
"id": app["id"],
|
||||
"url": app["url"],
|
||||
"author": app["author"],
|
||||
"name": app["name"],
|
||||
"otherAssetUrls": app.get("otherAssetUrls"),
|
||||
"apkUrls": app.get("apkUrls"),
|
||||
"preferredApkIndex": app.get("preferredApkIndex"),
|
||||
"additionalSettings": app.get("additionalSettings"),
|
||||
"categories": app.get("categories"),
|
||||
"overrideSource": app.get("overrideSource"),
|
||||
"allowIdChange": app.get("allowIdChange"),
|
||||
}
|
||||
encoded = urllib.parse.quote(json.dumps(payload, separators=(",", ":")), safe="")
|
||||
return f"{REDIRECT_URL}?r={OBTAINIUM_SCHEME}{encoded}"
|
||||
from utils import get_application_url, get_display_name, make_obtainium_link, should_include_app
|
||||
|
||||
|
||||
def generate_category_tables(apps: list[dict[str, Any]]) -> str:
|
||||
"""Generate markdown tables grouped by category."""
|
||||
# Categorize apps
|
||||
categorized: defaultdict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for app in apps:
|
||||
categories = app.get("categories", [])
|
||||
@@ -78,7 +55,6 @@ def generate_category_tables(apps: list[dict[str, Any]]) -> str:
|
||||
|
||||
|
||||
def main(input_file: str, output_file: str) -> None:
|
||||
"""Generate category-based markdown table from applications.json."""
|
||||
with open(input_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
aPS3e: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22aenu.aps3e%22%2C%22url%22%3A%22https%3A//github.com/aenu1/aps3e%22%2C%22author%22%3A%22aenu1%22%2C%22name%22%3A%22aPS3e%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%2C%5C%22includeZips%5C%22%3Afalse%2C%5C%22zippedApkFilterRegEx%5C%22%3A%5C%22%5C%22%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22GitHub%22%2C%22allowIdChange%22%3Afalse%7D
|
||||
|
||||
Azahar: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22io.github.lime3ds.android%22%2C%22url%22%3A%22https%3A//github.com/azahar-emu/azahar%22%2C%22author%22%3A%22azahar-emu%22%2C%22name%22%3A%22Azahar%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22GitHub%22%7D
|
||||
|
||||
Cemu: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22info.cemu.cemu%22%2C%22url%22%3A%22https%3A//github.com/SSimco/Cemu%22%2C%22author%22%3A%22SSimco%22%2C%22name%22%3A%22Cemu%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22dontSortReleasesList%5C%22%3Afalse%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22GitHub%22%2C%22meta%22%3A%7B%22includeInDualScreen%22%3Afalse%7D%7D
|
||||
|
||||
Citra: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22org.citra.emu%22%2C%22url%22%3A%22https%3A//github.com/weihuoya/citra%22%2C%22author%22%3A%22weihuoya%22%2C%22name%22%3A%22Citra%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22Citra%20MMJ%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%2C%5C%22includeZips%5C%22%3Afalse%2C%5C%22zippedApkFilterRegEx%5C%22%3A%5C%22%5C%22%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22GitHub%22%2C%22allowIdChange%22%3Afalse%2C%22meta%22%3A%7B%22nameOverride%22%3A%22Citra%20MMJ%22%7D%7D
|
||||
|
||||
Citron: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22org.citron.citron_emu%22%2C%22url%22%3A%22https%3A//git.citron-emu.org/Citron/Emulator%22%2C%22author%22%3A%22citron%22%2C%22name%22%3A%22Citron%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22app-mainline-release%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22Codeberg%22%2C%22allowIdChange%22%3Afalse%7D
|
||||
|
||||
Citron Nightly: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22org.citron.citron_emu%22%2C%22url%22%3A%22https%3A//github.com/Zephyron-Dev/Citron-CI%22%2C%22author%22%3A%22Zephyron-Dev%22%2C%22name%22%3A%22Citron%20Nightly%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22app-mainline-release%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22GitHub%22%2C%22allowIdChange%22%3Afalse%2C%22meta%22%3A%7B%22excludeFromExport%22%3Atrue%7D%7D
|
||||
|
||||
Dolphin Emulator (Dev build): http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22org.dolphinemu.dolphinemu%22%2C%22url%22%3A%22https%3A//dolphin-emu.org/download/%3Fref%3Dbtn%22%2C%22author%22%3A%22dolphin-emu.org%22%2C%22name%22%3A%22Dolphin%20Emulator%20%28Dev%20build%29%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22https%3A%5C%5C%5C%5C/%5C%5C%5C%5C/dl.dolphin-emu.org%5C%5C%5C%5C/builds.%2Bdolphin-master-.%2B.apk%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Atrue%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla/5.0%20%28Linux%3B%20Android%2010%3B%20K%29%20AppleWebKit/537.36%20%28KHTML%2C%20like%20Gecko%29%20Chrome/114.0.0.0%20Mobile%20Safari/537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22dolphin-master-%28.%2B%29.apk%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%241%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22Dolphin%20Emulator%20%28Dev%20build%29%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22HTML%22%2C%22meta%22%3A%7B%22urlOverride%22%3A%22https%3A//dolphin-emu.org%22%2C%22excludeFromExport%22%3Atrue%7D%7D
|
||||
|
||||
Dolphin-MMJR2-VBI: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22org.dolphinemu.mmjr%22%2C%22url%22%3A%22https%3A//github.com/Medard22/Dolphin-MMJR2-VBI%22%2C%22author%22%3A%22Medard22%22%2C%22name%22%3A%22Dolphin-MMJR2-VBI%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22GitHub%22%2C%22meta%22%3A%7B%22excludeFromExport%22%3Atrue%7D%7D
|
||||
|
||||
Dolphin Emulator: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22org.dolphinemu.dolphinemu%22%2C%22url%22%3A%22https%3A//dolphin-emu.org/download/%3Fref%3Dbtn%22%2C%22author%22%3A%22dolphin-emu.org%22%2C%22name%22%3A%22Dolphin%20Emulator%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22https%3A%5C%5C%5C%5C/%5C%5C%5C%5C/dl.dolphin-emu.org%5C%5C%5C%5C/releases.%2Bdolphin.%2B.apk%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Atrue%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla/5.0%20%28Linux%3B%20Android%2010%3B%20K%29%20AppleWebKit/537.36%20%28KHTML%2C%20like%20Gecko%29%20Chrome/114.0.0.0%20Mobile%20Safari/537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22/releases/.%2B/dolphin-%28.%2B%29.apk%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%241%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22Dolphin%20Emulator%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22HTML%22%2C%22meta%22%3A%7B%22urlOverride%22%3A%22https%3A//dolphin-emu.org%22%7D%7D
|
||||
|
||||
DuckStation: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22com.github.stenzek.duckstation%22%2C%22url%22%3A%22https%3A//duckstation-mirror.rmacias.workers.dev%22%2C%22author%22%3A%22duckstation-mirror.rmacias.workers.dev%22%2C%22name%22%3A%22DuckStation%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22matchLinksOutsideATags%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Atrue%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla/5.0%20%28Linux%3B%20Android%2010%3B%20K%29%20AppleWebKit/537.36%20%28KHTML%2C%20like%20Gecko%29%20Chrome/114.0.0.0%20Mobile%20Safari/537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22Version%3A.%2A%3F%28%5B%5C%5C%5C%5Cd.-%5D%2B%29%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%221%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22Duckstation%20%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22stenzek%20%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22HTML%22%2C%22allowIdChange%22%3Afalse%7D
|
||||
|
||||
Releases: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22dev.eden.eden_emulator%22%2C%22url%22%3A%22https%3A//github.com/eden-emulator/Releases%22%2C%22author%22%3A%22eden-emulator%22%2C%22name%22%3A%22Releases%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22Eden%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22GitHub%22%2C%22meta%22%3A%7B%22nameOverride%22%3A%22Eden%22%2C%22urlOverride%22%3A%22https%3A//eden-emulator.github.io%22%7D%7D
|
||||
|
||||
Eden Nightly: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22dev.eden.eden_nightly%22%2C%22url%22%3A%22https%3A//github.com/pflyly/eden-nightly%22%2C%22author%22%3A%22pflyly%22%2C%22name%22%3A%22Eden%20Nightly%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%2C%5C%22includeZips%5C%22%3Afalse%2C%5C%22zippedApkFilterRegEx%5C%22%3A%5C%22%5C%22%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22GitHub%22%2C%22allowIdChange%22%3Afalse%2C%22meta%22%3A%7B%22excludeFromExport%22%3Atrue%7D%7D
|
||||
|
||||
Flycast: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22com.flycast.emulator%22%2C%22url%22%3A%22https%3A//github.com/flyinghead/flycast%22%2C%22author%22%3A%22flyinghead%22%2C%22name%22%3A%22Flycast%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22GitHub%22%7D
|
||||
|
||||
MelonDS: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22me.magnum.melonds%22%2C%22url%22%3A%22https%3A//github.com/rafaelvcaetano/melonDS-android%22%2C%22author%22%3A%22rafaelvcaetano%22%2C%22name%22%3A%22MelonDS%22%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22MelonDS%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%2C%5C%22github-creds%5C%22%3A%5C%22%5C%22%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22GitHub%22%2C%22meta%22%3A%7B%22includeInStandard%22%3Atrue%2C%22includeInDualScreen%22%3Afalse%7D%7D
|
||||
|
||||
MelonDS Nightly: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22me.magnum.melonds.nightly%22%2C%22url%22%3A%22https%3A//github.com/rafaelvcaetano/melonDS-android%22%2C%22author%22%3A%22rafaelvcaetano%22%2C%22name%22%3A%22MelonDS%20Nightly%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Afalse%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Atrue%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22.%2Anightly.%2A%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22MelonDS%20%28nightly%29%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22GitHub%22%2C%22meta%22%3A%7B%22excludeFromExport%22%3Atrue%7D%7D
|
||||
|
||||
Kenji-NX: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22org.kenjinx.android%22%2C%22url%22%3A%22https%3A//GitHub.com/Kenji-NX/Android-Releases%22%2C%22author%22%3A%22Kenji-NX%22%2C%22name%22%3A%22Kenji-NX%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22dontSortReleasesList%5C%22%3Afalse%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22GitHub%22%2C%22meta%22%3A%7B%22urlOverride%22%3A%22https%3A//git.ryujinx.app/kenji-nx/ryujinx/-/tree/libryujinx_bionic%22%2C%22excludeFromExport%22%3Atrue%7D%7D
|
||||
|
||||
PPSSPP: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22org.ppsspp.ppsspp%22%2C%22url%22%3A%22https%3A//www.ppsspp.org/download%22%2C%22author%22%3A%22www.ppsspp.org%22%2C%22name%22%3A%22PPSSPP%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%5C%5C%5C/%5B0-9%5D%2B%28%3F%3A_%5B0-9%5D%2B%29%7B1%2C2%7D%5C%5C%5C%5C/ppsspp%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla/5.0%20%28Linux%3B%20Android%2010%3B%20K%29%20AppleWebKit/537.36%20%28KHTML%2C%20like%20Gecko%29%20Chrome/114.0.0.0%20Mobile%20Safari/537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22APKLinkHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%5C%5C%5C/%28%3F%3A%28%3F%3A%28%5B0-9%5D%2B%29_%28%5B0-9%5D%2B%29%5C%5C%5C%5C/ppsspp%5C%5C%5C%5C.%29%7C%28%3F%3A%28%5B0-9%5D%2B%29_%28%5B0-9%5D%2B%29_%28%5B0-9%5D%2B%29%5C%5C%5C%5C/ppsspp%28%5C%5C%5C%5C.%29%29%29apk%24%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%241%243.%242%244%246%245%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22PPSSPP%5C%22%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22PPSSPP%20is%20an%20open%20source%2C%20fast%20and%20portable%20PSP%20emulator%5C%22%2C%5C%22supportFixedAPKURL%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22HTML%22%7D
|
||||
|
||||
RetroArch (AArch64): http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22487343354%22%2C%22url%22%3A%22https%3A//buildbot.libretro.com/stable%22%2C%22author%22%3A%22buildbot.libretro.com%22%2C%22name%22%3A%22RetroArch%20%28AArch64%29%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%7B%5C%22customLinkFilterRegex%5C%22%3A%5C%22/stable/%5C%5C%5C%5Cd%2B.%5C%5C%5C%5Cd%2B.%5C%5C%5C%5Cd%2B/%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%7D%2C%7B%5C%22customLinkFilterRegex%5C%22%3A%5C%22/stable/%5C%5C%5C%5Cd%2B.%5C%5C%5C%5Cd%2B.%5C%5C%5C%5Cd%2B/android/%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%7D%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22/stable/%5C%5C%5C%5Cd%2B.%5C%5C%5C%5Cd%2B.%5C%5C%5C%5Cd%2B/android/RetroArch_aarch64.apk%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Atrue%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla/5.0%20%28Linux%3B%20Android%2010%3B%20K%29%20AppleWebKit/537.36%20%28KHTML%2C%20like%20Gecko%29%20Chrome/114.0.0.0%20Mobile%20Safari/537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22APKLinkHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%5C%5C%5Cd%2B.%5C%5C%5C%5Cd%2B.%5C%5C%5C%5Cd%2B%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22RetroArch%20AArch64%5C%22%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Atrue%2C%5C%22skipUpdateNotifications%5C%22%3Atrue%2C%5C%22about%5C%22%3A%5C%22Released%20less%20frequently.%20Better%20stability.%20%5C%22%2C%5C%22supportFixedAPKURL%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22HTML%22%2C%22meta%22%3A%7B%22nameOverride%22%3A%22RetroArch%20%28AArch64%29%22%2C%22urlOverride%22%3A%22https%3A//www.retroarch.com%22%7D%7D
|
||||
|
||||
RetroArch (AArch64): http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22487343355%22%2C%22url%22%3A%22https%3A//buildbot.libretro.com/nightly/android%22%2C%22author%22%3A%22buildbot.libretro.com%22%2C%22name%22%3A%22RetroArch%20%28AArch64%29%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%5C%5C%5Cd%7B4%7D-%5C%5C%5C%5Cd%7B2%7D-%5C%5C%5C%5Cd%7B2%7D-RetroArch_aarch64.apk%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Atrue%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla/5.0%20%28Linux%3B%20Android%2010%3B%20K%29%20AppleWebKit/537.36%20%28KHTML%2C%20like%20Gecko%29%20Chrome/114.0.0.0%20Mobile%20Safari/537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22APKLinkHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%5C%5C%5Cd%7B4%7D-%5C%5C%5C%5Cd%7B2%7D-%5C%5C%5C%5Cd%7B2%7D%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%5C%5C%5Cd%7B4%7D-%5C%5C%5C%5Cd%7B2%7D-%5C%5C%5C%5Cd%7B2%7D-RetroArch_aarch64.apk%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22RetroArch%20AArch64%20%28Nightly%29%5C%22%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22Nightly%20releases.%20cutting%20edge%20features%20but%20may%20contain%20bugs%5C%22%2C%5C%22supportFixedAPKURL%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22HTML%22%2C%22meta%22%3A%7B%22nameOverride%22%3A%22RetroArch%20Nightly%20%28AArch64%29%22%2C%22excludeFromExport%22%3Atrue%2C%22urlOverride%22%3A%22https%3A//www.retroarch.com%22%7D%7D
|
||||
|
||||
RPCSX: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22net.rpcsx%22%2C%22url%22%3A%22https%3A//github.com/RPCSX/rpcsx-ui-android%22%2C%22author%22%3A%22RPCSX%22%2C%22name%22%3A%22RPCSX%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22GitHub%22%7D
|
||||
|
||||
ScummVM: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22org.scummvm.scummvm%22%2C%22url%22%3A%22https%3A//downloads.scummvm.org/frs/scummvm/%22%2C%22author%22%3A%22ScummVM%22%2C%22name%22%3A%22ScummVM%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%7B%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%5C%5C%5Cd%2B%5C%5C%5C%5C.%5C%5C%5C%5Cd%2B%5C%5C%5C%5C.%5C%5C%5C%5Cd%2B%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%7D%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla/5.0%20%28Linux%3B%20Android%2010%3B%20K%29%20AppleWebKit/537.36%20%28KHTML%2C%20like%20Gecko%29%20Chrome/114.0.0.0%20Mobile%20Safari/537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22APKLinkHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%5C%5C%5Cd%2B%5C%5C%5C%5C.%5C%5C%5C%5Cd%2B%5C%5C%5C%5C.%5C%5C%5C%5Cd%2B%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22.%2Aandroid-arm64-v8a.apk%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22A%20program%20which%20allows%20you%20to%20run%20certain%20classic%20graphical%20adventure%20and%20role-playing%20games%2C%20provided%20you%20already%20have%20their%20data%20files.%5C%22%2C%5C%22supportFixedAPKURL%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22HTML%22%2C%22meta%22%3A%7B%22urlOverride%22%3A%22https%3A//www.scummvm.org%22%7D%7D
|
||||
|
||||
GameHub Lite: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22gamehub.lite%22%2C%22url%22%3A%22https%3A//github.com/Producdevity/gamehub-lite%22%2C%22author%22%3A%22Producdevity%22%2C%22name%22%3A%22GameHub%20Lite%22%2C%22preferredApkIndex%22%3A4%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Atrue%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%2C%5C%22includeZips%5C%22%3Afalse%2C%5C%22zippedApkFilterRegEx%5C%22%3A%5C%22%5C%22%7D%22%2C%22categories%22%3A%5B%22PC%20Emulation%22%5D%2C%22overrideSource%22%3A%22GitHub%22%2C%22allowIdChange%22%3Afalse%7D
|
||||
|
||||
GameNative: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22app.gamenative%22%2C%22url%22%3A%22https%3A//github.com/utkarshdalal/GameNative%22%2C%22author%22%3A%22utkarshdalal%22%2C%22name%22%3A%22GameNative%22%2C%22preferredApkIndex%22%3A3%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22PC%20Emulation%22%5D%2C%22overrideSource%22%3A%22GitHub%22%7D
|
||||
|
||||
Winlator: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22com.winlator%22%2C%22url%22%3A%22https%3A//github.com/brunodev85/winlator%22%2C%22author%22%3A%22brunodev85%22%2C%22name%22%3A%22Winlator%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22dontSortReleasesList%5C%22%3Afalse%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22PC%20Emulation%22%5D%7D
|
||||
|
||||
winlator: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22com.winlator.cmod%22%2C%22url%22%3A%22https%3A//github.com/coffincolors/winlator%22%2C%22author%22%3A%22coffincolors%22%2C%22name%22%3A%22winlator%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22Winlator%20Cmod%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22PC%20Emulation%22%5D%2C%22overrideSource%22%3A%22GitHub%22%2C%22allowIdChange%22%3Atrue%2C%22meta%22%3A%7B%22nameOverride%22%3A%22Winlator%20CMod%22%7D%7D
|
||||
|
||||
Winlator-Ludashi: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22com.miHoYo.GenshinImpact%22%2C%22url%22%3A%22https%3A//github.com/StevenMXZ/Winlator-Ludashi%22%2C%22author%22%3A%22StevenMXZ%22%2C%22name%22%3A%22Winlator-Ludashi%22%2C%22preferredApkIndex%22%3A2%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22PC%20Emulation%22%5D%2C%22overrideSource%22%3A%22GitHub%22%2C%22allowIdChange%22%3Afalse%7D
|
||||
|
||||
Vita3K: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22org.vita3k.emulator%22%2C%22url%22%3A%22https%3A//github.com/Vita3K/Vita3K-Android%22%2C%22author%22%3A%22Vita3K%22%2C%22name%22%3A%22Vita3K%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22dontSortReleasesList%5C%22%3Afalse%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%7D
|
||||
|
||||
Vita3K ZX: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22org.vita3k.emulator.ikhoeyZX%22%2C%22url%22%3A%22https%3A//github.com/ikhoeyZX/Vita3K-Android%22%2C%22author%22%3A%22ikhoeyZX%22%2C%22name%22%3A%22Vita3K%20ZX%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22GitHub%22%7D
|
||||
|
||||
NetherSX2 Classic: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22xyz.aethersx2.android%22%2C%22url%22%3A%22https%3A//github.com/Trixarian/NetherSX2-classic%22%2C%22author%22%3A%22Trixarian%22%2C%22name%22%3A%22NetherSX2%20Classic%22%2C%22preferredApkIndex%22%3A1%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22NetherSX2-Classic%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22GitHub%22%2C%22meta%22%3A%7B%22nameOverride%22%3A%22NetherSX2-Classic%22%7D%7D
|
||||
|
||||
NetherSX2: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22xyz.aethersx2.android%22%2C%22url%22%3A%22https%3A//github.com/Trixarian/NetherSX2-patch%22%2C%22author%22%3A%22Trixarian%22%2C%22name%22%3A%22NetherSX2%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22NetherSX2-Patch%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22GitHub%22%2C%22meta%22%3A%7B%22nameOverride%22%3A%22NetherSX2-Patch%22%2C%22excludeFromExport%22%3Atrue%7D%7D
|
||||
|
||||
Artemis: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22com.limelight.noir%22%2C%22url%22%3A%22https%3A//github.com/ClassicOldSong/moonlight-android%22%2C%22author%22%3A%22ClassicOldSong%22%2C%22name%22%3A%22Artemis%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Streaming%22%5D%2C%22overrideSource%22%3A%22GitHub%22%7D
|
||||
|
||||
Moonlight: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22com.limelight%22%2C%22url%22%3A%22https%3A//github.com/moonlight-stream/moonlight-android%22%2C%22author%22%3A%22moonlight-stream%22%2C%22name%22%3A%22Moonlight%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Streaming%22%5D%2C%22overrideSource%22%3A%22GitHub%22%7D
|
||||
|
||||
Syncthing-Fork: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22com.github.catfriend1.syncthingfork%22%2C%22url%22%3A%22https%3A//github.com/researchxxl/syncthing-android%22%2C%22author%22%3A%22researchxxl%22%2C%22name%22%3A%22Syncthing-Fork%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Atrue%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%2C%5C%22includeZips%5C%22%3Afalse%2C%5C%22zippedApkFilterRegEx%5C%22%3A%5C%22%5C%22%7D%22%2C%22categories%22%3A%5B%22Utilities%22%5D%2C%22overrideSource%22%3A%22GitHub%22%2C%22allowIdChange%22%3Afalse%7D
|
||||
|
||||
ES-DE Android Apps: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22com.quantumsoul.esde_android%22%2C%22url%22%3A%22https%3A//github.com/BinaryQuantumSoul/esde_android_apps%22%2C%22author%22%3A%22BinaryQuantumSoul%22%2C%22name%22%3A%22ES-DE%20Android%20Apps%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Utilities%22%5D%2C%22overrideSource%22%3A%22GitHub%22%7D
|
||||
|
||||
AdrenoToolsDrivers: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22476086958%22%2C%22url%22%3A%22https%3A//github.com/K11MCH1/AdrenoToolsDrivers%22%2C%22author%22%3A%22K11MCH1%22%2C%22name%22%3A%22AdrenoToolsDrivers%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22Turnip%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Atrue%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22appName%5C%22%3A%5C%22Turnip%20Drivers%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%2C%5C%22github-creds%5C%22%3A%5C%22%5C%22%7D%22%2C%22categories%22%3A%5B%22Track%20Only%22%5D%2C%22overrideSource%22%3A%22GitHub%22%7D
|
||||
|
||||
Obtainium Emulation Pack: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22904332840%22%2C%22url%22%3A%22https%3A//github.com/RJNY/Obtainium-Emulation-Pack%22%2C%22author%22%3A%22RJNY%22%2C%22name%22%3A%22Obtainium%20Emulation%20Pack%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22dontSortReleasesList%5C%22%3Afalse%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Atrue%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22Obtainium%20Emulation%20Pack%5C%22%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%7D%22%2C%22categories%22%3A%5B%22Track%20Only%22%5D%7D
|
||||
|
||||
Daijishō: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22com.magneticchen.daijishou%22%2C%22url%22%3A%22https%3A//github.com/TapiocaFox/Daijishou%22%2C%22author%22%3A%22TapiocaFox%22%2C%22name%22%3A%22Daijish%5Cu014d%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Frontend%22%5D%2C%22overrideSource%22%3A%22GitHub%22%7D
|
||||
|
||||
Pegasus: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22org.pegasus_frontend.android%22%2C%22url%22%3A%22https%3A//github.com/mmatyas/pegasus-frontend%22%2C%22author%22%3A%22mmatyas%22%2C%22name%22%3A%22Pegasus%22%2C%22preferredApkIndex%22%3A1%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Frontend%22%5D%2C%22overrideSource%22%3A%22GitHub%22%7D
|
||||
|
||||
OdinTools: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22de.langerhans.odintools%22%2C%22url%22%3A%22https%3A//github.com/langerhans/OdinTools%22%2C%22author%22%3A%22langerhans%22%2C%22name%22%3A%22OdinTools%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Utilities%22%5D%2C%22overrideSource%22%3A%22GitHub%22%7D
|
||||
|
||||
MelonDS: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22me.magnum.melonds%22%2C%22url%22%3A%22https%3A//github.com/SapphireRhodonite/melonDS-android%22%2C%22author%22%3A%22SapphireRhodonite%22%2C%22name%22%3A%22MelonDS%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22MelonDS%20%28DS%29%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22categories%22%3A%5B%22Dual%20Screen%22%5D%2C%22overrideSource%22%3A%22GitHub%22%2C%22allowIdChange%22%3Afalse%2C%22meta%22%3A%7B%22includeInStandard%22%3Afalse%2C%22includeInDualScreen%22%3Atrue%7D%7D
|
||||
|
||||
Cemu: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22info.cemu.cemu%22%2C%22url%22%3A%22https%3A//github.com/SapphireRhodonite/Cemu%22%2C%22author%22%3A%22SapphireRhodonite%22%2C%22name%22%3A%22Cemu%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22Cemu%20%28DS%29%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%2C%5C%22github-creds%5C%22%3A%5C%22%5C%22%7D%22%2C%22categories%22%3A%5B%22Dual%20Screen%22%5D%2C%22overrideSource%22%3A%22GitHub%22%2C%22allowIdChange%22%3Afalse%2C%22meta%22%3A%7B%22includeInStandard%22%3Afalse%2C%22includeInDualScreen%22%3Atrue%7D%7D
|
||||
|
||||
ARMSX2: http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/%7B%22id%22%3A%22come.nanodata.armsx2%22%2C%22url%22%3A%22https%3A//github.com/ARMSX2/ARMSX2%22%2C%22author%22%3A%22ARMSX2%22%2C%22name%22%3A%22ARMSX2%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%2C%5C%22includeZips%5C%22%3Afalse%2C%5C%22zippedApkFilterRegEx%5C%22%3A%5C%22%5C%22%7D%22%2C%22categories%22%3A%5B%22Emulator%22%5D%2C%22overrideSource%22%3A%22GitHub%22%2C%22allowIdChange%22%3Atrue%7D
|
||||
|
||||
@@ -9,30 +9,19 @@ from utils import should_include_app
|
||||
|
||||
|
||||
def minify_json(input_file: str, output_file: str, variant: str = "standard") -> None:
|
||||
"""Filter apps by variant, remove meta fields, and output minified JSON.
|
||||
|
||||
Args:
|
||||
input_file: Path to source applications.json
|
||||
output_file: Path to write minified JSON
|
||||
variant: One of 'standard' or 'dual-screen'
|
||||
"""
|
||||
try:
|
||||
# Read JSON data from input file
|
||||
with open(input_file, "r", encoding="utf-8") as f:
|
||||
data: dict[str, Any] = json.load(f)
|
||||
|
||||
# Filter apps based on variant
|
||||
if "apps" in data:
|
||||
filtered_apps = []
|
||||
for app in data["apps"]:
|
||||
if should_include_app(app, variant):
|
||||
# Remove meta key from export
|
||||
app_copy = app.copy()
|
||||
app_copy.pop("meta", None)
|
||||
filtered_apps.append(app_copy)
|
||||
data["apps"] = filtered_apps
|
||||
|
||||
# Minify JSON and write to output file
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
|
||||
@@ -26,11 +26,12 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from utils import get_application_url, get_display_name, make_obtainium_link, should_include_app
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Release artifact paths (relative to repo root)
|
||||
@@ -41,8 +42,6 @@ APPLICATIONS_JSON = REPO_ROOT / "src" / "applications.json"
|
||||
SEMVER_PATTERN = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")
|
||||
|
||||
|
||||
# Helpers
|
||||
|
||||
def run(cmd: list[str], capture: bool = False, check: bool = True) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
@@ -65,8 +64,6 @@ def check_prerequisites() -> None:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Version helpers
|
||||
|
||||
def get_latest_tag() -> str | None:
|
||||
result = run(["git", "tag", "--sort=-v:refname"], capture=True, check=False)
|
||||
if result.returncode != 0:
|
||||
@@ -135,8 +132,6 @@ def prompt_version(latest: str | None) -> str:
|
||||
print("Invalid choice. Enter 1, 2, 3, or 4.")
|
||||
|
||||
|
||||
# App diff detection
|
||||
|
||||
def load_apps_from_ref(ref: str) -> dict[str, dict[str, Any]]:
|
||||
result = run(
|
||||
["git", "show", f"{ref}:src/applications.json"],
|
||||
@@ -192,44 +187,7 @@ def diff_apps(
|
||||
return added, changed, removed
|
||||
|
||||
|
||||
# Obtainium link generation (mirrors generate-table.py)
|
||||
|
||||
def make_obtainium_link(app: dict[str, Any]) -> str:
|
||||
payload = {
|
||||
"id": app["id"],
|
||||
"url": app["url"],
|
||||
"author": app["author"],
|
||||
"name": app["name"],
|
||||
"otherAssetUrls": app.get("otherAssetUrls"),
|
||||
"apkUrls": app.get("apkUrls"),
|
||||
"preferredApkIndex": app.get("preferredApkIndex"),
|
||||
"additionalSettings": app.get("additionalSettings"),
|
||||
"categories": app.get("categories"),
|
||||
"overrideSource": app.get("overrideSource"),
|
||||
"allowIdChange": app.get("allowIdChange"),
|
||||
}
|
||||
encoded = urllib.parse.quote(json.dumps(payload, separators=(",", ":")), safe="")
|
||||
return f"http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/{encoded}"
|
||||
|
||||
|
||||
def should_include_app(app: dict[str, Any], variant: str) -> bool:
|
||||
meta = app.get("meta", {})
|
||||
if meta.get("excludeFromExport", False):
|
||||
return False
|
||||
if variant == "standard":
|
||||
return meta.get("includeInStandard", True)
|
||||
elif variant == "dual-screen":
|
||||
return meta.get("includeInDualScreen", True)
|
||||
return True
|
||||
|
||||
|
||||
def get_display_name(app: dict[str, Any]) -> str:
|
||||
return app.get("meta", {}).get("nameOverride") or app.get("name", "")
|
||||
|
||||
|
||||
def get_application_url(app: dict[str, Any]) -> str:
|
||||
return app.get("meta", {}).get("urlOverride") or app.get("url", "")
|
||||
|
||||
# Table rendering for release notes
|
||||
|
||||
def make_app_table_row(app: dict[str, Any]) -> str:
|
||||
display_name = f'<a href="{get_application_url(app)}">{get_display_name(app)}</a>'
|
||||
@@ -273,8 +231,6 @@ def generate_app_table(apps: list[dict[str, Any]], group_by_category: bool = Fal
|
||||
return "\n".join(sections)
|
||||
|
||||
|
||||
# Commit log
|
||||
|
||||
def get_commit_summaries(since_tag: str | None) -> list[str]:
|
||||
if since_tag:
|
||||
cmd = ["git", "log", f"{since_tag}..HEAD", "--pretty=format:%s"]
|
||||
@@ -288,8 +244,6 @@ def get_commit_summaries(since_tag: str | None) -> list[str]:
|
||||
return [line.strip() for line in result.stdout.strip().splitlines() if line.strip()]
|
||||
|
||||
|
||||
# Release notes
|
||||
|
||||
def generate_release_notes(
|
||||
latest_tag: str | None,
|
||||
added: list[dict[str, Any]],
|
||||
@@ -352,8 +306,6 @@ def edit_release_notes(notes: str) -> str:
|
||||
return edited
|
||||
|
||||
|
||||
# Git / GitHub
|
||||
|
||||
def check_working_tree_clean() -> bool:
|
||||
result = run(["git", "status", "--porcelain"], capture=True)
|
||||
return result.stdout.strip() == ""
|
||||
@@ -411,8 +363,6 @@ def get_app_count(json_path: Path) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
# Main
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Create a GitHub release for Obtainium Emulation Pack"
|
||||
|
||||
622
scripts/test-apps.py
Normal file
622
scripts/test-apps.py
Normal file
@@ -0,0 +1,622 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Live validation that app configs can resolve to downloadable APKs.
|
||||
|
||||
Usage:
|
||||
python scripts/test-apps.py src/applications.json
|
||||
python scripts/test-apps.py src/applications.json Dolphin
|
||||
python scripts/test-apps.py src/applications.json --id org.dolphinemu.dolphinemu
|
||||
|
||||
Set GITHUB_TOKEN in .env or environment to avoid API rate limits.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
from html.parser import HTMLParser
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from utils import load_dotenv
|
||||
|
||||
USER_AGENT = (
|
||||
"Mozilla/5.0 (Linux; Android 10; K) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/114.0.0.0 Mobile Safari/537.36"
|
||||
)
|
||||
REQUEST_TIMEOUT = 30
|
||||
MAX_RELEASES_TO_CHECK = 25
|
||||
APK_EXTENSIONS = (".apk", ".xapk")
|
||||
MAX_STORED_APK_URLS = 5
|
||||
MAX_DISPLAYED_APK_URLS = 3
|
||||
|
||||
|
||||
def _make_request(
|
||||
url: str,
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: int = REQUEST_TIMEOUT,
|
||||
) -> tuple[str, dict[str, str], str]:
|
||||
"""Returns (body, response_headers, final_url). Allows self-signed certs."""
|
||||
hdrs = {"User-Agent": USER_AGENT}
|
||||
if headers:
|
||||
hdrs.update(headers)
|
||||
|
||||
req = Request(url, headers=hdrs)
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
resp = urlopen(req, timeout=timeout, context=ctx)
|
||||
body = resp.read().decode("utf-8", errors="replace")
|
||||
resp_headers = {k.lower(): v for k, v in resp.headers.items()}
|
||||
return body, resp_headers, resp.url
|
||||
|
||||
|
||||
def _fetch_json(
|
||||
url: str,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> tuple[Any, dict[str, str]]:
|
||||
hdrs = {"Accept": "application/json"}
|
||||
if headers:
|
||||
hdrs.update(headers)
|
||||
body, resp_headers, _ = _make_request(url, headers=hdrs)
|
||||
return json.loads(body), resp_headers
|
||||
|
||||
|
||||
class LinkExtractor(HTMLParser):
|
||||
def __init__(self, base_url: str):
|
||||
super().__init__()
|
||||
self.base_url = base_url
|
||||
self.links: list[str] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
if tag == "a":
|
||||
for name, value in attrs:
|
||||
if name == "href" and value:
|
||||
self.links.append(urljoin(self.base_url, value))
|
||||
|
||||
|
||||
def _extract_links(html_body: str, base_url: str) -> list[str]:
|
||||
parser = LinkExtractor(base_url)
|
||||
parser.feed(html_body)
|
||||
return parser.links
|
||||
|
||||
|
||||
def _filter_links_by_regex(links: list[str], regex: str) -> list[str]:
|
||||
pattern = re.compile(regex)
|
||||
return [link for link in links if pattern.search(link)]
|
||||
|
||||
|
||||
def _filter_links_by_extension(links: list[str]) -> list[str]:
|
||||
return [link for link in links if any(link.lower().endswith(ext) for ext in APK_EXTENSIONS)]
|
||||
|
||||
|
||||
def _sort_links(
|
||||
links: list[str],
|
||||
skip_sort: bool = False,
|
||||
reverse_sort: bool = False,
|
||||
sort_by_last_segment: bool = False,
|
||||
) -> list[str]:
|
||||
if skip_sort:
|
||||
return links
|
||||
key = (lambda u: u.rsplit("/", 1)[-1]) if sort_by_last_segment else None
|
||||
result = sorted(links, key=key)
|
||||
if reverse_sort:
|
||||
result.reverse()
|
||||
return result
|
||||
|
||||
|
||||
def _format_filter_context(**filters: str) -> str:
|
||||
"""Build a diagnostic string of active filters, e.g. ', apkFilter=foo, titleFilter=bar'."""
|
||||
parts = [f", {name}={value}" for name, value in filters.items() if value]
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _apply_apk_filter(urls: list[str], settings: dict[str, Any]) -> list[str]:
|
||||
apk_filter = settings.get("apkFilterRegEx", "")
|
||||
if not apk_filter or not urls:
|
||||
return urls
|
||||
pattern = re.compile(apk_filter)
|
||||
if settings.get("invertAPKFilter", False):
|
||||
return [u for u in urls if not pattern.search(u)]
|
||||
return [u for u in urls if pattern.search(u)]
|
||||
|
||||
|
||||
def _extract_version(raw_version: str, settings: dict[str, Any]) -> tuple[str, str | None]:
|
||||
"""Apply versionExtractionRegEx. Returns (version, warning_or_none)."""
|
||||
regex_str = settings.get("versionExtractionRegEx", "")
|
||||
if not regex_str or not raw_version:
|
||||
return raw_version, None
|
||||
try:
|
||||
match = re.search(regex_str, raw_version)
|
||||
if match:
|
||||
group_to_use = settings.get("matchGroupToUse", "")
|
||||
if group_to_use:
|
||||
return match.expand(group_to_use), None
|
||||
elif match.groups():
|
||||
return match.group(1), None
|
||||
return match.group(0), None
|
||||
except re.error as e:
|
||||
return raw_version, f"versionExtractionRegEx error: {e}"
|
||||
return raw_version, None
|
||||
|
||||
|
||||
def _check_apk_index(app: dict[str, Any], apk_count: int) -> str | None:
|
||||
"""Returns a warning string if preferredApkIndex is out of bounds."""
|
||||
index = app.get("preferredApkIndex", 0)
|
||||
if apk_count > 0 and index >= apk_count:
|
||||
return f"preferredApkIndex={index} but only {apk_count} APKs found"
|
||||
return None
|
||||
|
||||
|
||||
class TestResult:
|
||||
def __init__(self, app_name: str, app_id: str, source: str, url: str):
|
||||
self.app_name = app_name
|
||||
self.app_id = app_id
|
||||
self.source = source
|
||||
self.url = url
|
||||
self.passed = False
|
||||
self.version: str | None = None
|
||||
self.apk_count = 0
|
||||
self.apk_urls: list[str] = []
|
||||
self.error: str | None = None
|
||||
self.warnings: list[str] = []
|
||||
self.duration_ms = 0
|
||||
|
||||
def __repr__(self) -> str:
|
||||
status = "PASS" if self.passed else "FAIL"
|
||||
return f"{status}: {self.app_name} ({self.source})"
|
||||
|
||||
|
||||
def _github_headers() -> dict[str, str]:
|
||||
headers = {"Accept": "application/vnd.github.v3+json"}
|
||||
token = os.environ.get("GITHUB_TOKEN", "")
|
||||
if token:
|
||||
headers["Authorization"] = f"token {token}"
|
||||
return headers
|
||||
|
||||
|
||||
def _parse_owner_repo(url: str) -> tuple[str, str, str]:
|
||||
"""Returns (owner, repo, host)."""
|
||||
parsed = urlparse(url)
|
||||
parts = parsed.path.strip("/").split("/")
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f"Cannot parse owner/repo from: {url}")
|
||||
return parts[0], parts[1], parsed.netloc
|
||||
|
||||
|
||||
def _collect_apks_from_assets(assets: list[dict], settings: dict[str, Any]) -> list[str]:
|
||||
"""Extract APK download URLs from a GitHub/Codeberg release's asset list."""
|
||||
urls = []
|
||||
for asset in assets:
|
||||
name = asset.get("name", "").lower()
|
||||
dl_url = asset.get("browser_download_url", "")
|
||||
if any(name.endswith(ext) for ext in APK_EXTENSIONS):
|
||||
urls.append(dl_url)
|
||||
elif name.endswith(".zip") and settings.get("includeZips", False):
|
||||
urls.append(dl_url)
|
||||
return urls
|
||||
|
||||
|
||||
def _find_release_with_apks(
|
||||
releases: list[dict],
|
||||
settings: dict[str, Any],
|
||||
title_filter: re.Pattern | None = None,
|
||||
notes_filter: re.Pattern | None = None,
|
||||
) -> tuple[dict | None, list[str]]:
|
||||
"""Walk releases and return the first one with matching APK assets.
|
||||
|
||||
Returns (target_release, filtered_apk_urls). For track-only apps,
|
||||
falls back to any release with a tag_name even if no APKs found.
|
||||
"""
|
||||
include_prereleases = settings.get("includePrereleases", False)
|
||||
track_only = settings.get("trackOnly", False)
|
||||
fallback = settings.get("fallbackToOlderReleases", True)
|
||||
|
||||
for release in releases:
|
||||
if release.get("draft", False):
|
||||
continue
|
||||
if release.get("prerelease", False) and not include_prereleases:
|
||||
continue
|
||||
|
||||
if title_filter:
|
||||
name = release.get("name", "") or ""
|
||||
if not title_filter.search(name):
|
||||
continue
|
||||
if notes_filter:
|
||||
body = release.get("body", "") or ""
|
||||
if not notes_filter.search(body):
|
||||
continue
|
||||
|
||||
apk_urls = _collect_apks_from_assets(release.get("assets", []), settings)
|
||||
apk_urls = _apply_apk_filter(apk_urls, settings)
|
||||
|
||||
if not apk_urls and not track_only:
|
||||
if fallback:
|
||||
continue
|
||||
break
|
||||
|
||||
return release, apk_urls
|
||||
|
||||
# Track-only fallback: any release with a version tag
|
||||
if track_only:
|
||||
for release in releases:
|
||||
if release.get("tag_name"):
|
||||
return release, []
|
||||
|
||||
return None, []
|
||||
|
||||
|
||||
def test_github(app: dict[str, Any], settings: dict[str, Any]) -> TestResult:
|
||||
result = TestResult(app["name"], app["id"], "GitHub", app["url"])
|
||||
|
||||
try:
|
||||
owner, repo, _ = _parse_owner_repo(app["url"])
|
||||
except ValueError as e:
|
||||
result.error = str(e)
|
||||
return result
|
||||
|
||||
api_url = f"https://api.github.com/repos/{owner}/{repo}/releases?per_page={MAX_RELEASES_TO_CHECK}"
|
||||
|
||||
try:
|
||||
releases, resp_headers = _fetch_json(api_url, headers=_github_headers())
|
||||
except Exception as e:
|
||||
result.error = f"GitHub API error: {e}"
|
||||
if "403" in str(e) or "rate" in str(e).lower():
|
||||
result.error += " (rate limited - set GITHUB_TOKEN env var)"
|
||||
return result
|
||||
|
||||
remaining = resp_headers.get("x-ratelimit-remaining", "")
|
||||
if remaining and int(remaining) < 10:
|
||||
result.warnings.append(f"GitHub API rate limit low: {remaining} remaining")
|
||||
|
||||
if not releases:
|
||||
result.error = "No releases found"
|
||||
return result
|
||||
|
||||
title_str = settings.get("filterReleaseTitlesByRegEx", "")
|
||||
notes_str = settings.get("filterReleaseNotesByRegEx", "")
|
||||
title_regex = re.compile(title_str) if title_str else None
|
||||
notes_regex = re.compile(notes_str) if notes_str else None
|
||||
|
||||
target, apk_urls = _find_release_with_apks(
|
||||
releases, settings, title_filter=title_regex, notes_filter=notes_regex
|
||||
)
|
||||
|
||||
if not target:
|
||||
prerelease_state = "on" if settings.get("includePrereleases", False) else "off"
|
||||
context = _format_filter_context(
|
||||
titleFilter=title_str,
|
||||
apkFilter=settings.get("apkFilterRegEx", ""),
|
||||
)
|
||||
result.error = (
|
||||
f"No releases with matching APK assets found "
|
||||
f"(checked {len(releases)} releases, prereleases={prerelease_state}{context})"
|
||||
)
|
||||
return result
|
||||
|
||||
version = target.get("tag_name", "") or target.get("name", "")
|
||||
version, warning = _extract_version(version, settings)
|
||||
if warning:
|
||||
result.warnings.append(warning)
|
||||
|
||||
index_warning = _check_apk_index(app, len(apk_urls))
|
||||
if index_warning:
|
||||
result.warnings.append(index_warning)
|
||||
|
||||
result.passed = True
|
||||
result.version = version
|
||||
result.apk_count = len(apk_urls)
|
||||
result.apk_urls = apk_urls
|
||||
return result
|
||||
|
||||
|
||||
def test_codeberg(app: dict[str, Any], settings: dict[str, Any]) -> TestResult:
|
||||
result = TestResult(app["name"], app["id"], "Codeberg", app["url"])
|
||||
|
||||
try:
|
||||
owner, repo, host = _parse_owner_repo(app["url"])
|
||||
except ValueError as e:
|
||||
result.error = str(e)
|
||||
return result
|
||||
|
||||
api_url = f"https://{host}/api/v1/repos/{owner}/{repo}/releases?limit={MAX_RELEASES_TO_CHECK}"
|
||||
|
||||
try:
|
||||
releases, _ = _fetch_json(api_url)
|
||||
except Exception as e:
|
||||
result.error = f"Codeberg API error: {e}"
|
||||
return result
|
||||
|
||||
if not releases:
|
||||
result.error = "No releases found"
|
||||
return result
|
||||
|
||||
target, apk_urls = _find_release_with_apks(releases, settings)
|
||||
|
||||
if not target:
|
||||
result.error = "No releases with matching APK assets"
|
||||
return result
|
||||
|
||||
version = target.get("tag_name", "") or target.get("name", "")
|
||||
version, warning = _extract_version(version, settings)
|
||||
if warning:
|
||||
result.warnings.append(warning)
|
||||
|
||||
index_warning = _check_apk_index(app, len(apk_urls))
|
||||
if index_warning:
|
||||
result.warnings.append(index_warning)
|
||||
|
||||
result.passed = True
|
||||
result.version = version
|
||||
result.apk_count = len(apk_urls)
|
||||
result.apk_urls = apk_urls
|
||||
return result
|
||||
|
||||
|
||||
def _parse_request_headers(settings: dict[str, Any]) -> dict[str, str]:
|
||||
headers = {}
|
||||
for header_obj in settings.get("requestHeader", []):
|
||||
if isinstance(header_obj, dict):
|
||||
header_str = header_obj.get("requestHeader", "")
|
||||
if ": " in header_str:
|
||||
key, val = header_str.split(": ", 1)
|
||||
headers[key] = val
|
||||
return headers
|
||||
|
||||
|
||||
def _follow_intermediate_links(
|
||||
start_url: str,
|
||||
steps: list[dict],
|
||||
headers: dict[str, str],
|
||||
) -> tuple[str, str | None]:
|
||||
"""Walk intermediateLink chain. Returns (final_url, error_or_none)."""
|
||||
current_url = start_url
|
||||
for i, step in enumerate(steps):
|
||||
if not isinstance(step, dict):
|
||||
continue
|
||||
try:
|
||||
body, _, final_url = _make_request(current_url, headers=headers)
|
||||
except Exception as e:
|
||||
return current_url, f"Failed to fetch intermediate URL ({current_url}): {e}"
|
||||
|
||||
links = _extract_links(body, final_url)
|
||||
step_regex = step.get("customLinkFilterRegex", "")
|
||||
if step_regex:
|
||||
links = _filter_links_by_regex(links, step_regex)
|
||||
|
||||
links = _sort_links(
|
||||
links,
|
||||
skip_sort=step.get("skipSort", False),
|
||||
reverse_sort=step.get("reverseSort", False),
|
||||
sort_by_last_segment=step.get("sortByLastLinkSegment", False),
|
||||
)
|
||||
|
||||
if not links:
|
||||
return current_url, (
|
||||
f"Intermediate link step {i} found no matching links "
|
||||
f"(url={current_url}, regex={step_regex!r})"
|
||||
)
|
||||
|
||||
current_url = links[-1] # Obtainium takes the last link after sorting
|
||||
|
||||
return current_url, None
|
||||
|
||||
|
||||
def test_html(app: dict[str, Any], settings: dict[str, Any]) -> TestResult:
|
||||
result = TestResult(app["name"], app["id"], "HTML", app["url"])
|
||||
|
||||
req_headers = _parse_request_headers(settings)
|
||||
intermediate_links = settings.get("intermediateLink", [])
|
||||
|
||||
current_url, error = _follow_intermediate_links(app["url"], intermediate_links, req_headers)
|
||||
if error:
|
||||
result.error = error
|
||||
return result
|
||||
|
||||
try:
|
||||
body, _, final_url = _make_request(current_url, headers=req_headers)
|
||||
except Exception as e:
|
||||
result.error = f"Failed to fetch final URL ({current_url}): {e}"
|
||||
return result
|
||||
|
||||
links = _extract_links(body, final_url)
|
||||
custom_regex = settings.get("customLinkFilterRegex", "")
|
||||
apk_links = _filter_links_by_regex(links, custom_regex) if custom_regex else _filter_links_by_extension(links)
|
||||
apk_links = _apply_apk_filter(apk_links, settings)
|
||||
|
||||
track_only = settings.get("trackOnly", False)
|
||||
if not apk_links and not track_only:
|
||||
context = _format_filter_context(
|
||||
customLinkFilterRegex=custom_regex,
|
||||
apkFilterRegEx=settings.get("apkFilterRegEx", ""),
|
||||
)
|
||||
result.error = (
|
||||
f"No APK links found on page ({current_url}{context}, "
|
||||
f"{len(links)} total links on page)"
|
||||
)
|
||||
return result
|
||||
|
||||
version = None
|
||||
version_regex_str = settings.get("versionExtractionRegEx", "")
|
||||
if version_regex_str:
|
||||
extract_whole_page = settings.get("versionExtractWholePage", False)
|
||||
if extract_whole_page:
|
||||
search_text = body
|
||||
elif apk_links:
|
||||
search_text = apk_links[-1] # Obtainium uses last link
|
||||
else:
|
||||
search_text = ""
|
||||
|
||||
version, warning = _extract_version(search_text, settings)
|
||||
if warning:
|
||||
result.warnings.append(warning)
|
||||
|
||||
if not version:
|
||||
pseudo_method = settings.get("defaultPseudoVersioningMethod", "")
|
||||
if pseudo_method:
|
||||
version = f"<pseudo:{pseudo_method}>"
|
||||
else:
|
||||
result.warnings.append("No version extracted (no regex match, no pseudo-method)")
|
||||
|
||||
index_warning = _check_apk_index(app, len(apk_links))
|
||||
if index_warning:
|
||||
result.warnings.append(index_warning)
|
||||
|
||||
result.passed = True
|
||||
result.version = version
|
||||
result.apk_count = len(apk_links)
|
||||
result.apk_urls = apk_links[:MAX_STORED_APK_URLS]
|
||||
return result
|
||||
|
||||
|
||||
def _effective_source(app: dict[str, Any]) -> str:
|
||||
override = app.get("overrideSource")
|
||||
if override:
|
||||
return override
|
||||
|
||||
host = urlparse(app.get("url", "")).netloc.lower().lstrip("www.")
|
||||
if "github.com" in host:
|
||||
return "GitHub"
|
||||
if "gitlab.com" in host:
|
||||
return "GitLab"
|
||||
if "codeberg.org" in host:
|
||||
return "Codeberg"
|
||||
if "f-droid.org" in host:
|
||||
return "FDroid"
|
||||
return "HTML"
|
||||
|
||||
|
||||
def test_app(app: dict[str, Any]) -> TestResult:
|
||||
source = _effective_source(app)
|
||||
|
||||
settings_str = app.get("additionalSettings", "{}")
|
||||
try:
|
||||
settings = json.loads(settings_str) if isinstance(settings_str, str) else {}
|
||||
except json.JSONDecodeError:
|
||||
result = TestResult(app.get("name", "?"), app.get("id", "?"), source, app.get("url", "?"))
|
||||
result.error = "Cannot parse additionalSettings JSON"
|
||||
return result
|
||||
|
||||
start = time.monotonic()
|
||||
|
||||
if source == "GitHub":
|
||||
result = test_github(app, settings)
|
||||
elif source == "Codeberg":
|
||||
result = test_codeberg(app, settings)
|
||||
elif source in ("HTML", "DirectAPKLink"):
|
||||
result = test_html(app, settings)
|
||||
else:
|
||||
result = TestResult(app.get("name", "?"), app.get("id", "?"), source, app.get("url", "?"))
|
||||
result.passed = True
|
||||
result.warnings.append(f"Skipped: source type '{source}' not yet supported")
|
||||
|
||||
result.duration_ms = int((time.monotonic() - start) * 1000)
|
||||
return result
|
||||
|
||||
|
||||
def print_result(result: TestResult, verbose: bool = False) -> None:
|
||||
status = "\033[32mPASS\033[0m" if result.passed else "\033[31mFAIL\033[0m"
|
||||
version_str = f" v{result.version}" if result.version else ""
|
||||
apk_str = f" ({result.apk_count} APKs)" if result.apk_count else ""
|
||||
|
||||
print(f" {status} {result.app_name}{version_str}{apk_str} [{result.duration_ms}ms]")
|
||||
|
||||
if result.error:
|
||||
print(f" Error: {result.error}")
|
||||
for warning in result.warnings:
|
||||
print(f" \033[33mWarn\033[0m: {warning}")
|
||||
if verbose and result.apk_urls:
|
||||
for url in result.apk_urls[:MAX_DISPLAYED_APK_URLS]:
|
||||
print(f" APK: {url}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
load_dotenv()
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python test-apps.py <json_file> [name_filter] [--id <app_id>] [--verbose]")
|
||||
print()
|
||||
print("Examples:")
|
||||
print(" python test-apps.py src/applications.json # test all apps")
|
||||
print(" python test-apps.py src/applications.json Dolphin # filter by name")
|
||||
print(" python test-apps.py src/applications.json --id org.dolphinemu.dolphinemu")
|
||||
print(" python test-apps.py src/applications.json --verbose # show APK URLs")
|
||||
return 1
|
||||
|
||||
json_file = sys.argv[1]
|
||||
args = sys.argv[2:]
|
||||
|
||||
verbose = "--verbose" in args
|
||||
if verbose:
|
||||
args.remove("--verbose")
|
||||
|
||||
id_filter = None
|
||||
if "--id" in args:
|
||||
idx = args.index("--id")
|
||||
if idx + 1 < len(args):
|
||||
id_filter = args[idx + 1]
|
||||
args = args[:idx] + args[idx + 2:]
|
||||
else:
|
||||
print("Error: --id requires an argument")
|
||||
return 1
|
||||
|
||||
name_filter = " ".join(args).lower() if args else None
|
||||
|
||||
try:
|
||||
with open(json_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, FileNotFoundError) as e:
|
||||
print(f"Error loading {json_file}: {e}")
|
||||
return 1
|
||||
|
||||
apps = data.get("apps", [])
|
||||
if id_filter:
|
||||
apps = [a for a in apps if a.get("id") == id_filter]
|
||||
elif name_filter:
|
||||
apps = [a for a in apps if name_filter in a.get("name", "").lower()]
|
||||
|
||||
if not apps:
|
||||
print("No apps matched the filter.")
|
||||
return 1
|
||||
|
||||
has_token = bool(os.environ.get("GITHUB_TOKEN"))
|
||||
github_count = sum(1 for a in apps if _effective_source(a) == "GitHub")
|
||||
if github_count > 0 and not has_token:
|
||||
print(
|
||||
f"\033[33mNote\033[0m: {github_count} GitHub apps to test, "
|
||||
"but GITHUB_TOKEN is not set. You may hit rate limits.\n"
|
||||
" Set it with: export GITHUB_TOKEN=<your_token>\n"
|
||||
)
|
||||
|
||||
print(f"Testing {len(apps)} app(s)...\n")
|
||||
|
||||
results = []
|
||||
for app in apps:
|
||||
result = test_app(app)
|
||||
results.append(result)
|
||||
print_result(result, verbose=verbose)
|
||||
|
||||
passed = sum(1 for r in results if r.passed)
|
||||
failed = sum(1 for r in results if not r.passed)
|
||||
warned = sum(1 for r in results if r.warnings)
|
||||
total_time = sum(r.duration_ms for r in results)
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Results: {passed} passed, {failed} failed, {warned} with warnings")
|
||||
print(f"Time: {total_time / 1000:.1f}s total")
|
||||
|
||||
if failed > 0:
|
||||
print(f"\nFailed apps:")
|
||||
for r in results:
|
||||
if not r.passed:
|
||||
print(f" - {r.app_name}: {r.error}")
|
||||
|
||||
return 1 if failed > 0 else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,40 +1,64 @@
|
||||
"""Shared utility functions for Obtainium Emulation Pack scripts."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from constants import OBTAINIUM_SCHEME, REDIRECT_URL
|
||||
|
||||
|
||||
def load_dotenv() -> None:
|
||||
"""Load .env into os.environ. Real env vars take precedence. Blank values skipped."""
|
||||
env_path = Path(__file__).resolve().parent.parent / ".env"
|
||||
if not env_path.exists():
|
||||
return
|
||||
|
||||
with open(env_path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip("\"'")
|
||||
if key and value and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def should_include_app(app: dict[str, Any], variant: str) -> bool:
|
||||
"""Determine if an app should be included based on variant and meta fields.
|
||||
|
||||
Args:
|
||||
app: Application dictionary from applications.json
|
||||
variant: One of 'standard' or 'dual-screen'
|
||||
|
||||
Returns:
|
||||
True if the app should be included in the specified variant
|
||||
"""
|
||||
meta = app.get("meta", {})
|
||||
|
||||
# HIGHEST PRIORITY: Global exclusion overrides everything
|
||||
if meta.get("excludeFromExport", False):
|
||||
return False
|
||||
|
||||
# SECOND PRIORITY: Variant-specific inclusion/exclusion
|
||||
if variant == "standard":
|
||||
# Default: include in standard
|
||||
return meta.get("includeInStandard", True)
|
||||
elif variant == "dual-screen":
|
||||
# Default: include in dual screen
|
||||
return meta.get("includeInDualScreen", True)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_display_name(app: dict[str, Any]) -> str:
|
||||
"""Get the display name for an app, respecting nameOverride."""
|
||||
return app.get("meta", {}).get("nameOverride") or app.get("name", "")
|
||||
|
||||
|
||||
def get_application_url(app: dict[str, Any]) -> str:
|
||||
"""Get the URL for an app, respecting urlOverride."""
|
||||
return app.get("meta", {}).get("urlOverride") or app.get("url", "")
|
||||
|
||||
|
||||
def make_obtainium_link(app: dict[str, Any]) -> str:
|
||||
payload = {
|
||||
"id": app["id"],
|
||||
"url": app["url"],
|
||||
"author": app["author"],
|
||||
"name": app["name"],
|
||||
"otherAssetUrls": app.get("otherAssetUrls"),
|
||||
"apkUrls": app.get("apkUrls"),
|
||||
"preferredApkIndex": app.get("preferredApkIndex"),
|
||||
"additionalSettings": app.get("additionalSettings"),
|
||||
"categories": app.get("categories"),
|
||||
"overrideSource": app.get("overrideSource"),
|
||||
"allowIdChange": app.get("allowIdChange"),
|
||||
}
|
||||
encoded = urllib.parse.quote(json.dumps(payload, separators=(",", ":")), safe="")
|
||||
return f"{REDIRECT_URL}?r={OBTAINIUM_SCHEME}{encoded}"
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
"""Validate applications.json against schema and check for common issues."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from constants import VARIANTS
|
||||
from constants import (
|
||||
COMMON_SETTINGS_KEYS,
|
||||
REGEX_SETTINGS_KEYS,
|
||||
SOURCE_HOST_MAP,
|
||||
SOURCE_SPECIFIC_KEYS,
|
||||
VALID_SOURCES,
|
||||
VARIANTS,
|
||||
)
|
||||
from utils import should_include_app
|
||||
|
||||
# Required fields for each app
|
||||
REQUIRED_FIELDS = {"id", "url", "author", "name"}
|
||||
|
||||
# Valid meta keys
|
||||
VALID_META_KEYS = {
|
||||
"excludeFromExport",
|
||||
"excludeFromTable",
|
||||
@@ -21,55 +27,206 @@ VALID_META_KEYS = {
|
||||
"includeInDualScreen",
|
||||
}
|
||||
|
||||
META_TYPO_MAP = {
|
||||
"exludeFromExport": "excludeFromExport",
|
||||
"exludeFromTable": "excludeFromTable",
|
||||
"nameOveride": "nameOverride",
|
||||
"urlOveride": "urlOverride",
|
||||
}
|
||||
|
||||
def validate_app(app: dict[str, Any], index: int) -> list[str]:
|
||||
"""Validate a single app entry and return list of errors."""
|
||||
|
||||
def _check_regex(pattern: str, field_name: str, app_name: str) -> str | None:
|
||||
if not pattern:
|
||||
return None
|
||||
try:
|
||||
re.compile(pattern)
|
||||
return None
|
||||
except re.error as e:
|
||||
return f"{app_name}: invalid regex in '{field_name}': {e} (pattern: {pattern!r})"
|
||||
|
||||
|
||||
def _detect_source_from_url(url: str) -> str | None:
|
||||
try:
|
||||
host = urlparse(url).netloc.lower().lstrip("www.")
|
||||
except Exception:
|
||||
return None
|
||||
for domain, source in SOURCE_HOST_MAP.items():
|
||||
if host == domain or host.endswith(f".{domain}"):
|
||||
return source
|
||||
return None
|
||||
|
||||
|
||||
def _valid_keys_for_source(source: str | None) -> set[str]:
|
||||
valid = set(COMMON_SETTINGS_KEYS)
|
||||
if source and source in SOURCE_SPECIFIC_KEYS:
|
||||
valid |= SOURCE_SPECIFIC_KEYS[source]
|
||||
return valid
|
||||
|
||||
|
||||
def _validate_required_fields(app: dict, app_name: str) -> list[str]:
|
||||
return [
|
||||
f"{app_name}: missing required field '{f}'"
|
||||
for f in REQUIRED_FIELDS
|
||||
if f not in app
|
||||
]
|
||||
|
||||
|
||||
def _validate_url(app: dict, app_name: str) -> list[str]:
|
||||
errors = []
|
||||
app_name = app.get("name", f"app[{index}]")
|
||||
url = app.get("url", "")
|
||||
if not url:
|
||||
return errors
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
if not parsed.scheme:
|
||||
errors.append(f"{app_name}: URL missing scheme (http/https): {url}")
|
||||
elif parsed.scheme not in ("http", "https"):
|
||||
errors.append(f"{app_name}: URL has non-http scheme: {parsed.scheme}")
|
||||
if not parsed.netloc:
|
||||
errors.append(f"{app_name}: URL missing host: {url}")
|
||||
except Exception as e:
|
||||
errors.append(f"{app_name}: malformed URL: {e}")
|
||||
return errors
|
||||
|
||||
# Check required fields
|
||||
for field in REQUIRED_FIELDS:
|
||||
if field not in app:
|
||||
errors.append(f"{app_name}: missing required field '{field}'")
|
||||
|
||||
# Validate meta keys if present
|
||||
def _validate_override_source(
|
||||
app: dict, app_name: str
|
||||
) -> tuple[list[str], list[str]]:
|
||||
errors, warnings = [], []
|
||||
source = app.get("overrideSource")
|
||||
url = app.get("url", "")
|
||||
|
||||
if source is not None and source not in VALID_SOURCES:
|
||||
errors.append(
|
||||
f"{app_name}: unknown overrideSource '{source}' "
|
||||
f"(valid: {', '.join(sorted(VALID_SOURCES))})"
|
||||
)
|
||||
elif source is None:
|
||||
warnings.append(f"{app_name}: missing overrideSource (auto-detection may be fragile)")
|
||||
|
||||
if url and source:
|
||||
detected = _detect_source_from_url(url)
|
||||
if detected and detected != source and source != "HTML" and detected != "HTML":
|
||||
warnings.append(
|
||||
f"{app_name}: URL host suggests '{detected}' but "
|
||||
f"overrideSource is '{source}'"
|
||||
)
|
||||
|
||||
return errors, warnings
|
||||
|
||||
|
||||
def _validate_apk_index(app: dict, app_name: str) -> list[str]:
|
||||
index = app.get("preferredApkIndex")
|
||||
if index is not None and (not isinstance(index, int) or index < 0):
|
||||
return [
|
||||
f"{app_name}: preferredApkIndex must be a non-negative integer, got {index!r}"
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def _validate_meta(app: dict, app_name: str) -> list[str]:
|
||||
errors = []
|
||||
meta = app.get("meta", {})
|
||||
for key in meta.keys():
|
||||
|
||||
for key in meta:
|
||||
if key not in VALID_META_KEYS:
|
||||
errors.append(f"{app_name}: unknown meta key '{key}' (typo?)")
|
||||
|
||||
# Check for common typos
|
||||
typo_checks = {
|
||||
"exludeFromExport": "excludeFromExport",
|
||||
"exludeFromTable": "excludeFromTable",
|
||||
"nameOveride": "nameOverride",
|
||||
"urlOveride": "urlOverride",
|
||||
}
|
||||
for typo, correct in typo_checks.items():
|
||||
for typo, correct in META_TYPO_MAP.items():
|
||||
if typo in meta:
|
||||
errors.append(f"{app_name}: typo in meta key '{typo}', should be '{correct}'")
|
||||
|
||||
# Validate additionalSettings is valid inner JSON
|
||||
additional_settings = app.get("additionalSettings")
|
||||
if additional_settings is not None:
|
||||
if not isinstance(additional_settings, str):
|
||||
errors.append(f"{app_name}: 'additionalSettings' should be a JSON string")
|
||||
else:
|
||||
try:
|
||||
json.loads(additional_settings)
|
||||
except json.JSONDecodeError as e:
|
||||
errors.append(f"{app_name}: 'additionalSettings' contains invalid JSON: {e}")
|
||||
|
||||
# Validate categories is a list
|
||||
categories = app.get("categories")
|
||||
if categories is not None and not isinstance(categories, list):
|
||||
errors.append(f"{app_name}: 'categories' should be a list")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_categories(app: dict, app_name: str) -> list[str]:
|
||||
categories = app.get("categories")
|
||||
if categories is not None and not isinstance(categories, list):
|
||||
return [f"{app_name}: 'categories' should be a list"]
|
||||
return []
|
||||
|
||||
|
||||
def _validate_additional_settings(
|
||||
app: dict, app_name: str
|
||||
) -> tuple[list[str], list[str]]:
|
||||
errors, warnings = [], []
|
||||
raw = app.get("additionalSettings")
|
||||
if raw is None:
|
||||
return errors, warnings
|
||||
|
||||
if not isinstance(raw, str):
|
||||
errors.append(f"{app_name}: 'additionalSettings' should be a JSON string")
|
||||
return errors, warnings
|
||||
|
||||
try:
|
||||
settings = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
errors.append(f"{app_name}: 'additionalSettings' contains invalid JSON: {e}")
|
||||
return errors, warnings
|
||||
|
||||
if not isinstance(settings, dict):
|
||||
return errors, warnings
|
||||
|
||||
# Validate regex fields
|
||||
for key in REGEX_SETTINGS_KEYS:
|
||||
value = settings.get(key, "")
|
||||
if isinstance(value, str):
|
||||
err = _check_regex(value, key, app_name)
|
||||
if err:
|
||||
errors.append(err)
|
||||
|
||||
# Validate regex in intermediate link steps
|
||||
for i, link in enumerate(settings.get("intermediateLink", [])):
|
||||
if isinstance(link, dict):
|
||||
regex_val = link.get("customLinkFilterRegex", "")
|
||||
if isinstance(regex_val, str):
|
||||
err = _check_regex(regex_val, f"intermediateLink[{i}].customLinkFilterRegex", app_name)
|
||||
if err:
|
||||
errors.append(err)
|
||||
|
||||
# Check for source-inappropriate keys
|
||||
url = app.get("url", "")
|
||||
effective_source = app.get("overrideSource") or _detect_source_from_url(url)
|
||||
if effective_source:
|
||||
valid_keys = _valid_keys_for_source(effective_source)
|
||||
for key in settings:
|
||||
if key not in valid_keys:
|
||||
belongs_to = [
|
||||
s for s, keys in SOURCE_SPECIFIC_KEYS.items()
|
||||
if key in keys and s != effective_source
|
||||
]
|
||||
if belongs_to:
|
||||
warnings.append(
|
||||
f"{app_name}: additionalSettings key '{key}' "
|
||||
f"is for {'/'.join(belongs_to)}, not {effective_source}"
|
||||
)
|
||||
|
||||
return errors, warnings
|
||||
|
||||
|
||||
def validate_app(app: dict[str, Any], index: int) -> tuple[list[str], list[str]]:
|
||||
errors, warnings = [], []
|
||||
app_name = app.get("name", f"app[{index}]")
|
||||
|
||||
errors += _validate_required_fields(app, app_name)
|
||||
errors += _validate_url(app, app_name)
|
||||
|
||||
src_errors, src_warnings = _validate_override_source(app, app_name)
|
||||
errors += src_errors
|
||||
warnings += src_warnings
|
||||
|
||||
errors += _validate_apk_index(app, app_name)
|
||||
errors += _validate_meta(app, app_name)
|
||||
errors += _validate_categories(app, app_name)
|
||||
|
||||
settings_errors, settings_warnings = _validate_additional_settings(app, app_name)
|
||||
errors += settings_errors
|
||||
warnings += settings_warnings
|
||||
|
||||
return errors, warnings
|
||||
|
||||
|
||||
def check_duplicate_ids(apps: list[dict[str, Any]], variant: str) -> list[str]:
|
||||
"""Check for duplicate app IDs within a variant."""
|
||||
errors = []
|
||||
ids_seen: dict[str, str] = {}
|
||||
|
||||
@@ -79,9 +236,8 @@ def check_duplicate_ids(apps: list[dict[str, Any]], variant: str) -> list[str]:
|
||||
|
||||
app_id = app.get("id", "")
|
||||
app_name = app.get("name", "unknown")
|
||||
|
||||
if not app_id:
|
||||
continue # Already caught by required field check
|
||||
continue
|
||||
|
||||
if app_id in ids_seen:
|
||||
errors.append(
|
||||
@@ -95,7 +251,6 @@ def check_duplicate_ids(apps: list[dict[str, Any]], variant: str) -> list[str]:
|
||||
|
||||
|
||||
def validate_json(input_file: str) -> int:
|
||||
"""Validate applications.json and return exit code (0=success, 1=errors)."""
|
||||
try:
|
||||
with open(input_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
@@ -111,25 +266,33 @@ def validate_json(input_file: str) -> int:
|
||||
return 1
|
||||
|
||||
apps = data["apps"]
|
||||
all_errors = []
|
||||
all_errors, all_warnings = [], []
|
||||
|
||||
# Validate each app
|
||||
for i, app in enumerate(apps):
|
||||
errors = validate_app(app, i)
|
||||
errors, warnings = validate_app(app, i)
|
||||
all_errors.extend(errors)
|
||||
all_warnings.extend(warnings)
|
||||
|
||||
# Check for duplicate IDs per variant
|
||||
for variant in VARIANTS:
|
||||
errors = check_duplicate_ids(apps, variant)
|
||||
all_errors.extend(errors)
|
||||
all_errors.extend(check_duplicate_ids(apps, variant))
|
||||
|
||||
if all_warnings:
|
||||
print(f"Warnings ({len(all_warnings)}):\n")
|
||||
for warning in all_warnings:
|
||||
print(f" ~ {warning}")
|
||||
print()
|
||||
|
||||
if all_errors:
|
||||
print(f"Validation failed with {len(all_errors)} error(s):\n")
|
||||
for error in all_errors:
|
||||
print(f" - {error}")
|
||||
print(f" x {error}")
|
||||
return 1
|
||||
|
||||
print(f"Validation passed: {len(apps)} apps checked")
|
||||
print(f"Validation passed: {len(apps)} apps checked", end="")
|
||||
if all_warnings:
|
||||
print(f" ({len(all_warnings)} warnings)")
|
||||
else:
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user