Files
twitch-bot/search-game.py
2026-04-27 23:21:39 +02:00

105 lines
3.4 KiB
Python
Executable File

#!/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 "<game_id>\\t<canonical_name>" (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 <id>\t<name> 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 <query>", 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)