Files
FastAnime/fastanime/libs/api/anilist/api.py
2025-07-24 13:38:31 +03:00

258 lines
8.7 KiB
Python

import logging
from enum import Enum
from typing import Optional
from httpx import Client
from ....core.config import AnilistConfig
from ....core.utils.graphql import (
execute_graphql,
)
from ..base import BaseApiClient
from ..params import (
MediaAiringScheduleParams,
MediaCharactersParams,
MediaRecommendationParams,
MediaRelationsParams,
MediaSearchParams,
UpdateUserMediaListEntryParams,
UserMediaListSearchParams,
)
from ..types import MediaSearchResult, UserMediaListStatus, UserProfile
from . import gql, mapper
logger = logging.getLogger(__name__)
ANILIST_ENDPOINT = "https://graphql.anilist.co"
user_list_status_map = {
UserMediaListStatus.WATCHING: "CURRENT",
UserMediaListStatus.PLANNING: "PLANNING",
UserMediaListStatus.COMPLETED: "COMPLETED",
UserMediaListStatus.DROPPED: "DROPPED",
UserMediaListStatus.PAUSED: "PAUSED",
UserMediaListStatus.REPEATING: "REPEATING",
}
# TODO: Just remove and have consistent variable naming between the two
search_params_map = {
# Custom Name: AniList Variable Name
"query": "query",
"page": "page",
"per_page": "per_page",
"sort": "sort",
"id_in": "id_in",
"genre_in": "genre_in",
"genre_not_in": "genre_not_in",
"tag_in": "tag_in",
"tag_not_in": "tag_not_in",
"status_in": "status_in",
"status": "status",
"status_not_in": "status_not_in",
"popularity_greater": "popularity_greater",
"popularity_lesser": "popularity_lesser",
"averageScore_greater": "averageScore_greater",
"averageScore_lesser": "averageScore_lesser",
"seasonYear": "seasonYear",
"season": "season",
"startDate_greater": "startDate_greater",
"startDate_lesser": "startDate_lesser",
"startDate": "startDate",
"endDate_greater": "endDate_greater",
"endDate_lesser": "endDate_lesser",
"format_in": "format_in",
"type": "type",
"on_list": "on_list",
}
class AniListApi(BaseApiClient):
"""AniList API implementation of the BaseApiClient contract."""
def __init__(self, config: AnilistConfig, client: Client):
super().__init__(config, client)
self.token: Optional[str] = None
self.user_profile: Optional[UserProfile] = None
def authenticate(self, token: str) -> Optional[UserProfile]:
self.token = token
self.http_client.headers["Authorization"] = f"Bearer {token}"
self.user_profile = self.get_viewer_profile()
if not self.user_profile:
self.token = None
self.http_client.headers.pop("Authorization", None)
return self.user_profile
def is_authenticated(self) -> bool:
return True if self.user_profile else False
def get_viewer_profile(self) -> Optional[UserProfile]:
if not self.token:
return None
response = execute_graphql(
ANILIST_ENDPOINT, self.http_client, gql.GET_LOGGED_IN_USER, {}
)
return mapper.to_generic_user_profile(response.json())
def search_media(self, params: MediaSearchParams) -> Optional[MediaSearchResult]:
variables = {
search_params_map[k]: v
for k, v in params.__dict__.items()
if v is not None and not isinstance(v, Enum)
}
# handle case where value is an enum
variables.update(
{
search_params_map[k]: v.value
for k, v in params.__dict__.items()
if v is not None and isinstance(v, Enum)
}
)
# handle case where is a list of enums
variables.update(
{
search_params_map[k]: list(map(lambda item: item.value, v))
for k, v in params.__dict__.items()
if v is not None and isinstance(v, list) and isinstance(v[0], Enum)
}
)
variables["per_page"] = params.per_page or self.config.per_page
# ignore hentai by default
variables["genre_not_in"] = (
list(map(lambda item: item.value, params.genre_not_in))
if params.genre_not_in
else ["Hentai"]
)
# anime by default
variables["type"] = params.type.value if params.type else "ANIME"
response = execute_graphql(
ANILIST_ENDPOINT, self.http_client, gql.SEARCH_MEDIA, variables
)
return mapper.to_generic_search_result(response.json())
def search_media_list(
self, params: UserMediaListSearchParams
) -> Optional[MediaSearchResult]:
if not self.user_profile:
logger.error("Cannot fetch user list: user is not authenticated.")
return None
# TODO: use consistent variable naming btw graphql and params
# so variables can be dynamically filled
variables = {
"sort": params.sort.value
if params.sort
else self.config.media_list_sort_by.value,
"userId": self.user_profile.id,
"status": user_list_status_map[params.status] if params.status else None,
"page": params.page,
"perPage": params.per_page or self.config.per_page,
"type": params.type.value if params.type else "ANIME",
}
response = execute_graphql(
ANILIST_ENDPOINT, self.http_client, gql.SEARCH_USER_MEDIA_LIST, variables
)
return mapper.to_generic_user_list_result(response.json()) if response else None
def update_list_entry(self, params: UpdateUserMediaListEntryParams) -> bool:
if not self.token:
return False
score_raw = int(params.score * 10) if params.score is not None else None
variables = {
"mediaId": params.media_id,
"status": user_list_status_map[params.status] if params.status else None,
"progress": int(float(params.progress)) if params.progress else None,
"scoreRaw": score_raw,
}
variables = {k: v for k, v in variables.items() if v is not None}
response = execute_graphql(
ANILIST_ENDPOINT, self.http_client, gql.SAVE_MEDIA_LIST_ENTRY, variables
)
return response.json() is not None and "errors" not in response.json()
def delete_list_entry(self, media_id: int) -> bool:
if not self.token:
return False
response = execute_graphql(
ANILIST_ENDPOINT,
self.http_client,
gql.GET_MEDIA_LIST_ITEM,
{"mediaId": media_id},
)
entry_data = response.json()
list_id = (
entry_data.get("data", {}).get("MediaList", {}).get("id")
if entry_data
else None
)
if not list_id:
return False
response = execute_graphql(
ANILIST_ENDPOINT,
self.http_client,
gql.DELETE_MEDIA_LIST_ENTRY,
{"id": list_id},
)
return (
response.json()
.get("data", {})
.get("DeleteMediaListEntry", {})
.get("deleted", False)
if response
else False
)
def get_recommendation_for(self, params: MediaRecommendationParams):
variables = {"mediaRecommendationId": params.id, "page": params.page}
response = execute_graphql(
ANILIST_ENDPOINT, self.http_client, gql.GET_RECOMMENDATIONS, variables
)
return response
def get_characters_of(self, params: MediaCharactersParams):
variables = {"id": params.id}
response = execute_graphql(
ANILIST_ENDPOINT, self.http_client, gql.GET_CHARACTERS, variables
)
return response
def get_related_anime_for(self, params: MediaRelationsParams):
variables = {"id": params.id}
response = execute_graphql(
ANILIST_ENDPOINT, self.http_client, gql.GET_MEDIA_RELATIONS, variables
)
def get_airing_schedule_for(self, params: MediaAiringScheduleParams):
variables = {"id": params.id}
response = execute_graphql(
ANILIST_ENDPOINT, self.http_client, gql.GET_AIRING_SCHEDULE, variables
)
if __name__ == "__main__":
from httpx import Client
from ....core.config import AnilistConfig
from ....core.constants import APP_ASCII_ART
from ..params import MediaSearchParams
anilist = AniListApi(AnilistConfig(), Client())
print(APP_ASCII_ART)
# search
query = input("What anime would you like to search for: ")
search_results = anilist.search_media(MediaSearchParams(query=query))
if not search_results:
print("Nothing was finding matching: ", query)
exit()
for result in search_results.media:
print(
f"Title: {result.title.english or result.title.romaji} Episodes: {result.episodes}"
)