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,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