import httpx from typing import List, Optional from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from ..db_models import Anime from ..schemas import AnimeSearchResult from ..config import downloader_settings # Shared HTTP client for Shikimori API _shikimori_client: Optional[httpx.AsyncClient] = None def _get_shikimori_client() -> httpx.AsyncClient: """Get or create shared HTTP client for Shikimori.""" global _shikimori_client if _shikimori_client is None or _shikimori_client.is_closed: headers = { "User-Agent": downloader_settings.shikimori_user_agent, } if downloader_settings.shikimori_token: headers["Authorization"] = f"Bearer {downloader_settings.shikimori_token}" _shikimori_client = httpx.AsyncClient( headers=headers, timeout=30.0, ) return _shikimori_client class ShikimoriService: """Service for Shikimori GraphQL API.""" GRAPHQL_URL = "https://shikimori.one/api/graphql" def __init__(self): self.client = _get_shikimori_client() async def search( self, query: str, year: Optional[int] = None, status: Optional[str] = None, limit: int = 20, ) -> List[AnimeSearchResult]: """Search anime by query using Shikimori GraphQL API.""" graphql_query = """ query($search: String, $limit: Int, $season: SeasonString, $status: AnimeStatusString) { animes(search: $search, limit: $limit, season: $season, status: $status) { id russian english japanese airedOn { year } poster { originalUrl } } } """ variables = { "search": query, "limit": limit, } if year: variables["season"] = str(year) if status: variables["status"] = status response = await self.client.post( self.GRAPHQL_URL, json={"query": graphql_query, "variables": variables}, ) response.raise_for_status() data = response.json() results = [] for anime in data.get("data", {}).get("animes", []): results.append(AnimeSearchResult( shikimori_id=int(anime["id"]), title_russian=anime.get("russian"), title_english=anime.get("english"), title_japanese=anime.get("japanese"), year=anime.get("airedOn", {}).get("year") if anime.get("airedOn") else None, poster_url=anime.get("poster", {}).get("originalUrl") if anime.get("poster") else None, )) return results async def get_or_create_anime(self, db: AsyncSession, shikimori_id: int) -> Anime: """Get anime from DB or fetch from Shikimori and create.""" # Check if exists (with themes eagerly loaded) query = ( select(Anime) .where(Anime.shikimori_id == shikimori_id) .options(selectinload(Anime.themes)) ) result = await db.execute(query) anime = result.scalar_one_or_none() if anime: return anime # Fetch from Shikimori graphql_query = """ query($ids: String!) { animes(ids: $ids, limit: 1) { id russian english japanese airedOn { year } poster { originalUrl } } } """ response = await self.client.post( self.GRAPHQL_URL, json={"query": graphql_query, "variables": {"ids": str(shikimori_id)}}, ) response.raise_for_status() data = response.json() animes = data.get("data", {}).get("animes", []) if not animes: raise ValueError(f"Anime with ID {shikimori_id} not found on Shikimori") anime_data = animes[0] anime = Anime( shikimori_id=shikimori_id, title_russian=anime_data.get("russian"), title_english=anime_data.get("english"), title_japanese=anime_data.get("japanese"), year=anime_data.get("airedOn", {}).get("year") if anime_data.get("airedOn") else None, poster_url=anime_data.get("poster", {}).get("originalUrl") if anime_data.get("poster") else None, ) db.add(anime) await db.commit() await db.refresh(anime) return anime