Last active
July 24, 2025 23:37
-
-
Save playday3008/9e6188d5190f1963bde544972f55b270 to your computer and use it in GitHub Desktop.
Lutris API Stuff
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| from __future__ import annotations | |
| from datetime import datetime | |
| from typing import Optional, Any, cast | |
| from enum import Enum | |
| import sys | |
| import requests | |
| import math | |
| import time | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| from pydantic import BaseModel, Field | |
| LUTRIS_API_BASE = "https://lutris.net/api" | |
| LUTRIS_API_GAMES = LUTRIS_API_BASE + "/games" | |
| LUTRIS_API_INSTALLERS = LUTRIS_API_BASE + "/installers" | |
| class Platform(BaseModel): | |
| name: str | |
| class ProviderGame(BaseModel): | |
| name: str | |
| slug: str | |
| service: str | |
| class GameAlias(BaseModel): | |
| slug: str | |
| name: str | |
| class ShaderCache(BaseModel): | |
| url: str | |
| updated_at: str | |
| class ChangeFor(BaseModel): | |
| id: int | |
| name: str | |
| slug: str | |
| class Game(BaseModel): | |
| id: int | |
| name: str | |
| slug: str | |
| year: Optional[int] | |
| banner_url: str | |
| icon_url: str | |
| coverart: Optional[str] | |
| platforms: list[Platform] | |
| provider_games: list[ProviderGame] | |
| aliases: list[GameAlias] | |
| shaders: list[ShaderCache] | |
| discord_id: Optional[str] | |
| change_for: Optional[ChangeFor] | |
| class Config: | |
| populate_by_name = True | |
| class InstallerScript(BaseModel): | |
| class RunnerEnum(str, Enum): | |
| AGS = "ags" | |
| ATARI800 = "atari800" | |
| BROWSER = "browser" | |
| CITRA = "citra" | |
| DESMUME = "desmume" | |
| DGEN = "dgen" | |
| DOLPHIN = "dolphin" | |
| DOSBOX = "dosbox" | |
| FROTZ = "frotz" | |
| FSUAE = "fsuae" | |
| HATARI = "hatari" | |
| JZINTV = "jzintv" | |
| LIBRETRO = "libretro" | |
| LINUX = "linux" | |
| MAME = "mame" | |
| MEDNAFEN = "mednafen" | |
| MUPEN64PLUS = "mupen64plus" | |
| O2EM = "o2em" | |
| OPENMSX = "openmsx" | |
| OSMOSE = "osmose" | |
| PCSX2 = "pcsx2" | |
| PCSXR = "pcsxr" | |
| PPSSPP = "ppsspp" | |
| REICAST = "reicast" | |
| RPCS3 = "rpcs3" | |
| SCUMMVM = "scummvm" | |
| SNES9X = "snes9x" | |
| STEAM = "steam" | |
| WINESTEAM = "winesteam" | |
| STELLA = "stella" | |
| VICE = "vice" | |
| VIRTUALJAGUAR = "virtualjaguar" | |
| WEB = "web" | |
| WINE = "wine" | |
| YUZU = "yuzu" | |
| ZDOOM = "zdoom" | |
| class ArchiveFormatEnum(str, Enum): | |
| TGZ = "tgz" | |
| TAR = "tar" | |
| ZIP = "zip" | |
| SEVEN_Z = "7z" | |
| RAR = "rar" | |
| TXZ = "txz" | |
| BZ2 = "bz2" | |
| GZIP = "gzip" | |
| DEB = "deb" | |
| EXE = "exe" | |
| GOG = "gog" | |
| class TaskNameEnum(str, Enum): | |
| CREATE_PREFIX = "create_prefix" | |
| WINEEXEC = "wineexec" | |
| WINETRICKS = "winetricks" | |
| EJECT_DISK = "eject_disk" | |
| SET_REGEDIT = "set_regedit" | |
| DELETE_REGISTRY_KEY = "delete_registry_key" | |
| SET_REGEDIT_FILE = "set_regedit_file" | |
| WINEKILL = "winekill" | |
| DOSEXEC = "dosexec" | |
| class LaunchConfig(BaseModel): | |
| name: str = Field(..., description="Identifier to show in the launcher dialog") | |
| command: Optional[str] = Field( | |
| None, | |
| description="Full bash command to execute, overriding default game command entirely", | |
| ) | |
| exe: str = Field( | |
| ..., description="Main game executable, to combine with the command" | |
| ) | |
| args: Optional[str] = Field(None, description="Additional command arguments") | |
| working_dir: Optional[str] = Field( | |
| None, description="Working directory to use when executing the executable" | |
| ) | |
| class Game(BaseModel): | |
| exe: Optional[str] = Field(None, description="Main game executable") | |
| main_file: Optional[str] = Field(None, description="Emulator ROM or disk file") | |
| args: Optional[str] = Field(None, description="Additional command arguments") | |
| working_dir: Optional[str] = Field( | |
| None, description="Working directory to use when executing the executable" | |
| ) | |
| launch_configs: Optional[list["InstallerScript.LaunchConfig"]] = Field( | |
| None, description="A list of mutiple related executable configurations" | |
| ) | |
| arch: Optional[str] = Field( | |
| None, description="The architecture of a Wine prefix" | |
| ) | |
| prefix: Optional[str] = Field(None, description="Path to the Wine prefix") | |
| run_without_steam: Optional[bool] = Field( | |
| None, | |
| description="Activate the DRM free mode and no not launch Steam when the game runs", | |
| ) | |
| steamless_binary: Optional[str] = Field( | |
| None, | |
| description="Path of the game executable if it's able to run without the Steam client", | |
| ) | |
| path: Optional[str] = Field(None, description="Location of the game files") | |
| class FileItem(BaseModel): | |
| url: Optional[str] = Field(None, description="File URL") | |
| filename: Optional[str] = Field( | |
| None, description="The name of the file's saved copy" | |
| ) | |
| referer: Optional[str] = Field(None, description="File referer domain") | |
| class InsertDisc(BaseModel): | |
| requires: str = Field(..., description="Required disk") | |
| class Move(BaseModel): | |
| src: str = Field(..., description="Source file ID or path") | |
| dst: str = Field(..., description="Destination path") | |
| class Merge(BaseModel): | |
| src: str = Field(..., description="Source file ID or path") | |
| dst: str = Field(..., description="Destination path") | |
| class Extract(BaseModel): | |
| file: str = Field(..., description="Source file ID or path") | |
| dst: str = Field(..., description="Destination path") | |
| format: Optional["InstallerScript.ArchiveFormatEnum"] = Field( | |
| None, description="Archive's type" | |
| ) | |
| class Execute(BaseModel): | |
| command: Optional[str] = Field(None, description="Full bash command to execute") | |
| file: Optional[str] = Field(None, description="File ID or a path to file") | |
| args: Optional[str] = Field(None, description="Executable arguments") | |
| terminal: Optional[bool] = Field( | |
| None, description="Execute in a new terminal window" | |
| ) | |
| exclude_processes: Optional[str] = Field( | |
| None, | |
| description="Space-separated list of processes to exclude from being monitored when determining if the execute phase finished", | |
| ) | |
| include_processes: Optional[str] = Field( | |
| None, | |
| description="Space-separated list of processes to monitor when determining if the execute phase finished", | |
| ) | |
| disable_runtime: Optional[bool] = Field( | |
| None, description="Run a process without Lutris runtime" | |
| ) | |
| env: Optional[dict[str, str]] = Field(None, description="Environment variables") | |
| class WriteFile(BaseModel): | |
| content: str = Field(..., description="File content to write") | |
| file: str = Field(..., description="Destination file pathpath") | |
| class WriteConfig(BaseModel): | |
| merge: Optional[bool] = Field(None, description="Truncate the file") | |
| file: str = Field(..., description="Destination file pathpath") | |
| section: Optional[str] = Field(None, description="INI section to write") | |
| key: Optional[str] = Field(None, description="Property key") | |
| value: Optional[str] = Field(None, description="Property value") | |
| data: Optional[dict[str, Any]] = Field( | |
| None, description="Free form data to write" | |
| ) | |
| class WriteJson(BaseModel): | |
| merge: Optional[bool] = Field(None, description="Update the file") | |
| file: str = Field(..., description="Destination file pathpath") | |
| data: dict[str, Any] = Field(..., description="Free form data to write") | |
| class Task(BaseModel): | |
| name: Optional["InstallerScript.TaskNameEnum"] = None | |
| prefix: Optional[str] = Field(None, description="The prefix path") | |
| arch: Optional[str] = Field(None, description="Architecture of the prefix") | |
| overrides: Optional[dict[str, str]] = Field( | |
| None, description="WINE DLL overrides" | |
| ) | |
| install_gecko: Optional[bool] = Field( | |
| None, description="Enable installing Gecko" | |
| ) | |
| install_mono: Optional[bool] = Field(None, description="Enable installing Mono") | |
| executable: Optional[str] = Field( | |
| None, description="Wine executable id or path" | |
| ) | |
| args: Optional[str] = Field(None, description="Optional command arguments") | |
| blocking: Optional[bool] = Field(None, description="Suppress threading") | |
| description: Optional[str] = Field( | |
| None, | |
| description="A message be shown to the user during the execution of the task", | |
| ) | |
| working_dir: Optional[str] = Field(None, description="Working directory") | |
| exclude_processes: Optional[str] = Field( | |
| None, | |
| description="Space-separated list of processes to exclude from being monitored when determining if the execute phase finished", | |
| ) | |
| include_processes: Optional[str] = Field( | |
| None, | |
| description="Space-separated list of processes to monitor when determining if the execute phase finished", | |
| ) | |
| env: Optional[dict[str, str]] = Field(None, description="Environment variables") | |
| app: Optional[str] = Field( | |
| None, description="Space-separated list of winetricks apps to run" | |
| ) | |
| silent: Optional[bool] = Field(None, description="Run in silent mode") | |
| key: Optional[str] = Field(None, description="Registry key") | |
| value: Optional[str] = Field(None, description="Registry value") | |
| type: Optional[str] = Field(None, description="Registry value type") | |
| filename: Optional[str] = Field(None, description="Registry file") | |
| config_file: Optional[str] = Field( | |
| None, description="File id or path to .conf file" | |
| ) | |
| exit: Optional[bool] = Field( | |
| None, description="Exit DOSBox when the executable is terminated" | |
| ) | |
| class InputMenu(BaseModel): | |
| description: str = Field(..., description="Dropdown label") | |
| id: Optional[str] = Field(None, description="$INPUT_<id> reference key") | |
| options: list[str | dict[str, Any]] = Field( | |
| ..., description="Indented list of `value: label` lines to show" | |
| ) | |
| preselect: Optional[str] = Field(None, description="Default selected value") | |
| class InstallerStep(BaseModel): | |
| insert_disc: Optional["InstallerScript.InsertDisc"] = Field( | |
| None, alias="insert-disc" | |
| ) | |
| move: Optional["InstallerScript.Move"] = None | |
| merge: Optional["InstallerScript.Merge"] = None | |
| extract: Optional["InstallerScript.Extract"] = None | |
| chmodx: Optional[str] = Field( | |
| None, description="Path to a file to Make executable" | |
| ) | |
| execute: Optional["InstallerScript.Execute"] = None | |
| write_file: Optional["InstallerScript.WriteFile"] = None | |
| write_config: Optional["InstallerScript.WriteConfig"] = None | |
| write_json: Optional["InstallerScript.WriteJson"] = None | |
| task: Optional["InstallerScript.Task"] = None | |
| input_menu: Optional["InstallerScript.InputMenu"] = None | |
| class System(BaseModel): | |
| env: Optional[dict[str, str]] = Field(None, description="Environment variables") | |
| terminal: Optional[bool] = Field( | |
| None, | |
| description="Run the game in a terminal if the game is a text based one", | |
| ) | |
| single_cpu: Optional[bool] = Field( | |
| None, description="Run the game on a single CPU core" | |
| ) | |
| pulse_latency: Optional[bool] = Field( | |
| None, description="Set PulseAudio latency to 60 msecs" | |
| ) | |
| use_us_layout: Optional[bool] = Field( | |
| None, | |
| description="Change the keyboard layout to a standard US one while the game is running", | |
| ) | |
| class WineOverride(BaseModel): | |
| pass # Additional properties allowed | |
| class WineRunner(BaseModel): | |
| version: Optional[str] = Field(None, description="WINE version override") | |
| Desktop: Optional[bool] = Field( | |
| None, description="Run the game in a Wine virtual desktop" | |
| ) | |
| WineDesktop: Optional[str] = Field( | |
| None, description="The resolution of the Wine virtual desktop" | |
| ) | |
| dxvk: Optional[bool] = Field(None, description="Enable DXVK") | |
| esync: Optional[bool] = Field(None, description="Enable ESync") | |
| overrides: Optional[list["InstallerScript.WineOverride"]] = Field( | |
| None, description="Overrides for Wine DLLs" | |
| ) | |
| class Script(BaseModel): | |
| game: Game = Field(..., description="Game configuration directives") | |
| files: Optional[list["str | InstallerScript.FileItem"]] = Field( | |
| None, description="Fetch required files" | |
| ) | |
| installer: list["InstallerScript.InstallerStep"] = Field( | |
| ..., description="Installation script" | |
| ) | |
| system: Optional["InstallerScript.System"] = Field( | |
| None, description="System configuration directives" | |
| ) | |
| wine: Optional["InstallerScript.WineRunner"] = None | |
| name: str = Field( | |
| ..., | |
| description="Name of the game, should be surrounded in quotes if containing special characters.", | |
| ) | |
| game_slug: str = Field(..., description="Game identifier on the Lutris website") | |
| version: str = Field(..., description="Name of the installer") | |
| slug: str = Field(..., description="Installer identifier") | |
| require_binaries: Optional[str] = Field( | |
| None, alias="require-binaries", description="Additional binaries" | |
| ) | |
| requires: Optional[str] = Field(None, description="Mods and add-ons") | |
| extends: Optional[str] = Field(None, description="Extensions / patches") | |
| install_complete_text: Optional[str] = Field( | |
| None, description="Custom end of install text" | |
| ) | |
| runner: RunnerEnum = Field(..., description="The runner to be used by this game") | |
| script: Script = Field(..., description="Main script") | |
| class Installer(BaseModel): | |
| id: int | |
| game_id: int | |
| game_slug: str | |
| name: str | |
| year: Optional[int] | |
| user: str | |
| runner: str | |
| slug: str | |
| version: str | |
| description: Optional[str] | |
| notes: str | |
| credits: str | |
| created_at: datetime | |
| updated_at: datetime | |
| draft: bool | |
| published: bool | |
| published_by: Optional[int] | |
| rating: str | |
| is_playable: Optional[bool] | |
| steamid: Optional[int] | |
| gogid: Optional[int] | |
| gogslug: str | |
| humbleid: str | |
| humblestoreid: str | |
| humblestoreid_real: str | |
| script: InstallerScript | |
| content: str | |
| discord_id: Optional[str] | |
| class Config: | |
| populate_by_name = True | |
| class Paginated[T](BaseModel): | |
| count: int | |
| next: Optional[str] | |
| previous: Optional[str] | |
| results: list[T] | |
| class Config: | |
| populate_by_name = True | |
| def update_progress(completed: int, total: int, prefix: str = "Progress"): | |
| """ | |
| Update progress in place using carriage return. | |
| Args: | |
| completed: Number of completed items | |
| total: Total number of items | |
| prefix: Text to show before progress | |
| """ | |
| percentage: float = (completed / total * 100) if total > 0 else 0 | |
| bar_length: int = 30 | |
| filled_length: int = int(bar_length * completed // total) | |
| bar: str = "█" * filled_length + "░" * (bar_length - filled_length) | |
| progress_text: str = f"\r{prefix}: [{bar}] {completed}/{total} ({percentage:.1f}%)" | |
| sys.stdout.write(progress_text) | |
| sys.stdout.flush() | |
| # Add newline when complete | |
| if completed == total: | |
| print() | |
| def get_page_count(url: str) -> int: | |
| """ | |
| Fetch the first page to calculate total number of pages. | |
| Returns: | |
| Total pages count | |
| """ | |
| print("Fetching first page to determine pagination info...") | |
| try: | |
| response: requests.Response = requests.get(url) | |
| response.raise_for_status() | |
| data = Paginated(**response.json()) | |
| total_count: int = data.count | |
| results_per_page: int = len(data.results) | |
| total_pages: int = ( | |
| math.ceil(total_count / results_per_page) if results_per_page > 0 else 0 | |
| ) | |
| print(f"Total count: {total_count}") | |
| print(f"Results per page: {results_per_page}") | |
| print(f"Total pages: {total_pages}") | |
| return total_pages | |
| except requests.exceptions.JSONDecodeError as e: | |
| print(f"Error parsing JSON: {e}") | |
| raise | |
| except requests.exceptions.RequestException as e: | |
| print(f"Error fetching page info: {e}") | |
| raise | |
| def fetch_single_page[T]( | |
| _: T, url: str, page_num: int, session: requests.Session | |
| ) -> tuple[int, list[T]]: | |
| """ | |
| Fetch a single page of results. | |
| Args: | |
| page_num: Page number to fetch (1-based) | |
| session: Requests session to use | |
| Returns: | |
| Tuple of (page_number, results_list) | |
| """ | |
| url = url + f"?page={page_num}" | |
| try: | |
| response: requests.Response = session.get(url, timeout=30) | |
| response.raise_for_status() | |
| data = Paginated[T].model_validate(response.json()) | |
| results: list[T] = data.results | |
| # print(f"Page {page_num}: Fetched {len(results)} results") | |
| return page_num, results | |
| except Exception as e: | |
| print(f"\nError fetching page {page_num}: {e}") | |
| return page_num, [] | |
| def fetch_pages_parallel[T]( | |
| _: T, url: str, total_pages: int, max_workers: int = 10 | |
| ) -> list[T]: | |
| """ | |
| Fetch all pages in parallel using ThreadPoolExecutor. | |
| Args: | |
| total_pages: Total number of pages to fetch | |
| max_workers: Maximum number of concurrent workers | |
| Returns: | |
| List of all results from all pages | |
| """ | |
| all_results: list[T] = [] | |
| completed_pages: int = 0 | |
| print( | |
| f"Starting parallel fetch of {total_pages} pages with {max_workers} workers..." | |
| ) | |
| # Create a session for connection pooling | |
| session = requests.Session() | |
| try: | |
| with ThreadPoolExecutor(max_workers=max_workers) as executor: | |
| # Submit all page fetch tasks | |
| future_to_page = { | |
| executor.submit(fetch_single_page, T, url, page_num, session): page_num | |
| for page_num in range(1, total_pages + 1) | |
| } | |
| # Collect results as they complete | |
| page_results: dict[int, list[T]] = {} | |
| for future in as_completed(future_to_page): | |
| page_num: int = future_to_page[future] | |
| try: | |
| page_num_result, results = future.result() | |
| page_results[page_num_result] = cast(list[T], results) | |
| completed_pages += 1 | |
| # print( | |
| # f"Progress: {completed_pages}/{total_pages} pages completed " | |
| # f"({completed_pages/total_pages*100:.1f}%)" | |
| # ) | |
| update_progress(completed_pages, total_pages, "Fetching pages") | |
| except Exception as e: | |
| print(f"\nPage {page_num} generated an exception: {e}") | |
| page_results[page_num] = [] | |
| completed_pages += 1 | |
| update_progress(completed_pages, total_pages, "Fetching pages") | |
| # Combine results in correct page order | |
| for page_num in sorted(page_results.keys()): | |
| all_results.extend(page_results[page_num]) | |
| finally: | |
| session.close() | |
| print(f"Parallel fetch complete! Total results collected: {len(all_results)}") | |
| return all_results | |
| def fetch_lutris_data_parallel[T]( | |
| _: type[T], url: str, max_workers: int = 10 | |
| ) -> Paginated[T]: | |
| """ | |
| Fetch all data from the Lutris API using parallel requests. | |
| Args: | |
| use_async: Whether to use async/await or ThreadPoolExecutor | |
| max_workers: Maximum number of concurrent workers/requests | |
| Returns: | |
| Dict containing all data | |
| """ | |
| try: | |
| # First, get pagination info | |
| total_pages: int = get_page_count(url) | |
| if total_pages == 0: | |
| print("No pages to fetch!") | |
| return Paginated[T]( | |
| count=0, | |
| next=None, | |
| previous=None, | |
| results=[], | |
| ) | |
| # Fetch all pages in parallel | |
| start_time: float = time.time() | |
| # Use ThreadPoolExecutor approach | |
| all_results = cast( | |
| list[T], fetch_pages_parallel(T, url, total_pages, max_workers) | |
| ) | |
| end_time: float = time.time() | |
| print(f"Total fetch time: {end_time - start_time:.2f} seconds") | |
| print( | |
| f"Average time per page: {(end_time - start_time) / total_pages:.2f} seconds" | |
| ) | |
| # Create final data structure | |
| final = Paginated[T]( | |
| count=len(all_results), | |
| next=None, | |
| previous=None, | |
| results=all_results, | |
| ) | |
| return final | |
| except Exception as e: | |
| print(f"Error in parallel fetch: {e}") | |
| raise | |
| def save_to_json[T](data: Paginated[T], filename: str = "lutris_data.json") -> None: | |
| """ | |
| Save the data to a JSON file. | |
| Args: | |
| data: The data to save | |
| filename: Output filename | |
| """ | |
| try: | |
| with open(filename, "w", encoding="utf-8") as f: | |
| f.write(data.model_dump_json(indent=2)) | |
| print(f"Data successfully saved to {filename}") | |
| # Print some statistics | |
| print(f"File contains {data.count} results") | |
| except IOError as e: | |
| print(f"Error saving to file: {e}") | |
| def main(): | |
| """Main function to orchestrate the parallel fetching and saving process.""" | |
| try: | |
| # Fetch all games data in parallel | |
| print("Fetching Games...") | |
| games = fetch_lutris_data_parallel(Game, LUTRIS_API_GAMES) | |
| save_to_json(games, "lutris_games.json") | |
| # Fetch all installers data in parallel | |
| print("Fetching Installers...") | |
| installers = fetch_lutris_data_parallel(Installer, LUTRIS_API_INSTALLERS) | |
| save_to_json(installers, "lutris_installers.json") | |
| except KeyboardInterrupt: | |
| print("\nOperation interrupted by user") | |
| except Exception as e: | |
| print(f"An unexpected error occurred: {e}") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment