adding start.sh

This commit is contained in:
Blitz08
2026-04-22 10:04:59 +02:00
parent d6113da7e1
commit 1f610c2f36
15 changed files with 1378 additions and 1354 deletions

View File

@@ -1,6 +1,6 @@
DISCORD_BOT_TOKEN=your_discord_bot_token DISCORD_BOT_TOKEN=your_discord_bot_token
DISCORD_GUILD_ID=your_discord_guild_id DISCORD_GUILD_ID=your_discord_guild_id
ANIMESCHEDULE_APP_ID=your_animeschedule_app_id ANIMESCHEDULE_APP_ID=your_animeschedule_app_id
ANIMESCHEDULE_API_TOKEN=your_animeschedule_api_token ANIMESCHEDULE_API_TOKEN=your_animeschedule_api_token
TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token
LOCALE=de-DE LOCALE=de-DE

412
.gitignore vendored
View File

@@ -1,206 +1,206 @@
# ---> Python # ---> Python
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
# C extensions # C extensions
*.so *.so
# Distribution / packaging # Distribution / packaging
.Python .Python
build/ build/
develop-eggs/ develop-eggs/
dist/ dist/
downloads/ downloads/
eggs/ eggs/
.eggs/ .eggs/
lib/ lib/
lib64/ lib64/
parts/ parts/
sdist/ sdist/
var/ var/
wheels/ wheels/
share/python-wheels/ share/python-wheels/
*.egg-info/ *.egg-info/
.installed.cfg .installed.cfg
*.egg *.egg
MANIFEST MANIFEST
# PyInstaller # PyInstaller
# Usually these files are written by a python script from a template # 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. # before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest *.manifest
*.spec *.spec
# Installer logs # Installer logs
pip-log.txt pip-log.txt
pip-delete-this-directory.txt pip-delete-this-directory.txt
# Unit test / coverage reports # Unit test / coverage reports
htmlcov/ htmlcov/
.tox/ .tox/
.nox/ .nox/
.coverage .coverage
.coverage.* .coverage.*
.cache .cache
nosetests.xml nosetests.xml
coverage.xml coverage.xml
*.cover *.cover
*.py,cover *.py,cover
.hypothesis/ .hypothesis/
.pytest_cache/ .pytest_cache/
cover/ cover/
# Translations # Translations
*.mo *.mo
*.pot *.pot
# Django stuff: # Django stuff:
*.log *.log
local_settings.py local_settings.py
db.sqlite3 db.sqlite3
db.sqlite3-journal db.sqlite3-journal
# Flask stuff: # Flask stuff:
instance/ instance/
.webassets-cache .webassets-cache
# Scrapy stuff: # Scrapy stuff:
.scrapy .scrapy
# Sphinx documentation # Sphinx documentation
docs/_build/ docs/_build/
# PyBuilder # PyBuilder
.pybuilder/ .pybuilder/
target/ target/
# Jupyter Notebook # Jupyter Notebook
.ipynb_checkpoints .ipynb_checkpoints
# IPython # IPython
profile_default/ profile_default/
ipython_config.py ipython_config.py
# pyenv # pyenv
# For a library or package, you might want to ignore these files since the code is # 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: # intended to run in multiple environments; otherwise, check them in:
# .python-version # .python-version
# pipenv # pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # 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 # 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 # having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies. # install all needed dependencies.
#Pipfile.lock #Pipfile.lock
# UV # UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. # 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 # This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries. # commonly ignored for libraries.
#uv.lock #uv.lock
# poetry # poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # 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 # This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries. # commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock #poetry.lock
# pdm # pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock #pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control. # in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml .pdm.toml
.pdm-python .pdm-python
.pdm-build/ .pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/ __pypackages__/
# Celery stuff # Celery stuff
celerybeat-schedule celerybeat-schedule
celerybeat.pid celerybeat.pid
# SageMath parsed files # SageMath parsed files
*.sage.py *.sage.py
# Environments # Environments
.env .env
.venv .venv
env/ env/
venv/ venv/
ENV/ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
# Spyder project settings # Spyder project settings
.spyderproject .spyderproject
.spyproject .spyproject
# Rope project settings # Rope project settings
.ropeproject .ropeproject
# mkdocs documentation # mkdocs documentation
/site /site
# mypy # mypy
.mypy_cache/ .mypy_cache/
.dmypy.json .dmypy.json
dmypy.json dmypy.json
# Pyre type checker # Pyre type checker
.pyre/ .pyre/
# pytype static type analyzer # pytype static type analyzer
.pytype/ .pytype/
# Cython debug symbols # Cython debug symbols
cython_debug/ cython_debug/
# PyCharm # PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can # 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 # 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 # 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. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
# Ruff stuff: # Ruff stuff:
.ruff_cache/ .ruff_cache/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
# ---> VisualStudioCode # ---> VisualStudioCode
.vscode/* .vscode/*
!.vscode/settings.json !.vscode/settings.json
!.vscode/tasks.json !.vscode/tasks.json
!.vscode/launch.json !.vscode/launch.json
!.vscode/extensions.json !.vscode/extensions.json
!.vscode/*.code-snippets !.vscode/*.code-snippets
# Local History for Visual Studio Code # Local History for Visual Studio Code
.history/ .history/
# Built Visual Studio Code Extensions # Built Visual Studio Code Extensions
*.vsix *.vsix
# ---> VirtualEnv # ---> VirtualEnv
# Virtualenv # Virtualenv
# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/
.Python .Python
[Bb]in [Bb]in
[Ii]nclude [Ii]nclude
[Ll]ib [Ll]ib
[Ll]ib64 [Ll]ib64
[Ll]ocal [Ll]ocal
[Ss]cripts [Ss]cripts
pyvenv.cfg pyvenv.cfg
.venv .venv
pip-selfcheck.json pip-selfcheck.json
env/ env/

View File

@@ -1,2 +1,2 @@
# Anime-Movies-Upcomming-Realese-Bot # Anime-Movies-Upcomming-Realese-Bot

View File

@@ -1,77 +1,77 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from __future__ import annotations from __future__ import annotations
import sys import sys
from datetime import date from datetime import date
from app_config import get_settings from app_config import get_settings
from movie_pipeline import get_upcoming_movie_records from movie_pipeline import get_upcoming_movie_records
def truncate(text: str, max_len: int) -> str: def truncate(text: str, max_len: int) -> str:
if len(text) <= max_len: if len(text) <= max_len:
return text return text
if max_len <= 3: if max_len <= 3:
return text[:max_len] return text[:max_len]
return text[: max_len - 3] + "..." return text[: max_len - 3] + "..."
def format_table(headers: list[str], rows: list[list[str]]) -> str: def format_table(headers: list[str], rows: list[list[str]]) -> str:
max_widths = [36, 24, 24, 12, 8, 60] max_widths = [36, 24, 24, 12, 8, 60]
default_max_width = 40 default_max_width = 40
widths = [len(h) for h in headers] widths = [len(h) for h in headers]
for row in rows: for row in rows:
for i, cell in enumerate(row): for i, cell in enumerate(row):
max_width = max_widths[i] if i < len(max_widths) else default_max_width max_width = max_widths[i] if i < len(max_widths) else default_max_width
widths[i] = min(max(widths[i], len(cell)), max_width) widths[i] = min(max(widths[i], len(cell)), max_width)
lines = [] lines = []
header_line = " | ".join(truncate(h, widths[i]).ljust(widths[i]) for i, h in enumerate(headers)) header_line = " | ".join(truncate(h, widths[i]).ljust(widths[i]) for i, h in enumerate(headers))
sep_line = "-+-".join("-" * w for w in widths) sep_line = "-+-".join("-" * w for w in widths)
lines.append(header_line) lines.append(header_line)
lines.append(sep_line) lines.append(sep_line)
for row in rows: for row in rows:
line = " | ".join(truncate(row[i], widths[i]).ljust(widths[i]) for i in range(len(headers))) line = " | ".join(truncate(row[i], widths[i]).ljust(widths[i]) for i in range(len(headers)))
lines.append(line) lines.append(line)
return "\n".join(lines) return "\n".join(lines)
def get_upcoming_movie_rows(locale: str, today: date | None = None) -> list[list[str]]: def get_upcoming_movie_rows(locale: str, today: date | None = None) -> list[list[str]]:
records = get_upcoming_movie_records(locale, today=today) records = get_upcoming_movie_records(locale, today=today)
rows = [] rows = []
for item in records: for item in records:
rows.append( rows.append(
[ [
item["title"], item["title"],
item["studio"], item["studio"],
item["genres"], item["genres"],
item["release"], item["release"],
item["anilist_url"], item["anilist_url"],
] ]
) )
return rows return rows
def main() -> int: def main() -> int:
settings = get_settings() settings = get_settings()
locale = settings.locale locale = settings.locale
try: try:
rows = get_upcoming_movie_rows(locale) rows = get_upcoming_movie_rows(locale)
except Exception as exc: except Exception as exc:
print(f"Fehler beim Datenabruf: {exc}", file=sys.stderr) print(f"Fehler beim Datenabruf: {exc}", file=sys.stderr)
return 1 return 1
if not rows: if not rows:
print("Keine Anime Filme fuer diesen und naechsten Monat gefunden.") print("Keine Anime Filme fuer diesen und naechsten Monat gefunden.")
return 0 return 0
headers = ["Title", "Studio", "Genres", "Release", "AniList"] headers = ["Title", "Studio", "Genres", "Release", "AniList"]
print(format_table(headers, rows)) print(format_table(headers, rows))
return 0 return 0
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())

View File

@@ -1,43 +1,43 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from __future__ import annotations from __future__ import annotations
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
def load_dotenv(path: str = ".env") -> None: def load_dotenv(path: str = ".env") -> None:
dotenv_path = Path(path) dotenv_path = Path(path)
if not dotenv_path.exists(): if not dotenv_path.exists():
return return
for raw_line in dotenv_path.read_text(encoding="utf-8").splitlines(): for raw_line in dotenv_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip() line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line: if not line or line.startswith("#") or "=" not in line:
continue continue
key, value = line.split("=", 1) key, value = line.split("=", 1)
key = key.strip() key = key.strip()
value = value.strip().strip('"').strip("'") value = value.strip().strip('"').strip("'")
if key and key not in os.environ: if key and key not in os.environ:
os.environ[key] = value os.environ[key] = value
@dataclass(frozen=True) @dataclass(frozen=True)
class AppSettings: class AppSettings:
discord_bot_token: str discord_bot_token: str
discord_guild_id: str discord_guild_id: str
locale: str locale: str
animeschedule_api_token: str animeschedule_api_token: str
tmdb_read_access_token: str tmdb_read_access_token: str
def get_settings() -> AppSettings: def get_settings() -> AppSettings:
load_dotenv() load_dotenv()
return AppSettings( return AppSettings(
discord_bot_token=os.environ.get("DISCORD_BOT_TOKEN", "").strip(), discord_bot_token=os.environ.get("DISCORD_BOT_TOKEN", "").strip(),
discord_guild_id=os.environ.get("DISCORD_GUILD_ID", "").strip(), discord_guild_id=os.environ.get("DISCORD_GUILD_ID", "").strip(),
locale=os.environ.get("LOCALE", "de-DE").strip() or "de-DE", locale=os.environ.get("LOCALE", "de-DE").strip() or "de-DE",
animeschedule_api_token=os.environ.get("ANIMESCHEDULE_API_TOKEN", "").strip(), animeschedule_api_token=os.environ.get("ANIMESCHEDULE_API_TOKEN", "").strip(),
tmdb_read_access_token=os.environ.get("TMDB_READ_ACCESS_TOKEN", "").strip(), tmdb_read_access_token=os.environ.get("TMDB_READ_ACCESS_TOKEN", "").strip(),
) )

View File

@@ -1 +1 @@
#!/usr/bin/env python3 #!/usr/bin/env python3

View File

@@ -1,141 +1,141 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from __future__ import annotations from __future__ import annotations
import json import json
import urllib.error import urllib.error
import urllib.request import urllib.request
from datetime import date, timedelta from datetime import date, timedelta
ANILIST_URL = "https://graphql.anilist.co" ANILIST_URL = "https://graphql.anilist.co"
def yyyymmdd(value: date) -> int: def yyyymmdd(value: date) -> int:
return value.year * 10000 + value.month * 100 + value.day return value.year * 10000 + value.month * 100 + value.day
def post_graphql(query: str, variables: dict) -> dict: def post_graphql(query: str, variables: dict) -> dict:
payload = json.dumps({"query": query, "variables": variables}).encode("utf-8") payload = json.dumps({"query": query, "variables": variables}).encode("utf-8")
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Accept": "application/json", "Accept": "application/json",
"User-Agent": "anime-movies-script/1.0", "User-Agent": "anime-movies-script/1.0",
} }
req = urllib.request.Request(ANILIST_URL, data=payload, headers=headers) req = urllib.request.Request(ANILIST_URL, data=payload, headers=headers)
try: try:
with urllib.request.urlopen(req, timeout=30) as resp: with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8")) return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", "replace") body = exc.read().decode("utf-8", "replace")
raise RuntimeError(f"HTTP {exc.code}: {body}") from exc raise RuntimeError(f"HTTP {exc.code}: {body}") from exc
def map_anilist_movie(media: dict | None) -> dict: def map_anilist_movie(media: dict | None) -> dict:
media = media or {} media = media or {}
title = media.get("title") or {} title = media.get("title") or {}
start_date = media.get("startDate") or {} start_date = media.get("startDate") or {}
studios = ((media.get("studios") or {}).get("nodes") or []) studios = ((media.get("studios") or {}).get("nodes") or [])
studio_names = [] studio_names = []
for node in studios: for node in studios:
name = ((node or {}).get("name") or "").strip() name = ((node or {}).get("name") or "").strip()
if name: if name:
studio_names.append(name) studio_names.append(name)
genres = [] genres = []
for entry in media.get("genres") or []: for entry in media.get("genres") or []:
text = str(entry or "").strip() text = str(entry or "").strip()
if text: if text:
genres.append(text) genres.append(text)
tags = [] tags = []
for tag in media.get("tags") or []: for tag in media.get("tags") or []:
if not isinstance(tag, dict): if not isinstance(tag, dict):
continue continue
name = (tag.get("name") or "").strip() name = (tag.get("name") or "").strip()
rank = int(tag.get("rank") or 0) rank = int(tag.get("rank") or 0)
if name and rank >= 70: if name and rank >= 70:
tags.append(name) tags.append(name)
return { return {
"anilist_id": int(media.get("id") or 0), "anilist_id": int(media.get("id") or 0),
"title_english": (title.get("english") or "").strip(), "title_english": (title.get("english") or "").strip(),
"title_romaji": (title.get("romaji") or "").strip(), "title_romaji": (title.get("romaji") or "").strip(),
"title_native": (title.get("native") or "").strip(), "title_native": (title.get("native") or "").strip(),
"cover_image": (((media.get("coverImage") or {}).get("large")) or "").strip(), "cover_image": (((media.get("coverImage") or {}).get("large")) or "").strip(),
"anilist_url": (media.get("siteUrl") or "").strip(), "anilist_url": (media.get("siteUrl") or "").strip(),
"start_year": int(start_date.get("year") or 0), "start_year": int(start_date.get("year") or 0),
"format": (media.get("format") or "").strip() or "MOVIE", "format": (media.get("format") or "").strip() or "MOVIE",
"description": (media.get("description") or "").strip(), "description": (media.get("description") or "").strip(),
"genres_text": ", ".join(genres), "genres_text": ", ".join(genres),
"tags_text": ", ".join(tags), "tags_text": ", ".join(tags),
"studio_text": ", ".join(studio_names), "studio_text": ", ".join(studio_names),
} }
def fetch_anilist_movie_candidates(today: date, years_window: int = 1) -> list[dict]: def fetch_anilist_movie_candidates(today: date, years_window: int = 1) -> list[dict]:
start_date = today - timedelta(days=365 * years_window) start_date = today - timedelta(days=365 * years_window)
end_date = today + timedelta(days=365 * years_window) end_date = today + timedelta(days=365 * years_window)
query = """ query = """
query ($page: Int, $perPage: Int, $start: FuzzyDateInt, $end: FuzzyDateInt) { query ($page: Int, $perPage: Int, $start: FuzzyDateInt, $end: FuzzyDateInt) {
Page(page: $page, perPage: $perPage) { Page(page: $page, perPage: $perPage) {
pageInfo { hasNextPage } pageInfo { hasNextPage }
media( media(
type: ANIME type: ANIME
format: MOVIE format: MOVIE
countryOfOrigin: JP countryOfOrigin: JP
sort: [POPULARITY_DESC, START_DATE_DESC] sort: [POPULARITY_DESC, START_DATE_DESC]
startDate_greater: $start startDate_greater: $start
startDate_lesser: $end startDate_lesser: $end
) { ) {
id id
title { english romaji native } title { english romaji native }
startDate { year } startDate { year }
format format
description(asHtml: false) description(asHtml: false)
genres genres
tags { name rank } tags { name rank }
coverImage { large } coverImage { large }
studios { nodes { name } } studios { nodes { name } }
siteUrl siteUrl
} }
} }
} }
""" """
page = 1 page = 1
results = [] results = []
seen_ids = set() seen_ids = set()
while True: while True:
payload = post_graphql( payload = post_graphql(
query, query,
{ {
"page": page, "page": page,
"perPage": 50, "perPage": 50,
"start": yyyymmdd(start_date) - 1, "start": yyyymmdd(start_date) - 1,
"end": yyyymmdd(end_date) + 1, "end": yyyymmdd(end_date) + 1,
}, },
) )
if "errors" in payload: if "errors" in payload:
raise RuntimeError(payload["errors"]) raise RuntimeError(payload["errors"])
page_data = payload.get("data", {}).get("Page", {}) page_data = payload.get("data", {}).get("Page", {})
for media in page_data.get("media", []): for media in page_data.get("media", []):
mapped = map_anilist_movie(media) mapped = map_anilist_movie(media)
anilist_id = mapped.get("anilist_id") or 0 anilist_id = mapped.get("anilist_id") or 0
if anilist_id <= 0 or anilist_id in seen_ids: if anilist_id <= 0 or anilist_id in seen_ids:
continue continue
seen_ids.add(anilist_id) seen_ids.add(anilist_id)
if not mapped.get("title_english") and not mapped.get("title_romaji") and not mapped.get("title_native"): if not mapped.get("title_english") and not mapped.get("title_romaji") and not mapped.get("title_native"):
continue continue
results.append(mapped) results.append(mapped)
if not page_data.get("pageInfo", {}).get("hasNextPage"): if not page_data.get("pageInfo", {}).get("hasNextPage"):
break break
page += 1 page += 1
return results return results

View File

@@ -1,261 +1,261 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from __future__ import annotations from __future__ import annotations
import json import json
import urllib.parse import urllib.parse
import urllib.request import urllib.request
ANIMESCHEDULE_API_BASE = "https://animeschedule.net/api/v3" ANIMESCHEDULE_API_BASE = "https://animeschedule.net/api/v3"
def normalize_title(text: str) -> str: def normalize_title(text: str) -> str:
cleaned = "".join(ch.lower() if ch.isalnum() else " " for ch in (text or "")) cleaned = "".join(ch.lower() if ch.isalnum() else " " for ch in (text or ""))
return " ".join(cleaned.split()) return " ".join(cleaned.split())
def flatten_items(payload: dict | list) -> list[dict]: def flatten_items(payload: dict | list) -> list[dict]:
if isinstance(payload, list): if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)] return [item for item in payload if isinstance(item, dict)]
if not isinstance(payload, dict): if not isinstance(payload, dict):
return [] return []
for key in ("anime", "items", "data", "results"): for key in ("anime", "items", "data", "results"):
value = payload.get(key) value = payload.get(key)
if isinstance(value, list): if isinstance(value, list):
return [item for item in value if isinstance(item, dict)] return [item for item in value if isinstance(item, dict)]
return [] return []
def extract_names(item: dict) -> list[str]: def extract_names(item: dict) -> list[str]:
names = [] names = []
direct = [item.get("title"), item.get("name"), item.get("romaji"), item.get("english"), item.get("native")] direct = [item.get("title"), item.get("name"), item.get("romaji"), item.get("english"), item.get("native")]
for candidate in direct: for candidate in direct:
text = (candidate or "").strip() text = (candidate or "").strip()
if text: if text:
names.append(text) names.append(text)
nested = item.get("names") or {} nested = item.get("names") or {}
if isinstance(nested, dict): if isinstance(nested, dict):
for candidate in nested.values(): for candidate in nested.values():
if isinstance(candidate, list): if isinstance(candidate, list):
for value in candidate: for value in candidate:
text = (str(value) if value is not None else "").strip() text = (str(value) if value is not None else "").strip()
if text: if text:
names.append(text) names.append(text)
continue continue
text = (str(candidate) if candidate is not None else "").strip() text = (str(candidate) if candidate is not None else "").strip()
if text: if text:
names.append(text) names.append(text)
unique = [] unique = []
seen = set() seen = set()
for name in names: for name in names:
lowered = name.lower() lowered = name.lower()
if lowered in seen: if lowered in seen:
continue continue
seen.add(lowered) seen.add(lowered)
unique.append(name) unique.append(name)
return unique return unique
def extract_url(item: dict) -> str: def extract_url(item: dict) -> str:
slug = (item.get("slug") or item.get("route") or item.get("id") or "").strip() slug = (item.get("slug") or item.get("route") or item.get("id") or "").strip()
if slug: if slug:
return f"https://animeschedule.net/anime/{slug}" return f"https://animeschedule.net/anime/{slug}"
websites = item.get("websites") or item.get("links") or [] websites = item.get("websites") or item.get("links") or []
if isinstance(websites, list): if isinstance(websites, list):
for entry in websites: for entry in websites:
if not isinstance(entry, dict): if not isinstance(entry, dict):
continue continue
url = (entry.get("url") or "").strip() url = (entry.get("url") or "").strip()
if url: if url:
return url return url
return "" return ""
def extract_english_title(item: dict) -> str: def extract_english_title(item: dict) -> str:
direct = [item.get("english"), item.get("titleEnglish"), item.get("englishTitle")] direct = [item.get("english"), item.get("titleEnglish"), item.get("englishTitle")]
for candidate in direct: for candidate in direct:
text = (candidate or "").strip() text = (candidate or "").strip()
if text: if text:
return text return text
names = item.get("names") or {} names = item.get("names") or {}
if isinstance(names, dict): if isinstance(names, dict):
for key, value in names.items(): for key, value in names.items():
key_norm = str(key).strip().lower() key_norm = str(key).strip().lower()
if key_norm in {"en", "eng", "english", "titleenglish"}: if key_norm in {"en", "eng", "english", "titleenglish"}:
text = (str(value) if value is not None else "").strip() text = (str(value) if value is not None else "").strip()
if text: if text:
return text return text
return "" return ""
def extract_list(item: dict, *keys: str) -> str: def extract_list(item: dict, *keys: str) -> str:
for key in keys: for key in keys:
value = item.get(key) value = item.get(key)
if not value: if not value:
continue continue
if isinstance(value, str): if isinstance(value, str):
text = value.strip() text = value.strip()
if text: if text:
return text return text
if isinstance(value, list): if isinstance(value, list):
names = [] names = []
for entry in value: for entry in value:
if isinstance(entry, str): if isinstance(entry, str):
text = entry.strip() text = entry.strip()
elif isinstance(entry, dict): elif isinstance(entry, dict):
text = (entry.get("name") or entry.get("title") or "").strip() text = (entry.get("name") or entry.get("title") or "").strip()
else: else:
text = "" text = ""
if text: if text:
names.append(text) names.append(text)
if names: if names:
return ", ".join(names) return ", ".join(names)
return "" return ""
def extract_format(item: dict) -> str: def extract_format(item: dict) -> str:
for key in ("format", "mediaType", "type"): for key in ("format", "mediaType", "type"):
value = item.get(key) value = item.get(key)
text = (str(value) if value is not None else "").strip() text = (str(value) if value is not None else "").strip()
if text: if text:
return text return text
return "" return ""
def extract_description(item: dict) -> str: def extract_description(item: dict) -> str:
for key in ("description", "synopsis", "overview", "summary"): for key in ("description", "synopsis", "overview", "summary"):
value = item.get(key) value = item.get(key)
text = (str(value) if value is not None else "").strip() text = (str(value) if value is not None else "").strip()
if text: if text:
return text return text
details = item.get("details") or {} details = item.get("details") or {}
if isinstance(details, dict): if isinstance(details, dict):
for key in ("description", "synopsis", "overview", "summary"): for key in ("description", "synopsis", "overview", "summary"):
value = details.get(key) value = details.get(key)
text = (str(value) if value is not None else "").strip() text = (str(value) if value is not None else "").strip()
if text: if text:
return text return text
return "" return ""
def empty_result() -> dict: def empty_result() -> dict:
return { return {
"url": "", "url": "",
"title": "", "title": "",
"english_title": "", "english_title": "",
"format": "", "format": "",
"genres": "", "genres": "",
"studios": "", "studios": "",
"description": "", "description": "",
"names": [], "names": [],
"match_score": 0, "match_score": 0,
"raw": {}, "raw": {},
} }
def title_match_score(wanted: str, names: list[str]) -> int: def title_match_score(wanted: str, names: list[str]) -> int:
wanted_norm = normalize_title(wanted) wanted_norm = normalize_title(wanted)
if not wanted_norm: if not wanted_norm:
return 0 return 0
best = 0 best = 0
wanted_parts = wanted_norm.split(" ") wanted_parts = wanted_norm.split(" ")
for candidate in names: for candidate in names:
normalized = normalize_title(candidate) normalized = normalize_title(candidate)
if not normalized: if not normalized:
continue continue
if normalized == wanted_norm: if normalized == wanted_norm:
best = max(best, 4) best = max(best, 4)
continue continue
if wanted_norm in normalized or normalized in wanted_norm: if wanted_norm in normalized or normalized in wanted_norm:
best = max(best, 3) best = max(best, 3)
continue continue
normalized_parts = normalized.split(" ") normalized_parts = normalized.split(" ")
if normalized_parts[:2] == wanted_parts[:2] and len(normalized_parts) >= 2 and len(wanted_parts) >= 2: if normalized_parts[:2] == wanted_parts[:2] and len(normalized_parts) >= 2 and len(wanted_parts) >= 2:
best = max(best, 2) best = max(best, 2)
continue continue
if set(normalized_parts) & set(wanted_parts): if set(normalized_parts) & set(wanted_parts):
best = max(best, 1) best = max(best, 1)
return best return best
def fetch_animeschedule_anime_by_title(title: str, token: str, cache: dict[str, dict]) -> dict: def fetch_animeschedule_anime_by_title(title: str, token: str, cache: dict[str, dict]) -> dict:
key = normalize_title(title) key = normalize_title(title)
if key in cache: if key in cache:
return cache[key] return cache[key]
if not token: if not token:
cache[key] = empty_result() cache[key] = empty_result()
return cache[key] return cache[key]
params = {"search": title, "take": "10"} params = {"search": title, "take": "10"}
url = ANIMESCHEDULE_API_BASE + "/anime?" + urllib.parse.urlencode(params) url = ANIMESCHEDULE_API_BASE + "/anime?" + urllib.parse.urlencode(params)
req = urllib.request.Request( req = urllib.request.Request(
url, url,
headers={ headers={
"Accept": "application/json", "Accept": "application/json",
"Authorization": f"Bearer {token}", "Authorization": f"Bearer {token}",
"User-Agent": "anime-movies-script/1.0", "User-Agent": "anime-movies-script/1.0",
}, },
) )
try: try:
with urllib.request.urlopen(req, timeout=20) as resp: with urllib.request.urlopen(req, timeout=20) as resp:
payload = json.loads(resp.read().decode("utf-8")) payload = json.loads(resp.read().decode("utf-8"))
except Exception: except Exception:
cache[key] = empty_result() cache[key] = empty_result()
return cache[key] return cache[key]
candidates = flatten_items(payload) candidates = flatten_items(payload)
if not candidates: if not candidates:
cache[key] = empty_result() cache[key] = empty_result()
return cache[key] return cache[key]
best = None best = None
best_score = -1 best_score = -1
for item in candidates: for item in candidates:
names = extract_names(item) names = extract_names(item)
if not names: if not names:
continue continue
score = title_match_score(title, names) score = title_match_score(title, names)
if score <= 0: if score <= 0:
continue continue
if score > best_score: if score > best_score:
best_score = score best_score = score
best = item best = item
if score == 4: if score == 4:
break break
if not best: if not best:
cache[key] = empty_result() cache[key] = empty_result()
return cache[key] return cache[key]
names = extract_names(best) names = extract_names(best)
cache[key] = { cache[key] = {
"url": extract_url(best), "url": extract_url(best),
"title": names[0] if names else "", "title": names[0] if names else "",
"english_title": extract_english_title(best), "english_title": extract_english_title(best),
"format": extract_format(best), "format": extract_format(best),
"genres": extract_list(best, "genres", "genre", "categories"), "genres": extract_list(best, "genres", "genre", "categories"),
"studios": extract_list(best, "studios", "studio"), "studios": extract_list(best, "studios", "studio"),
"description": extract_description(best), "description": extract_description(best),
"names": names, "names": names,
"match_score": best_score, "match_score": best_score,
"raw": best, "raw": best,
} }
return cache[key] return cache[key]

View File

@@ -1,105 +1,105 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from __future__ import annotations from __future__ import annotations
import json import json
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from datetime import date from datetime import date
TMDB_API_BASE = "https://api.themoviedb.org/3" TMDB_API_BASE = "https://api.themoviedb.org/3"
def parse_release_date(value: str) -> date | None: def parse_release_date(value: str) -> date | None:
text = (value or "").strip() text = (value or "").strip()
if not text: if not text:
return None return None
try: try:
return date.fromisoformat(text[:10]) return date.fromisoformat(text[:10])
except ValueError: except ValueError:
return None return None
def tmdb_get_json(path: str, params: dict, token: str) -> dict: def tmdb_get_json(path: str, params: dict, token: str) -> dict:
url = TMDB_API_BASE + path url = TMDB_API_BASE + path
if params: if params:
url += "?" + urllib.parse.urlencode(params) url += "?" + urllib.parse.urlencode(params)
req = urllib.request.Request( req = urllib.request.Request(
url, url,
headers={ headers={
"Accept": "application/json", "Accept": "application/json",
"Authorization": f"Bearer {token}", "Authorization": f"Bearer {token}",
"User-Agent": "anime-movies-script/1.0", "User-Agent": "anime-movies-script/1.0",
}, },
) )
with urllib.request.urlopen(req, timeout=20) as resp: with urllib.request.urlopen(req, timeout=20) as resp:
return json.loads(resp.read().decode("utf-8")) return json.loads(resp.read().decode("utf-8"))
def search_tmdb_movies(title: str, token: str, language: str = "en-US") -> list[dict]: def search_tmdb_movies(title: str, token: str, language: str = "en-US") -> list[dict]:
query = (title or "").strip() query = (title or "").strip()
if not query: if not query:
return [] return []
payload = tmdb_get_json( payload = tmdb_get_json(
"/search/movie", "/search/movie",
{ {
"query": query, "query": query,
"include_adult": "false", "include_adult": "false",
"language": language, "language": language,
"page": "1", "page": "1",
}, },
token, token,
) )
results = [] results = []
for item in payload.get("results", []): for item in payload.get("results", []):
movie_id = item.get("id") movie_id = item.get("id")
if not movie_id: if not movie_id:
continue continue
results.append( results.append(
{ {
"tmdb_id": int(movie_id), "tmdb_id": int(movie_id),
"title": (item.get("title") or "").strip(), "title": (item.get("title") or "").strip(),
"original_title": (item.get("original_title") or "").strip(), "original_title": (item.get("original_title") or "").strip(),
"release_date": parse_release_date(str(item.get("release_date") or "")), "release_date": parse_release_date(str(item.get("release_date") or "")),
} }
) )
return results return results
def fetch_tmdb_release_dates(movie_id: int, token: str) -> dict: def fetch_tmdb_release_dates(movie_id: int, token: str) -> dict:
return tmdb_get_json(f"/movie/{movie_id}/release_dates", {}, token) return tmdb_get_json(f"/movie/{movie_id}/release_dates", {}, token)
def extract_de_theatrical_dates(release_payload: dict) -> list[date]: def extract_de_theatrical_dates(release_payload: dict) -> list[date]:
german_block = None german_block = None
for result in release_payload.get("results", []): for result in release_payload.get("results", []):
if str(result.get("iso_3166_1") or "").upper() == "DE": if str(result.get("iso_3166_1") or "").upper() == "DE":
german_block = result german_block = result
break break
if not german_block: if not german_block:
return [] return []
dates = [] dates = []
for entry in german_block.get("release_dates", []): for entry in german_block.get("release_dates", []):
release_type = int(entry.get("type") or 0) release_type = int(entry.get("type") or 0)
if release_type not in {2, 3}: if release_type not in {2, 3}:
continue continue
release_date = parse_release_date(str(entry.get("release_date") or "")) release_date = parse_release_date(str(entry.get("release_date") or ""))
if release_date: if release_date:
dates.append(release_date) dates.append(release_date)
return sorted(dates) return sorted(dates)
def select_release_in_range(release_dates: list[date], start_date: date, end_date: date) -> date | None: def select_release_in_range(release_dates: list[date], start_date: date, end_date: date) -> date | None:
for release_date in sorted(release_dates): for release_date in sorted(release_dates):
if start_date <= release_date <= end_date: if start_date <= release_date <= end_date:
return release_date return release_date
return None return None

View File

@@ -1,113 +1,113 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import asyncio import asyncio
import discord import discord
from discord import app_commands from discord import app_commands
from discord.ext import commands from discord.ext import commands
from app_config import get_settings from app_config import get_settings
from movie_pipeline import get_upcoming_movie_records from movie_pipeline import get_upcoming_movie_records
from embed_builder import build_movie_embed, make_links_view from embed_builder import build_movie_embed, make_links_view
async def fetch_records(locale: str, tmdb_token: str, schedule_token: str) -> list[dict]: async def fetch_records(locale: str, tmdb_token: str, schedule_token: str) -> list[dict]:
return await asyncio.to_thread( return await asyncio.to_thread(
get_upcoming_movie_records, get_upcoming_movie_records,
locale, locale,
None, None,
schedule_token, schedule_token,
tmdb_token, tmdb_token,
) )
def create_bot() -> commands.Bot: def create_bot() -> commands.Bot:
settings = get_settings() settings = get_settings()
intents = discord.Intents.default() intents = discord.Intents.default()
bot = commands.Bot(command_prefix="!", intents=intents) 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 guild_obj = discord.Object(id=int(settings.discord_guild_id)) if settings.discord_guild_id.isdigit() else None
async def sync_commands() -> None: async def sync_commands() -> None:
if guild_obj: if guild_obj:
try: try:
# Remove stale global commands to avoid duplicate /anime entries. # Remove stale global commands to avoid duplicate /anime entries.
cleared_global = await bot.tree.sync() cleared_global = await bot.tree.sync()
cleared_names = ", ".join(cmd.name for cmd in cleared_global) if cleared_global else "keine" 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})") print(f"Globale Slash Commands aktualisiert: {len(cleared_global)} ({cleared_names})")
synced = await bot.tree.sync(guild=guild_obj) synced = await bot.tree.sync(guild=guild_obj)
names = ", ".join(cmd.name for cmd in synced) if synced else "keine" names = ", ".join(cmd.name for cmd in synced) if synced else "keine"
print(f"Slash Commands fuer Guild synchronisiert: {len(synced)} ({names})") print(f"Slash Commands fuer Guild synchronisiert: {len(synced)} ({names})")
return return
except Exception as exc: except Exception as exc:
print(f"Guild-Sync fehlgeschlagen, nutze globalen Sync: {exc}") print(f"Guild-Sync fehlgeschlagen, nutze globalen Sync: {exc}")
synced = await bot.tree.sync() synced = await bot.tree.sync()
names = ", ".join(cmd.name for cmd in synced) if synced else "keine" names = ", ".join(cmd.name for cmd in synced) if synced else "keine"
print(f"Globale Slash Commands synchronisiert: {len(synced)} ({names})") print(f"Globale Slash Commands synchronisiert: {len(synced)} ({names})")
print("Hinweis: Globale Slash Commands koennen bis zu 60 Minuten brauchen.") print("Hinweis: Globale Slash Commands koennen bis zu 60 Minuten brauchen.")
print("Setze DISCORD_GUILD_ID fuer sofortige Verfuegbarkeit in deinem Server.") print("Setze DISCORD_GUILD_ID fuer sofortige Verfuegbarkeit in deinem Server.")
@bot.event @bot.event
async def setup_hook() -> None: async def setup_hook() -> None:
await sync_commands() await sync_commands()
@bot.event @bot.event
async def on_ready() -> None: async def on_ready() -> None:
print(f"Bot online als {bot.user}") print(f"Bot online als {bot.user}")
async def anime_handler(interaction: discord.Interaction, limit: app_commands.Range[int, 1, 25] = 10) -> None: async def anime_handler(interaction: discord.Interaction, limit: app_commands.Range[int, 1, 25] = 10) -> None:
locale = settings.locale locale = settings.locale
safe_limit = int(limit) safe_limit = int(limit)
await interaction.response.defer(thinking=True) await interaction.response.defer(thinking=True)
try: try:
records = await fetch_records(locale, settings.tmdb_read_access_token, settings.animeschedule_api_token) records = await fetch_records(locale, settings.tmdb_read_access_token, settings.animeschedule_api_token)
except Exception as exc: except Exception as exc:
await interaction.followup.send(f"Fehler beim Abruf: {exc}") await interaction.followup.send(f"Fehler beim Abruf: {exc}")
return return
if not records: if not records:
await interaction.followup.send("Keine Anime Filme fuer diesen und naechsten Monat gefunden.") await interaction.followup.send("Keine Anime Filme fuer diesen und naechsten Monat gefunden.")
return return
selected = records[:safe_limit] selected = records[:safe_limit]
await interaction.followup.send(f"Gefunden: {len(selected)} Anime-Filme") await interaction.followup.send(f"Gefunden: {len(selected)} Anime-Filme")
for idx, item in enumerate(selected, start=1): for idx, item in enumerate(selected, start=1):
embed = build_movie_embed(item, idx) embed = build_movie_embed(item, idx)
view = make_links_view(str(item.get("anilist_url", ""))) view = make_links_view(str(item.get("anilist_url", "")))
if view is None: if view is None:
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
else: else:
await interaction.followup.send(embed=embed, view=view) await interaction.followup.send(embed=embed, view=view)
anime_handler = app_commands.describe(limit="Anzahl Ergebnisse (1-25)")(anime_handler) anime_handler = app_commands.describe(limit="Anzahl Ergebnisse (1-25)")(anime_handler)
if guild_obj: if guild_obj:
bot.tree.command( bot.tree.command(
name="anime", name="anime",
description="Zeigt kommende Anime-Filme", description="Zeigt kommende Anime-Filme",
guild=guild_obj, guild=guild_obj,
)(anime_handler) )(anime_handler)
else: else:
bot.tree.command( bot.tree.command(
name="anime", name="anime",
description="Zeigt kommende Anime-Filme", description="Zeigt kommende Anime-Filme",
)(anime_handler) )(anime_handler)
return bot return bot
def main() -> int: def main() -> int:
settings = get_settings() settings = get_settings()
token = settings.discord_bot_token token = settings.discord_bot_token
if not token: if not token:
print("Fehler: DISCORD_BOT_TOKEN ist nicht gesetzt.") print("Fehler: DISCORD_BOT_TOKEN ist nicht gesetzt.")
return 1 return 1
bot = create_bot() bot = create_bot()
bot.run(token) bot.run(token)
return 0 return 0
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())

View File

@@ -1,110 +1,110 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from __future__ import annotations from __future__ import annotations
import html import html
import re import re
import discord import discord
def is_missing(value: str | None) -> bool: def is_missing(value: str | None) -> bool:
text = (value or "").strip().lower() text = (value or "").strip().lower()
return text in {"", "n/a", "none", "null"} return text in {"", "n/a", "none", "null"}
def fit_embed_value(text: str, max_len: int = 1000) -> str: def fit_embed_value(text: str, max_len: int = 1000) -> str:
value = (text or "").strip() value = (text or "").strip()
if is_missing(value): if is_missing(value):
value = "Unbekannt" value = "Unbekannt"
if len(value) <= max_len: if len(value) <= max_len:
return value return value
return value[: max_len - 3] + "..." return value[: max_len - 3] + "..."
def fit_embed_description(text: str, max_len: int = 3800) -> str: def fit_embed_description(text: str, max_len: int = 3800) -> str:
value = (text or "").strip() value = (text or "").strip()
if len(value) <= max_len: if len(value) <= max_len:
return value return value
return value[: max_len - 3] + "..." return value[: max_len - 3] + "..."
def format_tag_badges(tags_text: str) -> str: def format_tag_badges(tags_text: str) -> str:
if is_missing(tags_text): if is_missing(tags_text):
return "Unbekannt" return "Unbekannt"
parts = [part.strip() for part in tags_text.split(",") if part.strip()] parts = [part.strip() for part in tags_text.split(",") if part.strip()]
if not parts: if not parts:
return "Unbekannt" return "Unbekannt"
return " ".join(f"`{part}`" for part in parts) return " ".join(f"`{part}`" for part in parts)
def format_single_badge(text: str) -> str: def format_single_badge(text: str) -> str:
if is_missing(text): if is_missing(text):
return "Unbekannt" return "Unbekannt"
return f"`{fit_embed_value(text, max_len=200)}`" return f"`{fit_embed_value(text, max_len=200)}`"
def compact_text(text: str) -> str: def compact_text(text: str) -> str:
raw = text or "" raw = text or ""
raw = re.sub(r"<\s*br\s*/?\s*>", " ", raw, flags=re.IGNORECASE) raw = re.sub(r"<\s*br\s*/?\s*>", " ", raw, flags=re.IGNORECASE)
raw = re.sub(r"</\s*p\s*>", " ", raw, flags=re.IGNORECASE) raw = re.sub(r"</\s*p\s*>", " ", raw, flags=re.IGNORECASE)
raw = re.sub(r"<[^>]+>", "", raw) raw = re.sub(r"<[^>]+>", "", raw)
raw = html.unescape(raw) raw = html.unescape(raw)
return " ".join(raw.replace("\n", " ").split()) return " ".join(raw.replace("\n", " ").split())
def add_line(lines: list[str], label: str, value: str) -> None: def add_line(lines: list[str], label: str, value: str) -> None:
if is_missing(value): if is_missing(value):
return return
lines.append(f"**{label}:** {fit_embed_value(value)}") lines.append(f"**{label}:** {fit_embed_value(value)}")
def make_links_view(anilist_url: str) -> discord.ui.View | None: def make_links_view(anilist_url: str) -> discord.ui.View | None:
if is_missing(anilist_url): if is_missing(anilist_url):
return None return None
view = discord.ui.View(timeout=None) view = discord.ui.View(timeout=None)
view.add_item(discord.ui.Button(label="Zum Anime", style=discord.ButtonStyle.link, url=anilist_url)) view.add_item(discord.ui.Button(label="Zum Anime", style=discord.ButtonStyle.link, url=anilist_url))
return view return view
def build_movie_embed(item: dict, index: int) -> discord.Embed: def build_movie_embed(item: dict, index: int) -> discord.Embed:
title = fit_embed_value( title = fit_embed_value(
str(item.get("title_schedule_english") or item.get("title_english_anilist") or item.get("title", "")), str(item.get("title_schedule_english") or item.get("title_english_anilist") or item.get("title", "")),
max_len=200, max_len=200,
) )
anilist_url = str(item.get("anilist_url", "")).strip() anilist_url = str(item.get("anilist_url", "")).strip()
embed = discord.Embed( embed = discord.Embed(
title=title, title=title,
url=anilist_url if not is_missing(anilist_url) else None, url=anilist_url if not is_missing(anilist_url) else None,
color=discord.Color.from_rgb(30, 144, 255), color=discord.Color.from_rgb(30, 144, 255),
) )
tags_badges = format_tag_badges(str(item.get("tags", ""))) tags_badges = format_tag_badges(str(item.get("tags", "")))
genres_badges = format_tag_badges(str(item.get("genres", ""))) genres_badges = format_tag_badges(str(item.get("genres", "")))
romaji_badge = format_single_badge(str(item.get("title_romaji", ""))) romaji_badge = format_single_badge(str(item.get("title_romaji", "")))
native_badge = format_single_badge(str(item.get("title_native", ""))) native_badge = format_single_badge(str(item.get("title_native", "")))
description_text = compact_text(str(item.get("description", ""))) description_text = compact_text(str(item.get("description", "")))
lines: list[str] = [] lines: list[str] = []
add_line(lines, "Anime Typ", str(item.get("format", ""))) add_line(lines, "Anime Typ", str(item.get("format", "")))
add_line(lines, "Genres", genres_badges) add_line(lines, "Genres", genres_badges)
add_line(lines, "Tags", tags_badges) add_line(lines, "Tags", tags_badges)
add_line(lines, "Release", str(item.get("release", ""))) add_line(lines, "Release", str(item.get("release", "")))
add_line(lines, "Studios", str(item.get("studio", ""))) add_line(lines, "Studios", str(item.get("studio", "")))
add_line(lines, "Romaji", romaji_badge) add_line(lines, "Romaji", romaji_badge)
add_line(lines, "Nativ", native_badge) add_line(lines, "Nativ", native_badge)
add_line(lines, "Beschreibung", description_text) add_line(lines, "Beschreibung", description_text)
embed.description = fit_embed_description("\n".join(lines) if lines else "Keine Details verfuegbar") embed.description = fit_embed_description("\n".join(lines) if lines else "Keine Details verfuegbar")
cover = (item.get("cover_image") or "").strip() cover = (item.get("cover_image") or "").strip()
if cover: if cover:
embed.set_image(url=cover) embed.set_image(url=cover)
embed.set_footer( embed.set_footer(
text=( text=(
"Daten: AniList, AnimeSchedule, TMDB | " "Daten: AniList, AnimeSchedule, TMDB | "
"This product uses the TMDB API but is not endorsed or certified by TMDB. " "This product uses the TMDB API but is not endorsed or certified by TMDB. "
f"| Eintrag #{index}" f"| Eintrag #{index}"
) )
) )
return embed return embed

View File

@@ -1,279 +1,279 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from __future__ import annotations from __future__ import annotations
from datetime import date, timedelta from datetime import date, timedelta
from difflib import SequenceMatcher from difflib import SequenceMatcher
from app_config import get_settings from app_config import get_settings
from data_sources.anilist_source import fetch_anilist_movie_candidates from data_sources.anilist_source import fetch_anilist_movie_candidates
from data_sources.animeschedule_source import fetch_animeschedule_anime_by_title from data_sources.animeschedule_source import fetch_animeschedule_anime_by_title
from data_sources.tmdb_source import ( from data_sources.tmdb_source import (
extract_de_theatrical_dates, extract_de_theatrical_dates,
fetch_tmdb_release_dates, fetch_tmdb_release_dates,
search_tmdb_movies, search_tmdb_movies,
select_release_in_range, select_release_in_range,
) )
FUZZY_MATCH_THRESHOLD = 0.65 FUZZY_MATCH_THRESHOLD = 0.65
SCHEDULE_MATCH_THRESHOLD = 2 SCHEDULE_MATCH_THRESHOLD = 2
def add_months(value: date, months: int) -> date: def add_months(value: date, months: int) -> date:
year = value.year + ((value.month - 1 + months) // 12) year = value.year + ((value.month - 1 + months) // 12)
month = ((value.month - 1 + months) % 12) + 1 month = ((value.month - 1 + months) % 12) + 1
return date(year, month, 1) return date(year, month, 1)
def normalize_title(text: str) -> str: def normalize_title(text: str) -> str:
cleaned = "".join(ch.lower() if ch.isalnum() else " " for ch in (text or "")) cleaned = "".join(ch.lower() if ch.isalnum() else " " for ch in (text or ""))
return " ".join(cleaned.split()) return " ".join(cleaned.split())
def fuzzy_ratio(left: str, right: str) -> float: def fuzzy_ratio(left: str, right: str) -> float:
a = normalize_title(left) a = normalize_title(left)
b = normalize_title(right) b = normalize_title(right)
if not a or not b: if not a or not b:
return 0.0 return 0.0
return SequenceMatcher(None, a, b).ratio() return SequenceMatcher(None, a, b).ratio()
def format_date(value: date, locale: str) -> str: def format_date(value: date, locale: str) -> str:
if locale == "de-DE": if locale == "de-DE":
return f"{value.day:02d}.{value.month:02d}.{value.year}" return f"{value.day:02d}.{value.month:02d}.{value.year}"
return value.isoformat() return value.isoformat()
def parse_record_release_date(record: dict) -> date: def parse_record_release_date(record: dict) -> date:
text = str(record.get("releaseDate") or "").strip() text = str(record.get("releaseDate") or "").strip()
try: try:
return date.fromisoformat(text) return date.fromisoformat(text)
except ValueError: except ValueError:
return date.max return date.max
def is_year_match(anilist_year: int, tmdb_year: int) -> bool: def is_year_match(anilist_year: int, tmdb_year: int) -> bool:
if anilist_year <= 0 or tmdb_year <= 0: if anilist_year <= 0 or tmdb_year <= 0:
return False return False
return abs(anilist_year - tmdb_year) <= 1 return abs(anilist_year - tmdb_year) <= 1
def title_score(anilist_english: str, anilist_romaji: str, anilist_native: str, tmdb_title: str, tmdb_original: str) -> tuple[int, int, int, float]: 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) english_norm = normalize_title(anilist_english)
romaji_norm = normalize_title(anilist_romaji) romaji_norm = normalize_title(anilist_romaji)
native_norm = normalize_title(anilist_native) native_norm = normalize_title(anilist_native)
tmdb_options = [normalize_title(tmdb_title), normalize_title(tmdb_original)] tmdb_options = [normalize_title(tmdb_title), normalize_title(tmdb_original)]
exact_english = 1 if english_norm and english_norm in tmdb_options else 0 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_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 exact_native = 1 if native_norm and native_norm in tmdb_options else 0
best_ratio = 0.0 best_ratio = 0.0
for option in (tmdb_title, tmdb_original): for option in (tmdb_title, tmdb_original):
best_ratio = max( best_ratio = max(
best_ratio, best_ratio,
fuzzy_ratio(anilist_english, option), fuzzy_ratio(anilist_english, option),
fuzzy_ratio(anilist_romaji, option), fuzzy_ratio(anilist_romaji, option),
fuzzy_ratio(anilist_native, option), fuzzy_ratio(anilist_native, option),
) )
return exact_english, exact_romaji, exact_native, best_ratio 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]: 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() english = (anilist_entry.get("title_english") or "").strip()
romaji = (anilist_entry.get("title_romaji") or "").strip() romaji = (anilist_entry.get("title_romaji") or "").strip()
native = (anilist_entry.get("title_native") or "").strip() native = (anilist_entry.get("title_native") or "").strip()
queries = [] queries = []
if english: if english:
queries.append(english) queries.append(english)
if romaji and normalize_title(romaji) != normalize_title(english): if romaji and normalize_title(romaji) != normalize_title(english):
queries.append(romaji) queries.append(romaji)
if native and normalize_title(native) not in {normalize_title(english), normalize_title(romaji)}: if native and normalize_title(native) not in {normalize_title(english), normalize_title(romaji)}:
queries.append(native) queries.append(native)
if not queries: if not queries:
return [] return []
candidates_by_id = {} candidates_by_id = {}
languages = ["de-DE", "en-US", "ja-JP"] languages = ["de-DE", "en-US", "ja-JP"]
for query in queries: for query in queries:
normalized_query = normalize_title(query) normalized_query = normalize_title(query)
for language in languages: for language in languages:
cache_key = f"{language}:{normalized_query}" cache_key = f"{language}:{normalized_query}"
if cache_key not in search_cache: if cache_key not in search_cache:
search_cache[cache_key] = search_tmdb_movies(query, tmdb_token, language=language) search_cache[cache_key] = search_tmdb_movies(query, tmdb_token, language=language)
for item in search_cache[cache_key]: for item in search_cache[cache_key]:
tmdb_id = int(item.get("tmdb_id") or 0) tmdb_id = int(item.get("tmdb_id") or 0)
if tmdb_id <= 0: if tmdb_id <= 0:
continue continue
existing = candidates_by_id.get(tmdb_id) existing = candidates_by_id.get(tmdb_id)
if not existing: if not existing:
candidates_by_id[tmdb_id] = item candidates_by_id[tmdb_id] = item
continue continue
# Prefer candidate carrying a release date if duplicate appears from different language queries. # Prefer candidate carrying a release date if duplicate appears from different language queries.
if not existing.get("release_date") and item.get("release_date"): if not existing.get("release_date") and item.get("release_date"):
candidates_by_id[tmdb_id] = item candidates_by_id[tmdb_id] = item
return list(candidates_by_id.values()) return list(candidates_by_id.values())
def pick_best_tmdb_match(anilist_entry: dict, tmdb_candidates: list[dict]) -> dict | None: def pick_best_tmdb_match(anilist_entry: dict, tmdb_candidates: list[dict]) -> dict | None:
english = (anilist_entry.get("title_english") or "").strip() english = (anilist_entry.get("title_english") or "").strip()
romaji = (anilist_entry.get("title_romaji") or "").strip() romaji = (anilist_entry.get("title_romaji") or "").strip()
native = (anilist_entry.get("title_native") or "").strip() native = (anilist_entry.get("title_native") or "").strip()
anilist_year = int(anilist_entry.get("start_year") or 0) anilist_year = int(anilist_entry.get("start_year") or 0)
best = None best = None
best_tuple = (-1, -1, -1, 0.0) best_tuple = (-1, -1, -1, 0.0)
for candidate in tmdb_candidates: for candidate in tmdb_candidates:
exact_english, exact_romaji, exact_native, ratio = title_score( exact_english, exact_romaji, exact_native, ratio = title_score(
english, english,
romaji, romaji,
native, native,
str(candidate.get("title") or ""), str(candidate.get("title") or ""),
str(candidate.get("original_title") or ""), str(candidate.get("original_title") or ""),
) )
tmdb_release = candidate.get("release_date") tmdb_release = candidate.get("release_date")
tmdb_year = tmdb_release.year if tmdb_release else 0 tmdb_year = tmdb_release.year if tmdb_release else 0
has_exact_match = exact_english == 1 or exact_romaji == 1 or exact_native == 1 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): if not has_exact_match and not is_year_match(anilist_year, tmdb_year):
continue continue
if ratio < FUZZY_MATCH_THRESHOLD and exact_english == 0 and exact_romaji == 0 and exact_native == 0: if ratio < FUZZY_MATCH_THRESHOLD and exact_english == 0 and exact_romaji == 0 and exact_native == 0:
continue continue
score_tuple = (exact_english, exact_romaji, exact_native, ratio) score_tuple = (exact_english, exact_romaji, exact_native, ratio)
if score_tuple > best_tuple: if score_tuple > best_tuple:
best_tuple = score_tuple best_tuple = score_tuple
best = candidate best = candidate
return best return best
def resolve_titles(anilist_entry: dict, schedule_token: str, schedule_cache: dict[str, dict]) -> tuple[str, str]: 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() english = (anilist_entry.get("title_english") or "").strip()
romaji = (anilist_entry.get("title_romaji") or "").strip() romaji = (anilist_entry.get("title_romaji") or "").strip()
native = (anilist_entry.get("title_native") or "").strip() native = (anilist_entry.get("title_native") or "").strip()
schedule_english = "" schedule_english = ""
if not english and romaji: if not english and romaji:
schedule = fetch_animeschedule_anime_by_title(romaji, schedule_token, schedule_cache) schedule = fetch_animeschedule_anime_by_title(romaji, schedule_token, schedule_cache)
if int(schedule.get("match_score") or 0) >= SCHEDULE_MATCH_THRESHOLD: if int(schedule.get("match_score") or 0) >= SCHEDULE_MATCH_THRESHOLD:
schedule_english = (schedule.get("english_title") or "").strip() schedule_english = (schedule.get("english_title") or "").strip()
preferred_title = schedule_english or english or romaji or native preferred_title = schedule_english or english or romaji or native
return preferred_title, schedule_english 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: def build_record(anilist_entry: dict, tmdb_entry: dict, release_date: date, locale: str, title: str, schedule_english: str) -> dict:
return { return {
"title": title, "title": title,
"title_english_anilist": (anilist_entry.get("title_english") or "").strip(), "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_anilist": (anilist_entry.get("title_english") or "").strip() or (anilist_entry.get("title_romaji") or "").strip(),
"title_schedule_english": schedule_english, "title_schedule_english": schedule_english,
"title_romaji": (anilist_entry.get("title_romaji") or "").strip(), "title_romaji": (anilist_entry.get("title_romaji") or "").strip(),
"title_native": (anilist_entry.get("title_native") or "").strip(), "title_native": (anilist_entry.get("title_native") or "").strip(),
"studio": (anilist_entry.get("studio_text") or "").strip(), "studio": (anilist_entry.get("studio_text") or "").strip(),
"genres": (anilist_entry.get("genres_text") or "").strip() or "n/a", "genres": (anilist_entry.get("genres_text") or "").strip() or "n/a",
"tags": (anilist_entry.get("tags_text") or "").strip(), "tags": (anilist_entry.get("tags_text") or "").strip(),
"release": format_date(release_date, locale), "release": format_date(release_date, locale),
"releaseDate": release_date.isoformat(), "releaseDate": release_date.isoformat(),
"anilist_url": (anilist_entry.get("anilist_url") or "").strip() or "n/a", "anilist_url": (anilist_entry.get("anilist_url") or "").strip() or "n/a",
"format": (anilist_entry.get("format") or "").strip() or "MOVIE", "format": (anilist_entry.get("format") or "").strip() or "MOVIE",
"cover_image": (anilist_entry.get("cover_image") or "").strip(), "cover_image": (anilist_entry.get("cover_image") or "").strip(),
"description": (anilist_entry.get("description") or "").strip(), "description": (anilist_entry.get("description") or "").strip(),
"tmdb_title": (tmdb_entry.get("title") or "").strip(), "tmdb_title": (tmdb_entry.get("title") or "").strip(),
"tmdb_id": int(tmdb_entry.get("tmdb_id") or 0), "tmdb_id": int(tmdb_entry.get("tmdb_id") or 0),
"ids": { "ids": {
"anilist": int(anilist_entry.get("anilist_id") or 0), "anilist": int(anilist_entry.get("anilist_id") or 0),
"tmdb": int(tmdb_entry.get("tmdb_id") or 0), "tmdb": int(tmdb_entry.get("tmdb_id") or 0),
}, },
} }
def sort_dedup_records(records: list[dict]) -> list[dict]: def sort_dedup_records(records: list[dict]) -> list[dict]:
unique = {} unique = {}
for record in records: for record in records:
ids = record.get("ids") or {} ids = record.get("ids") or {}
key = (int(ids.get("anilist") or 0), int(ids.get("tmdb") or 0), str(record.get("releaseDate") or "")) key = (int(ids.get("anilist") or 0), int(ids.get("tmdb") or 0), str(record.get("releaseDate") or ""))
if key not in unique: if key not in unique:
unique[key] = record unique[key] = record
result = list(unique.values()) result = list(unique.values())
result.sort(key=lambda item: (parse_record_release_date(item), str(item.get("title") or ""))) result.sort(key=lambda item: (parse_record_release_date(item), str(item.get("title") or "")))
return result return result
def get_upcoming_movie_records( def get_upcoming_movie_records(
locale: str, locale: str,
today: date | None = None, today: date | None = None,
anime_schedule_token: str | None = None, anime_schedule_token: str | None = None,
tmdb_read_access_token: str | None = None, tmdb_read_access_token: str | None = None,
) -> list[dict]: ) -> list[dict]:
settings = get_settings() settings = get_settings()
schedule_token = (anime_schedule_token or settings.animeschedule_api_token).strip() schedule_token = (anime_schedule_token or settings.animeschedule_api_token).strip()
tmdb_token = (tmdb_read_access_token or settings.tmdb_read_access_token).strip() tmdb_token = (tmdb_read_access_token or settings.tmdb_read_access_token).strip()
if not tmdb_token: if not tmdb_token:
return [] return []
current_day = today or date.today() current_day = today or date.today()
month_start = date(current_day.year, current_day.month, 1) month_start = date(current_day.year, current_day.month, 1)
end_date = add_months(month_start, 2) - timedelta(days=1) end_date = add_months(month_start, 2) - timedelta(days=1)
anilist_candidates = fetch_anilist_movie_candidates(current_day) anilist_candidates = fetch_anilist_movie_candidates(current_day)
schedule_cache: dict[str, dict] = {} schedule_cache: dict[str, dict] = {}
tmdb_search_cache: dict[str, list[dict]] = {} tmdb_search_cache: dict[str, list[dict]] = {}
tmdb_release_cache: dict[int, dict] = {} tmdb_release_cache: dict[int, dict] = {}
output_records = [] output_records = []
for anilist_entry in anilist_candidates: for anilist_entry in anilist_candidates:
tmdb_candidates = collect_tmdb_candidates_for_anilist(anilist_entry, tmdb_token, tmdb_search_cache) tmdb_candidates = collect_tmdb_candidates_for_anilist(anilist_entry, tmdb_token, tmdb_search_cache)
if not tmdb_candidates: if not tmdb_candidates:
continue continue
matched_tmdb = pick_best_tmdb_match(anilist_entry, tmdb_candidates) matched_tmdb = pick_best_tmdb_match(anilist_entry, tmdb_candidates)
if not matched_tmdb: if not matched_tmdb:
continue continue
tmdb_id = int(matched_tmdb.get("tmdb_id") or 0) tmdb_id = int(matched_tmdb.get("tmdb_id") or 0)
if tmdb_id <= 0: if tmdb_id <= 0:
continue continue
if tmdb_id not in tmdb_release_cache: if tmdb_id not in tmdb_release_cache:
tmdb_release_cache[tmdb_id] = fetch_tmdb_release_dates(tmdb_id, tmdb_token) tmdb_release_cache[tmdb_id] = fetch_tmdb_release_dates(tmdb_id, tmdb_token)
release_payload = tmdb_release_cache[tmdb_id] release_payload = tmdb_release_cache[tmdb_id]
de_theatrical_dates = extract_de_theatrical_dates(release_payload) de_theatrical_dates = extract_de_theatrical_dates(release_payload)
release_date = select_release_in_range(de_theatrical_dates, current_day, end_date) release_date = select_release_in_range(de_theatrical_dates, current_day, end_date)
if not release_date: if not release_date:
candidate_release = matched_tmdb.get("release_date") candidate_release = matched_tmdb.get("release_date")
if isinstance(candidate_release, date) and current_day <= candidate_release <= end_date: if isinstance(candidate_release, date) and current_day <= candidate_release <= end_date:
release_date = candidate_release release_date = candidate_release
else: else:
continue continue
preferred_title, schedule_english = resolve_titles(anilist_entry, schedule_token, schedule_cache) preferred_title, schedule_english = resolve_titles(anilist_entry, schedule_token, schedule_cache)
if not preferred_title: if not preferred_title:
continue continue
output_records.append( output_records.append(
build_record( build_record(
anilist_entry=anilist_entry, anilist_entry=anilist_entry,
tmdb_entry=matched_tmdb, tmdb_entry=matched_tmdb,
release_date=release_date, release_date=release_date,
locale=locale, locale=locale,
title=preferred_title, title=preferred_title,
schedule_english=schedule_english, schedule_english=schedule_english,
) )
) )
return sort_dedup_records(output_records) return sort_dedup_records(output_records)

View File

@@ -1 +1 @@
discord.py>=2.4,<3.0 discord.py>=2.4,<3.0

View File

@@ -1,10 +1,10 @@
@echo off @echo off
setlocal setlocal
echo Starte Discord Bot... echo Starte Discord Bot...
if not exist ".env" ( if not exist ".env" (
echo Hinweis: .env wurde nicht gefunden. Der Bot startet nur, wenn die benoetigten Variablen bereits als Umgebungsvariablen gesetzt sind. 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 k:\dev\Animes\.venv\Scripts\python.exe k:\dev\Animes\discord_bot.py
exit /b %errorlevel% exit /b %errorlevel%

24
start.sh Normal file
View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
# Ensure the script works regardless of the current working directory.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
echo "Starte Discord Bot..."
if [[ ! -f ".env" ]]; then
echo "Hinweis: .env wurde nicht gefunden. Der Bot startet nur, wenn die benoetigten Variablen bereits als Umgebungsvariablen gesetzt sind."
fi
PYTHON_BIN=".venv/bin/python"
if [[ ! -x "$PYTHON_BIN" ]]; then
if command -v python3 >/dev/null 2>&1; then
PYTHON_BIN="$(command -v python3)"
echo "Hinweis: .venv/bin/python nicht gefunden, nutze $PYTHON_BIN."
else
echo "Fehler: Kein Python-Interpreter gefunden. Lege zuerst ein venv an oder installiere python3."
exit 1
fi
fi
exec "$PYTHON_BIN" "$SCRIPT_DIR/discord_bot.py"