Keep the titles a store cannot install
`select_games` answers what a store can install and throws the rest away, which is fine for installing and wrong for showing: a client that hides what it cannot install leaves the visitor wondering whether the catalog is small or their machine is unusual. `survey_catalog` is the same walk with those titles kept — one record per title, with a reason code (platform_off, host_asset, no_asset, vetoed), a sentence, and enough of the catalog entry to draw a card. `select_games` is now a two-value wrapper around it, so an adapter that only installs needs no change; the Batocera and RetroArch stores are untouched. Titles the store *chooses* not to offer stay invisible: the wrong status or an only/exclude list is editorial, not a limitation of the machine. A platform switched off is reported, because to somebody reading a catalog it says "not supported here". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -54,6 +54,31 @@ games, skipped = ws.select_games(cfg, ws.fetch_catalog(cfg, use_cache=True), acc
|
|||||||
playlist. It is what `state.json` is keyed by (`<scope>:<name>`), so two hosts
|
playlist. It is what `state.json` is keyed by (`<scope>:<name>`), so two hosts
|
||||||
can carry a game of the same name without colliding.
|
can carry a game of the same name without colliding.
|
||||||
|
|
||||||
|
### Listing what cannot be installed
|
||||||
|
|
||||||
|
`select_games` answers what a store can install. `survey_catalog` answers the same
|
||||||
|
question and keeps the rest, which is what a client needs to show a catalog honestly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
games, skipped, unavailable = ws.survey_catalog(cfg, catalog, accept)
|
||||||
|
```
|
||||||
|
|
||||||
|
Each `unavailable` record carries `name`, `title`, `platform`, `desc`, `author`,
|
||||||
|
`image_url`, the latest `version`, a `reason` code and a `detail` sentence. The codes:
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `platform_off` | this store does not carry that platform at all |
|
||||||
|
| `host_asset` | the platform has no asset kind for this OS and architecture |
|
||||||
|
| `no_asset` | no release carries the asset it would need |
|
||||||
|
| `vetoed` | the adapter's `accept` refused it |
|
||||||
|
|
||||||
|
Titles the store *chooses* not to offer — the wrong status, an `only`/`exclude` list —
|
||||||
|
are in none of the three lists: that is editorial, not a limitation of the machine. A
|
||||||
|
platform switched off is reported, because to somebody looking at a catalog it reads as
|
||||||
|
"not supported here". `select_games` is now a two-value wrapper around this, so an
|
||||||
|
adapter that only installs needs no change.
|
||||||
|
|
||||||
## What is in here
|
## What is in here
|
||||||
|
|
||||||
| Area | Functions |
|
| Area | Functions |
|
||||||
@@ -64,7 +89,7 @@ can carry a game of the same name without colliding.
|
|||||||
| Store home | `init`, then `HOME`, `CONFIG_PATH`, `STATE_PATH`, `CATALOG_CACHE` |
|
| Store home | `init`, then `HOME`, `CONFIG_PATH`, `STATE_PATH`, `CATALOG_CACHE` |
|
||||||
| HTTP | `http_get`, `http_download`, `api_url`, `download_url`, `user_agent` |
|
| HTTP | `http_get`, `http_download`, `api_url`, `download_url`, `user_agent` |
|
||||||
| Host | `machine`, `host_os`, `host`, `resolve_for_host` |
|
| Host | `machine`, `host_os`, `host`, `resolve_for_host` |
|
||||||
| Catalog | `fetch_catalog`, `select_games`, `pick_release`, `asset_basename`, `download_image` |
|
| Catalog | `fetch_catalog`, `select_games`, `survey_catalog`, `pick_release`, `asset_basename`, `download_image` |
|
||||||
| State | `load_state`, `save_state`, `game_key`, `record_scope`, `records_by_scope`, `match_keys`, `limit_to_names` |
|
| State | `load_state`, `save_state`, `game_key`, `record_scope`, `records_by_scope`, `match_keys`, `limit_to_names` |
|
||||||
|
|
||||||
Four of them carry decisions worth knowing about.
|
Four of them carry decisions worth knowing about.
|
||||||
|
|||||||
+60
-8
@@ -28,7 +28,7 @@ import urllib.error
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
VERSION = "1.3.0"
|
VERSION = "1.4.0"
|
||||||
|
|
||||||
# Bumped when the on-disk shape of state.json changes. v1 keyed `installed` by
|
# Bumped when the on-disk shape of state.json changes. v1 keyed `installed` by
|
||||||
# bare software name; v2 keys by `<scope>:<name>`.
|
# bare software name; v2 keys by `<scope>:<name>`.
|
||||||
@@ -444,6 +444,15 @@ def pick_release(entry, kinds, ext):
|
|||||||
return None, None, None, None
|
return None, None, None, None
|
||||||
|
|
||||||
|
|
||||||
|
# Why a title the store offers cannot be installed here. A client shows these rather
|
||||||
|
# than hiding the title: "there is nothing for your machine" is an answer, and an
|
||||||
|
# absent entry is not.
|
||||||
|
UNAVAILABLE_PLATFORM = "platform_off" # this store does not carry that platform
|
||||||
|
UNAVAILABLE_HOST = "host_asset" # the platform has no asset kind for this os/arch
|
||||||
|
UNAVAILABLE_ASSET = "no_asset" # no release carries the asset it would need
|
||||||
|
UNAVAILABLE_VETO = "vetoed" # the adapter refused it
|
||||||
|
|
||||||
|
|
||||||
def select_games(cfg, catalog, accept, host_info=None):
|
def select_games(cfg, catalog, accept, host_info=None):
|
||||||
"""Catalog -> (list of installable records, list of skip reasons).
|
"""Catalog -> (list of installable records, list of skip reasons).
|
||||||
|
|
||||||
@@ -455,22 +464,54 @@ def select_games(cfg, catalog, accept, host_info=None):
|
|||||||
where `scope` groups records the way the host groups them — a Batocera
|
where `scope` groups records the way the host groups them — a Batocera
|
||||||
system, a RetroArch playlist — and `extras` are the host-specific fields the
|
system, a RetroArch playlist — and `extras` are the host-specific fields the
|
||||||
adapter wants on the record. To reject the title, it raises `Skip(reason)`.
|
adapter wants on the record. To reject the title, it raises `Skip(reason)`.
|
||||||
|
|
||||||
|
See `survey_catalog` for the same walk with the unavailable titles kept.
|
||||||
|
"""
|
||||||
|
games, skipped, _unavailable = survey_catalog(cfg, catalog, accept, host_info)
|
||||||
|
return games, skipped
|
||||||
|
|
||||||
|
|
||||||
|
def survey_catalog(cfg, catalog, accept, host_info=None):
|
||||||
|
"""Catalog -> (installable records, skip reasons, unavailable records).
|
||||||
|
|
||||||
|
The third list is what `select_games` throws away: one record per title this store
|
||||||
|
offers but this machine cannot install, each with a `reason` code from the
|
||||||
|
UNAVAILABLE_* set and a sentence for a person. A store that hides those titles is
|
||||||
|
lying about its catalog by omission, so a client can show them greyed out instead.
|
||||||
|
|
||||||
|
Titles the store *chooses* not to offer — the wrong status, an `only`/`exclude`
|
||||||
|
list, a platform switched off — are not in any of the three: that is editorial, not
|
||||||
|
a limitation of the machine. The one exception is a platform switched off, which is
|
||||||
|
reported, because to a person looking at a catalog it reads as "not supported here".
|
||||||
"""
|
"""
|
||||||
host_info = host_info or host()
|
host_info = host_info or host()
|
||||||
statuses = {s.lower() for s in cfg["catalog"].get("statuses") or []}
|
statuses = {s.lower() for s in cfg["catalog"].get("statuses") or []}
|
||||||
only = {n.lower() for n in cfg["catalog"].get("only") or []}
|
only = {n.lower() for n in cfg["catalog"].get("only") or []}
|
||||||
exclude = {n.lower() for n in cfg["catalog"].get("exclude") or []}
|
exclude = {n.lower() for n in cfg["catalog"].get("exclude") or []}
|
||||||
|
|
||||||
games, skipped = [], []
|
games, skipped, unavailable = [], [], []
|
||||||
|
|
||||||
|
def unmet(sw, entry, code, reason):
|
||||||
|
"""One unavailable record, with enough for a client to draw a card."""
|
||||||
|
latest = entry.get("latestRelease") or {}
|
||||||
|
unavailable.append({
|
||||||
|
"name": sw.get("name"),
|
||||||
|
"title": sw.get("title") or sw.get("name"),
|
||||||
|
"platform": sw.get("platform"),
|
||||||
|
"desc": (sw.get("desc") or "").strip(),
|
||||||
|
"author": (sw.get("author") or "").strip(),
|
||||||
|
"image_url": sw.get("imageUrl"),
|
||||||
|
"version": str(latest.get("version", "")),
|
||||||
|
"reason": code,
|
||||||
|
"detail": reason,
|
||||||
|
})
|
||||||
|
|
||||||
for entry in catalog.get("softwares") or []:
|
for entry in catalog.get("softwares") or []:
|
||||||
sw = entry.get("software") or {}
|
sw = entry.get("software") or {}
|
||||||
name = sw.get("name")
|
name = sw.get("name")
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
spec = (cfg["platforms"] or {}).get(sw.get("platform"))
|
|
||||||
if not spec or not spec.get("enabled", True):
|
|
||||||
continue
|
|
||||||
if statuses and str(sw.get("status", "")).lower() not in statuses:
|
if statuses and str(sw.get("status", "")).lower() not in statuses:
|
||||||
continue
|
continue
|
||||||
if only and name.lower() not in only:
|
if only and name.lower() not in only:
|
||||||
@@ -478,23 +519,34 @@ def select_games(cfg, catalog, accept, host_info=None):
|
|||||||
if name.lower() in exclude:
|
if name.lower() in exclude:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
spec = (cfg["platforms"] or {}).get(sw.get("platform"))
|
||||||
|
if not spec or not spec.get("enabled", True):
|
||||||
|
unmet(sw, entry, UNAVAILABLE_PLATFORM,
|
||||||
|
f"{sw.get('platform')} is not carried by this store")
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
scope, extras = accept(spec, sw)
|
scope, extras = accept(spec, sw)
|
||||||
except Skip as reason:
|
except Skip as reason:
|
||||||
skipped.append(f"{name}: {reason}")
|
skipped.append(f"{name}: {reason}")
|
||||||
|
unmet(sw, entry, UNAVAILABLE_VETO, str(reason))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
wanted = resolve_for_host(spec.get("kind"), host_info)
|
wanted = resolve_for_host(spec.get("kind"), host_info)
|
||||||
if not wanted:
|
if not wanted:
|
||||||
skipped.append(f"{name}: {sw.get('platform')} has no asset kind for "
|
reason = (f"{sw.get('platform')} has no asset kind for "
|
||||||
f"{host_info['os']}/{host_info['arch']}")
|
f"{host_info['os']}/{host_info['arch']}")
|
||||||
|
skipped.append(f"{name}: {reason}")
|
||||||
|
unmet(sw, entry, UNAVAILABLE_HOST, reason)
|
||||||
continue
|
continue
|
||||||
ext = resolve_for_host(spec.get("ext", ""), host_info) or ""
|
ext = resolve_for_host(spec.get("ext", ""), host_info) or ""
|
||||||
|
|
||||||
release, file_name, kind, asset_path = pick_release(entry, wanted, ext)
|
release, file_name, kind, asset_path = pick_release(entry, wanted, ext)
|
||||||
if not release:
|
if not release:
|
||||||
listed = wanted if isinstance(wanted, str) else "/".join(wanted)
|
listed = wanted if isinstance(wanted, str) else "/".join(wanted)
|
||||||
skipped.append(f"{name}: no '{listed}' asset in any release")
|
reason = f"no '{listed}' asset in any release"
|
||||||
|
skipped.append(f"{name}: {reason}")
|
||||||
|
unmet(sw, entry, UNAVAILABLE_ASSET, reason)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
record = {
|
record = {
|
||||||
@@ -515,7 +567,7 @@ def select_games(cfg, catalog, accept, host_info=None):
|
|||||||
}
|
}
|
||||||
record.update(extras or {})
|
record.update(extras or {})
|
||||||
games.append(record)
|
games.append(record)
|
||||||
return games, skipped
|
return games, skipped, unavailable
|
||||||
|
|
||||||
|
|
||||||
def image_url(cfg, game):
|
def image_url(cfg, game):
|
||||||
|
|||||||
Reference in New Issue
Block a user