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

212 lines
6.5 KiB
Python

"""Filmvorschläge fürs Rad.
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 .db import connect
from .users import besitzer
SCHEMA = """
CREATE TABLE IF NOT EXISTS suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
movie_id INTEGER NOT NULL,
title TEXT NOT NULL,
year INTEGER,
poster_path TEXT,
name TEXT,
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 = 4.2
# Ein Vorschlag je Person. Früher stand das UNIQUE auf der Sitzung — dann
# blockierte der Vorschlag des einen Namens den des nächsten, sobald beide
# denselben Browser benutzten.
INDIZES = """
CREATE UNIQUE INDEX IF NOT EXISTS suggestions_je_benutzer
ON suggestions (user_id) WHERE user_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS suggestions_je_sitzung
ON suggestions (session_id) WHERE user_id IS NULL;
"""
def _unique_loesen(conn):
"""Das alte UNIQUE steckt in der Spaltendefinition — Tabelle neu bauen."""
sql = conn.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='suggestions'"
).fetchone()
if not sql or "UNIQUE" not in sql["sql"]:
return
conn.executescript(
"""
ALTER TABLE suggestions RENAME TO suggestions_alt;
CREATE TABLE 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
);
INSERT INTO suggestions
(id, session_id, user_id, movie_id, title, year, poster_path,
name, created_at)
SELECT id, session_id, user_id, movie_id, title, year, poster_path,
name, created_at FROM suggestions_alt;
DROP TABLE suggestions_alt;
"""
)
def _spalte_user_id(conn, tabelle):
"""Nachrüsten und aus der Sitzung füllen — einmalig je Datenbank."""
spalten = {r[1] for r in conn.execute(f"PRAGMA table_info({tabelle})")}
if "user_id" in spalten:
return
conn.execute(f"ALTER TABLE {tabelle} ADD COLUMN user_id INTEGER")
conn.execute(
f"UPDATE {tabelle} SET user_id = ("
" SELECT user_id FROM user_sessions s WHERE s.session_id = "
f" {tabelle}.session_id)"
)
def init():
with connect() as conn:
conn.executescript(SCHEMA)
_spalte_user_id(conn, "suggestions")
_unique_loesen(conn)
conn.executescript(INDIZES)
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
]
# Mit Namen zählt der Name, ohne Namen die Sitzung
WEM = "(user_id = ? OR (? IS NULL AND user_id IS NULL AND session_id = ?))"
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 ziehen():
"""Server lost aus, damit alle Seiten dasselbe Ergebnis sehen.
Der Zielwinkel gehört dazu: sonst dreht jede Seite woanders hin und
der Zeiger stünde am Ende auf einem anderen Segment.
"""
eintraege = alle()
if not eintraege:
return None
treffer = random.randrange(len(eintraege))
schritt = 360 / len(eintraege)
runden = 5 + random.randrange(3)
# Der Zeiger steht oben, also muss die Mitte des Zielsegments dorthin
winkel = runden * 360 + (360 - (treffer * schritt + schritt / 2))
return {
"index": treffer,
"winkel": winkel,
"dauer": DREH_DAUER,
"gewinner": {
k: v for k, v in eintraege[treffer].items() if k != "session_id"
},
"anzahl": len(eintraege),
}
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:
return conn.execute("DELETE FROM suggestions").rowcount