Files
magnus919_agent-skills/trakt/scripts/trakt
T
Magnus Hedemarkandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> eba124cb5e fix(trakt): expose --page and normalize X-Pagination headers in JSON
Every discovery command (movie/tv x trending/popular/anticipated) now
accepts --page alongside --limit and forwards both as query parameters.
TraktClient._get returns (data, pagination) where pagination is the
X-Pagination-* header set normalized onto the stable keys page, limit,
page_count, and item_count; missing or non-numeric headers degrade to an
empty object. JSON output keeps the movies/shows array beside a new
pagination object, human output appends "Page N of M" only when the
headers are present, and cmd_movie/cmd_tv collapse into one shared
cmd_discovery handler.

Adds mocked coverage for page=2 request params across all six commands,
header normalization (full/lowercase/partial/unparseable/missing),
missing-header fallback at client level, stable movie trending wrapper
and TV shapes beside pagination, and human page-line presence rules.
Green under pytest strict-markers, unittest discover, and the proxy
trap (zero egress).

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-08-26 18:49:47 -04:00

179 lines
8.1 KiB
Python
Executable File

#!/usr/bin/env python3
"""trakt — Trakt.tv media discovery from the terminal.
Discover trending, anticipated, and popular movies and TV shows.
Calendar of upcoming releases. Uses Trakt.tv API with Client ID.
"""
import argparse, json, os, sys, warnings
from typing import Any, Dict, List, Optional, Tuple
warnings.simplefilter("ignore")
import requests
ENV_CLIENT_ID = os.getenv("TRAKT_CLIENT_ID", "")
API_BASE = "https://api.trakt.tv"
USER_AGENT = "agent-skills-trakt/1.0"
QUIET = False
GLOBAL_FLAGS: Dict[str, Any] = {"json": False, "dry_run": False, "quiet": False, "verbose": False}
# Canonical X-Pagination-* response headers mapped onto the stable JSON keys
# surfaced as the `pagination` object beside every discovery result.
PAGINATION_HEADERS: Tuple[Tuple[str, str], ...] = (
("X-Pagination-Page", "page"),
("X-Pagination-Limit", "limit"),
("X-Pagination-Page-Count", "page_count"),
("X-Pagination-Item-Count", "item_count"),
)
def log(m): global QUIET; (not QUIET and not GLOBAL_FLAGS.get("json")) and print(m)
def warn(m): print(f"Warning: {m}", file=sys.stderr)
def die(m, c=1): print(f"Error: {m}", file=sys.stderr); sys.exit(c)
def emit(h, d):
if GLOBAL_FLAGS.get("json"): print(json.dumps(d, default=str))
else: print(h)
def normalize_pagination(headers):
"""Map X-Pagination-* headers onto the stable pagination keys.
Missing or non-numeric header values fall out of the mapping, so a
response without pagination metadata degrades to an empty object
instead of failing.
"""
pagination: Dict[str, int] = {}
for header, key in PAGINATION_HEADERS:
raw = None
for name in (header, header.lower()):
if name in headers:
raw = headers[name]
break
if raw is None: continue
try: pagination[key] = int(str(raw).strip())
except (TypeError, ValueError): continue
return pagination
def _preparse(argv):
BOOLS = {"--json","--dry-run","--quiet","--verbose"}
f, fl = {}, [argv[0]]
i = 1
while i < len(argv):
a = argv[i]
if a in BOOLS: f[a.lstrip("-").replace("-","_")] = True; i += 1
elif a in ("--help","-h"): return f, argv
elif a == "--": fl.extend(argv[i:]); break
else: fl.append(a); i += 1
return f, fl
class TraktClient:
def __init__(self, client_id="", dry_run=False):
self.client_id = client_id or ENV_CLIENT_ID
self.dry_run = dry_run
def _get(self, path, params=None):
"""Fetch one page; returns (json data, normalized pagination dict)."""
url = f"{API_BASE}{path}"
if self.dry_run: return [{"dry_run":True, "url":url, "params":params}], {}
if not self.client_id: die("TRAKT_CLIENT_ID not set. Get one from trakt.tv/oauth/applications.")
try:
r = requests.get(url, params=params, headers={
"Content-Type": "application/json", "User-Agent": USER_AGENT,
"trakt-api-version": "2", "trakt-api-key": self.client_id}, timeout=30)
except ConnectionError as e: die(f"Cannot connect: {e}")
if r.status_code == 401: die("Auth failed (401). Check TRAKT_CLIENT_ID.")
if r.status_code >= 400:
try: d = r.json()
except: d = r.text[:200]
die(f"API error ({r.status_code}): {d}")
return r.json(), normalize_pagination(dict(r.headers))
def movie_trending(self, page=1, limit=10):
return self._get("/movies/trending", {"page":page, "limit":limit})
def movie_anticipated(self, page=1, limit=10):
return self._get("/movies/anticipated", {"page":page, "limit":limit})
def movie_popular(self, page=1, limit=10):
return self._get("/movies/popular", {"page":page, "limit":limit})
def movie_calendar(self, start_date="", days=7):
params = {"days":days}
if start_date: params["start_date"] = start_date
return self._get("/calendars/my/movies", params)
def tv_trending(self, page=1, limit=10):
return self._get("/shows/trending", {"page":page, "limit":limit})
def tv_anticipated(self, page=1, limit=10):
return self._get("/shows/anticipated", {"page":page, "limit":limit})
def tv_popular(self, page=1, limit=10):
return self._get("/shows/popular", {"page":page, "limit":limit})
def tv_calendar(self, start_date="", days=7):
params = {"days":days}
if start_date: params["start_date"] = start_date
return self._get("/calendars/my/shows", params)
def fmt_movie(d, idx=None):
m = d.get("movie", d)
t = m.get("title","?"); y = m.get("year","")
ids = m.get("ids",{}); tmdb = ids.get("tmdb","?")
tagline = m.get("tagline","")
s = f" {'%d. '%idx if idx else ''}{t:40} ({y}) tmdb={tmdb}"
if tagline: s += f"\n {'':4}{tagline[:80]}"
return s
def fmt_show(d, idx=None):
s = d.get("show", d)
t = s.get("title","?"); y = s.get("year","")
ids = s.get("ids",{}); tvdb = ids.get("tvdb","?")
net = s.get("network",""); net_str = f" [{net}]" if net else ""
s_out = f" {'%d. '%idx if idx else ''}{t:35}{net_str} ({y}) tvdb={tvdb}"
return s_out
def cmd_discovery(client, resource, endpoint, args):
fmt = fmt_movie if resource == "movie" else fmt_show
list_key = "movies" if resource == "movie" else "shows"
plural = "movies" if resource == "movie" else "TV shows"
label = "Movie" if resource == "movie" else "TV"
p = argparse.ArgumentParser(prog=f"trakt {resource} {endpoint}")
p.add_argument("--page", type=int, default=1,
help="1-based page number to fetch (default 1)")
p.add_argument("--limit", type=int, default=10,
help="items per page (default 10)")
parsed, _ = p.parse_known_args(args)
if client.dry_run: return emit(f"[dry-run] {label} {endpoint}", {"dry_run":True})
data, pagination = getattr(client, f"{resource}_{endpoint}")(page=parsed.page, limit=parsed.limit)
data = data or []
entries = data[:parsed.limit] if isinstance(data, list) else []
payload: Dict[str, Any] = {list_key: entries, "pagination": pagination}
if not entries: return emit(f"No {endpoint} {plural}.", payload)
lines = [fmt(entry, i+1) for i, entry in enumerate(entries)]
output = f"{endpoint.title()} {'movies' if resource == 'movie' else 'TV'}:\n"+"\n".join(lines)
if pagination.get("page") is not None and pagination.get("page_count"):
output += f"\n Page {pagination['page']} of {pagination['page_count']}"
emit(output, payload)
def main():
global GLOBAL_FLAGS, QUIET
GLOBAL_FLAGS, filtered_argv = _preparse(sys.argv)
if GLOBAL_FLAGS.get("quiet"): QUIET = True
if GLOBAL_FLAGS.get("json"): warnings.simplefilter("ignore")
parser = argparse.ArgumentParser(prog="trakt", description="Trakt.tv media discovery.",
epilog="Set TRAKT_CLIENT_ID. Get one at trakt.tv/oauth/applications.")
sub = parser.add_subparsers(dest="resource")
mp = sub.add_parser("movie", help="Movie discovery")
ms = mp.add_subparsers(dest="action")
for a in ["trending","anticipated","popular"]:
ap = ms.add_parser(a, help=f"{a.title()} movies")
ap.add_argument("--page", type=int, default=1, help="page number to fetch (default 1)")
ap.add_argument("--limit", type=int, default=10, help="items per page (default 10)")
tp = sub.add_parser("tv", help="TV discovery")
ts = tp.add_subparsers(dest="action")
for a in ["trending","anticipated","popular"]:
ap = ts.add_parser(a, help=f"{a.title()} TV")
ap.add_argument("--page", type=int, default=1, help="page number to fetch (default 1)")
ap.add_argument("--limit", type=int, default=10, help="items per page (default 10)")
args = parser.parse_args(filtered_argv[1:])
if not args.resource: parser.print_help(); sys.exit(1)
client = TraktClient(dry_run=GLOBAL_FLAGS.get("dry_run",False))
remaining = filtered_argv[filtered_argv.index(args.resource)+1:]
if args.resource in ("movie","tv"):
if args.action: cmd_discovery(client, args.resource, args.action, remaining)
elif args.resource == "movie": mp.print_help()
else: tp.print_help()
else: parser.print_help()
if __name__ == "__main__": main()