Files
kino-app/backend/trakt.py
T
2026-07-23 22:18:08 +00:00

102 lines
3.5 KiB
Python

"""Trakt.tv API client.
Uses OAuth 2.0 device code flow:
1. POST /oauth/device/code with client_id → get device_code + user_code + verification_url
2. User visits trakt.tv/activate, enters user_code
3. Poll POST /oauth/device/token with device_code until user approves → access_token + refresh_token
4. Use access_token as Bearer for all subsequent calls.
Scrobble: POST /scrobble/{start|pause|stop} with movie/episode + progress (0-100).
"""
import requests
from typing import Dict, Optional
BASE = "https://api.trakt.tv"
def _headers(client_id: str, access_token: Optional[str] = None) -> dict:
h = {
"Content-Type": "application/json",
"trakt-api-version": "2",
"trakt-api-key": client_id,
}
if access_token:
h["Authorization"] = f"Bearer {access_token}"
return h
def device_code(client_id: str) -> Dict:
r = requests.post(f"{BASE}/oauth/device/code", json={"client_id": client_id}, timeout=15)
r.raise_for_status()
d = r.json()
return {
"device_code": d["device_code"],
"user_code": d["user_code"],
"verification_url": d["verification_url"],
"expires_in": d["expires_in"],
"interval": d["interval"],
}
def poll_token(client_id: str, client_secret: str, device_code_val: str) -> Optional[Dict]:
"""Returns access_token dict on success, None if still pending, raises on error."""
r = requests.post(f"{BASE}/oauth/device/token", json={
"code": device_code_val,
"client_id": client_id,
"client_secret": client_secret,
}, timeout=15)
if r.status_code == 200:
return r.json() # {access_token, refresh_token, expires_in, created_at, ...}
if r.status_code in (400, 404): # pending / expired
return None
r.raise_for_status()
return None
def refresh(client_id: str, client_secret: str, refresh_token: str) -> Dict:
r = requests.post(f"{BASE}/oauth/token", json={
"refresh_token": refresh_token,
"client_id": client_id,
"client_secret": client_secret,
"grant_type": "refresh_token",
}, timeout=15)
r.raise_for_status()
return r.json()
def user_settings(client_id: str, access_token: str) -> Dict:
r = requests.get(f"{BASE}/users/settings", headers=_headers(client_id, access_token), timeout=15)
r.raise_for_status()
return r.json()
def _scrobble(action: str, client_id: str, access_token: str, payload: dict):
r = requests.post(f"{BASE}/scrobble/{action}",
headers=_headers(client_id, access_token),
json=payload, timeout=15)
if r.status_code in (409, 429): # duplicate / rate-limit
return None
r.raise_for_status()
return r.json()
def scrobble_movie(client_id: str, access_token: str, tmdb_id: int, progress: float, action: str = "stop"):
return _scrobble(action, client_id, access_token, {
"movie": {"ids": {"tmdb": int(tmdb_id)}},
"progress": max(0.0, min(100.0, float(progress))),
})
def scrobble_episode(client_id: str, access_token: str, tvdb_id: Optional[int], tmdb_id: Optional[int],
season: int, episode: int, progress: float, action: str = "stop"):
ids = {}
if tvdb_id: ids["tvdb"] = int(tvdb_id)
if tmdb_id: ids["tmdb"] = int(tmdb_id)
if not ids:
return None
return _scrobble(action, client_id, access_token, {
"show": {"ids": ids},
"episode": {"season": int(season), "number": int(episode)},
"progress": max(0.0, min(100.0, float(progress))),
})