anicove2 / api /utils /mal_service.py
mwask's picture
Upload 124 files
19ecc0d verified
Raw
History Blame Contribute Delete
6.26 kB
"""
MyAnimeList API v2 — OAuth2 (PKCE) helpers and anime list update utilities.
"""
import os
import hashlib
import base64
import secrets
import time
import logging
import requests
from ..core.config import Config
logger = logging.getLogger(__name__)
MAL_AUTH_URL = "https://myanimelist.net/v1/oauth2/authorize"
MAL_TOKEN_URL = "https://myanimelist.net/v1/oauth2/token"
MAL_API_BASE = "https://api.myanimelist.net/v2"
# ── PKCE helpers ────────────────────────────────────────────────
def _generate_code_verifier(length: int = 128) -> str:
"""Generate a high-entropy code_verifier (43–128 chars, URL-safe)."""
return secrets.token_urlsafe(length)[:128]
def _generate_code_challenge(verifier: str) -> str:
"""MAL uses 'plain' code_challenge_method — verifier == challenge."""
return verifier
# ── OAuth2 flow ─────────────────────────────────────────────────
def get_mal_auth_url(state: str, code_verifier: str) -> str:
"""Build the MAL authorization URL (with PKCE)."""
params = {
"response_type": "code",
"client_id": Config.MAL_CLIENT_ID,
"redirect_uri": Config.MAL_REDIRECT_URI,
"state": state,
"code_challenge": _generate_code_challenge(code_verifier),
"code_challenge_method": "plain",
}
from urllib.parse import urlencode
return f"{MAL_AUTH_URL}?{urlencode(params)}"
def exchange_mal_code(code: str, code_verifier: str) -> dict | None:
"""Exchange authorization code for access + refresh tokens."""
try:
resp = requests.post(MAL_TOKEN_URL, data={
"client_id": Config.MAL_CLIENT_ID,
"client_secret": Config.MAL_CLIENT_SECRET,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": Config.MAL_REDIRECT_URI,
"code_verifier": code_verifier,
}, timeout=15)
if resp.status_code != 200:
logger.error(f"MAL token exchange failed ({resp.status_code}): {resp.text[:300]}")
return None
data = resp.json()
return {
"access_token": data["access_token"],
"refresh_token": data.get("refresh_token"),
"expires_in": data.get("expires_in", 3600),
}
except Exception as e:
logger.error(f"MAL token exchange error: {e}")
return None
def refresh_mal_token(refresh_token: str) -> dict | None:
"""Refresh an expired MAL access token."""
try:
resp = requests.post(MAL_TOKEN_URL, data={
"client_id": Config.MAL_CLIENT_ID,
"client_secret": Config.MAL_CLIENT_SECRET,
"grant_type": "refresh_token",
"refresh_token": refresh_token,
}, timeout=15)
if resp.status_code != 200:
logger.error(f"MAL token refresh failed ({resp.status_code}): {resp.text[:300]}")
return None
data = resp.json()
return {
"access_token": data["access_token"],
"refresh_token": data.get("refresh_token", refresh_token),
"expires_in": data.get("expires_in", 3600),
}
except Exception as e:
logger.error(f"MAL token refresh error: {e}")
return None
# ── Authenticated API calls ─────────────────────────────────────
def _mal_headers(access_token: str) -> dict:
return {"Authorization": f"Bearer {access_token}"}
def get_mal_user_info(access_token: str) -> dict | None:
"""GET /v2/users/@me — returns basic profile info."""
try:
resp = requests.get(
f"{MAL_API_BASE}/users/@me",
headers=_mal_headers(access_token),
params={"fields": "anime_statistics,picture"},
timeout=10,
)
if resp.status_code != 200:
logger.error(f"MAL user info failed ({resp.status_code}): {resp.text[:300]}")
return None
return resp.json()
except Exception as e:
logger.error(f"MAL user info error: {e}")
return None
def update_mal_anime_status(
access_token: str,
mal_id: int,
*,
status: str | None = None,
num_watched_episodes: int | None = None,
score: int | None = None,
) -> bool:
"""
PATCH /v2/anime/{mal_id}/my_list_status
Updates the user's list entry on MAL.
status: watching | completed | on_hold | dropped | plan_to_watch
"""
url = f"{MAL_API_BASE}/anime/{mal_id}/my_list_status"
data = {}
if status:
data["status"] = status
if num_watched_episodes is not None:
data["num_watched_episodes"] = num_watched_episodes
if score is not None:
data["score"] = score
if not data:
return True # nothing to update
try:
resp = requests.patch(
url,
headers=_mal_headers(access_token),
data=data,
timeout=10,
)
if resp.status_code == 200:
logger.info(f"MAL update OK: mal_id={mal_id} {data}")
return True
logger.error(f"MAL update failed ({resp.status_code}): {resp.text[:300]}")
return False
except Exception as e:
logger.error(f"MAL update error for mal_id={mal_id}: {e}")
return False
def get_mal_anime_status(access_token: str, mal_id: int) -> dict | None:
"""GET /v2/anime/{mal_id} with list_status field — check current progress."""
try:
resp = requests.get(
f"{MAL_API_BASE}/anime/{mal_id}",
headers=_mal_headers(access_token),
params={"fields": "my_list_status,num_episodes"},
timeout=10,
)
if resp.status_code != 200:
return None
return resp.json()
except Exception as e:
logger.error(f"MAL get status error for mal_id={mal_id}: {e}")
return None