init
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.env
|
||||
data/
|
||||
backend/.venv/
|
||||
backend/static/
|
||||
backend/horror.db
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
__pycache__/
|
||||
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
TMDB_TOKEN=
|
||||
MINIMAX_KEY=
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.env
|
||||
*.db
|
||||
__pycache__/
|
||||
.venv/
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
backend/static/
|
||||
data/
|
||||
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
# Stage 1: Frontend bauen
|
||||
FROM node:20-alpine AS frontend
|
||||
WORKDIR /build
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm install
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Runtime — nur Python, das Frontend liegt fertig gebaut daneben
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
|
||||
COPY backend/requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/app ./app
|
||||
COPY --from=frontend /backend/static ./static
|
||||
|
||||
# Katalog und Teaser liegen im Volume, nicht im Image
|
||||
ENV DB_PATH=/data/horror.db
|
||||
RUN mkdir -p /data
|
||||
VOLUME /data
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
33
Makefile
Normal file
33
Makefile
Normal file
@@ -0,0 +1,33 @@
|
||||
.PHONY: install dev stop sync build prod logs down
|
||||
|
||||
install:
|
||||
cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||
cd frontend && npm install
|
||||
|
||||
dev:
|
||||
@echo "Backend: http://localhost:8000 Frontend: http://localhost:5173"
|
||||
@cd backend && .venv/bin/uvicorn app.main:app --reload --port 8000 &
|
||||
@cd frontend && npx vite --port 5173
|
||||
|
||||
stop:
|
||||
-@pkill -f "uvicorn app.main:app" 2>/dev/null
|
||||
-@pkill -f "vite --port 5173" 2>/dev/null
|
||||
@echo "gestoppt."
|
||||
|
||||
# Katalog holen und fehlende Teaser erzeugen (läuft im Betrieb täglich 4:30)
|
||||
sync:
|
||||
cd backend && .venv/bin/python -m app.sync
|
||||
|
||||
build:
|
||||
cd frontend && npm run build
|
||||
|
||||
# ── Produktion (auf dem Server ausführen, im Projektverzeichnis) ──────────────
|
||||
prod:
|
||||
docker compose up -d --build
|
||||
@echo "horror läuft: https://horror.marha.de"
|
||||
|
||||
logs:
|
||||
docker compose logs -f
|
||||
|
||||
down:
|
||||
docker compose down
|
||||
171
README.md
Normal file
171
README.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# Horrorfilme
|
||||
|
||||
Privater Katalog-Browser mit zwei Reitern:
|
||||
|
||||
- **Netflix** — alle Horrorfilme im Netflix-DE-Abo, aktuell 325.
|
||||
- **Kanon** — 194 Einträge aus anerkannten Bestenlisten, davon 28 Filmreihen.
|
||||
Zusammen 320 Filme, unabhängig davon, wo sie laufen.
|
||||
|
||||
Daten kommen von TMDB (Provider-Info stammt dort aus der JustWatch-Partnerschaft).
|
||||
Die deutschen Teaser schreibt MiniMax M3 beim Sync und landen in der SQLite-Datei.
|
||||
|
||||
## Einrichten
|
||||
|
||||
```bash
|
||||
cp .env.example .env # TMDB_TOKEN und MINIMAX_KEY eintragen
|
||||
make install
|
||||
```
|
||||
|
||||
## Sync
|
||||
|
||||
Holt den Katalog und erzeugt fehlende Teaser. Der erste Lauf dauert einige
|
||||
Minuten, weil für jeden Film ein Teaser generiert wird.
|
||||
|
||||
```bash
|
||||
make sync
|
||||
```
|
||||
|
||||
Teaser werden nur einmal erzeugt. Spätere Syncs schreiben nur für neue Filme.
|
||||
Im Betrieb läuft der Sync täglich um 4:30 von selbst.
|
||||
|
||||
## Entwickeln
|
||||
|
||||
```bash
|
||||
make dev # Backend :8000, Frontend :5173
|
||||
make stop
|
||||
```
|
||||
|
||||
Der Vite-Dev-Server leitet `/api` an den Backend-Port weiter.
|
||||
|
||||
## Produktiv
|
||||
|
||||
Auf dem Server im Projektverzeichnis. Läuft hinter Traefik im externen
|
||||
Docker-Netz `web`, wie die anderen Projekte.
|
||||
|
||||
```bash
|
||||
make prod # docker compose up -d --build
|
||||
make logs
|
||||
make down
|
||||
```
|
||||
|
||||
Die SQLite-Datei liegt im Bind-Mount `./data`, überlebt also jeden Rebuild.
|
||||
|
||||
Ohne Docker geht es auch:
|
||||
|
||||
```bash
|
||||
make build # Vue nach backend/static
|
||||
cd backend && .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
## Wie es läuft
|
||||
|
||||
```
|
||||
APScheduler (täglich 4:30)
|
||||
│
|
||||
▼
|
||||
TMDB /discover/movie ──► pro Film /movie/{id} (Bilder, Videos, Übersetzungen)
|
||||
│ │
|
||||
│ ▼
|
||||
│ MiniMax M3 schreibt den Teaser
|
||||
▼ │
|
||||
SQLite (backend/horror.db) ◄────────────────┘
|
||||
│
|
||||
▼
|
||||
FastAPI /api/movies ──► Vue-Grid, Detail-Overlay
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| Endpoint | Zweck |
|
||||
| --- | --- |
|
||||
| `GET /api/movies` | ganzer Katalog, das Frontend filtert clientseitig |
|
||||
| `GET /api/movies/{id}` | einzelner Film |
|
||||
| `GET /api/status` | Anzahl Filme, letzter Sync, läuft gerade einer |
|
||||
| `GET /api/health` | für den Docker-Healthcheck |
|
||||
| `POST /api/sync` | Sync von Hand anstoßen |
|
||||
|
||||
## Filmreihen
|
||||
|
||||
Gehört ein Film zu einer Reihe, zeigt die Detailansicht die anderen Teile —
|
||||
aber nur die, die selbst auf Netflix liegen. Grundlage ist TMDBs
|
||||
`belongs_to_collection`. Ein Klick springt direkt zum nächsten Teil.
|
||||
|
||||
Aktuell haben 86 Filme eine Reihe, 24 Reihen sind mit mehr als einem Teil
|
||||
vertreten. Die größte ist Resident Evil mit fünf Filmen.
|
||||
|
||||
## Der Kanon-Reiter
|
||||
|
||||
Die Liste steht in `app/canon_list.py`. Ein Eintrag ist ein Film oder eine
|
||||
Reihe. Reihen bekommen eine breitere Karte, die durch ihre Teile blättert —
|
||||
immer ein Teil vollständig sichtbar.
|
||||
|
||||
`app/canon.py` löst die deutschen Verleihtitel gegen TMDB auf. Das klappt bei
|
||||
309 von 320 Titeln allein über die Suche. Der Rest steht in `OVERRIDES` mit
|
||||
fester TMDB-ID, weil die Suche danebengriff:
|
||||
|
||||
- Ohne Jahresangabe liefert TMDB den jüngsten Titel zuerst. "Final Destination"
|
||||
traf deshalb "Bloodlines", "A Quiet Place" den zweiten Teil.
|
||||
- "Das Böse" ist auf Deutsch sowohl Phantasm als auch Amityville Horror.
|
||||
- "Us" traf "Forgive Us All" von 2025.
|
||||
|
||||
Die Vorlage führte "Black Christmas" und "Jessy – Die Treppe in den Tod"
|
||||
getrennt. Das ist derselbe Film, hier nur einmal.
|
||||
|
||||
Innerhalb einer Reihe wird nach Erscheinungsjahr sortiert, nicht nach der
|
||||
Reihenfolge der Vorlage. Die gruppiert das Conjuring-Universum nach
|
||||
Sub-Reihen — erst alle Conjuring, dann Nun, dann Annabelle.
|
||||
|
||||
## Woher der Netflix-Link kommt
|
||||
|
||||
Die TMDB-API nennt nur den Anbieternamen, nicht die Titel-ID. Die steht
|
||||
ausschließlich im HTML der Watch-Seite, URL-kodiert im JustWatch-Link.
|
||||
`app/netflix.py` liest sie dort aus und merkt sie sich in der Spalte
|
||||
`netflix_id`, damit spätere Syncs die Seite nicht erneut laden.
|
||||
|
||||
Der Abruf läuft seriell mit 1,5 Sekunden Pause. Bei dichteren Abrufen
|
||||
antwortet TMDB mit Status 200, aber ohne Anbieterblock — die ID fehlt dann
|
||||
stillschweigend. Filme ohne ID versucht der nächste Sync erneut.
|
||||
|
||||
Hat ein Film mehrere IDs, gewinnt die erste. Godzilla Minus One liegt bei
|
||||
Netflix in Farbe und in Schwarzweiß.
|
||||
|
||||
## Warum der Katalog bei drei Stimmen abschneidet
|
||||
|
||||
JustWatch ordnet unbekannte Titel gelegentlich der Netflix-ID eines
|
||||
gleichnamigen bekannten Films zu. TMDB übernimmt das ungeprüft. Der Film
|
||||
steht dann im Katalog, obwohl Netflix ihn nicht hat.
|
||||
|
||||
Nachgewiesen an fünf Fällen, alle nach demselben Muster:
|
||||
|
||||
| Eintrag | Stimmen | bekam die ID von |
|
||||
| --- | --- | --- |
|
||||
| CREEP (2014, 8 Min) | 2 | Creep (2014, 1678 Stimmen) |
|
||||
| Muerte, muerte, muerte | 2 | Bodies Bodies Bodies |
|
||||
| LIENZO | 1 | Die Leinwand / Canvas |
|
||||
| NOISE! | 1 | Noise |
|
||||
| The Roommate (2022) | 1 | The Roommate (2011) |
|
||||
|
||||
Nachweis über TMDBs Watch-Seiten: Dort steht die Netflix-ID im
|
||||
Weiterleitungslink. Zwei Filme mit derselben ID können nicht beide stimmen.
|
||||
Oberhalb von zwei Stimmen trat kein einziger Fall auf, deshalb die Grenze
|
||||
bei drei. Das kostet 11 Einträge von 336.
|
||||
|
||||
## Grenzen
|
||||
|
||||
- Verfügbarkeit hinkt Netflix 24–48 h hinterher. So oft liefert JustWatch an TMDB.
|
||||
- Es gibt keine Info, ob ein Film deutsche Synchronisation hat. TMDB kennt nur
|
||||
die Originalsprache, nicht die Tonspuren bei Netflix.
|
||||
- Der Netflix-Knopf führt bei 310 von 325 Filmen direkt auf die Titelseite.
|
||||
Die restlichen 15 landen auf TMDBs Watch-Seite, weil dort kein Netflix-Link
|
||||
hinterlegt ist. Der nächste Sync versucht sie erneut.
|
||||
- Die Szenenbilder sind TMDB-Backdrops. Der Sync wirft Duplikate raus, indem
|
||||
er die Bilder selbst vergleicht (dHash plus Farbhistogramm, siehe
|
||||
`app/imagehash.py`). Motive mit Titeltext fliegen ganz raus. Werbemotive
|
||||
ohne Text bleiben drin — die lassen sich nicht von Szenenfotos trennen.
|
||||
- 15 Filme haben gar keine Bilder, 46 keinen Trailer. Dann zeigt die
|
||||
Detailansicht entsprechend weniger.
|
||||
|
||||
## Attribution
|
||||
|
||||
This product uses the TMDB API but is not endorsed or certified by TMDB.
|
||||
Verfügbarkeitsdaten stammen von JustWatch.
|
||||
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
|
||||
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
services:
|
||||
horror:
|
||||
build:
|
||||
context: .
|
||||
container_name: horror
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
DB_PATH: /data/horror.db
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
# Traefik routet erst ab "healthy" — ohne dichten Start-Takt prüft Docker
|
||||
# erstmals nach `interval` und die Seite ist ~30-60s lang 404
|
||||
start_period: 30s
|
||||
start_interval: 2s
|
||||
networks:
|
||||
- web
|
||||
volumes:
|
||||
- ./data:/data
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.horrorapp.rule=Host(`horror.marha.de`)"
|
||||
- "traefik.http.routers.horrorapp.entrypoints=websecure"
|
||||
- "traefik.http.routers.horrorapp.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.horrorapp.loadbalancer.server.port=8000"
|
||||
|
||||
networks:
|
||||
web:
|
||||
external: true
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Horror auf Netflix</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1380
frontend/package-lock.json
generated
Normal file
1380
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
frontend/package.json
Normal file
17
frontend/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "horror-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
337
frontend/src/App.vue
Normal file
337
frontend/src/App.vue
Normal file
@@ -0,0 +1,337 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import MovieCard from './components/MovieCard.vue'
|
||||
import MovieDetail from './components/MovieDetail.vue'
|
||||
import SeriesCard from './components/SeriesCard.vue'
|
||||
|
||||
const movies = ref([])
|
||||
const status = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const search = ref('')
|
||||
const sort = ref('rating')
|
||||
const selected = ref(null)
|
||||
const activeTab = ref('netflix')
|
||||
|
||||
const netflixMovies = computed(() => movies.value.filter((m) => m.on_netflix))
|
||||
const canonMovies = computed(() =>
|
||||
movies.value.filter((m) => m.canon_group !== null)
|
||||
)
|
||||
|
||||
// Ein Kanon-Eintrag ist ein Film oder eine ganze Reihe. Reihen bleiben
|
||||
// zusammen, damit die Karte durch ihre Teile blättern kann.
|
||||
const canonGroups = computed(() => {
|
||||
const byGroup = new Map()
|
||||
for (const movie of canonMovies.value) {
|
||||
if (!byGroup.has(movie.canon_group)) byGroup.set(movie.canon_group, [])
|
||||
byGroup.get(movie.canon_group).push(movie)
|
||||
}
|
||||
return [...byGroup.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([group, parts]) => {
|
||||
// Erscheinungsreihenfolge, nicht die der Vorlage: die gruppiert das
|
||||
// Conjuring-Universum nach Sub-Reihen statt nach Jahr
|
||||
const sorted = parts.sort(
|
||||
(a, b) =>
|
||||
(a.release_date || '').localeCompare(b.release_date || '') ||
|
||||
a.canon_pos - b.canon_pos
|
||||
)
|
||||
// Die Karte startet beim stärksten Teil, und danach sortiert auch die
|
||||
// Kachelreihenfolge — sonst entscheidet ein schwacher Auftakt über
|
||||
// den Platz der ganzen Reihe
|
||||
let best = 0
|
||||
sorted.forEach((part, i) => {
|
||||
if (score(part) > score(sorted[best])) best = i
|
||||
})
|
||||
return { group, parts: sorted, best }
|
||||
})
|
||||
})
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.length },
|
||||
{ id: 'canon', label: 'Kanon', count: canonGroups.value.length },
|
||||
])
|
||||
|
||||
// Gewichtete Note: ohne sie stehen Filme mit 10.0 aus zwei Stimmen ganz oben.
|
||||
// Je weniger Stimmen, desto stärker zieht der Katalogschnitt die Note zu sich.
|
||||
const MIN_VOTES = 150
|
||||
|
||||
const average = computed(() => {
|
||||
const rated = movies.value.filter((m) => m.vote_count)
|
||||
if (!rated.length) return 0
|
||||
return rated.reduce((sum, m) => sum + m.vote_average, 0) / rated.length
|
||||
})
|
||||
|
||||
function score(movie) {
|
||||
const votes = movie.vote_count || 0
|
||||
return (
|
||||
(votes / (votes + MIN_VOTES)) * (movie.vote_average || 0) +
|
||||
(MIN_VOTES / (votes + MIN_VOTES)) * average.value
|
||||
)
|
||||
}
|
||||
|
||||
const SORTS = {
|
||||
rating: (a, b) => score(b) - score(a),
|
||||
newest: (a, b) => (b.year || 0) - (a.year || 0),
|
||||
oldest: (a, b) => (a.year || 9999) - (b.year || 9999),
|
||||
title: (a, b) => a.title.localeCompare(b.title, 'de'),
|
||||
}
|
||||
|
||||
function matches(movie, term) {
|
||||
return (
|
||||
movie.title.toLowerCase().includes(term) ||
|
||||
(movie.original_title || '').toLowerCase().includes(term)
|
||||
)
|
||||
}
|
||||
|
||||
const visible = computed(() => {
|
||||
const term = search.value.trim().toLowerCase()
|
||||
const list = term
|
||||
? netflixMovies.value.filter((m) => matches(m, term))
|
||||
: [...netflixMovies.value]
|
||||
return list.sort(SORTS[sort.value])
|
||||
})
|
||||
|
||||
// Im Kanon greift die Suche auf die ganze Reihe: wer "Saw" sucht, will den
|
||||
// Eintrag sehen, egal welcher Teil gerade vorne liegt.
|
||||
const visibleCanon = computed(() => {
|
||||
const term = search.value.trim().toLowerCase()
|
||||
const list = term
|
||||
? canonGroups.value.filter((g) => g.parts.some((m) => matches(m, term)))
|
||||
: [...canonGroups.value]
|
||||
return list.sort((a, b) =>
|
||||
SORTS[sort.value](a.parts[a.best], b.parts[b.best])
|
||||
)
|
||||
})
|
||||
|
||||
// Die Reihe zum geöffneten Film. Im Kanon ist das die Gruppe der Vorlage,
|
||||
// sonst TMDBs Collection — dort aber nur, was auch auf Netflix liegt.
|
||||
const series = computed(() => {
|
||||
const movie = selected.value
|
||||
if (!movie) return []
|
||||
|
||||
const group =
|
||||
movie.canon_group !== null
|
||||
? canonMovies.value.filter((m) => m.canon_group === movie.canon_group)
|
||||
: movie.collection_id
|
||||
? netflixMovies.value.filter((m) => m.collection_id === movie.collection_id)
|
||||
: []
|
||||
|
||||
if (group.length < 2) return []
|
||||
return [...group].sort((a, b) =>
|
||||
(a.release_date || '').localeCompare(b.release_date || '')
|
||||
)
|
||||
})
|
||||
|
||||
const currentCount = computed(() =>
|
||||
activeTab.value === 'netflix' ? visible.value.length : visibleCanon.value.length
|
||||
)
|
||||
|
||||
const lastSync = computed(() => {
|
||||
if (!status.value?.last_sync) return null
|
||||
return new Date(status.value.last_sync).toLocaleString('de-DE', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [moviesRes, statusRes] = await Promise.all([
|
||||
fetch('/api/movies'),
|
||||
fetch('/api/status'),
|
||||
])
|
||||
if (!moviesRes.ok) throw new Error('Filme konnten nicht geladen werden')
|
||||
movies.value = await moviesRes.json()
|
||||
status.value = await statusRes.json()
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<header class="top">
|
||||
<div class="brand">
|
||||
<h1>Horrorfilme</h1>
|
||||
<p v-if="lastSync" class="sync">Stand {{ lastSync }}</p>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input v-model="search" type="search" placeholder="Titel suchen…" />
|
||||
<select v-model="sort">
|
||||
<option value="rating">Beste Bewertung</option>
|
||||
<option value="newest">Neueste zuerst</option>
|
||||
<option value="oldest">Älteste zuerst</option>
|
||||
<option value="title">Titel A–Z</option>
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
:class="{ active: tab.id === activeTab }"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span class="count">{{ tab.count }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<p v-if="loading" class="note">Lade Katalog…</p>
|
||||
<p v-else-if="error" class="note error">{{ error }}</p>
|
||||
<p v-else-if="!movies.length" class="note">
|
||||
Noch keine Filme in der Datenbank. Der erste Sync läuft eventuell noch.
|
||||
</p>
|
||||
<p v-else-if="!currentCount" class="note">Nichts gefunden für „{{ search }}“.</p>
|
||||
|
||||
<div v-if="activeTab === 'netflix'" class="grid">
|
||||
<MovieCard
|
||||
v-for="movie in visible"
|
||||
:key="movie.id"
|
||||
:movie="movie"
|
||||
@open="selected = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid">
|
||||
<template v-for="entry in visibleCanon" :key="entry.group">
|
||||
<MovieCard
|
||||
v-if="entry.parts.length === 1"
|
||||
:movie="entry.parts[0]"
|
||||
@open="selected = $event"
|
||||
/>
|
||||
<SeriesCard
|
||||
v-else
|
||||
:parts="entry.parts"
|
||||
:start="entry.best"
|
||||
@open="selected = $event"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<MovieDetail
|
||||
v-if="selected"
|
||||
:movie="selected"
|
||||
:series="series"
|
||||
@close="selected = null"
|
||||
@open="selected = $event"
|
||||
/>
|
||||
|
||||
<footer>
|
||||
Daten von
|
||||
<a href="https://www.themoviedb.org" target="_blank" rel="noopener">TMDB</a>
|
||||
und JustWatch. This product uses the TMDB API but is not endorsed or certified
|
||||
by TMDB.
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1.25rem 3rem;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1.75rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.6rem 1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.tabs button:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tabs .count {
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.75rem;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.sync {
|
||||
margin-top: 0.3rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
/* Ohne min-width schiebt das Suchfeld auf schmalen Fenstern die Seite breit */
|
||||
.controls input {
|
||||
flex: 1 1 12rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
.note {
|
||||
padding: 2.5rem 0;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.note.error {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 3rem;
|
||||
padding-top: 1.25rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
</style>
|
||||
109
frontend/src/components/MovieCard.vue
Normal file
109
frontend/src/components/MovieCard.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { posterUrl } from '../images'
|
||||
|
||||
const props = defineProps({
|
||||
movie: { type: Object, required: true },
|
||||
})
|
||||
|
||||
defineEmits(['open'])
|
||||
|
||||
const poster = computed(() => posterUrl(props.movie.poster_path, 'w342'))
|
||||
const rating = computed(() =>
|
||||
props.movie.vote_average ? props.movie.vote_average.toFixed(1) : null
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="card" type="button" @click="$emit('open', movie)">
|
||||
<div class="poster">
|
||||
<img v-if="poster" :src="poster" :alt="movie.title" loading="lazy" />
|
||||
<span v-else class="placeholder">Kein Cover</span>
|
||||
<span v-if="rating" class="rating">{{ rating }}</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<h2>{{ movie.title }}</h2>
|
||||
<p class="meta">{{ movie.year || 'Jahr unbekannt' }}</p>
|
||||
<p class="teaser">{{ movie.teaser || movie.overview }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.card:focus-visible {
|
||||
transform: translateY(-4px);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.poster {
|
||||
position: relative;
|
||||
aspect-ratio: 2 / 3;
|
||||
background: #1c1c22;
|
||||
}
|
||||
|
||||
.poster img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100%;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.rating {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
padding: 0.15rem 0.45rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.teaser {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
line-clamp: 4;
|
||||
overflow: hidden;
|
||||
color: #b9b9c6;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
</style>
|
||||
383
frontend/src/components/MovieDetail.vue
Normal file
383
frontend/src/components/MovieDetail.vue
Normal file
@@ -0,0 +1,383 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { backdropUrl, posterUrl } from '../images'
|
||||
|
||||
const props = defineProps({
|
||||
movie: { type: Object, required: true },
|
||||
series: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'open'])
|
||||
|
||||
const poster = computed(() => posterUrl(props.movie.poster_path, 'w342'))
|
||||
const shots = computed(() =>
|
||||
(props.movie.images || []).map((path) => backdropUrl(path, 'w780'))
|
||||
)
|
||||
const trailer = computed(() =>
|
||||
props.movie.trailer_key
|
||||
? `https://www.youtube-nocookie.com/embed/${props.movie.trailer_key}`
|
||||
: null
|
||||
)
|
||||
const runtime = computed(() =>
|
||||
props.movie.runtime ? `${props.movie.runtime} Min.` : null
|
||||
)
|
||||
|
||||
function onKey(event) {
|
||||
if (event.key === 'Escape') emit('close')
|
||||
}
|
||||
|
||||
function openPart(part) {
|
||||
emit('open', part)
|
||||
// sonst startet der nächste Teil dort, wo man gerade gescrollt hat
|
||||
document.querySelector('.backdrop')?.scrollTo({ top: 0 })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKey)
|
||||
document.body.style.overflow = 'hidden'
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="backdrop" @click.self="$emit('close')">
|
||||
<article class="sheet">
|
||||
<button class="close" type="button" aria-label="Schließen" @click="$emit('close')">
|
||||
×
|
||||
</button>
|
||||
|
||||
<header>
|
||||
<img v-if="poster" class="poster" :src="poster" :alt="movie.title" />
|
||||
<div class="head-text">
|
||||
<h2>{{ movie.title }}</h2>
|
||||
<p v-if="movie.original_title !== movie.title" class="original">
|
||||
{{ movie.original_title }}
|
||||
</p>
|
||||
<p class="meta">
|
||||
<span v-if="movie.year">{{ movie.year }}</span>
|
||||
<span v-if="runtime">{{ runtime }}</span>
|
||||
<span v-if="movie.vote_average">
|
||||
★ {{ movie.vote_average.toFixed(1) }} ({{ movie.vote_count }})
|
||||
</span>
|
||||
</p>
|
||||
<p class="genres">{{ (movie.genres || []).join(' · ') }}</p>
|
||||
<p v-if="movie.teaser" class="teaser">{{ movie.teaser }}</p>
|
||||
<div class="links">
|
||||
<a
|
||||
v-if="movie.on_netflix"
|
||||
class="netflix"
|
||||
:href="movie.netflix_url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Zu Netflix
|
||||
</a>
|
||||
<a
|
||||
v-if="movie.imdb_id"
|
||||
class="imdb"
|
||||
:href="`https://www.imdb.com/title/${movie.imdb_id}/`"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
IMDb
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section v-if="series.length">
|
||||
<h3>{{ movie.collection_name || 'Die Reihe' }}</h3>
|
||||
<div class="series">
|
||||
<button
|
||||
v-for="part in series"
|
||||
:key="part.id"
|
||||
class="part"
|
||||
:class="{ current: part.id === movie.id }"
|
||||
type="button"
|
||||
@click="openPart(part)"
|
||||
>
|
||||
<img
|
||||
v-if="posterUrl(part.poster_path, 'w154')"
|
||||
:src="posterUrl(part.poster_path, 'w154')"
|
||||
:alt="part.title"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span v-else class="no-poster">Kein Cover</span>
|
||||
<span class="part-title">{{ part.title }}</span>
|
||||
<span class="part-year">{{ part.year }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="movie.overview">
|
||||
<h3>Handlung</h3>
|
||||
<p class="overview">{{ movie.overview }}</p>
|
||||
</section>
|
||||
|
||||
<section v-if="trailer">
|
||||
<h3>Trailer</h3>
|
||||
<div class="video">
|
||||
<iframe
|
||||
:src="trailer"
|
||||
:title="`Trailer zu ${movie.title}`"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
</section>
|
||||
<p v-else class="no-trailer">Für diesen Film liegt kein Trailer vor.</p>
|
||||
|
||||
<section v-if="shots.length">
|
||||
<h3>Bilder</h3>
|
||||
<div class="shots">
|
||||
<img
|
||||
v-for="(shot, index) in shots"
|
||||
:key="shot"
|
||||
:src="shot"
|
||||
:alt="`${movie.title} – Szenenbild ${index + 1}`"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
overflow-y: auto;
|
||||
padding: 2rem 1rem;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.sheet {
|
||||
position: relative;
|
||||
width: min(880px, 100%);
|
||||
height: fit-content;
|
||||
padding: 1.75rem;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.85rem;
|
||||
color: var(--muted);
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.poster {
|
||||
flex-shrink: 0;
|
||||
width: 160px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.head-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
h2 {
|
||||
padding-right: 2rem;
|
||||
font-size: 1.6rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.original {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.85rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.genres {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.teaser {
|
||||
color: #cfcfda;
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.netflix,
|
||||
.imdb {
|
||||
padding: 0.55rem 1.1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.netflix {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.imdb {
|
||||
color: #111;
|
||||
background: #f5c518;
|
||||
}
|
||||
|
||||
.netflix:hover,
|
||||
.imdb:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 0.7rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.overview {
|
||||
color: #c4c4d0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.series {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.part {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
gap: 0.3rem;
|
||||
width: 110px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.part img,
|
||||
.part .no-poster {
|
||||
width: 110px;
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.part .no-poster {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.part:hover img,
|
||||
.part:focus-visible img {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.part.current img {
|
||||
border-color: var(--accent);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.part.current .part-title {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.part-title {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.part-year {
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.shots {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.shots img {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.video {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.video iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.no-trailer {
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.poster {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.shots {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
200
frontend/src/components/SeriesCard.vue
Normal file
200
frontend/src/components/SeriesCard.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { posterUrl } from '../images'
|
||||
|
||||
const props = defineProps({
|
||||
parts: { type: Array, required: true },
|
||||
// Die Karte öffnet mit dem stärksten Teil, nicht mit dem ersten
|
||||
start: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
defineEmits(['open'])
|
||||
|
||||
const index = ref(props.start)
|
||||
const current = computed(() => props.parts[index.value])
|
||||
const poster = computed(() => posterUrl(current.value.poster_path, 'w342'))
|
||||
const rating = computed(() =>
|
||||
current.value.vote_average ? current.value.vote_average.toFixed(1) : null
|
||||
)
|
||||
|
||||
function step(delta) {
|
||||
const count = props.parts.length
|
||||
index.value = (index.value + delta + count) % count
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="card">
|
||||
<button class="poster" type="button" @click="$emit('open', current)">
|
||||
<img v-if="poster" :src="poster" :alt="current.title" loading="lazy" />
|
||||
<span v-else class="placeholder">Kein Cover</span>
|
||||
<span v-if="rating" class="rating">{{ rating }}</span>
|
||||
</button>
|
||||
|
||||
<div class="nav">
|
||||
<button type="button" aria-label="Vorheriger Teil" @click="step(-1)">‹</button>
|
||||
<div class="dots">
|
||||
<button
|
||||
v-for="(part, i) in parts"
|
||||
:key="part.id"
|
||||
type="button"
|
||||
class="dot"
|
||||
:class="{ on: i === index }"
|
||||
:title="part.title"
|
||||
@click="index = i"
|
||||
></button>
|
||||
</div>
|
||||
<button type="button" aria-label="Nächster Teil" @click="step(1)">›</button>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<p class="counter">Teil {{ index + 1 }} von {{ parts.length }}</p>
|
||||
<h2 @click="$emit('open', current)">{{ current.title }}</h2>
|
||||
<p class="meta">{{ current.year || 'Jahr unbekannt' }}</p>
|
||||
<p class="teaser">{{ current.teaser || current.overview }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.poster {
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: 0;
|
||||
aspect-ratio: 2 / 3;
|
||||
background: #1c1c22;
|
||||
}
|
||||
|
||||
.poster img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100%;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.rating {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
padding: 0.15rem 0.45rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
gap: 0.35rem;
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.counter {
|
||||
color: var(--accent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
line-height: 1.3;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
h2:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.teaser {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
line-clamp: 4;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
color: #b9b9c6;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.nav > button {
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
flex-shrink: 0;
|
||||
color: var(--muted);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.nav > button:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.dots {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.28rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
padding: 0;
|
||||
background: var(--line);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.dot.on {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.dot:hover {
|
||||
background: var(--muted);
|
||||
}
|
||||
</style>
|
||||
9
frontend/src/images.js
Normal file
9
frontend/src/images.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const BASE = 'https://image.tmdb.org/t/p'
|
||||
|
||||
export function posterUrl(path, size = 'w500') {
|
||||
return path ? `${BASE}/${size}${path}` : null
|
||||
}
|
||||
|
||||
export function backdropUrl(path, size = 'w780') {
|
||||
return path ? `${BASE}/${size}${path}` : null
|
||||
}
|
||||
5
frontend/src/main.js
Normal file
5
frontend/src/main.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
50
frontend/src/style.css
Normal file
50
frontend/src/style.css
Normal file
@@ -0,0 +1,50 @@
|
||||
:root {
|
||||
--bg: #0a0a0c;
|
||||
--bg-soft: #141419;
|
||||
--line: #26262e;
|
||||
--text: #ececf1;
|
||||
--muted: #8b8b99;
|
||||
--accent: #e50914;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.85rem;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
15
frontend/vite.config.js
Normal file
15
frontend/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
outDir: '../backend/static',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user