This commit is contained in:
team3
2026-08-12 19:30:41 +02:00
commit 38d5a8cfae
30 changed files with 4157 additions and 0 deletions

0
backend/app/__init__.py Normal file
View File

128
backend/app/canon.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]