From bbba110268292efb13c0e0ed039fe7cfcda2aa7f Mon Sep 17 00:00:00 2001 From: ProgrammGamer Date: Tue, 21 Apr 2026 21:43:35 +0200 Subject: [PATCH] Add modular TMDb-first movie pipeline and Discord bot --- .env.example | 6 + .gitignore | 204 +++++++++++++++++++++ anime_movies_months.py | 77 ++++++++ app_config.py | 43 +++++ data_sources/__init__.py | 1 + data_sources/anilist_source.py | 176 ++++++++++++++++++ data_sources/animeschedule_source.py | 261 +++++++++++++++++++++++++++ data_sources/tmdb_source.py | 118 ++++++++++++ discord_bot.py | 113 ++++++++++++ embed_builder.py | 110 +++++++++++ movie_pipeline.py | 163 +++++++++++++++++ requirements.txt | 1 + start-bot.bat | 10 + 13 files changed, 1283 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 anime_movies_months.py create mode 100644 app_config.py create mode 100644 data_sources/__init__.py create mode 100644 data_sources/anilist_source.py create mode 100644 data_sources/animeschedule_source.py create mode 100644 data_sources/tmdb_source.py create mode 100644 discord_bot.py create mode 100644 embed_builder.py create mode 100644 movie_pipeline.py create mode 100644 requirements.txt create mode 100644 start-bot.bat diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ec69e3f --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +DISCORD_BOT_TOKEN=your_discord_bot_token +DISCORD_GUILD_ID=your_discord_guild_id +ANIMESCHEDULE_APP_ID=your_animeschedule_app_id +ANIMESCHEDULE_API_TOKEN=your_animeschedule_api_token +TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token +LOCALE=de-DE diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d41752c --- /dev/null +++ b/.gitignore @@ -0,0 +1,204 @@ +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# ---> VisualStudioCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +# ---> VirtualEnv +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ +.Python +[Bb]in +[Ii]nclude +[Ll]ib +[Ll]ib64 +[Ll]ocal +[Ss]cripts +pyvenv.cfg +.venv +pip-selfcheck.json + diff --git a/anime_movies_months.py b/anime_movies_months.py new file mode 100644 index 0000000..36a646b --- /dev/null +++ b/anime_movies_months.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import sys +from datetime import date + +from app_config import get_settings +from movie_pipeline import get_upcoming_movie_records + + +def truncate(text: str, max_len: int) -> str: + if len(text) <= max_len: + return text + if max_len <= 3: + return text[:max_len] + return text[: max_len - 3] + "..." + + +def format_table(headers: list[str], rows: list[list[str]]) -> str: + max_widths = [36, 24, 24, 12, 8, 60] + default_max_width = 40 + widths = [len(h) for h in headers] + for row in rows: + for i, cell in enumerate(row): + max_width = max_widths[i] if i < len(max_widths) else default_max_width + widths[i] = min(max(widths[i], len(cell)), max_width) + + lines = [] + header_line = " | ".join(truncate(h, widths[i]).ljust(widths[i]) for i, h in enumerate(headers)) + sep_line = "-+-".join("-" * w for w in widths) + lines.append(header_line) + lines.append(sep_line) + + for row in rows: + line = " | ".join(truncate(row[i], widths[i]).ljust(widths[i]) for i in range(len(headers))) + lines.append(line) + + return "\n".join(lines) + + +def get_upcoming_movie_rows(locale: str, today: date | None = None) -> list[list[str]]: + records = get_upcoming_movie_records(locale, today=today) + rows = [] + for item in records: + rows.append( + [ + item["title"], + item["studio"], + item["genres"], + item["release"], + item["anilist_url"], + ] + ) + return rows + + +def main() -> int: + settings = get_settings() + locale = settings.locale + + try: + rows = get_upcoming_movie_rows(locale) + except Exception as exc: + print(f"Fehler beim Datenabruf: {exc}", file=sys.stderr) + return 1 + + if not rows: + print("Keine Anime Filme fuer diesen und naechsten Monat gefunden.") + return 0 + + headers = ["Title", "Studio", "Genres", "Release", "AniList"] + print(format_table(headers, rows)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/app_config.py b/app_config.py new file mode 100644 index 0000000..fe0a164 --- /dev/null +++ b/app_config.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +def load_dotenv(path: str = ".env") -> None: + dotenv_path = Path(path) + if not dotenv_path.exists(): + return + + for raw_line in dotenv_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = value + + +@dataclass(frozen=True) +class AppSettings: + discord_bot_token: str + discord_guild_id: str + locale: str + animeschedule_api_token: str + tmdb_read_access_token: str + + +def get_settings() -> AppSettings: + load_dotenv() + return AppSettings( + discord_bot_token=os.environ.get("DISCORD_BOT_TOKEN", "").strip(), + discord_guild_id=os.environ.get("DISCORD_GUILD_ID", "").strip(), + locale=os.environ.get("LOCALE", "de-DE").strip() or "de-DE", + animeschedule_api_token=os.environ.get("ANIMESCHEDULE_API_TOKEN", "").strip(), + tmdb_read_access_token=os.environ.get("TMDB_READ_ACCESS_TOKEN", "").strip(), + ) diff --git a/data_sources/__init__.py b/data_sources/__init__.py new file mode 100644 index 0000000..e5a0d9b --- /dev/null +++ b/data_sources/__init__.py @@ -0,0 +1 @@ +#!/usr/bin/env python3 diff --git a/data_sources/anilist_source.py b/data_sources/anilist_source.py new file mode 100644 index 0000000..69293be --- /dev/null +++ b/data_sources/anilist_source.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from datetime import date + +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 post_graphql(query: str, variables: dict) -> dict: + payload = json.dumps({"query": query, "variables": variables}).encode("utf-8") + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "anime-movies-script/1.0", + } + req = urllib.request.Request(ANILIST_URL, data=payload, headers=headers) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", "replace") + 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: + media = media or {} + title = media.get("title") 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] + + tags_raw = media.get("tags") or [] + tags = [] + for tag in tags_raw: + 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}) + + mapped = { + "id": media.get("id"), + "title_best": pick_best_title(title), + "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, + } + 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] + + query = """ + query ($search: String, $perPage: Int) { + Page(page: 1, perPage: $perPage) { + media(type: ANIME, format: MOVIE, search: $search, sort: [SEARCH_MATCH, POPULARITY_DESC]) { + id + title { english romaji native } + startDate { year month day } + format + episodes + duration + source + description(asHtml: false) + genres + tags { name rank } + coverImage { large } + studios { nodes { name } } + siteUrl + } + } + } + """ + + try: + data = post_graphql(query, {"search": search_text, "perPage": 5}) + except Exception: + cache[key] = None + return None + + if "errors" in data: + cache[key] = None + return None + + candidates = data.get("data", {}).get("Page", {}).get("media", []) + if not candidates: + cache[key] = None + return None + + 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: + 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 + + if best_score <= 0: + cache[key] = None + return None + + mapped = map_anilist_media(best) if best else None + cache[key] = mapped + return mapped diff --git a/data_sources/animeschedule_source.py b/data_sources/animeschedule_source.py new file mode 100644 index 0000000..71113ea --- /dev/null +++ b/data_sources/animeschedule_source.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import urllib.parse +import urllib.request + +ANIMESCHEDULE_API_BASE = "https://animeschedule.net/api/v3" + + +def normalize_title(text: str) -> str: + cleaned = "".join(ch.lower() if ch.isalnum() else " " for ch in (text or "")) + return " ".join(cleaned.split()) + + +def flatten_items(payload: dict | list) -> list[dict]: + if isinstance(payload, list): + return [item for item in payload if isinstance(item, dict)] + if not isinstance(payload, dict): + return [] + + for key in ("anime", "items", "data", "results"): + value = payload.get(key) + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + return [] + + +def extract_names(item: dict) -> list[str]: + names = [] + direct = [item.get("title"), item.get("name"), item.get("romaji"), item.get("english"), item.get("native")] + for candidate in direct: + text = (candidate or "").strip() + if text: + names.append(text) + + nested = item.get("names") or {} + if isinstance(nested, dict): + for candidate in nested.values(): + if isinstance(candidate, list): + for value in candidate: + text = (str(value) if value is not None else "").strip() + if text: + names.append(text) + continue + + text = (str(candidate) if candidate is not None else "").strip() + if text: + names.append(text) + + unique = [] + seen = set() + for name in names: + lowered = name.lower() + if lowered in seen: + continue + seen.add(lowered) + unique.append(name) + return unique + + +def extract_url(item: dict) -> str: + slug = (item.get("slug") or item.get("route") or item.get("id") or "").strip() + if slug: + return f"https://animeschedule.net/anime/{slug}" + + websites = item.get("websites") or item.get("links") or [] + if isinstance(websites, list): + for entry in websites: + if not isinstance(entry, dict): + continue + url = (entry.get("url") or "").strip() + if url: + return url + return "" + + +def extract_english_title(item: dict) -> str: + direct = [item.get("english"), item.get("titleEnglish"), item.get("englishTitle")] + for candidate in direct: + text = (candidate or "").strip() + if text: + return text + + names = item.get("names") or {} + if isinstance(names, dict): + for key, value in names.items(): + key_norm = str(key).strip().lower() + if key_norm in {"en", "eng", "english", "titleenglish"}: + text = (str(value) if value is not None else "").strip() + if text: + return text + return "" + + +def extract_list(item: dict, *keys: str) -> str: + for key in keys: + value = item.get(key) + if not value: + continue + if isinstance(value, str): + text = value.strip() + if text: + return text + if isinstance(value, list): + names = [] + for entry in value: + if isinstance(entry, str): + text = entry.strip() + elif isinstance(entry, dict): + text = (entry.get("name") or entry.get("title") or "").strip() + else: + text = "" + if text: + names.append(text) + if names: + return ", ".join(names) + return "" + + +def extract_format(item: dict) -> str: + for key in ("format", "mediaType", "type"): + value = item.get(key) + text = (str(value) if value is not None else "").strip() + if text: + return text + return "" + + +def extract_description(item: dict) -> str: + for key in ("description", "synopsis", "overview", "summary"): + value = item.get(key) + text = (str(value) if value is not None else "").strip() + if text: + return text + + details = item.get("details") or {} + if isinstance(details, dict): + for key in ("description", "synopsis", "overview", "summary"): + value = details.get(key) + text = (str(value) if value is not None else "").strip() + if text: + return text + return "" + + +def empty_result() -> dict: + return { + "url": "", + "title": "", + "english_title": "", + "format": "", + "genres": "", + "studios": "", + "description": "", + "names": [], + "match_score": 0, + "raw": {}, + } + + +def title_match_score(wanted: str, names: list[str]) -> int: + wanted_norm = normalize_title(wanted) + if not wanted_norm: + return 0 + + best = 0 + wanted_parts = wanted_norm.split(" ") + + for candidate in names: + normalized = normalize_title(candidate) + if not normalized: + continue + + if normalized == wanted_norm: + best = max(best, 4) + continue + + if wanted_norm in normalized or normalized in wanted_norm: + best = max(best, 3) + continue + + normalized_parts = normalized.split(" ") + if normalized_parts[:2] == wanted_parts[:2] and len(normalized_parts) >= 2 and len(wanted_parts) >= 2: + best = max(best, 2) + continue + + if set(normalized_parts) & set(wanted_parts): + best = max(best, 1) + + return best + + +def fetch_animeschedule_anime_by_title(title: str, token: str, cache: dict[str, dict]) -> dict: + key = normalize_title(title) + if key in cache: + return cache[key] + + if not token: + cache[key] = empty_result() + return cache[key] + + params = {"search": title, "take": "10"} + url = ANIMESCHEDULE_API_BASE + "/anime?" + urllib.parse.urlencode(params) + req = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {token}", + "User-Agent": "anime-movies-script/1.0", + }, + ) + + try: + with urllib.request.urlopen(req, timeout=20) as resp: + payload = json.loads(resp.read().decode("utf-8")) + except Exception: + cache[key] = empty_result() + return cache[key] + + candidates = flatten_items(payload) + if not candidates: + cache[key] = empty_result() + return cache[key] + + best = None + best_score = -1 + + for item in candidates: + names = extract_names(item) + if not names: + continue + + score = title_match_score(title, names) + if score <= 0: + continue + + if score > best_score: + best_score = score + best = item + if score == 4: + break + + if not best: + cache[key] = empty_result() + return cache[key] + + names = extract_names(best) + cache[key] = { + "url": extract_url(best), + "title": names[0] if names else "", + "english_title": extract_english_title(best), + "format": extract_format(best), + "genres": extract_list(best, "genres", "genre", "categories"), + "studios": extract_list(best, "studios", "studio"), + "description": extract_description(best), + "names": names, + "match_score": best_score, + "raw": best, + } + return cache[key] diff --git a/data_sources/tmdb_source.py b/data_sources/tmdb_source.py new file mode 100644 index 0000000..c4da5dd --- /dev/null +++ b/data_sources/tmdb_source.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import urllib.parse +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: + text = (value or "").strip() + if not text: + return None + try: + return date.fromisoformat(text[:10]) + except ValueError: + return None + + +def tmdb_get_json(path: str, params: dict, token: str) -> dict: + url = TMDB_API_BASE + path + "?" + urllib.parse.urlencode(params) + req = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {token}", + "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: + return [] + + genre_map = fetch_genre_map(token, language=language) + page = 1 + results = [] + seen_ids = set() + + while True: + params = { + "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 + + for movie in page_results: + movie_id = movie.get("id") + if movie_id in seen_ids: + 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 + + return results diff --git a/discord_bot.py b/discord_bot.py new file mode 100644 index 0000000..aae80ff --- /dev/null +++ b/discord_bot.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +import asyncio + +import discord +from discord import app_commands +from discord.ext import commands + +from app_config import get_settings +from movie_pipeline import get_upcoming_movie_records +from embed_builder import build_movie_embed, make_links_view + + +async def fetch_records(locale: str, tmdb_token: str, schedule_token: str) -> list[dict]: + return await asyncio.to_thread( + get_upcoming_movie_records, + locale, + None, + schedule_token, + tmdb_token, + ) + + +def create_bot() -> commands.Bot: + settings = get_settings() + intents = discord.Intents.default() + bot = commands.Bot(command_prefix="!", intents=intents) + guild_obj = discord.Object(id=int(settings.discord_guild_id)) if settings.discord_guild_id.isdigit() else None + + async def sync_commands() -> None: + if guild_obj: + try: + # Remove stale global commands to avoid duplicate /anime entries. + cleared_global = await bot.tree.sync() + cleared_names = ", ".join(cmd.name for cmd in cleared_global) if cleared_global else "keine" + print(f"Globale Slash Commands aktualisiert: {len(cleared_global)} ({cleared_names})") + + synced = await bot.tree.sync(guild=guild_obj) + names = ", ".join(cmd.name for cmd in synced) if synced else "keine" + print(f"Slash Commands fuer Guild synchronisiert: {len(synced)} ({names})") + return + except Exception as exc: + print(f"Guild-Sync fehlgeschlagen, nutze globalen Sync: {exc}") + + synced = await bot.tree.sync() + names = ", ".join(cmd.name for cmd in synced) if synced else "keine" + print(f"Globale Slash Commands synchronisiert: {len(synced)} ({names})") + print("Hinweis: Globale Slash Commands koennen bis zu 60 Minuten brauchen.") + print("Setze DISCORD_GUILD_ID fuer sofortige Verfuegbarkeit in deinem Server.") + + @bot.event + async def setup_hook() -> None: + await sync_commands() + + @bot.event + async def on_ready() -> None: + print(f"Bot online als {bot.user}") + + async def anime_handler(interaction: discord.Interaction, limit: app_commands.Range[int, 1, 25] = 10) -> None: + locale = settings.locale + safe_limit = int(limit) + + await interaction.response.defer(thinking=True) + try: + records = await fetch_records(locale, settings.tmdb_read_access_token, settings.animeschedule_api_token) + except Exception as exc: + await interaction.followup.send(f"Fehler beim Abruf: {exc}") + return + + if not records: + await interaction.followup.send("Keine Anime Filme fuer diesen und naechsten Monat gefunden.") + return + + selected = records[:safe_limit] + await interaction.followup.send(f"Gefunden: {len(selected)} Anime-Filme") + + for idx, item in enumerate(selected, start=1): + embed = build_movie_embed(item, idx) + view = make_links_view(str(item.get("anilist_url", ""))) + if view is None: + await interaction.followup.send(embed=embed) + else: + await interaction.followup.send(embed=embed, view=view) + + anime_handler = app_commands.describe(limit="Anzahl Ergebnisse (1-25)")(anime_handler) + if guild_obj: + bot.tree.command( + name="anime", + description="Zeigt kommende Anime-Filme", + guild=guild_obj, + )(anime_handler) + else: + bot.tree.command( + name="anime", + description="Zeigt kommende Anime-Filme", + )(anime_handler) + + return bot + + +def main() -> int: + settings = get_settings() + token = settings.discord_bot_token + if not token: + print("Fehler: DISCORD_BOT_TOKEN ist nicht gesetzt.") + return 1 + + bot = create_bot() + bot.run(token) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/embed_builder.py b/embed_builder.py new file mode 100644 index 0000000..4886656 --- /dev/null +++ b/embed_builder.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import html +import re + +import discord + + +def is_missing(value: str | None) -> bool: + text = (value or "").strip().lower() + return text in {"", "n/a", "none", "null"} + + +def fit_embed_value(text: str, max_len: int = 1000) -> str: + value = (text or "").strip() + if is_missing(value): + value = "Unbekannt" + if len(value) <= max_len: + return value + return value[: max_len - 3] + "..." + + +def fit_embed_description(text: str, max_len: int = 3800) -> str: + value = (text or "").strip() + if len(value) <= max_len: + return value + return value[: max_len - 3] + "..." + + +def format_tag_badges(tags_text: str) -> str: + if is_missing(tags_text): + return "Unbekannt" + parts = [part.strip() for part in tags_text.split(",") if part.strip()] + if not parts: + return "Unbekannt" + return " ".join(f"`{part}`" for part in parts) + + +def format_single_badge(text: str) -> str: + if is_missing(text): + return "Unbekannt" + return f"`{fit_embed_value(text, max_len=200)}`" + + +def compact_text(text: str) -> str: + raw = text or "" + raw = re.sub(r"<\s*br\s*/?\s*>", " ", raw, flags=re.IGNORECASE) + raw = re.sub(r"", " ", raw, flags=re.IGNORECASE) + raw = re.sub(r"<[^>]+>", "", raw) + raw = html.unescape(raw) + return " ".join(raw.replace("\n", " ").split()) + + +def add_line(lines: list[str], label: str, value: str) -> None: + if is_missing(value): + return + lines.append(f"**{label}:** {fit_embed_value(value)}") + + +def make_links_view(anilist_url: str) -> discord.ui.View | None: + if is_missing(anilist_url): + return None + + view = discord.ui.View(timeout=None) + view.add_item(discord.ui.Button(label="Zum Anime", style=discord.ButtonStyle.link, url=anilist_url)) + return view + + +def build_movie_embed(item: dict, index: int) -> discord.Embed: + title = fit_embed_value( + str(item.get("title_schedule_english") or item.get("title_english_anilist") or item.get("title", "")), + max_len=200, + ) + anilist_url = str(item.get("anilist_url", "")).strip() + embed = discord.Embed( + title=title, + url=anilist_url if not is_missing(anilist_url) else None, + color=discord.Color.from_rgb(30, 144, 255), + ) + + tags_badges = format_tag_badges(str(item.get("tags", ""))) + genres_badges = format_tag_badges(str(item.get("genres", ""))) + romaji_badge = format_single_badge(str(item.get("title_romaji", ""))) + native_badge = format_single_badge(str(item.get("title_native", ""))) + description_text = compact_text(str(item.get("description", ""))) + + lines: list[str] = [] + add_line(lines, "Anime Typ", str(item.get("format", ""))) + add_line(lines, "Genres", genres_badges) + add_line(lines, "Tags", tags_badges) + add_line(lines, "Release", str(item.get("release", ""))) + add_line(lines, "Studios", str(item.get("studio", ""))) + add_line(lines, "Romaji", romaji_badge) + add_line(lines, "Nativ", native_badge) + add_line(lines, "Beschreibung", description_text) + embed.description = fit_embed_description("\n".join(lines) if lines else "Keine Details verfuegbar") + + cover = (item.get("cover_image") or "").strip() + if cover: + embed.set_image(url=cover) + + embed.set_footer( + text=( + "Daten: AniList, AnimeSchedule, TMDB | " + "This product uses the TMDB API but is not endorsed or certified by TMDB. " + f"| Eintrag #{index}" + ) + ) + return embed diff --git a/movie_pipeline.py b/movie_pipeline.py new file mode 100644 index 0000000..79e432f --- /dev/null +++ b/movie_pipeline.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from datetime import date, timedelta + +from app_config import get_settings +from data_sources.anilist_source import fetch_anilist_movie_by_search +from data_sources.animeschedule_source import fetch_animeschedule_anime_by_title +from data_sources.tmdb_source import fetch_tmdb_anime_movies + + +def add_months(d: date, months: int) -> date: + year = d.year + ((d.month - 1 + months) // 12) + month = ((d.month - 1 + months) % 12) + 1 + return date(year, month, 1) + + +def format_date(d: date, locale: str) -> str: + if locale == "de-DE": + return f"{d.day:02d}.{d.month:02d}.{d.year}" + return d.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 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" + + 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" + ) + + # User requirement: cover images should come from AniList. + cover_image = (anilist.get("cover_image") or "").strip() + + 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" + ) + + 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, + }, + "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] = {} + + 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 + + +def get_upcoming_movie_records( + locale: str, + today: date | None = None, + anime_schedule_token: str | None = None, + tmdb_read_access_token: str | None = None, +) -> list[dict]: + settings = get_settings() + schedule_token = (anime_schedule_token or settings.animeschedule_api_token).strip() + tmdb_token = (tmdb_read_access_token or settings.tmdb_read_access_token).strip() + + 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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d97d58e --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +discord.py>=2.4,<3.0 diff --git a/start-bot.bat b/start-bot.bat new file mode 100644 index 0000000..d61242c --- /dev/null +++ b/start-bot.bat @@ -0,0 +1,10 @@ +@echo off +setlocal + +echo Starte Discord Bot... +if not exist ".env" ( + echo Hinweis: .env wurde nicht gefunden. Der Bot startet nur, wenn die benoetigten Variablen bereits als Umgebungsvariablen gesetzt sind. +) + +k:\dev\Animes\.venv\Scripts\python.exe k:\dev\Animes\discord_bot.py +exit /b %errorlevel% \ No newline at end of file