From ee52b945ea420875bdde4833893bc5f552e4f65d Mon Sep 17 00:00:00 2001 From: Benexl Date: Tue, 29 Jul 2025 01:40:18 +0300 Subject: [PATCH] feat(media-api): notifications --- fastanime/cli/commands/anilist/cmd.py | 1 + .../anilist/commands/notifications.py | 56 +++++++++++++++ fastanime/libs/media_api/anilist/api.py | 15 ++++ fastanime/libs/media_api/anilist/mapper.py | 71 +++++++++++++++++++ fastanime/libs/media_api/anilist/types.py | 46 ++++++------ fastanime/libs/media_api/base.py | 6 ++ fastanime/libs/media_api/jikan/api.py | 8 ++- fastanime/libs/media_api/types.py | 18 +++++ 8 files changed, 196 insertions(+), 25 deletions(-) create mode 100644 fastanime/cli/commands/anilist/commands/notifications.py diff --git a/fastanime/cli/commands/anilist/cmd.py b/fastanime/cli/commands/anilist/cmd.py index b877c0a..3f492c6 100644 --- a/fastanime/cli/commands/anilist/cmd.py +++ b/fastanime/cli/commands/anilist/cmd.py @@ -10,6 +10,7 @@ commands = { "download": "download.download", "auth": "auth.auth", "stats": "stats.stats", + "notifications": "notifications.notifications", } diff --git a/fastanime/cli/commands/anilist/commands/notifications.py b/fastanime/cli/commands/anilist/commands/notifications.py new file mode 100644 index 0000000..ff10184 --- /dev/null +++ b/fastanime/cli/commands/anilist/commands/notifications.py @@ -0,0 +1,56 @@ +import click +from fastanime.core.config import AppConfig +from rich.console import Console +from rich.table import Table + + +@click.command(help="Check for new AniList notifications (e.g., for airing episodes).") +@click.pass_obj +def notifications(config: AppConfig): + """ + Displays unread notifications from AniList. + Running this command will also mark the notifications as read on the AniList website. + """ + from fastanime.cli.service.feedback import FeedbackService + from fastanime.libs.media_api.api import create_api_client + + from ....service.auth import AuthService + + feedback = FeedbackService(config.general.icons) + console = Console() + auth = AuthService(config.general.media_api) + api_client = create_api_client(config.general.media_api, config) + if profile := auth.get_auth(): + api_client.authenticate(profile.token) + + if not api_client.is_authenticated(): + feedback.error( + "Authentication Required", "Please log in with 'fastanime anilist auth'." + ) + return + + with feedback.progress("Fetching notifications..."): + notifs = api_client.get_notifications() + + if not notifs: + feedback.success("All caught up!", "You have no new notifications.") + return + + table = Table( + title="🔔 AniList Notifications", show_header=True, header_style="bold magenta" + ) + table.add_column("Date", style="dim", width=12) + table.add_column("Anime Title", style="cyan") + table.add_column("Details", style="green") + + for notif in sorted(notifs, key=lambda n: n.created_at, reverse=True): + title = notif.media.title.english or notif.media.title.romaji or "Unknown" + date_str = notif.created_at.strftime("%Y-%m-%d") + details = f"Episode {notif.episode} has aired!" + + table.add_row(date_str, title, details) + + console.print(table) + feedback.info( + "Notifications have been marked as read on AniList.", + ) diff --git a/fastanime/libs/media_api/anilist/api.py b/fastanime/libs/media_api/anilist/api.py index c68149a..71aa6b1 100644 --- a/fastanime/libs/media_api/anilist/api.py +++ b/fastanime/libs/media_api/anilist/api.py @@ -25,6 +25,7 @@ from ..types import ( MediaItem, MediaReview, MediaSearchResult, + Notification, UserMediaListStatus, UserProfile, ) @@ -276,6 +277,20 @@ class AniListApi(BaseApiClient): return mapper.to_generic_reviews_list(response.json()) return None + def get_notifications(self) -> Optional[List[Notification]]: + """Fetches the user's unread notifications from AniList.""" + if not self.is_authenticated(): + logger.warning("Cannot fetch notifications: user is not authenticated.") + return None + + response = execute_graphql( + ANILIST_ENDPOINT, self.http_client, gql.GET_NOTIFICATIONS, {} + ) + if response and "errors" not in response.json(): + return mapper.to_generic_notifications(response.json()) + logger.error(f"Failed to fetch notifications: {response.text}") + return None + def transform_raw_search_data(self, raw_data: Any) -> Optional[MediaSearchResult]: """ Transform raw AniList API response data into a MediaSearchResult. diff --git a/fastanime/libs/media_api/anilist/mapper.py b/fastanime/libs/media_api/anilist/mapper.py index ca667b5..c60fd80 100644 --- a/fastanime/libs/media_api/anilist/mapper.py +++ b/fastanime/libs/media_api/anilist/mapper.py @@ -25,6 +25,8 @@ from ..types import ( MediaTagItem, MediaTitle, MediaTrailer, + Notification, + NotificationType, PageInfo, Reviewer, StreamingEpisode, @@ -45,6 +47,8 @@ from .types import ( AnilistMediaTag, AnilistMediaTitle, AnilistMediaTrailer, + AnilistNotification, + AnilistNotifications, AnilistPageInfo, AnilistReview, AnilistReviews, @@ -520,3 +524,70 @@ def to_generic_airing_schedule_result(data: Dict) -> Optional[AiringScheduleResu except (KeyError, IndexError, TypeError) as e: logger.error(f"Error parsing airing schedule data: {e}") return None + + +def _to_generic_media_item_from_notification_partial( + data: AnilistBaseMediaDataSchema, +) -> MediaItem: + """ + A specialized mapper for the partial MediaItem object received in notifications. + It provides default values for fields not present in the notification's media payload. + """ + return MediaItem( + id=data["id"], + id_mal=data.get("idMal"), + title=_to_generic_media_title(data["title"]), + cover_image=_to_generic_media_image(data["coverImage"]), + # Provide default/empty values for fields not in notification payload + type="ANIME", + status=MediaStatus.RELEASING, # Assume releasing for airing notifications + format=None, + description=None, + episodes=None, + duration=None, + genres=[], + tags=[], + studios=[], + synonymns=[], + average_score=None, + popularity=None, + favourites=None, + streaming_episodes={}, + user_status=None, + ) + + +def _to_generic_notification(anilist_notification: AnilistNotification) -> Notification: + """Maps a single AniList notification to a generic Notification object.""" + return Notification( + id=anilist_notification["id"], + type=NotificationType(anilist_notification["type"]), + episode=anilist_notification.get("episode"), + contexts=anilist_notification.get("contexts", []), + created_at=datetime.fromtimestamp(anilist_notification["createdAt"]), + media=_to_generic_media_item_from_notification_partial( + anilist_notification["media"] + ), + ) + + +def to_generic_notifications( + data: AnilistNotifications, +) -> Optional[List[Notification]]: + """Top-level mapper for a list of notifications.""" + if not data or "data" not in data: + return None + + page_data = data["data"].get("Page", {}) + if not page_data: + return None + + raw_notifications = page_data.get("notifications", []) + if not raw_notifications: + return [] + + return [ + _to_generic_notification(notification) + for notification in raw_notifications + if notification + ] diff --git a/fastanime/libs/media_api/anilist/types.py b/fastanime/libs/media_api/anilist/types.py index ad9a771..015fcab 100644 --- a/fastanime/libs/media_api/anilist/types.py +++ b/fastanime/libs/media_api/anilist/types.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional, TypedDict +from typing import List, Literal, Optional, TypedDict class AnilistPageInfo(TypedDict): @@ -226,28 +226,6 @@ class AnilistDataSchema(TypedDict): data: AnilistPages -class AnilistNotification(TypedDict): - id: int - type: str - episode: int - context: str - createdAt: str - media: AnilistBaseMediaDataSchema - - -class AnilistNotificationPage(TypedDict): - pageInfo: AnilistPageInfo - notifications: list[AnilistNotification] - - -class AnilistNotificationPages(TypedDict): - Page: AnilistNotificationPage - - -class AnilistNotifications(TypedDict): - data: AnilistNotificationPages - - class AnilistMediaList(TypedDict): media: AnilistBaseMediaDataSchema status: AnilistMediaListStatus @@ -271,3 +249,25 @@ class AnilistMediaListPages(TypedDict): class AnilistMediaLists(TypedDict): data: AnilistMediaListPages + + +class AnilistNotification(TypedDict): + id: int + type: str + episode: int + contexts: List[str] + createdAt: int # This is a Unix timestamp + media: AnilistBaseMediaDataSchema # This will be a partial response + + +class AnilistNotificationPage(TypedDict): + pageInfo: AnilistPageInfo + notifications: list[AnilistNotification] + + +class AnilistNotificationPages(TypedDict): + Page: AnilistNotificationPage + + +class AnilistNotifications(TypedDict): + data: AnilistNotificationPages diff --git a/fastanime/libs/media_api/base.py b/fastanime/libs/media_api/base.py index 0d7f0eb..ac11e36 100644 --- a/fastanime/libs/media_api/base.py +++ b/fastanime/libs/media_api/base.py @@ -18,6 +18,7 @@ from .types import ( MediaItem, MediaReview, MediaSearchResult, + Notification, UserProfile, ) @@ -95,6 +96,11 @@ class BaseApiClient(abc.ABC): ) -> Optional[List[MediaReview]]: pass + @abc.abstractmethod + def get_notifications(self) -> Optional[List[Notification]]: + """Fetches the user's unread notifications.""" + pass + @abc.abstractmethod def transform_raw_search_data(self, raw_data: Dict) -> Optional[MediaSearchResult]: """ diff --git a/fastanime/libs/media_api/jikan/api.py b/fastanime/libs/media_api/jikan/api.py index 93197af..7d361be 100644 --- a/fastanime/libs/media_api/jikan/api.py +++ b/fastanime/libs/media_api/jikan/api.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import logging from typing import TYPE_CHECKING, List, Optional @@ -20,6 +18,7 @@ from ..types import ( MediaItem, MediaSearchResult, MediaTitle, + Notification, UserProfile, ) from . import mapper @@ -183,6 +182,11 @@ class JikanApi(BaseApiClient): logger.error(f"Failed to fetch related anime for media {params.id}: {e}") return None + def get_notifications(self) -> Optional[List[Notification]]: + """Jikan is a public API and does not support user notifications.""" + logger.warning("Jikan API does not support fetching user notifications.") + return None + def get_airing_schedule_for( self, params: MediaAiringScheduleParams ) -> Optional[AiringScheduleResult]: diff --git a/fastanime/libs/media_api/types.py b/fastanime/libs/media_api/types.py index c85eada..55a9168 100644 --- a/fastanime/libs/media_api/types.py +++ b/fastanime/libs/media_api/types.py @@ -65,6 +65,13 @@ class MediaFormat(Enum): ONE_SHOT = "ONE_SHOT" +class NotificationType(Enum): + AIRING = "AIRING" + RELATED_MEDIA_ADDITION = "RELATED_MEDIA_ADDITION" + MEDIA_DATA_CHANGE = "MEDIA_DATA_CHANGE" + # ... add other types as needed + + # MODELS class BaseMediaApiModel(BaseModel): model_config = ConfigDict(frozen=True) @@ -227,6 +234,17 @@ class MediaItem(BaseMediaApiModel): user_status: Optional[UserListItem] = None +class Notification(BaseMediaApiModel): + """A generic representation of a user notification.""" + + id: int + type: NotificationType + episode: Optional[int] = None + contexts: List[str] = Field(default_factory=list) + created_at: datetime + media: MediaItem + + class PageInfo(BaseMediaApiModel): """Generic pagination information."""