#!/usr/bin/env python3 """ Interactive Twitch category search. Used by loading.sh to resolve a free-form query into an exact directory entry. ./search-game.py "Heroes" stderr: numbered list of up to 20 matches + "Pick: " prompt stdout: a single line "\\t" (only on success) exit: 0 success | 1 no-match / cancel / error | 130 ctrl-c Auto-picks when there's exactly one result. Tempting to also auto-pick on an exact-name match within a larger result set (e.g. typing "Doom Eternal" and having that exact entry near the top), but a real test surfaced the failure: typing "Heroes" returns "Heroes" (TV show) plus all the Heroes-* games, and auto-picking the standalone match silently steals from the user — who is *using search* precisely because the query is ambiguous. So: only one rule. Endpoint: /helix/search/categories — same one Twitch's dashboard autocomplete uses. Accepts any user/app token; we reuse the broadcaster token from .env.ophi118 because it's already there and refreshable. """ import sys import urllib.parse import _twitch as tw MAX_RESULTS = 20 def err(msg=""): print(msg, file=sys.stderr, flush=True) def search(env: dict, query: str) -> list: qs = urllib.parse.urlencode({"query": query, "first": MAX_RESULTS}) status, body = tw.auth_call("GET", f"{tw.HELIX}/search/categories?{qs}", env) if status != 200: raise RuntimeError(f"search failed (status={status}): {body}") return (body or {}).get("data") or [] def pick(results: list, query: str) -> dict: if not results: raise RuntimeError(f"no Twitch categories match {query!r}") if len(results) == 1: err(f" ✓ only match: {results[0]['name']}") return results[0] err(f"── {len(results)} matches for {query!r} ──") for i, g in enumerate(results, 1): err(f" {i:2}) {g['name']}") err(" 0) cancel") while True: # input()'s prompt arg writes to stdout — fatal here because bash # captures stdout for the resolved \t line. Print to stderr # explicitly, then call input() with no prompt. print("Pick: ", end="", file=sys.stderr, flush=True) try: raw = input().strip() except EOFError: raise RuntimeError("cancelled (EOF)") if not raw.isdigit(): err(" ! enter a number") continue n = int(raw) if n == 0: raise RuntimeError("cancelled") if 1 <= n <= len(results): return results[n - 1] err(f" ! must be 0..{len(results)}") def main(): if len(sys.argv) != 2 or not sys.argv[1].strip(): print("usage: search-game.py ", file=sys.stderr) sys.exit(2) query = sys.argv[1].strip() env = tw.load_env() needed = ("TWITCH_ACCESS_TOKEN", "TWITCH_REFRESH_TOKEN", "TWITCH_CLIENT_ID") missing = [k for k in needed if not env.get(k)] if missing: err(f"[err] missing in {tw.ENV_FILE.name}: {', '.join(missing)}") sys.exit(1) try: results = search(env, query) chosen = pick(results, query) except Exception as e: err(f"[err] {type(e).__name__}: {e}") sys.exit(1) # ONLY the resolved tuple goes to stdout — bash captures this. print(f"{chosen['id']}\t{chosen['name']}") if __name__ == "__main__": try: main() except KeyboardInterrupt: sys.exit(130)