Added tracking or deactivating anime via Anilist ID, it queries Anilist for the name and saves it

This commit is contained in:
florian committed 2026-10-17 16:51:28 +02:00
1 parent 162f73fe3f
commit b87c1e1896
3 files changed
+245 -2

No files matched your search

+14
View File
@@ -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"]
+130
View File
@@ -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)
+101 -2
View File
@@ -4,8 +4,11 @@ from datetime import UTC, datetime
import uvicorn import uvicorn
from fastapi import Depends, FastAPI, HTTPException, Request from fastapi import Depends, FastAPI, HTTPException, Request
from pydantic import BaseModel, Field
from seadex import EntryNotFoundError, SeaDexEntry from seadex import EntryNotFoundError, SeaDexEntry
from send_notifications import NotificationClient
from anilist_query import AniListError, AnimeNotFoundError, MissingAnimeName, get_anime_name
from db_module import ( from db_module import (
close_connection_pool, close_connection_pool,
create_connection_pool, create_connection_pool,
@@ -14,18 +17,20 @@ from db_module import (
) )
from metrics_server import REQUEST_COUNTER, metrics_api from metrics_server import REQUEST_COUNTER, metrics_api
from secret_manager import cleanup_secret_files from secret_manager import cleanup_secret_files
from send_notifications import NotificationClient
from simple_logger_handler import LOG_LEVEL, setup_logger from simple_logger_handler import LOG_LEVEL, setup_logger
logger = setup_logger(__name__) logger = setup_logger(__name__)
class AnimeCreate(BaseModel):
anilist_id: int = Field(gt=0)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
logger.info("[App] Starting application...") logger.info("[App] Starting application...")
app.state.notifier = NotificationClient() app.state.notifier = NotificationClient()
logger.info("[DB] Creating MySQL connection pool...") logger.info("[DB] Creating MySQL connection pool...")
create_connection_pool("royalRoadPool") create_connection_pool("seadexPool")
start_healthcheck_thread() start_healthcheck_thread()
logger.info("[DB] MySQL healthcheck thread started.") 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}") logger.info(f"[FastAPI] SeaDex check complete: {results}")
return 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(): async def start_servers():
logger.info("[Server] Starting main API (port 5000) and metrics server (port 9000)...") 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) config_main = uvicorn.Config(api, host="0.0.0.0", port=5000, log_level=LOG_LEVEL.lower(),log_config=None)