Added tracking or deactivating anime via Anilist ID, it queries Anilist for the name and saves it
This commit is contained in:
1 parent
162f73fe3f
commit
b87c1e1896
3 files changed
+245
-2
No files matched your search
+14
@@ -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"]
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user