Add modular TMDb-first movie pipeline and Discord bot
This commit is contained in:
6
.env.example
Normal file
6
.env.example
Normal file
@@ -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
|
||||
204
.gitignore
vendored
Normal file
204
.gitignore
vendored
Normal file
@@ -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
|
||||
|
||||
77
anime_movies_months.py
Normal file
77
anime_movies_months.py
Normal file
@@ -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())
|
||||
43
app_config.py
Normal file
43
app_config.py
Normal file
@@ -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(),
|
||||
)
|
||||
1
data_sources/__init__.py
Normal file
1
data_sources/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
#!/usr/bin/env python3
|
||||
176
data_sources/anilist_source.py
Normal file
176
data_sources/anilist_source.py
Normal file
@@ -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
|
||||
261
data_sources/animeschedule_source.py
Normal file
261
data_sources/animeschedule_source.py
Normal file
@@ -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]
|
||||
118
data_sources/tmdb_source.py
Normal file
118
data_sources/tmdb_source.py
Normal file
@@ -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
|
||||
113
discord_bot.py
Normal file
113
discord_bot.py
Normal file
@@ -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())
|
||||
110
embed_builder.py
Normal file
110
embed_builder.py
Normal file
@@ -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"</\s*p\s*>", " ", 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
|
||||
163
movie_pipeline.py
Normal file
163
movie_pipeline.py
Normal file
@@ -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)
|
||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
discord.py>=2.4,<3.0
|
||||
10
start-bot.bat
Normal file
10
start-bot.bat
Normal file
@@ -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%
|
||||
Reference in New Issue
Block a user