Merge pull request 'Feature/subscription table' (#2) from feature/subscription-table into main

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
florian committed 2026-11-04 17:44:49 +00:00
commit 3b649e8862
7 files changed
+530 -198

No files matched your search

+14
View File
@@ -10,3 +10,17 @@ CREATE TABLE `seadex_state` (
UNIQUE KEY `uq_user_anilist` (`user_id`,`anilist_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 CONSTRAINT `fk_seadex_state_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE
) )
CREATE TABLE `seadex_state_subscriptions` (
`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_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
);
+73
View File
@@ -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 {}
+133 -44
View File
@@ -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 import requests
from anilist_errors import AniListApiError, AnimeNotFound, InvalidAnimeId, MissingAnimeName, RateLimitedError
from simple_logger_handler import setup_logger from simple_logger_handler import setup_logger
logger = setup_logger(__name__) logger = setup_logger(__name__)
ANILIST_API_URL = "https://graphql.anilist.co" ANILIST_API_URL = "https://graphql.anilist.co"
DEFAULT_TIMEOUT = 10 # seconds DEFAULT_TIMEOUT = 10
_ID_QUERY = """ _ID_QUERY = """
query ($id: Int) { query ($id: Int) {
@@ -20,31 +32,50 @@ query ($id: Int) {
# Preference order when picking a display name. # Preference order when picking a display name.
_TITLE_PREFERENCE = ("english", "romaji", "native") _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( def _post(
query: str, query: str,
variables: dict, variables: dict,
session: requests.Session | None, session: requests.Session | None,
timeout: float, timeout: float,
) -> dict: ) -> 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: Raises:
AnimeNotFoundError: AniList answered HTTP 404 (unknown ID). AnimeNotFound: AniList answered HTTP 404 (unknown ID).
AniListError: Network failure, rate limiting, or any other error. 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 http = session or requests
try: try:
response = http.post( response = http.post(
@@ -53,38 +84,69 @@ def _post(
timeout=timeout, timeout=timeout,
) )
except requests.RequestException as e: 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: 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: if response.status_code == 429:
retry_after = response.headers.get("Retry-After", "unknown") 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: try:
response.raise_for_status() response.raise_for_status()
payload = response.json() payload = response.json()
except (requests.HTTPError, ValueError) as e: 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"): if errors := payload.get("errors"):
messages = "; ".join(str(err.get("message", err)) for err in 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: 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 {} title = title or {}
for key in _TITLE_PREFERENCE: for key in _TITLE_PREFERENCE:
name = (title.get(key) or "").strip() name = (title.get(key) or "").strip()
if name: if name:
logger.debug("Picked '%s' title: %r", key, name)
return name return name
logger.debug("No usable title among keys %s in %s", _TITLE_PREFERENCE, title)
return None return None
def get_anime_name( def get_anime_name(
anilist_id: int, anilist_id: int,
*, *,
@@ -93,38 +155,65 @@ def get_anime_name(
) -> str: ) -> str:
"""Return the best available title for an AniList anime ID. """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: 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 session: Optional ``requests.Session`` to reuse connections when
looking up many IDs. 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: Raises:
ValueError: ``anilist_id`` is not a positive integer. InvalidAnimeId: ``anilist_id`` is not a positive integer. No request
AnimeNotFoundError: No anime exists for this ID. is made. Client-visible (422).
MissingAnimeName: The anime exists but has no title. AnimeNotFound: No anime exists for this ID, either via HTTP 404 or
AniListError: Network failure, rate limiting, or any other an empty ``Media`` result. Client-visible (422).
AniList/HTTP error. MissingAnimeName: The anime exists but none of its titles are usable.
""" Client-visible (422).
if not isinstance(anilist_id, int) or isinstance(anilist_id, bool) or anilist_id <= 0: RateLimitedError: AniList rate limited the request (503). Callers
raise ValueError(f"anilist_id must be a positive integer, got {anilist_id!r}") 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: Example:
data = _post(_ID_QUERY, {"id": anilist_id}, session, timeout) >>> get_anime_name(1)
except AnimeNotFoundError: 'Cowboy Bebop'
raise AnimeNotFoundError(f"No anime found for AniList ID={anilist_id}") from None """
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") media = data.get("Media")
if not 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")) name = _pick_name(media.get("title"))
if name is None: 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 return name
if __name__ == "__main__": if __name__ == "__main__":
asd = get_anime_name(2002927) from anilist_errors import AniListError
print(asd) 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}")
+2
View File
@@ -6,8 +6,10 @@ from service_auth import KeySource, ServiceAuth
class Caller(StrEnum): class Caller(StrEnum):
GATEWAY = "gateway" GATEWAY = "gateway"
SCHEDULER = "scheduler" SCHEDULER = "scheduler"
ADMIN = "admin"
auth = ServiceAuth({ auth = ServiceAuth({
Caller.GATEWAY: KeySource("GATEWAY_KEY", "/etc/secrets/gateway_key"), Caller.GATEWAY: KeySource("GATEWAY_KEY", "/etc/secrets/gateway_key"),
Caller.SCHEDULER: KeySource("SCHEDULER_KEY", "/etc/secrets/scheduler_key"), Caller.SCHEDULER: KeySource("SCHEDULER_KEY", "/etc/secrets/scheduler_key"),
Caller.ADMIN: KeySource("ADMIN_KEY","/etc/secrets/admin_key")
}) })
+65
View File
@@ -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)
+216 -154
View File
@@ -5,11 +5,10 @@ from datetime import UTC, datetime
import uvicorn import uvicorn
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
from pydantic import BaseModel, Field, computed_field
from seadex import EntryNotFoundError, SeaDexEntry from seadex import EntryNotFoundError, SeaDexEntry
from service_auth import UserId 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 auth_config import Caller, auth
from db_module import ( from db_module import (
close_connection_pool, close_connection_pool,
@@ -17,32 +16,16 @@ from db_module import (
get_db, get_db,
start_healthcheck_thread, 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, AnimeRemove
from secret_manager import cleanup_secret_files from secret_manager import cleanup_secret_files
from send_notifications import NotificationClient from send_notifications import NotificationClient, NotificationError, NotificationUnavailableError
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)
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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
@@ -76,25 +59,10 @@ api = FastAPI(
dependencies=[Depends(auth.authenticate)], dependencies=[Depends(auth.authenticate)],
) )
register_handlers(api)
user_router = APIRouter(dependencies=[Depends(auth.require(Caller.GATEWAY))]) user_router = APIRouter(dependencies=[Depends(auth.require(Caller.GATEWAY))])
scheduler_router = APIRouter(dependencies=[Depends(auth.require(Caller.SCHEDULER))]) scheduler_router = APIRouter(dependencies=[Depends(auth.require(Caller.SCHEDULER))])
admin_router = APIRouter(dependencies=[Depends(auth.require(Caller.ADMIN))])
@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: def to_iso(dt: datetime) -> str:
return dt.astimezone(UTC).isoformat(timespec="microseconds") return dt.astimezone(UTC).isoformat(timespec="microseconds")
@@ -113,186 +81,280 @@ def check_seadex(request:Request, db=Depends(get_db)):
with db.cursor() as cursor: with db.cursor() as cursor:
cursor.execute( 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" "FROM seadex_state WHERE active = 1"
) )
anime = cursor.fetchall() anime = cursor.fetchall()
cache: dict[int, str | None] = {} logger.debug("[FastAPI] Found %s active anime to check.", len(anime))
if not anime:
logger.info("[FastAPI] No active Seadex entries")
return results
with SeaDexEntry() as seadex: 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 results["checked"] += 1
try: try:
if anilist_id not in cache: current = check_for_update(seadex, anilist_id)
cache[anilist_id] = check_for_update(seadex, anilist_id)
current = cache[anilist_id]
if current is None: if current is None:
logger.debug(f"[FastAPI] No entry yet for {anime_name} on SeaDex") logger.debug("[FastAPI] No entry yet for Anime: %s on SeaDex", anime_name)
continue
if current == last_updated:
continue continue
if current != last_updated: logger.info(
logger.info( "[FastAPI] Anime: %s updated, Anilist ID: %s",
f"[FastAPI] {anime_name} updated, Anilist ID: {anilist_id}, User ID: {user_id}" anime_name,anilist_id,
) )
request.app.state.notifier.send( with db.cursor() as cursor:
title=anime_name, cursor.execute(
body="SeaDex entry updated", "SELECT user_id, custom_name "
link=f"https://seadex.moe/{anilist_id}/", "FROM seadex_state_subscriptions "
user_id=user_id, "WHERE seadex_state_id = %s AND active = 1",
(seadex_state_id,),
) )
subscribers = cursor.fetchall()
with db.cursor() as cursor: sent = 0
cursor.execute( failed = 0
"UPDATE seadex_state SET last_updated = %s WHERE id = %s AND user_id = %s", for user_id, custom_name in subscribers:
(current, row_id, user_id), 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("[FastAPI] Notifications backend unavailable, skipping "
"%s remaining user(s) for Anime: %s",
failed, anime_name,
)
break
except NotificationError as e:
failed += 1
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("[FastAPI] All notifications failed for Anime: %s, will retry later", anime_name)
continue
logger.debug( with db.cursor() as cursor:
f"[FastAPI] Notified and updated db for id={row_id}, " cursor.execute(
f"Anilist ID={anilist_id}, User ID: {user_id}" "UPDATE seadex_state SET last_updated = %s WHERE id = %s",
(current, seadex_state_id),
) )
results["new"] += 1 db.commit()
logger.debug("[FastAPI] Notification sent and database updated for Anime: %s", anime_name)
results["new"] += 1
except Exception: except Exception:
db.rollback() db.rollback()
results["errors"] += 1 results["errors"] += 1
logger.exception(f"[FastAPI] Failed for id={row_id} Anilist={anilist_id}, User ID: {user_id}",) logger.exception(
"[FastAPI] Failed for id: %s Anilist: %s",
seadex_state_id, anilist_id,
)
if results["errors"]: 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) 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 return results
@user_router.post("/add/anime") @user_router.post("/anime/add")
def add_anime(anime: AnimeCreate, user_id: UserId, db=Depends(get_db)): def add_anime(
logger.info(f"[FastAPI] Received request to track anilist_id={anime.anilist_id}, user_id={user_id}") 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)
try: with db.cursor() as cursor:
anime_name = get_anime_name(anime.anilist_id) cursor.execute(
except AnimeNotFoundError as e: "SELECT id, active FROM seadex_state WHERE anilist_id = %s",
raise HTTPException(status_code=404, detail="Not found on AniList") from e (anime.anilist_id,),
except MissingAnimeName as e: )
raise HTTPException(status_code=404, detail="The anime exists but has no usable title") from e known = cursor.fetchone()
except AniListError as e:
logger.exception("[FastAPI] AniList request failed") last_updated = None
raise HTTPException(status_code=502, detail="The AniList request failed") from e with SeaDexEntry() as seadex:
logger.debug(f"[FastAPI] Found name={anime_name} for Anilist id={anime.anilist_id}") 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: with db.cursor() as cursor:
try: try:
cursor.execute( cursor.execute(
"SELECT id FROM seadex_state WHERE anilist_id = %s AND user_id = %s", "INSERT INTO seadex_state (anilist_id, anime_name, last_updated) "
(anime.anilist_id, user_id), "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),
) )
seadex_state_id = cursor.lastrowid
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}")
cursor.execute( cursor.execute(
"INSERT INTO seadex_state (anilist_id, anime_name, last_updated, user_id) " "INSERT INTO seadex_state_subscriptions (user_id, seadex_state_id, custom_name) "
"VALUES (%s, %s, %s, %s)", "VALUES (%s, %s, %s) "
(anime.anilist_id, anime_name, current, user_id), "ON DUPLICATE KEY UPDATE active = 1, custom_name = VALUES(custom_name)",
(user_id, seadex_state_id, anime.custom_name or None),
) )
db.commit() db.commit()
return {
"status": "ok",
"detail": f"Created new entry name={anime_name}, "
f"Anilist ID={anime.anilist_id}",
}
except Exception as e: except Exception as e:
db.rollback() 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 raise HTTPException(status_code=500) from e
@user_router.post("/delete/anime") logger.info(
def remove_anime(anime: AnimeCreate, user_id: UserId, db=Depends(get_db)): "[FastAPI] Tracking anilist_id=%s name=%r for user_id=%s (state_id=%s)",
logger.info(f"[FastAPI] Received request to deactivate tracking for anilist_id={anime.anilist_id}, user_id={user_id}") 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("/anime/delete")
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: with db.cursor() as cursor:
try: try:
cursor.execute( cursor.execute(
"SELECT active FROM seadex_state WHERE anilist_id = %s AND user_id = %s", "SELECT id FROM seadex_state "
(anime.anilist_id, user_id), "WHERE anilist_id = %s FOR UPDATE",
(anime.anilist_id,),
) )
row = cursor.fetchone() known = 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}",
}
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: except Exception as e:
db.rollback() 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 raise HTTPException(status_code=500) from e
if row is None: logger.info(
logger.debug(f"[FastAPI] No tracked entry for anilist_id={anime.anilist_id}, user_id={user_id}") "[FastAPI] Successfully deactivated tracking for anilist_id=%s for user_id=%s",
raise HTTPException(status_code=404, detail="No tracked entry for this AniList ID") anime.anilist_id, user_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"Stopped tracking Anilist ID={anime.anilist_id}"}
return {"status": "ok", "detail": f"Entry {anime.anilist_id} was already inactive"}
@user_router.get("/list/anime", response_model=AnimeList) @user_router.get("/anime/list", response_model=AnimeList)
def list_active_and_inactive_anime(user_id: UserId, db=Depends(get_db)): def list_active_and_inactive_anime(
logger.info(f"[FastAPI] Received request to list anime for User ID = {user_id}") user_id: UserId,
with db.cursor() as cursor: db=Depends(get_db),
try: ):
logger.info("[FastAPI] Received request to list anime for User ID = %s", user_id)
try:
with db.cursor() as cursor:
cursor.execute( cursor.execute(
"SELECT anime_name, active, last_updated " \ "SELECT s.custom_name AS custom_name, st.anime_name AS anime_name, "
"FROM seadex_state WHERE user_id = %s " "st.anilist_id, s.active, st.last_updated from seadex_state_subscriptions AS s "
"ORDER BY anime_name", "JOIN seadex_state AS st on s.seadex_state_id = st.id "
"WHERE s.user_id = %s ORDER BY st.anilist_id",
(user_id,), (user_id,),
) )
rows = cursor.fetchall() rows = cursor.fetchall()
except Exception as e:
logger.exception("[FastAPI] Failed to list anime")
raise HTTPException(status_code=500) from e
anime_list=AnimeList () anime_list=AnimeList()
for name, is_active, last_updated in rows: for custom_name, anime_name, anilist_id, is_active, last_updated in rows:
entry = AnimeEntry( entry = AnimeEntry(
name=name, name=custom_name or anime_name,
last_updated=None if last_updated is None else str(last_updated), 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) (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
@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(user_router)
api.include_router(scheduler_router) api.include_router(scheduler_router)
api.include_router(admin_router)
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)...")
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) auth.load_keys(api, Caller.GATEWAY, Caller.SCHEDULER, Caller.ADMIN)
cleanup_secret_files(auth.key_paths(Caller.GATEWAY, Caller.SCHEDULER)) 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_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) config_metrics = uvicorn.Config(metrics_api, host="0.0.0.0", port=9000, log_level=LOG_LEVEL.lower(),log_config=None)
+27
View File
@@ -0,0 +1,27 @@
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 AnimeRemove(BaseModel):
anilist_id: int
class AnimeEntry(BaseModel):
name: str
anilist_id: int
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))