From e5784bc1b8efc7ce09576571806e4a3a7ec1d196 Mon Sep 17 00:00:00 2001 From: ProgrammGamer Date: Tue, 21 Apr 2026 22:41:43 +0200 Subject: [PATCH] finalising the Querries and the rest --- data_sources/anilist_source.py | 181 +++++++---------- data_sources/tmdb_source.py | 139 ++++++------- movie_pipeline.py | 356 ++++++++++++++++++++++----------- 3 files changed, 372 insertions(+), 304 deletions(-) diff --git a/data_sources/anilist_source.py b/data_sources/anilist_source.py index 69293be..e7555d1 100644 --- a/data_sources/anilist_source.py +++ b/data_sources/anilist_source.py @@ -4,30 +4,13 @@ from __future__ import annotations import json import urllib.error import urllib.request -from datetime import date +from datetime import date, timedelta ANILIST_URL = "https://graphql.anilist.co" -def normalize_title(text: str) -> str: - cleaned = "".join(ch.lower() if ch.isalnum() else " " for ch in (text or "")) - return " ".join(cleaned.split()) - - -def safe_date(sd: dict | None) -> date | None: - if not sd: - return None - year = sd.get("year") or 0 - month = sd.get("month") or 0 - day = sd.get("day") or 0 - if year <= 0 or month <= 0: - return None - if day <= 0: - day = 1 - try: - return date(year, month, day) - except ValueError: - return None +def yyyymmdd(value: date) -> int: + return value.year * 10000 + value.month * 100 + value.day def post_graphql(query: str, variables: dict) -> dict: @@ -46,78 +29,69 @@ def post_graphql(query: str, variables: dict) -> dict: raise RuntimeError(f"HTTP {exc.code}: {body}") from exc -def pick_best_title(title: dict) -> str: - english = (title.get("english") or "").strip() - if english: - return english - romaji = (title.get("romaji") or "").strip() - if romaji: - return romaji - native = (title.get("native") or "").strip() - return native or "" - - -def map_anilist_media(media: dict | None) -> dict: +def map_anilist_movie(media: dict | None) -> dict: media = media or {} title = media.get("title") or {} + start_date = media.get("startDate") or {} studios = ((media.get("studios") or {}).get("nodes") or []) - studio_names = [((node or {}).get("name") or "").strip() for node in studios] - studio_names = [name for name in studio_names if name] - genres = [str(item).strip() for item in (media.get("genres") or [])] - genres = [g for g in genres if g] + studio_names = [] + for node in studios: + name = ((node or {}).get("name") or "").strip() + if name: + studio_names.append(name) + + genres = [] + for entry in media.get("genres") or []: + text = str(entry or "").strip() + if text: + genres.append(text) - tags_raw = media.get("tags") or [] tags = [] - for tag in tags_raw: + for tag in media.get("tags") or []: if not isinstance(tag, dict): continue name = (tag.get("name") or "").strip() rank = int(tag.get("rank") or 0) - if name: - tags.append({"name": name, "rank": rank}) + if name and rank >= 70: + tags.append(name) - mapped = { - "id": media.get("id"), - "title_best": pick_best_title(title), + return { + "anilist_id": int(media.get("id") or 0), "title_english": (title.get("english") or "").strip(), "title_romaji": (title.get("romaji") or "").strip(), "title_native": (title.get("native") or "").strip(), - "start_date": safe_date(media.get("startDate")), - "format": (media.get("format") or "").strip(), - "episodes": media.get("episodes"), - "duration": media.get("duration"), - "source": str(media.get("source") or "").replace("_", " ").title(), - "description": (media.get("description") or "").strip(), - "genres": genres, - "genres_text": ", ".join(genres) if genres else "", - "tags": tags, - "tags_text": ", ".join(tag["name"] for tag in tags if tag["rank"] >= 70) or "", - "studio_names": studio_names, - "studio_text": ", ".join(studio_names) if studio_names else "", - "anilist_url": (media.get("siteUrl") or "").strip(), "cover_image": (((media.get("coverImage") or {}).get("large")) or "").strip(), - "raw": media, + "anilist_url": (media.get("siteUrl") or "").strip(), + "start_year": int(start_date.get("year") or 0), + "format": (media.get("format") or "").strip() or "MOVIE", + "description": (media.get("description") or "").strip(), + "genres_text": ", ".join(genres), + "tags_text": ", ".join(tags), + "studio_text": ", ".join(studio_names), } - return mapped -def fetch_anilist_movie_by_search(search_text: str, cache: dict[str, dict | None]) -> dict | None: - key = normalize_title(search_text) - if key in cache: - return cache[key] +def fetch_anilist_movie_candidates(today: date, years_window: int = 1) -> list[dict]: + start_date = today - timedelta(days=365 * years_window) + end_date = today + timedelta(days=365 * years_window) query = """ - query ($search: String, $perPage: Int) { - Page(page: 1, perPage: $perPage) { - media(type: ANIME, format: MOVIE, search: $search, sort: [SEARCH_MATCH, POPULARITY_DESC]) { + query ($page: Int, $perPage: Int, $start: FuzzyDateInt, $end: FuzzyDateInt) { + Page(page: $page, perPage: $perPage) { + pageInfo { hasNextPage } + media( + type: ANIME + format: MOVIE + countryOfOrigin: JP + sort: [POPULARITY_DESC, START_DATE_DESC] + startDate_greater: $start + startDate_lesser: $end + ) { id title { english romaji native } - startDate { year month day } + startDate { year } format - episodes - duration - source description(asHtml: false) genres tags { name rank } @@ -129,48 +103,39 @@ def fetch_anilist_movie_by_search(search_text: str, cache: dict[str, dict | None } """ - try: - data = post_graphql(query, {"search": search_text, "perPage": 5}) - except Exception: - cache[key] = None - return None + page = 1 + results = [] + seen_ids = set() - if "errors" in data: - cache[key] = None - return None + while True: + payload = post_graphql( + query, + { + "page": page, + "perPage": 50, + "start": yyyymmdd(start_date) - 1, + "end": yyyymmdd(end_date) + 1, + }, + ) - candidates = data.get("data", {}).get("Page", {}).get("media", []) - if not candidates: - cache[key] = None - return None + if "errors" in payload: + raise RuntimeError(payload["errors"]) - wanted = normalize_title(search_text) - best = None - best_score = -1 - for media in candidates: - title = media.get("title") or {} - options = [title.get("english"), title.get("romaji"), title.get("native")] - score = 0 - for option in options: - normalized = normalize_title(str(option or "")) - if not normalized: + page_data = payload.get("data", {}).get("Page", {}) + for media in page_data.get("media", []): + mapped = map_anilist_movie(media) + anilist_id = mapped.get("anilist_id") or 0 + if anilist_id <= 0 or anilist_id in seen_ids: continue - if normalized == wanted: - score = max(score, 3) - elif wanted and (wanted in normalized or normalized in wanted): - score = max(score, 2) - elif normalized.split(" ")[:2] == wanted.split(" ")[:2]: - score = max(score, 1) - if score > best_score: - best_score = score - best = media - if score == 3: - break + seen_ids.add(anilist_id) - if best_score <= 0: - cache[key] = None - return None + if not mapped.get("title_english") and not mapped.get("title_romaji") and not mapped.get("title_native"): + continue - mapped = map_anilist_media(best) if best else None - cache[key] = mapped - return mapped + results.append(mapped) + + if not page_data.get("pageInfo", {}).get("hasNextPage"): + break + page += 1 + + return results diff --git a/data_sources/tmdb_source.py b/data_sources/tmdb_source.py index c4da5dd..819266c 100644 --- a/data_sources/tmdb_source.py +++ b/data_sources/tmdb_source.py @@ -7,7 +7,6 @@ import urllib.request from datetime import date TMDB_API_BASE = "https://api.themoviedb.org/3" -TMDB_ANIME_KEYWORD_ID = "210024" def parse_release_date(value: str) -> date | None: @@ -21,7 +20,10 @@ def parse_release_date(value: str) -> date | None: def tmdb_get_json(path: str, params: dict, token: str) -> dict: - url = TMDB_API_BASE + path + "?" + urllib.parse.urlencode(params) + url = TMDB_API_BASE + path + if params: + url += "?" + urllib.parse.urlencode(params) + req = urllib.request.Request( url, headers={ @@ -30,89 +32,74 @@ def tmdb_get_json(path: str, params: dict, token: str) -> dict: "User-Agent": "anime-movies-script/1.0", }, ) + with urllib.request.urlopen(req, timeout=20) as resp: return json.loads(resp.read().decode("utf-8")) -def poster_url(path: str) -> str: - text = (path or "").strip() - if not text: - return "" - return "https://image.tmdb.org/t/p/w780" + text - - -def fetch_genre_map(token: str, language: str = "de-DE") -> dict[int, str]: - payload = tmdb_get_json("/genre/movie/list", {"language": language}, token) - result = {} - for entry in payload.get("genres", []): - try: - gid = int(entry.get("id")) - except Exception: - continue - name = (entry.get("name") or "").strip() - if name: - result[gid] = name - return result - - -def fetch_tmdb_anime_movies(start_date: date, end_date: date, token: str, language: str = "de-DE") -> list[dict]: - if not token: +def search_tmdb_movies(title: str, token: str, language: str = "en-US") -> list[dict]: + query = (title or "").strip() + if not query: return [] - genre_map = fetch_genre_map(token, language=language) - page = 1 - results = [] - seen_ids = set() - - while True: - params = { + payload = tmdb_get_json( + "/search/movie", + { + "query": query, "include_adult": "false", - "include_video": "false", "language": language, - "sort_by": "primary_release_date.desc", - "primary_release_date.gte": start_date.isoformat(), - "primary_release_date.lte": end_date.isoformat(), - "with_keywords": TMDB_ANIME_KEYWORD_ID, - "page": str(page), - } - payload = tmdb_get_json("/discover/movie", params, token) - page_results = payload.get("results", []) - if not page_results: - break + "page": "1", + }, + token, + ) - for movie in page_results: - movie_id = movie.get("id") - if movie_id in seen_ids: - continue + results = [] + for item in payload.get("results", []): + movie_id = item.get("id") + if not movie_id: + continue - release_date = parse_release_date(str(movie.get("release_date") or "")) - if not release_date: - continue - - seen_ids.add(movie_id) - genre_names = [genre_map.get(gid, "") for gid in (movie.get("genre_ids") or [])] - genre_names = [name for name in genre_names if name] - - results.append( - { - "tmdb_id": movie_id, - "title": (movie.get("title") or "").strip(), - "original_title": (movie.get("original_title") or "").strip(), - "overview": (movie.get("overview") or "").strip(), - "release_date": release_date, - "genres": genre_names, - "genres_text": ", ".join(genre_names), - "poster_url": poster_url(str(movie.get("poster_path") or "")), - "popularity": movie.get("popularity"), - "vote_average": movie.get("vote_average"), - "vote_count": movie.get("vote_count"), - "raw": movie, - } - ) - - total_pages = int(payload.get("total_pages") or page) - if page >= total_pages: - break - page += 1 + results.append( + { + "tmdb_id": int(movie_id), + "title": (item.get("title") or "").strip(), + "original_title": (item.get("original_title") or "").strip(), + "release_date": parse_release_date(str(item.get("release_date") or "")), + } + ) return results + + +def fetch_tmdb_release_dates(movie_id: int, token: str) -> dict: + return tmdb_get_json(f"/movie/{movie_id}/release_dates", {}, token) + + +def extract_de_theatrical_dates(release_payload: dict) -> list[date]: + german_block = None + for result in release_payload.get("results", []): + if str(result.get("iso_3166_1") or "").upper() == "DE": + german_block = result + break + + if not german_block: + return [] + + dates = [] + for entry in german_block.get("release_dates", []): + release_type = int(entry.get("type") or 0) + if release_type not in {2, 3}: + continue + + release_date = parse_release_date(str(entry.get("release_date") or "")) + if release_date: + dates.append(release_date) + + return sorted(dates) + + +def select_release_in_range(release_dates: list[date], start_date: date, end_date: date) -> date | None: + for release_date in sorted(release_dates): + if start_date <= release_date <= end_date: + return release_date + return None diff --git a/movie_pipeline.py b/movie_pipeline.py index 79e432f..7bc01be 100644 --- a/movie_pipeline.py +++ b/movie_pipeline.py @@ -2,147 +2,213 @@ from __future__ import annotations from datetime import date, timedelta +from difflib import SequenceMatcher from app_config import get_settings -from data_sources.anilist_source import fetch_anilist_movie_by_search +from data_sources.anilist_source import fetch_anilist_movie_candidates from data_sources.animeschedule_source import fetch_animeschedule_anime_by_title -from data_sources.tmdb_source import fetch_tmdb_anime_movies +from data_sources.tmdb_source import ( + extract_de_theatrical_dates, + fetch_tmdb_release_dates, + search_tmdb_movies, + select_release_in_range, +) + +FUZZY_MATCH_THRESHOLD = 0.65 +SCHEDULE_MATCH_THRESHOLD = 2 -def add_months(d: date, months: int) -> date: - year = d.year + ((d.month - 1 + months) // 12) - month = ((d.month - 1 + months) % 12) + 1 +def add_months(value: date, months: int) -> date: + year = value.year + ((value.month - 1 + months) // 12) + month = ((value.month - 1 + months) % 12) + 1 return date(year, month, 1) -def format_date(d: date, locale: str) -> str: +def normalize_title(text: str) -> str: + cleaned = "".join(ch.lower() if ch.isalnum() else " " for ch in (text or "")) + return " ".join(cleaned.split()) + + +def fuzzy_ratio(left: str, right: str) -> float: + a = normalize_title(left) + b = normalize_title(right) + if not a or not b: + return 0.0 + return SequenceMatcher(None, a, b).ratio() + + +def format_date(value: date, locale: str) -> str: if locale == "de-DE": - return f"{d.day:02d}.{d.month:02d}.{d.year}" - return d.isoformat() + return f"{value.day:02d}.{value.month:02d}.{value.year}" + return value.isoformat() -def select_title(anilist: dict, schedule: dict, tmdb: dict) -> str: - return ( - (schedule.get("english_title") or "").strip() - or (anilist.get("title_english") or "").strip() - or (anilist.get("title_best") or "").strip() - or (tmdb.get("title") or "").strip() - or (tmdb.get("original_title") or "").strip() - ) +def parse_record_release_date(record: dict) -> date: + text = str(record.get("releaseDate") or "").strip() + try: + return date.fromisoformat(text) + except ValueError: + return date.max -def structure_movie_record(tmdb_movie: dict, anilist: dict, schedule: dict, locale: str) -> dict: - release_date = tmdb_movie.get("release_date") - release = format_date(release_date, locale) if isinstance(release_date, date) else "n/a" +def is_year_match(anilist_year: int, tmdb_year: int) -> bool: + if anilist_year <= 0 or tmdb_year <= 0: + return False + return abs(anilist_year - tmdb_year) <= 1 - has_anilist = bool(anilist) - has_schedule = bool(schedule and (schedule.get("title") or schedule.get("english_title") or schedule.get("url"))) - final_description = ( - (tmdb_movie.get("overview") or "").strip() - or (anilist.get("description") or "").strip() - or (schedule.get("description") or "").strip() - or "n/a" - ) +def title_score(anilist_english: str, anilist_romaji: str, anilist_native: str, tmdb_title: str, tmdb_original: str) -> tuple[int, int, int, float]: + english_norm = normalize_title(anilist_english) + romaji_norm = normalize_title(anilist_romaji) + native_norm = normalize_title(anilist_native) + tmdb_options = [normalize_title(tmdb_title), normalize_title(tmdb_original)] - # User requirement: cover images should come from AniList. - cover_image = (anilist.get("cover_image") or "").strip() + exact_english = 1 if english_norm and english_norm in tmdb_options else 0 + exact_romaji = 1 if romaji_norm and romaji_norm in tmdb_options else 0 + exact_native = 1 if native_norm and native_norm in tmdb_options else 0 - genres_text = ( - (tmdb_movie.get("genres_text") or "").strip() - or (anilist.get("genres_text") or "").strip() - or (schedule.get("genres") or "").strip() - or "n/a" - ) + best_ratio = 0.0 + for option in (tmdb_title, tmdb_original): + best_ratio = max( + best_ratio, + fuzzy_ratio(anilist_english, option), + fuzzy_ratio(anilist_romaji, option), + fuzzy_ratio(anilist_native, option), + ) - record = { - "title": select_title(anilist, schedule, tmdb_movie), - "title_english_anilist": (anilist.get("title_english") or "").strip(), - "title_anilist": (anilist.get("title_best") or "").strip(), - "title_schedule_english": (schedule.get("english_title") or "").strip(), - "title_romaji": (anilist.get("title_romaji") or "").strip() or "n/a", - "title_native": (anilist.get("title_native") or "").strip() or "n/a", - "studio": (anilist.get("studio_text") or "").strip() or (schedule.get("studios") or "").strip() or "n/a", - "genres": genres_text, - "tags": (anilist.get("tags_text") or "").strip() or "n/a", - "release": release, - "anilist_url": (anilist.get("anilist_url") or "").strip() or "n/a", - "format": (anilist.get("format") or "").strip() or (schedule.get("format") or "").strip() or "MOVIE", - "episodes": anilist.get("episodes") or "n/a", - "duration": anilist.get("duration") or "n/a", - "source": (anilist.get("source") or "").strip() or "n/a", - "cover_image": cover_image, - "description": final_description, - "schedule_url": (schedule.get("url") or "").strip(), - "schedule_title": (schedule.get("title") or "").strip(), - "release_source": "TMDb DE", - "source_presence": { - "tmdb": True, - "anilist": has_anilist, - "animeschedule": has_schedule, + return exact_english, exact_romaji, exact_native, best_ratio + + +def collect_tmdb_candidates_for_anilist(anilist_entry: dict, tmdb_token: str, search_cache: dict[str, list[dict]]) -> list[dict]: + english = (anilist_entry.get("title_english") or "").strip() + romaji = (anilist_entry.get("title_romaji") or "").strip() + native = (anilist_entry.get("title_native") or "").strip() + + queries = [] + if english: + queries.append(english) + if romaji and normalize_title(romaji) != normalize_title(english): + queries.append(romaji) + if native and normalize_title(native) not in {normalize_title(english), normalize_title(romaji)}: + queries.append(native) + + if not queries: + return [] + + candidates_by_id = {} + languages = ["de-DE", "en-US", "ja-JP"] + + for query in queries: + normalized_query = normalize_title(query) + for language in languages: + cache_key = f"{language}:{normalized_query}" + if cache_key not in search_cache: + search_cache[cache_key] = search_tmdb_movies(query, tmdb_token, language=language) + + for item in search_cache[cache_key]: + tmdb_id = int(item.get("tmdb_id") or 0) + if tmdb_id <= 0: + continue + + existing = candidates_by_id.get(tmdb_id) + if not existing: + candidates_by_id[tmdb_id] = item + continue + + # Prefer candidate carrying a release date if duplicate appears from different language queries. + if not existing.get("release_date") and item.get("release_date"): + candidates_by_id[tmdb_id] = item + + return list(candidates_by_id.values()) + + +def pick_best_tmdb_match(anilist_entry: dict, tmdb_candidates: list[dict]) -> dict | None: + english = (anilist_entry.get("title_english") or "").strip() + romaji = (anilist_entry.get("title_romaji") or "").strip() + native = (anilist_entry.get("title_native") or "").strip() + anilist_year = int(anilist_entry.get("start_year") or 0) + + best = None + best_tuple = (-1, -1, -1, 0.0) + + for candidate in tmdb_candidates: + exact_english, exact_romaji, exact_native, ratio = title_score( + english, + romaji, + native, + str(candidate.get("title") or ""), + str(candidate.get("original_title") or ""), + ) + + tmdb_release = candidate.get("release_date") + tmdb_year = tmdb_release.year if tmdb_release else 0 + has_exact_match = exact_english == 1 or exact_romaji == 1 or exact_native == 1 + if not has_exact_match and not is_year_match(anilist_year, tmdb_year): + continue + + if ratio < FUZZY_MATCH_THRESHOLD and exact_english == 0 and exact_romaji == 0 and exact_native == 0: + continue + + score_tuple = (exact_english, exact_romaji, exact_native, ratio) + if score_tuple > best_tuple: + best_tuple = score_tuple + best = candidate + + return best + + +def resolve_titles(anilist_entry: dict, schedule_token: str, schedule_cache: dict[str, dict]) -> tuple[str, str]: + english = (anilist_entry.get("title_english") or "").strip() + romaji = (anilist_entry.get("title_romaji") or "").strip() + native = (anilist_entry.get("title_native") or "").strip() + schedule_english = "" + + if not english and romaji: + schedule = fetch_animeschedule_anime_by_title(romaji, schedule_token, schedule_cache) + if int(schedule.get("match_score") or 0) >= SCHEDULE_MATCH_THRESHOLD: + schedule_english = (schedule.get("english_title") or "").strip() + + preferred_title = schedule_english or english or romaji or native + return preferred_title, schedule_english + + +def build_record(anilist_entry: dict, tmdb_entry: dict, release_date: date, locale: str, title: str, schedule_english: str) -> dict: + return { + "title": title, + "title_english_anilist": (anilist_entry.get("title_english") or "").strip(), + "title_anilist": (anilist_entry.get("title_english") or "").strip() or (anilist_entry.get("title_romaji") or "").strip(), + "title_schedule_english": schedule_english, + "title_romaji": (anilist_entry.get("title_romaji") or "").strip(), + "title_native": (anilist_entry.get("title_native") or "").strip(), + "studio": (anilist_entry.get("studio_text") or "").strip(), + "genres": (anilist_entry.get("genres_text") or "").strip() or "n/a", + "tags": (anilist_entry.get("tags_text") or "").strip(), + "release": format_date(release_date, locale), + "releaseDate": release_date.isoformat(), + "anilist_url": (anilist_entry.get("anilist_url") or "").strip() or "n/a", + "format": (anilist_entry.get("format") or "").strip() or "MOVIE", + "cover_image": (anilist_entry.get("cover_image") or "").strip(), + "description": (anilist_entry.get("description") or "").strip(), + "tmdb_title": (tmdb_entry.get("title") or "").strip(), + "tmdb_id": int(tmdb_entry.get("tmdb_id") or 0), + "ids": { + "anilist": int(anilist_entry.get("anilist_id") or 0), + "tmdb": int(tmdb_entry.get("tmdb_id") or 0), }, - "tmdb": tmdb_movie, - "anilist": anilist, - "animeschedule": schedule, } - return record -def sort_and_structure_movies(tmdb_movies: list[dict], locale: str, schedule_token: str) -> list[dict]: - records = [] - anilist_cache: dict[str, dict | None] = {} - schedule_cache: dict[str, dict] = {} +def sort_dedup_records(records: list[dict]) -> list[dict]: + unique = {} + for record in records: + ids = record.get("ids") or {} + key = (int(ids.get("anilist") or 0), int(ids.get("tmdb") or 0), str(record.get("releaseDate") or "")) + if key not in unique: + unique[key] = record - sorted_tmdb = sorted( - tmdb_movies, - key=lambda item: item.get("release_date") or date.min, - reverse=True, - ) - - for tmdb_movie in sorted_tmdb: - anilist = None - for candidate in ((tmdb_movie.get("title") or "").strip(), (tmdb_movie.get("original_title") or "").strip()): - if not candidate: - continue - anilist = fetch_anilist_movie_by_search(candidate, anilist_cache) - if anilist: - break - anilist = anilist or {} - - schedule_candidates = [ - (anilist.get("title_english") or "").strip(), - (anilist.get("title_best") or "").strip(), - (tmdb_movie.get("title") or "").strip(), - (tmdb_movie.get("original_title") or "").strip(), - ] - - best_schedule = {} - best_schedule_score = 0 - seen_schedule_candidates = set() - for schedule_candidate in schedule_candidates: - if not schedule_candidate: - continue - norm_key = schedule_candidate.lower() - if norm_key in seen_schedule_candidates: - continue - seen_schedule_candidates.add(norm_key) - - candidate_result = fetch_animeschedule_anime_by_title(schedule_candidate, schedule_token, schedule_cache) - score = int(candidate_result.get("match_score") or 0) - if score > best_schedule_score: - best_schedule = candidate_result - best_schedule_score = score - - # Prefer results that have explicit English title when scores are tied. - if score == best_schedule_score and score > 0: - if not (best_schedule.get("english_title") or "").strip() and (candidate_result.get("english_title") or "").strip(): - best_schedule = candidate_result - - schedule = best_schedule if best_schedule_score > 0 else {} - - records.append(structure_movie_record(tmdb_movie, anilist, schedule, locale)) - - return records + result = list(unique.values()) + result.sort(key=lambda item: (parse_record_release_date(item), str(item.get("title") or ""))) + return result def get_upcoming_movie_records( @@ -155,9 +221,59 @@ def get_upcoming_movie_records( schedule_token = (anime_schedule_token or settings.animeschedule_api_token).strip() tmdb_token = (tmdb_read_access_token or settings.tmdb_read_access_token).strip() + if not tmdb_token: + return [] + current_day = today or date.today() month_start = date(current_day.year, current_day.month, 1) end_date = add_months(month_start, 2) - timedelta(days=1) - tmdb_movies = fetch_tmdb_anime_movies(current_day, end_date, tmdb_token, language=locale) - return sort_and_structure_movies(tmdb_movies, locale, schedule_token) + anilist_candidates = fetch_anilist_movie_candidates(current_day) + schedule_cache: dict[str, dict] = {} + tmdb_search_cache: dict[str, list[dict]] = {} + tmdb_release_cache: dict[int, dict] = {} + + output_records = [] + + for anilist_entry in anilist_candidates: + tmdb_candidates = collect_tmdb_candidates_for_anilist(anilist_entry, tmdb_token, tmdb_search_cache) + if not tmdb_candidates: + continue + + matched_tmdb = pick_best_tmdb_match(anilist_entry, tmdb_candidates) + if not matched_tmdb: + continue + + tmdb_id = int(matched_tmdb.get("tmdb_id") or 0) + if tmdb_id <= 0: + continue + + if tmdb_id not in tmdb_release_cache: + tmdb_release_cache[tmdb_id] = fetch_tmdb_release_dates(tmdb_id, tmdb_token) + + release_payload = tmdb_release_cache[tmdb_id] + de_theatrical_dates = extract_de_theatrical_dates(release_payload) + release_date = select_release_in_range(de_theatrical_dates, current_day, end_date) + if not release_date: + candidate_release = matched_tmdb.get("release_date") + if isinstance(candidate_release, date) and current_day <= candidate_release <= end_date: + release_date = candidate_release + else: + continue + + preferred_title, schedule_english = resolve_titles(anilist_entry, schedule_token, schedule_cache) + if not preferred_title: + continue + + output_records.append( + build_record( + anilist_entry=anilist_entry, + tmdb_entry=matched_tmdb, + release_date=release_date, + locale=locale, + title=preferred_title, + schedule_english=schedule_english, + ) + ) + + return sort_dedup_records(output_records)