From f1b796d72b780d084f8bd44a3bcb5b6b7bbc9cb2 Mon Sep 17 00:00:00 2001 From: axtrat Date: Thu, 21 Aug 2025 10:19:25 +0200 Subject: [PATCH] feat: Initial implementation of AnimeUnity provider --- viu_media/cli/commands/update.py | 1 - .../provider/anime/animeunity/__init__.py | 0 .../provider/anime/animeunity/constants.py | 9 ++ .../libs/provider/anime/animeunity/mappers.py | 111 +++++++++++++ .../provider/anime/animeunity/provider.py | 147 ++++++++++++++++++ viu_media/libs/provider/anime/provider.py | 1 + viu_media/libs/provider/anime/types.py | 1 + 7 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 viu_media/libs/provider/anime/animeunity/__init__.py create mode 100644 viu_media/libs/provider/anime/animeunity/constants.py create mode 100644 viu_media/libs/provider/anime/animeunity/mappers.py create mode 100644 viu_media/libs/provider/anime/animeunity/provider.py diff --git a/viu_media/cli/commands/update.py b/viu_media/cli/commands/update.py index cfb768c..c72d782 100644 --- a/viu_media/cli/commands/update.py +++ b/viu_media/cli/commands/update.py @@ -1,6 +1,5 @@ """Update command for Viu CLI.""" -import sys from typing import TYPE_CHECKING import click diff --git a/viu_media/libs/provider/anime/animeunity/__init__.py b/viu_media/libs/provider/anime/animeunity/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/viu_media/libs/provider/anime/animeunity/constants.py b/viu_media/libs/provider/anime/animeunity/constants.py new file mode 100644 index 0000000..454ee8b --- /dev/null +++ b/viu_media/libs/provider/anime/animeunity/constants.py @@ -0,0 +1,9 @@ +import re + +ANIMEUNITY = "animeunity.so" +ANIMEUNITY_BASE = f"https://www.{ANIMEUNITY}" + +MAX_TIMEOUT = 10 + +TOKEN_REGEX = re.compile(r'') +DOWNLOAD_URL_REGEX = r"window.downloadUrl\s*=\s*'([^']*)'" diff --git a/viu_media/libs/provider/anime/animeunity/mappers.py b/viu_media/libs/provider/anime/animeunity/mappers.py new file mode 100644 index 0000000..0135436 --- /dev/null +++ b/viu_media/libs/provider/anime/animeunity/mappers.py @@ -0,0 +1,111 @@ +from httpx import Response + +from ..types import ( + Anime, + AnimeEpisodeInfo, + AnimeEpisodes, + EpisodeStream, + MediaTranslationType, + PageInfo, + SearchResult, + SearchResults, + Server, +) + +translation_type_map = { + "sub": MediaTranslationType.SUB, + "dub": MediaTranslationType.DUB, + "raw": MediaTranslationType.RAW, +} + + +def map_to_search_results(response: Response) -> SearchResults: + """ + animes = list[Anime]() + for result in results: + title, anilist_id, info = self._parse_info(result) + anime = Anime(title, result['id']) + anime._set_info(anilist_id, info) + animes.append(anime) + + return animes + """ + data = response.json().get("records", []) + + return SearchResults( + page_info=PageInfo(), + results=[ + SearchResult( + id=str(result["id"]), + title=get_real_title(result), + episodes=AnimeEpisodes( + sub=( + list(map(str, range(1, result["episodes_count"] + 1))) + if result["dub"] == 0 + else [] + ), + dub=( + list(map(str, range(1, result["episodes_count"] + 1))) + if result["dub"] == 1 + else [] + ), + ), + # other_titles=[title for title in [result["title_eng"], result["title_it"]] if title], + media_type=result["type"], + score=result["score"], + status=result["status"], + season=result["season"], + poster=result["imageurl"], + year=result["date"], + ) + for result in data + ], + ) + + +def map_to_anime_result(response: Response, search_result: SearchResult) -> Anime: + data = response.json()["episodes"] + return Anime( + id=search_result.id, + title=search_result.title, + episodes=search_result.episodes, + episodes_info=[ + AnimeEpisodeInfo( + id=str(episode["id"]), + episode=episode["number"], + # session_id=episode.get("session_id"), + title=f"{search_result.title} - Ep {episode['number']}", + # poster=episode["tg_post"], + # duration=episode.get("duration"), + ) + for episode in data + ], + type=search_result.media_type, + poster=search_result.poster, + year=search_result.year, + ) + + +def map_to_server(episode: AnimeEpisodeInfo, download_url: str) -> Server: + return Server( + name="vixcloud", + links=[ + EpisodeStream( + link=download_url, + # translation_type=translation_type_map[episode.] + ) + ], + episode_title=episode.title, + ) + + +def get_real_title(record: dict) -> str: + """ + Return the most appropriate title from the record. + """ + if record.get("title_eng"): + return record["title_eng"] + elif record.get("title"): + return record["title"] + else: + return record.get("title_it", "") diff --git a/viu_media/libs/provider/anime/animeunity/provider.py b/viu_media/libs/provider/anime/animeunity/provider.py new file mode 100644 index 0000000..cfd56f7 --- /dev/null +++ b/viu_media/libs/provider/anime/animeunity/provider.py @@ -0,0 +1,147 @@ +import logging +import re +import time +from functools import lru_cache + +import httpx + +from ...scraping.user_agents import UserAgentGenerator +from ..base import BaseAnimeProvider +from ..params import AnimeParams, EpisodeStreamsParams, SearchParams +from ..types import Anime, AnimeEpisodeInfo, SearchResult, SearchResults +from ..utils.debug import debug_provider +from .constants import ANIMEUNITY_BASE, DOWNLOAD_URL_REGEX, MAX_TIMEOUT +from .mappers import map_to_anime_result, map_to_search_results, map_to_server + +logger = logging.getLogger(__name__) + + +class AnimeUnity(BaseAnimeProvider): + HEADERS = { + "user-agent": UserAgentGenerator().random(), + } + + @lru_cache + def _get_token(self) -> dict[str, str]: + response = self.client.get(ANIMEUNITY_BASE, headers=self.HEADERS) + data = response.cookies + cookies = { + "animeunity_session": data["animeunity_session"], + } + + self.HEADERS["x-xsrf-token"] = data["XSRF-TOKEN"] + return cookies + + @debug_provider + def search(self, params: SearchParams) -> SearchResults | None: + return self._search(params) + + @lru_cache + def _search(self, params: SearchParams) -> SearchResults | None: + cookies = self._get_token() + try: + response = self.client.post( + url=f"{ANIMEUNITY_BASE}/livesearch", + data={"title": params.query}, + headers=self.HEADERS, + cookies=cookies, + timeout=MAX_TIMEOUT, + ) + response.raise_for_status() + except httpx.HTTPStatusError as e: + logger.error(f"AnimeUnity 500 error for query '{params.query}'") + # Opzionale: retry dopo un breve delay + logger.info("Retrying after 2 seconds...") + time.sleep(2) + return self._search(params) + return map_to_search_results(response) + + @debug_provider + def get(self, params: AnimeParams) -> Anime | None: + return self._get_anime(params) + + @lru_cache() + def _get_search_result(self, params: AnimeParams) -> SearchResult | None: + search_results = self._search(SearchParams(query=params.query)) + if not search_results or not search_results.results: + logger.error(f"No search results found for ID {params.id}") + return None + for search_result in search_results.results: + if search_result.id == params.id: + return search_result + + @lru_cache + def _get_anime(self, params: AnimeParams) -> Anime | None: + search_result = self._get_search_result(params) + if not search_result: + logger.error(f"No search result found for ID {params.id}") + return None + + cookies = self._get_token() + response = self.client.get( + url=f"{ANIMEUNITY_BASE}/info_api/{params.id}/1", + params={ + "start_range": 0, + "end_range": max( + len(search_result.episodes.sub), len(search_result.episodes.dub) + ), + }, + headers=self.HEADERS, + cookies=cookies, + timeout=MAX_TIMEOUT, + ) + response.raise_for_status() + return map_to_anime_result(response, search_result) + + @lru_cache() + def _get_episode_info( + self, params: EpisodeStreamsParams + ) -> AnimeEpisodeInfo | None: + anime_info = self._get_anime( + AnimeParams(id=params.anime_id, query=params.query) + ) + if not anime_info: + logger.error(f"No anime info for {params.anime_id}") + return + if not anime_info.episodes_info: + logger.error(f"No episodes info for {params.anime_id}") + return + for episode in anime_info.episodes_info: + if episode.episode == params.episode: + return episode + + @debug_provider + def episode_streams(self, params): + episode = self._get_episode_info(params) + if not episode: + logger.error( + f"Episode {params.episode} doesn't exist for anime {params.anime_id}" + ) + return + + cookies = self._get_token() + response = self.client.get( + url=f"{ANIMEUNITY_BASE}/embed-url/{episode.id}", + headers=self.HEADERS, + cookies=cookies, + timeout=MAX_TIMEOUT, + ) + response.raise_for_status() + # The embed URL is returned as plain text + iframe_src = response.text.strip() + # Fetch the video page + video_response = self.client.get( + iframe_src, headers=self.HEADERS, cookies=cookies, timeout=MAX_TIMEOUT + ) + video_response.raise_for_status() + + download_url_match = re.search(DOWNLOAD_URL_REGEX, video_response.text) + if download_url_match: + yield map_to_server(episode, download_url_match.group(1)) + return None + + +if __name__ == "__main__": + from ..utils.debug import test_anime_provider + + test_anime_provider(AnimeUnity) diff --git a/viu_media/libs/provider/anime/provider.py b/viu_media/libs/provider/anime/provider.py index 1ef2ee8..4784dab 100644 --- a/viu_media/libs/provider/anime/provider.py +++ b/viu_media/libs/provider/anime/provider.py @@ -14,6 +14,7 @@ PROVIDERS_AVAILABLE = { "hianime": "provider.HiAnime", "nyaa": "provider.Nyaa", "yugen": "provider.Yugen", + "animeunity": "provider.AnimeUnity", } diff --git a/viu_media/libs/provider/anime/types.py b/viu_media/libs/provider/anime/types.py index c8afb96..a55a6e2 100644 --- a/viu_media/libs/provider/anime/types.py +++ b/viu_media/libs/provider/anime/types.py @@ -11,6 +11,7 @@ from pydantic import BaseModel, ConfigDict class ProviderName(Enum): ALLANIME = "allanime" ANIMEPAHE = "animepahe" + ANIMEUNITY = "animeunity" class ProviderServer(Enum):