diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1f08d14 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +# Builder stage +FROM python:3.12-slim AS builder +WORKDIR /install +COPY requirements.txt . +RUN pip install --prefix=/install --no-cache-dir -r requirements.txt +WORKDIR /app +COPY src/ /app/ + +# Runtime image +FROM nvcr.io/nvidia/distroless/python:3.12-v4.1.6 +COPY --from=builder /install /usr/local +COPY --from=builder /app /app +WORKDIR /app +ENTRYPOINT ["python", "main.py"] \ No newline at end of file diff --git a/src/anilist_query.py b/src/anilist_query.py new file mode 100644 index 0000000..9634f2c --- /dev/null +++ b/src/anilist_query.py @@ -0,0 +1,130 @@ +"""Look up anime on AniList by ID.""" +import requests + +from simple_logger_handler import setup_logger + +logger = setup_logger(__name__) + +ANILIST_API_URL = "https://graphql.anilist.co" +DEFAULT_TIMEOUT = 10 # seconds + +_ID_QUERY = """ +query ($id: Int) { + Media(id: $id, type: ANIME) { + id + title { romaji english native } + } +} +""" + +# Preference order when picking a display name. +_TITLE_PREFERENCE = ("english", "romaji", "native") + + +class AniListError(RuntimeError): + """The AniList request failed (network, HTTP, or GraphQL error).""" + + +class AnimeNotFoundError(AniListError): + """No anime exists on AniList for this ID or name.""" + + +class MissingAnimeName(AniListError): + """The anime exists but has no usable title.""" + + +def _post( + query: str, + variables: dict, + session: requests.Session | None, + timeout: float, +) -> dict: + """POST a GraphQL query and return its ``data`` dict. + + Raises: + AnimeNotFoundError: AniList answered HTTP 404 (unknown ID). + AniListError: Network failure, rate limiting, or any other error. + """ + http = session or requests + try: + response = http.post( + ANILIST_API_URL, + json={"query": query, "variables": variables}, + timeout=timeout, + ) + except requests.RequestException as e: + raise AniListError(f"Request to AniList failed: {type(e).__name__}") from e + + if response.status_code == 404: + raise AnimeNotFoundError("Not found on AniList") + + if response.status_code == 429: + retry_after = response.headers.get("Retry-After", "unknown") + raise AniListError(f"Rate limited by AniList (retry after {retry_after}s)") + + try: + response.raise_for_status() + payload = response.json() + except (requests.HTTPError, ValueError) as e: + raise AniListError(f"Bad response from AniList: {e}") from e + + if errors := payload.get("errors"): + messages = "; ".join(str(err.get("message", err)) for err in errors) + raise AniListError(f"AniList returned errors: {messages}") + + return payload.get("data") or {} + + +def _pick_name(title: dict | None) -> str | None: + """Return the best available title (English, romaji, then native).""" + title = title or {} + for key in _TITLE_PREFERENCE: + name = (title.get(key) or "").strip() + if name: + return name + return None + + +def get_anime_name( + anilist_id: int, + *, + session: requests.Session | None = None, + timeout: float = DEFAULT_TIMEOUT, +) -> str: + """Return the best available title for an AniList anime ID. + + Prefers the English title, then romaji, then native. + + Args: + anilist_id: AniList media ID (positive integer). + session: Optional ``requests.Session`` to reuse connections when + looking up many IDs. + timeout: Request timeout in seconds. + + Raises: + ValueError: ``anilist_id`` is not a positive integer. + AnimeNotFoundError: No anime exists for this ID. + MissingAnimeName: The anime exists but has no title. + AniListError: Network failure, rate limiting, or any other + AniList/HTTP error. + """ + if not isinstance(anilist_id, int) or isinstance(anilist_id, bool) or anilist_id <= 0: + raise ValueError(f"anilist_id must be a positive integer, got {anilist_id!r}") + + try: + data = _post(_ID_QUERY, {"id": anilist_id}, session, timeout) + except AnimeNotFoundError: + raise AnimeNotFoundError(f"No anime found for AniList ID={anilist_id}") from None + + media = data.get("Media") + if not media: + raise AnimeNotFoundError(f"No anime found for AniList ID={anilist_id}") + + name = _pick_name(media.get("title")) + if name is None: + raise MissingAnimeName(f"Anime has no title for AniList ID={anilist_id}") + return name + +if __name__ == "__main__": + asd = get_anime_name(2002927) + print(asd) \ No newline at end of file diff --git a/src/main.py b/src/main.py index 583d2f7..b078bf1 100644 --- a/src/main.py +++ b/src/main.py @@ -4,8 +4,11 @@ from datetime import UTC, datetime import uvicorn from fastapi import Depends, FastAPI, HTTPException, Request +from pydantic import BaseModel, Field from seadex import EntryNotFoundError, SeaDexEntry +from send_notifications import NotificationClient +from anilist_query import AniListError, AnimeNotFoundError, MissingAnimeName, get_anime_name from db_module import ( close_connection_pool, create_connection_pool, @@ -14,18 +17,20 @@ from db_module import ( ) from metrics_server import REQUEST_COUNTER, metrics_api from secret_manager import cleanup_secret_files -from send_notifications import NotificationClient from simple_logger_handler import LOG_LEVEL, setup_logger logger = setup_logger(__name__) +class AnimeCreate(BaseModel): + anilist_id: int = Field(gt=0) + @asynccontextmanager async def lifespan(app: FastAPI): logger.info("[App] Starting application...") app.state.notifier = NotificationClient() logger.info("[DB] Creating MySQL connection pool...") - create_connection_pool("royalRoadPool") + create_connection_pool("seadexPool") start_healthcheck_thread() logger.info("[DB] MySQL healthcheck thread started.") @@ -135,6 +140,100 @@ def check_seadex(request:Request, db=Depends(get_db)): logger.info(f"[FastAPI] SeaDex check complete: {results}") return results +@api.post("/add/anime") +def add_anime(anime: AnimeCreate, db=Depends(get_db)): + logger.info("[FastAPI] Received request to track anilist_id={anime.anilist_id}") + + try: + anime_name = get_anime_name(anime.anilist_id) + except AnimeNotFoundError as e: + raise HTTPException(status_code=404, detail="Not found on AniList") from e + except MissingAnimeName as e: + raise HTTPException(status_code=404, detail="The anime exists but has no usable title") from e + except AniListError as e: + logger.exception("[FastAPI] AniList request failed") + raise HTTPException(status_code=502, detail="The AniList request failed") from e + logger.debug(f"[FastAPI] Found name={anime_name} for Anilist id={anime.anilist_id}") + + with db.cursor() as cursor: + try: + cursor.execute( + "SELECT id FROM seadex_state WHERE anilist_id = %s", + (anime.anilist_id,), + ) + + if cursor.fetchone(): + logger.debug(f"[FastAPI] Found existing entry, updating name and setting to active") + cursor.execute( + "UPDATE seadex_state SET anime_name = %s, active = 1 " + "WHERE anilist_id = %s", + (anime_name, anime.anilist_id), + ) + db.commit() + return { + "status": "ok", + "detail": f"Updated existing entry name={anime_name}, " + f"Anilist ID={anime.anilist_id}", + } + + with SeaDexEntry() as seadex: + current = check_for_update(seadex, anime.anilist_id) + logger.debug("[FastAPI] Inserting new anime with last_updated={current}") + + cursor.execute( + "INSERT INTO seadex_state (anilist_id, anime_name, last_updated) " + "VALUES (%s, %s, %s)", + (anime.anilist_id, anime_name, current), + ) + db.commit() + return { + "status": "ok", + "detail": f"Created new entry name={anime_name}, " + f"Anilist ID={anime.anilist_id}", + } + + except Exception as e: + db.rollback() + logger.exception(f"[FastAPI] Failed for anilist_id={anime.anilist_id} name={anime_name}") + raise HTTPException(status_code=500) from e + +@api.post("/delete/anime") +def remove_anime(anime: AnimeCreate, db=Depends(get_db)): + logger.info(f"[FastAPI] Received request to deactivate tracking for anilist_id={anime.anilist_id}") + with db.cursor() as cursor: + try: + cursor.execute( + "SELECT active FROM seadex_state WHERE anilist_id = %s", + (anime.anilist_id,), + ) + row = cursor.fetchone() + if row and row[0]: + cursor.execute( + "UPDATE seadex_state SET active = 0 " + "WHERE anilist_id = %s", + (anime.anilist_id,), + ) + db.commit() + logger.debug(f"[FastAPI] Successfully deactivated tracking for anilist_id={anime.anilist_id}") + return { + "status": "ok", + "detail": f"Deactivated entry {anime.anilist_id}", + } + + except Exception as e: + db.rollback() + logger.exception(f"[FastAPI] Failed removeing tracking for id={anime.anilist_id}") + raise HTTPException(status_code=500) from e + + if row is None: + logger.debug(f"[FastAPI] No tracked entry for anilist_id={anime.anilist_id}") + raise HTTPException(status_code=404, detail="No tracked entry for this AniList ID") + if not row[0]: + logger.debug(f"[FastAPI] Tracking was already deactivated for anilist_id={anime.anilist_id}") + return {"status": "ok", "detail": f"Entry {anime.anilist_id} was already inactive"} + + + async def start_servers(): logger.info("[Server] Starting main API (port 5000) and metrics server (port 9000)...") config_main = uvicorn.Config(api, host="0.0.0.0", port=5000, log_level=LOG_LEVEL.lower(),log_config=None)