Remove Shikimori API, use AnimeThemes only, switch to WebM format
- Remove ShikimoriService, use AnimeThemes API for search - Replace shikimori_id with animethemes_slug as primary identifier - Remove FFmpeg MP3 conversion, download WebM directly - Add .webm support in storage and upload endpoints - Update frontend to use animethemes_slug 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -45,8 +45,6 @@ AUDIO_FADE_DURATION=0.7
|
|||||||
# ===========================================
|
# ===========================================
|
||||||
# Openings Downloader Settings
|
# Openings Downloader Settings
|
||||||
# ===========================================
|
# ===========================================
|
||||||
DOWNLOADER_SHIKIMORI_USER_AGENT=AnimeQuiz/1.0
|
|
||||||
DOWNLOADER_SHIKIMORI_TOKEN=your_shikimori_oauth_token_here
|
|
||||||
DOWNLOADER_S3_STORAGE_LIMIT_BYTES=107374182400
|
DOWNLOADER_S3_STORAGE_LIMIT_BYTES=107374182400
|
||||||
DOWNLOADER_DOWNLOAD_TIMEOUT_SECONDS=300
|
DOWNLOADER_DOWNLOAD_TIMEOUT_SECONDS=300
|
||||||
DOWNLOADER_DEFAULT_ESTIMATED_SIZE_BYTES=6291456
|
DOWNLOADER_DEFAULT_ESTIMATED_SIZE_BYTES=6291456
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ async def get_audio(filename: str):
|
|||||||
".wav": "audio/wav",
|
".wav": "audio/wav",
|
||||||
".ogg": "audio/ogg",
|
".ogg": "audio/ogg",
|
||||||
".m4a": "audio/mp4",
|
".m4a": "audio/mp4",
|
||||||
|
".webm": "video/webm",
|
||||||
}
|
}
|
||||||
media_type = media_types.get(suffix, "audio/mpeg")
|
media_type = media_types.get(suffix, "audio/mpeg")
|
||||||
|
|
||||||
@@ -284,7 +285,7 @@ async def upload_audio(files: List[UploadFile] = File(...)):
|
|||||||
"""Upload audio files to S3."""
|
"""Upload audio files to S3."""
|
||||||
results = []
|
results = []
|
||||||
for file in files:
|
for file in files:
|
||||||
if not file.filename.lower().endswith((".mp3", ".wav", ".ogg", ".m4a")):
|
if not file.filename.lower().endswith((".mp3", ".wav", ".ogg", ".m4a", ".webm")):
|
||||||
results.append({"filename": file.filename, "success": False, "error": "Invalid file type"})
|
results.append({"filename": file.filename, "success": False, "error": "Invalid file type"})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,6 @@ from pydantic_settings import BaseSettings
|
|||||||
class DownloaderSettings(BaseSettings):
|
class DownloaderSettings(BaseSettings):
|
||||||
"""Settings for the Openings Downloader module."""
|
"""Settings for the Openings Downloader module."""
|
||||||
|
|
||||||
# Shikimori API
|
|
||||||
shikimori_user_agent: str = "AnimeQuiz/1.0"
|
|
||||||
shikimori_token: str = "" # Optional OAuth token for higher rate limits
|
|
||||||
|
|
||||||
# S3 Storage limit (100 GB default)
|
# S3 Storage limit (100 GB default)
|
||||||
s3_storage_limit_bytes: int = 107_374_182_400 # 100 GB
|
s3_storage_limit_bytes: int = 107_374_182_400 # 100 GB
|
||||||
|
|
||||||
|
|||||||
@@ -27,12 +27,11 @@ class DownloadStatus(str, enum.Enum):
|
|||||||
|
|
||||||
|
|
||||||
class Anime(Base):
|
class Anime(Base):
|
||||||
"""Anime entity from Shikimori."""
|
"""Anime entity from AnimeThemes."""
|
||||||
__tablename__ = "anime"
|
__tablename__ = "anime"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
shikimori_id: Mapped[int] = mapped_column(Integer, unique=True, index=True, nullable=False)
|
animethemes_slug: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
|
||||||
animethemes_slug: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, index=True)
|
|
||||||
|
|
||||||
title_russian: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
title_russian: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
title_english: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
title_english: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
@@ -54,7 +53,7 @@ class Anime(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Anime {self.shikimori_id}: {self.title_russian or self.title_english}>"
|
return f"<Anime {self.animethemes_slug}: {self.title_english}>"
|
||||||
|
|
||||||
|
|
||||||
class AnimeTheme(Base):
|
class AnimeTheme(Base):
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ from .schemas import (
|
|||||||
StorageStatsResponse,
|
StorageStatsResponse,
|
||||||
)
|
)
|
||||||
from .db_models import Anime, AnimeTheme, DownloadTask, DownloadStatus
|
from .db_models import Anime, AnimeTheme, DownloadTask, DownloadStatus
|
||||||
from .services.shikimori import ShikimoriService
|
|
||||||
from .services.animethemes import AnimeThemesService
|
from .services.animethemes import AnimeThemesService
|
||||||
from .services.downloader import DownloadService
|
from .services.downloader import DownloadService
|
||||||
from .services.storage_tracker import StorageTrackerService
|
from .services.storage_tracker import StorageTrackerService
|
||||||
@@ -34,30 +33,29 @@ router = APIRouter(prefix="/downloader", tags=["openings-downloader"])
|
|||||||
@router.get("/search", response_model=SearchResponse)
|
@router.get("/search", response_model=SearchResponse)
|
||||||
async def search_anime(
|
async def search_anime(
|
||||||
query: str = Query(..., min_length=1, description="Search query"),
|
query: str = Query(..., min_length=1, description="Search query"),
|
||||||
year: Optional[int] = Query(None, description="Filter by year"),
|
|
||||||
status: Optional[str] = Query(None, description="Filter by status (ongoing, released, announced)"),
|
|
||||||
limit: int = Query(20, ge=1, le=50, description="Maximum results"),
|
limit: int = Query(20, ge=1, le=50, description="Maximum results"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Search anime via Shikimori API."""
|
"""Search anime via AnimeThemes API."""
|
||||||
service = ShikimoriService()
|
service = AnimeThemesService()
|
||||||
results = await service.search(query, year=year, status=status, limit=limit)
|
results = await service.search(query, limit=limit)
|
||||||
return SearchResponse(results=results, total=len(results))
|
return SearchResponse(results=results, total=len(results))
|
||||||
|
|
||||||
|
|
||||||
# ============== Anime Detail ==============
|
# ============== Anime Detail ==============
|
||||||
|
|
||||||
@router.get("/anime/{shikimori_id}", response_model=AnimeDetailResponse)
|
@router.get("/anime/{slug:path}", response_model=AnimeDetailResponse)
|
||||||
async def get_anime_detail(
|
async def get_anime_detail(
|
||||||
shikimori_id: int,
|
slug: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get anime details with available themes from AnimeThemes."""
|
"""Get anime details with available themes from AnimeThemes."""
|
||||||
shikimori_service = ShikimoriService()
|
|
||||||
animethemes_service = AnimeThemesService()
|
animethemes_service = AnimeThemesService()
|
||||||
|
|
||||||
# Get or create anime record
|
# Get or create anime record
|
||||||
anime = await shikimori_service.get_or_create_anime(db, shikimori_id)
|
anime = await animethemes_service.get_or_create_anime(db, slug)
|
||||||
|
if not anime:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Anime not found: {slug}")
|
||||||
|
|
||||||
# Fetch themes from AnimeThemes API
|
# Fetch themes from AnimeThemes API
|
||||||
themes = await animethemes_service.fetch_themes(db, anime)
|
themes = await animethemes_service.fetch_themes(db, anime)
|
||||||
@@ -92,10 +90,8 @@ async def get_anime_detail(
|
|||||||
|
|
||||||
return AnimeDetailResponse(
|
return AnimeDetailResponse(
|
||||||
id=anime.id,
|
id=anime.id,
|
||||||
shikimori_id=anime.shikimori_id,
|
animethemes_slug=anime.animethemes_slug,
|
||||||
title_russian=anime.title_russian,
|
|
||||||
title_english=anime.title_english,
|
title_english=anime.title_english,
|
||||||
title_japanese=anime.title_japanese,
|
|
||||||
year=anime.year,
|
year=anime.year,
|
||||||
poster_url=anime.poster_url,
|
poster_url=anime.poster_url,
|
||||||
themes=theme_infos,
|
themes=theme_infos,
|
||||||
|
|||||||
@@ -5,14 +5,12 @@ from pydantic import BaseModel, Field
|
|||||||
from .db_models import ThemeType, DownloadStatus
|
from .db_models import ThemeType, DownloadStatus
|
||||||
|
|
||||||
|
|
||||||
# ============== Shikimori Search ==============
|
# ============== AnimeThemes Search ==============
|
||||||
|
|
||||||
class AnimeSearchResult(BaseModel):
|
class AnimeSearchResult(BaseModel):
|
||||||
"""Single anime search result from Shikimori."""
|
"""Single anime search result from AnimeThemes."""
|
||||||
shikimori_id: int
|
animethemes_slug: str
|
||||||
title_russian: Optional[str] = None
|
|
||||||
title_english: Optional[str] = None
|
title_english: Optional[str] = None
|
||||||
title_japanese: Optional[str] = None
|
|
||||||
year: Optional[int] = None
|
year: Optional[int] = None
|
||||||
poster_url: Optional[str] = None
|
poster_url: Optional[str] = None
|
||||||
|
|
||||||
@@ -48,10 +46,8 @@ class ThemeInfo(BaseModel):
|
|||||||
class AnimeDetailResponse(BaseModel):
|
class AnimeDetailResponse(BaseModel):
|
||||||
"""Detailed anime info with themes."""
|
"""Detailed anime info with themes."""
|
||||||
id: int
|
id: int
|
||||||
shikimori_id: int
|
animethemes_slug: str
|
||||||
title_russian: Optional[str] = None
|
|
||||||
title_english: Optional[str] = None
|
title_english: Optional[str] = None
|
||||||
title_japanese: Optional[str] = None
|
|
||||||
year: Optional[int] = None
|
year: Optional[int] = None
|
||||||
poster_url: Optional[str] = None
|
poster_url: Optional[str] = None
|
||||||
themes: List[ThemeInfo]
|
themes: List[ThemeInfo]
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
# Services for Openings Downloader
|
# Services for Openings Downloader
|
||||||
from .shikimori import ShikimoriService
|
|
||||||
from .animethemes import AnimeThemesService
|
from .animethemes import AnimeThemesService
|
||||||
from .downloader import DownloadService
|
from .downloader import DownloadService
|
||||||
from .storage_tracker import StorageTrackerService
|
from .storage_tracker import StorageTrackerService
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ShikimoriService",
|
|
||||||
"AnimeThemesService",
|
"AnimeThemesService",
|
||||||
"DownloadService",
|
"DownloadService",
|
||||||
"StorageTrackerService",
|
"StorageTrackerService",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from ..db_models import Anime, AnimeTheme, ThemeType
|
from ..db_models import Anime, AnimeTheme, ThemeType
|
||||||
|
from ..schemas import AnimeSearchResult
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -31,42 +32,101 @@ class AnimeThemesService:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.client = _get_animethemes_client()
|
self.client = _get_animethemes_client()
|
||||||
|
|
||||||
async def _find_anime_slug(self, anime: Anime) -> Optional[str]:
|
async def search(self, query: str, limit: int = 20) -> List[AnimeSearchResult]:
|
||||||
"""Find AnimeThemes slug by searching anime title."""
|
"""Search anime by query using AnimeThemes API."""
|
||||||
|
|
||||||
# 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:
|
try:
|
||||||
response = await self.client.get(
|
response = await self.client.get(
|
||||||
"/anime",
|
"/anime",
|
||||||
params={
|
params={
|
||||||
"q": term,
|
"q": query,
|
||||||
"include": "animethemes.animethemeentries.videos.audio",
|
"include": "images",
|
||||||
|
"page[size]": limit,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
response.raise_for_status()
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
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
|
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for anime in data.get("anime", []):
|
||||||
|
# Get poster URL from images
|
||||||
|
poster_url = None
|
||||||
|
images = anime.get("images", [])
|
||||||
|
if images:
|
||||||
|
# Prefer large_cover or first available
|
||||||
|
for img in images:
|
||||||
|
if img.get("facet") == "Large Cover":
|
||||||
|
poster_url = img.get("link")
|
||||||
|
break
|
||||||
|
if not poster_url and images:
|
||||||
|
poster_url = images[0].get("link")
|
||||||
|
|
||||||
|
results.append(AnimeSearchResult(
|
||||||
|
animethemes_slug=anime.get("slug"),
|
||||||
|
title_english=anime.get("name"),
|
||||||
|
year=anime.get("year"),
|
||||||
|
poster_url=poster_url,
|
||||||
|
))
|
||||||
|
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to search AnimeThemes: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_or_create_anime(self, db: AsyncSession, slug: str) -> Optional[Anime]:
|
||||||
|
"""Get anime from DB or fetch from AnimeThemes and create."""
|
||||||
|
|
||||||
|
# Check if exists (with themes eagerly loaded)
|
||||||
|
query = (
|
||||||
|
select(Anime)
|
||||||
|
.where(Anime.animethemes_slug == slug)
|
||||||
|
.options(selectinload(Anime.themes))
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
anime = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if anime:
|
||||||
|
return anime
|
||||||
|
|
||||||
|
# Fetch from AnimeThemes
|
||||||
|
try:
|
||||||
|
response = await self.client.get(
|
||||||
|
f"/anime/{slug}",
|
||||||
|
params={"include": "images"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fetch anime from AnimeThemes: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
anime_data = data.get("anime", {})
|
||||||
|
if not anime_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get poster URL
|
||||||
|
poster_url = None
|
||||||
|
images = anime_data.get("images", [])
|
||||||
|
if images:
|
||||||
|
for img in images:
|
||||||
|
if img.get("facet") == "Large Cover":
|
||||||
|
poster_url = img.get("link")
|
||||||
|
break
|
||||||
|
if not poster_url and images:
|
||||||
|
poster_url = images[0].get("link")
|
||||||
|
|
||||||
|
anime = Anime(
|
||||||
|
animethemes_slug=slug,
|
||||||
|
title_english=anime_data.get("name"),
|
||||||
|
year=anime_data.get("year"),
|
||||||
|
poster_url=poster_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(anime)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(anime)
|
||||||
|
|
||||||
|
return anime
|
||||||
|
|
||||||
async def fetch_themes(self, db: AsyncSession, anime: Anime) -> List[AnimeTheme]:
|
async def fetch_themes(self, db: AsyncSession, anime: Anime) -> List[AnimeTheme]:
|
||||||
"""Fetch themes from AnimeThemes API and sync to DB."""
|
"""Fetch themes from AnimeThemes API and sync to DB."""
|
||||||
|
|
||||||
@@ -79,17 +139,8 @@ class AnimeThemesService:
|
|||||||
anime = result.scalar_one()
|
anime = result.scalar_one()
|
||||||
current_themes = anime.themes or []
|
current_themes = anime.themes or []
|
||||||
|
|
||||||
# Find slug if not cached
|
|
||||||
if not anime.animethemes_slug:
|
if not anime.animethemes_slug:
|
||||||
logger.info(f"Searching AnimeThemes slug for: {anime.title_english or anime.title_russian}")
|
logger.warning(f"No AnimeThemes slug for anime {anime.id}")
|
||||||
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
|
return current_themes
|
||||||
|
|
||||||
# Fetch themes from AnimeThemes API
|
# Fetch themes from AnimeThemes API
|
||||||
|
|||||||
@@ -173,11 +173,10 @@ class DownloadService:
|
|||||||
if not theme.animethemes_video_url:
|
if not theme.animethemes_video_url:
|
||||||
raise ValueError("No video URL available")
|
raise ValueError("No video URL available")
|
||||||
|
|
||||||
# Download and convert in temp directory
|
# Download WebM file directly (no conversion needed)
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
tmp_path = Path(tmp_dir)
|
tmp_path = Path(tmp_dir)
|
||||||
webm_file = tmp_path / "video.webm"
|
webm_file = tmp_path / "audio.webm"
|
||||||
mp3_file = tmp_path / "audio.mp3"
|
|
||||||
|
|
||||||
# Stream download WebM file
|
# Stream download WebM file
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
@@ -192,44 +191,23 @@ class DownloadService:
|
|||||||
async for chunk in response.aiter_bytes(chunk_size=8192):
|
async for chunk in response.aiter_bytes(chunk_size=8192):
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
|
|
||||||
task.progress_percent = 40
|
|
||||||
task.status = DownloadStatus.CONVERTING
|
|
||||||
await self.db.commit()
|
|
||||||
|
|
||||||
# Convert to MP3 with FFmpeg
|
|
||||||
process = await asyncio.create_subprocess_exec(
|
|
||||||
"ffmpeg", "-i", str(webm_file),
|
|
||||||
"-vn", "-acodec", "libmp3lame", "-q:a", "2",
|
|
||||||
str(mp3_file),
|
|
||||||
"-y",
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
)
|
|
||||||
stdout, stderr = await process.communicate()
|
|
||||||
|
|
||||||
if process.returncode != 0:
|
|
||||||
raise RuntimeError(f"FFmpeg error: {stderr.decode()[:500]}")
|
|
||||||
|
|
||||||
if not mp3_file.exists():
|
|
||||||
raise RuntimeError("FFmpeg did not create output file")
|
|
||||||
|
|
||||||
task.progress_percent = 70
|
task.progress_percent = 70
|
||||||
task.status = DownloadStatus.UPLOADING
|
task.status = DownloadStatus.UPLOADING
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
|
|
||||||
# Generate safe S3 key
|
# Generate safe S3 key
|
||||||
anime_name = self._sanitize_filename(
|
anime_name = self._sanitize_filename(
|
||||||
anime.title_english or anime.title_russian or f"anime_{anime.shikimori_id}"
|
anime.title_english or f"anime_{anime.animethemes_slug}"
|
||||||
)
|
)
|
||||||
theme_name = theme.full_name
|
theme_name = theme.full_name
|
||||||
song_part = f"_{self._sanitize_filename(theme.song_title)}" if theme.song_title else ""
|
song_part = f"_{self._sanitize_filename(theme.song_title)}" if theme.song_title else ""
|
||||||
s3_key = f"audio/{anime_name}_{theme_name}{song_part}.mp3"
|
s3_key = f"audio/{anime_name}_{theme_name}{song_part}.webm"
|
||||||
|
|
||||||
# Read file and upload to S3
|
# Read file and upload to S3
|
||||||
file_data = mp3_file.read_bytes()
|
file_data = webm_file.read_bytes()
|
||||||
file_size = len(file_data)
|
file_size = len(file_data)
|
||||||
|
|
||||||
success = storage.upload_file(s3_key, file_data, "audio/mpeg")
|
success = storage.upload_file(s3_key, file_data, "video/webm")
|
||||||
if not success:
|
if not success:
|
||||||
raise RuntimeError("Failed to upload to S3")
|
raise RuntimeError("Failed to upload to S3")
|
||||||
|
|
||||||
@@ -239,7 +217,7 @@ class DownloadService:
|
|||||||
|
|
||||||
# Create Opening entity in main table
|
# Create Opening entity in main table
|
||||||
opening = Opening(
|
opening = Opening(
|
||||||
anime_name=anime.title_russian or anime.title_english or f"Anime {anime.shikimori_id}",
|
anime_name=anime.title_english or f"Anime {anime.animethemes_slug}",
|
||||||
op_number=theme_name,
|
op_number=theme_name,
|
||||||
song_name=theme.song_title,
|
song_name=theme.song_title,
|
||||||
audio_file=s3_key.replace("audio/", ""),
|
audio_file=s3_key.replace("audio/", ""),
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -117,7 +117,7 @@ class S3Storage:
|
|||||||
|
|
||||||
def list_audio_files(self) -> list[str]:
|
def list_audio_files(self) -> list[str]:
|
||||||
"""List available audio files."""
|
"""List available audio files."""
|
||||||
return self.list_files("audio/", [".mp3", ".wav", ".ogg", ".m4a"])
|
return self.list_files("audio/", [".mp3", ".wav", ".ogg", ".m4a", ".webm"])
|
||||||
|
|
||||||
def list_background_videos(self) -> list[str]:
|
def list_background_videos(self) -> list[str]:
|
||||||
"""List available background videos."""
|
"""List available background videos."""
|
||||||
@@ -167,7 +167,15 @@ class S3Storage:
|
|||||||
|
|
||||||
def upload_audio(self, filename: str, file_data: bytes) -> bool:
|
def upload_audio(self, filename: str, file_data: bytes) -> bool:
|
||||||
"""Upload audio file to S3."""
|
"""Upload audio file to S3."""
|
||||||
content_type = "audio/mpeg" if filename.lower().endswith(".mp3") else "audio/wav"
|
ext = filename.lower().split(".")[-1]
|
||||||
|
content_types = {
|
||||||
|
"mp3": "audio/mpeg",
|
||||||
|
"wav": "audio/wav",
|
||||||
|
"ogg": "audio/ogg",
|
||||||
|
"m4a": "audio/mp4",
|
||||||
|
"webm": "video/webm",
|
||||||
|
}
|
||||||
|
content_type = content_types.get(ext, "audio/mpeg")
|
||||||
return self.upload_file(f"audio/{filename}", file_data, content_type)
|
return self.upload_file(f"audio/{filename}", file_data, content_type)
|
||||||
|
|
||||||
def upload_background(self, filename: str, file_data: bytes) -> bool:
|
def upload_background(self, filename: str, file_data: bytes) -> bool:
|
||||||
|
|||||||
@@ -58,20 +58,20 @@
|
|||||||
<div class="results-grid">
|
<div class="results-grid">
|
||||||
<div
|
<div
|
||||||
v-for="anime in searchResults"
|
v-for="anime in searchResults"
|
||||||
:key="anime.shikimori_id"
|
:key="anime.animethemes_slug"
|
||||||
class="anime-card"
|
class="anime-card"
|
||||||
:class="{ selected: selectedAnime?.shikimori_id === anime.shikimori_id }"
|
:class="{ selected: selectedAnime?.animethemes_slug === anime.animethemes_slug }"
|
||||||
@click="selectAnime(anime.shikimori_id)"
|
@click="selectAnime(anime.animethemes_slug)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="anime.poster_url"
|
v-if="anime.poster_url"
|
||||||
:src="anime.poster_url"
|
:src="anime.poster_url"
|
||||||
:alt="anime.title_russian || anime.title_english"
|
:alt="anime.title_english"
|
||||||
class="anime-poster"
|
class="anime-poster"
|
||||||
/>
|
/>
|
||||||
<div v-else class="anime-poster placeholder">No Image</div>
|
<div v-else class="anime-poster placeholder">No Image</div>
|
||||||
<div class="anime-info">
|
<div class="anime-info">
|
||||||
<div class="anime-title">{{ anime.title_russian || anime.title_english }}</div>
|
<div class="anime-title">{{ anime.title_english }}</div>
|
||||||
<div class="anime-year" v-if="anime.year">{{ anime.year }}</div>
|
<div class="anime-year" v-if="anime.year">{{ anime.year }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,8 +94,7 @@
|
|||||||
class="detail-poster"
|
class="detail-poster"
|
||||||
/>
|
/>
|
||||||
<div class="detail-info">
|
<div class="detail-info">
|
||||||
<h2>{{ selectedAnime.title_russian || selectedAnime.title_english }}</h2>
|
<h2>{{ selectedAnime.title_english }}</h2>
|
||||||
<p v-if="selectedAnime.title_japanese" class="japanese-title">{{ selectedAnime.title_japanese }}</p>
|
|
||||||
<p v-if="selectedAnime.year" class="year-info">Year: {{ selectedAnime.year }}</p>
|
<p v-if="selectedAnime.year" class="year-info">Year: {{ selectedAnime.year }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,12 +246,12 @@ async function searchAnime() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectAnime(shikimoriId) {
|
async function selectAnime(slug) {
|
||||||
loadingDetail.value = true
|
loadingDetail.value = true
|
||||||
selectedAnime.value = null
|
selectedAnime.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/downloader/anime/${shikimoriId}`)
|
const response = await fetch(`/api/downloader/anime/${slug}`)
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
selectedAnime.value = data
|
selectedAnime.value = data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -274,7 +273,7 @@ async function addToQueue(themeIds) {
|
|||||||
Object.assign(queueStatus, data)
|
Object.assign(queueStatus, data)
|
||||||
// Refresh selected anime to update statuses
|
// Refresh selected anime to update statuses
|
||||||
if (selectedAnime.value) {
|
if (selectedAnime.value) {
|
||||||
await selectAnime(selectedAnime.value.shikimori_id)
|
await selectAnime(selectedAnime.value.animethemes_slug)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
alert(data.detail || 'Failed to add to queue')
|
alert(data.detail || 'Failed to add to queue')
|
||||||
@@ -296,7 +295,7 @@ async function addAllToQueue() {
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
Object.assign(queueStatus, data)
|
Object.assign(queueStatus, data)
|
||||||
await selectAnime(selectedAnime.value.shikimori_id)
|
await selectAnime(selectedAnime.value.animethemes_slug)
|
||||||
} else {
|
} else {
|
||||||
alert(data.detail || 'Failed to add all to queue')
|
alert(data.detail || 'Failed to add all to queue')
|
||||||
}
|
}
|
||||||
@@ -361,8 +360,8 @@ async function refreshStorage() {
|
|||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
await Promise.all([refreshQueue(), refreshStorage()])
|
await Promise.all([refreshQueue(), refreshStorage()])
|
||||||
// Also refresh selected anime if any
|
// Also refresh selected anime if any
|
||||||
if (selectedAnime.value?.shikimori_id) {
|
if (selectedAnime.value?.animethemes_slug) {
|
||||||
const response = await fetch(`/api/downloader/anime/${selectedAnime.value.shikimori_id}`)
|
const response = await fetch(`/api/downloader/anime/${selectedAnime.value.animethemes_slug}`)
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
selectedAnime.value = await response.json()
|
selectedAnime.value = await response.json()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user