mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 06:00:33 +02:00
Library artwork backfill: devkit titles in refresh-art; missing art offered and re-applied
- 'Refresh artwork' (settings) and the API's refresh-art --all cover devkit titles as well as Android apps; frame_titles.py gains refresh-art ID|--all. - Apps and titles without complete Steam artwork are flagged (art_missing): the app shows 'Add artwork', and the CLIs' list prints the refresh command. - When the app lists them and Steam answers, Frame Control re-applies their art in the background (at most every five minutes), e.g. for a title Steam registered after an install made while it wasn't running. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
f0db805d4b
commit
fde2f9620a
5 files changed
+94
-10
No files matched your search
@@ -81,12 +81,37 @@ class EntryPoints(unittest.TestCase):
|
|||||||
self.assertEqual(result['kind'], 'apk')
|
self.assertEqual(result['kind'], 'apk')
|
||||||
self.assert_art()
|
self.assert_art()
|
||||||
|
|
||||||
def test_refresh_api(self):
|
def test_refresh_api_covers_android_apps_and_titles(self):
|
||||||
with patch.object(server, 'ensure_master'), \
|
with patch.object(server, 'ensure_master'), \
|
||||||
patch.object(server, 'start_job', side_effect=lambda label, work: work()), \
|
patch.object(server, 'start_job', side_effect=lambda label, work: work()), \
|
||||||
patch.object(android, 'refresh_art', return_value=[]) as refresh:
|
patch.object(android, 'refresh_art', return_value=[]) as refresh, \
|
||||||
self.assertEqual(server.android({'action':'refresh-art', 'all':True}), {'apps':[]})
|
patch.object(titles, 'refresh_art', return_value=[{'id':'G','error':'x'}]) as title_refresh:
|
||||||
refresh.assert_called_once_with(None)
|
self.assertEqual(server.android({'action':'refresh-art', 'all':True}),
|
||||||
|
{'apps':[], 'titles':[{'id':'G','error':'x'}]})
|
||||||
|
refresh.assert_called_once_with(); title_refresh.assert_called_once_with()
|
||||||
|
server.titles({'action':'refresh-art', 'id':'G'})
|
||||||
|
title_refresh.assert_called_with('G')
|
||||||
|
|
||||||
|
def test_backfill_applies_missing_art_once_steam_answers(self):
|
||||||
|
import threading
|
||||||
|
ran = threading.Event()
|
||||||
|
with patch.dict(server._backfill, {'running': False, 'last': 0.0}), \
|
||||||
|
patch.object(android, 'refresh_art', side_effect=[RuntimeError('odd'), None]) as refresh, \
|
||||||
|
patch.object(titles, 'refresh_art', side_effect=lambda gid: ran.set()) as title_refresh:
|
||||||
|
apps = [{'package':'org.a.x','art_missing':True}, {'package':'org.b.x','art_missing':True},
|
||||||
|
{'package':'org.c.x','art_missing':False}]
|
||||||
|
with contextlib.redirect_stderr(io.StringIO()):
|
||||||
|
self.assertTrue(server.backfill_art(apps=apps, titles=[{'id':'G','art_missing':True}]))
|
||||||
|
self.assertTrue(ran.wait(5))
|
||||||
|
self.assertFalse(server.backfill_art(apps=apps)) # throttled
|
||||||
|
self.assertEqual([c.args for c in refresh.call_args_list], [('org.a.x',), ('org.b.x',)])
|
||||||
|
title_refresh.assert_called_once_with('G')
|
||||||
|
self.assertFalse(server.backfill_art(apps=[{'package':'org.c.x','art_missing':False}]))
|
||||||
|
|
||||||
|
def test_art_missing_flags(self):
|
||||||
|
self.assertTrue(android.art_missing({'artwork': {}}))
|
||||||
|
self.assertTrue(android.art_missing({'artwork': {'grid': 'x'}}))
|
||||||
|
self.assertFalse(android.art_missing({'artwork': {s: 'x' for s in artwork.SLOTS}}))
|
||||||
|
|
||||||
def test_source_search_shared_installer_contract(self):
|
def test_source_search_shared_installer_contract(self):
|
||||||
# Source workers hand their download and optional images to this public seam.
|
# Source workers hand their download and optional images to this public seam.
|
||||||
|
|||||||
@@ -377,6 +377,7 @@ def list_apps():
|
|||||||
continue
|
continue
|
||||||
if m:
|
if m:
|
||||||
m['running'] = f"lepton-steamlaunch-{m['instance']}" in running
|
m['running'] = f"lepton-steamlaunch-{m['instance']}" in running
|
||||||
|
m['art_missing'] = art_missing(m)
|
||||||
apps.append(m)
|
apps.append(m)
|
||||||
return sorted(apps, key=lambda m: m['label'].lower())
|
return sorted(apps, key=lambda m: m['label'].lower())
|
||||||
|
|
||||||
@@ -537,6 +538,8 @@ def main():
|
|||||||
r = (backup_data if cmd == 'backup-data' else restore_data)(*args)
|
r = (backup_data if cmd == 'backup-data' else restore_data)(*args)
|
||||||
elif cmd == 'list':
|
elif cmd == 'list':
|
||||||
r = list_apps()
|
r = list_apps()
|
||||||
|
if any(a['art_missing'] for a in r):
|
||||||
|
print('Some apps have no Steam artwork: python3 ui/frame_android.py refresh-art --all', file=sys.stderr)
|
||||||
elif cmd in ('launch', 'stop', 'probe'):
|
elif cmd in ('launch', 'stop', 'probe'):
|
||||||
r = globals()[cmd](args[0])
|
r = globals()[cmd](args[0])
|
||||||
elif cmd == 'remove':
|
elif cmd == 'remove':
|
||||||
|
|||||||
+8
-1
@@ -852,7 +852,7 @@ def list_titles():
|
|||||||
'runtime': alias, 'runtime_label': RUNTIMES.get(alias, {}).get('label', alias or 'not set'),
|
'runtime': alias, 'runtime_label': RUNTIMES.get(alias, {}).get('label', alias or 'not set'),
|
||||||
'source': str(meta.get('source') or ''), 'size': meta.get('size'),
|
'source': str(meta.get('source') or ''), 'size': meta.get('size'),
|
||||||
'installed': meta.get('installed'), 'registered': t.get('settings') is not None,
|
'installed': meta.get('installed'), 'registered': t.get('settings') is not None,
|
||||||
'frame_control': bool(meta)})
|
'frame_control': bool(meta), 'art_missing': bool(meta) and frame_android.art_missing(meta)})
|
||||||
return titles
|
return titles
|
||||||
|
|
||||||
|
|
||||||
@@ -933,8 +933,15 @@ def main():
|
|||||||
progress=lambda text, _: print(text + '…', file=sys.stderr))
|
progress=lambda text, _: print(text + '…', file=sys.stderr))
|
||||||
elif cmd == 'list':
|
elif cmd == 'list':
|
||||||
r = list_titles()
|
r = list_titles()
|
||||||
|
if any(t['art_missing'] for t in r):
|
||||||
|
print('Some titles have no Steam artwork: python3 ui/frame_titles.py refresh-art --all', file=sys.stderr)
|
||||||
elif cmd in ('launch', 'remove') and args:
|
elif cmd in ('launch', 'remove') and args:
|
||||||
r = globals()[cmd](args[0])
|
r = globals()[cmd](args[0])
|
||||||
|
elif cmd == 'refresh-art' and args:
|
||||||
|
r = refresh_art(None if args[0] == '--all' else args[0])
|
||||||
|
if isinstance(r, list) and any('error' in t for t in r):
|
||||||
|
print(json.dumps(r, indent=1))
|
||||||
|
raise SystemExit(1)
|
||||||
else:
|
else:
|
||||||
sys.exit(__doc__)
|
sys.exit(__doc__)
|
||||||
except FrameError as e:
|
except FrameError as e:
|
||||||
|
|||||||
+8
-1
@@ -795,7 +795,7 @@
|
|||||||
<a href="https://www.steamgriddb.com/profile/preferences/api" target="_blank" rel="noreferrer">Get a free key</a>
|
<a href="https://www.steamgriddb.com/profile/preferences/api" target="_blank" rel="noreferrer">Get a free key</a>
|
||||||
</div>
|
</div>
|
||||||
<div id="steamGridStatus" class="hint" role="status"></div>
|
<div id="steamGridStatus" class="hint" role="status"></div>
|
||||||
<button id="refreshAndroidArt" class="small" style="margin-top:12px">Refresh installed Android artwork</button>
|
<button id="refreshAndroidArt" class="small" style="margin-top:12px">Refresh artwork for installed apps and titles</button>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel" id="power">
|
<section class="panel" id="power">
|
||||||
<div class="shelf-head"><h2>Remote & power</h2></div>
|
<div class="shelf-head"><h2>Remote & power</h2></div>
|
||||||
@@ -1801,6 +1801,7 @@ async function loadTitles() {
|
|||||||
<div class="item">
|
<div class="item">
|
||||||
<div class="grow"><div class="t">${esc(t.id)}</div>
|
<div class="grow"><div class="t">${esc(t.id)}</div>
|
||||||
<div class="s" title="${esc(t.target)}">${esc(t.runtime_label)}${t.target ? " · " + esc(t.target) : ""}</div></div>
|
<div class="s" title="${esc(t.target)}">${esc(t.runtime_label)}${t.target ? " · " + esc(t.target) : ""}</div></div>
|
||||||
|
${t.art_missing ? `<button class="small" data-title="refresh-art" data-id="${esc(t.id)}" title="Its Steam library tile has no artwork yet">Add artwork</button>` : ""}
|
||||||
<button class="play small" data-title="launch" data-id="${esc(t.id)}" title="Launch in the headset">▶</button>
|
<button class="play small" data-title="launch" data-id="${esc(t.id)}" title="Launch in the headset">▶</button>
|
||||||
<button class="small danger" data-title="remove" data-id="${esc(t.id)}">Remove</button>
|
<button class="small danger" data-title="remove" data-id="${esc(t.id)}">Remove</button>
|
||||||
</div>`).join("") : `<div class="sub">None yet.</div>`;
|
</div>`).join("") : `<div class="sub">None yet.</div>`;
|
||||||
@@ -1812,6 +1813,9 @@ $("titleList").onclick = async e => {
|
|||||||
if (!confirm(`Remove ${id} and its files from the Frame?`)) return;
|
if (!confirm(`Remove ${id} and its files from the Frame?`)) return;
|
||||||
await act(`Remove ${id}`, () => api("/api/titles", { action: "remove", id }), b);
|
await act(`Remove ${id}`, () => api("/api/titles", { action: "remove", id }), b);
|
||||||
loadTitles();
|
loadTitles();
|
||||||
|
} else if (b.dataset.title === "refresh-art") {
|
||||||
|
await runJob(`Add Steam artwork for ${id}`, "art-" + id, () => api("/api/titles", { action: "refresh-art", id }));
|
||||||
|
loadTitles();
|
||||||
} else await act(`Launch ${id}`, () => api("/api/titles", { action: "launch", id }), b);
|
} else await act(`Launch ${id}`, () => api("/api/titles", { action: "launch", id }), b);
|
||||||
};
|
};
|
||||||
loadTitles();
|
loadTitles();
|
||||||
@@ -2133,6 +2137,7 @@ async function loadAndroid() {
|
|||||||
<div class="row and-btns">
|
<div class="row and-btns">
|
||||||
${a.running ? `<button class="small" data-and="stop" data-pkg="${esc(a.package)}">Stop</button>`
|
${a.running ? `<button class="small" data-and="stop" data-pkg="${esc(a.package)}">Stop</button>`
|
||||||
: `<button class="play small" data-and="launch" data-pkg="${esc(a.package)}">▶</button>`}
|
: `<button class="play small" data-and="launch" data-pkg="${esc(a.package)}">▶</button>`}
|
||||||
|
${a.art_missing ? `<button class="small" data-and="refresh-art" data-pkg="${esc(a.package)}" data-name="${esc(a.label)}" title="Its Steam library tile has no artwork yet">Add artwork</button>` : ""}
|
||||||
<button class="small" data-and="probe" data-pkg="${esc(a.package)}" title="Launch it and record whether it stays up">Test</button>
|
<button class="small" data-and="probe" data-pkg="${esc(a.package)}" title="Launch it and record whether it stays up">Test</button>
|
||||||
<button class="small" data-and="rate" data-pkg="${esc(a.package)}" data-ver="${esc(a.version)}" data-name="${esc(a.label)}" data-src="${esc(a.source || "")}" title="Report how it worked">Report</button>
|
<button class="small" data-and="rate" data-pkg="${esc(a.package)}" data-ver="${esc(a.version)}" data-name="${esc(a.label)}" data-src="${esc(a.source || "")}" title="Report how it worked">Report</button>
|
||||||
<button class="small danger" data-and="remove" data-pkg="${esc(a.package)}" data-name="${esc(a.label)}">Remove</button>
|
<button class="small danger" data-and="remove" data-pkg="${esc(a.package)}" data-name="${esc(a.label)}">Remove</button>
|
||||||
@@ -2215,6 +2220,8 @@ document.body.addEventListener("click", async e => {
|
|||||||
} else if (action === "remove") {
|
} else if (action === "remove") {
|
||||||
if (!confirm(`Remove ${b.dataset.name} and its data from the Frame?`)) return;
|
if (!confirm(`Remove ${b.dataset.name} and its data from the Frame?`)) return;
|
||||||
await act(`Remove ${b.dataset.name}`, () => api("/api/android", { action, package: pkg }), b);
|
await act(`Remove ${b.dataset.name}`, () => api("/api/android", { action, package: pkg }), b);
|
||||||
|
} else if (action === "refresh-art") {
|
||||||
|
await runJob(`Add Steam artwork for ${b.dataset.name}`, "art-" + pkg, () => api("/api/android", { action, package: pkg }));
|
||||||
} else if (action === "rate") {
|
} else if (action === "rate") {
|
||||||
openReport({ package: pkg, version: b.dataset.ver, label: b.dataset.name, source: b.dataset.src });
|
openReport({ package: pkg, version: b.dataset.ver, label: b.dataset.name, source: b.dataset.src });
|
||||||
return;
|
return;
|
||||||
|
|||||||
+46
-4
@@ -148,6 +148,39 @@ _jobs_lock = threading.Lock()
|
|||||||
_jobs = {} # id -> {"label", "done", "error", "message", "result", "time"}
|
_jobs = {} # id -> {"label", "done", "error", "message", "result", "time"}
|
||||||
|
|
||||||
|
|
||||||
|
_backfill = {"running": False, "last": 0.0}
|
||||||
|
_backfill_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_art(apps=(), titles=()):
|
||||||
|
"""Give installs that lack Steam artwork (Steam wasn't running, say) their art once Steam is up.
|
||||||
|
|
||||||
|
Runs in the background, at most once every five minutes; refresh-art reports failures on demand."""
|
||||||
|
pkgs = [a["package"] for a in apps if a.get("art_missing")]
|
||||||
|
gids = [t["id"] for t in titles if t.get("art_missing")]
|
||||||
|
with _backfill_lock:
|
||||||
|
if not (pkgs or gids) or _backfill["running"] or time.time() - _backfill["last"] < 300:
|
||||||
|
return False
|
||||||
|
_backfill.update(running=True, last=time.time())
|
||||||
|
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
frame_android.shortcut_tool("list") # Steam isn't up: try again on a later listing
|
||||||
|
for refresh, key in [(frame_android.refresh_art, p) for p in pkgs] + \
|
||||||
|
[(frame_titles.refresh_art, g) for g in gids]:
|
||||||
|
try:
|
||||||
|
refresh(key)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"artwork backfill for {key}: {e}", file=sys.stderr)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
with _backfill_lock:
|
||||||
|
_backfill["running"] = False
|
||||||
|
threading.Thread(target=run, daemon=True).start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def start_job(label, work, progress=False):
|
def start_job(label, work, progress=False):
|
||||||
"""Run work() in the background. It returns a dict with a "message"."""
|
"""Run work() in the background. It returns a dict with a "message"."""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -599,8 +632,11 @@ def android(body):
|
|||||||
if action == "refresh-art":
|
if action == "refresh-art":
|
||||||
if not pkg and not body.get("all"):
|
if not pkg and not body.get("all"):
|
||||||
raise Failure('choose a package or all apps', 400)
|
raise Failure('choose a package or all apps', 400)
|
||||||
|
if not body.get('all'):
|
||||||
|
return start_job('Refresh Steam artwork', lambda: {'apps': [frame_android.refresh_art(pkg)]})
|
||||||
|
# Everything Frame Control sideloaded: Android apps and devkit titles.
|
||||||
return start_job('Refresh Steam artwork', lambda: {
|
return start_job('Refresh Steam artwork', lambda: {
|
||||||
'apps': frame_android.refresh_art(None if body.get('all') else pkg)})
|
'apps': frame_android.refresh_art(), 'titles': frame_titles.refresh_art()})
|
||||||
if action in ("launch", "stop"):
|
if action in ("launch", "stop"):
|
||||||
m = getattr(frame_android, action)(pkg)
|
m = getattr(frame_android, action)(pkg)
|
||||||
return {"message": f"{'Launching' if action == 'launch' else 'Stopped'} {m['label']}"}
|
return {"message": f"{'Launching' if action == 'launch' else 'Stopped'} {m['label']}"}
|
||||||
@@ -717,12 +753,14 @@ def titles(body):
|
|||||||
threading.Thread(target=_run_title_install, daemon=True,
|
threading.Thread(target=_run_title_install, daemon=True,
|
||||||
args=(token, entry, opt("name"), opt("exe"), opt("runtime"))).start()
|
args=(token, entry, opt("name"), opt("exe"), opt("runtime"))).start()
|
||||||
return {"message": f"Installing {entry['plan']['source']}", "job": token}
|
return {"message": f"Installing {entry['plan']['source']}", "job": token}
|
||||||
if action not in ("launch", "remove"):
|
if action not in ("launch", "remove", "refresh-art"):
|
||||||
raise Failure("unknown action", 400)
|
raise Failure("unknown action", 400)
|
||||||
gid = str(body.get("id", ""))
|
gid = str(body.get("id", ""))
|
||||||
if not frame_titles.ID_RE.match(gid):
|
if not frame_titles.ID_RE.match(gid):
|
||||||
raise Failure("bad title id", 400)
|
raise Failure("bad title id", 400)
|
||||||
ensure_master()
|
ensure_master()
|
||||||
|
if action == "refresh-art":
|
||||||
|
return start_job(f"Steam artwork for {gid}", lambda: {"titles": [frame_titles.refresh_art(gid)]})
|
||||||
try:
|
try:
|
||||||
m = getattr(frame_titles, action)(gid)
|
m = getattr(frame_titles, action)(gid)
|
||||||
except frame_android.FrameError as e:
|
except frame_android.FrameError as e:
|
||||||
@@ -1446,10 +1484,14 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json(apk_versions(url.query))
|
self.send_json(apk_versions(url.query))
|
||||||
elif path == "/api/android":
|
elif path == "/api/android":
|
||||||
ensure_master()
|
ensure_master()
|
||||||
self.send_json({"apps": frame_android.list_apps()})
|
apps = frame_android.list_apps()
|
||||||
|
backfill_art(apps=apps)
|
||||||
|
self.send_json({"apps": apps})
|
||||||
elif path == "/api/titles":
|
elif path == "/api/titles":
|
||||||
ensure_master()
|
ensure_master()
|
||||||
self.send_json({"titles": frame_titles.list_titles()})
|
titles_list = frame_titles.list_titles()
|
||||||
|
backfill_art(titles=titles_list)
|
||||||
|
self.send_json({"titles": titles_list})
|
||||||
elif path == "/api/titles/job":
|
elif path == "/api/titles/job":
|
||||||
self.send_json(title_job(url.query))
|
self.send_json(title_job(url.query))
|
||||||
elif path == "/api/job":
|
elif path == "/api/job":
|
||||||
|
|||||||
Reference in new issue
Block a user