Files
horror/backend/app/suggestions.py
2026-09-29 00:54:58 +02:00

248 lines
8.1 KiB
Python

"""Filmvorschläge für die Ziehung.
Jede Sitzung darf einen Film vorschlagen. Die Sitzung erkennt ein Cookie —
ohne Anmeldung geht es nicht genauer, und für einen Filmabend im Freundeskreis
reicht es.
Titel, Jahr und Poster werden mitgespeichert. So bleibt ein Vorschlag
lesbar, auch wenn der Film nie im Katalog war oder Netflix ihn abgibt.
"""
import json
import random
import sqlite3
import uuid
from datetime import datetime, timezone
from . import users
from .db import connect
from .users import WEM, besitzer
SCHEMA = """
CREATE TABLE IF NOT EXISTS suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
user_id INTEGER,
movie_id INTEGER NOT NULL,
title TEXT NOT NULL,
year INTEGER,
poster_path TEXT,
name TEXT,
created_at TEXT NOT NULL
);
"""
# Wer am Abend da ist, aber nichts vorschlagen will. Zusammen mit den
# Vorschlagenden ergibt das die Anwesenheit — die zählt beim Dreh.
DABEI = """
CREATE TABLE IF NOT EXISTS dabei (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
user_id INTEGER,
created_at TEXT NOT NULL
);
"""
COOKIE = "filmabend_sid"
# So lange läuft die Animation im Browser. Der Server merkt sich das
# Ergebnis mit, damit eine Seite, die mitten im Dreh geladen wird, es
# trotzdem bekommt.
DREH_DAUER = 11.2
# Die Ziehung läuft wie das Öffnen einer Lootbox: ein Streifen aus
# Postern rast vorbei und bremst auf dem Gewinner aus. Der Streifen ist
# so lang, und an dieser Stelle steht der Treffer.
STREIFEN_LAENGE = 64
STREIFEN_ZIEL = 56
# Manchmal bricht nach dem Halt die Halterung: die Bahn rutscht ein paar
# Poster zurück, und ein anderer gewinnt. So lange dauert das im Browser
# zusätzlich (Ruhe plus Rückfahrt — die Werte stehen auch in WheelTab.vue).
BRUCH_DAUER = 3.7
BRUCH_ANTEIL = 0.5
def init():
with connect() as conn:
conn.executescript(SCHEMA + DABEI)
# Ein Vorschlag je Person
users.je_person(conn, "suggestions", SCHEMA)
users.je_person(conn, "dabei", DABEI)
def neue_session():
return uuid.uuid4().hex
def alle():
with connect() as conn:
rows = conn.execute(
# Laufzeit und Genres stehen im Katalog. Ein Vorschlag von
# außerhalb hat sie nicht — die Liste zeigt dann nur den Titel.
"SELECT s.*, m.runtime, m.genres FROM suggestions s "
"LEFT JOIN movies m ON m.id = s.movie_id "
"ORDER BY s.created_at ASC"
).fetchall()
return [
{
**{k: v for k, v in dict(r).items() if k != "genres"},
"genres": json.loads(r["genres"]) if r["genres"] else [],
}
for r in rows
]
def von_session(session_id):
"""Der eigene Vorschlag — auch aus einem anderen Browser."""
ich = besitzer(session_id)
with connect() as conn:
row = conn.execute(
f"SELECT * FROM suggestions WHERE {WEM}", (ich, ich, session_id)
).fetchone()
return dict(row) if row else None
def anlegen(session_id, movie, name=None):
"""Gibt den Vorschlag zurück, oder None wenn die Sitzung schon einen hat."""
eintrag = {
"session_id": session_id,
"user_id": besitzer(session_id),
"movie_id": movie["id"],
"title": movie["title"],
"year": movie.get("year"),
"poster_path": movie.get("poster_path"),
"name": (name or "").strip()[:40] or None,
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
try:
with connect() as conn:
conn.execute(
"INSERT INTO suggestions "
"(session_id, user_id, movie_id, title, year, poster_path, "
"name, created_at) "
"VALUES (:session_id, :user_id, :movie_id, :title, :year, "
":poster_path, :name, :created_at)",
eintrag,
)
except sqlite3.IntegrityError:
return None
return von_session(session_id)
def ersetzen(session_id, movie, name=None):
"""Wer schon vorgeschlagen hat, darf seinen Vorschlag ändern."""
loeschen(session_id)
return anlegen(session_id, movie, name)
def _versatz():
"""Wo genau im Poster die Marke stehen bleibt — nie exakt mittig.
Jedes zweite Mal ganz knapp an der Kante: fast der andere."""
if random.random() < 0.5:
return round(random.choice((-1, 1)) * random.uniform(0.34, 0.46), 3)
return round(random.uniform(-0.3, 0.3), 3)
def ziehen():
"""Server lost aus, damit alle Seiten dasselbe Ergebnis sehen.
Der Streifen gehört dazu: sonst zeigt jede Seite andere Poster
vorbeirauschen, und der Treffer stünde am Ende woanders.
"""
eintraege = alle()
if not eintraege:
return None
# Schon gelaufene Filme überspringen. Sind alle Vorschläge gesehen,
# bleibt es bei der vollen Auswahl — sonst gäbe es keinen Gewinner.
with connect() as conn:
gesehen = {
r["movie_id"] for r in conn.execute("SELECT movie_id FROM watched")
}
frisch = [i for i, e in enumerate(eintraege) if e["movie_id"] not in gesehen]
treffer = random.choice(frisch) if frisch else random.randrange(len(eintraege))
# Blockweise gemischt: jeder Vorschlag kommt gleich oft vorbei
streifen = []
while len(streifen) < STREIFEN_LAENGE:
block = list(range(len(eintraege)))
random.shuffle(block)
streifen.extend(block)
streifen = streifen[:STREIFEN_LAENGE]
# Wo die Bahn hält — beim Bruch zweimal, und erst der zweite Halt zählt
stopps = [{"position": STREIFEN_ZIEL, "versatz": _versatz()}]
ziel = STREIFEN_ZIEL
if random.random() < BRUCH_ANTEIL:
ziel = STREIFEN_ZIEL - random.randint(1, 4)
stopps.append({"position": ziel, "versatz": _versatz()})
# Der erste Halt zeigt einen anderen — sonst wäre der Bruch egal
andere = [i for i in range(len(eintraege)) if i != treffer]
streifen[STREIFEN_ZIEL] = random.choice(andere) if andere else treffer
streifen[ziel] = treffer
def karte(e):
return {k: v for k, v in e.items() if k not in ("session_id", "user_id")}
return {
"index": treffer,
"streifen": streifen,
"stopps": stopps,
"dauer": DREH_DAUER + (BRUCH_DAUER if len(stopps) > 1 else 0),
# Der Streifen zeigt diesen Stand, auch wenn danach jemand einen
# Vorschlag dazutut
"karten": [karte(e) for e in eintraege],
"gewinner": karte(eintraege[treffer]),
}
def loeschen(session_id):
ich = besitzer(session_id)
with connect() as conn:
return conn.execute(
f"DELETE FROM suggestions WHERE {WEM}", (ich, ich, session_id)
).rowcount
def alles_loeschen():
"""Host setzt die Runde zurück."""
with connect() as conn:
conn.execute("DELETE FROM dabei")
return conn.execute("DELETE FROM suggestions").rowcount
def dabei_umschalten(session_id):
"""Dabei ohne Vorschlag — der zweite Klick nimmt es zurück.
Gibt zurück, ob es jetzt gesetzt ist."""
ich = besitzer(session_id)
with connect() as conn:
weg = conn.execute(
f"DELETE FROM dabei WHERE {WEM}", (ich, ich, session_id)
).rowcount
if weg:
return False
conn.execute(
"INSERT INTO dabei (session_id, user_id, created_at) VALUES (?, ?, ?)",
(session_id, ich, datetime.now(timezone.utc).isoformat(timespec="seconds")),
)
return True
def dabei_alle():
"""Wer sich ohne Vorschlag eingetragen hat — Namen und Kennungen."""
with connect() as conn:
rows = conn.execute(
"SELECT d.*, u.name FROM dabei d LEFT JOIN users u ON u.id = d.user_id "
"ORDER BY d.created_at ASC"
).fetchall()
return [dict(r) for r in rows]
def teilnehmer():
"""user_ids aller Anwesenden: Vorschlagende und Eingetragene."""
with connect() as conn:
ids = {r["user_id"] for r in conn.execute("SELECT user_id FROM suggestions")}
ids |= {r["user_id"] for r in conn.execute("SELECT user_id FROM dabei")}
return sorted(i for i in ids if i is not None)