Titles — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: edit, split, remove. Its badge counts shaky read lines — the model wasn't sure and nothing has verified them; filter to them, then press ✓ looks right or edit each one.
Review — the decisions only you can make: which game a title is, which edition a copy is, whether two same-game reads are really one box (merges show a veto), and whether an unmatched title is a real game BGG simply doesn't have (keep locally: it joins the Library, never uploads). Keyboard-first; see shortcuts.
Queue — exactly what upload will do (new entries and version upgrades) and the log of everything it has done. Nothing reaches BGG that isn't visible here first. A job that fails is skipped by later runs (so one broken game can't loop forever); when any exist, the Pipeline's upload card offers a retry N failed checkbox. Each queued row shows what upload did with it — pending, done, failed, or retired (a review decision since the last diff withdrew it). Finished rows stay listed until the next diff rebuilds the queue; the log below them is the permanent record.
-
Library — your enriched collection: filter by board games or RPGs. RPG matches are identified and enriched but never uploaded — BGG collections can't hold them, so they stay local citizens.
+
Library — your enriched collection. Search titles, designers, mechanics and categories at once; filter by kind (board games, RPGs, off-BGG) or by how many people are playing tonight; sort by name, year, BGG rank, weight, or playing time. Click any game for its full detail: art, the usual stats, designers and mechanics, your edition, the shelf photos it was read from, and a link to its BGG page. RPG and off-BGG games live here too — identified and enriched, never uploaded.
diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py
index 4d9c1f0..4f46455 100644
--- a/src/bggpipe/webreview.py
+++ b/src/bggpipe/webreview.py
@@ -723,6 +723,11 @@ def create_app(
def library_page() -> str:
return render_page("library")
+ @app.get("/library/game/{key:path}", response_class=HTMLResponse)
+ def library_game_page(key: str) -> str:
+ # the fragment reads the key from its own URL; nothing interpolated
+ return render_page("librarygame", active="library")
+
@app.get("/help", response_class=HTMLResponse)
def help_page() -> str:
return render_page("help")
@@ -781,10 +786,51 @@ def create_app(
"log": log,
}
+ def library_entries() -> dict[str, dict]:
+ """games.json plus what only the pipeline knows: the entry's key and
+ the photos the game was read from (its provenance back to a shelf)."""
+ games = read_games()
+ available = photo_names()
+ photos_by_key: dict[str, list[str]] = {}
+ for row in session.rows:
+ if not row["bgg_id"]:
+ continue
+ key = (
+ f"{row['bgg_id']}:{row['version_id']}"
+ if row["version_id"]
+ else row["bgg_id"]
+ )
+ photos_by_key.setdefault(key, []).extend(
+ p for p in row["source_photos"].split(";") if p
+ )
+ out = {}
+ for key, game in games.items():
+ photos = game.get("source_photos") or photos_by_key.get(key, [])
+ out[key] = {
+ **game,
+ "key": key,
+ "photos": [p for p in dict.fromkeys(photos) if p in available],
+ }
+ return out
+
@app.get("/api/library")
def api_library() -> list[dict]:
- games = read_games()
- return sorted(games.values(), key=lambda g: (g.get("name") or "").casefold())
+ # the list view never needs the description: it is by far the
+ # largest field, and 136 of them is a megabyte of dead weight
+ return sorted(
+ (
+ {k: v for k, v in g.items() if k != "description"}
+ for g in library_entries().values()
+ ),
+ key=lambda g: (g.get("name") or "").casefold(),
+ )
+
+ @app.get("/api/library/{key:path}")
+ def api_library_game(key: str) -> dict:
+ game = library_entries().get(key)
+ if game is None:
+ raise HTTPException(404, "no such game in the library")
+ return game
def _pending(path: Path, action: str, log_rows: list[dict]) -> int:
from bggpipe.upload import annotate_queue
diff --git a/tests/test_webreview.py b/tests/test_webreview.py
index 27aa0f5..881ff35 100644
--- a/tests/test_webreview.py
+++ b/tests/test_webreview.py
@@ -1243,3 +1243,48 @@ def test_same_title_lines_pair_rows_by_photos_not_csv_order(tmp_path):
}
assert lines[("a.jpg",)]["version_status"] == "version_unknown"
assert lines[("b.jpg",)]["version_status"] == "version_ambiguous"
+
+
+def test_library_detail_serves_one_game_with_provenance(tmp_path):
+ cfg = make_cfg(tmp_path)
+ rows = read_matches(cfg.matches_path)
+ rows.append(
+ _row(
+ title_raw="Britannia",
+ match_status="auto",
+ bgg_id="240",
+ bgg_name="Britannia",
+ version_id="24621",
+ source_photos="shelf.jpg",
+ )
+ )
+ write_matches(cfg.matches_path, rows)
+ cfg.games_path.write_text(
+ json.dumps(
+ {
+ "240:24621": {
+ "bgg_id": 240,
+ "name": "Britannia",
+ "year": 1986,
+ "type": "boardgame",
+ "description": "A long description.",
+ "version": {"version_id": 24621, "name": "Avalon Hill second"},
+ }
+ }
+ )
+ )
+ web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
+
+ (listed,) = web.get("/api/library").json()
+ assert listed["key"] == "240:24621"
+ assert "description" not in listed # the list view stays light
+
+ detail = web.get("/api/library/240:24621").json()
+ assert detail["name"] == "Britannia"
+ assert detail["description"] == "A long description."
+ # provenance the pipeline knows and games.json doesn't: the shelf photo
+ assert detail["photos"] == ["shelf.jpg"]
+
+ assert web.get("/api/library/nope").status_code == 404
+ page = web.get("/library/game/240:24621")
+ assert page.status_code == 200 and 'href="/library"' in page.text