mirror of
https://github.com/Benexl/FastAnime.git
synced 2026-04-28 11:53:08 -07:00
feat: add jikan api
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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")
|
||||
|
||||
0
fastanime/libs/api/jikan/__init__.py
Normal file
0
fastanime/libs/api/jikan/__init__.py
Normal file
100
fastanime/libs/api/jikan/api.py
Normal file
100
fastanime/libs/api/jikan/api.py
Normal 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
|
||||
104
fastanime/libs/api/jikan/mapper.py
Normal file
104
fastanime/libs/api/jikan/mapper.py
Normal 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)
|
||||
Reference in New Issue
Block a user