Feature/subscription table #2
No files matched your search
@@ -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
|
||||||
|
);
|
||||||
@@ -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
@@ -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}")
|
||||||
@@ -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")
|
||||||
})
|
})
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
Reference in new issue
Block a user