init
This commit is contained in:
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
128
backend/app/canon.py
Normal file
128
backend/app/canon.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Die Kanon-Liste gegen TMDB auflösen.
|
||||
|
||||
Die Vorlage nennt deutsche Verleihtitel. TMDB kennt die meisten, aber nicht
|
||||
alle — deshalb mehrere Anläufe pro Titel, vom genauesten zum gröbsten.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
from .canon_list import CANON
|
||||
from .config import LANGUAGE, TMDB_BASE, TMDB_TOKEN
|
||||
|
||||
log = logging.getLogger("canon")
|
||||
|
||||
HEADERS = {"Authorization": f"Bearer {TMDB_TOKEN}", "accept": "application/json"}
|
||||
YEAR = re.compile(r"\s*\((\d{4})\)\s*$")
|
||||
CONCURRENCY = 8
|
||||
|
||||
# Wo die Titelsuche danebengreift, steht die TMDB-ID fest.
|
||||
OVERRIDES = {
|
||||
"Us": 458723, # sonst "Forgive Us All" von 2025
|
||||
"Nightmare 2 – Die Rache": 10014, # sonst "Blade Master"
|
||||
"REC 2": 10664, # sonst ein Freddy-Verschnitt von 2025
|
||||
"Salò oder die 120 Tage von Sodom": 5336,
|
||||
"Freitag der 13. Teil 3": 9728,
|
||||
"Freitag der 13. Teil 7 – Jason im Blutrausch": 10281,
|
||||
# Reihen, in denen die Suche Teile durcheinanderwarf: ohne Jahresangabe
|
||||
# liefert TMDB den jüngsten Titel zuerst, also festnageln
|
||||
"Phantasm – Das Böse": 9638,
|
||||
"Phantasm: Ravager – Das Böse V": 262848,
|
||||
"Freitag der 13.": 4488,
|
||||
"Freitag der 13. (2009)": 13207,
|
||||
"Final Destination": 9532,
|
||||
"The Final Destination": 19912,
|
||||
"Final Destination Bloodlines": 574475,
|
||||
"A Quiet Place": 447332,
|
||||
"A Quiet Place 2": 520763,
|
||||
# "Das Böse" ist auf Deutsch sowohl Phantasm als auch Amityville Horror
|
||||
"Das Böse (1979)": 11449,
|
||||
# Gemeint ist das japanische Ring 2, nicht das US-Remake "The Ring Two"
|
||||
"Ring 2": 9669,
|
||||
# Traf einen gleichnamigen Film von 1976 statt Romeros Teil von 2007
|
||||
"Diary of the Dead": 13025,
|
||||
}
|
||||
|
||||
|
||||
def parse_entry(raw):
|
||||
"""'Psycho (1960)' → ('Psycho', 1960). Das Jahr trennt Gleichnamige."""
|
||||
match = YEAR.search(raw)
|
||||
if not match:
|
||||
return raw.strip(), None
|
||||
return YEAR.sub("", raw).strip(), int(match.group(1))
|
||||
|
||||
|
||||
def variants(title):
|
||||
"""Vom vollen Titel zu immer gröberen Formen."""
|
||||
yield title
|
||||
# Deutsche Verleihtitel hängen oft einen Untertitel an
|
||||
for sep in (" – ", " - ", ": "):
|
||||
if sep in title:
|
||||
head = title.split(sep)[0].strip()
|
||||
if len(head) > 2:
|
||||
yield head
|
||||
plain = title.replace("…", "").replace("’", "'").strip()
|
||||
if plain != title:
|
||||
yield plain
|
||||
|
||||
|
||||
async def search(http, title, year):
|
||||
for variant in variants(title):
|
||||
params = {"query": variant, "language": LANGUAGE, "include_adult": "true"}
|
||||
if year:
|
||||
params["primary_release_year"] = year
|
||||
resp = await http.get("/search/movie", params=params)
|
||||
resp.raise_for_status()
|
||||
results = resp.json().get("results")
|
||||
if results:
|
||||
return results[0], variant
|
||||
# Jahr kann in der Vorlage abweichen — zweiter Anlauf ohne
|
||||
if year:
|
||||
params.pop("primary_release_year")
|
||||
resp = await http.get("/search/movie", params=params)
|
||||
resp.raise_for_status()
|
||||
results = resp.json().get("results")
|
||||
if results:
|
||||
near = [
|
||||
r
|
||||
for r in results
|
||||
if (r.get("release_date") or "")[:4].isdigit()
|
||||
and abs(int(r["release_date"][:4]) - year) <= 1
|
||||
]
|
||||
if near:
|
||||
return near[0], variant
|
||||
return None, None
|
||||
|
||||
|
||||
def client():
|
||||
return httpx.AsyncClient(base_url=TMDB_BASE, headers=HEADERS, timeout=30.0)
|
||||
|
||||
|
||||
async def resolve_all():
|
||||
"""Gibt je Kanon-Eintrag die Liste der gefundenen TMDB-Treffer zurück."""
|
||||
semaphore = asyncio.Semaphore(CONCURRENCY)
|
||||
|
||||
async def one(http, raw):
|
||||
if raw in OVERRIDES:
|
||||
async with semaphore:
|
||||
resp = await http.get(
|
||||
f"/movie/{OVERRIDES[raw]}", params={"language": LANGUAGE}
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return {"raw": raw, "hit": resp.json(), "used": "override"}
|
||||
title, year = parse_entry(raw)
|
||||
async with semaphore:
|
||||
hit, used = await search(http, title, year)
|
||||
return {"raw": raw, "title": title, "year": year, "hit": hit, "used": used}
|
||||
|
||||
async with client() as http:
|
||||
groups = await asyncio.gather(
|
||||
*(
|
||||
asyncio.gather(*(one(http, raw) for raw in group))
|
||||
for group in CANON
|
||||
)
|
||||
)
|
||||
return list(groups)
|
||||
351
backend/app/canon_list.py
Normal file
351
backend/app/canon_list.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""Der Horror-Kanon, zusammengeführt aus anerkannten Bestenlisten.
|
||||
|
||||
Ein Eintrag ist entweder ein einzelner Film oder eine Reihe. Die Reihenfolge
|
||||
innerhalb einer Reihe ist die der Vorlage, nicht zwingend chronologisch.
|
||||
|
||||
Ein Jahr in Klammern hinter dem Titel dient nur der Unterscheidung
|
||||
gleichnamiger Filme und wird beim Auflösen abgetrennt.
|
||||
"""
|
||||
|
||||
CANON = [
|
||||
["Das Cabinet des Dr. Caligari"],
|
||||
[
|
||||
"Nosferatu – Eine Symphonie des Grauens",
|
||||
"Nosferatu – Phantom der Nacht",
|
||||
"Nosferatu (2025)",
|
||||
],
|
||||
["Ein andalusischer Hund"],
|
||||
[
|
||||
"Dracula (1931)",
|
||||
"Frankenstein (1931)",
|
||||
"Die Mumie (1932)",
|
||||
"Der Unsichtbare (1933)",
|
||||
"Frankensteins Braut",
|
||||
"Der Wolfsmensch",
|
||||
],
|
||||
["Freaks"],
|
||||
["Das Haus des Grauens (1932)"],
|
||||
["Vampyr – Der Traum des Allan Grey"],
|
||||
["Katzenmenschen (1942)"],
|
||||
["Traum ohne Ende"],
|
||||
["Die Nacht des Jägers"],
|
||||
["Die Teuflischen"],
|
||||
["Die Dämonischen (1956)", "Die Körperfresser kommen (1978)"],
|
||||
["Der Fluch des Dämonen"],
|
||||
["Dracula (1958)"],
|
||||
["Psycho (1960)"],
|
||||
["Augen der Angst"],
|
||||
["Augen ohne Gesicht"],
|
||||
["Die Stunde, wenn Dracula kommt"],
|
||||
["Schloss des Schreckens"],
|
||||
["Carnival of Souls"],
|
||||
["Bis das Blut gefriert"],
|
||||
["Die Vögel"],
|
||||
["Die drei Gesichter der Furcht"],
|
||||
["Kwaidan"],
|
||||
["Ekel"],
|
||||
["Kill, Baby… Kill!"],
|
||||
["Rosemaries Baby"],
|
||||
[
|
||||
"Die Nacht der lebenden Toten (1968)",
|
||||
"Zombie – Dawn of the Dead",
|
||||
"Day of the Dead – Zombie 2",
|
||||
"Land of the Dead",
|
||||
"Diary of the Dead",
|
||||
"Survival of the Dead",
|
||||
],
|
||||
["Die Stunde des Wolfs"],
|
||||
["Die Braut des Teufels"],
|
||||
["The Dunwich Horror"],
|
||||
["Duell (1971)"],
|
||||
["Die Teufel (1971)"],
|
||||
["Das Schreckenskabinett des Dr. Phibes"],
|
||||
["In den Krallen des Hexenjägers"],
|
||||
[
|
||||
"Der Exorzist",
|
||||
"Exorzist II – Der Ketzer",
|
||||
"Der Exorzist III",
|
||||
"Exorcist: Der Anfang",
|
||||
"Dominion: Exorzist – Der Anfang des Bösen",
|
||||
"Der Exorzist: Bekenntnis",
|
||||
],
|
||||
["The Wicker Man (1973)"],
|
||||
["Wenn die Gondeln Trauer tragen"],
|
||||
[
|
||||
"Blutgericht in Texas",
|
||||
"Texas Chainsaw Massacre 2",
|
||||
"Leatherface: Texas Chainsaw Massacre III",
|
||||
"Texas Chainsaw Massacre – Die Rückkehr",
|
||||
"The Texas Chainsaw Massacre (2003)",
|
||||
"Texas Chainsaw Massacre: The Beginning",
|
||||
"Texas Chainsaw 3D",
|
||||
"Leatherface (2017)",
|
||||
"Texas Chainsaw Massacre (2022)",
|
||||
],
|
||||
# Die Vorlage führt "Black Christmas" und "Jessy – Die Treppe in den Tod"
|
||||
# getrennt. Das ist derselbe Film, hier unter dem deutschen Verleihtitel.
|
||||
["Black Christmas (1974)"],
|
||||
["Andy Warhol's Dracula"],
|
||||
["Der weiße Hai"],
|
||||
["Rosso – Die Farbe des Todes"],
|
||||
["Salò oder die 120 Tage von Sodom"],
|
||||
["Carrie – Des Satans jüngste Tochter"],
|
||||
[
|
||||
"Das Omen (1976)",
|
||||
"Damien – Omen II",
|
||||
"Omen III – Der Endkampf",
|
||||
"Omen IV – Das Erwachen",
|
||||
"The Omen (2006)",
|
||||
"The First Omen",
|
||||
],
|
||||
["Der Mieter"],
|
||||
["Ein Kind zu töten…"],
|
||||
["God Told Me To"],
|
||||
["Suspiria (1977)", "Suspiria (2018)"],
|
||||
["Eraserhead"],
|
||||
[
|
||||
"Halloween – Die Nacht des Grauens",
|
||||
"Halloween II – Das Grauen kehrt zurück",
|
||||
"Halloween III – Die Nacht der Entscheidung",
|
||||
"Halloween 4 – Michael Myers kehrt zurück",
|
||||
"Halloween 5 – Die Rache des Michael Myers",
|
||||
"Halloween – Der Fluch des Michael Myers",
|
||||
"Halloween H20",
|
||||
"Halloween: Resurrection",
|
||||
"Halloween (2007)",
|
||||
"Halloween II (2009)",
|
||||
"Halloween (2018)",
|
||||
"Halloween Kills",
|
||||
"Halloween Ends",
|
||||
],
|
||||
["Ich spuck' auf dein Grab (1978)"],
|
||||
[
|
||||
"Alien – Das unheimliche Wesen aus einer fremden Welt",
|
||||
"Aliens – Die Rückkehr",
|
||||
"Alien 3",
|
||||
"Alien – Die Wiedergeburt",
|
||||
"Prometheus – Dunkle Zeichen",
|
||||
"Alien: Covenant",
|
||||
"Alien: Romulus",
|
||||
],
|
||||
[
|
||||
"Phantasm – Das Böse",
|
||||
"Phantasm II – Das Böse II",
|
||||
"Phantasm III – Das Böse III",
|
||||
"Phantasm IV – Das Böse IV",
|
||||
"Phantasm: Ravager – Das Böse V",
|
||||
],
|
||||
["Die Brut (1979)"],
|
||||
["Das Böse (1979)"],
|
||||
["Brennen muss Salem"],
|
||||
["Sado – Stoß das Tor zur Hölle auf"],
|
||||
["Shining"],
|
||||
["The Fog – Nebel des Grauens"],
|
||||
["Maniac (1980)", "Alexandre Ajas Maniac"],
|
||||
["Das Grauen (1980)"],
|
||||
["Nackt und zerfleischt"],
|
||||
["Das Grauen aus der Tiefe"],
|
||||
["Man-Eater – Der Menschenfresser"],
|
||||
[
|
||||
"Freitag der 13.",
|
||||
"Freitag der 13. Teil 2 – Jason kehrt zurück",
|
||||
"Freitag der 13. Teil 3",
|
||||
"Freitag der 13. Teil 4 – Das letzte Kapitel",
|
||||
"Freitag der 13. Teil 5 – Ein neuer Anfang",
|
||||
"Freitag der 13. Teil 6 – Jason lebt",
|
||||
"Freitag der 13. Teil 7 – Jason im Blutrausch",
|
||||
"Freitag der 13. Teil 8 – Todesfalle Manhattan",
|
||||
"Jason Goes to Hell",
|
||||
"Jason X",
|
||||
"Freddy vs. Jason",
|
||||
"Freitag der 13. (2009)",
|
||||
],
|
||||
[
|
||||
"Tanz der Teufel",
|
||||
"Tanz der Teufel 2",
|
||||
"Armee der Finsternis",
|
||||
"Evil Dead (2013)",
|
||||
"Evil Dead Rise",
|
||||
],
|
||||
["Possession (1981)"],
|
||||
["American Werewolf"],
|
||||
["Das Tier (1981)"],
|
||||
["Über dem Jenseits"],
|
||||
["Das Haus an der Friedhofsmauer"],
|
||||
["Das Ding aus einer anderen Welt (1982)"],
|
||||
["Poltergeist (1982)"],
|
||||
["Videodrome"],
|
||||
[
|
||||
"Nightmare – Mörderische Träume",
|
||||
"Nightmare 2 – Die Rache",
|
||||
"Nightmare 3 – Freddy Krueger lebt",
|
||||
"Nightmare 4 – Freddys fatales Finale",
|
||||
"Nightmare 5 – Das Trauma",
|
||||
"Freddy's Finale – Nightmare on Elm Street 6",
|
||||
"Freddy's New Nightmare",
|
||||
"A Nightmare on Elm Street (2010)",
|
||||
],
|
||||
["Re-Animator"],
|
||||
["Lifeforce – Die tödliche Bedrohung"],
|
||||
["Die Fliege (1986)"],
|
||||
["Henry: Portrait of a Serial Killer"],
|
||||
["From Beyond – Aliens des Grauens"],
|
||||
[
|
||||
"Hellraiser – Das Tor zur Hölle",
|
||||
"Hellbound: Hellraiser II",
|
||||
"Hellraiser III",
|
||||
"Hellraiser: Bloodline",
|
||||
"Hellraiser (2022)",
|
||||
],
|
||||
["Angel Heart"],
|
||||
["Nekromantik"],
|
||||
["Predator (1987)"],
|
||||
["Dead Ringers – Die Unzertrennlichen"],
|
||||
["Spurlos verschwunden (1988)"],
|
||||
["Die Schlange im Regenbogen"],
|
||||
["Misery"],
|
||||
["Jacob's Ladder – In der Gewalt des Jenseits"],
|
||||
["Arachnophobia"],
|
||||
["Cabal – Die Brut der Nacht"],
|
||||
["Das Schweigen der Lämmer"],
|
||||
["Braindead"],
|
||||
[
|
||||
"Candyman's Fluch",
|
||||
"Candyman 2 – Die Blutrache",
|
||||
"Candyman 3 – Der Tag der Toten",
|
||||
"Candyman (2021)",
|
||||
],
|
||||
["Dark Waters (1993)"],
|
||||
["Die Mächte des Wahnsinns"],
|
||||
["DellaMorte DellAmore"],
|
||||
[
|
||||
"Scream – Schrei!",
|
||||
"Scream 2",
|
||||
"Scream 3",
|
||||
"Scream 4",
|
||||
"Scream (2022)",
|
||||
"Scream VI",
|
||||
],
|
||||
["From Dusk Till Dawn"],
|
||||
["Tesis – Der Snuff Film"],
|
||||
["Funny Games (1997)"],
|
||||
["Event Horizon – Am Rande des Universums"],
|
||||
["Lost Highway"],
|
||||
["Ring – Das Original", "Ring 2", "Ring 0", "The Ring (2002)"],
|
||||
["Blair Witch Project"],
|
||||
["The Sixth Sense"],
|
||||
["Audition (1999)"],
|
||||
["Ginger Snaps", "Ginger Snaps II", "Ginger Snaps III"],
|
||||
[
|
||||
"Final Destination",
|
||||
"Final Destination 2",
|
||||
"Final Destination 3",
|
||||
"The Final Destination",
|
||||
"Final Destination 5",
|
||||
"Final Destination Bloodlines",
|
||||
],
|
||||
["Kairo (2001)"],
|
||||
["The Others"],
|
||||
["The Devil's Backbone – Das Rückgrat des Teufels"],
|
||||
["28 Days Later", "28 Weeks Later", "28 Years Later"],
|
||||
["Irreversibel"],
|
||||
["May (2002)"],
|
||||
["A Tale of Two Sisters"],
|
||||
["High Tension"],
|
||||
[
|
||||
"Saw",
|
||||
"Saw II",
|
||||
"Saw III",
|
||||
"Saw IV",
|
||||
"Saw V",
|
||||
"Saw VI",
|
||||
"Saw 3D – Vollendung",
|
||||
"Jigsaw",
|
||||
"Spiral",
|
||||
"Saw X",
|
||||
],
|
||||
["Shaun of the Dead"],
|
||||
["The Descent – Abgrund des Grauens"],
|
||||
["Cigarette Burns"],
|
||||
["The Call of Cthulhu"],
|
||||
["Pans Labyrinth"],
|
||||
["Silent Hill"],
|
||||
["The Hills Have Eyes (2006)"],
|
||||
["REC", "REC 2", "REC 3: Génesis", "REC 4: Apocalypse"],
|
||||
["Das Waisenhaus"],
|
||||
["Inside (2007)"],
|
||||
["The Mist – Der Nebel"],
|
||||
["Martyrs (2008)"],
|
||||
["So finster die Nacht"],
|
||||
["Lake Mungo"],
|
||||
["Eden Lake"],
|
||||
["Antichrist"],
|
||||
["I Saw the Devil"],
|
||||
["The Cabin in the Woods"],
|
||||
["Livid – Das Blut der Ballerinas"],
|
||||
["Sleep Tight"],
|
||||
["Sinister", "Sinister 2"],
|
||||
["The Lords of Salem"],
|
||||
["Die Frau in Schwarz"],
|
||||
[
|
||||
"The Conjuring – Die Heimsuchung",
|
||||
"The Conjuring 2",
|
||||
"The Conjuring 3: Im Bann des Teufels",
|
||||
"The Conjuring: Last Rites",
|
||||
"The Nun",
|
||||
"The Nun II",
|
||||
"Annabelle",
|
||||
"Annabelle 2",
|
||||
"Annabelle 3",
|
||||
],
|
||||
["It Follows"],
|
||||
["Der Babadook"],
|
||||
["Ich seh, ich seh"],
|
||||
["The Witch"],
|
||||
["The Autopsy of Jane Doe"],
|
||||
["The Wailing – Die Besessenen"],
|
||||
["Get Out"],
|
||||
["Hagazussa – Der Hexenfluch"],
|
||||
["The Killing of a Sacred Deer"],
|
||||
["Revenge (2017)"],
|
||||
["Hereditary – Das Vermächtnis"],
|
||||
["A Quiet Place", "A Quiet Place 2", "A Quiet Place: Day One"],
|
||||
["Ghostland"],
|
||||
["Midsommar"],
|
||||
["Us"],
|
||||
["Doctor Sleeps Erwachen"],
|
||||
["Der Leuchtturm"],
|
||||
["Der Unsichtbare (2020)"],
|
||||
["Saint Maud"],
|
||||
["Host (2020)"],
|
||||
["His House"],
|
||||
["Relic"],
|
||||
["Possessor"],
|
||||
["La Llorona (2019)"],
|
||||
["Impetigore"],
|
||||
["Censor"],
|
||||
["Titane"],
|
||||
["The Medium"],
|
||||
["The Night House"],
|
||||
["Terrifier", "Terrifier 2", "Terrifier 3"],
|
||||
["Huesera"],
|
||||
["Speak No Evil (2022)"],
|
||||
["Barbarian"],
|
||||
["Skinamarink"],
|
||||
["Smile – Siehst du es auch?"],
|
||||
["Talk to Me"],
|
||||
["When Evil Lurks"],
|
||||
["Exhuma"],
|
||||
["Longlegs"],
|
||||
["Oddity"],
|
||||
["In a Violent Nature"],
|
||||
["Immaculate"],
|
||||
["Cuckoo"],
|
||||
["Heretic"],
|
||||
["The Substance"],
|
||||
["Sinners"],
|
||||
["Weapons"],
|
||||
["Bring Her Back"],
|
||||
["The Ugly Stepsister"],
|
||||
["Frankenstein (2025)"],
|
||||
]
|
||||
25
backend/app/config.py
Normal file
25
backend/app/config.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
load_dotenv(ROOT / ".env")
|
||||
|
||||
TMDB_TOKEN = os.environ["TMDB_TOKEN"]
|
||||
MINIMAX_KEY = os.environ["MINIMAX_KEY"]
|
||||
|
||||
TMDB_BASE = "https://api.themoviedb.org/3"
|
||||
IMAGE_BASE = "https://image.tmdb.org/t/p"
|
||||
|
||||
MINIMAX_URL = "https://api.minimax.io/v1/chat/completions"
|
||||
MINIMAX_MODEL = "MiniMax-M3"
|
||||
|
||||
# Netflix = 8, Horror = 27
|
||||
PROVIDER_NETFLIX = 8
|
||||
GENRE_HORROR = 27
|
||||
REGION = "DE"
|
||||
LANGUAGE = "de-DE"
|
||||
|
||||
DB_PATH = Path(os.getenv("DB_PATH", ROOT / "backend" / "horror.db"))
|
||||
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
||||
180
backend/app/db.py
Normal file
180
backend/app/db.py
Normal file
@@ -0,0 +1,180 @@
|
||||
import json
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
|
||||
from .config import DB_PATH
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS movies (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
original_title TEXT,
|
||||
release_date TEXT,
|
||||
year INTEGER,
|
||||
overview TEXT,
|
||||
teaser TEXT,
|
||||
poster_path TEXT,
|
||||
backdrop_path TEXT,
|
||||
vote_average REAL,
|
||||
vote_count INTEGER,
|
||||
runtime INTEGER,
|
||||
genres TEXT,
|
||||
images TEXT,
|
||||
trailer_key TEXT,
|
||||
imdb_id TEXT,
|
||||
netflix_id TEXT,
|
||||
netflix_url TEXT,
|
||||
on_netflix INTEGER DEFAULT 0,
|
||||
canon_group INTEGER,
|
||||
canon_pos INTEGER,
|
||||
collection_id INTEGER,
|
||||
collection_name TEXT,
|
||||
synced_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
-- Fingerabdrücke der Backdrops, damit spätere Syncs die Bilder nicht
|
||||
-- erneut herunterladen müssen
|
||||
-- hash als BLOB: der dHash ist vorzeichenlos 64 Bit und passt nicht in
|
||||
-- SQLites vorzeichenbehaftetes INTEGER
|
||||
CREATE TABLE IF NOT EXISTS image_prints (
|
||||
file_path TEXT PRIMARY KEY,
|
||||
hash BLOB NOT NULL,
|
||||
histogram BLOB NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
JSON_FIELDS = ("genres", "images")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def connect():
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def init():
|
||||
with connect() as conn:
|
||||
conn.executescript(SCHEMA)
|
||||
# Spalten, die erst später dazukamen — die Teaser in einer bestehenden
|
||||
# Datenbank sind zu teuer, um sie für ein Schema-Update wegzuwerfen
|
||||
existing = {row["name"] for row in conn.execute("PRAGMA table_info(movies)")}
|
||||
for column, kind in (
|
||||
("collection_id", "INTEGER"),
|
||||
("collection_name", "TEXT"),
|
||||
("netflix_id", "TEXT"),
|
||||
("imdb_id", "TEXT"),
|
||||
("on_netflix", "INTEGER DEFAULT 0"),
|
||||
("canon_group", "INTEGER"),
|
||||
("canon_pos", "INTEGER"),
|
||||
):
|
||||
if column not in existing:
|
||||
conn.execute(f"ALTER TABLE movies ADD COLUMN {column} {kind}")
|
||||
|
||||
|
||||
def row_to_dict(row):
|
||||
movie = dict(row)
|
||||
for field in JSON_FIELDS:
|
||||
movie[field] = json.loads(movie[field] or "[]")
|
||||
return movie
|
||||
|
||||
|
||||
def upsert(conn, movie):
|
||||
payload = dict(movie)
|
||||
for field in JSON_FIELDS:
|
||||
payload[field] = json.dumps(payload.get(field) or [], ensure_ascii=False)
|
||||
columns = ", ".join(payload)
|
||||
placeholders = ", ".join(f":{c}" for c in payload)
|
||||
conn.execute(
|
||||
f"INSERT OR REPLACE INTO movies ({columns}) VALUES ({placeholders})", payload
|
||||
)
|
||||
|
||||
|
||||
def existing_teasers(conn):
|
||||
rows = conn.execute(
|
||||
"SELECT id, teaser FROM movies WHERE teaser IS NOT NULL AND teaser != ''"
|
||||
).fetchall()
|
||||
return {row["id"]: row["teaser"] for row in rows}
|
||||
|
||||
|
||||
def existing_netflix_ids(conn):
|
||||
rows = conn.execute(
|
||||
"SELECT id, netflix_id FROM movies WHERE netflix_id IS NOT NULL"
|
||||
).fetchall()
|
||||
return {row["id"]: row["netflix_id"] for row in rows}
|
||||
|
||||
|
||||
def drop_missing(conn, keep_ids):
|
||||
"""Was weder auf Netflix läuft noch im Kanon steht, fliegt raus."""
|
||||
current = {r["id"] for r in conn.execute("SELECT id FROM movies").fetchall()}
|
||||
gone = current - set(keep_ids)
|
||||
if gone:
|
||||
conn.executemany("DELETE FROM movies WHERE id = ?", [(i,) for i in gone])
|
||||
return len(gone)
|
||||
|
||||
|
||||
def all_movies():
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM movies ORDER BY vote_count DESC, title ASC"
|
||||
).fetchall()
|
||||
return [row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_movie(movie_id):
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT * FROM movies WHERE id = ?", (movie_id,)).fetchone()
|
||||
return row_to_dict(row) if row else None
|
||||
|
||||
|
||||
def known_prints(conn, paths):
|
||||
"""SQLite begrenzt die Parameterzahl, also in Blöcken abfragen."""
|
||||
found = {}
|
||||
paths = list(paths)
|
||||
for start in range(0, len(paths), 500):
|
||||
chunk = paths[start : start + 500]
|
||||
marks = ", ".join("?" * len(chunk))
|
||||
rows = conn.execute(
|
||||
f"SELECT file_path, hash, histogram FROM image_prints "
|
||||
f"WHERE file_path IN ({marks})",
|
||||
chunk,
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
found[row["file_path"]] = (
|
||||
int.from_bytes(row["hash"], "big"),
|
||||
row["histogram"],
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def store_prints(conn, prints):
|
||||
conn.executemany(
|
||||
"INSERT OR REPLACE INTO image_prints (file_path, hash, histogram) "
|
||||
"VALUES (?, ?, ?)",
|
||||
[
|
||||
(path, value[0].to_bytes(8, "big"), value[1])
|
||||
for path, value in prints.items()
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def set_meta(conn, key, value):
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", (key, str(value))
|
||||
)
|
||||
|
||||
|
||||
def get_meta(key):
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone()
|
||||
return row["value"] if row else None
|
||||
122
backend/app/imagehash.py
Normal file
122
backend/app/imagehash.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Duplikate unter den TMDB-Backdrops finden.
|
||||
|
||||
TMDB kennzeichnet nicht, welches Bild ein Szenenfoto und welches ein Crop
|
||||
desselben Frames ist. Also vergleichen wir die Bilder selbst.
|
||||
|
||||
Zwei Metriken, weil eine allein nicht reicht:
|
||||
- dHash über eine 9x8-Graustufenminiatur erkennt gleiche Bilder zuverlässig,
|
||||
scheitert aber an Crops. Gemessen: ein echter Crop lag bei Distanz 14, zwei
|
||||
völlig verschiedene Bilder bei 13.
|
||||
- Das Farbhistogramm trennt genau diese Fälle. Der Crop lag bei 0.12, die
|
||||
verschiedenen Bilder bei über 0.27.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from PIL import Image
|
||||
|
||||
from .config import IMAGE_BASE
|
||||
|
||||
log = logging.getLogger("imagehash")
|
||||
|
||||
HASH_SIZE = 8
|
||||
# Bis hierher entscheidet der dHash allein
|
||||
STRICT_DISTANCE = 10
|
||||
# Darüber hinaus nur zusammen mit sehr ähnlicher Farbverteilung
|
||||
LOOSE_DISTANCE = 18
|
||||
MAX_HIST_DISTANCE = 0.15
|
||||
|
||||
HIST_BINS = 32
|
||||
THUMB_SIZE = "w300"
|
||||
CONCURRENCY = 12
|
||||
|
||||
|
||||
def dhash(img):
|
||||
"""64-Bit-Fingerabdruck: je Pixelpaar ein Bit, ob links heller ist."""
|
||||
thumb = img.convert("L").resize((HASH_SIZE + 1, HASH_SIZE), Image.LANCZOS)
|
||||
pixels = list(thumb.getdata())
|
||||
|
||||
bits = 0
|
||||
for row in range(HASH_SIZE):
|
||||
offset = row * (HASH_SIZE + 1)
|
||||
for col in range(HASH_SIZE):
|
||||
bits <<= 1
|
||||
if pixels[offset + col] > pixels[offset + col + 1]:
|
||||
bits |= 1
|
||||
return bits
|
||||
|
||||
|
||||
def histogram(img):
|
||||
"""96 Bytes Farbverteilung, unabhängig von Bildausschnitt und Größe."""
|
||||
small = img.convert("RGB").resize((64, 64), Image.LANCZOS)
|
||||
raw = small.histogram()
|
||||
total = 64 * 64
|
||||
step = 256 // HIST_BINS
|
||||
|
||||
packed = bytearray()
|
||||
for channel in range(3):
|
||||
base = channel * 256
|
||||
for start in range(0, 256, step):
|
||||
share = sum(raw[base + start : base + start + step]) / total
|
||||
packed.append(min(255, round(share * 255)))
|
||||
return bytes(packed)
|
||||
|
||||
|
||||
def fingerprint(data):
|
||||
with Image.open(io.BytesIO(data)) as img:
|
||||
return dhash(img), histogram(img)
|
||||
|
||||
|
||||
def distance(a, b):
|
||||
return bin(a ^ b).count("1")
|
||||
|
||||
|
||||
def hist_distance(a, b):
|
||||
"""0 = gleiche Farbverteilung, 1 = keine Überschneidung."""
|
||||
overlap = sum(min(x, y) for x, y in zip(a, b)) / 255
|
||||
return max(0.0, 1 - overlap / 3)
|
||||
|
||||
|
||||
def is_duplicate(a, b):
|
||||
gap = distance(a[0], b[0])
|
||||
if gap <= STRICT_DISTANCE:
|
||||
return True
|
||||
return gap <= LOOSE_DISTANCE and hist_distance(a[1], b[1]) <= MAX_HIST_DISTANCE
|
||||
|
||||
|
||||
async def fetch_fingerprint(http, path, semaphore):
|
||||
async with semaphore:
|
||||
try:
|
||||
resp = await http.get(f"{IMAGE_BASE}/{THUMB_SIZE}{path}")
|
||||
resp.raise_for_status()
|
||||
return path, fingerprint(resp.content)
|
||||
except (httpx.HTTPError, OSError):
|
||||
log.warning("Bild nicht lesbar: %s", path)
|
||||
return path, None
|
||||
|
||||
|
||||
async def fingerprint_many(paths):
|
||||
semaphore = asyncio.Semaphore(CONCURRENCY)
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as http:
|
||||
pairs = await asyncio.gather(
|
||||
*(fetch_fingerprint(http, p, semaphore) for p in paths)
|
||||
)
|
||||
return {path: value for path, value in pairs if value is not None}
|
||||
|
||||
|
||||
def dedupe(paths, prints):
|
||||
"""Reihenfolge bleibt, jedes weitere Bild muss sich von allen bisherigen
|
||||
unterscheiden. Bilder ohne Fingerabdruck fallen raus."""
|
||||
kept, kept_prints = [], []
|
||||
for path in paths:
|
||||
current = prints.get(path)
|
||||
if current is None:
|
||||
continue
|
||||
if any(is_duplicate(current, other) for other in kept_prints):
|
||||
continue
|
||||
kept.append(path)
|
||||
kept_prints.append(current)
|
||||
return kept
|
||||
83
backend/app/main.py
Normal file
83
backend/app/main.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import db, sync
|
||||
from .config import IMAGE_BASE, STATIC_DIR
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger("app")
|
||||
|
||||
app = FastAPI(title="Netflix Horror DE")
|
||||
scheduler = AsyncIOScheduler()
|
||||
sync_lock = asyncio.Lock()
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
async def guarded_sync():
|
||||
if sync_lock.locked():
|
||||
log.info("Sync läuft bereits")
|
||||
return None
|
||||
async with sync_lock:
|
||||
return await sync.run()
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
db.init()
|
||||
scheduler.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync")
|
||||
scheduler.start()
|
||||
if not db.get_meta("last_sync"):
|
||||
log.info("Leere Datenbank, starte ersten Sync im Hintergrund")
|
||||
asyncio.create_task(guarded_sync())
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
def status():
|
||||
return {
|
||||
"last_sync": db.get_meta("last_sync"),
|
||||
"movie_count": int(db.get_meta("movie_count") or 0),
|
||||
"canon_count": int(db.get_meta("canon_count") or 0),
|
||||
"syncing": sync_lock.locked(),
|
||||
"image_base": IMAGE_BASE,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/movies")
|
||||
def movies():
|
||||
return db.all_movies()
|
||||
|
||||
|
||||
@app.get("/api/movies/{movie_id}")
|
||||
def movie(movie_id: int):
|
||||
found = db.get_movie(movie_id)
|
||||
if not found:
|
||||
raise HTTPException(status_code=404, detail="Film nicht gefunden")
|
||||
return found
|
||||
|
||||
|
||||
@app.post("/api/sync")
|
||||
async def trigger_sync():
|
||||
if sync_lock.locked():
|
||||
return {"started": False, "reason": "läuft bereits"}
|
||||
asyncio.create_task(guarded_sync())
|
||||
return {"started": True}
|
||||
|
||||
|
||||
if STATIC_DIR.is_dir():
|
||||
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
|
||||
53
backend/app/netflix.py
Normal file
53
backend/app/netflix.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Die echte Netflix-Titel-ID aus TMDBs Watch-Seite lesen.
|
||||
|
||||
Die API liefert nur den Anbieternamen. Die Titel-ID steht ausschließlich im
|
||||
HTML der Watch-Seite, URL-kodiert im Weiterleitungslink von JustWatch.
|
||||
|
||||
Ohne sie bliebe nur eine Netflix-Titelsuche, und die geht ins Leere, sobald
|
||||
Netflix den Film anders nennt: "LIENZO" heißt dort "Die Leinwand".
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
log = logging.getLogger("netflix")
|
||||
|
||||
TITLE_ID = re.compile(r"netflix\.com%2Ftitle%2F(\d+)")
|
||||
UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/131.0"
|
||||
# Zu dichte Abrufe drosselt TMDB. Die Seite kommt dann mit Status 200, aber
|
||||
# ohne Anbieterblock. Filme ohne ID versucht der nächste Sync erneut.
|
||||
PAUSE = 1.5
|
||||
|
||||
|
||||
def watch_url(movie_id):
|
||||
return f"https://www.themoviedb.org/movie/{movie_id}/watch?locale=DE"
|
||||
|
||||
|
||||
def title_url(netflix_id):
|
||||
return f"https://www.netflix.com/de/title/{netflix_id}"
|
||||
|
||||
|
||||
async def fetch_ids(movie_ids):
|
||||
"""Seriell, mit Pause. Gibt {tmdb_id: netflix_id} für alles Gefundene."""
|
||||
found = {}
|
||||
async with httpx.AsyncClient(
|
||||
headers={"User-Agent": UA}, timeout=60.0, follow_redirects=True
|
||||
) as http:
|
||||
for index, movie_id in enumerate(movie_ids, 1):
|
||||
try:
|
||||
resp = await http.get(watch_url(movie_id))
|
||||
# Mehrere IDs bedeuten mehrere Fassungen — Godzilla Minus One
|
||||
# liegt in Farbe und in Schwarzweiß. Die erste Kachel ist die
|
||||
# Hauptfassung.
|
||||
ids = TITLE_ID.findall(resp.text)
|
||||
if ids:
|
||||
found[movie_id] = ids[0]
|
||||
except httpx.HTTPError:
|
||||
log.warning("Watch-Seite nicht erreichbar: %s", movie_id)
|
||||
if index % 50 == 0:
|
||||
log.info(" %s/%s Watch-Seiten gelesen", index, len(movie_ids))
|
||||
await asyncio.sleep(PAUSE)
|
||||
return found
|
||||
196
backend/app/sync.py
Normal file
196
backend/app/sync.py
Normal file
@@ -0,0 +1,196 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from . import canon, db, imagehash, netflix, teaser, tmdb
|
||||
|
||||
log = logging.getLogger("sync")
|
||||
|
||||
TMDB_CONCURRENCY = 8
|
||||
TEASER_CONCURRENCY = 5
|
||||
|
||||
|
||||
def netflix_url(title):
|
||||
return f"https://www.netflix.com/search?q={quote_plus(title)}"
|
||||
|
||||
|
||||
def build_movie(data):
|
||||
release = data.get("release_date") or ""
|
||||
collection = data.get("belongs_to_collection") or {}
|
||||
return {
|
||||
"collection_id": collection.get("id"),
|
||||
"collection_name": collection.get("name"),
|
||||
"id": data["id"],
|
||||
"title": data.get("title") or data.get("original_title") or "Ohne Titel",
|
||||
"original_title": data.get("original_title"),
|
||||
"release_date": release or None,
|
||||
"year": int(release[:4]) if release[:4].isdigit() else None,
|
||||
"overview": tmdb.pick_overview(data),
|
||||
"poster_path": data.get("poster_path"),
|
||||
"backdrop_path": data.get("backdrop_path"),
|
||||
"vote_average": data.get("vote_average"),
|
||||
"vote_count": data.get("vote_count"),
|
||||
"runtime": data.get("runtime"),
|
||||
"genres": [g["name"] for g in data.get("genres", [])],
|
||||
"images": tmdb.pick_backdrops(data),
|
||||
"trailer_key": tmdb.pick_trailer(data),
|
||||
"imdb_id": data.get("imdb_id"),
|
||||
# Die echte Titel-ID trägt fill_netflix_links nach
|
||||
"netflix_id": None,
|
||||
"netflix_url": tmdb.pick_watch_link(data)
|
||||
or netflix_url(data.get("title") or data.get("original_title") or ""),
|
||||
# setzt run() je nach Quelle
|
||||
"on_netflix": 0,
|
||||
"canon_group": None,
|
||||
"canon_pos": None,
|
||||
}
|
||||
|
||||
|
||||
async def fetch_details(http, ids):
|
||||
semaphore = asyncio.Semaphore(TMDB_CONCURRENCY)
|
||||
|
||||
async def one(movie_id):
|
||||
async with semaphore:
|
||||
try:
|
||||
return build_movie(await tmdb.detail(http, movie_id))
|
||||
except Exception:
|
||||
log.warning("Details fehlgeschlagen für %s", movie_id)
|
||||
return None
|
||||
|
||||
results = await asyncio.gather(*(one(i) for i in ids))
|
||||
return [m for m in results if m]
|
||||
|
||||
|
||||
async def dedupe_images(movies):
|
||||
"""Crops und Werbemotive desselben Frames fliegen raus."""
|
||||
all_paths = {p for m in movies for p in m["images"]}
|
||||
with db.connect() as conn:
|
||||
prints = db.known_prints(conn, all_paths)
|
||||
|
||||
missing = sorted(all_paths - set(prints))
|
||||
if missing:
|
||||
log.info("Berechne Fingerabdrücke für %s Bilder", len(missing))
|
||||
fresh = await imagehash.fingerprint_many(missing)
|
||||
with db.connect() as conn:
|
||||
db.store_prints(conn, fresh)
|
||||
prints.update(fresh)
|
||||
|
||||
before = sum(len(m["images"]) for m in movies)
|
||||
for movie in movies:
|
||||
movie["images"] = imagehash.dedupe(movie["images"], prints)
|
||||
after = sum(len(m["images"]) for m in movies)
|
||||
log.info("Bilder: %s → %s nach Dedup", before, after)
|
||||
|
||||
|
||||
async def fill_netflix_links(movies, known):
|
||||
"""Direktlink auf die Netflix-Titelseite, sofern die ID auffindbar ist.
|
||||
|
||||
Sonst bleibt der Link auf TMDBs Watch-Seite stehen — von dort ist es
|
||||
ein Klick weiter.
|
||||
"""
|
||||
for movie in movies:
|
||||
movie["netflix_id"] = known.get(movie["id"])
|
||||
|
||||
todo = [m["id"] for m in movies if not m["netflix_id"]]
|
||||
if todo:
|
||||
log.info("Suche Netflix-IDs für %s Filme", len(todo))
|
||||
found = await netflix.fetch_ids(todo)
|
||||
for movie in movies:
|
||||
movie["netflix_id"] = movie["netflix_id"] or found.get(movie["id"])
|
||||
|
||||
for movie in movies:
|
||||
if movie["netflix_id"]:
|
||||
movie["netflix_url"] = netflix.title_url(movie["netflix_id"])
|
||||
return sum(1 for m in movies if m["netflix_id"])
|
||||
|
||||
|
||||
async def fill_teasers(movies, known):
|
||||
todo = [m for m in movies if not known.get(m["id"])]
|
||||
for movie in movies:
|
||||
movie["teaser"] = known.get(movie["id"], "")
|
||||
if not todo:
|
||||
return 0
|
||||
|
||||
log.info("Erzeuge %s Teaser über MiniMax", len(todo))
|
||||
semaphore = asyncio.Semaphore(TEASER_CONCURRENCY)
|
||||
async with teaser.client() as http:
|
||||
texts = await asyncio.gather(
|
||||
*(teaser.generate(http, m, semaphore) for m in todo)
|
||||
)
|
||||
for movie, text in zip(todo, texts):
|
||||
movie["teaser"] = text
|
||||
return len(todo)
|
||||
|
||||
|
||||
async def resolve_canon():
|
||||
"""Kanon-Liste auf TMDB-IDs abbilden: {tmdb_id: (gruppe, position)}."""
|
||||
groups = await canon.resolve_all()
|
||||
places = {}
|
||||
for group_index, entries in enumerate(groups):
|
||||
for position, entry in enumerate(entries):
|
||||
if entry["hit"]:
|
||||
places.setdefault(entry["hit"]["id"], (group_index, position))
|
||||
return places
|
||||
|
||||
|
||||
async def run():
|
||||
log.info("Sync startet")
|
||||
canon_places = await resolve_canon()
|
||||
log.info("%s Kanon-Titel aufgelöst", len(canon_places))
|
||||
|
||||
async with tmdb.client() as http:
|
||||
listed = await tmdb.discover_all(http)
|
||||
netflix_ids = {m["id"] for m in listed}
|
||||
log.info("%s Filme bei Netflix DE gefunden", len(listed))
|
||||
wanted = sorted(netflix_ids | set(canon_places))
|
||||
movies = await fetch_details(http, wanted)
|
||||
|
||||
for movie in movies:
|
||||
movie["on_netflix"] = 1 if movie["id"] in netflix_ids else 0
|
||||
place = canon_places.get(movie["id"])
|
||||
if place:
|
||||
movie["canon_group"], movie["canon_pos"] = place
|
||||
|
||||
await dedupe_images(movies)
|
||||
|
||||
with db.connect() as conn:
|
||||
known_ids = db.existing_netflix_ids(conn)
|
||||
known = db.existing_teasers(conn)
|
||||
on_netflix = [m for m in movies if m["on_netflix"]]
|
||||
linked = await fill_netflix_links(on_netflix, known_ids)
|
||||
new_teasers = await fill_teasers(movies, known)
|
||||
|
||||
stamp = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
with db.connect() as conn:
|
||||
for movie in movies:
|
||||
movie["synced_at"] = stamp
|
||||
db.upsert(conn, movie)
|
||||
removed = db.drop_missing(conn, [m["id"] for m in movies])
|
||||
db.set_meta(conn, "last_sync", stamp)
|
||||
db.set_meta(conn, "movie_count", len(on_netflix))
|
||||
db.set_meta(conn, "canon_count", sum(1 for m in movies if m["canon_group"] is not None))
|
||||
|
||||
log.info(
|
||||
"Sync fertig: %s Filme (%s Netflix, %s Kanon), %s Netflix-Links, "
|
||||
"%s neue Teaser, %s entfernt",
|
||||
len(movies),
|
||||
len(on_netflix),
|
||||
sum(1 for m in movies if m["canon_group"] is not None),
|
||||
linked,
|
||||
new_teasers,
|
||||
removed,
|
||||
)
|
||||
return {
|
||||
"movies": len(movies),
|
||||
"netflix": len(on_netflix),
|
||||
"netflix_links": linked,
|
||||
"new_teasers": new_teasers,
|
||||
"removed": removed,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
|
||||
db.init()
|
||||
asyncio.run(run())
|
||||
98
backend/app/teaser.py
Normal file
98
backend/app/teaser.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import MINIMAX_KEY, MINIMAX_MODEL, MINIMAX_URL
|
||||
|
||||
THINK_BLOCK = re.compile(r"<think>.*?</think>", re.DOTALL)
|
||||
|
||||
SYSTEM = (
|
||||
"Du schreibst kurze deutsche Teaser für Horrorfilme. "
|
||||
"Antworte immer auf Deutsch, auch wenn die Vorlage englisch ist. "
|
||||
"Übersetze die Vorlage nicht, sondern formuliere neu. "
|
||||
"Genau zwei Sätze, zusammen höchstens 40 Wörter. "
|
||||
"Atmosphärisch und reißerisch, aber ohne Spoiler und ohne das Ende zu verraten. "
|
||||
"Kein Filmtitel im Text, keine Anführungszeichen, keine Einleitung. "
|
||||
"Gib nur den Teaser aus."
|
||||
)
|
||||
|
||||
|
||||
def build_prompt(movie):
|
||||
year = movie.get("year") or "unbekannt"
|
||||
overview = (movie.get("overview") or "").strip()
|
||||
if overview:
|
||||
return (
|
||||
f"Film: {movie['title']} ({year})\n"
|
||||
f"Inhaltsangabe: {overview}\n\n"
|
||||
"Schreibe den Teaser."
|
||||
)
|
||||
return (
|
||||
f"Film: {movie['title']} ({year})\n"
|
||||
"Es liegt keine Inhaltsangabe vor. Schreibe einen passenden, "
|
||||
"allgemein gehaltenen Horror-Teaser zu diesem Titel."
|
||||
)
|
||||
|
||||
|
||||
def clean(text):
|
||||
text = THINK_BLOCK.sub("", text)
|
||||
return " ".join(text.split()).strip('"„“ ')
|
||||
|
||||
|
||||
def fallback(movie):
|
||||
"""MiniMax nicht erreichbar: gekürzte TMDB-Synopsis."""
|
||||
overview = (movie.get("overview") or "").strip()
|
||||
if not overview:
|
||||
return ""
|
||||
sentences = re.split(r"(?<=[.!?])\s+", overview)
|
||||
return " ".join(sentences[:2])
|
||||
|
||||
|
||||
def build_payload(movie):
|
||||
return {
|
||||
"model": MINIMAX_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM},
|
||||
{"role": "user", "content": build_prompt(movie)},
|
||||
],
|
||||
# M3 denkt sonst über das Token-Limit hinaus und liefert nur den <think>-Block
|
||||
"reasoning_effort": "low",
|
||||
"max_tokens": 1500,
|
||||
"temperature": 0.8,
|
||||
}
|
||||
|
||||
|
||||
async def ask(http, movie, max_tokens):
|
||||
payload = {**build_payload(movie), "max_tokens": max_tokens}
|
||||
resp = await http.post(MINIMAX_URL, json=payload)
|
||||
resp.raise_for_status()
|
||||
return clean(resp.json()["choices"][0]["message"]["content"])
|
||||
|
||||
|
||||
def complete(text):
|
||||
"""Am Token-Limit bricht der Teaser mitten im Wort ab."""
|
||||
return bool(text) and text.rstrip().endswith((".", "!", "?", "…", '"'))
|
||||
|
||||
|
||||
async def generate(http, movie, semaphore):
|
||||
async with semaphore:
|
||||
try:
|
||||
text = await ask(http, movie, 1500)
|
||||
if not complete(text):
|
||||
# Denkt das Modell zu lang, bleibt vom Teaser nichts oder
|
||||
# nur ein angefangener Satz übrig
|
||||
retry = await ask(http, movie, 4000)
|
||||
if complete(retry) or not text:
|
||||
text = retry
|
||||
return text or fallback(movie)
|
||||
except (httpx.HTTPError, KeyError, IndexError):
|
||||
return fallback(movie)
|
||||
|
||||
|
||||
def client():
|
||||
return httpx.AsyncClient(
|
||||
headers={
|
||||
"Authorization": f"Bearer {MINIMAX_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=120.0,
|
||||
)
|
||||
117
backend/app/tmdb.py
Normal file
117
backend/app/tmdb.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import httpx
|
||||
|
||||
from .config import (
|
||||
GENRE_HORROR,
|
||||
LANGUAGE,
|
||||
PROVIDER_NETFLIX,
|
||||
REGION,
|
||||
TMDB_BASE,
|
||||
TMDB_TOKEN,
|
||||
)
|
||||
|
||||
HEADERS = {"Authorization": f"Bearer {TMDB_TOKEN}", "accept": "application/json"}
|
||||
|
||||
DISCOVER_PARAMS = {
|
||||
"with_watch_providers": PROVIDER_NETFLIX,
|
||||
"watch_region": REGION,
|
||||
"with_genres": GENRE_HORROR,
|
||||
"with_watch_monetization_types": "flatrate",
|
||||
"language": LANGUAGE,
|
||||
"include_adult": "false",
|
||||
# JustWatch hängt unbekannte Titel gelegentlich an die Netflix-ID eines
|
||||
# gleichnamigen bekannten Films. Betroffen waren ausschließlich Einträge
|
||||
# mit ein oder zwei Stimmen — "LIENZO" bekam die ID von "Die Leinwand".
|
||||
"vote_count.gte": 3,
|
||||
}
|
||||
|
||||
DETAIL_PARAMS = {
|
||||
"language": LANGUAGE,
|
||||
"append_to_response": "images,videos,translations,watch/providers",
|
||||
"include_image_language": "de,en,null",
|
||||
"include_video_language": "de,en",
|
||||
}
|
||||
|
||||
|
||||
def client():
|
||||
return httpx.AsyncClient(base_url=TMDB_BASE, headers=HEADERS, timeout=30.0)
|
||||
|
||||
|
||||
async def discover_page(http, page):
|
||||
resp = await http.get("/discover/movie", params={**DISCOVER_PARAMS, "page": page})
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def discover_all(http):
|
||||
"""Alle Netflix-DE-Horrorfilme durchpaginieren."""
|
||||
first = await discover_page(http, 1)
|
||||
movies = list(first["results"])
|
||||
for page in range(2, first["total_pages"] + 1):
|
||||
data = await discover_page(http, page)
|
||||
movies.extend(data["results"])
|
||||
return movies
|
||||
|
||||
|
||||
async def detail(http, movie_id):
|
||||
resp = await http.get(f"/movie/{movie_id}", params=DETAIL_PARAMS)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def pick_overview(data):
|
||||
"""Deutsche Synopsis, sonst englische aus den Translations."""
|
||||
if data.get("overview"):
|
||||
return data["overview"]
|
||||
for entry in data.get("translations", {}).get("translations", []):
|
||||
if entry.get("iso_639_1") == "en":
|
||||
text = entry.get("data", {}).get("overview")
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def pick_backdrops(data):
|
||||
"""Alle Szenenbilder. Duplikate wirft der Sync raus.
|
||||
|
||||
Ein gesetztes iso_639_1 heißt: auf dem Bild steht Text, meist der
|
||||
Filmtitel. Das sind Werbemotive, keine Szenen. Hat ein Film nur solche
|
||||
Bilder, nehmen wir sie trotzdem — sonst bliebe die Detailseite leer.
|
||||
"""
|
||||
backdrops = data.get("images", {}).get("backdrops", [])
|
||||
textfree = [b for b in backdrops if b.get("iso_639_1") is None]
|
||||
usable = textfree or backdrops
|
||||
ranked = sorted(
|
||||
usable,
|
||||
key=lambda b: (-(b.get("vote_average") or 0), -(b.get("width") or 0)),
|
||||
)
|
||||
return [b["file_path"] for b in ranked]
|
||||
|
||||
|
||||
def pick_watch_link(data):
|
||||
"""TMDBs Watch-Seite für Deutschland.
|
||||
|
||||
Sie führt zur echten Netflix-Titelseite. Eine Netflix-Suche nach dem
|
||||
TMDB-Titel geht ins Leere, sobald Netflix den Film anders nennt —
|
||||
"LIENZO" heißt dort "CANVAS".
|
||||
"""
|
||||
providers = data.get("watch/providers", {}).get("results", {})
|
||||
return (providers.get(REGION) or {}).get("link")
|
||||
|
||||
|
||||
def pick_trailer(data):
|
||||
"""Trailer bevorzugt, sonst Teaser. Sprache egal."""
|
||||
videos = [
|
||||
v for v in data.get("videos", {}).get("results", []) if v.get("site") == "YouTube"
|
||||
]
|
||||
if not videos:
|
||||
return None
|
||||
order = {"Trailer": 0, "Teaser": 1, "Clip": 2}
|
||||
|
||||
def rank(video):
|
||||
return (
|
||||
order.get(video.get("type"), 3),
|
||||
0 if video.get("iso_639_1") == "de" else 1,
|
||||
0 if video.get("official") else 1,
|
||||
)
|
||||
|
||||
return sorted(videos, key=rank)[0]["key"]
|
||||
6
backend/requirements.txt
Normal file
6
backend/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
httpx==0.28.1
|
||||
python-dotenv==1.0.1
|
||||
apscheduler==3.11.0
|
||||
pillow==11.1.0
|
||||
Reference in New Issue
Block a user