diff --git a/tests/test_library_entrypoints.py b/tests/test_library_entrypoints.py index 204b72b..a8c59c6 100644 --- a/tests/test_library_entrypoints.py +++ b/tests/test_library_entrypoints.py @@ -81,12 +81,37 @@ class EntryPoints(unittest.TestCase): self.assertEqual(result['kind'], 'apk') self.assert_art() - def test_refresh_api(self): + def test_refresh_api_covers_android_apps_and_titles(self): with patch.object(server, 'ensure_master'), \ patch.object(server, 'start_job', side_effect=lambda label, work: work()), \ - patch.object(android, 'refresh_art', return_value=[]) as refresh: - self.assertEqual(server.android({'action':'refresh-art', 'all':True}), {'apps':[]}) - refresh.assert_called_once_with(None) + patch.object(android, 'refresh_art', return_value=[]) as refresh, \ + patch.object(titles, 'refresh_art', return_value=[{'id':'G','error':'x'}]) as title_refresh: + 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): # Source workers hand their download and optional images to this public seam. diff --git a/ui/frame_android.py b/ui/frame_android.py index bcb2fc9..024e15f 100644 --- a/ui/frame_android.py +++ b/ui/frame_android.py @@ -377,6 +377,7 @@ def list_apps(): continue if m: m['running'] = f"lepton-steamlaunch-{m['instance']}" in running + m['art_missing'] = art_missing(m) apps.append(m) 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) elif cmd == 'list': 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'): r = globals()[cmd](args[0]) elif cmd == 'remove': diff --git a/ui/frame_titles.py b/ui/frame_titles.py index 891bcfc..2fb3706 100644 --- a/ui/frame_titles.py +++ b/ui/frame_titles.py @@ -852,7 +852,7 @@ def list_titles(): 'runtime': alias, 'runtime_label': RUNTIMES.get(alias, {}).get('label', alias or 'not set'), 'source': str(meta.get('source') or ''), 'size': meta.get('size'), '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 @@ -933,8 +933,15 @@ def main(): progress=lambda text, _: print(text + '…', file=sys.stderr)) elif cmd == 'list': 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: 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: sys.exit(__doc__) except FrameError as e: diff --git a/ui/index.html b/ui/index.html index fb9011f..f1999a4 100644 --- a/ui/index.html +++ b/ui/index.html @@ -795,7 +795,7 @@ Get a free key
- +

Remote & power

@@ -1801,6 +1801,7 @@ async function loadTitles() {
${esc(t.id)}
${esc(t.runtime_label)}${t.target ? " · " + esc(t.target) : ""}
+ ${t.art_missing ? `` : ""}
`).join("") : `
None yet.
`; @@ -1812,6 +1813,9 @@ $("titleList").onclick = async e => { if (!confirm(`Remove ${id} and its files from the Frame?`)) return; await act(`Remove ${id}`, () => api("/api/titles", { action: "remove", id }), b); 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); }; loadTitles(); @@ -2133,6 +2137,7 @@ async function loadAndroid() {
${a.running ? `` : ``} + ${a.art_missing ? `` : ""} @@ -2215,6 +2220,8 @@ document.body.addEventListener("click", async e => { } else if (action === "remove") { 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); + } 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") { openReport({ package: pkg, version: b.dataset.ver, label: b.dataset.name, source: b.dataset.src }); return; diff --git a/ui/server.py b/ui/server.py index 8a0fa01..da00b00 100755 --- a/ui/server.py +++ b/ui/server.py @@ -148,6 +148,39 @@ _jobs_lock = threading.Lock() _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): """Run work() in the background. It returns a dict with a "message".""" now = time.time() @@ -599,8 +632,11 @@ def android(body): if action == "refresh-art": if not pkg and not body.get("all"): 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: { - '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"): m = getattr(frame_android, action)(pkg) 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, args=(token, entry, opt("name"), opt("exe"), opt("runtime"))).start() 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) gid = str(body.get("id", "")) if not frame_titles.ID_RE.match(gid): raise Failure("bad title id", 400) ensure_master() + if action == "refresh-art": + return start_job(f"Steam artwork for {gid}", lambda: {"titles": [frame_titles.refresh_art(gid)]}) try: m = getattr(frame_titles, action)(gid) except frame_android.FrameError as e: @@ -1446,10 +1484,14 @@ class Handler(BaseHTTPRequestHandler): self.send_json(apk_versions(url.query)) elif path == "/api/android": 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": 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": self.send_json(title_job(url.query)) elif path == "/api/job":