From 79e8de124a0241445962e585f030b9d8b73f5a67 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 4 Nov 2026 11:17:16 +0100 Subject: [PATCH 1/7] Updated updated path for the new table layout, moved models to a new module --- database.sql | 16 ++++++++- src/main.py | 99 +++++++++++++++++++++++++++------------------------ src/models.py | 21 +++++++++++ 3 files changed, 88 insertions(+), 48 deletions(-) create mode 100644 src/models.py diff --git a/database.sql b/database.sql index c42af13..3885ed0 100644 --- a/database.sql +++ b/database.sql @@ -9,4 +9,18 @@ CREATE TABLE `seadex_state` ( PRIMARY KEY (`id`), UNIQUE KEY `uq_user_anilist` (`user_id`,`anilist_id`), CONSTRAINT `fk_seadex_state_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE -) \ No newline at end of file +) + +CREATE TABLE `seadex_state_subscriptions` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `user_id` bigint(20) NOT NULL, + `seadex_state_id` int(10) unsigned NOT NULL, + `anime_name` varchar(255) NOT NULL, + `active` tinyint(1) NOT NULL DEFAULT '1', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_user_state` (`user_id`,`seadex_state_id`), + KEY `idx_subscriptions_state` (`seadex_state_id`), + CONSTRAINT `fk_subscriptions_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE, + CONSTRAINT `fk_subscriptions_state` FOREIGN KEY (`seadex_state_id`) REFERENCES `seadex_state` (`id`) ON DELETE CASCADE +); diff --git a/src/main.py b/src/main.py index 95742e3..d8355ac 100644 --- a/src/main.py +++ b/src/main.py @@ -5,7 +5,6 @@ from datetime import UTC, datetime import uvicorn from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request -from pydantic import BaseModel, Field, computed_field from seadex import EntryNotFoundError, SeaDexEntry from service_auth import UserId @@ -19,30 +18,13 @@ 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 send_notifications import NotificationClient, NotificationUnavailableError, NotificationError from simple_logger_handler import LOG_LEVEL, setup_logger +from models import AnimeCreate,AnimeEntry,AnimeList logger = setup_logger(__name__) -class AnimeCreate(BaseModel): - anilist_id: int = Field(gt=0) -class AnimeEntry(BaseModel): - name: str - last_updated: str | None = None - -class Counts(BaseModel): - active: int - inactive: int - -class AnimeList(BaseModel): - active: list[AnimeEntry] = Field(default_factory=list) - inactive: list[AnimeEntry] = Field(default_factory=list) - - @computed_field - @property - def counts(self) -> Counts: - return Counts(active=len(self.active),inactive=len(self.inactive)) @asynccontextmanager async def lifespan(app: FastAPI): @@ -113,52 +95,75 @@ def check_seadex(request:Request, db=Depends(get_db)): with db.cursor() as cursor: cursor.execute( - "SELECT id, anilist_id, anime_name, last_updated, user_id " + "SELECT id, anilist_id, anime_name, last_updated " "FROM seadex_state WHERE active = 1" ) anime = cursor.fetchall() - cache: dict[int, str | None] = {} + logger.debug(f"[FastAPI] Found {len(anime)} active anime to check.") + if not anime[0]: + logger.info("[FastAPI] No active Seadex entries") + return results + with SeaDexEntry() as seadex: - for row_id, anilist_id, anime_name, last_updated, user_id in anime: + for seadex_state_id, anilist_id, anime_name, last_updated in anime: results["checked"] += 1 try: - if anilist_id not in cache: - cache[anilist_id] = check_for_update(seadex, anilist_id) - current = cache[anilist_id] - + current = check_for_update(seadex, anilist_id) if current is None: - logger.debug(f"[FastAPI] No entry yet for {anime_name} on SeaDex") + logger.debug(f"[FastAPI] No entry yet for Anime: {anime_name} on SeaDex") + continue + if current == last_updated: continue - if current != last_updated: - logger.info( - f"[FastAPI] {anime_name} updated, Anilist ID: {anilist_id}, User ID: {user_id}" - ) - request.app.state.notifier.send( - title=anime_name, - body="SeaDex entry updated", - link=f"https://seadex.moe/{anilist_id}/", - user_id=user_id, + logger.info(f"[FastAPI] Anime: {anime_name} updated, Anilist ID: {anilist_id}") + with db.cursor() as cursor: + cursor.execute( + "SELECT user_id, anime_name as custom_name " + "FROM seadex_state_subscriptions " + "WHERE seadex_state_id = %s AND active = 1", + (seadex_state_id,), ) + subscribers = cursor.fetchall() - with db.cursor() as cursor: - cursor.execute( - "UPDATE seadex_state SET last_updated = %s WHERE id = %s AND user_id = %s", - (current, row_id, user_id), + sent = 0 + failed = 0 + for user_id, custom_name in subscribers: + try: + request.app.state.notifier.send( + title=custom_name or anime_name, + body="SeaDex entry updated", + link=f"https://seadex.moe/{anilist_id}/", + user_id=user_id, ) - db.commit() + sent += 1 + except NotificationUnavailableError: + failed = len(subscribers) - sent + logger.error (f"[FastAPI] Notifications backend unavailable, skipping " + f"{failed} remaining user(s) for Anime: {anime_name}") + break + except NotificationError as e: + failed += 1 + logger.error(f"[FastAPI] Failed to send notification to user_id={user_id} for Anime: {anime_name} " + f"status={getattr(e, 'status_code',None)}") + if subscribers and sent == 0: + results["errors"] += 1 + logger.error(f"[FastAPI] All notifications failed for Anime: {anime_name}, will retry later") + continue - logger.debug( - f"[FastAPI] Notified and updated db for id={row_id}, " - f"Anilist ID={anilist_id}, User ID: {user_id}" + with db.cursor() as cursor: + cursor.execute( + "UPDATE seadex_state SET last_updated = %s WHERE id = %s", + (current, seadex_state_id), ) - results["new"] += 1 + db.commit() + logger.debug(f"[FastAPI] Notification sent and database updated for Anime: {anime_name}") + results["new"] += 1 except Exception: db.rollback() results["errors"] += 1 - logger.exception(f"[FastAPI] Failed for id={row_id} Anilist={anilist_id}, User ID: {user_id}",) + logger.exception(f"[FastAPI] Failed for id: {seadex_state_id} Anilist: {anilist_id}",) if results["errors"]: logger.warning(f"[FastAPI] SeaDex check finished with errors: {results}") diff --git a/src/models.py b/src/models.py new file mode 100644 index 0000000..9520cc7 --- /dev/null +++ b/src/models.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel, Field, computed_field + +class AnimeCreate(BaseModel): + anilist_id: int = Field(gt=0) + +class AnimeEntry(BaseModel): + name: str + last_updated: str | None = None + +class Counts(BaseModel): + active: int + inactive: int + +class AnimeList(BaseModel): + active: list[AnimeEntry] = Field(default_factory=list) + inactive: list[AnimeEntry] = Field(default_factory=list) + + @computed_field + @property + def counts(self) -> Counts: + return Counts(active=len(self.active),inactive=len(self.inactive)) \ No newline at end of file From be2e0857e48bb8c6e0df97cd85927700d6972e53 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 4 Nov 2026 13:02:53 +0100 Subject: [PATCH 2/7] - Moved Anilist errors to its own module and gave them status codes and messages that can propagate upstream - Created exception handler to propagate erros --- src/anilist_errors.py | 73 +++++++++++++++++ src/anilist_query.py | 177 +++++++++++++++++++++++++++++++----------- src/handlers.py | 65 ++++++++++++++++ src/main.py | 39 ++-------- src/models.py | 1 + 5 files changed, 280 insertions(+), 75 deletions(-) create mode 100644 src/anilist_errors.py create mode 100644 src/handlers.py diff --git a/src/anilist_errors.py b/src/anilist_errors.py new file mode 100644 index 0000000..41233f8 --- /dev/null +++ b/src/anilist_errors.py @@ -0,0 +1,73 @@ +from typing import ClassVar + + +class AniListError(Exception): + """Base class for every AniList error the API can explain to a client.""" + status_code: ClassVar[int] = 502 + code: ClassVar[str] = "sea_upstream_error" + client_visible: ClassVar[bool] = False + + @property + def client_message(self) -> str: + return str(self) if self.client_visible else "The Anilist entry could not be checked right now, please try again later" + @property + def headers(self) -> dict[str, str]: + """Extra HTTP headers the proxy should send with this error.""" + return {} + +class InvalidAnimeId(AniListError): + """The supplied AniList ID is not a positive integer.""" + status_code = 422 + code = "sea_invalid_anilist_id" + client_visible = True + + def __init__(self, anilist_id: object): + self.anilist_id = anilist_id + super().__init__(f"Invalid AniList ID: {anilist_id!r}") + + +class AnimeNotFound(AniListError): + """No anime exists on AniList for this ID.""" + status_code = 422 + code = "sea_anime_not_found" + client_visible = True + + def __init__(self, anilist_id: int | None): + self.anilist_id = anilist_id + super().__init__(f"Could not find the anime: {anilist_id}") + + +class MissingAnimeName(AniListError): + """The anime exists but has no usable title.""" + status_code = 422 + code = "sea_anime_missing_name" + client_visible = True + + def __init__(self, anilist_id: int): + self.anilist_id = anilist_id + super().__init__(f"Anime exists on AniList but has no name: {anilist_id}") + + +class AniListApiError(AniListError): + """Temporary or unexpected API problem (network, timeout, 5xx, bad response). + + Nothing is wrong with the AniList ID, so retry later. + """ + + def __init__(self, detail: str): + super().__init__(f"API error: {detail}") + + +class RateLimitedError(AniListError): + """AniList asked us to slow down. Stop the run instead of continuing.""" + status_code = 503 + code = "sea_rate_limited" + + def __init__(self, retry_after: int | None = None): + self.retry_after = retry_after + hint = f", retry after {retry_after}s" if retry_after is not None else "" + super().__init__(f"rate limited{hint}") + + @property + def headers(self) -> dict[str, str]: + return {"Retry-After": str(self.retry_after)} if self.retry_after is not None else {} \ No newline at end of file diff --git a/src/anilist_query.py b/src/anilist_query.py index 9634f2c..24967c4 100644 --- a/src/anilist_query.py +++ b/src/anilist_query.py @@ -1,12 +1,24 @@ -"""Look up anime on AniList by ID.""" +"""Look up anime on AniList by ID. + +This module wraps AniList's public GraphQL API (https://graphql.anilist.co) +and exposes one public function, :func:`get_anime_name`, which resolves an +AniList media ID to the best available display title. + +All failures are raised as subclasses of :class:`anilist_errors.AniListError` +so callers (and the proxy) can handle them in one place. Each error carries a +``status_code``, a stable ``code`` and a safe ``client_message``; internal +detail is logged here rather than exposed to clients. +""" + import requests +from anilist_errors import AniListApiError, AnimeNotFound, InvalidAnimeId, MissingAnimeName, RateLimitedError from simple_logger_handler import setup_logger logger = setup_logger(__name__) ANILIST_API_URL = "https://graphql.anilist.co" -DEFAULT_TIMEOUT = 10 # seconds +DEFAULT_TIMEOUT = 10 _ID_QUERY = """ query ($id: Int) { @@ -20,31 +32,50 @@ query ($id: Int) { # 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. + """POST a GraphQL query to AniList and return the ``data`` part of the reply. + + This is the only function that talks to the network. It translates every + way a request can go wrong into a :class:`AniListError` subclass, in this + order: + + 1. Transport failure (DNS, connection, timeout, ...) -> ``AniListApiError`` + 2. HTTP 404 -> ``AnimeNotFound`` + 3. HTTP 429 -> ``RateLimitedError`` + 4. Other HTTP error status or body that isn't JSON -> ``AniListApiError`` + 5. JSON body that isn't an object -> ``AniListApiError`` + 6. GraphQL ``errors`` array present -> ``AniListApiError`` + 7. ``data`` present but not an object -> ``AniListApiError`` + + A missing or ``null`` ``data`` field is not an error here; it is returned + as an empty dict and the caller decides what that means. + + Args: + query: The GraphQL query string. + variables: Variables for the query. The ``"id"`` key, if present, is + used to identify the anime in a 404 error. + session: Optional ``requests.Session``. When ``None``, a one-off + ``requests.post`` is used. + timeout: Request timeout in seconds. + + Returns: + The ``data`` dict from the GraphQL response, or ``{}`` if AniList + returned no ``data``. Raises: - AnimeNotFoundError: AniList answered HTTP 404 (unknown ID). - AniListError: Network failure, rate limiting, or any other error. + AnimeNotFound: AniList answered HTTP 404 (unknown ID). + RateLimitedError: AniList answered HTTP 429. ``retry_after`` is set + when the ``Retry-After`` header was a valid integer. + AniListApiError: Network failure, non-2xx status, invalid or + unexpected JSON, or a GraphQL-level error. """ + + anime_id = variables.get("id") http = session or requests try: response = http.post( @@ -53,38 +84,69 @@ def _post( timeout=timeout, ) except requests.RequestException as e: - raise AniListError(f"Request to AniList failed: {type(e).__name__}") from e + raise AniListApiError(f"Request to AniList failed: {type(e).__name__}") from e if response.status_code == 404: - raise AnimeNotFoundError("Not found on AniList") + logger.warning("AniList returned 404 for id=%s", anime_id) + raise AnimeNotFound(variables.get("id")) 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)") + logger.warning("Rate limited by AniList on id=%s (Retry-After=%s)", anime_id, retry_after,) + raise RateLimitedError(int(retry_after) if retry_after.isdigit() else None) try: response.raise_for_status() payload = response.json() except (requests.HTTPError, ValueError) as e: - raise AniListError(f"Bad response from AniList: {e}") from e + logger.warning("Bad response from AniList: %s", e) + raise AniListApiError(f"Bad response from AniList: {e}") from e + + if not isinstance(payload, dict): + logger.warning("Unexpected AniList payload type: %s", type(payload).__name__) + raise AniListApiError(f"Unexpected response shape: {type(payload).__name__}") if errors := payload.get("errors"): messages = "; ".join(str(err.get("message", err)) for err in errors) - raise AniListError(f"AniList returned errors: {messages}") + raise AniListApiError(f"AniList returned errors: {messages}") - return payload.get("data") or {} + data = payload.get("data") + if data is None: + logger.debug("AniList response for id=%s had no 'data'; returning empty dict", anime_id) + return {} + if not isinstance(data, dict): + logger.warning("Unexpected 'data' shape for id=%s: %s", anime_id, type(data).__name__,) + raise AniListApiError(f"Unexpected 'data' shape: {type(data).__name__}") + logger.debug("Parsed AniList data for id=%s: keys=%s", anime_id, list(data)) + return data def _pick_name(title: dict | None) -> str | None: - """Return the best available title (English, romaji, then native).""" + """Choose the best display name from an AniList ``title`` object. + + Tries each key in ``_TITLE_PREFERENCE`` (English, then romaji, then + native) and returns the first one that is non-empty after stripping + whitespace. Missing keys, ``None`` values and blank strings are skipped. + + Args: + title: The ``title`` object from AniList, e.g. + ``{"romaji": "...", "english": None, "native": "..."}``. + ``None`` is treated the same as an empty dict. + + Returns: + The chosen title, or ``None`` if no usable title exists. + """ title = title or {} for key in _TITLE_PREFERENCE: name = (title.get(key) or "").strip() if name: + logger.debug("Picked '%s' title: %r", key, name) return name + logger.debug("No usable title among keys %s in %s", _TITLE_PREFERENCE, title) return None + def get_anime_name( anilist_id: int, *, @@ -93,38 +155,65 @@ def get_anime_name( ) -> str: """Return the best available title for an AniList anime ID. - Prefers the English title, then romaji, then native. + Validates the ID, queries AniList, and picks a title in the order + English, romaji, native. Only media of type ``ANIME`` match; a manga or + other media ID is treated as not found. + + Every failure is raised as an :class:`AniListError` subclass, so a single + ``except AniListError`` is enough to catch them all. Errors with + ``client_visible = True`` are safe to show to end users; the rest carry + internal detail and should only be logged. Args: - anilist_id: AniList media ID (positive integer). + anilist_id: AniList media ID. Must be a positive ``int`` + (``bool`` is rejected even though it is an ``int`` subclass). session: Optional ``requests.Session`` to reuse connections when looking up many IDs. - timeout: Request timeout in seconds. + timeout: Request timeout in seconds. Defaults to ``DEFAULT_TIMEOUT``. + + Returns: + The anime's display title, stripped of surrounding whitespace. 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}") + InvalidAnimeId: ``anilist_id`` is not a positive integer. No request + is made. Client-visible (422). + AnimeNotFound: No anime exists for this ID, either via HTTP 404 or + an empty ``Media`` result. Client-visible (422). + MissingAnimeName: The anime exists but none of its titles are usable. + Client-visible (422). + RateLimitedError: AniList rate limited the request (503). Callers + running in a loop should stop rather than keep going; see + ``retry_after`` on the exception. + AniListApiError: Network failure, timeout, 5xx, malformed response or + GraphQL error. Not client-visible (502); safe to retry later. - 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 + Example: + >>> get_anime_name(1) + 'Cowboy Bebop' + """ + + if not isinstance(anilist_id, int) or isinstance(anilist_id, bool) or anilist_id <= 0: + logger.warning("Rejected invalid anilist_id: %r", anilist_id) + raise InvalidAnimeId(anilist_id) + data = _post(_ID_QUERY, {"id": anilist_id}, session, timeout) media = data.get("Media") if not media: - raise AnimeNotFoundError(f"No anime found for AniList ID={anilist_id}") + logger.warning("AniList returned no Media for anilist_id=%s", anilist_id) + raise AnimeNotFound(anilist_id) name = _pick_name(media.get("title")) if name is None: - raise MissingAnimeName(f"Anime has no title for AniList ID={anilist_id}") + logger.warning("Anime anilist_id=%s exists but has no usable title", anilist_id) + raise MissingAnimeName(anilist_id) + + logger.info("Resolved anilist_id=%s to %r", anilist_id, name) return name if __name__ == "__main__": - asd = get_anime_name(2002927) - print(asd) \ No newline at end of file + from anilist_errors import AniListError + try: + print(get_anime_name(200927)) + print(get_anime_name(999999)) + except AniListError as e: + print(f"{e.code} ({e.status_code}): {e.client_message}") \ No newline at end of file diff --git a/src/handlers.py b/src/handlers.py new file mode 100644 index 0000000..db655e3 --- /dev/null +++ b/src/handlers.py @@ -0,0 +1,65 @@ +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware + +from anilist_errors import AniListError +from metrics_server import REQUEST_COUNTER +from simple_logger_handler import setup_logger + +logger = setup_logger(__name__) + + +async def anilist_error_handler(request: Request, exc: AniListError): + log = logger.warning if exc.client_visible else logger.error + log(f"[FastAPI] {request.method} {request.url.path} failed: {exc}") + return JSONResponse( + status_code=exc.status_code, + content={"code": exc.code, "detail": exc.client_message}, + ) + +async def validation_error_handler(request: Request, exc: RequestValidationError): + errors = exc.errors() + + for err in errors: + original = (err.get("ctx") or {}).get("error") + if isinstance(original, AniListError): + return JSONResponse( + status_code=original.status_code, + content={"code": original.code, "detail": original.client_message}, + ) + + # Problems in headers, path or query are the calling service's fault, not the user's + if any(err["loc"][0] != "body" for err in errors): + logger.error(f"[FastAPI] Invalid request from a calling service: {[(e['loc'], e['msg']) for e in errors]}") + return JSONResponse(status_code=400, content={"code": "bad_request", "detail": "The request could not be processed"}) + + messages = [] + for err in errors: + field = ".".join(str(p) for p in err["loc"] if p != "body") + msg = err["msg"].removeprefix("Value error, ") + messages.append(f"{field}: {msg}" if field else msg) + return JSONResponse(status_code=422, content={"code": "validation_error", "detail": "; ".join(messages)}) + + +async def prometheus_middleware(request: Request, call_next): + logger.debug(f"[FastAPI] Incoming request: {request.method} {request.url.path}") + status = 500 + try: + response = await call_next(request) + status = response.status_code + logger.debug(f"[FastAPI] Request processed with status {status}") + except Exception as e: + logger.error(f"[FastAPI] Exception occurred: {e}", exc_info=True) + raise + finally: + REQUEST_COUNTER.labels(request.method, request.url.path, status).inc() + logger.debug(f"[FastAPI] Counter incremented for {request.method} {request.url.path} [{status}]") + return response + + +def register_handlers(app: FastAPI) -> None: + """Attach the error handlers and the metrics middleware to the app.""" + app.add_exception_handler(AniListError, anilist_error_handler) + app.add_exception_handler(RequestValidationError, validation_error_handler) + app.add_middleware(BaseHTTPMiddleware, dispatch=prometheus_middleware) diff --git a/src/main.py b/src/main.py index d8355ac..de55177 100644 --- a/src/main.py +++ b/src/main.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request from seadex import EntryNotFoundError, SeaDexEntry from service_auth import UserId -from anilist_query import AniListError, AnimeNotFoundError, MissingAnimeName, get_anime_name +from anilist_query import get_anime_name from auth_config import Caller, auth from db_module import ( close_connection_pool, @@ -16,11 +16,12 @@ from db_module import ( get_db, start_healthcheck_thread, ) -from metrics_server import REQUEST_COUNTER, metrics_api +from handlers import register_handlers +from metrics_server import metrics_api +from models import AnimeCreate, AnimeEntry, AnimeList from secret_manager import cleanup_secret_files -from send_notifications import NotificationClient, NotificationUnavailableError, NotificationError +from send_notifications import NotificationClient, NotificationError, NotificationUnavailableError from simple_logger_handler import LOG_LEVEL, setup_logger -from models import AnimeCreate,AnimeEntry,AnimeList logger = setup_logger(__name__) @@ -58,26 +59,10 @@ api = FastAPI( dependencies=[Depends(auth.authenticate)], ) +register_handlers(api) user_router = APIRouter(dependencies=[Depends(auth.require(Caller.GATEWAY))]) scheduler_router = APIRouter(dependencies=[Depends(auth.require(Caller.SCHEDULER))]) -@api.middleware("http") -async def prometheus_middleware(request: Request, call_next): - logger.debug(f"[Metrics] Incoming request: {request.method} {request.url.path}") - status = 500 - try: - response = await call_next(request) - status = response.status_code - logger.debug(f"[Metrics] Request processed with status {status}") - except Exception: - logger.exception("[Metrics] Exception occurred:") - raise - finally: - REQUEST_COUNTER.labels(request.method, request.url.path, status).inc() - logger.debug(f"[Metrics] Counter incremented for {request.method} {request.url.path} [{status}]") - return response - - def to_iso(dt: datetime) -> str: return dt.astimezone(UTC).isoformat(timespec="microseconds") @@ -175,16 +160,8 @@ def check_seadex(request:Request, db=Depends(get_db)): @user_router.post("/add/anime") def add_anime(anime: AnimeCreate, user_id: UserId, db=Depends(get_db)): logger.info(f"[FastAPI] Received request to track anilist_id={anime.anilist_id}, user_id={user_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 + + anime_name = get_anime_name(anime.anilist_id) logger.debug(f"[FastAPI] Found name={anime_name} for Anilist id={anime.anilist_id}") with db.cursor() as cursor: diff --git a/src/models.py b/src/models.py index 9520cc7..73a1413 100644 --- a/src/models.py +++ b/src/models.py @@ -1,5 +1,6 @@ from pydantic import BaseModel, Field, computed_field + class AnimeCreate(BaseModel): anilist_id: int = Field(gt=0) From fbad8a4390b2a236ea40000611adef1f8c335697 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 4 Nov 2026 13:56:57 +0100 Subject: [PATCH 3/7] Updated add path for the new table layout --- src/main.py | 76 ++++++++++++++++++++++++++++----------------------- src/models.py | 1 + 2 files changed, 43 insertions(+), 34 deletions(-) diff --git a/src/main.py b/src/main.py index de55177..5924bd3 100644 --- a/src/main.py +++ b/src/main.py @@ -158,54 +158,62 @@ def check_seadex(request:Request, db=Depends(get_db)): return results @user_router.post("/add/anime") -def add_anime(anime: AnimeCreate, user_id: UserId, db=Depends(get_db)): - logger.info(f"[FastAPI] Received request to track anilist_id={anime.anilist_id}, user_id={user_id}") +def add_anime( + anime: AnimeCreate, + user_id: UserId, + db=Depends(get_db), + ): + logger.info("[FastAPI] Received request to track anilist_id=%s, user_id=%s", anime.anilist_id, user_id) - anime_name = get_anime_name(anime.anilist_id) - logger.debug(f"[FastAPI] Found name={anime_name} for Anilist id={anime.anilist_id}") + with db.cursor() as cursor: + cursor.execute( + "SELECT id, active FROM seadex_state WHERE anilist_id = %s", + (anime.anilist_id,), + ) + known = cursor.fetchone() + + last_updated = None + with SeaDexEntry() as seadex: + if known is None: + anime_name = get_anime_name(anime.anilist_id) + last_updated = check_for_update(seadex, anime.anilist_id) + else: + anime_name, active = known + if not active: + last_updated = check_for_update(seadex, anime.anilist_id) with db.cursor() as cursor: try: cursor.execute( - "SELECT id FROM seadex_state WHERE anilist_id = %s AND user_id = %s", - (anime.anilist_id, user_id), + "INSERT INTO seadex_state (anilist_id, anime_name, last_updated) " + "VALUES (%s, %s, %s) " + "ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id), active = 1, " + "last_updated = COALESCE(VALUES(last_updated), last_updated)", + (anime.anilist_id, anime_name, last_updated), ) - - if cursor.fetchone(): - logger.debug("[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 AND user_id=%s", - (anime_name, anime.anilist_id, user_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(f"[FastAPI] Inserting new anime with last_updated={current}") + seadex_state_id = cursor.lastrowid cursor.execute( - "INSERT INTO seadex_state (anilist_id, anime_name, last_updated, user_id) " - "VALUES (%s, %s, %s, %s)", - (anime.anilist_id, anime_name, current, user_id), + "INSERT INTO seadex_state_subscriptions (user_id, seadex_state_id, custom_name) " + "VALUES (%s, %s, %s) " + "ON DUPLICATE KEY UPDATE active = 1, custom_name = VALUES(custom_name)", + (user_id, seadex_state_id, anime.custom_name or None), ) 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}, user_id={user_id}") + logger.exception( + "[FastAPI] Failed for anilist_id=%s, name=%s, user_id=%s", + anime.anilist_id, anime_name, user_id, + ) raise HTTPException(status_code=500) from e + logger.info( + "[FastAPI] Tracking anilist_id=%s name=%r for user_id=%s (state_id=%s)", + anime.anilist_id, anime_name, user_id, seadex_state_id, + ) + return {"status": "ok", "detail": f"Tracking name={anime.custom_name or anime_name}, Anilist ID={anime.anilist_id}"} + @user_router.post("/delete/anime") def remove_anime(anime: AnimeCreate, user_id: UserId, db=Depends(get_db)): logger.info(f"[FastAPI] Received request to deactivate tracking for anilist_id={anime.anilist_id}, user_id={user_id}") diff --git a/src/models.py b/src/models.py index 73a1413..c6a4b6b 100644 --- a/src/models.py +++ b/src/models.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, Field, computed_field class AnimeCreate(BaseModel): anilist_id: int = Field(gt=0) + custom_name: str | None = Field(default=None, max_length=255) class AnimeEntry(BaseModel): name: str From 84da6184fd7ea7825334fa587720ca1de39f2d7e Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 4 Nov 2026 16:48:41 +0100 Subject: [PATCH 4/7] Updated delete path for the new table layout --- src/main.py | 76 +++++++++++++++++++++++++++++++++------------------ src/models.py | 3 ++ 2 files changed, 53 insertions(+), 26 deletions(-) diff --git a/src/main.py b/src/main.py index 5924bd3..1e38a0b 100644 --- a/src/main.py +++ b/src/main.py @@ -18,7 +18,7 @@ from db_module import ( ) from handlers import register_handlers from metrics_server import metrics_api -from models import AnimeCreate, AnimeEntry, AnimeList +from models import AnimeCreate, AnimeEntry, AnimeList, AnimeRemove from secret_manager import cleanup_secret_files from send_notifications import NotificationClient, NotificationError, NotificationUnavailableError from simple_logger_handler import LOG_LEVEL, setup_logger @@ -104,7 +104,7 @@ def check_seadex(request:Request, db=Depends(get_db)): logger.info(f"[FastAPI] Anime: {anime_name} updated, Anilist ID: {anilist_id}") with db.cursor() as cursor: cursor.execute( - "SELECT user_id, anime_name as custom_name " + "SELECT user_id, custom_name " "FROM seadex_state_subscriptions " "WHERE seadex_state_id = %s AND active = 1", (seadex_state_id,), @@ -215,39 +215,63 @@ def add_anime( return {"status": "ok", "detail": f"Tracking name={anime.custom_name or anime_name}, Anilist ID={anime.anilist_id}"} @user_router.post("/delete/anime") -def remove_anime(anime: AnimeCreate, user_id: UserId, db=Depends(get_db)): - logger.info(f"[FastAPI] Received request to deactivate tracking for anilist_id={anime.anilist_id}, user_id={user_id}") +def remove_anime( + anime: AnimeRemove, + user_id: UserId, + db=Depends(get_db) +): + logger.info( + "[FastAPI] Received request to deactivate anilist_id=%s for user_id=%s", + anime.anilist_id, user_id, + ) with db.cursor() as cursor: try: cursor.execute( - "SELECT active FROM seadex_state WHERE anilist_id = %s AND user_id = %s", - (anime.anilist_id, user_id), + "SELECT id FROM seadex_state " + "WHERE anilist_id = %s FOR UPDATE", + (anime.anilist_id,), ) - row = cursor.fetchone() - if row and row[0]: - cursor.execute( - "UPDATE seadex_state SET active = 0 " - "WHERE anilist_id = %s AND user_id = %s", - (anime.anilist_id, user_id), - ) - db.commit() - logger.debug(f"[FastAPI] Successfully deactivated tracking for anilist_id={anime.anilist_id}, user_id={user_id}") - return { - "status": "ok", - "detail": f"Deactivated entry {anime.anilist_id}", - } + known = cursor.fetchone() + if known is None: + db.rollback() + logger.info("[FastAPI] Anime anilist_id=%s is not tracked, nothing to do", anime.anilist_id) + return {"status": "ok", "detail": f"Stopped tracking Anilist ID={anime.anilist_id}"} + + seadex_state_id = known[0] + cursor.execute( + "UPDATE seadex_state_subscriptions SET active = 0 " + "WHERE user_id = %s AND seadex_state_id = %s", + (user_id, seadex_state_id), + ) + + cursor.execute( + "SELECT 1 from seadex_state_subscriptions " + "WHERE seadex_state_id = %s AND active = 1 LIMIT 1", + (seadex_state_id,), + ) + + if cursor.fetchone() is None: + cursor.execute( + "UPDATE seadex_state SET active = 0 WHERE id = %s", + (seadex_state_id,), + ) + logger.info("[FastAPI] No active subscribers left, deactivated state_id=%s", seadex_state_id) + + db.commit() except Exception as e: db.rollback() - logger.exception(f"[FastAPI] Failed removeing tracking for id={anime.anilist_id}, user_id={user_id}") + logger.exception( + "[FastAPI] Failed to unsubscribe anime anilist_id=%s for user_id=%s", + anime.anilist_id, user_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}, user_id={user_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}, user_id={user_id}") - return {"status": "ok", "detail": f"Entry {anime.anilist_id} was already inactive"} + logger.info( + "[FastAPI] Successfully deactivated tracking for anilist_id=%s for user_id=%s", + anime.anilist_id, user_id, + ) + return {"status": "ok", "detail": f"Stopped tracking Anilist ID={anime.anilist_id}"} @user_router.get("/list/anime", response_model=AnimeList) def list_active_and_inactive_anime(user_id: UserId, db=Depends(get_db)): diff --git a/src/models.py b/src/models.py index c6a4b6b..8ce5d78 100644 --- a/src/models.py +++ b/src/models.py @@ -5,6 +5,9 @@ class AnimeCreate(BaseModel): anilist_id: int = Field(gt=0) custom_name: str | None = Field(default=None, max_length=255) +class AnimeRemove(BaseModel): + anilist_id: int + class AnimeEntry(BaseModel): name: str last_updated: str | None = None From d05bf55dd47c8b7127a1e9eb6089842d9948863f Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 4 Nov 2026 17:55:56 +0100 Subject: [PATCH 5/7] Updated list path for the new table layout --- src/main.py | 81 +++++++++++++++++++++++++++++++-------------------- src/models.py | 1 + 2 files changed, 50 insertions(+), 32 deletions(-) diff --git a/src/main.py b/src/main.py index 1e38a0b..b3f6922 100644 --- a/src/main.py +++ b/src/main.py @@ -85,8 +85,8 @@ def check_seadex(request:Request, db=Depends(get_db)): ) anime = cursor.fetchall() - logger.debug(f"[FastAPI] Found {len(anime)} active anime to check.") - if not anime[0]: + logger.debug("[FastAPI] Found %s active anime to check.", len(anime)) + if not anime: logger.info("[FastAPI] No active Seadex entries") return results @@ -96,12 +96,15 @@ def check_seadex(request:Request, db=Depends(get_db)): try: current = check_for_update(seadex, anilist_id) if current is None: - logger.debug(f"[FastAPI] No entry yet for Anime: {anime_name} on SeaDex") + logger.debug("[FastAPI] No entry yet for Anime: %s on SeaDex", anime_name) continue if current == last_updated: continue - logger.info(f"[FastAPI] Anime: {anime_name} updated, Anilist ID: {anilist_id}") + logger.info( + "[FastAPI] Anime: %s updated, Anilist ID: %s", + anime_name,anilist_id, + ) with db.cursor() as cursor: cursor.execute( "SELECT user_id, custom_name " @@ -124,16 +127,20 @@ def check_seadex(request:Request, db=Depends(get_db)): sent += 1 except NotificationUnavailableError: failed = len(subscribers) - sent - logger.error (f"[FastAPI] Notifications backend unavailable, skipping " - f"{failed} remaining user(s) for Anime: {anime_name}") + logger.error("[FastAPI] Notifications backend unavailable, skipping " + "%s remaining user(s) for Anime: %s", + failed, anime_name, + ) break except NotificationError as e: failed += 1 - logger.error(f"[FastAPI] Failed to send notification to user_id={user_id} for Anime: {anime_name} " - f"status={getattr(e, 'status_code',None)}") + logger.error( + "[FastAPI] Failed to send notification to user_id=%s for Anime: %s, status=%s", + user_id, anime_name, getattr(e, 'status_code',None), + ) if subscribers and sent == 0: results["errors"] += 1 - logger.error(f"[FastAPI] All notifications failed for Anime: {anime_name}, will retry later") + logger.error("[FastAPI] All notifications failed for Anime: %s, will retry later", anime_name) continue with db.cursor() as cursor: @@ -142,22 +149,25 @@ def check_seadex(request:Request, db=Depends(get_db)): (current, seadex_state_id), ) db.commit() - logger.debug(f"[FastAPI] Notification sent and database updated for Anime: {anime_name}") + logger.debug("[FastAPI] Notification sent and database updated for Anime: %s", anime_name) results["new"] += 1 except Exception: db.rollback() results["errors"] += 1 - logger.exception(f"[FastAPI] Failed for id: {seadex_state_id} Anilist: {anilist_id}",) + logger.exception( + "[FastAPI] Failed for id: %s Anilist: %s", + seadex_state_id, anilist_id, + ) if results["errors"]: - logger.warning(f"[FastAPI] SeaDex check finished with errors: {results}") + logger.warning("[FastAPI] SeaDex check finished with errors: %s", results) raise HTTPException(status_code=500, detail=results) - logger.info(f"[FastAPI] SeaDex check complete: {results}") + logger.info("[FastAPI] SeaDex check complete: %s", results) return results -@user_router.post("/add/anime") +@user_router.post("/anime/add") def add_anime( anime: AnimeCreate, user_id: UserId, @@ -214,7 +224,7 @@ def add_anime( ) return {"status": "ok", "detail": f"Tracking name={anime.custom_name or anime_name}, Anilist ID={anime.anilist_id}"} -@user_router.post("/delete/anime") +@user_router.post("/anime/delete") def remove_anime( anime: AnimeRemove, user_id: UserId, @@ -273,37 +283,44 @@ def remove_anime( ) return {"status": "ok", "detail": f"Stopped tracking Anilist ID={anime.anilist_id}"} -@user_router.get("/list/anime", response_model=AnimeList) -def list_active_and_inactive_anime(user_id: UserId, db=Depends(get_db)): - logger.info(f"[FastAPI] Received request to list anime for User ID = {user_id}") - with db.cursor() as cursor: - try: +@user_router.get("/anime/list", response_model=AnimeList) +def list_active_and_inactive_anime( + user_id: UserId, + db=Depends(get_db), + ): + logger.info("[FastAPI] Received request to list anime for User ID = %s", user_id) + + try: + with db.cursor() as cursor: cursor.execute( - "SELECT anime_name, active, last_updated " \ - "FROM seadex_state WHERE user_id = %s " - "ORDER BY anime_name", + "SELECT s.custom_name AS custom_name, st.anime_name AS anime_name, " + "st.anilist_id, s.active, st.last_updated from seadex_state_subscriptions AS s " + "JOIN seadex_state AS st on s.seadex_state_id = st.id " + "WHERE s.user_id = %s ORDER BY st.anilist_id", (user_id,), ) rows = cursor.fetchall() - except Exception as e: - logger.exception("[FastAPI] Failed to list anime") - raise HTTPException(status_code=500) from e - anime_list=AnimeList () - for name, is_active, last_updated in rows: + anime_list=AnimeList() + for custom_name, anime_name, anilist_id, is_active, last_updated in rows: entry = AnimeEntry( - name=name, - last_updated=None if last_updated is None else str(last_updated), + name=custom_name or anime_name, + anilist_id=anilist_id, + last_updated=None if last_updated is None else last_updated, ) (anime_list.active if is_active else anime_list.inactive).append(entry) - return anime_list + except Exception as e: + logger.exception("[FastAPI] Failed to list anime") + raise HTTPException(status_code=500) from e + + return anime_list api.include_router(user_router) api.include_router(scheduler_router) async def start_servers(): logger.info("[Server] Starting main API (port 5000) and metrics server (port 9000)...") - logger.info(f"[Server] Build commit id={os.getenv("COMMIT_ID","unknown")}") + logger.info("[Server] Build commit id=%s", os.getenv("COMMIT_ID", "unknown")) auth.load_keys(api, Caller.GATEWAY, Caller.SCHEDULER) cleanup_secret_files(auth.key_paths(Caller.GATEWAY, Caller.SCHEDULER)) diff --git a/src/models.py b/src/models.py index 8ce5d78..4796380 100644 --- a/src/models.py +++ b/src/models.py @@ -10,6 +10,7 @@ class AnimeRemove(BaseModel): class AnimeEntry(BaseModel): name: str + anilist_id: int last_updated: str | None = None class Counts(BaseModel): From f64f613ab486ec7c85ddd1dc2e4063a8741bc3b5 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 4 Nov 2026 18:37:57 +0100 Subject: [PATCH 6/7] Added admin route to update all names --- src/auth_config.py | 2 ++ src/main.py | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/auth_config.py b/src/auth_config.py index 43c3784..07592b9 100644 --- a/src/auth_config.py +++ b/src/auth_config.py @@ -6,8 +6,10 @@ from service_auth import KeySource, ServiceAuth class Caller(StrEnum): GATEWAY = "gateway" SCHEDULER = "scheduler" + ADMIN = "admin" auth = ServiceAuth({ Caller.GATEWAY: KeySource("GATEWAY_KEY", "/etc/secrets/gateway_key"), Caller.SCHEDULER: KeySource("SCHEDULER_KEY", "/etc/secrets/scheduler_key"), + Caller.ADMIN: KeySource("ADMIN_KEY","/etc/secrets/admin_key") }) \ No newline at end of file diff --git a/src/main.py b/src/main.py index b3f6922..b563f73 100644 --- a/src/main.py +++ b/src/main.py @@ -62,6 +62,7 @@ api = FastAPI( register_handlers(api) user_router = APIRouter(dependencies=[Depends(auth.require(Caller.GATEWAY))]) scheduler_router = APIRouter(dependencies=[Depends(auth.require(Caller.SCHEDULER))]) +admin_router = APIRouter(dependencies=[Depends(auth.require(Caller.ADMIN))]) def to_iso(dt: datetime) -> str: return dt.astimezone(UTC).isoformat(timespec="microseconds") @@ -315,15 +316,45 @@ def list_active_and_inactive_anime( return anime_list +@admin_router.post("/admin/update/names") +def update_all_names_to_most_recently_available(db=Depends(get_db)): + logger.info("[FastAPI] ADMIN: Got request to update all existing names") + try: + with db.cursor() as cursor: + cursor.execute( + "SELECT id, anilist_id from seadex_state" + ) + rows = cursor.fetchall() + for id, anilist_id in rows: + anime_name = get_anime_name(anilist_id) + cursor.execute( + "UPDATE seadex_state SET anime_name = %s " + "WHERE id = %s", + (anime_name,id), + ) + db.commit() + logger.info( + "[FastAPI] ADMIN: Updated id=%s, anilist_id=%s with name=%s", + id, anilist_id, anime_name, + ) + except Exception as e: + db.rollback() + logger.exception("[FastAPI] ADMIN: Failed to update all existing names") + raise HTTPException(status_code=500) from e + + logger.info("[FastAPI] ADMIN: Updated %s entries", len(rows)) + return {"status": "ok", "detail": f"Updated {len(rows)} entries"} + api.include_router(user_router) api.include_router(scheduler_router) +api.include_router(admin_router) async def start_servers(): logger.info("[Server] Starting main API (port 5000) and metrics server (port 9000)...") logger.info("[Server] Build commit id=%s", os.getenv("COMMIT_ID", "unknown")) - auth.load_keys(api, Caller.GATEWAY, Caller.SCHEDULER) - cleanup_secret_files(auth.key_paths(Caller.GATEWAY, Caller.SCHEDULER)) + auth.load_keys(api, Caller.GATEWAY, Caller.SCHEDULER, Caller.ADMIN) + cleanup_secret_files(auth.key_paths(Caller.GATEWAY, Caller.SCHEDULER, Caller.ADMIN)) config_main = uvicorn.Config(api, host="0.0.0.0", port=5000, log_level=LOG_LEVEL.lower(),log_config=None) config_metrics = uvicorn.Config(metrics_api, host="0.0.0.0", port=9000, log_level=LOG_LEVEL.lower(),log_config=None) From b1976d1c97bf4a95193128677466b7ef97b90c0f Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 4 Nov 2026 18:44:04 +0100 Subject: [PATCH 7/7] Changed name field for custom name --- database.sql | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/database.sql b/database.sql index 3885ed0..923876d 100644 --- a/database.sql +++ b/database.sql @@ -12,15 +12,15 @@ CREATE TABLE `seadex_state` ( ) CREATE TABLE `seadex_state_subscriptions` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` bigint(20) NOT NULL, - `seadex_state_id` int(10) unsigned NOT NULL, - `anime_name` varchar(255) NOT NULL, + `id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `seadex_state_id` int unsigned NOT NULL, + `custom_name` varchar(255) DEFAULT NULL, `active` tinyint(1) NOT NULL DEFAULT '1', `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uq_user_state` (`user_id`,`seadex_state_id`), KEY `idx_subscriptions_state` (`seadex_state_id`), - CONSTRAINT `fk_subscriptions_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE, - CONSTRAINT `fk_subscriptions_state` FOREIGN KEY (`seadex_state_id`) REFERENCES `seadex_state` (`id`) ON DELETE CASCADE -); + CONSTRAINT `fk_subscriptions_state` FOREIGN KEY (`seadex_state_id`) REFERENCES `seadex_state` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_subscriptions_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE +); \ No newline at end of file