import httpx import logging import re 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, AnimeTheme, ThemeType logger = logging.getLogger(__name__) # Shared HTTP client for AnimeThemes API _animethemes_client: Optional[httpx.AsyncClient] = None def _get_animethemes_client() -> httpx.AsyncClient: """Get or create shared HTTP client for AnimeThemes.""" global _animethemes_client if _animethemes_client is None or _animethemes_client.is_closed: _animethemes_client = httpx.AsyncClient( base_url="https://api.animethemes.moe", timeout=30.0, ) return _animethemes_client class AnimeThemesService: """Service for AnimeThemes API (api.animethemes.moe).""" def __init__(self): self.client = _get_animethemes_client() async def _find_anime_slug(self, anime: Anime) -> Optional[str]: """Find AnimeThemes slug by searching anime title.""" # Try different title variations search_terms = [ anime.title_english, anime.title_russian, anime.title_japanese, ] for term in search_terms: if not term: continue try: response = await self.client.get( "/anime", params={ "q": term, "include": "animethemes.animethemeentries.videos.audio", }, ) if response.status_code == 200: data = response.json() animes = data.get("anime", []) if animes: slug = animes[0].get("slug") logger.info(f"Found AnimeThemes slug '{slug}' for '{term}'") return slug except Exception as e: logger.warning(f"Failed to search AnimeThemes for '{term}': {e}") continue return None async def fetch_themes(self, db: AsyncSession, anime: Anime) -> List[AnimeTheme]: """Fetch themes from AnimeThemes API and sync to DB.""" # Always reload anime with themes to avoid lazy loading issues result = await db.execute( select(Anime) .where(Anime.id == anime.id) .options(selectinload(Anime.themes)) ) anime = result.scalar_one() current_themes = anime.themes or [] # Find slug if not cached if not anime.animethemes_slug: logger.info(f"Searching AnimeThemes slug for: {anime.title_english or anime.title_russian}") slug = await self._find_anime_slug(anime) logger.info(f"Found slug: {slug}") if slug: anime.animethemes_slug = slug await db.commit() if not anime.animethemes_slug: logger.warning(f"No AnimeThemes slug found for anime {anime.id}: {anime.title_english or anime.title_russian}") return current_themes # Fetch themes from AnimeThemes API try: response = await self.client.get( f"/anime/{anime.animethemes_slug}", params={ "include": "animethemes.animethemeentries.videos.audio,animethemes.song.artists", }, ) if response.status_code != 200: logger.warning(f"AnimeThemes API returned {response.status_code} for {anime.animethemes_slug}") return current_themes data = response.json() except Exception as e: logger.error(f"Failed to fetch themes from AnimeThemes for {anime.animethemes_slug}: {e}") return current_themes anime_data = data.get("anime", {}) themes_data = anime_data.get("animethemes", []) logger.info(f"AnimeThemes API returned {len(themes_data)} themes for {anime.animethemes_slug}") # Build dict of existing themes existing_themes = { (t.theme_type, t.sequence): t for t in current_themes } for theme_data in themes_data: # Parse theme type and sequence: "OP1", "ED1", etc. slug = theme_data.get("slug", "") # e.g., "OP1", "ED1" match = re.match(r"(OP|ED)(\d*)", slug) if not match: continue theme_type = ThemeType.OP if match.group(1) == "OP" else ThemeType.ED sequence = int(match.group(2)) if match.group(2) else 1 # Get video URL (prioritize audio link, then video link) video_url = None entries = theme_data.get("animethemeentries", []) if entries: videos = entries[0].get("videos", []) if videos: # Try to get audio link first audio = videos[0].get("audio") if audio: video_url = audio.get("link") # Fallback to video link if not video_url: video_url = videos[0].get("link") # Get song info song_data = theme_data.get("song", {}) song_title = song_data.get("title") artist = None artists = song_data.get("artists", []) if artists: artist = artists[0].get("name") key = (theme_type, sequence) if key in existing_themes: # Update existing theme theme = existing_themes[key] theme.song_title = song_title theme.artist = artist if video_url: theme.animethemes_video_url = video_url else: # Create new theme theme = AnimeTheme( anime_id=anime.id, theme_type=theme_type, sequence=sequence, song_title=song_title, artist=artist, animethemes_video_url=video_url, ) db.add(theme) if anime.themes is None: anime.themes = [] anime.themes.append(theme) await db.commit() # Reload anime with themes to get fresh data result = await db.execute( select(Anime) .where(Anime.id == anime.id) .options(selectinload(Anime.themes)) ) refreshed_anime = result.scalar_one() return refreshed_anime.themes