diff --git a/data/titles.json b/data/titles.json index 7c4486a..0d61c87 100644 --- a/data/titles.json +++ b/data/titles.json @@ -1654,5 +1654,57 @@ "IMG_4571.jpeg" ], "title_normalized": "botany expansion perilous perfumes poisonous carnivorous parasitic and bizarre plants" + }, + { + "title_raw": "ACTION CASTLE", + "confidence": "high", + "publisher_hint": "Memento More Computers Inc.", + "edition_hint": "", + "year_hint": 2007, + "language_hint": "English", + "art_notes": "Green monochrome text on black computer screen display, styled as retro terminal game", + "source_photos": [ + "IMG_4573.jpeg" + ], + "title_normalized": "action castle" + }, + { + "title_raw": "ACTION CASTLE I", + "confidence": "medium", + "publisher_hint": "", + "edition_hint": "", + "year_hint": null, + "language_hint": "English", + "art_notes": "Dark spine box among a stack of illustrated game boxes on the book cover art", + "source_photos": [ + "IMG_4573.jpeg" + ], + "title_normalized": "action castle i" + }, + { + "title_raw": "SIX-GUN SHOWDOWN", + "confidence": "medium", + "publisher_hint": "", + "edition_hint": "", + "year_hint": null, + "language_hint": "English", + "art_notes": "Western-themed box art with red/orange coloring, partially visible spine text", + "source_photos": [ + "IMG_4573.jpeg" + ], + "title_normalized": "six gun showdown" + }, + { + "title_raw": "Blackboa[rd]", + "confidence": "low", + "publisher_hint": "", + "edition_hint": "", + "year_hint": null, + "language_hint": "English", + "art_notes": "Dark spine at bottom right of cover art, title partially cut off", + "source_photos": [ + "IMG_4573.jpeg" + ], + "title_normalized": "blackboa rd" } ] diff --git a/data/unidentified.json b/data/unidentified.json index 45ae05e..d2556d2 100644 --- a/data/unidentified.json +++ b/data/unidentified.json @@ -360,5 +360,17 @@ "partial_text": "TOWN...FUKU (possibly Japanese text)", "art_notes": "Colorful box with cartoon character illustrations, appears to be a small/medium sized game box, mostly obscured by foreground items" } + ], + "IMG_4573.jpeg": [ + { + "location": "Upper right area of the book cover art, stacked above 'ACTION CASTLE I' box", + "partial_text": "AC... (partially obscured by hand/fingers)", + "art_notes": "Red/orange box spine, appears to be part of a series with other 'Action Castle' related titles" + }, + { + "location": "Bottom right of cover art, near 'Blackboard' box", + "partial_text": "", + "art_notes": "Small dark box with logo icon, title illegible due to size and angle" + } ] } diff --git a/data/unidentified_dismissed.json b/data/unidentified_dismissed.json index 60a0e3b..a4a56f6 100644 --- a/data/unidentified_dismissed.json +++ b/data/unidentified_dismissed.json @@ -108,5 +108,6 @@ "IMG_4556.jpeg|Top right corner, quilted patchwork-pattern box, right of the box with 'TCH ORK' text||Multicolored quilt/patchwork square pattern box, title not legible, partially cut at frame edge", "IMG_4556.jpeg|Top row, green textured spine with cartoon dinosaur, left of 'Ravensbu...' spine||Green speckled/scaly texture spine with small cartoon dinosaur illustration", "IMG_4556.jpeg|Top row, second shelf, spine reading 'Ravensbu...' between an unidentified dark box and green dinosaur-patterned spine|Ravensbu...|White spine with blue text, likely Ravensburger logo/publisher rather than title, top cut off", - "IMG_4566.jpeg|Top shelf, background, partially obscured behind and above the two Alice Is Missing boxes|TOWN...FUKU (possibly Japanese text)|Colorful box with cartoon character illustrations, appears to be a small/medium sized game box, mostly obscured by foreground items" + "IMG_4566.jpeg|Top shelf, background, partially obscured behind and above the two Alice Is Missing boxes|TOWN...FUKU (possibly Japanese text)|Colorful box with cartoon character illustrations, appears to be a small/medium sized game box, mostly obscured by foreground items", + "IMG_4573.jpeg|Bottom right of cover art, near 'Blackboard' box||Small dark box with logo icon, title illegible due to size and angle" ] diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py index df0aac0..5a2a877 100644 --- a/src/bggpipe/webreview.py +++ b/src/bggpipe/webreview.py @@ -247,6 +247,21 @@ NAV_PAGES = ( PHOTO_SUFFIXES = {".jpg", ".jpeg", ".png", ".heic"} +def _route_ip() -> str | None: + """The IPv4 address of this machine's default outbound interface — the + single best guess for the address a phone on the same network dials. + Multi-homed machines (VM bridges, Ethernet + Wi-Fi) have several + addresses; this is the one the OS actually routes through.""" + try: + probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + probe.connect(("192.0.2.1", 80)) # never sent; just picks the route + address = probe.getsockname()[0] + probe.close() + return address + except OSError: + return None + + def lan_hosts() -> set[str]: """This machine's names and addresses on the local network — what a phone's browser will put in the Host header. An allowlist (never a @@ -256,13 +271,8 @@ def lan_hosts() -> set[str]: hostname = socket.gethostname() hosts.add(hostname.lower()) hosts.add(hostname.split(".")[0].lower() + ".local") - try: - probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - probe.connect(("192.0.2.1", 80)) # never sent; just picks the route - hosts.add(probe.getsockname()[0]) - probe.close() - except OSError: - pass + if route := _route_ip(): + hosts.add(route) try: for info in socket.getaddrinfo(hostname, None): address = info[4][0] @@ -1091,27 +1101,38 @@ def run_web_review( # type it once; the cookie remembers it from then on. token = secrets.token_urlsafe(16) if lan else None if lan: - hosts = lan_hosts() - ips = sorted( - h - for h in hosts - if h.replace(".", "").isdigit() and not h.startswith("127.") + primary = _route_ip() + spares = sorted( + h for h in lan_hosts() if h != primary and not h.startswith("127.") ) - names = sorted(h for h in hosts if not h.replace(".", "").isdigit()) - addresses = ", ".join(f"http://{h}:{port}/?k={token}" for h in ips + names) - typer.echo(f"bggpipe web UI: {url}?k={token}") - typer.echo(f" from your phone, open: {addresses}") + typer.echo(f"bggpipe web UI: {url} (this machine needs no key)") + if primary: + typer.echo(f" on your phone, open: http://{primary}:{port}/?k={token}") + if spares: + typer.echo( + " (several network interfaces here — if that address " + "doesn't answer, try: " + + ", ".join(f"http://{h}:{port}/?k={token}" for h in spares) + + ")" + ) + else: + typer.echo( + " couldn't determine this machine's network address — find " + "it in your network settings and open " + f"http://:{port}/?k={token}" + + ( + " (or try: " + + ", ".join(f"http://{h}:{port}/?k={token}" for h in spares) + + ")" + if spares + else "" + ) + ) typer.echo( " --lan: no login beyond that key — anyone who has it can run " "stages, change your data, and (once unlocked) drive uploads " "to your BGG account. Use only on a network you trust." ) - if not ips: - typer.echo( - " couldn't determine this machine's network address — find " - "it in your network settings and open " - f"http://:{port}/?k={token}" - ) else: typer.echo( f"bggpipe web UI: {url} (localhost only; dashboard at /, review " diff --git a/tests/test_webreview.py b/tests/test_webreview.py index 9dd4443..c221ebf 100644 --- a/tests/test_webreview.py +++ b/tests/test_webreview.py @@ -1007,17 +1007,21 @@ def test_run_web_review_lan_branch_binds_and_warns(tmp_path, monkeypatch, capsys captured = {} monkeypatch.setattr("uvicorn.run", lambda app, **kw: captured.update(kw, app=app)) cfg = make_cfg(tmp_path) + monkeypatch.setattr(wr, "_route_ip", lambda: "192.168.1.5") monkeypatch.setattr( - wr, "lan_hosts", lambda: {"127.0.0.1", "192.168.1.5", "erics-mac.local"} + wr, + "lan_hosts", + lambda: {"127.0.0.1", "192.168.1.5", "192.168.64.1", "erics-mac.local"}, ) wr.run_web_review(cfg, port=9999, lan=True) out = capsys.readouterr().out assert captured["host"] == "0.0.0.0" - assert "?k=" in out # every printed URL carries the access key - assert "http://192.168.1.5:9999/?k=" in out - # no phone can reach loopback: it never appears in the phone list - assert "phone" not in out.split("http://127.0.0.1:9999")[-1].split("\n")[0] - assert out.count("http://127.0.0.1:9999") == 1 # the desktop line only + # the default-route address leads; other interfaces are fallbacks + assert "on your phone, open: http://192.168.1.5:9999/?k=" in out + fallback = next(line for line in out.splitlines() if "try:" in line) + assert "192.168.64.1" in fallback and "erics-mac.local" in fallback + # no phone can reach loopback: only the desktop line mentions it + assert out.count("http://127.0.0.1:9999") == 1 assert "Use only on a network you trust" in out wr.run_web_review(cfg, port=9999, lan=False) assert captured["host"] == "127.0.0.1"