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