From 91d67241754fbed5d3517f2d387862ff3fd58114 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Sun, 7 Jun 2026 18:17:45 -0500 Subject: [PATCH] feat(deck): add SteamGridDB batch art scripts and skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/deck-sgdb-art.py — runs on Deck; fetches hero, wide capsule, and portrait/poster art from SGDB for all RomM-synced shortcuts. scripts/deck-sgdb-art.ps1 — Windows wrapper; handles SSH ASKPASS upload and execution. .claude/skills/deck-sgdb/ — Claude skill documenting invocation, file naming conventions, appid formula, and known gotchas. --- .claude/skills/deck-sgdb/SKILL.md | 67 +++++++++++ scripts/deck-sgdb-art.ps1 | 53 +++++++++ scripts/deck-sgdb-art.py | 182 ++++++++++++++++++++++++++++++ 3 files changed, 302 insertions(+) create mode 100644 .claude/skills/deck-sgdb/SKILL.md create mode 100644 scripts/deck-sgdb-art.ps1 create mode 100644 scripts/deck-sgdb-art.py diff --git a/.claude/skills/deck-sgdb/SKILL.md b/.claude/skills/deck-sgdb/SKILL.md new file mode 100644 index 0000000..46931a8 --- /dev/null +++ b/.claude/skills/deck-sgdb/SKILL.md @@ -0,0 +1,67 @@ +--- +name: deck-sgdb +description: Batch-download SteamGridDB artwork (hero, wide capsule, portrait/poster) for RomM-synced shortcuts on the Steam Deck. Use when the user asks to update, download, or clean up Steam library artwork for their ROM games. +--- + +# deck-sgdb + +Batch artwork tool for the Steam Deck's RomM-synced game library. Fetches hero, wide capsule, and portrait (poster) images from SteamGridDB for every shortcut added by decky-romm-sync. + +## Scripts + +| File | Where it runs | Purpose | +|---|---|---| +| `scripts/deck-sgdb-art.py` | On the Deck | Core logic — parse shortcuts.vdf, search SGDB, download images | +| `scripts/deck-sgdb-art.ps1` | Windows (this machine) | Upload + run wrapper; handles SSH ASKPASS | + +## Invocation + +```powershell +# Full pass — all three art types for anything missing +.\scripts\deck-sgdb-art.ps1 + +# Specific types only +.\scripts\deck-sgdb-art.ps1 --types hero,wide +.\scripts\deck-sgdb-art.ps1 --types poster + +# Preview without downloading +.\scripts\deck-sgdb-art.ps1 --dry-run + +# Remove portrait + wide art for ROM hacks whose names still have file +# extensions (.gba, .nds, etc.) — these got fuzzy fallback matches and the +# art is more confusing than helpful +.\scripts\deck-sgdb-art.ps1 --clean +``` + +## Prerequisites + +- **sshd on the Deck**: `sudo systemctl start sshd` in Desktop Mode (off by default on SteamOS) +- **SSH password**: in Obsidian vault → `Homelab/ROM Library.md` → Access (SSH) section +- **SGDB API key**: `25431ca935008934cae436d78e9d451c` (hardcoded in script; also in vault) +- **Deck connection**: Tailscale IP `100.96.86.40` (default) or LAN `192.168.1.132` + +## What the script does + +1. Parses `~/.local/share/Steam/userdata/43872485/config/shortcuts.vdf` on the Deck +2. Filters to shortcuts whose `Exe` path contains `rom-launcher` (all decky-romm-sync games) +3. For each game missing the requested art type, searches SGDB by game name +4. Downloads the first result and saves to the Steam grid directory + +## Steam grid file naming + +| Type | Filename | SGDB dimensions | +|---|---|---| +| Wide capsule | `.png` | 460x215, 920x430 | +| Portrait / poster | `p.png` | 600x900, 342x482, 660x930 | +| Hero / background | `_hero.png` | 1920x620 | + +`appid` is derived from the VDF raw value: `(raw_appid & 0xffffffff) | 0x80000000` + +## Known gotchas + +- **Cloudflare blocks urllib's default UA** — the script uses a Chrome UA for all SGDB requests; don't remove it +- **ROM hack names get fuzzy matches** — hacks with no SGDB entry fall back to the closest game (e.g. "Pokemon Emerald Azure" → Pokémon Emerald). Run `--clean` after a bulk pass to strip art from games whose names still contain a file extension, which is the reliable signal that igir didn't find a clean title match +- **`decky-steamgriddb` plugin is frontend-only** — the installed Decky plugin has no batch mode; it's UI-driven per game. The Python script here is the only way to bulk-update +- **vdf module** lives at `~/homebrew/plugins/decky-steamgriddb/py_modules` — the script loads it from there; no install needed +- **sshd is off by default** on SteamOS — must be started manually each session unless the user has added it to a startup script +- **Steam user ID** for shortcuts and grid is `43872485`. The `22396545` userdata dir also exists but has no `shortcuts.vdf` diff --git a/scripts/deck-sgdb-art.ps1 b/scripts/deck-sgdb-art.ps1 new file mode 100644 index 0000000..f7b20af --- /dev/null +++ b/scripts/deck-sgdb-art.ps1 @@ -0,0 +1,53 @@ +#!/usr/bin/env pwsh +# scripts/deck-sgdb-art.ps1 +# +# Upload deck-sgdb-art.py to the Steam Deck and run it. +# Handles the Windows SSH ASKPASS dance (no sshpass/plink on Windows). +# +# Prerequisites: +# * Steam Deck sshd running: ssh into Deck → sudo systemctl start sshd +# * Password for deck user in Obsidian vault (Homelab/ROM Library.md, Access section) +# * Tailscale connected (uses 100.96.86.40 by default; swap for 192.168.1.132 on LAN) +# +# Usage: +# .\scripts\deck-sgdb-art.ps1 # fetch all types (hero, wide, poster) +# .\scripts\deck-sgdb-art.ps1 --types hero,wide # hero + wide only +# .\scripts\deck-sgdb-art.ps1 --types poster # portrait capsules only +# .\scripts\deck-sgdb-art.ps1 --clean # remove art for unmatched ROM hacks +# .\scripts\deck-sgdb-art.ps1 --dry-run # preview without downloading +# +# All extra arguments are forwarded verbatim to deck-sgdb-art.py. + +param( + [string]$DeckHost = '100.96.86.40', + [string]$DeckUser = 'deck', + [string]$DeckPass = 'd0fet0th3x', + [Parameter(ValueFromRemainingArguments)] + [string[]]$ScriptArgs +) + +$ErrorActionPreference = 'Stop' + +# --- ASKPASS setup (required on Windows — no sshpass available) --------------- +$askpass = "$env:TEMP\deck-askpass.cmd" +Set-Content -Path $askpass -Value "@echo $DeckPass" -Encoding ASCII +$env:SSH_ASKPASS = $askpass +$env:SSH_ASKPASS_REQUIRE = 'force' + +$sshOpts = '-o StrictHostKeyChecking=no -o ConnectTimeout=10 -o ServerAliveInterval=30 -o ServerAliveCountMax=20' + +# --- Upload the Python script ------------------------------------------------- +$scriptSrc = Join-Path $PSScriptRoot 'deck-sgdb-art.py' +if (-not (Test-Path $scriptSrc)) { + Write-Error "deck-sgdb-art.py not found at $scriptSrc" + exit 1 +} + +Write-Host "Uploading deck-sgdb-art.py to ${DeckUser}@${DeckHost}..." +Get-Content $scriptSrc -Raw | ssh $sshOpts.Split() "$DeckUser@$DeckHost" "cat > /tmp/deck-sgdb-art.py && echo 'uploaded OK'" + +# --- Run it ------------------------------------------------------------------- +$argsStr = if ($ScriptArgs) { $ScriptArgs -join ' ' } else { '' } +Write-Host "Running: python3 /tmp/deck-sgdb-art.py $argsStr`n" + +ssh $sshOpts.Split() "$DeckUser@$DeckHost" "python3 /tmp/deck-sgdb-art.py $argsStr" diff --git a/scripts/deck-sgdb-art.py b/scripts/deck-sgdb-art.py new file mode 100644 index 0000000..7ef5cbd --- /dev/null +++ b/scripts/deck-sgdb-art.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# scripts/deck-sgdb-art.py +# +# Batch-download SteamGridDB artwork for RomM-synced shortcuts on the Steam Deck. +# Designed to run ON the Deck (not from Windows) — upload via deck-sgdb-art.ps1. +# +# Usage (on Deck): +# python3 deck-sgdb-art.py [--types hero,wide,poster] [--clean] [--dry-run] +# +# --types Comma-separated list of art types to fetch (default: hero,wide,poster) +# hero = background banner → _hero.{png,jpg} (1920x620) +# wide = landscape capsule → .{png,jpg} (460x215 / 920x430) +# poster = portrait capsule → p.{png,jpg} (600x900) +# --clean Remove art for games where no exact SGDB match was found (first-result +# fallbacks can be misleading for ROM hacks). Safe to run after a bulk pass. +# --dry-run Print what would happen without downloading or deleting anything. +# +# Gotchas: +# * Cloudflare blocks Python's default urllib UA — browser UA is required. +# * appid formula: (raw_appid & 0xffffffff) | 0x80000000 +# * vdf module lives at ~/homebrew/plugins/decky-steamgriddb/py_modules +# * All RomM shortcuts use 'rom-launcher' in the Exe path. +# * Steam user for shortcuts/grid: 43872485 (check userdata/ if this ever changes) +# * SGDB_KEY env var or hardcoded below. + +import sys, os, time, json, re +sys.path.insert(0, os.path.expanduser('~/homebrew/plugins/decky-steamgriddb/py_modules')) +from vdf import binary_load +from urllib.request import Request, urlopen +from urllib.parse import quote +from urllib.error import HTTPError, URLError +from pathlib import Path + +SGDB_KEY = os.environ.get('SGDB_KEY', '25431ca935008934cae436d78e9d451c') +STEAM_USER = '43872485' +GRID_DIR = Path(f'/home/deck/.local/share/Steam/userdata/{STEAM_USER}/config/grid') +VDF_PATH = Path(f'/home/deck/.local/share/Steam/userdata/{STEAM_USER}/config/shortcuts.vdf') + +BROWSER_UA = ('Mozilla/5.0 (X11; Linux x86_64) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/120.0.0.0 Safari/537.36') + +# --- arg parsing ----------------------------------------------------------- +args = sys.argv[1:] +DRY_RUN = '--dry-run' in args +DO_CLEAN = '--clean' in args +types_arg = next((a.split('=', 1)[1] for a in args if a.startswith('--types=')), + next((args[args.index('--types') + 1] for i, a in enumerate(args) + if a == '--types' and i + 1 < len(args)), None) if '--types' in args else None) +TYPES = set((types_arg or 'hero,wide,poster').split(',')) + +# --- helpers --------------------------------------------------------------- +def sgdb_get(path): + req = Request(f'https://www.steamgriddb.com/api/v2{path}', + headers={'Authorization': f'Bearer {SGDB_KEY}', 'User-Agent': BROWSER_UA}) + try: + r = urlopen(req, timeout=15) + return json.loads(r.read()) + except HTTPError as e: + return {'success': False, 'error': f'HTTP {e.code}'} + except URLError as e: + return {'success': False, 'error': str(e)} + +def download(url, out_path): + req = Request(url, headers={'User-Agent': BROWSER_UA}) + try: + data = urlopen(req, timeout=30).read() + if not DRY_RUN: + with open(out_path, 'wb') as f: + f.write(data) + return True + except Exception as e: + print(f' download error: {e}') + return False + +def clean_name(name): + name = re.sub(r'\.[a-zA-Z0-9]{2,4}$', '', name) # strip file extension + name = re.sub(r'\s*[\(\[].+?[\)\]]', '', name) # strip (USA), [!], (Hack), etc. + return name.strip() + +def existing(appid, suffix, exts=('png', 'jpg')): + return next((GRID_DIR / f'{appid}{suffix}.{e}' for e in exts + if (GRID_DIR / f'{appid}{suffix}.{e}').exists()), None) + +def remove_if_exists(appid, suffix): + for ext in ('png', 'jpg'): + p = GRID_DIR / f'{appid}{suffix}.{ext}' + if p.exists(): + if not DRY_RUN: + p.unlink() + print(f' removed: {p.name}') + return True + return False + +# --- load shortcuts -------------------------------------------------------- +d = binary_load(open(VDF_PATH, 'rb')) +romm = [s for s in d['shortcuts'].values() + if 'rom-launcher' in s.get('Exe', '') or 'romm' in s.get('Exe', '').lower()] +print(f'RomM shortcuts: {len(romm)} | types: {", ".join(sorted(TYPES))} | dry_run={DRY_RUN}') + +# art type config: (flag_name, grid_path_or_None, file_suffix, dimensions_param) +ART_TYPES = { + 'hero': ('/heroes/game/{id}', '_hero', None), + 'wide': ('/grids/game/{id}?dimensions=460x215,920x430', '', None), + 'poster': ('/grids/game/{id}?dimensions=600x900,342x482,660x930', 'p', None), +} + +done = 0; skipped = 0; not_found = 0; errors = 0 + +for i, shortcut in enumerate(romm): + appid = (shortcut['appid'] & 0xffffffff) | 0x80000000 + name = shortcut.get('appname', '') + + need = {t for t in TYPES if not existing(appid, ART_TYPES[t][1])} + + if DO_CLEAN: + # In clean mode, remove art for games whose names look like unmatched ROM hacks + # (file extension still in name = igir didn't find a clean title) + if re.search(r'\.[a-zA-Z0-9]{2,4}$', name): + removed = 0 + for t in TYPES: + if remove_if_exists(appid, ART_TYPES[t][1]): + removed += 1 + if removed: + print(f'[clean] {name} — removed {removed} file(s)') + skipped += 1 + continue + + if not need: + skipped += 1 + continue + + search_name = clean_name(name) + label = f'[{i+1}/{len(romm)}] {name}' + if search_name != name: + label += f" -> '{search_name}'" + print(label) + sys.stdout.flush() + + if DRY_RUN: + print(f' would fetch: {", ".join(sorted(need))}') + done += 1 + continue + + try: + search = sgdb_get(f'/search/autocomplete/{quote(search_name)}') + if not search.get('success') or not search.get('data'): + print(f' NOT FOUND ({search.get("error", "no data")})') + not_found += 1 + time.sleep(0.2) + continue + + game = search['data'][0] + game_id = game['id'] + print(f' matched: {game["name"]} (#{game_id})') + sys.stdout.flush() + + for art_type in sorted(need): + endpoint, suffix, _ = ART_TYPES[art_type] + resp = sgdb_get(endpoint.format(id=game_id)) + if resp.get('success') and resp.get('data'): + url = resp['data'][0]['url'] + ext = url.rsplit('.', 1)[-1].lower().split('?')[0] + out = GRID_DIR / f'{appid}{suffix}.{ext}' + if download(url, out): + print(f' {art_type}: {out.name}') + else: + print(f' {art_type}: download failed') + else: + print(f' {art_type}: no results on SGDB') + sys.stdout.flush() + + done += 1 + time.sleep(0.35) + + except Exception as e: + print(f' ERROR: {e}') + sys.stdout.flush() + errors += 1 + time.sleep(1) + +print(f'\nDone={done} Skipped={skipped} NotFound={not_found} Errors={errors}')