feat: add jikan api

This commit is contained in:
Benexl
2025-07-07 00:23:33 +03:00
parent 0737c5c14b
commit cdad70e40d
6 changed files with 272 additions and 45 deletions

View File

@@ -1,45 +1 @@
import os
from pathlib import Path
import click
from ..core.constants import APP_NAME, ICONS_DIR, PLATFORM
APP_ASCII_ART = """\
███████╗░█████╗░░██████╗████████╗░█████╗░███╗░░██╗██╗███╗░░░███╗███████╗
██╔════╝██╔══██╗██╔════╝╚══██╔══╝██╔══██╗████╗░██║██║████╗░████║██╔════╝
█████╗░░███████║╚█████╗░░░░██║░░░███████║██╔██╗██║██║██╔████╔██║█████╗░░
██╔══╝░░██╔══██║░╚═══██╗░░░██║░░░██╔══██║██║╚████║██║██║╚██╔╝██║██╔══╝░░
██║░░░░░██║░░██║██████╔╝░░░██║░░░██║░░██║██║░╚███║██║██║░╚═╝░██║███████╗
╚═╝░░░░░╚═╝░░╚═╝╚═════╝░░░░╚═╝░░░╚═╝░░╚═╝╚═╝░░╚══╝╚═╝╚═╝░░░░░╚═╝╚══════╝
"""
USER_NAME = os.environ.get("USERNAME", "Anime Fan")
APP_DATA_DIR = Path(click.get_app_dir(APP_NAME, roaming=False))
if PLATFORM == "win32":
APP_CACHE_DIR = APP_DATA_DIR / "cache"
USER_VIDEOS_DIR = Path.home() / "Videos" / APP_NAME
elif PLATFORM == "darwin":
APP_CACHE_DIR = Path.home() / "Library" / "Caches" / APP_NAME
USER_VIDEOS_DIR = Path.home() / "Movies" / APP_NAME
else:
xdg_cache_home = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
APP_CACHE_DIR = xdg_cache_home / APP_NAME
xdg_videos_dir = Path(os.environ.get("XDG_VIDEOS_DIR", Path.home() / "Videos"))
USER_VIDEOS_DIR = xdg_videos_dir / APP_NAME
APP_DATA_DIR.mkdir(parents=True, exist_ok=True)
APP_CACHE_DIR.mkdir(parents=True, exist_ok=True)
USER_VIDEOS_DIR.mkdir(parents=True, exist_ok=True)
USER_DATA_PATH = APP_DATA_DIR / "user_data.json"
USER_WATCH_HISTORY_PATH = APP_DATA_DIR / "watch_history.json"
USER_CONFIG_PATH = APP_DATA_DIR / "config.ini"
LOG_FILE_PATH = APP_CACHE_DIR / "fastanime.log"
ICON_PATH = ICONS_DIR / ("logo.ico" if PLATFORM == "Windows" else "logo.png")

View File

@@ -11,7 +11,7 @@ from ...core.constants import (
ROFI_THEME_MAIN,
ROFI_THEME_PREVIEW,
)
from ...libs.anilist.constants import SORTS_AVAILABLE
from ...libs.api.anilist.constants import SORTS_AVAILABLE
from ...libs.providers.anime import PROVIDERS_AVAILABLE, SERVERS_AVAILABLE
from ..constants import APP_ASCII_ART, USER_VIDEOS_DIR
@@ -135,9 +135,19 @@ class AnilistConfig(OtherConfig):
return v
class JikanConfig(OtherConfig):
"""Configuration for the Jikan API (currently none)."""
pass
class GeneralConfig(BaseModel):
"""Configuration for general application behavior and integrations."""
api_client: Literal["anilist", "jikan"] = Field(
default="anilist",
description="The media database API to use (e.g., 'anilist', 'jikan').",
)
provider: str = Field(
default="allanime",
description="The default anime provider to use for scraping.",

View File

@@ -1,6 +1,7 @@
import os
import sys
from importlib import resources
from pathlib import Path
PLATFORM = sys.platform
APP_NAME = os.environ.get("FASTANIME_APP_NAME", "fastanime")
@@ -38,3 +39,59 @@ except ModuleNotFoundError:
# fzf
FZF_DEFAULT_OPTS = DEFAULTS / "fzf-opts"
APP_ASCII_ART = """\
███████╗░█████╗░░██████╗████████╗░█████╗░███╗░░██╗██╗███╗░░░███╗███████╗
██╔════╝██╔══██╗██╔════╝╚══██╔══╝██╔══██╗████╗░██║██║████╗░████║██╔════╝
█████╗░░███████║╚█████╗░░░░██║░░░███████║██╔██╗██║██║██╔████╔██║█████╗░░
██╔══╝░░██╔══██║░╚═══██╗░░░██║░░░██╔══██║██║╚████║██║██║╚██╔╝██║██╔══╝░░
██║░░░░░██║░░██║██████╔╝░░░██║░░░██║░░██║██║░╚███║██║██║░╚═╝░██║███████╗
╚═╝░░░░░╚═╝░░╚═╝╚═════╝░░░░╚═╝░░░╚═╝░░╚═╝╚═╝░░╚══╝╚═╝╚═╝░░░░░╚═╝╚══════╝
"""
USER_NAME = os.environ.get("USERNAME", "Anime Fan")
try:
import click
APP_DATA_DIR = Path(click.get_app_dir(APP_NAME, roaming=False))
except ModuleNotFoundError:
# TODO: change to path objects
if PLATFORM == "win32":
folder = os.environ.get("LOCALAPPDATA")
if folder is None:
folder = os.path.expanduser("~")
APP_DATA_DIR = os.path.join(folder, APP_NAME)
if PLATFORM == "darwin":
APP_DATA_DIR = os.path.join(
os.path.expanduser("~/Library/Application Support"), APP_NAME
)
APP_DATA_DIR = os.path.join(
os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")),
)
if PLATFORM == "win32":
APP_CACHE_DIR = APP_DATA_DIR / "cache"
USER_VIDEOS_DIR = Path.home() / "Videos" / APP_NAME
elif PLATFORM == "darwin":
APP_CACHE_DIR = Path.home() / "Library" / "Caches" / APP_NAME
USER_VIDEOS_DIR = Path.home() / "Movies" / APP_NAME
else:
xdg_cache_home = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
APP_CACHE_DIR = xdg_cache_home / APP_NAME
xdg_videos_dir = Path(os.environ.get("XDG_VIDEOS_DIR", Path.home() / "Videos"))
USER_VIDEOS_DIR = xdg_videos_dir / APP_NAME
APP_DATA_DIR.mkdir(parents=True, exist_ok=True)
APP_CACHE_DIR.mkdir(parents=True, exist_ok=True)
USER_VIDEOS_DIR.mkdir(parents=True, exist_ok=True)
USER_DATA_PATH = APP_DATA_DIR / "user_data.json"
USER_WATCH_HISTORY_PATH = APP_DATA_DIR / "watch_history.json"
USER_CONFIG_PATH = APP_DATA_DIR / "config.ini"
LOG_FILE_PATH = APP_CACHE_DIR / "fastanime.log"
ICON_PATH = ICONS_DIR / ("logo.ico" if PLATFORM == "Windows" else "logo.png")

View File

View File

@@ -0,0 +1,100 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, List, Optional
from ..base import (
ApiSearchParams,
BaseApiClient,
UpdateListEntryParams,
UserListParams,
)
from ..types import MediaItem, MediaSearchResult, UserProfile
from . import mapper
if TYPE_CHECKING:
from httpx import Client
from ....core.config import AppConfig
logger = logging.getLogger(__name__)
JIKAN_ENDPOINT = "https://api.jikan.moe/v4"
class JikanApi(BaseApiClient):
"""
Jikan API (MyAnimeList) implementation of the BaseApiClient contract.
Note: Jikan is a read-only API for public data. All authentication and
list modification methods will be no-ops.
"""
def _execute_request(
self, endpoint: str, params: Optional[dict] = None
) -> Optional[dict]:
"""Executes a GET request to a Jikan endpoint."""
try:
response = self.http_client.get(
f"{JIKAN_ENDPOINT}{endpoint}", params=params, timeout=10
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Jikan API request failed for endpoint '{endpoint}': {e}")
return None
# --- Read-Only Method Implementations ---
def search_media(self, params: ApiSearchParams) -> Optional[MediaSearchResult]:
"""Searches for anime on MyAnimeList via Jikan."""
jikan_params = {
"q": params.query,
"page": params.page,
"limit": params.per_page,
}
raw_data = self._execute_request("/anime", params=jikan_params)
return mapper.to_generic_search_result(raw_data) if raw_data else None
def fetch_trending_media(
self, page: int, per_page: int
) -> Optional[MediaSearchResult]:
"""Jikan doesn't have a 'trending' sort, so we'll use 'bypopularity'."""
jikan_params = {"order_by": "popularity", "page": page, "limit": per_page}
raw_data = self._execute_request("/anime", params=jikan_params)
return mapper.to_generic_search_result(raw_data) if raw_data else None
def fetch_popular_media(
self, page: int, per_page: int
) -> Optional[MediaSearchResult]:
"""Alias for trending in Jikan's case."""
return self.fetch_trending_media(page, per_page)
def fetch_favourite_media(
self, page: int, per_page: int
) -> Optional[MediaSearchResult]:
"""Fetches the most favorited media."""
jikan_params = {"order_by": "favorites", "page": page, "limit": per_page}
raw_data = self._execute_request("/anime", params=jikan_params)
return mapper.to_generic_search_result(raw_data) if raw_data else None
# --- No-Op Methods (Jikan is Read-Only) ---
def authenticate(self, token: str) -> Optional[UserProfile]:
logger.warning("Jikan API does not support authentication.")
return None
def get_viewer_profile(self) -> Optional[UserProfile]:
logger.warning("Jikan API does not support user profiles.")
return None
def fetch_user_list(self, params: UserListParams) -> Optional[MediaSearchResult]:
logger.warning("Jikan API does not support fetching user lists.")
return None
def update_list_entry(self, params: UpdateListEntryParams) -> bool:
logger.warning("Jikan API does not support updating list entries.")
return False
def delete_list_entry(self, media_id: int) -> bool:
logger.warning("Jikan API does not support deleting list entries.")
return False

View File

@@ -0,0 +1,104 @@
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING, List, Optional
from ..types import (
AiringSchedule,
MediaImage,
MediaItem,
MediaSearchResult,
MediaStatus,
MediaTag,
MediaTitle,
PageInfo,
Studio,
UserListStatus,
UserProfile,
)
if TYPE_CHECKING:
# Jikan doesn't have a formal schema like GraphQL, so we work with dicts.
pass
# Jikan uses specific strings for status, we can map them to our generic enum.
JIKAN_STATUS_MAP = {
"Finished Airing": "FINISHED",
"Currently Airing": "RELEASING",
"Not yet aired": "NOT_YET_RELEASED",
}
def _to_generic_title(jikan_titles: list[dict]) -> MediaTitle:
"""Extracts titles from Jikan's list of title objects."""
title_obj = MediaTitle()
# Jikan's default title is often the romaji one.
# We prioritize specific types if available.
for t in jikan_titles:
type_ = t.get("type")
title_ = t.get("title")
if type_ == "Default":
title_obj.romaji = title_
elif type_ == "English":
title_obj.english = title_
elif type_ == "Japanese":
title_obj.native = title_
return title_obj
def _to_generic_image(jikan_images: dict) -> MediaImage:
"""Maps Jikan's image structure."""
if not jikan_images:
return MediaImage()
# Jikan provides different image formats under a 'jpg' key.
jpg_images = jikan_images.get("jpg", {})
return MediaImage(
medium=jpg_images.get("image_url"),
large=jpg_images.get("large_image_url"),
)
def _to_generic_media_item(data: dict) -> MediaItem:
"""Maps a single Jikan anime entry to our generic MediaItem."""
# Jikan score is 0-10, our generic model is 0-10, so we can use it directly.
# AniList was 0-100, so its mapper had to divide by 10.
score = data.get("score")
return MediaItem(
id=data["mal_id"],
id_mal=data["mal_id"],
title=_to_generic_title(data.get("titles", [])),
cover_image=_to_generic_image(data.get("images", {})),
status=JIKAN_STATUS_MAP.get(data.get("status")),
episodes=data.get("episodes"),
duration=data.get("duration"),
average_score=score,
popularity=data.get("popularity"),
favourites=data.get("favorites"),
description=data.get("synopsis"),
genres=[g["name"] for g in data.get("genres", [])],
studios=[
Studio(id=s["mal_id"], name=s["name"]) for s in data.get("studios", [])
],
# Jikan doesn't provide user list status in its search results.
user_list_status=None,
)
def to_generic_search_result(api_response: dict) -> Optional[MediaSearchResult]:
"""Top-level mapper for Jikan search results."""
if not api_response or "data" not in api_response:
return None
media_items = [_to_generic_media_item(item) for item in api_response["data"]]
pagination = api_response.get("pagination", {})
page_info = PageInfo(
total=pagination.get("items", {}).get("total", 0),
current_page=pagination.get("current_page", 1),
has_next_page=pagination.get("has_next_page", False),
per_page=pagination.get("items", {}).get("per_page", 25),
)
return MediaSearchResult(page_info=page_info, media=media_items)