mirror of
https://github.com/Benexl/FastAnime.git
synced 2025-12-22 15:16:26 -08:00
247 lines
7.3 KiB
Python
247 lines
7.3 KiB
Python
import re
|
||
from datetime import datetime
|
||
from typing import List, Optional, Dict, Union
|
||
|
||
from ...libs.media_api.types import AiringSchedule
|
||
|
||
COMMA_REGEX = re.compile(r"([0-9]{3})(?=\d)")
|
||
|
||
|
||
def format_media_duration(total_minutes: Optional[int]) -> str:
|
||
"""
|
||
Converts a duration in minutes into a more human-readable format
|
||
(e.g., "1 hour 30 minutes", "45 minutes", "2 hours").
|
||
|
||
Args:
|
||
total_minutes: The total duration in minutes (integer).
|
||
|
||
Returns:
|
||
A string representing the formatted duration.
|
||
"""
|
||
if not total_minutes:
|
||
return "N/A"
|
||
|
||
if not isinstance(total_minutes, int) or total_minutes < 0:
|
||
raise ValueError("Input must be a non-negative integer representing minutes.")
|
||
|
||
if total_minutes == 0:
|
||
return "0 minutes"
|
||
|
||
hours = total_minutes // 60
|
||
minutes = total_minutes % 60
|
||
|
||
parts = []
|
||
|
||
if hours > 0:
|
||
parts.append(f"{hours} hour{'s' if hours > 1 else ''}")
|
||
|
||
if minutes > 0:
|
||
parts.append(f"{minutes} minute{'s' if minutes > 1 else ''}")
|
||
|
||
# Join the parts with " and " if both hours and minutes are present
|
||
if len(parts) == 2:
|
||
return f"{parts[0]} and {parts[1]}"
|
||
elif len(parts) == 1:
|
||
return parts[0]
|
||
else:
|
||
# This case should ideally not be reached if total_minutes > 0
|
||
return "0 minutes" # Fallback for safety, though handled by initial check
|
||
|
||
|
||
def format_date(dt: Optional[datetime], format_str: str = "%A, %d %B %Y") -> str:
|
||
"""
|
||
Formats a datetime object to a readable string.
|
||
|
||
Default format: '2025-22 July'
|
||
|
||
Params:
|
||
dt (datetime): The datetime object to format.
|
||
format_str (str): Optional custom format string (defaults to "%Y-%d %B").
|
||
|
||
Returns:
|
||
str: The formatted date.
|
||
"""
|
||
if not dt:
|
||
return "N/A"
|
||
return dt.strftime(format_str)
|
||
|
||
|
||
def _htmlentity_transform(entity_with_semicolon):
|
||
import contextlib
|
||
import html.entities
|
||
import html.parser
|
||
|
||
"""Transforms an HTML entity to a character."""
|
||
entity = entity_with_semicolon[:-1]
|
||
|
||
# Known non-numeric HTML entity
|
||
if entity in html.entities.name2codepoint:
|
||
return chr(html.entities.name2codepoint[entity])
|
||
|
||
# TODO: HTML5 allows entities without a semicolon.
|
||
# E.g. 'Éric' should be decoded as 'Éric'.
|
||
if entity_with_semicolon in html.entities.html5:
|
||
return html.entities.html5[entity_with_semicolon]
|
||
|
||
mobj = re.match(r"#(x[0-9a-fA-F]+|[0-9]+)", entity)
|
||
if mobj is not None:
|
||
numstr = mobj.group(1)
|
||
if numstr.startswith("x"):
|
||
base = 16
|
||
numstr = f"0{numstr}"
|
||
else:
|
||
base = 10
|
||
# See https://github.com/ytdl-org/youtube-dl/issues/7518
|
||
with contextlib.suppress(ValueError):
|
||
return chr(int(numstr, base))
|
||
|
||
# Unknown entity in name, return its literal representation
|
||
return f"&{entity};"
|
||
|
||
|
||
def unescapeHTML(s: str):
|
||
if s is None:
|
||
return None
|
||
assert isinstance(s, str)
|
||
|
||
return re.sub(r"&([^&;]+;)", lambda m: _htmlentity_transform(m.group(1)), s)
|
||
|
||
|
||
def escapeHTML(text):
|
||
return (
|
||
text.replace("&", "&")
|
||
.replace("<", "<")
|
||
.replace(">", ">")
|
||
.replace('"', """)
|
||
.replace("'", "'")
|
||
)
|
||
|
||
|
||
def clean_html(html: Optional[str]):
|
||
"""Clean an HTML snippet into a readable string"""
|
||
|
||
if html is None: # Convenience for sanitizing descriptions etc.
|
||
return html
|
||
|
||
html = re.sub(r"\s+", " ", html)
|
||
html = re.sub(r"(?u)\s?<\s?br\s?/?\s?>\s?", "\n", html)
|
||
html = re.sub(r"(?u)<\s?/\s?p\s?>\s?<\s?p[^>]*>", "\n", html)
|
||
# Strip html tags
|
||
html = re.sub("<.*?>", "", html)
|
||
# Replace html entities
|
||
html = unescapeHTML(html)
|
||
return html.strip()
|
||
|
||
|
||
def format_number_with_commas(number: Optional[int]) -> str:
|
||
"""Formats an integer with commas for thousands separation."""
|
||
if number is None:
|
||
return "N/A"
|
||
return COMMA_REGEX.sub(r"\1,", str(number)[::-1])[::-1]
|
||
|
||
|
||
def format_airing_schedule(airing: Optional[AiringSchedule]) -> str:
|
||
"""Formats the next airing episode information into a readable string."""
|
||
if not airing or not airing.airing_at:
|
||
return "N/A"
|
||
|
||
# Get a human-readable date and time
|
||
air_date = airing.airing_at.strftime("%a, %b %d at %I:%M %p")
|
||
return f"Ep {airing.episode} on {air_date}"
|
||
|
||
|
||
def format_list_with_commas(list_of_strs: List[str]) -> str:
|
||
"""Joins a list of genres into a single, comma-separated string."""
|
||
return ", ".join(list_of_strs) if list_of_strs else "N/A"
|
||
|
||
|
||
def format_score_stars_full(score: Optional[float]) -> str:
|
||
"""Formats an AniList score (0-100) to a 0-10 scale using full stars."""
|
||
if score is None:
|
||
return "N/A"
|
||
|
||
# Convert 0-100 to 0-10, then to a whole number of stars
|
||
num_stars = min(round(score * 6 / 100), 6)
|
||
return "⭐" * num_stars
|
||
|
||
|
||
def format_score(score: Optional[float]) -> str:
|
||
"""Formats an AniList score (0-100) to a 0-10 scale."""
|
||
if score is None:
|
||
return "N/A"
|
||
return f"{score / 10.0:.1f} / 10"
|
||
|
||
|
||
def shell_safe(text: Optional[str]) -> str:
|
||
"""
|
||
Escapes a string for safe inclusion in a shell script,
|
||
specifically for use within double quotes. It escapes backticks,
|
||
double quotes, and dollar signs.
|
||
"""
|
||
if not text:
|
||
return ""
|
||
return text.replace("`", "\\`").replace('"', '\\"').replace("$", "\\$")
|
||
|
||
|
||
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
|