From 3092ef0887da36d25ab01b045bc1f551a2642ad1 Mon Sep 17 00:00:00 2001 From: Benexl Date: Tue, 22 Jul 2025 17:25:33 +0300 Subject: [PATCH] feat: properly normalize episodes --- fastanime/core/utils/converters.py | 0 fastanime/core/utils/formatting.py | 65 +++++++++++++++++++++++++++ fastanime/libs/api/anilist/mapper.py | 67 +++++++++++++++++++++++++--- 3 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 fastanime/core/utils/converters.py create mode 100644 fastanime/core/utils/formatting.py diff --git a/fastanime/core/utils/converters.py b/fastanime/core/utils/converters.py new file mode 100644 index 0000000..e69de29 diff --git a/fastanime/core/utils/formatting.py b/fastanime/core/utils/formatting.py new file mode 100644 index 0000000..999aa77 --- /dev/null +++ b/fastanime/core/utils/formatting.py @@ -0,0 +1,65 @@ +import re +from typing import Dict, List, Optional, Union + + +def extract_episode_number(title: str) -> Optional[float]: + """ + Extracts the episode number (supports floats) from a title like: + "Episode 2.5 - Some Title". Returns None if no match. + """ + match = re.search(r"Episode\s+([0-9]+(?:\.[0-9]+)?)", title, re.IGNORECASE) + if match: + return round(float(match.group(1)), 3) + return None + + +def strip_original_episode_prefix(title: str) -> str: + """ + Removes the original 'Episode X' prefix from the title. + """ + return re.sub( + r"^Episode\s+[0-9]+(?:\.[0-9]+)?\s*[-:–]?\s*", "", title, flags=re.IGNORECASE + ) + + +def renumber_titles(titles: List[str]) -> Dict[str, Union[int, float, None]]: + """ + Extracts and renumbers episode numbers from titles starting at 1. + Preserves fractional spacing and leaves titles without episode numbers untouched. + + Returns a dict: {original_title: new_episode_number or None} + """ + # Separate titles with and without numbers + with_numbers = [(t, extract_episode_number(t)) for t in titles] + with_numbers = [(t, n) for t, n in with_numbers if n is not None] + without_numbers = [t for t in titles if extract_episode_number(t) is None] + + # Sort numerically + with_numbers.sort(key=lambda x: x[1]) + + renumbered = {} + base_map = {} + next_index = 1 + + for title, orig_ep in with_numbers: + int_part = int(orig_ep) + is_whole = orig_ep == int_part + + if is_whole: + base_map[int_part] = next_index + renumbered_val = next_index + next_index += 1 + else: + base_val = base_map.get(int_part, next_index - 1) + offset = round(orig_ep - int_part, 3) + renumbered_val = round(base_val + offset, 3) + + renumbered[title] = ( + int(renumbered_val) if renumbered_val.is_integer() else renumbered_val + ) + + # Add back the unnumbered titles with `None` + for t in without_numbers: + renumbered[t] = None + + return renumbered diff --git a/fastanime/libs/api/anilist/mapper.py b/fastanime/libs/api/anilist/mapper.py index 466010a..bd20ab0 100644 --- a/fastanime/libs/api/anilist/mapper.py +++ b/fastanime/libs/api/anilist/mapper.py @@ -2,6 +2,7 @@ import logging from datetime import datetime from typing import Dict, List, Optional +from ....core.utils.formatting import renumber_titles, strip_original_episode_prefix from ..types import ( AiringSchedule, MediaImage, @@ -131,15 +132,69 @@ def _to_generic_tags(anilist_tags: list[AnilistMediaTag]) -> List[MediaTag]: ] +# def _to_generic_streaming_episodes( +# anilist_episodes: list[AnilistStreamingEpisode], +# ) -> List[StreamingEpisode]: +# """Maps a list of AniList streaming episodes to generic StreamingEpisode objects.""" +# return [ +# StreamingEpisode(title=episode["title"], thumbnail=episode.get("thumbnail")) +# for episode in anilist_episodes +# if episode.get("title") +# ] + + +# def _to_generic_streaming_episodes( +# anilist_episodes: list[dict], +# ) -> List[StreamingEpisode]: +# """Maps a list of AniList streaming episodes to generic StreamingEpisode objects with renumbered episode titles.""" + +# # Extract titles +# titles = [ep["title"] for ep in anilist_episodes if "title" in ep] + +# # Generate mapping: title -> renumbered_ep +# renumbered_map = renumber_titles(titles) + +# # Apply renumbering +# return [ +# StreamingEpisode( +# title=f"{renumbered_map[ep['title']]} - {ep['title']}", +# thumbnail=ep.get("thumbnail"), +# ) +# for ep in anilist_episodes +# if ep.get("title") +# ] + + def _to_generic_streaming_episodes( anilist_episodes: list[AnilistStreamingEpisode], ) -> List[StreamingEpisode]: - """Maps a list of AniList streaming episodes to generic StreamingEpisode objects.""" - return [ - StreamingEpisode(title=episode["title"], thumbnail=episode.get("thumbnail")) - for episode in anilist_episodes - if episode.get("title") - ] + """Maps a list of AniList streaming episodes to generic StreamingEpisode objects, + renumbering them fresh if they contain episode numbers.""" + + titles = [ep["title"] for ep in anilist_episodes if "title" in ep and ep["title"]] + renumber_map = renumber_titles(titles) + + result = [] + for ep in anilist_episodes: + title = ep.get("title") + if not title: + continue + + renumbered_ep = renumber_map.get(title) + display_title = ( + f"Episode {renumbered_ep} - {strip_original_episode_prefix(title)}" + if renumbered_ep is not None + else title + ) + + result.append( + StreamingEpisode( + title=display_title, + thumbnail=ep.get("thumbnail"), + ) + ) + + return result def _to_generic_user_status(