105 lines
3.4 KiB
Python
Executable File
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)
|