feat(deck): add SteamGridDB batch art scripts and skill
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.
This commit is contained in:
@@ -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 | `<appid>.png` | 460x215, 920x430 |
|
||||||
|
| Portrait / poster | `<appid>p.png` | 600x900, 342x482, 660x930 |
|
||||||
|
| Hero / background | `<appid>_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`
|
||||||
@@ -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"
|
||||||
@@ -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 → <appid>_hero.{png,jpg} (1920x620)
|
||||||
|
# wide = landscape capsule → <appid>.{png,jpg} (460x215 / 920x430)
|
||||||
|
# poster = portrait capsule → <appid>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}')
|
||||||
Reference in New Issue
Block a user