import asyncio import os from contextlib import asynccontextmanager from datetime import UTC, datetime import uvicorn from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request from seadex import EntryNotFoundError, SeaDexEntry from service_auth import UserId from anilist_query import get_anime_name from auth_config import Caller, auth from db_module import ( close_connection_pool, create_connection_pool, get_db, start_healthcheck_thread, ) 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, NotificationError, NotificationUnavailableError from simple_logger_handler import LOG_LEVEL, setup_logger logger = setup_logger(__name__) @asynccontextmanager async def lifespan(app: FastAPI): logger.info("[App] Starting application...") app.state.notifier = NotificationClient() logger.info("[DB] Creating MySQL connection pool...") create_connection_pool("seadexPool") start_healthcheck_thread() logger.info("[DB] MySQL healthcheck thread started.") SECRET_PATHS = frozenset({ "/etc/secrets/api_key", "/etc/secrets/db_username", "/etc/secrets/db_password" }) cleanup_secret_files(SECRET_PATHS) yield logger.info("[App] Closing MySQL connection pool...") close_connection_pool() logger.info("[App] Shutdown complete.") api = FastAPI( title="Service SeaDex", description="Queries SeaDex for new torrent information", version="1.0.0", lifespan=lifespan, 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))]) def to_iso(dt: datetime) -> str: return dt.astimezone(UTC).isoformat(timespec="microseconds") def check_for_update(seadex: SeaDexEntry, anilist_id: int) -> str | None: try: return to_iso(seadex.from_id(anilist_id).updated_at) except EntryNotFoundError: return None @scheduler_router.post("/update") def check_seadex(request:Request, db=Depends(get_db)): results = {"checked": 0, "new": 0, "errors": 0} with db.cursor() as cursor: cursor.execute( "SELECT id, anilist_id, anime_name, last_updated " "FROM seadex_state WHERE active = 1" ) anime = cursor.fetchall() 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 seadex_state_id, anilist_id, anime_name, last_updated in anime: results["checked"] += 1 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") continue if current == last_updated: continue 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() 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, ) 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 with db.cursor() as cursor: cursor.execute( "UPDATE seadex_state SET last_updated = %s WHERE id = %s", (current, seadex_state_id), ) 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: {seadex_state_id} Anilist: {anilist_id}",) if results["errors"]: logger.warning(f"[FastAPI] SeaDex check finished with errors: {results}") raise HTTPException(status_code=500, detail=results) logger.info(f"[FastAPI] SeaDex check complete: {results}") return results @user_router.post("/add/anime") 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) 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( "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), ) seadex_state_id = cursor.lastrowid cursor.execute( "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() except Exception as e: db.rollback() 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}") 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), ) 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}", } except Exception as e: db.rollback() logger.exception(f"[FastAPI] Failed removeing tracking for id={anime.anilist_id}, user_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"} @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: cursor.execute( "SELECT anime_name, active, last_updated " \ "FROM seadex_state WHERE user_id = %s " "ORDER BY anime_name", (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: entry = AnimeEntry( name=name, last_updated=None if last_updated is None else str(last_updated), ) (anime_list.active if is_active else anime_list.inactive).append(entry) 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")}") auth.load_keys(api, Caller.GATEWAY, Caller.SCHEDULER) cleanup_secret_files(auth.key_paths(Caller.GATEWAY, Caller.SCHEDULER)) 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) server_main = uvicorn.Server(config_main) server_metrics = uvicorn.Server(config_metrics) await asyncio.gather(server_main.serve(), server_metrics.serve()) logger.info("[Server] Both servers have stopped.") if __name__ == "__main__": asyncio.run(start_servers())