From 38d5a8cfaeb6acd6c5b2f9181e43e7328f860850 Mon Sep 17 00:00:00 2001 From: team3 Date: Wed, 12 Aug 2026 19:30:41 +0200 Subject: [PATCH] init --- .dockerignore | 9 + .env.example | 2 + .gitignore | 8 + Dockerfile | 26 + Makefile | 33 + README.md | 171 +++ backend/app/__init__.py | 0 backend/app/canon.py | 128 +++ backend/app/canon_list.py | 351 ++++++ backend/app/config.py | 25 + backend/app/db.py | 180 +++ backend/app/imagehash.py | 122 ++ backend/app/main.py | 83 ++ backend/app/netflix.py | 53 + backend/app/sync.py | 196 ++++ backend/app/teaser.py | 98 ++ backend/app/tmdb.py | 117 ++ backend/requirements.txt | 6 + docker-compose.yml | 32 + frontend/index.html | 12 + frontend/package-lock.json | 1380 +++++++++++++++++++++++ frontend/package.json | 17 + frontend/src/App.vue | 337 ++++++ frontend/src/components/MovieCard.vue | 109 ++ frontend/src/components/MovieDetail.vue | 383 +++++++ frontend/src/components/SeriesCard.vue | 200 ++++ frontend/src/images.js | 9 + frontend/src/main.js | 5 + frontend/src/style.css | 50 + frontend/vite.config.js | 15 + 30 files changed, 4157 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 backend/app/__init__.py create mode 100644 backend/app/canon.py create mode 100644 backend/app/canon_list.py create mode 100644 backend/app/config.py create mode 100644 backend/app/db.py create mode 100644 backend/app/imagehash.py create mode 100644 backend/app/main.py create mode 100644 backend/app/netflix.py create mode 100644 backend/app/sync.py create mode 100644 backend/app/teaser.py create mode 100644 backend/app/tmdb.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/components/MovieCard.vue create mode 100644 frontend/src/components/MovieDetail.vue create mode 100644 frontend/src/components/SeriesCard.vue create mode 100644 frontend/src/images.js create mode 100644 frontend/src/main.js create mode 100644 frontend/src/style.css create mode 100644 frontend/vite.config.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2716af9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.env +data/ +backend/.venv/ +backend/static/ +backend/horror.db +frontend/node_modules/ +frontend/dist/ +__pycache__/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9042f87 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +TMDB_TOKEN= +MINIMAX_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3416bcb --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +*.db +__pycache__/ +.venv/ +node_modules/ +frontend/dist/ +backend/static/ +data/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4010968 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..acde039 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..12873a6 --- /dev/null +++ b/README.md @@ -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. diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/canon.py b/backend/app/canon.py new file mode 100644 index 0000000..8c7d97a --- /dev/null +++ b/backend/app/canon.py @@ -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) diff --git a/backend/app/canon_list.py b/backend/app/canon_list.py new file mode 100644 index 0000000..bd26ecd --- /dev/null +++ b/backend/app/canon_list.py @@ -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)"], +] diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..7182e0b --- /dev/null +++ b/backend/app/config.py @@ -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" diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..e0bd7be --- /dev/null +++ b/backend/app/db.py @@ -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 diff --git a/backend/app/imagehash.py b/backend/app/imagehash.py new file mode 100644 index 0000000..9b2de2b --- /dev/null +++ b/backend/app/imagehash.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..e3e307d --- /dev/null +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/netflix.py b/backend/app/netflix.py new file mode 100644 index 0000000..c9f53f5 --- /dev/null +++ b/backend/app/netflix.py @@ -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 diff --git a/backend/app/sync.py b/backend/app/sync.py new file mode 100644 index 0000000..91552b4 --- /dev/null +++ b/backend/app/sync.py @@ -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()) diff --git a/backend/app/teaser.py b/backend/app/teaser.py new file mode 100644 index 0000000..93414c3 --- /dev/null +++ b/backend/app/teaser.py @@ -0,0 +1,98 @@ +import re + +import httpx + +from .config import MINIMAX_KEY, MINIMAX_MODEL, MINIMAX_URL + +THINK_BLOCK = re.compile(r".*?", 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 -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, + ) diff --git a/backend/app/tmdb.py b/backend/app/tmdb.py new file mode 100644 index 0000000..6dda74b --- /dev/null +++ b/backend/app/tmdb.py @@ -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"] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..f3aab0c --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a8d2422 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d961627 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Horror auf Netflix + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..06397e2 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1380 @@ +{ + "name": "horror-frontend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "horror-frontend", + "dependencies": { + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "vite": "^6.0.7" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz", + "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.41", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", + "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", + "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.41", + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-ssr": "3.5.41", + "@vue/shared": "3.5.41", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", + "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz", + "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz", + "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", + "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/runtime-core": "3.5.41", + "@vue/shared": "3.5.41", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz", + "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz", + "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", + "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-sfc": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/server-renderer": "3.5.41", + "@vue/shared": "3.5.41" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b73e6da --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..5d5721b --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,337 @@ + + + + + diff --git a/frontend/src/components/MovieCard.vue b/frontend/src/components/MovieCard.vue new file mode 100644 index 0000000..210aaa9 --- /dev/null +++ b/frontend/src/components/MovieCard.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/frontend/src/components/MovieDetail.vue b/frontend/src/components/MovieDetail.vue new file mode 100644 index 0000000..ee7f26e --- /dev/null +++ b/frontend/src/components/MovieDetail.vue @@ -0,0 +1,383 @@ + + + + + diff --git a/frontend/src/components/SeriesCard.vue b/frontend/src/components/SeriesCard.vue new file mode 100644 index 0000000..abce3d1 --- /dev/null +++ b/frontend/src/components/SeriesCard.vue @@ -0,0 +1,200 @@ + + + + + diff --git a/frontend/src/images.js b/frontend/src/images.js new file mode 100644 index 0000000..71b4c1c --- /dev/null +++ b/frontend/src/images.js @@ -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 +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..fe5bae3 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './style.css' + +createApp(App).mount('#app') diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..a5d5fba --- /dev/null +++ b/frontend/src/style.css @@ -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; +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..93e19ba --- /dev/null +++ b/frontend/vite.config.js @@ -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', + }, + }, +})