commit 526b0353e7a27ee75e5d79bc519b3d65de8f7d08
Author: SimolZimol <70102430+SimolZimol@users.noreply.github.com>
Date: Fri Aug 7 20:48:08 2026 +0200
new file: .env.example
new file: .gitignore
new file: Dockerfile
new file: README.md
new file: app.py
new file: app/__init__.py
new file: app/game.py
new file: app/models.py
new file: app/routes.py
new file: app/seed.py
new file: data/movies.json
new file: docker-compose.yml
new file: requirements.txt
new file: static/css/style.css
new file: static/js/game.js
new file: static/js/player.js
new file: templates/index.html
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..544de23
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,15 @@
+# Filmdle Umgebungsvariablen (Coolify / docker-compose)
+# PostgreSQL
+DB_HOST=localhost
+DB_PORT=5432
+DB_USER=filmdle
+DB_PASSWORD=changeme
+DB_DATABASE=filmdle
+
+# Flask
+SECRET_KEY=change-me-to-a-long-random-string
+
+# Spiel
+MAX_ATTEMPTS=10
+# Globale Uhrzeit (UTC) fuer den Taeglichen Reset / neue Tages-Filmwahl um 09:00 UTC
+DAILY_RESET_UTC=9
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9424a3f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+__pycache__/
+*.pyc
+.venv/
+venv/
+.env
+*.log
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..bac0972
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,32 @@
+# Basis-Image mit Python
+FROM python:3.11-slim
+
+# Arbeitsverzeichnis erstellen
+WORKDIR /app
+
+# Port für Gunicorn
+EXPOSE 8000
+
+# Kopiere die requirements-Datei und installiere die Abhängigkeiten
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+# Kopiere den gesamten Projektinhalt in das Arbeitsverzeichnis
+COPY . .
+
+# Umgebungsvariablen von Coolify / docker-compose übernehmen (zur Laufzeit)
+ENV DB_HOST=$DB_HOST
+ENV DB_PORT=$DB_PORT
+ENV DB_USER=$DB_USER
+ENV DB_PASSWORD=$DB_PASSWORD
+ENV DB_DATABASE=$DB_DATABASE
+ENV SECRET_KEY=$SECRET_KEY
+ENV MAX_ATTEMPTS=$MAX_ATTEMPTS
+ENV DAILY_RESET_UTC=$DAILY_RESET_UTC
+
+# Nicht-root Benutzer aus Sicherheitsgründen
+RUN useradd --create-home appuser && chown -R appuser:appuser /app
+USER appuser
+
+# Startbefehl (Production: Gunicorn)
+CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120", "app:app"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..90dac63
--- /dev/null
+++ b/README.md
@@ -0,0 +1,60 @@
+# Filmdle – Film-Quiz
+
+Ein "Rate den Film anhand seiner Eigenschaften"-Quiz, inspiriert von Gamedle's *Guess*.
+Du bekommst einen geheimen Zielfilm und tippst ueber eine Suchleiste (mit Autocomplete).
+Jeder Tipp zeigt eine Zeile mit 9 Spalten, die farblich zeigen, wie nah du dran bist
+(grün = passt, gelb = teilweise, rot = passt nicht). Bei Jahr und Budget zeigen die
+Pfeile ▲/▼ an, ob dein Tipp zu früh oder zu spät liegt.
+
+## Modi
+- **Täglich**: Derselbe Film für alle pro Tag (wird um 09:00 UTC gewechselt).
+- **Unbegrenzt**: Zufälliger Film pro Runde, so oft du willst.
+
+Für beide Modi werden getrennte Sieg- und Niederlagen-Str eaks gespeichert
+(pro Spielername, in PostgreSQL).
+
+## Technologie
+- Flask + SQLAlchemy (PostgreSQL)
+- Gunicorn im Docker-Container, lauffähig auf Coolify
+
+## Lokale Entwicklung
+
+Voraussetzung: Python 3.11+, Docker (für Postgres).
+
+```bash
+# 1. Postgres starten
+docker compose up -d db
+
+# 2. Python venv + Abhängigkeiten
+python -m venv .venv
+.venv\Scripts\activate # Windows
+pip install -r requirements.txt
+
+# 3. Umgebungsvariablen (lokal)
+copy .env.example .env # Werte anpassen (DB_USER/DB_PASSWORD/DB_DATABASE = filmdle)
+
+# 4. App starten (laden der .env wird automatisch via python-dotenv)
+python app.py
+```
+
+Die App läuft dann auf http://localhost:8000
+
+## Datenbank-Schema
+
+Die Tabellen werden beim ersten Start automatisch angelegt und mit den festen
+Referenzlisten (Plattformen, Genres, Studios, Universen) sowie den Filmen aus
+`data/movies.json` befüllt (idempotent).
+
+Neue Filme einfach als Einträge in `data/movies.json` ergänzen (alle 9 Attribute
+inkl. Plattformen, FSK, Studio, Budget, Universum) und Werte müssen aus den
+festen Listen in `app/seed.py` stammen.
+
+## Deployment auf Coolify
+
+1. Repo in Coolify einbinden.
+2. Build-Pack: `Dockerfile` verwenden.
+3. Eine PostgreSQL-Ressource anlegen und folgende Umgebungsvariablen setzen:
+ - `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_DATABASE`
+ - `SECRET_KEY` (langer Zufallswert)
+ - `MAX_ATTEMPTS` (optional, Standard 10)
+ - `DAILY_RESET_UTC` (optional, Standard 9 = 09:00 UTC)
diff --git a/app.py b/app.py
new file mode 100644
index 0000000..77e8c4f
--- /dev/null
+++ b/app.py
@@ -0,0 +1,6 @@
+from app import create_app
+
+app = create_app()
+
+if __name__ == "__main__":
+ app.run(host="0.0.0.0", port=8000, debug=True)
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..28b6499
--- /dev/null
+++ b/app/__init__.py
@@ -0,0 +1,39 @@
+import os
+
+from flask import Flask
+from flask_sqlalchemy import SQLAlchemy
+
+db = SQLAlchemy()
+
+
+def create_app():
+ app = Flask(__name__)
+
+ # Konfiguration aus Umgebungsvariablen (Coolify)
+ app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-key")
+ app.config["MAX_ATTEMPTS"] = int(os.environ.get("MAX_ATTEMPTS", "10"))
+ app.config["DAILY_RESET_UTC"] = int(os.environ.get("DAILY_RESET_UTC", "9"))
+
+ db_host = os.environ.get("DB_HOST", "localhost")
+ db_port = os.environ.get("DB_PORT", "5432")
+ db_user = os.environ.get("DB_USER", "filmdle")
+ db_password = os.environ.get("DB_PASSWORD", "")
+ db_name = os.environ.get("DB_DATABASE", "filmdle")
+
+ app.config["SQLALCHEMY_DATABASE_URI"] = (
+ f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
+ )
+ app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
+
+ db.init_app(app)
+
+ from . import routes # noqa: F401
+
+ with app.app_context():
+ db.create_all()
+ # Seed (idempotent) beim Start, damit die App sofort funktioniert
+ from .seed import seed_all
+
+ seed_all()
+
+ return app
diff --git a/app/game.py b/app/game.py
new file mode 100644
index 0000000..ac149d8
--- /dev/null
+++ b/app/game.py
@@ -0,0 +1,202 @@
+import hashlib
+import random
+from datetime import date, datetime, time, timedelta
+
+from . import db
+from .models import Movie, Player, Session, Universe
+
+# Statusklassen fuer die Zellen
+GREEN = "green"
+YELLOW = "yellow"
+RED = "red"
+
+
+def get_max_attempts():
+ from flask import current_app
+ return current_app.config.get("MAX_ATTEMPTS", 10)
+
+
+def _game_date(today=None):
+ """Ermittelt den 'Spieltag' basierend auf der globalen Reset-Uhrzeit (UTC)."""
+ from flask import current_app
+ reset_hour = current_app.config.get("DAILY_RESET_UTC", 9)
+ now = today or datetime.utcnow()
+ game_date = now.date()
+ # Vor der Resetzeit gilt noch der Vortag
+ if now.time() < time(reset_hour % 24, 0, 0):
+ game_date = game_date - timedelta(days=1)
+ return game_date
+
+
+def daily_target(game_date=None):
+ """Bestimmt deterministisch den taeglichen Ziel-Film (fuer alle gleich)."""
+ game_date = game_date or _game_date()
+ movies = Movie.query.order_by(Movie.id).all()
+ if not movies:
+ return None
+ idx = int(hashlib.sha256(str(game_date).encode()).hexdigest(), 16) % len(movies)
+ return movies[idx]
+
+
+def random_target():
+ movies = Movie.query.order_by(Movie.id).all()
+ if not movies:
+ return None
+ return random.choice(movies)
+
+
+def get_or_create_player(nickname):
+ player = Player.query.filter_by(nickname=nickname).first()
+ if player is None:
+ player = Player(nickname=nickname)
+ db.session.add(player)
+ db.session.commit()
+ return player
+
+
+def _series(normalized_title):
+ """Erstellt eine Normalisierung fuer Titel-Vergleiche (nur Buchstaben, klein)."""
+ return "".join(ch for ch in normalized_title.lower() if ch.isalnum())
+
+
+def match_platforms(guess_movie, target_movie):
+ guess = set(p.name for p in guess_movie.platforms)
+ target = set(p.name for p in target_movie.platforms)
+ if guess == target:
+ return GREEN
+ # gelb: mindestens eine gemeinsame Platform
+ if guess & target:
+ return YELLOW
+ return RED
+
+
+def match_genres(guess_movie, target_movie):
+ guess = set(g.name for g in guess_movie.genres)
+ target = set(g.name for g in target_movie.genres)
+ if guess == target:
+ return GREEN
+ if guess & target:
+ return YELLOW
+ return RED
+
+
+def match_year(guess_year, target_year):
+ if guess_year == target_year:
+ return GREEN, "equal"
+ if guess_year < target_year:
+ return YELLOW, "up" # Ziel liegt hoeher (spaeter)
+ return YELLOW, "down" # Ziel liegt tiefer (frueher)
+
+
+def match_fsk(guess_fsk, target_fsk):
+ if guess_fsk == target_fsk:
+ return GREEN
+ if guess_fsk is None or target_fsk is None:
+ return YELLOW if guess_fsk == target_fsk else RED
+ return RED
+
+
+def match_director(guess_director, target_director):
+ if not guess_director or not target_director:
+ return GREEN if guess_director == target_director else RED
+ if _series(guess_director) == _series(target_director):
+ return GREEN
+ return RED
+
+
+def match_studio(guess_studio, target_studio):
+ if (guess_studio or "").lower() == (target_studio or "").lower():
+ return GREEN
+ return RED
+
+
+def match_budget(guess_budget, target_budget):
+ if guess_budget is None or target_budget is None:
+ return RED if guess_budget != target_budget else GREEN
+ if guess_budget == target_budget:
+ return GREEN, "equal"
+ if guess_budget < target_budget:
+ return YELLOW, "up"
+ return YELLOW, "down"
+
+
+def match_universe(guess_universe, target_universe):
+ # Fuer beide None => green (kein Universum)
+ if (guess_universe or "") == (target_universe or ""):
+ return GREEN
+ if not guess_universe or not target_universe:
+ return RED
+ return RED
+
+
+def compare(guess_movie, target_movie):
+ """Vergleicht einen Tipp mit dem Ziel-Film und liefert die 9 Zellen."""
+ ys, ydir = match_year(guess_movie.year, target_movie.year)
+ bs, bdir = match_budget(guess_movie.budget, target_movie.budget)
+
+ return {
+ "title": guess_movie.title,
+ "platforms": {"status": match_platforms(guess_movie, target_movie), "value": sorted(p.name for p in guess_movie.platforms)},
+ "genres": {"status": match_genres(guess_movie, target_movie), "value": sorted(g.name for g in guess_movie.genres)},
+ "year": {"status": ys, "value": guess_movie.year, "direction": ydir, "diff": target_movie.year - guess_movie.year},
+ "fsk": {"status": match_fsk(guess_movie.fsk, target_movie.fsk), "value": guess_movie.fsk},
+ "studio": {"status": match_studio(guess_movie.studio.name if guess_movie.studio else None, target_movie.studio.name if target_movie.studio else None), "value": guess_movie.studio.name if guess_movie.studio else None},
+ "director": {"status": match_director(guess_movie.director, target_movie.director), "value": guess_movie.director or None},
+ "budget": {"status": bs, "value": guess_movie.budget, "direction": bdir, "diff": (target_movie.budget - guess_movie.budget) if guess_movie.budget is not None and target_movie.budget is not None else None},
+ "universe": {"status": match_universe(guess_movie.universe.name if guess_movie.universe else None, target_movie.universe.name if target_movie.universe else None), "value": guess_movie.universe.name if guess_movie.universe else None},
+ }
+
+
+def is_correct(compared):
+ """Prueft ob alle Zellen gruen sind (== Ziel gefunden)."""
+ return all(cell["status"] == GREEN for cell in compared.values() if cell is not compared["title"])
+
+
+def finish_session(session, won):
+ """Markiert eine Runde als gewonnen/verloren und aktualisiert den Str eak."""
+ session.status = "won" if won else "lost"
+ session.won = won
+ db.session.commit()
+
+
+def _streak_for(player, mode, won):
+ """Berechnet den aktuellen Str eak (Sieg bzw. Niederlage) fuer Spieler+Modus."""
+ sessions = (
+ Session.query
+ .filter_by(player_id=player.id, mode=mode)
+ .order_by(Session.id.desc())
+ .all()
+ )
+ count = 0
+ for s in sessions:
+ if s.won is None:
+ continue
+ if s.won == won:
+ count += 1
+ else:
+ break
+ return count
+
+
+def get_streaks(player_id):
+ """Liefert getrennte Sieg-/Niederlagen-Str eaks fuer Tages- und Unbegrenzt-Modus."""
+ player = Player.query.get(player_id)
+ if player is None:
+ return None
+ return {
+ "daily": {
+ "win": _streak_for(player, "daily", True),
+ "loss": _streak_for(player, "daily", False),
+ },
+ "unlimited": {
+ "win": _streak_for(player, "unlimited", True),
+ "loss": _streak_for(player, "unlimited", False),
+ },
+ }
+
+
+def existing_daily_session(player_id, game_date):
+ """Gibt eine vorhandene, abgeschlossene Tages-Runde zurueck (falls vorhanden)."""
+ return Session.query.filter_by(
+ player_id=player_id, mode="daily", date=game_date
+ ).order_by(Session.id.desc()).first()
diff --git a/app/models.py b/app/models.py
new file mode 100644
index 0000000..8102d63
--- /dev/null
+++ b/app/models.py
@@ -0,0 +1,85 @@
+from datetime import datetime
+
+from . import db
+
+# ---- Referenz-/Nachschlagetabellen (feste Liste) ----
+
+movie_genres = db.Table(
+ "movie_genres",
+ db.Column("movie_id", db.Integer, db.ForeignKey("movies.id"), primary_key=True),
+ db.Column("genre_id", db.Integer, db.ForeignKey("genres.id"), primary_key=True),
+)
+
+movie_platforms = db.Table(
+ "movie_platforms",
+ db.Column("movie_id", db.Integer, db.ForeignKey("movies.id"), primary_key=True),
+ db.Column("platform_id", db.Integer, db.ForeignKey("platforms.id"), primary_key=True),
+)
+
+
+class Genre(db.Model):
+ __tablename__ = "genres"
+ id = db.Column(db.Integer, primary_key=True)
+ name = db.Column(db.String(60), unique=True, nullable=False)
+
+
+class Platform(db.Model):
+ __tablename__ = "platforms"
+ id = db.Column(db.Integer, primary_key=True)
+ name = db.Column(db.String(60), unique=True, nullable=False)
+
+
+class Studio(db.Model):
+ __tablename__ = "studios"
+ id = db.Column(db.Integer, primary_key=True)
+ name = db.Column(db.String(60), unique=True, nullable=False)
+
+
+class Universe(db.Model):
+ __tablename__ = "universes"
+ id = db.Column(db.Integer, primary_key=True)
+ name = db.Column(db.String(60), unique=True, nullable=False)
+
+
+# ---- Haupttabelle ----
+
+class Movie(db.Model):
+ __tablename__ = "movies"
+ id = db.Column(db.Integer, primary_key=True)
+ title = db.Column(db.String(200), nullable=False, index=True)
+ year = db.Column(db.Integer, nullable=False)
+ fsk = db.Column(db.Integer, nullable=True) # 0, 6, 12, 16, 18
+ studio_id = db.Column(db.Integer, db.ForeignKey("studios.id"), nullable=True)
+ director = db.Column(db.String(120), nullable=True)
+ budget = db.Column(db.Integer, nullable=True) # in USD
+ universe_id = db.Column(db.Integer, db.ForeignKey("universes.id"), nullable=True)
+
+ studio = db.relationship("Studio")
+ universe = db.relationship("Universe")
+ genres = db.relationship("Genre", secondary=movie_genres, lazy="joined")
+ platforms = db.relationship("Platform", secondary=movie_platforms, lazy="joined")
+
+
+# ---- Spieler & Runden ----
+
+class Player(db.Model):
+ __tablename__ = "players"
+ id = db.Column(db.Integer, primary_key=True)
+ nickname = db.Column(db.String(60), unique=True, nullable=False)
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
+
+
+class Session(db.Model):
+ __tablename__ = "sessions"
+ id = db.Column(db.Integer, primary_key=True)
+ player_id = db.Column(db.Integer, db.ForeignKey("players.id"), nullable=False)
+ mode = db.Column(db.String(20), nullable=False) # 'daily' | 'unlimited'
+ target_movie_id = db.Column(db.Integer, db.ForeignKey("movies.id"), nullable=False)
+ status = db.Column(db.String(20), nullable=False, default="in_progress") # won|lost|in_progress
+ attempts_used = db.Column(db.Integer, nullable=False, default=0)
+ won = db.Column(db.Boolean, nullable=True) # True won / False lost / None in-progress
+ date = db.Column(db.Date, nullable=True) # nur fuer 'daily'
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
+
+ player = db.relationship("Player")
+ target = db.relationship("Movie")
diff --git a/app/routes.py b/app/routes.py
new file mode 100644
index 0000000..0db082f
--- /dev/null
+++ b/app/routes.py
@@ -0,0 +1,226 @@
+from flask import Blueprint, jsonify, render_template, request
+
+from . import db
+from .game import (
+ _game_date, compare, daily_target, existing_daily_session, finish_session,
+ get_max_attempts, get_or_create_player, get_streaks, is_correct, random_target,
+)
+from .models import Movie, Player, Session
+
+bp = Blueprint("main", __name__)
+
+
+# ---------- Seiten ----------
+
+@bp.route("/")
+def index():
+ return render_template("index.html", mode="daily")
+
+
+@bp.route("/unlimited")
+def unlimited():
+ return render_template("index.html", mode="unlimited")
+
+
+# ---------- API ----------
+
+@bp.route("/api/player", methods=["POST"])
+def api_player():
+ data = request.get_json(silent=True) or {}
+ nickname = (data.get("nickname") or "").strip()
+ if not nickname:
+ return jsonify({"error": "nickname required"}), 400
+ player = get_or_create_player(nickname)
+ return jsonify({
+ "player": {"id": player.id, "nickname": player.nickname},
+ "streaks": get_streaks(player.id),
+ })
+
+
+@bp.route("/api/search")
+def api_search():
+ q = (request.args.get("q") or "").strip().lower()
+ if not q:
+ return jsonify([])
+ movies = Movie.query.filter(Movie.title.ilike(f"%{q}%")).limit(10).all()
+ return jsonify([
+ {"id": m.id, "title": m.title, "year": m.year}
+ for m in movies
+ ])
+
+
+@bp.route("/api/start", methods=["POST"])
+def api_start():
+ data = request.get_json(silent=True) or {}
+ player_id = data.get("player_id")
+ mode = data.get("mode", "daily")
+ if not player_id:
+ return jsonify({"error": "player_id required"}), 400
+ if Player.query.get(player_id) is None:
+ return jsonify({"error": "player not found"}), 404
+ if mode not in ("daily", "unlimited"):
+ return jsonify({"error": "invalid mode"}), 400
+
+ max_attempts = get_max_attempts()
+
+ if mode == "daily":
+ game_date = _game_date()
+ target = daily_target(game_date)
+ # Bereits heute gespielt?
+ existing = existing_daily_session(player_id, game_date)
+ if existing and existing.status != "in_progress":
+ return jsonify({
+ "session_id": existing.id,
+ "mode": mode,
+ "max_attempts": max_attempts,
+ "status": existing.status,
+ "already_played": True,
+ "target": _movie_public(target),
+ "attempts_used": existing.attempts_used,
+ })
+ # In-progress Rueckgabe, sonst neue Runde
+ if existing:
+ return jsonify({
+ "session_id": existing.id,
+ "mode": mode,
+ "max_attempts": max_attempts,
+ "status": existing.status,
+ "already_played": False,
+ "attempts_used": existing.attempts_used,
+ })
+ session = Session(
+ player_id=player_id, mode=mode, target_movie_id=target.id,
+ status="in_progress", attempts_used=0, date=game_date,
+ )
+ db.session.add(session)
+ db.session.commit()
+ return jsonify({
+ "session_id": session.id,
+ "mode": mode,
+ "max_attempts": max_attempts,
+ "status": "in_progress",
+ "already_played": False,
+ "attempts_used": 0,
+ })
+
+ # Unbegrenzt: neue Zufallsrunde
+ target = random_target()
+ if target is None:
+ return jsonify({"error": "no movies"}), 500
+ session = Session(
+ player_id=player_id, mode="unlimited", target_movie_id=target.id,
+ status="in_progress", attempts_used=0,
+ )
+ db.session.add(session)
+ db.session.commit()
+ return jsonify({
+ "session_id": session.id,
+ "mode": mode,
+ "max_attempts": max_attempts,
+ "status": "in_progress",
+ "already_played": False,
+ "attempts_used": 0,
+ })
+
+
+@bp.route("/api/guess", methods=["POST"])
+def api_guess():
+ data = request.get_json(silent=True) or {}
+ session_id = data.get("session_id")
+ movie_id = data.get("movie_id")
+ if not session_id or not movie_id:
+ return jsonify({"error": "session_id and movie_id required"}), 400
+
+ session = Session.query.get(session_id)
+ if session is None:
+ return jsonify({"error": "session not found"}), 404
+ if session.status != "in_progress":
+ return jsonify({"error": "session already finished", "status": session.status}), 409
+
+ guess = Movie.query.get(movie_id)
+ if guess is None:
+ return jsonify({"error": "movie not found"}), 404
+
+ target = Movie.query.get(session.target_movie_id)
+ max_attempts = get_max_attempts()
+
+ compared = compare(guess, target)
+ session.attempts_used += 1
+ used = session.attempts_used
+ finished = False
+ status = "in_progress"
+ won = False
+
+ if is_correct(compared):
+ finish_session(session, True)
+ finished = True
+ won = True
+ status = "won"
+ elif used >= max_attempts:
+ finish_session(session, False)
+ finished = True
+ won = False
+ status = "lost"
+
+ response = {
+ "session_id": session.id,
+ "attempts_used": used,
+ "max_attempts": max_attempts,
+ "status": status,
+ "finished": finished,
+ "won": won,
+ "row": compared,
+ }
+ if finished:
+ response["target"] = _movie_public(target)
+ response["streaks"] = get_streaks(session.player_id)
+ return jsonify(response)
+
+
+@bp.route("/api/streaks", methods=["POST"])
+def api_streaks():
+ data = request.get_json(silent=True) or {}
+ player_id = data.get("player_id")
+ if not player_id:
+ return jsonify({"error": "player_id required"}), 400
+ return jsonify(get_streaks(player_id) or {"error": "player not found"})
+
+
+@bp.route("/api/new-game", methods=["POST"])
+def api_new_game():
+ """Startet (fuer den Unbegrenzt-Modus) eine neue Zufallsrunde."""
+ data = request.get_json(silent=True) or {}
+ player_id = data.get("player_id")
+ if not player_id or Player.query.get(player_id) is None:
+ return jsonify({"error": "player not found"}), 404
+ target = random_target()
+ if target is None:
+ return jsonify({"error": "no movies"}), 500
+ session = Session(
+ player_id=player_id, mode="unlimited", target_movie_id=target.id,
+ status="in_progress", attempts_used=0,
+ )
+ db.session.add(session)
+ db.session.commit()
+ return jsonify({
+ "session_id": session.id,
+ "mode": "unlimited",
+ "max_attempts": get_max_attempts(),
+ "status": "in_progress",
+ "attempts_used": 0,
+ })
+
+
+def _movie_public(movie):
+ return {
+ "id": movie.id,
+ "title": movie.title,
+ "year": movie.year,
+ "fsk": movie.fsk,
+ "studio": movie.studio.name if movie.studio else None,
+ "director": movie.director,
+ "budget": movie.budget,
+ "universe": movie.universe.name if movie.universe else None,
+ "genres": [g.name for g in movie.genres],
+ "platforms": [p.name for p in movie.platforms],
+ }
diff --git a/app/seed.py b/app/seed.py
new file mode 100644
index 0000000..341cb31
--- /dev/null
+++ b/app/seed.py
@@ -0,0 +1,89 @@
+import json
+import os
+
+from . import db
+from .models import Genre, Movie, Platform, Studio, Universe
+
+# Feste Referenzlisten (die moeglichen Werte fuer Plattform/Genre/Studio/Universum)
+PLATFORMS = [
+ "Netflix", "Disney+", "Prime Video", "Max", "Apple TV+", "Paramount+",
+ "Blu-ray", "DVD", "4K UHD", "Cinema"
+]
+
+GENRES = [
+ "Action", "Abenteuer", "Animation", "Komödie", "Krimi", "Drama", "Fantasy",
+ "Horror", "Mystery", "Romantik", "Sci-Fi", "Thriller", "Western", "Musical", "Krieg", "Biografie"
+]
+
+STUDIOS = [
+ "Warner Bros", "Disney", "Universal", "Paramount", "Sony Pictures",
+ "20th Century", "MGM", "Lionsgate", "A24", "DreamWorks", "New Line",
+ "Orion", "Neon", "CJ Entertainment", "Show East", "Dimension", "Netflix"
+]
+
+UNIVERSES = [
+ "MCU", "Star Wars", "DC", "Wizarding World", "Fast & Furious", "Herr der Ringe", "Spider-Man"
+]
+
+
+def _get_or_create(model, name):
+ if not name:
+ return None
+ obj = model.query.filter_by(name=name).first()
+ if obj is None:
+ obj = model(name=name)
+ db.session.add(obj)
+ db.session.flush()
+ return obj
+
+
+def seed_all():
+ """Idempotent: legt Referenzlisten und Filme an, ohne bestehende zu duplizieren."""
+ # Referenzlisten
+ for name in PLATFORMS:
+ _get_or_create(Platform, name)
+ for name in GENRES:
+ _get_or_create(Genre, name)
+ for name in STUDIOS:
+ _get_or_create(Studio, name)
+ for name in UNIVERSES:
+ _get_or_create(Universe, name)
+ db.session.commit()
+
+ # Filme aus JSON laden
+ _seed_movies_from_json()
+
+
+def _seed_movies_from_json():
+ data_path = os.path.join(
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "movies.json"
+ )
+ if not os.path.exists(data_path):
+ return
+
+ with open(data_path, "r", encoding="utf-8") as f:
+ movies = json.load(f)
+
+ # Nur hinzufuegen, wenn noch kein Film in der DB ist (idempotent ueber den ganzen Datensatz)
+ if Movie.query.count() > 0:
+ return
+
+ for entry in movies:
+ movie = Movie(
+ title=entry["title"],
+ year=entry["year"],
+ fsk=entry.get("fsk"),
+ director=entry.get("director"),
+ budget=entry.get("budget"),
+ studio=_get_or_create(Studio, entry.get("studio")),
+ universe=_get_or_create(Universe, entry.get("universe")),
+ )
+ db.session.add(movie)
+ db.session.flush()
+
+ for gname in entry.get("genres", []):
+ movie.genres.append(_get_or_create(Genre, gname))
+ for pname in entry.get("platforms", []):
+ movie.platforms.append(_get_or_create(Platform, pname))
+
+ db.session.commit()
diff --git a/data/movies.json b/data/movies.json
new file mode 100644
index 0000000..716fd38
--- /dev/null
+++ b/data/movies.json
@@ -0,0 +1,1102 @@
+[
+ {
+ "title": "Star Wars: Episode IV – Eine neue Hoffnung",
+ "year": 1977,
+ "fsk": 12,
+ "studio": "20th Century",
+ "director": "George Lucas",
+ "budget": 11000000,
+ "universe": "Star Wars",
+ "genres": ["Sci-Fi", "Abenteuer", "Action"],
+ "platforms": ["Disney+", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Die Rückkehr der Jedi-Ritter",
+ "year": 1983,
+ "fsk": 12,
+ "studio": "20th Century",
+ "director": "Richard Marquand",
+ "budget": 32500000,
+ "universe": "Star Wars",
+ "genres": ["Sci-Fi", "Abenteuer", "Action"],
+ "platforms": ["Disney+", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Das Imperium schlägt zurück",
+ "year": 1980,
+ "fsk": 12,
+ "studio": "20th Century",
+ "director": "Irvin Kershner",
+ "budget": 18000000,
+ "universe": "Star Wars",
+ "genres": ["Sci-Fi", "Abenteuer", "Action"],
+ "platforms": ["Disney+", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Der Pate",
+ "year": 1972,
+ "fsk": 16,
+ "studio": "Paramount",
+ "director": "Francis Ford Coppola",
+ "budget": 6000000,
+ "universe": null,
+ "genres": ["Krimi", "Drama"],
+ "platforms": ["Paramount+", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "Pulp Fiction",
+ "year": 1994,
+ "fsk": 16,
+ "studio": "MGM",
+ "director": "Quentin Tarantino",
+ "budget": 8000000,
+ "universe": null,
+ "genres": ["Krimi", "Thriller", "Drama"],
+ "platforms": ["Paramount+", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "The Dark Knight",
+ "year": 2008,
+ "fsk": 12,
+ "studio": "Warner Bros",
+ "director": "Christopher Nolan",
+ "budget": 185000000,
+ "universe": "DC",
+ "genres": ["Action", "Krimi", "Thriller"],
+ "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "Batman Begins",
+ "year": 2005,
+ "fsk": 12,
+ "studio": "Warner Bros",
+ "director": "Christopher Nolan",
+ "budget": 150000000,
+ "universe": "DC",
+ "genres": ["Action", "Krimi"],
+ "platforms": ["Max", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "The Dark Knight Rises",
+ "year": 2012,
+ "fsk": 12,
+ "studio": "Warner Bros",
+ "director": "Christopher Nolan",
+ "budget": 230000000,
+ "universe": "DC",
+ "genres": ["Action", "Thriller"],
+ "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "Inception",
+ "year": 2010,
+ "fsk": 12,
+ "studio": "Warner Bros",
+ "director": "Christopher Nolan",
+ "budget": 160000000,
+ "universe": null,
+ "genres": ["Sci-Fi", "Action", "Thriller"],
+ "platforms": ["Max", "Prime Video", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Interstellar",
+ "year": 2014,
+ "fsk": 12,
+ "studio": "Paramount",
+ "director": "Christopher Nolan",
+ "budget": 165000000,
+ "universe": null,
+ "genres": ["Sci-Fi", "Drama", "Abenteuer"],
+ "platforms": ["Paramount+", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "Avengers: Endgame",
+ "year": 2019,
+ "fsk": 12,
+ "studio": "Disney",
+ "director": "Anthony Russo, Joe Russo",
+ "budget": 356000000,
+ "universe": "MCU",
+ "genres": ["Action", "Sci-Fi", "Abenteuer"],
+ "platforms": ["Disney+", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "Avengers: Infinity War",
+ "year": 2018,
+ "fsk": 12,
+ "studio": "Disney",
+ "director": "Anthony Russo, Joe Russo",
+ "budget": 300000000,
+ "universe": "MCU",
+ "genres": ["Action", "Sci-Fi", "Abenteuer"],
+ "platforms": ["Disney+", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "Iron Man",
+ "year": 2008,
+ "fsk": 12,
+ "studio": "Paramount",
+ "director": "Jon Favreau",
+ "budget": 140000000,
+ "universe": "MCU",
+ "genres": ["Action", "Sci-Fi", "Abenteuer"],
+ "platforms": ["Disney+", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Spider-Man: Homecoming",
+ "year": 2017,
+ "fsk": 12,
+ "studio": "Sony Pictures",
+ "director": "Jon Watts",
+ "budget": 175000000,
+ "universe": "Spider-Man",
+ "genres": ["Action", "Abenteuer", "Sci-Fi"],
+ "platforms": ["Disney+", "Netflix", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Spider-Man: No Way Home",
+ "year": 2021,
+ "fsk": 12,
+ "studio": "Sony Pictures",
+ "director": "Jon Watts",
+ "budget": 200000000,
+ "universe": "Spider-Man",
+ "genres": ["Action", "Abenteuer", "Sci-Fi"],
+ "platforms": ["Netflix", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "Der Herr der Ringe: Die Gefährten",
+ "year": 2001,
+ "fsk": 12,
+ "studio": "New Line",
+ "director": "Peter Jackson",
+ "budget": 93000000,
+ "universe": "Herr der Ringe",
+ "genres": ["Fantasy", "Abenteuer"],
+ "platforms": ["Max", "Prime Video", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Der Herr der Ringe: Die zwei Türme",
+ "year": 2002,
+ "fsk": 12,
+ "studio": "New Line",
+ "director": "Peter Jackson",
+ "budget": 94000000,
+ "universe": "Herr der Ringe",
+ "genres": ["Fantasy", "Abenteuer"],
+ "platforms": ["Max", "Prime Video", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Der Herr der Ringe: Die Rückkehr des Königs",
+ "year": 2003,
+ "fsk": 12,
+ "studio": "New Line",
+ "director": "Peter Jackson",
+ "budget": 94000000,
+ "universe": "Herr der Ringe",
+ "genres": ["Fantasy", "Abenteuer"],
+ "platforms": ["Max", "Prime Video", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Harry Potter und der Stein der Weisen",
+ "year": 2001,
+ "fsk": 6,
+ "studio": "Warner Bros",
+ "director": "Chris Columbus",
+ "budget": 125000000,
+ "universe": "Wizarding World",
+ "genres": ["Fantasy", "Abenteuer"],
+ "platforms": ["Max", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Harry Potter und die Kammer des Schreckens",
+ "year": 2002,
+ "fsk": 6,
+ "studio": "Warner Bros",
+ "director": "Chris Columbus",
+ "budget": 100000000,
+ "universe": "Wizarding World",
+ "genres": ["Fantasy", "Abenteuer"],
+ "platforms": ["Max", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Phantastische Tierwesen und wo sie zu finden sind",
+ "year": 2016,
+ "fsk": 6,
+ "studio": "Warner Bros",
+ "director": "David Yates",
+ "budget": 180000000,
+ "universe": "Wizarding World",
+ "genres": ["Fantasy", "Abenteuer"],
+ "platforms": ["Max", "Prime Video", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Jumanji: Willkommen im Dschungel",
+ "year": 2017,
+ "fsk": 6,
+ "studio": "Sony Pictures",
+ "director": "Jake Kasdan",
+ "budget": 90000000,
+ "universe": null,
+ "genres": ["Abenteuer", "Komödie", "Action"],
+ "platforms": ["Netflix", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Jumanji: The Next Level",
+ "year": 2019,
+ "fsk": 6,
+ "studio": "Sony Pictures",
+ "director": "Jake Kasdan",
+ "budget": 125000000,
+ "universe": null,
+ "genres": ["Abenteuer", "Komödie", "Action"],
+ "platforms": ["Netflix", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Titanic",
+ "year": 1997,
+ "fsk": 12,
+ "studio": "Paramount",
+ "director": "James Cameron",
+ "budget": 200000000,
+ "universe": null,
+ "genres": ["Drama", "Romantik"],
+ "platforms": ["Disney+", "Prime Video", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Avatar – Aufbruch nach Pandora",
+ "year": 2009,
+ "fsk": 12,
+ "studio": "20th Century",
+ "director": "James Cameron",
+ "budget": 237000000,
+ "universe": null,
+ "genres": ["Sci-Fi", "Abenteuer", "Action"],
+ "platforms": ["Disney+", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "Avatar: The Way of Water",
+ "year": 2022,
+ "fsk": 12,
+ "studio": "20th Century",
+ "director": "James Cameron",
+ "budget": 350000000,
+ "universe": null,
+ "genres": ["Sci-Fi", "Abenteuer", "Action"],
+ "platforms": ["Disney+", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "Fast & Furious 5",
+ "year": 2011,
+ "fsk": 12,
+ "studio": "Universal",
+ "director": "Justin Lin",
+ "budget": 125000000,
+ "universe": "Fast & Furious",
+ "genres": ["Action", "Krimi", "Thriller"],
+ "platforms": ["Netflix", "Prime Video", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "The Fast and the Furious: Tokyo Drift",
+ "year": 2006,
+ "fsk": 12,
+ "studio": "Universal",
+ "director": "Justin Lin",
+ "budget": 85000000,
+ "universe": "Fast & Furious",
+ "genres": ["Action", "Krimi"],
+ "platforms": ["Netflix", "Prime Video", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "No Time to Die",
+ "year": 2021,
+ "fsk": 12,
+ "studio": "MGM",
+ "director": "Cary Joji Fukunaga",
+ "budget": 250000000,
+ "universe": null,
+ "genres": ["Action", "Thriller", "Abenteuer"],
+ "platforms": ["Prime Video", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "James Bond 007 – Skyfall",
+ "year": 2012,
+ "fsk": 12,
+ "studio": "Sony Pictures",
+ "director": "Sam Mendes",
+ "budget": 200000000,
+ "universe": null,
+ "genres": ["Action", "Thriller", "Abenteuer"],
+ "platforms": ["Prime Video", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Fight Club",
+ "year": 1999,
+ "fsk": 16,
+ "studio": "20th Century",
+ "director": "David Fincher",
+ "budget": 63000000,
+ "universe": null,
+ "genres": ["Drama", "Thriller"],
+ "platforms": ["Prime Video", "Netflix", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "The Matrix",
+ "year": 1999,
+ "fsk": 16,
+ "studio": "Warner Bros",
+ "director": "Lana Wachowski, Lilly Wachowski",
+ "budget": 63000000,
+ "universe": null,
+ "genres": ["Sci-Fi", "Action"],
+ "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "Requiem for a Dream",
+ "year": 2000,
+ "fsk": 18,
+ "studio": "Lionsgate",
+ "director": "Darren Aronofsky",
+ "budget": 4500000,
+ "universe": null,
+ "genres": ["Drama"],
+ "platforms": ["Apple TV+", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Schindlers Liste",
+ "year": 1993,
+ "fsk": 12,
+ "studio": "Universal",
+ "director": "Steven Spielberg",
+ "budget": 22000000,
+ "universe": null,
+ "genres": ["Drama", "Krieg", "Biografie"],
+ "platforms": ["Netflix", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "Jurassic Park",
+ "year": 1993,
+ "fsk": 12,
+ "studio": "Universal",
+ "director": "Steven Spielberg",
+ "budget": 63000000,
+ "universe": null,
+ "genres": ["Sci-Fi", "Abenteuer", "Thriller"],
+ "platforms": ["Netflix", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "E.T. – Der Außerirdische",
+ "year": 1982,
+ "fsk": 6,
+ "studio": "Universal",
+ "director": "Steven Spielberg",
+ "budget": 10500000,
+ "universe": null,
+ "genres": ["Sci-Fi", "Drama", "Abenteuer"],
+ "platforms": ["Netflix", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Forrest Gump",
+ "year": 1994,
+ "fsk": 6,
+ "studio": "Paramount",
+ "director": "Robert Zemeckis",
+ "budget": 55000000,
+ "universe": null,
+ "genres": ["Drama", "Romantik", "Komödie"],
+ "platforms": ["Paramount+", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Zurück in die Zukunft",
+ "year": 1985,
+ "fsk": 6,
+ "studio": "Universal",
+ "director": "Robert Zemeckis",
+ "budget": 19000000,
+ "universe": null,
+ "genres": ["Sci-Fi", "Abenteuer", "Komödie"],
+ "platforms": ["Netflix", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "Gladiator",
+ "year": 2000,
+ "fsk": 16,
+ "studio": "Universal",
+ "director": "Ridley Scott",
+ "budget": 103000000,
+ "universe": null,
+ "genres": ["Action", "Drama", "Abenteuer"],
+ "platforms": ["Paramount+", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "Alien",
+ "year": 1979,
+ "fsk": 16,
+ "studio": "20th Century",
+ "director": "Ridley Scott",
+ "budget": 11000000,
+ "universe": null,
+ "genres": ["Horror", "Sci-Fi", "Thriller"],
+ "platforms": ["Disney+", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Blade Runner",
+ "year": 1982,
+ "fsk": 16,
+ "studio": "Warner Bros",
+ "director": "Ridley Scott",
+ "budget": 28000000,
+ "universe": null,
+ "genres": ["Sci-Fi", "Thriller", "Mystery"],
+ "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "Blade Runner 2049",
+ "year": 2017,
+ "fsk": 16,
+ "studio": "Warner Bros",
+ "director": "Denis Villeneuve",
+ "budget": 150000000,
+ "universe": null,
+ "genres": ["Sci-Fi", "Thriller", "Mystery"],
+ "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "Dune",
+ "year": 2021,
+ "fsk": 12,
+ "studio": "Warner Bros",
+ "director": "Denis Villeneuve",
+ "budget": 165000000,
+ "universe": null,
+ "genres": ["Sci-Fi", "Abenteuer", "Drama"],
+ "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "Die Verurteilten",
+ "year": 1994,
+ "fsk": 12,
+ "studio": "Warner Bros",
+ "director": "Frank Darabont",
+ "budget": 25000000,
+ "universe": null,
+ "genres": ["Drama", "Krimi"],
+ "platforms": ["Max", "Netflix", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Der Pianist",
+ "year": 2002,
+ "fsk": 12,
+ "studio": "Universal",
+ "director": "Roman Polanski",
+ "budget": 35000000,
+ "universe": null,
+ "genres": ["Drama", "Krieg"],
+ "platforms": ["Prime Video", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Die Passion Christi",
+ "year": 2004,
+ "fsk": 16,
+ "studio": "New Line",
+ "director": "Mel Gibson",
+ "budget": 30000000,
+ "universe": null,
+ "genres": ["Drama"],
+ "platforms": ["Netflix", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "V wie Vendetta",
+ "year": 2005,
+ "fsk": 16,
+ "studio": "Warner Bros",
+ "director": "James McTeigue",
+ "budget": 54000000,
+ "universe": null,
+ "genres": ["Action", "Thriller", "Drama"],
+ "platforms": ["Max", "Prime Video", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Sin City",
+ "year": 2005,
+ "fsk": 18,
+ "studio": "Dimension",
+ "director": "Frank Miller, Robert Rodriguez",
+ "budget": 40000000,
+ "universe": null,
+ "genres": ["Krimi", "Thriller", "Action"],
+ "platforms": ["Max", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Kill Bill: Volume 1",
+ "year": 2003,
+ "fsk": 16,
+ "studio": "MGM",
+ "director": "Quentin Tarantino",
+ "budget": 30000000,
+ "universe": null,
+ "genres": ["Action", "Thriller", "Krimi"],
+ "platforms": ["Max", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Reservoir Dogs",
+ "year": 1992,
+ "fsk": 16,
+ "studio": "MGM",
+ "director": "Quentin Tarantino",
+ "budget": 1200000,
+ "universe": null,
+ "genres": ["Krimi", "Thriller"],
+ "platforms": ["Prime Video", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Inglourious Basterds",
+ "year": 2009,
+ "fsk": 16,
+ "studio": "Universal",
+ "director": "Quentin Tarantino",
+ "budget": 70000000,
+ "universe": null,
+ "genres": ["Krieg", "Action", "Drama"],
+ "platforms": ["Netflix", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "Django Unchained",
+ "year": 2012,
+ "fsk": 16,
+ "studio": "Sony Pictures",
+ "director": "Quentin Tarantino",
+ "budget": 100000000,
+ "universe": null,
+ "genres": ["Western", "Drama"],
+ "platforms": ["Netflix", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "The Wolf of Wall Street",
+ "year": 2013,
+ "fsk": 16,
+ "studio": "Paramount",
+ "director": "Martin Scorsese",
+ "budget": 100000000,
+ "universe": null,
+ "genres": ["Drama", "Komödie", "Krimi"],
+ "platforms": ["Paramount+", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Goodfellas",
+ "year": 1990,
+ "fsk": 16,
+ "studio": "Warner Bros",
+ "director": "Martin Scorsese",
+ "budget": 25000000,
+ "universe": null,
+ "genres": ["Krimi", "Drama", "Thriller"],
+ "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "The Departed – Unter Feinden",
+ "year": 2006,
+ "fsk": 16,
+ "studio": "Warner Bros",
+ "director": "Martin Scorsese",
+ "budget": 90000000,
+ "universe": null,
+ "genres": ["Krimi", "Thriller", "Drama"],
+ "platforms": ["Max", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Shutter Island",
+ "year": 2010,
+ "fsk": 16,
+ "studio": "Paramount",
+ "director": "Martin Scorsese",
+ "budget": 80000000,
+ "universe": null,
+ "genres": ["Mystery", "Thriller", "Drama"],
+ "platforms": ["Paramount+", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Die Ritter der Kokosnuss",
+ "year": 1975,
+ "fsk": 6,
+ "studio": "Warner Bros",
+ "director": "Terry Gilliam, Terry Jones",
+ "budget": 400000,
+ "universe": null,
+ "genres": ["Komödie", "Abenteuer"],
+ "platforms": ["Netflix", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Brazil",
+ "year": 1985,
+ "fsk": 16,
+ "studio": "Universal",
+ "director": "Terry Gilliam",
+ "budget": 15000000,
+ "universe": null,
+ "genres": ["Sci-Fi", "Drama", "Komödie"],
+ "platforms": ["Max", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "12 Monkeys",
+ "year": 1995,
+ "fsk": 12,
+ "studio": "Universal",
+ "director": "Terry Gilliam",
+ "budget": 29000000,
+ "universe": null,
+ "genres": ["Sci-Fi", "Thriller", "Mystery"],
+ "platforms": ["Prime Video", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Captain Fantastic",
+ "year": 2016,
+ "fsk": 12,
+ "studio": "Universal",
+ "director": "Matt Ross",
+ "budget": 5000000,
+ "universe": null,
+ "genres": ["Drama", "Komödie", "Abenteuer"],
+ "platforms": ["Max", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Marriage Story",
+ "year": 2019,
+ "fsk": 6,
+ "studio": "Netflix",
+ "director": "Noah Baumbach",
+ "budget": 18000000,
+ "universe": null,
+ "genres": ["Drama", "Romantik"],
+ "platforms": ["Netflix", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Lalaland",
+ "year": 2016,
+ "fsk": 6,
+ "studio": "Lionsgate",
+ "director": "Damien Chazelle",
+ "budget": 30000000,
+ "universe": null,
+ "genres": ["Musical", "Romantik", "Drama"],
+ "platforms": ["Blu-ray", "DVD", "Netflix"]
+ },
+ {
+ "title": "Whiplash",
+ "year": 2014,
+ "fsk": 6,
+ "studio": "Sony Pictures",
+ "director": "Damien Chazelle",
+ "budget": 3300000,
+ "universe": null,
+ "genres": ["Drama", "Musical"],
+ "platforms": ["Netflix", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "The Silence of the Lambs",
+ "year": 1991,
+ "fsk": 16,
+ "studio": "Orion",
+ "director": "Jonathan Demme",
+ "budget": 19000000,
+ "universe": null,
+ "genres": ["Horror", "Thriller", "Krimi", "Mystery"],
+ "platforms": ["Prime Video", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Se7en",
+ "year": 1995,
+ "fsk": 16,
+ "studio": "New Line",
+ "director": "David Fincher",
+ "budget": 33000000,
+ "universe": null,
+ "genres": ["Krimi", "Thriller", "Mystery"],
+ "platforms": ["Max", "Netflix", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Zodiac",
+ "year": 2007,
+ "fsk": 16,
+ "studio": "Paramount",
+ "director": "David Fincher",
+ "budget": 85000000,
+ "universe": null,
+ "genres": ["Krimi", "Thriller", "Mystery"],
+ "platforms": ["Paramount+", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "The Girl with the Dragon Tattoo",
+ "year": 2011,
+ "fsk": 16,
+ "studio": "Sony Pictures",
+ "director": "David Fincher",
+ "budget": 90000000,
+ "universe": null,
+ "genres": ["Krimi", "Thriller", "Mystery"],
+ "platforms": ["Netflix", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Mad Max: Fury Road",
+ "year": 2015,
+ "fsk": 16,
+ "studio": "Warner Bros",
+ "director": "George Miller",
+ "budget": 150000000,
+ "universe": null,
+ "genres": ["Action", "Sci-Fi", "Abenteuer"],
+ "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "Children of Men",
+ "year": 2006,
+ "fsk": 16,
+ "studio": "Universal",
+ "director": "Alfonso Cuarón",
+ "budget": 76000000,
+ "universe": null,
+ "genres": ["Sci-Fi", "Thriller", "Drama"],
+ "platforms": ["Netflix", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Gravity",
+ "year": 2013,
+ "fsk": 12,
+ "studio": "Warner Bros",
+ "director": "Alfonso Cuarón",
+ "budget": 100000000,
+ "universe": null,
+ "genres": ["Sci-Fi", "Thriller", "Drama"],
+ "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "Moonlight",
+ "year": 2016,
+ "fsk": 6,
+ "studio": "A24",
+ "director": "Barry Jenkins",
+ "budget": 4000000,
+ "universe": null,
+ "genres": ["Drama"],
+ "platforms": ["Netflix", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Hereditary",
+ "year": 2018,
+ "fsk": 16,
+ "studio": "A24",
+ "director": "Ari Aster",
+ "budget": 10000000,
+ "universe": null,
+ "genres": ["Horror", "Mystery", "Drama"],
+ "platforms": ["Max", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Midsommar",
+ "year": 2019,
+ "fsk": 16,
+ "studio": "A24",
+ "director": "Ari Aster",
+ "budget": 9000000,
+ "universe": null,
+ "genres": ["Horror", "Mystery", "Thriller"],
+ "platforms": ["Max", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Parasite",
+ "year": 2019,
+ "fsk": 16,
+ "studio": "Neon",
+ "director": "Bong Joon-ho",
+ "budget": 11000000,
+ "universe": null,
+ "genres": ["Thriller", "Drama", "Komödie"],
+ "platforms": ["Max", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Oldboy",
+ "year": 2003,
+ "fsk": 18,
+ "studio": "Show East",
+ "director": "Park Chan-wook",
+ "budget": 3000000,
+ "universe": null,
+ "genres": ["Thriller", "Action", "Mystery"],
+ "platforms": ["Netflix", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Snowpiercer",
+ "year": 2013,
+ "fsk": 16,
+ "studio": "CJ Entertainment",
+ "director": "Bong Joon-ho",
+ "budget": 42000000,
+ "universe": null,
+ "genres": ["Sci-Fi", "Action", "Thriller"],
+ "platforms": ["Netflix", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Der König der Löwen",
+ "year": 1994,
+ "fsk": 0,
+ "studio": "Disney",
+ "director": "Roger Allers, Rob Minkoff",
+ "budget": 45000000,
+ "universe": null,
+ "genres": ["Animation", "Abenteuer", "Drama", "Musical"],
+ "platforms": ["Disney+", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Toy Story",
+ "year": 1995,
+ "fsk": 0,
+ "studio": "Disney",
+ "director": "John Lasseter",
+ "budget": 30000000,
+ "universe": null,
+ "genres": ["Animation", "Abenteuer", "Komödie"],
+ "platforms": ["Disney+", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Findet Nemo",
+ "year": 2003,
+ "fsk": 0,
+ "studio": "Disney",
+ "director": "Andrew Stanton",
+ "budget": 94000000,
+ "universe": null,
+ "genres": ["Animation", "Abenteuer", "Komödie"],
+ "platforms": ["Disney+", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Shrek",
+ "year": 2001,
+ "fsk": 0,
+ "studio": "DreamWorks",
+ "director": "Andrew Adamson, Vicky Jenson",
+ "budget": 60000000,
+ "universe": null,
+ "genres": ["Animation", "Komödie", "Abenteuer", "Fantasy"],
+ "platforms": ["Netflix", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Shrek 2",
+ "year": 2004,
+ "fsk": 0,
+ "studio": "DreamWorks",
+ "director": "Andrew Adamson, Kelly Asbury, Conrad Vernon",
+ "budget": 150000000,
+ "universe": null,
+ "genres": ["Animation", "Komödie", "Abenteuer", "Fantasy"],
+ "platforms": ["Netflix", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Wie der Wind sich hebt",
+ "year": 2013,
+ "fsk": 6,
+ "studio": "Disney",
+ "director": "Hayao Miyazaki",
+ "budget": 30000000,
+ "universe": null,
+ "genres": ["Animation", "Drama", "Biografie"],
+ "platforms": ["Netflix", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Chihiros Reise ins Zauberland",
+ "year": 2001,
+ "fsk": 6,
+ "studio": "Disney",
+ "director": "Hayao Miyazaki",
+ "budget": 19000000,
+ "universe": null,
+ "genres": ["Animation", "Abenteuer", "Fantasy", "Drama"],
+ "platforms": ["Netflix", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Porco Rosso – Roter Flieger",
+ "year": 1992,
+ "fsk": 6,
+ "studio": "Disney",
+ "director": "Hayao Miyazaki",
+ "budget": 9000000,
+ "universe": null,
+ "genres": ["Animation", "Abenteuer", "Action"],
+ "platforms": ["Netflix", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Das wandelnde Schloss",
+ "year": 2004,
+ "fsk": 6,
+ "studio": "Disney",
+ "director": "Hayao Miyazaki",
+ "budget": 24000000,
+ "universe": null,
+ "genres": ["Animation", "Fantasy", "Abenteuer"],
+ "platforms": ["Netflix", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Ich – Einfach unverbesserlich",
+ "year": 2010,
+ "fsk": 0,
+ "studio": "Universal",
+ "director": "Pierre Coffin, Chris Renaud",
+ "budget": 69000000,
+ "universe": null,
+ "genres": ["Animation", "Komödie", "Abenteuer"],
+ "platforms": ["Netflix", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Anomalisa",
+ "year": 2015,
+ "fsk": 12,
+ "studio": "Paramount",
+ "director": "Charlie Kaufman, Duke Johnson",
+ "budget": 8000000,
+ "universe": null,
+ "genres": ["Animation", "Drama", "Romantik"],
+ "platforms": ["Netflix", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "The Grand Budapest Hotel",
+ "year": 2014,
+ "fsk": 6,
+ "studio": "20th Century",
+ "director": "Wes Anderson",
+ "budget": 25000000,
+ "universe": null,
+ "genres": ["Komödie", "Abenteuer", "Drama"],
+ "platforms": ["Disney+", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Joker",
+ "year": 2019,
+ "fsk": 16,
+ "studio": "Warner Bros",
+ "director": "Todd Phillips",
+ "budget": 55000000,
+ "universe": "DC",
+ "genres": ["Drama", "Thriller", "Krimi"],
+ "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "The Batman",
+ "year": 2022,
+ "fsk": 16,
+ "studio": "Warner Bros",
+ "director": "Matt Reeves",
+ "budget": 200000000,
+ "universe": "DC",
+ "genres": ["Action", "Krimi", "Mystery"],
+ "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "Wonder Woman",
+ "year": 2017,
+ "fsk": 12,
+ "studio": "Warner Bros",
+ "director": "Patty Jenkins",
+ "budget": 149000000,
+ "universe": "DC",
+ "genres": ["Action", "Abenteuer", "Fantasy"],
+ "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "The Godfather Part II",
+ "year": 1974,
+ "fsk": 16,
+ "studio": "Paramount",
+ "director": "Francis Ford Coppola",
+ "budget": 13000000,
+ "universe": null,
+ "genres": ["Krimi", "Drama"],
+ "platforms": ["Paramount+", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "Taxi Driver",
+ "year": 1976,
+ "fsk": 16,
+ "studio": "Sony Pictures",
+ "director": "Martin Scorsese",
+ "budget": 1300000,
+ "universe": null,
+ "genres": ["Drama", "Thriller", "Krimi"],
+ "platforms": ["Netflix", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "Barry Lyndon",
+ "year": 1975,
+ "fsk": 16,
+ "studio": "Warner Bros",
+ "director": "Stanley Kubrick",
+ "budget": 11000000,
+ "universe": null,
+ "genres": ["Drama", "Krieg"],
+ "platforms": ["Max", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "A Clockwork Orange",
+ "year": 1971,
+ "fsk": 18,
+ "studio": "Warner Bros",
+ "director": "Stanley Kubrick",
+ "budget": 2200000,
+ "universe": null,
+ "genres": ["Sci-Fi", "Krimi", "Thriller"],
+ "platforms": ["Max", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "Full Metal Jacket",
+ "year": 1987,
+ "fsk": 16,
+ "studio": "Warner Bros",
+ "director": "Stanley Kubrick",
+ "budget": 17000000,
+ "universe": null,
+ "genres": ["Krieg", "Drama"],
+ "platforms": ["Max", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "2001: Odyssee im Weltraum",
+ "year": 1968,
+ "fsk": 6,
+ "studio": "MGM",
+ "director": "Stanley Kubrick",
+ "budget": 10500000,
+ "universe": null,
+ "genres": ["Sci-Fi", "Mystery", "Abenteuer"],
+ "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "Eyes Wide Shut",
+ "year": 1999,
+ "fsk": 16,
+ "studio": "Warner Bros",
+ "director": "Stanley Kubrick",
+ "budget": 65000000,
+ "universe": null,
+ "genres": ["Mystery", "Thriller", "Drama"],
+ "platforms": ["Max", "Blu-ray", "DVD"]
+ },
+ {
+ "title": "The Shining",
+ "year": 1980,
+ "fsk": 16,
+ "studio": "Warner Bros",
+ "director": "Stanley Kubrick",
+ "budget": 19000000,
+ "universe": null,
+ "genres": ["Horror", "Mystery", "Thriller"],
+ "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"]
+ },
+ {
+ "title": "Saving Private Ryan",
+ "year": 1998,
+ "fsk": 16,
+ "studio": "DreamWorks",
+ "director": "Steven Spielberg",
+ "budget": 70000000,
+ "universe": null,
+ "genres": ["Krieg", "Drama", "Action"],
+ "platforms": ["Paramount+", "Blu-ray", "DVD", "4K UHD"]
+ }
+]
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..5aaf14c
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,17 @@
+# Lokale Entwicklungsumgebung fuer Filmdle
+# Postgres fuer lokale Tests. Start: docker compose up -d db
+services:
+ db:
+ image: postgres:16-alpine
+ container_name: filmdle-db
+ environment:
+ POSTGRES_USER: filmdle
+ POSTGRES_PASSWORD: filmdle
+ POSTGRES_DB: filmdle
+ ports:
+ - "5432:5432"
+ volumes:
+ - db_data:/var/lib/postgresql/data
+
+volumes:
+ db_data:
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..154fb6b
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,5 @@
+Flask==3.0.3
+Flask-SQLAlchemy==3.1.1
+gunicorn==23.0.0
+psycopg2-binary==2.9.9
+python-dotenv==1.0.1
diff --git a/static/css/style.css b/static/css/style.css
new file mode 100644
index 0000000..e707c46
--- /dev/null
+++ b/static/css/style.css
@@ -0,0 +1,358 @@
+:root {
+ --bg: #0b141a;
+ --bg-2: #101c26;
+ --panel: #16222e;
+ --panel-2: #1b2c39;
+ --border: #2a475e;
+ --border-soft: #22384a;
+ --text: #d7e2ea;
+ --muted: #8299ab;
+ --accent: #66c0f4;
+ --green: #2e9e4f;
+ --yellow: #c9a227;
+ --red: #b3362f;
+ --radius: 10px;
+}
+
+* { box-sizing: border-box; margin: 0; padding: 0; }
+
+body {
+ background:
+ radial-gradient(1200px 500px at 50% -10%, #1e3d52 0%, var(--bg) 55%),
+ linear-gradient(180deg, #0b141a 0%, #060c12 100%);
+ background-attachment: fixed;
+ color: var(--text);
+ font-family: "Segoe UI", Roboto, system-ui, -apple-system, sans-serif;
+ min-height: 100vh;
+}
+
+header {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ padding: 14px 32px;
+ border-bottom: 1px solid var(--border);
+ background: linear-gradient(180deg, rgba(20,40,55,0.95) 0%, rgba(11,20,26,0.95) 100%);
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ backdrop-filter: blur(6px);
+}
+
+.logo {
+ font-size: 24px;
+ font-weight: 800;
+ letter-spacing: 2px;
+ color: #fff;
+ text-transform: uppercase;
+ background: linear-gradient(90deg, var(--accent), #8ab4ff);
+ -webkit-background-clip: text;
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+}
+
+nav {
+ display: flex;
+ gap: 6px;
+ flex: 1;
+}
+
+nav a {
+ color: var(--muted);
+ text-decoration: none;
+ padding: 8px 18px;
+ border-radius: 8px;
+ border: 1px solid transparent;
+ font-weight: 600;
+ font-size: 14px;
+ transition: all .15s;
+}
+
+nav a:hover { color: var(--text); background: var(--panel-2); }
+nav a.active {
+ color: #fff;
+ background: rgba(102, 192, 244, 0.12);
+ border-color: rgba(102, 192, 244, 0.4);
+ box-shadow: 0 0 0 1px rgba(102,192,244,0.1) inset;
+}
+
+.player-box {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 2px;
+ font-size: 14px;
+}
+#player-name { font-weight: 700; }
+#streak-label { color: var(--muted); font-size: 12px; }
+
+main {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 24px 32px 64px;
+}
+
+#game-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+}
+#mode-title {
+ font-size: 20px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--accent);
+}
+#attempts-info { color: var(--muted); font-size: 14px; font-variant-numeric: tabular-nums; }
+
+/* Suche */
+#search-wrapper { position: relative; margin-bottom: 24px; }
+
+#search-input {
+ width: 100%;
+ padding: 14px 18px;
+ font-size: 16px;
+ border-radius: var(--radius);
+ border: 1px solid var(--border);
+ background: var(--panel-2);
+ color: var(--text);
+ outline: none;
+ transition: border-color .15s, box-shadow .15s;
+}
+#search-input::placeholder { color: var(--muted); }
+#search-input:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(102,192,244,0.18);
+}
+
+.autocomplete {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ background: var(--panel-2);
+ border: 1px solid var(--border);
+ border-top: none;
+ border-radius: 0 0 var(--radius) var(--radius);
+ max-height: 340px;
+ overflow-y: auto;
+ z-index: 20;
+ display: none;
+}
+.autocomplete.open { display: block; }
+.autocomplete-item {
+ padding: 10px 18px;
+ cursor: pointer;
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ align-items: center;
+ border-left: 3px solid transparent;
+}
+.autocomplete-item:hover, .autocomplete-item.selected {
+ background: rgba(102,192,244,0.1);
+ border-left-color: var(--accent);
+}
+.autocomplete-item .year { color: var(--muted); font-size: 13px; }
+
+/* Brett */
+#board-wrapper {
+ overflow-x: auto;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--panel);
+}
+
+table#board {
+ width: 100%;
+ border-collapse: collapse;
+ min-width: 1000px;
+}
+
+#board th {
+ padding: 12px 12px;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.8px;
+ color: var(--muted);
+ background: var(--panel);
+ border-bottom: 1px solid var(--border);
+ text-align: left;
+ white-space: nowrap;
+ font-weight: 600;
+}
+
+#board td {
+ padding: 10px 12px;
+ border-bottom: 1px solid var(--border-soft);
+ background: var(--panel);
+ min-width: 96px;
+ font-size: 13px;
+ vertical-align: middle;
+ word-break: break-word;
+ line-height: 1.5;
+}
+
+#board tr:last-child td { border-bottom: none; }
+
+#board td.title-cell {
+ background: linear-gradient(180deg, var(--panel-2), var(--panel));
+ font-weight: 700;
+ min-width: 170px;
+ color: #fff;
+ border-right: 1px solid var(--border-soft);
+}
+
+/* Status-Zellen */
+.cell { border-radius: 6px; }
+.cell.green { background: linear-gradient(180deg, #34b25a, var(--green)); color: #fff; box-shadow: inset 0 0 0 1px rgba(255,255,255,0.12); }
+.cell.yellow { background: linear-gradient(180deg, #dfb43a, var(--yellow)); color: #17190d; box-shadow: inset 0 0 0 1px rgba(255,255,255,0.2); }
+.cell.red { background: linear-gradient(180deg, #cf453c, var(--red)); color: #fff; box-shadow: inset 0 0 0 1px rgba(255,255,255,0.12); }
+
+.cell.green .dir-pill, .cell.red .dir-pill { background: rgba(0,0,0,0.25); color: #fff; }
+.cell.yellow .dir-pill { background: rgba(0,0,0,0.18); color: #17190d; }
+.cell.green .badge, .cell.red .badge { background: rgba(255,255,255,0.18); border-color: rgba(255,255,255,0.25); }
+.cell.yellow .badge { background: rgba(255,255,255,0.35); border-color: rgba(0,0,0,0.15); }
+
+/* Badges (Icon + Label) */
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 3px 8px;
+ margin: 1px 3px 1px 0;
+ border-radius: 5px;
+ font-size: 12px;
+ font-weight: 600;
+ background: rgba(255,255,255,0.08);
+ border: 1px solid var(--border-soft);
+ white-space: nowrap;
+}
+.badge .bicon { font-size: 13px; line-height: 1; }
+
+/* Zahlen (Jahr/Budget) */
+.num { font-weight: 700; font-size: 14px; margin-right: 6px; }
+
+/* Pfeil-Kapsel mit Zahl */
+.dir-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ padding: 2px 7px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 800;
+ background: rgba(0,0,0,0.25);
+ color: #fff;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+.dir-pill.up { background: #216e37; color: #b8f5c6; }
+.dir-pill.down { background: #8f2f29; color: #ffd0ca; }
+
+/* FSK Badge */
+.fsk {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 34px;
+ height: 22px;
+ padding: 0 6px;
+ border-radius: 5px;
+ font-weight: 800;
+ font-size: 13px;
+ background: rgba(0,0,0,0.35);
+ color: #fff;
+ border: 1px solid rgba(255,255,255,0.25);
+}
+.fsk-0 { background: #2e7d32; }
+.fsk-6 { background: #2f6fbe; }
+.fsk-12 { background: #f9a825; color: #201a03; }
+.fsk-16 { background: #ef6c00; }
+.fsk-18 { background: #b71c1c; }
+
+.director { font-weight: 600; }
+.none { color: rgba(255,255,255,0.55); }
+
+/* Ergebnis */
+#result-box {
+ margin-top: 24px;
+ padding: 22px 26px;
+ border-radius: var(--radius);
+ background: var(--panel-2);
+ border: 1px solid var(--border);
+ font-size: 16px;
+}
+#result-box.won { border-color: var(--green); box-shadow: 0 0 0 1px var(--green) inset; }
+#result-box.lost { border-color: var(--red); box-shadow: 0 0 0 1px var(--red) inset; }
+#result-box .target-title { font-size: 20px; font-weight: 700; margin: 6px 0; color: #fff; }
+
+#result-box button {
+ margin-top: 14px;
+ padding: 11px 22px;
+ border: none;
+ border-radius: 8px;
+ background: linear-gradient(180deg, #7bd0f8, var(--accent));
+ color: #062030;
+ font-weight: 700;
+ font-size: 15px;
+ cursor: pointer;
+ box-shadow: 0 2px 8px rgba(102,192,244,0.3);
+ transition: filter .15s;
+}
+#result-box button:hover { filter: brightness(1.08); }
+
+.hidden { display: none !important; }
+
+/* Modal */
+.modal {
+ position: fixed;
+ inset: 0;
+ background: rgba(4, 10, 15, 0.78);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 100;
+ backdrop-filter: blur(4px);
+}
+.modal-content {
+ background: var(--panel-2);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ padding: 30px;
+ max-width: 420px;
+ width: 90%;
+ text-align: center;
+ box-shadow: 0 20px 50px rgba(0,0,0,0.5);
+}
+.modal-content h2 { margin-bottom: 8px; color: #fff; }
+.modal-content p { color: var(--muted); margin-bottom: 18px; line-height: 1.5; }
+.modal-content input {
+ width: 100%;
+ padding: 13px;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ background: var(--bg-2);
+ color: var(--text);
+ font-size: 15px;
+ margin-bottom: 14px;
+ outline: none;
+ transition: border-color .15s, box-shadow .15s;
+}
+.modal-content input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(102,192,244,0.18); }
+.modal-content button {
+ width: 100%;
+ padding: 13px;
+ border: none;
+ border-radius: 8px;
+ background: linear-gradient(180deg, #7bd0f8, var(--accent));
+ color: #062030;
+ font-weight: 700;
+ font-size: 15px;
+ cursor: pointer;
+ box-shadow: 0 2px 8px rgba(102,192,244,0.3);
+ transition: filter .15s;
+}
+
diff --git a/static/js/game.js b/static/js/game.js
new file mode 100644
index 0000000..680429e
--- /dev/null
+++ b/static/js/game.js
@@ -0,0 +1,375 @@
+(() => {
+ const mode = document.body.dataset.mode; // 'daily' | 'unlimited'
+ const searchInput = document.getElementById("search-input");
+ const autocompleteBox = document.getElementById("autocomplete");
+ const boardBody = document.getElementById("board-body");
+ const attemptsInfo = document.getElementById("attempts-info");
+ const resultBox = document.getElementById("result-box");
+ const nicknameModal = document.getElementById("nickname-modal");
+ const playerNameEl = document.getElementById("player-name");
+ const streakLabel = document.getElementById("streak-label");
+
+ let player = PlayerStore.load();
+ let sessionId = null;
+ let maxAttempts = 10;
+ let finished = false;
+
+ // ---------- Spieler ----------
+ function showNicknameModal() {
+ if (!player) {
+ nicknameModal.classList.remove("hidden");
+ } else {
+ nicknameModal.classList.add("hidden");
+ playerNameEl.textContent = player.nickname;
+ }
+ renderStreaks();
+ }
+
+ async function submitNickname() {
+ const input = document.getElementById("nickname-input");
+ const nickname = input.value.trim();
+ if (!nickname) return;
+ const res = await fetch("/api/player", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ nickname }),
+ });
+ const data = await res.json();
+ if (data.player) {
+ player = data.player;
+ PlayerStore.save(player);
+ PlayerStore.setStreaks(data.streaks);
+ nicknameModal.classList.add("hidden");
+ playerNameEl.textContent = player.nickname;
+ renderStreaks();
+ startGame();
+ }
+ }
+
+ function renderStreaks() {
+ const streaks = PlayerStore.getStreaks();
+ if (!streaks) { streakLabel.textContent = ""; return; }
+ const s = mode === "daily" ? streaks.daily : streaks.unlimited;
+ if (!s) { streakLabel.textContent = ""; return; }
+ streakLabel.textContent = `🏆 ${s.win} Siege · 💀 ${s.loss} Niederlagen`;
+ }
+
+ // ---------- Spielstart ----------
+ async function startGame() {
+ if (!player) return;
+ const res = await fetch("/api/start", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ player_id: player.id, mode }),
+ });
+ const data = await res.json();
+ if (data.error) { alert(data.error); return; }
+ sessionId = data.session_id;
+ maxAttempts = data.max_attempts;
+ attemptsInfo.textContent = `Versuche: 0 / ${maxAttempts}`;
+
+ if (data.already_played) {
+ finished = true;
+ showResult(data.status, data.target, data.attempts_used);
+ }
+ }
+
+ // ---------- Suche / Autocomplete ----------
+ let searchTimer = null;
+ let suggestions = [];
+ let selectedIndex = -1;
+
+ searchInput.addEventListener("input", () => {
+ clearTimeout(searchTimer);
+ const q = searchInput.value.trim();
+ if (!q) { closeAutocomplete(); return; }
+ searchTimer = setTimeout(async () => {
+ const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
+ suggestions = await res.json();
+ renderAutocomplete();
+ }, 200);
+ });
+
+ function renderAutocomplete() {
+ autocompleteBox.innerHTML = "";
+ selectedIndex = -1;
+ if (!suggestions.length) { closeAutocomplete(); return; }
+ suggestions.forEach((s, i) => {
+ const div = document.createElement("div");
+ div.className = "autocomplete-item";
+ div.innerHTML = `${escapeHtml(s.title)}${s.year}`;
+ div.addEventListener("mousedown", (e) => {
+ e.preventDefault();
+ selectSuggestion(i);
+ });
+ div.addEventListener("mouseenter", () => { selectedIndex = i; highlight(); });
+ autocompleteBox.appendChild(div);
+ });
+ autocompleteBox.classList.add("open");
+ }
+
+ function highlight() {
+ [...autocompleteBox.children].forEach((el, i) => {
+ el.classList.toggle("selected", i === selectedIndex);
+ });
+ }
+
+ function closeAutocomplete() {
+ autocompleteBox.classList.remove("open");
+ autocompleteBox.innerHTML = "";
+ }
+
+ function selectSuggestion(i) {
+ const s = suggestions[i];
+ if (!s) return;
+ closeAutocomplete();
+ submitGuess(s);
+ }
+
+ searchInput.addEventListener("keydown", (e) => {
+ if (!autocompleteBox.classList.contains("open")) return;
+ if (e.key === "ArrowDown") { e.preventDefault(); selectedIndex = Math.min(selectedIndex + 1, suggestions.length - 1); highlight(); }
+ else if (e.key === "ArrowUp") { e.preventDefault(); selectedIndex = Math.max(selectedIndex - 1, 0); highlight(); }
+ else if (e.key === "Enter") { e.preventDefault(); selectSuggestion(selectedIndex); }
+ else if (e.key === "Escape") { closeAutocomplete(); }
+ });
+
+ document.addEventListener("click", (e) => {
+ if (!searchWrapperContains(e.target)) closeAutocomplete();
+ });
+ function searchWrapperContains(t) {
+ const w = document.getElementById("search-wrapper");
+ return w.contains(t);
+ }
+
+ // ---------- Tipp abgeben ----------
+ async function submitGuess(movie) {
+ if (finished || !sessionId) return;
+ searchInput.value = "";
+ const res = await fetch("/api/guess", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ session_id: sessionId, movie_id: movie.id }),
+ });
+ const data = await res.json();
+ if (data.error) {
+ if (data.status) {
+ finished = true;
+ showResult(data.status, null, data.attempts_used || 0);
+ } else {
+ alert(data.error);
+ }
+ return;
+ }
+ attemptsInfo.textContent = `Versuche: ${data.attempts_used} / ${data.max_attempts}`;
+ addRow(data.row);
+
+ if (data.finished) {
+ finished = true;
+ if (data.streaks) {
+ PlayerStore.setStreaks(data.streaks);
+ renderStreaks();
+ }
+ showResult(data.status, data.target, data.attempts_used);
+ }
+ }
+
+ // ---------- Icons (Gamedle-/Steam-Stil) ----------
+ const ICONS = {
+ platforms: {
+ "Netflix": ["📺", "#e50914"],
+ "Disney+": ["🐭", "#3f6fc9"],
+ "Prime Video": ["📦", "#00a8e1"],
+ "Max": ["🌊", "#6a4fa3"],
+ "Apple TV+": ["🍎", "#9aa0a6"],
+ "Paramount+": ["🏔️", "#0f5cab"],
+ "Blu-ray": ["💿", "#2f6fbe"],
+ "DVD": ["💿", "#8f959b"],
+ "4K UHD": ["🖥️", "#7f5cd6"],
+ "Cinema": ["🍿", "#e53935"],
+ },
+ genres: {
+ "Action": ["💥", "#ff7043"],
+ "Abenteuer": ["🗺️", "#a1887f"],
+ "Animation": ["🎨", "#ab47bc"],
+ "Komödie": ["😂", "#f6c445"],
+ "Krimi": ["🕵️", "#5c6bc0"],
+ "Drama": ["🎭", "#7e8b98"],
+ "Fantasy": ["🐉", "#7e57c2"],
+ "Horror": ["👻", "#546e7a"],
+ "Mystery": ["🔍", "#26a69a"],
+ "Romantik": ["💘", "#ec407a"],
+ "Sci-Fi": ["🚀", "#29b6f6"],
+ "Thriller": ["🔪", "#8d2f2f"],
+ "Western": ["🤠", "#a1887f"],
+ "Musical": ["🎵", "#f06292"],
+ "Krieg": ["🪖", "#6d4c41"],
+ "Biografie": ["📖", "#b0bec5"],
+ },
+ studios: {
+ "Warner Bros": ["🎞️", "#3f6fc9"],
+ "Disney": ["🏰", "#2f6fbe"],
+ "Universal": ["🌐", "#1d8aaa"],
+ "Paramount": ["🏔️", "#3c5a93"],
+ "Sony Pictures": ["🎥", "#7f5cd6"],
+ "20th Century": ["🦊", "#e0892f"],
+ "MGM": ["🦁", "#c2a845"],
+ "Lionsgate": ["🦁", "#9aa0a6"],
+ "A24": ["⏳", "#e2a11f"],
+ "DreamWorks": ["🌙", "#5b6b8c"],
+ "New Line": ["🟦", "#2f6fbe"],
+ "Orion": ["⭐", "#c9c9c9"],
+ "Neon": ["⚡", "#1fb8e0"],
+ "CJ Entertainment": ["🎬", "#c93030"],
+ "Show East": ["🎥", "#666"],
+ "Dimension": ["🌀", "#9c27b0"],
+ "Netflix": ["📺", "#e50914"],
+ },
+ universes: {
+ "MCU": ["🛡️", "#d63031"],
+ "Star Wars": ["🎆", "#3a3f47"],
+ "DC": ["🦇", "#1f2937"],
+ "Wizarding World": ["⚡", "#7e57c2"],
+ "Fast & Furious": ["🏎️", "#37474f"],
+ "Herr der Ringe": ["💍", "#a1887f"],
+ "Spider-Man": ["🕷️", "#c62828"],
+ },
+ };
+
+ function iconFor(map, name) {
+ if (!name || !map[name]) return "▪️";
+ return map[name][0];
+ }
+ function colorFor(map, name) {
+ if (!map || !name || !map[name]) return null;
+ return map[name][1];
+ }
+
+ // Badge mit Icon + Label
+ function badge(map, name) {
+ if (!name) return "";
+ const color = colorFor(map, name);
+ const style = color ? `style="--tag:${color}"` : "";
+ return `${iconFor(map, name)}${escapeHtml(name)}`;
+ }
+
+ function addRow(row) {
+ const tr = document.createElement("tr");
+
+ const titleTd = document.createElement("td");
+ titleTd.className = "title-cell";
+ titleTd.textContent = row.title;
+ tr.appendChild(titleTd);
+
+ tr.appendChild(cell(row.platforms.value.map(v => badge(ICONS.platforms, v)).join(""), row.platforms.status));
+ tr.appendChild(cell(row.genres.value.map(v => badge(ICONS.genres, v)).join(""), row.genres.status));
+ tr.appendChild(cell(yearHtml(row.year), row.year.status));
+ tr.appendChild(cell(fskHtml(row.fsk.value), row.fsk.status));
+ tr.appendChild(cell(badge(ICONS.studios, row.studio.value), row.studio.status));
+ tr.appendChild(cell(row.director.value ? `${escapeHtml(row.director.value)}` : `–`, row.director.status));
+ tr.appendChild(cell(budgetHtml(row.budget), row.budget.status));
+ tr.appendChild(cell(badge(ICONS.universes, row.universe.value), row.universe.status));
+
+ boardBody.prepend(tr);
+ }
+
+ function yearHtml(row) {
+ return `${row.value}${arrowDiff(row.direction, row.diff)}`;
+ }
+
+ function fskHtml(fsk) {
+ if (fsk == null) return `–`;
+ return `${fsk}`;
+ }
+
+ function budgetHtml(row) {
+ if (row.value == null) return `–`;
+ return `${formatMoney(row.value)}${arrowMoney(row.direction, row.diff)}`;
+ }
+
+ // Pfeil mit Zahl (z.B. Jahr): ▲ 5 / ▼ 3
+ function arrowDiff(dir, diff) {
+ if (dir === "equal" || diff == null) return "";
+ const cls = dir === "up" ? "up" : "down";
+ const gly = dir === "up" ? "▲" : "▼";
+ return `${gly} ${Math.abs(diff)}`;
+ }
+
+ // Pfeil mit Differenz in Geld (Budget)
+ function arrowMoney(dir, diff) {
+ if (dir === "equal" || diff == null) return "";
+ const cls = dir === "up" ? "up" : "down";
+ const gly = dir === "up" ? "▲" : "▼";
+ const n = Math.abs(diff);
+ const txt = n >= 1000000 ? (n / 1000000).toFixed(0) + " Mio $" : Math.round(n / 1000) + " Tsd $";
+ return `${gly} ${txt}`;
+ }
+
+ function cell(inner, status) {
+ const td = document.createElement("td");
+ td.innerHTML = inner;
+ td.classList.add("cell", status);
+ return td;
+ }
+
+ function formatMoney(n) {
+ if (n >= 1000000) return (n / 1000000).toFixed(0) + " Mio $";
+ return n.toLocaleString("de-DE") + " $";
+ }
+
+ // ---------- Ergebnis ----------
+ function showResult(status, target, attemptsUsed) {
+ resultBox.classList.remove("hidden", "won", "lost");
+ if (status === "won") {
+ resultBox.classList.add("won");
+ resultBox.innerHTML = `
🎉 Gewonnen! Du hast den Film in ${attemptsUsed} Versuch(en) erraten.
${target ? `${escapeHtml(target.title)} (${target.year})
` : ""}${newGameButton()}`;
+ } else {
+ resultBox.classList.add("lost");
+ resultBox.innerHTML = `😞 Verloren! Du hattest ${attemptsUsed} Versuch(e).
${target ? `Der gesuchte Film war: ${escapeHtml(target.title)} (${target.year})
` : ""}${newGameButton()}`;
+ }
+ }
+
+ function newGameButton() {
+ const label = mode === "unlimited" ? "Neues Spiel" : "Zurück zu Täglich";
+ if (mode === "unlimited") {
+ return ``;
+ }
+ return "";
+ }
+
+ document.addEventListener("click", async (e) => {
+ if (e.target.id === "nickname-submit") await submitNickname();
+ if (e.target.id === "new-game-btn") {
+ finished = false;
+ resultBox.classList.add("hidden");
+ resultBox.innerHTML = "";
+ boardBody.innerHTML = "";
+ const res = await fetch("/api/new-game", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ player_id: player.id }),
+ });
+ const data = await res.json();
+ sessionId = data.session_id;
+ maxAttempts = data.max_attempts;
+ attemptsInfo.textContent = `Versuche: 0 / ${maxAttempts}`;
+ }
+ });
+
+ document.getElementById("nickname-input").addEventListener("keydown", (e) => {
+ if (e.key === "Enter") submitNickname();
+ });
+
+ // ---------- Init ----------
+ function escapeHtml(s) {
+ if (s == null) return "";
+ const div = document.createElement("div");
+ div.textContent = String(s);
+ return div.innerHTML;
+ }
+
+ window.addEventListener("load", () => {
+ showNicknameModal();
+ if (player) startGame();
+ });
+})();
diff --git a/static/js/player.js b/static/js/player.js
new file mode 100644
index 0000000..7c3c732
--- /dev/null
+++ b/static/js/player.js
@@ -0,0 +1,36 @@
+// Spieler-Verwaltung (Nickname -> player_id in localStorage)
+const PlayerStore = (() => {
+ const KEY = "filmdle_player";
+ const STREAK_KEY = "filmdle_streaks";
+
+ function save(player) {
+ localStorage.setItem(KEY, JSON.stringify(player));
+ }
+
+ function load() {
+ try {
+ return JSON.parse(localStorage.getItem(KEY));
+ } catch (e) {
+ return null;
+ }
+ }
+
+ function getStreaks() {
+ try {
+ return JSON.parse(localStorage.getItem(STREAK_KEY));
+ } catch (e) {
+ return null;
+ }
+ }
+
+ function setStreaks(streaks) {
+ localStorage.setItem(STREAK_KEY, JSON.stringify(streaks));
+ }
+
+ function clear() {
+ localStorage.removeItem(KEY);
+ localStorage.removeItem(STREAK_KEY);
+ }
+
+ return { save, load, getStreaks, setStreaks, clear };
+})();
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..9060b10
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,69 @@
+
+
+
+
+
+ Filmdle – Film Quiz
+
+
+
+
+
+
+
+
+
+
Willkommen bei Filmdle!
+
Gib einen Spielernamen ein, um deine Sieg- und Niederlagen-Str eaks zu speichern.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Titel |
+ Plattformen |
+ Genres |
+ Jahr |
+ FSK |
+ Studio |
+ Regisseur |
+ Budget |
+ Universum |
+
+
+
+
+
+
+
+
+
+
+
+
+
+