Files
horror/backend/app/watched.py
2026-09-30 00:59:59 +02:00

445 lines
16 KiB
Python

"""Bereits geschaute Filme — das Buch der Abende.
Der Host trägt ein, was am Abend lief. Jeder Film bekommt eine Seite mit
zwei Teilen. Links die Momentaufnahme: eine Wertung je Person, nur Sterne,
so wie es direkt nach dem Abspann war. Rechts die Diskussion: Kommentare,
auf die man antworten kann, so tief man will.
Titel und Poster werden mitgespeichert, damit ein Eintrag lesbar bleibt,
auch wenn der Film später aus dem Katalog fällt. Szenenbild und Laufzeit
kommen aus dem Katalog.
"""
import json
from datetime import datetime, timezone
from . import users
from .db import connect
from .users import WEM, besitzer, gehoert_mir
SCHEMA = """
CREATE TABLE IF NOT EXISTS watched (
id INTEGER PRIMARY KEY AUTOINCREMENT,
movie_id INTEGER NOT NULL UNIQUE,
title TEXT NOT NULL,
year INTEGER,
poster_path TEXT,
watched_at TEXT NOT NULL,
hidden INTEGER NOT NULL DEFAULT 0
);
"""
RATINGS = """
CREATE TABLE IF NOT EXISTS ratings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
watched_id INTEGER NOT NULL,
session_id TEXT NOT NULL,
user_id INTEGER,
stars INTEGER NOT NULL,
name TEXT,
text TEXT,
created_at TEXT NOT NULL
);
"""
# Die Diskussion: ein Kommentar zum Abend, oder mit parent_id eine
# Antwort auf einen anderen Kommentar — beliebig tief
NOTES = """
CREATE TABLE IF NOT EXISTS watched_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
watched_id INTEGER NOT NULL,
parent_id INTEGER,
session_id TEXT NOT NULL,
user_id INTEGER,
text TEXT NOT NULL,
created_at TEXT NOT NULL
);
"""
# Ein Herz je Person und Kommentar: "sehe ich auch so". Alle sehen die
# Zahl, wer es gegeben hat, sieht nur der Host.
HEARTS = """
CREATE TABLE IF NOT EXISTS watched_hearts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
session_id TEXT NOT NULL,
user_id INTEGER,
created_at TEXT NOT NULL
);
"""
# Wer am Abend dabei war — kommt aus der Runde am Rad, sobald der Host
# den gezogenen Film einträgt. Daraus rechnen sich die Punkte.
DABEI = """
CREATE TABLE IF NOT EXISTS watched_dabei (
watched_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
PRIMARY KEY (watched_id, user_id)
);
"""
MAX_STERNE = 5
# Manche schreiben eine ganze Rezension — rund 600 Wörter passen hinein
MAX_KOMMENTAR = 4000
# Was das Gästebuch aus dem Katalog dazuholt
AUS_KATALOG = ("backdrop_path", "images", "runtime", "genres")
def init():
with connect() as conn:
conn.executescript(SCHEMA + RATINGS + NOTES + HEARTS + DABEI)
# Eine Stimme je Person und Film, ein Herz je Person und Kommentar
users.je_person(conn, "ratings", RATINGS, "watched_id")
users.je_person(conn, "watched_hearts", HEARTS, "note_id")
# Kurz hingen Antworten an der Wertung statt aneinander. Daten gab
# es dazu nie, die Spalte bekommt nur ihren neuen Namen.
spalten = {r["name"] for r in conn.execute("PRAGMA table_info(watched_notes)")}
if "rating_id" in spalten:
conn.execute("ALTER TABLE watched_notes RENAME COLUMN rating_id TO parent_id")
# Eine Weile stand ein Text an der Wertung. Seit die Sterne allein
# die Wertung sind, gehört er in die Diskussion — einmalig umziehen.
alte = conn.execute(
"SELECT * FROM ratings WHERE text IS NOT NULL AND text != ''"
).fetchall()
for r in alte:
conn.execute(
"INSERT INTO watched_notes "
"(watched_id, parent_id, session_id, user_id, text, created_at) "
"VALUES (?, NULL, ?, ?, ?, ?)",
(r["watched_id"], r["session_id"], r["user_id"], r["text"], r["created_at"]),
)
if alte:
conn.execute("UPDATE ratings SET text = NULL")
# Die Tabelle stand vor der Spalte schon in bestehenden Datenbanken
spalten = {r["name"] for r in conn.execute("PRAGMA table_info(watched)")}
if "hidden" not in spalten:
conn.execute(
"ALTER TABLE watched ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0"
)
# Ein Satz zur Wertung kam später dazu
spalten = {r["name"] for r in conn.execute("PRAGMA table_info(ratings)")}
if "text" not in spalten:
conn.execute("ALTER TABLE ratings ADD COLUMN text TEXT")
def _jetzt():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def alle(session_id, host=False):
"""Zuletzt geschaut zuerst, mit Schnitt, eigener Stimme, Kommentaren
und wer dabei war. Die laufende Nummer zählt vom ersten Abend.
Wer wie gewertet und wer ein Herz gegeben hat, sieht nur der Host —
für die Runde bleibt die Wertung anonym, das nimmt Hemmung."""
felder = ", ".join(f"m.{f}" for f in AUS_KATALOG)
with connect() as conn:
filme = conn.execute(
f"SELECT w.*, {felder} FROM watched w "
"LEFT JOIN movies m ON m.id = w.movie_id "
"ORDER BY w.watched_at DESC, w.id DESC"
).fetchall()
stimmen = conn.execute(
"SELECT id, watched_id, session_id, user_id, stars, name, created_at "
"FROM ratings ORDER BY created_at ASC"
).fetchall()
notizen = conn.execute(
"SELECT n.*, u.name FROM watched_notes n "
"LEFT JOIN users u ON u.id = n.user_id "
"ORDER BY n.created_at ASC"
).fetchall()
herzen = conn.execute(
"SELECT h.note_id, h.session_id, h.user_id, u.name FROM watched_hearts h "
"LEFT JOIN users u ON u.id = h.user_id ORDER BY h.created_at ASC"
).fetchall()
anwesend = conn.execute(
"SELECT d.watched_id, u.id, u.name FROM watched_dabei d "
"JOIN users u ON u.id = d.user_id ORDER BY u.name COLLATE NOCASE"
).fetchall()
ich = besitzer(session_id)
herz_je_notiz = {}
for h in herzen:
herz_je_notiz.setdefault(h["note_id"], []).append(h)
dabei_je_film = {}
for d in anwesend:
dabei_je_film.setdefault(d["watched_id"], []).append({"id": d["id"], "name": d["name"]})
def notiz(n):
meine_herzen = herz_je_notiz.get(n["id"], [])
return {
"id": n["id"],
"text": n["text"],
"name": n["name"],
"created_at": n["created_at"],
"mine": gehoert_mir(n, session_id, ich),
"replies": [],
"hearts": len(meine_herzen),
"hearted": any(gehoert_mir(h, session_id, ich) for h in meine_herzen),
"hearted_by": [h["name"] or "Anonym" for h in meine_herzen] if host else [],
}
# Die Kommentare eines Films als Baum: jede Antwort hängt sich an
# ihren Vater, was keinen hat, steht oben. Die Liste kommt nach
# Zeit sortiert, also ist der Vater immer schon da.
knoten = {n["id"]: notiz(n) for n in notizen}
kommentare = {}
for n in notizen:
k = knoten[n["id"]]
vater = knoten.get(n["parent_id"]) if n["parent_id"] is not None else None
if vater:
vater["replies"].append(k)
else:
kommentare.setdefault(n["watched_id"], []).append(k)
je_film = {}
for s in stimmen:
je_film.setdefault(s["watched_id"], []).append(s)
out = []
for nummer, film in zip(range(len(filme), 0, -1), filme):
eigene = je_film.get(film["id"], [])
werte = [s["stars"] for s in eigene]
meine = next((s for s in eigene if gehoert_mir(s, session_id, ich)), None)
daten = dict(film)
daten["images"] = json.loads(daten["images"]) if daten["images"] else []
daten["genres"] = json.loads(daten["genres"]) if daten["genres"] else []
out.append(
{
**daten,
"nummer": nummer,
"ratings": [
{
"id": s["id"],
"stars": s["stars"],
"name": s["name"],
"created_at": s["created_at"],
"mine": gehoert_mir(s, session_id, ich),
}
for s in eigene
]
if host
else [],
"dabei": dabei_je_film.get(film["id"], []),
"notes": kommentare.get(film["id"], []),
"average": round(sum(werte) / len(werte), 1) if werte else None,
"count": len(werte),
"mine": meine["stars"] if meine else None,
}
)
return out
def juengster():
"""Der zuletzt geschaute Abend — für Marken ohne Dreh."""
with connect() as conn:
row = conn.execute(
"SELECT * FROM watched ORDER BY watched_at DESC, id DESC LIMIT 1"
).fetchone()
return dict(row) if row else None
def von_film(movie_id):
with connect() as conn:
row = conn.execute("SELECT * FROM watched WHERE movie_id = ?", (movie_id,)).fetchone()
return dict(row) if row else None
def markieren(movie):
"""Film als geschaut eintragen. Ein zweiter Klick ändert nichts."""
with connect() as conn:
conn.execute(
"INSERT OR IGNORE INTO watched "
"(movie_id, title, year, poster_path, watched_at) "
"VALUES (?, ?, ?, ?, ?)",
(
movie["id"],
movie["title"],
movie.get("year"),
movie.get("poster_path"),
_jetzt(),
),
)
row = conn.execute(
"SELECT * FROM watched WHERE movie_id = ?", (movie["id"],)
).fetchone()
return dict(row)
def dabei_setzen(watched_id, user_ids):
"""Die Anwesenheit eines Abends — ersetzt die bisherige Liste."""
with connect() as conn:
conn.execute("DELETE FROM watched_dabei WHERE watched_id = ?", (watched_id,))
conn.executemany(
"INSERT OR IGNORE INTO watched_dabei (watched_id, user_id) VALUES (?, ?)",
[(watched_id, i) for i in user_ids],
)
def _herzen_weg(conn, note_ids):
conn.executemany(
"DELETE FROM watched_hearts WHERE note_id = ?", [(i,) for i in note_ids]
)
def entfernen(watched_id):
"""Samt Wertungen, Kommentaren, Herzen und Anwesenheit."""
with connect() as conn:
notizen = [
r["id"]
for r in conn.execute("SELECT id FROM watched_notes WHERE watched_id = ?", (watched_id,))
]
_herzen_weg(conn, notizen)
conn.execute("DELETE FROM watched_dabei WHERE watched_id = ?", (watched_id,))
conn.execute("DELETE FROM watched_notes WHERE watched_id = ?", (watched_id,))
conn.execute("DELETE FROM ratings WHERE watched_id = ?", (watched_id,))
return conn.execute(
"DELETE FROM watched WHERE id = ?", (watched_id,)
).rowcount
def datum_setzen(watched_id, tag):
"""tag im Format JJJJ-MM-TT. Mittags in UTC, damit die Anzeige in
keiner Zeitzone auf den Nachbartag rutscht."""
with connect() as conn:
return conn.execute(
"UPDATE watched SET watched_at = ? WHERE id = ?",
(f"{tag}T12:00:00+00:00", watched_id),
).rowcount
def sichtbarkeit(watched_id, versteckt):
"""Versteckte Filme sieht nur der Host."""
with connect() as conn:
return conn.execute(
"UPDATE watched SET hidden = ? WHERE id = ?",
(1 if versteckt else 0, watched_id),
).rowcount
def bewerten(watched_id, session_id, stars, name=None):
"""Sterne setzen. Gibt None zurück, wenn der Film nicht in der Liste
steht."""
with connect() as conn:
da = conn.execute(
"SELECT 1 FROM watched WHERE id = ?", (watched_id,)
).fetchone()
if not da:
return None
ich = besitzer(session_id)
# Eine Stimme je Person — die eigene alte fliegt vorher raus
conn.execute(
f"DELETE FROM ratings WHERE watched_id = ? AND {WEM}",
(watched_id, ich, ich, session_id),
)
conn.execute(
"INSERT INTO ratings "
"(watched_id, session_id, user_id, stars, name, created_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(watched_id, session_id, ich, stars, (name or "").strip()[:40] or None, _jetzt()),
)
return {"watched_id": watched_id, "stars": stars}
def stimme_loeschen(rating_id):
"""Der Host räumt eine einzelne Wertung weg."""
with connect() as conn:
return conn.execute(
"DELETE FROM ratings WHERE id = ?", (rating_id,)
).rowcount
def anmerken(watched_id, session_id, text, parent_id=None):
"""Kommentar zum Abend, oder mit parent_id eine Antwort auf einen
anderen. Gibt None zurück, wenn Film oder Vater fehlen."""
text = (text or "").strip()[:MAX_KOMMENTAR]
if not text:
return None
with connect() as conn:
da = conn.execute(
"SELECT 1 FROM watched WHERE id = ?", (watched_id,)
).fetchone()
if not da:
return None
if parent_id is not None:
passt = conn.execute(
"SELECT 1 FROM watched_notes WHERE id = ? AND watched_id = ?",
(parent_id, watched_id),
).fetchone()
if not passt:
return None
cur = conn.execute(
"INSERT INTO watched_notes "
"(watched_id, parent_id, session_id, user_id, text, created_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(watched_id, parent_id, session_id, besitzer(session_id), text, _jetzt()),
)
return cur.lastrowid
def _nachkommen(conn, note_id):
"""Alle Antworten unter einem Kommentar, beliebig tief."""
gefunden, offen = [], [note_id]
while offen:
kinder = [
r["id"]
for r in conn.execute(
f"SELECT id FROM watched_notes WHERE parent_id IN "
f"({','.join('?' * len(offen))})",
offen,
)
]
gefunden.extend(kinder)
offen = kinder
return gefunden
def anmerkung_loeschen(note_id, session_id, host):
"""Der Verfasser und der Host dürfen weg damit — samt allem, was
darunter hängt. Eine Antwort ohne ihren Anlass ergäbe keinen Sinn."""
ich = besitzer(session_id)
with connect() as conn:
zeile = conn.execute(
"SELECT * FROM watched_notes WHERE id = ?", (note_id,)
).fetchone()
if not zeile:
return 0
if not host and not gehoert_mir(zeile, session_id, ich):
return 0
alle_ids = [note_id, *_nachkommen(conn, note_id)]
_herzen_weg(conn, alle_ids)
conn.executemany(
"DELETE FROM watched_notes WHERE id = ?", [(i,) for i in alle_ids]
)
return len(alle_ids)
def herz(session_id, note_id):
"""Herz setzen oder zurücknehmen. None, wenn es den Kommentar nicht
gibt, sonst ob das Herz jetzt steht."""
ich = besitzer(session_id)
with connect() as conn:
weg = conn.execute(
f"DELETE FROM watched_hearts WHERE note_id = ? AND {WEM}",
(note_id, ich, ich, session_id),
).rowcount
if weg:
return False
da = conn.execute("SELECT 1 FROM watched_notes WHERE id = ?", (note_id,)).fetchone()
if not da:
return None
conn.execute(
"INSERT INTO watched_hearts (note_id, session_id, user_id, created_at) "
"VALUES (?, ?, ?, ?)",
(note_id, session_id, ich, _jetzt()),
)
return True
def ids():
"""movie_ids der geschauten Filme, für die Detailansicht."""
with connect() as conn:
return [r["movie_id"] for r in conn.execute("SELECT movie_id FROM watched")]