Initial version
This commit is contained in:
104
search-game.py
Executable file
104
search-game.py
Executable file
@@ -0,0 +1,104 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user