This commit is contained in:
team3
2026-09-17 15:40:28 +02:00
parent a1684cf54f
commit ea54a86db4
30 changed files with 3680 additions and 989 deletions

View File

@@ -9,6 +9,11 @@ load_dotenv(ROOT / ".env")
TMDB_TOKEN = os.environ["TMDB_TOKEN"]
MINIMAX_KEY = os.environ["MINIMAX_KEY"]
# Wer den Hostmodus einschalten darf. Einmal /?host=<schlüssel> aufrufen,
# danach steckt es in der Sitzung. Ohne Eintrag kommt niemand an die
# Hostknöpfe — auch nicht über die Tastenkombination.
HOST_SCHLUESSEL = os.getenv("HOST_SCHLUESSEL", "")
TMDB_BASE = "https://api.themoviedb.org/3"
IMAGE_BASE = "https://image.tmdb.org/t/p"

View File

@@ -116,10 +116,25 @@ def existing_netflix_ids(conn):
return {row["id"]: row["netflix_id"] for row in rows}
# Sammlung, Rad und Gesehen zeigen auch Filme, die nie auf Netflix liefen.
# Deren Daten stehen trotzdem im Katalog — sonst blieben die Karten leer.
BENUTZT = ("wishlist", "suggestions", "watched")
def benutzte_ids(conn):
"""Alle movie_ids, an denen jemand hängt."""
teile = " UNION ".join(f"SELECT movie_id FROM {t}" for t in BENUTZT)
return {r[0] for r in conn.execute(teile)}
def drop_missing(conn, keep_ids):
"""Was weder auf Netflix läuft noch im Kanon steht, fliegt raus."""
"""Was weder auf Netflix läuft noch im Kanon steht, fliegt raus.
Ausgenommen, was jemand gemerkt, vorgeschlagen oder geschaut hat: die
Karten dazu holen ihre Daten aus dieser Tabelle.
"""
current = {r["id"] for r in conn.execute("SELECT id FROM movies").fetchall()}
gone = current - set(keep_ids)
gone = current - set(keep_ids) - benutzte_ids(conn)
if gone:
conn.executemany("DELETE FROM movies WHERE id = ?", [(i,) for i in gone])
return len(gone)

View File

@@ -1,5 +1,6 @@
import asyncio
import logging
import secrets
import time
from apscheduler.schedulers.asyncio import AsyncIOScheduler
@@ -22,7 +23,7 @@ from . import (
watched,
wishlist,
)
from .config import IMAGE_BASE, STATIC_DIR
from .config import HOST_SCHLUESSEL, IMAGE_BASE, STATIC_DIR
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
# Ein einzelner Zuschauer erzeugt gut zwanzig Stream-Anfragen je Sekunde.
@@ -93,12 +94,19 @@ async def startup():
features.init()
# Erst die Sicherung, dann der Sync — so liegt vor jedem Katalog-Update
# ein sauberer Stand
if not HOST_SCHLUESSEL:
log.warning(
"HOST_SCHLUESSEL fehlt in der .env — der Hostmodus bleibt zu."
)
scheduler.add_job(sicherung, "cron", hour=4, minute=0, id="daily-backup")
scheduler.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync")
scheduler.start()
if not db.get_meta("last_sync"):
log.info("Leere Datenbank, starte ersten Sync im Hintergrund")
asyncio.create_task(guarded_sync())
else:
# Altbestand: gemerkte Filme, die noch keine Katalogdaten haben
asyncio.create_task(sync.katalog_nachtragen())
@app.on_event("shutdown")
@@ -364,14 +372,21 @@ def vorschlag_loeschen(response: Response, filmabend_sid: str | None = Cookie(No
async def film_holen(movie_id: int):
"""Erst der Katalog, dann TMDB — Suchtreffer stehen nicht in der DB."""
"""Erst der Katalog, dann TMDB — Suchtreffer stehen nicht in der DB.
Was von TMDB kommt, landet gleich im Katalog. Sonst blieben die Karten
in Sammlung, Rad und Gesehen leer: die lesen ihre Daten von dort.
"""
movie = db.get_movie(movie_id)
if movie:
return movie
try:
return await search.detail(movie_id)
movie = await search.detail(movie_id)
except Exception:
raise HTTPException(status_code=404, detail="Film nicht gefunden")
with db.connect() as conn:
db.upsert(conn, movie)
return movie
@app.get("/api/features")
@@ -493,7 +508,11 @@ def vergessen(
@app.get("/api/users")
def runde(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
return {"items": users.alle(), "mine": users.aktuell(sid)}
return {
"items": users.alle(),
"mine": users.aktuell(sid),
"host": users.ist_host(sid),
}
@app.post("/api/users")
@@ -514,17 +533,116 @@ def name_entfernen(user_id: int):
return {"removed": 1}
@app.get("/api/users/{user_id}/schutz")
async def schutz_zeigen(
user_id: int,
response: Response,
filmabend_sid: str | None = Cookie(None),
):
"""Welcher Film hinterlegt ist — nur für den Host und den Namen selbst.
Wer ihn ändern darf, darf ihn auch sehen: sonst müsste man raten, was
man gerade überschreibt.
"""
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid) and users.besitzer(sid) != user_id:
raise HTTPException(status_code=403, detail="Nicht dein Name")
movie_id = users.schutz_von(user_id)
if movie_id is None:
return {"film": None}
film = await film_holen(movie_id)
return {
"film": {
"id": film["id"],
"title": film["title"],
"year": film.get("year"),
"poster_path": film.get("poster_path"),
}
}
@app.post("/api/users/{user_id}/schutz")
def schutz_setzen(
user_id: int,
response: Response,
movie_id: int | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Anmeldefilm hinterlegen — oder mit movie_id null wieder freigeben.
Der Host pflegt die Runde, sonst darf jeder nur den eigenen Namen
ändern. Wer angemeldet ist, hat den alten Film ohnehin gewusst.
"""
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid) and users.besitzer(sid) != user_id:
raise HTTPException(status_code=403, detail="Nicht dein Name")
if not users.schutz_setzen(user_id, movie_id):
raise HTTPException(status_code=404, detail="Unbekannter Name")
events.senden("users")
return {"geschuetzt": movie_id is not None}
@app.get("/api/host")
def host_stand(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
return {"host": users.ist_host(sid)}
@app.post("/api/host")
def host_modus(
response: Response,
an: bool = Body(..., embed=True),
schluessel: str | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Die Sitzung als Host freischalten — oder die Rechte abgeben.
Freischalten geht nur mit dem Schlüssel aus der .env, einmal über
/?host=<schlüssel>. Ohne das käme jeder mit der Tastenkombination an
den Anmeldefilmen vorbei.
Die Freischaltung bleibt, bis jemand sie abgibt. Ob die Hostknöpfe
gerade sichtbar sind, entscheidet der Browser für sich — dafür ist
diese Route nicht zuständig.
"""
sid = sitzung(response, filmabend_sid)
if not an:
return {"host": users.host_setzen(sid, False)}
darf = users.ist_host(sid) or (
bool(HOST_SCHLUESSEL) and secrets.compare_digest(schluessel or "", HOST_SCHLUESSEL)
)
if not darf:
raise HTTPException(status_code=403, detail="Kein Hostschlüssel")
return {"host": users.host_setzen(sid, True)}
@app.post("/api/users/waehlen")
def name_waehlen(
response: Response,
user_id: int | None = Body(None, embed=True),
movie_id: int | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Wer bin ich? Ohne user_id wieder anonym."""
"""Wer bin ich? Ohne user_id wieder anonym.
Hat der Name einen Anmeldefilm, muss movie_id ihn treffen. Der Host
und wer schon angemeldet ist, kommen ohne durch.
"""
sid = sitzung(response, filmabend_sid)
if user_id is None:
users.abwaehlen(sid)
return {"mine": None}
film = users.schutz_von(user_id)
frei = (
film is None
or users.ist_host(sid)
or users.besitzer(sid) == user_id
)
if not frei and movie_id != film:
raise HTTPException(status_code=403, detail="Das ist nicht der Film.")
benutzer = users.waehlen(sid, user_id)
if not benutzer:
raise HTTPException(status_code=404, detail="Unbekannter Name")
@@ -576,14 +694,13 @@ def bewerten(
watched_id: int,
response: Response,
stars: int = Body(..., embed=True),
text: str | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
mitglied(sid)
stars = max(1, min(watched.MAX_STERNE, stars))
# Der Name kommt aus der Runde, nicht aus der Anfrage
eintrag = watched.bewerten(watched_id, sid, stars, users.name_von(sid), text)
eintrag = watched.bewerten(watched_id, sid, stars, users.name_von(sid))
if not eintrag:
raise HTTPException(status_code=404, detail="Film steht nicht in der Liste")
events.senden("watched")
@@ -599,6 +716,40 @@ def wertung_loeschen(rating_id: int):
return {"removed": 1}
@app.post("/api/watched/{watched_id}/notes")
def gaestebuch_schreiben(
watched_id: int,
response: Response,
text: str = Body(..., embed=True),
parent_id: int | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Kommentar zum Abend, oder mit parent_id eine Antwort auf einen anderen."""
sid = sitzung(response, filmabend_sid)
mitglied(sid)
note_id = watched.anmerken(watched_id, sid, text, parent_id)
if note_id is None:
raise HTTPException(status_code=400, detail="Kein Text oder Ziel unbekannt")
events.senden("watched")
return {"id": note_id}
# Eigener Pfad, damit er /api/watched/{watched_id} nicht in die Quere kommt
@app.delete("/api/watched-notes/{note_id}")
def gaestebuch_loeschen(
note_id: int,
response: Response,
filmabend_sid: str | None = Cookie(None),
):
"""Der Verfasser oder der Host — samt allen Antworten darunter."""
sid = sitzung(response, filmabend_sid)
entfernt = watched.anmerkung_loeschen(note_id, sid, users.ist_host(sid))
if not entfernt:
raise HTTPException(status_code=404, detail="Nicht gefunden oder nicht deins")
events.senden("watched")
return {"removed": entfernt}
@app.delete("/api/suggestions/alle")
def runde_zuruecksetzen():
"""Host leert die Runde: Vorschläge weg, Rad wieder leer."""

View File

@@ -3,7 +3,7 @@ import logging
from datetime import datetime, timezone
from urllib.parse import quote_plus
from . import canon, db, imagehash, netflix, teaser, tmdb
from . import canon, db, imagehash, netflix, search, teaser, tmdb
log = logging.getLogger("sync")
@@ -144,6 +144,40 @@ async def resolve_canon():
return places
async def katalog_nachtragen():
"""Filme in den Katalog holen, die jemand gemerkt hat, die aber weder
auf Netflix laufen noch im Kanon stehen.
Ohne sie bleiben die Karten in Sammlung, Rad und Gesehen leer: kein
Teaser, keine Note, keine Bilder. Die Detailansicht holt sich das
jedes Mal neu von TMDB — hier landet es einmal in der Datenbank.
"""
with db.connect() as conn:
fehlen = sorted(
db.benutzte_ids(conn)
- {r["id"] for r in conn.execute("SELECT id FROM movies")}
)
if not fehlen:
return 0
bremse = asyncio.Semaphore(TMDB_CONCURRENCY)
async def hole(movie_id):
async with bremse:
try:
return await search.detail(movie_id)
except Exception:
# Ein gelöschter TMDB-Eintrag soll den Rest nicht aufhalten
return None
filme = [f for f in await asyncio.gather(*map(hole, fehlen)) if f]
with db.connect() as conn:
for film in filme:
db.upsert(conn, film)
log.info("%s Filme aus Sammlungen in den Katalog geholt", len(filme))
return len(filme)
async def run():
log.info("Sync startet")
canon_places = await resolve_canon()
@@ -180,15 +214,18 @@ async def run():
db.set_meta(conn, "movie_count", len(on_netflix))
db.set_meta(conn, "canon_count", sum(1 for m in movies if m["canon_group"] is not None))
nachgetragen = await katalog_nachtragen()
log.info(
"Sync fertig: %s Filme (%s Netflix, %s Kanon), "
"%s Netflix-Links, %s neue Teaser, %s entfernt",
"%s Netflix-Links, %s neue Teaser, %s entfernt, %s nachgetragen",
len(movies),
len(on_netflix),
sum(1 for m in movies if m["canon_group"] is not None),
linked,
new_teasers,
removed,
nachgetragen,
)
return {
"movies": len(movies),
@@ -196,6 +233,7 @@ async def run():
"netflix_links": linked,
"new_teasers": new_teasers,
"removed": removed,
"nachgetragen": nachgetragen,
}

View File

@@ -1,8 +1,12 @@
"""Die Runde: wer mitmacht, und wer gerade wer ist.
Der Host pflegt die Namensliste. Alle anderen wählen sich einen davon aus.
Kein Passwort — für acht Freunde, die sich ohnehin über Discord hören, wäre
das nur eine Hürde ohne Nutzen.
Wer nicht will, dass ein anderer in seinem Namen bewertet, hinterlegt in
der Verwaltung einen Anmeldefilm. Dann muss man ihn beim Wechsel erst in
der Suche finden und anklicken. Kein Passwort im Klartext, nichts zu
merken außer einem Film — und für einen Filmabend unter Freunden genau die
richtige Hürde. Wer keinen Film hinterlegt, ist weiter offen für alle.
Die Zuordnung hängt an derselben Cookie-Sitzung wie Vorschläge und
Wertungen. Damit steht der Name serverseitig fest und kommt nicht mehr aus
@@ -26,18 +30,37 @@ CREATE TABLE IF NOT EXISTS user_sessions (
user_id INTEGER NOT NULL,
seit TEXT NOT NULL
);
-- Wer den Hostmodus eingeschaltet hat. Der Host führt durch den Abend und
-- wechselt die Namen frei, ohne jedes Mal den Anmeldefilm zu suchen.
CREATE TABLE IF NOT EXISTS host_sessions (
session_id TEXT PRIMARY KEY,
seit TEXT NOT NULL
);
"""
def init():
with connect() as conn:
conn.executescript(SCHEMA)
# Der Anmeldefilm kam später dazu
spalten = {r[1] for r in conn.execute("PRAGMA table_info(users)")}
if "auth_movie_id" not in spalten:
conn.execute("ALTER TABLE users ADD COLUMN auth_movie_id INTEGER")
def _oeffentlich(row):
"""Der hinterlegte Film verlässt den Server nie — sonst stünde das
Passwort im Netzwerk-Tab. Nach draußen geht nur, ob einer gesetzt ist."""
benutzer = dict(row)
benutzer["geschuetzt"] = benutzer.pop("auth_movie_id", None) is not None
return benutzer
def alle():
with connect() as conn:
rows = conn.execute("SELECT * FROM users ORDER BY name COLLATE NOCASE").fetchall()
return [dict(r) for r in rows]
return [_oeffentlich(r) for r in rows]
def anlegen(name):
@@ -59,7 +82,24 @@ def anlegen(name):
def von_name(name):
with connect() as conn:
row = conn.execute("SELECT * FROM users WHERE name = ?", (name,)).fetchone()
return dict(row) if row else None
return _oeffentlich(row) if row else None
def schutz_setzen(user_id, movie_id):
"""Anmeldefilm hinterlegen oder mit None wieder freigeben."""
with connect() as conn:
return conn.execute(
"UPDATE users SET auth_movie_id = ? WHERE id = ?", (movie_id, user_id)
).rowcount
def schutz_von(user_id):
"""Die hinterlegte Film-Kennung, oder None bei offenem Namen."""
with connect() as conn:
row = conn.execute(
"SELECT auth_movie_id FROM users WHERE id = ?", (user_id,)
).fetchone()
return row["auth_movie_id"] if row else None
def entfernen(user_id):
@@ -84,7 +124,7 @@ def waehlen(session_id, user_id):
datetime.now(timezone.utc).isoformat(timespec="seconds"),
),
)
return dict(da)
return _oeffentlich(da)
def abwaehlen(session_id):
@@ -103,7 +143,32 @@ def aktuell(session_id):
"WHERE s.session_id = ?",
(session_id,),
).fetchone()
return dict(row) if row else None
return _oeffentlich(row) if row else None
def ist_host(session_id):
with connect() as conn:
return bool(
conn.execute(
"SELECT 1 FROM host_sessions WHERE session_id = ?", (session_id,)
).fetchone()
)
def host_setzen(session_id, an):
"""Den Hostmodus merkt sich auch der Server — sonst könnte er den
Anmeldefilm nicht überspringen."""
with connect() as conn:
if an:
conn.execute(
"INSERT OR REPLACE INTO host_sessions (session_id, seit) VALUES (?, ?)",
(session_id, datetime.now(timezone.utc).isoformat(timespec="seconds")),
)
else:
conn.execute(
"DELETE FROM host_sessions WHERE session_id = ?", (session_id,)
)
return an
def besitzer(session_id):

View File

@@ -1,13 +1,16 @@
"""Bereits geschaute Filme und ihre Bewertungen.
"""Bereits geschaute Filme — das Buch der Abende.
Der Host trägt ein, was am Abend lief. Bewerten darf jeder, eine Stimme
je Sitzung — dieselbe Cookie-Sitzung wie bei den Vorschlägen. Der Name ist
freiwillig, ohne ihn zählt die Stimme trotzdem.
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.
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
@@ -39,16 +42,54 @@ CREATE TABLE IF NOT EXISTS ratings (
);
"""
# 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
);
"""
MAX_STERNE = 5
# Manche schreiben eine ganze Rezension — rund 600 Wörter passen hinein
MAX_TEXT = 4000
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)
conn.executescript(SCHEMA + RATINGS + NOTES)
# Eine Stimme je Person und Film
users.je_person(conn, "ratings", RATINGS, "watched_id")
# Herzen an Kommentaren gab es kurz — keiner hat sie vermisst
conn.execute("DROP TABLE IF EXISTS watched_hearts")
# 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:
@@ -66,43 +107,80 @@ def _jetzt():
def alle(session_id):
"""Zuletzt geschaut zuerst, mit Schnitt und der eigenen Stimme."""
"""Zuletzt geschaut zuerst, mit Schnitt, eigener Stimme und
Kommentaren. Die laufende Nummer zählt vom ersten Abend."""
felder = ", ".join(f"m.{f}" for f in AUS_KATALOG)
with connect() as conn:
filme = conn.execute(
"SELECT * FROM watched ORDER BY watched_at DESC, id DESC"
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, text "
"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()
ich = besitzer(session_id)
def notiz(n):
return {
"id": n["id"],
"text": n["text"],
"name": n["name"],
"created_at": n["created_at"],
"mine": gehoert_mir(n, session_id, ich),
"replies": [],
}
# 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 film in filme:
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(
{
**dict(film),
**daten,
"nummer": nummer,
"ratings": [
{
"id": s["id"],
"stars": s["stars"],
"name": s["name"],
"text": s["text"],
"created_at": s["created_at"],
"mine": gehoert_mir(s, session_id, ich),
}
for s in eigene
],
"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,
"mine_text": meine["text"] if meine else None,
}
)
return out
@@ -130,7 +208,9 @@ def markieren(movie):
def entfernen(watched_id):
"""Samt Wertungen und Kommentaren."""
with connect() as conn:
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,)
@@ -156,15 +236,9 @@ def sichtbarkeit(watched_id, versteckt):
).rowcount
def bewerten(watched_id, session_id, stars, name=None, text=None):
"""Sterne und ein optionaler Satz dazu.
Kommt kein Text mit (None), bleibt der bisherige stehen — wer nur die
Sterne nachjustiert, verliert seinen Satz nicht. Ein leerer String
löscht ihn ausdrücklich.
Gibt None zurück, wenn der Film gar nicht in der Liste steht.
"""
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,)
@@ -172,14 +246,6 @@ def bewerten(watched_id, session_id, stars, name=None, text=None):
if not da:
return None
ich = besitzer(session_id)
if text is None:
alt = conn.execute(
f"SELECT text FROM ratings WHERE watched_id = ? AND {WEM}",
(watched_id, ich, ich, session_id),
).fetchone()
text = alt["text"] if alt else None
else:
text = text.strip()[:MAX_TEXT] or None
# Eine Stimme je Person — die eigene alte fliegt vorher raus
conn.execute(
f"DELETE FROM ratings WHERE watched_id = ? AND {WEM}",
@@ -187,19 +253,11 @@ def bewerten(watched_id, session_id, stars, name=None, text=None):
)
conn.execute(
"INSERT INTO ratings "
"(watched_id, session_id, user_id, stars, name, text, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
watched_id,
session_id,
ich,
stars,
(name or "").strip()[:40] or None,
text,
_jetzt(),
),
"(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, "text": text}
return {"watched_id": watched_id, "stars": stars}
def stimme_loeschen(rating_id):
@@ -210,6 +268,70 @@ def stimme_loeschen(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)]
conn.executemany(
"DELETE FROM watched_notes WHERE id = ?", [(i,) for i in alle_ids]
)
return len(alle_ids)
def ids():
"""movie_ids der geschauten Filme, für die Detailansicht."""
with connect() as conn:

View File

@@ -37,13 +37,20 @@ def init():
users.je_person(conn, "wishlist", SCHEMA, "movie_id")
# Was die Karte in der Detailansicht zeigt. Steht im Katalog, nicht in der
# Sammlung — ein Film, der nie im Katalog war, bleibt hier leer.
AUS_KATALOG = ("genres", "teaser", "overview", "runtime", "vote_average",
"vote_count", "images")
def alle(session_id):
"""Alle Sammlungen, jede mit dem Namen ihres Besitzers."""
felder = ", ".join(f"m.{f}" for f in AUS_KATALOG)
with connect() as conn:
rows = conn.execute(
# Genres kommen aus dem Katalog. Ein Film, der nie darin stand,
# hat keine — das Rad behandelt ihn dann als "nicht Horror".
"SELECT w.*, u.name, m.genres FROM wishlist w "
f"SELECT w.*, u.name, {felder} FROM wishlist w "
"LEFT JOIN users u ON u.id = w.user_id "
"LEFT JOIN movies m ON m.id = w.movie_id "
"ORDER BY w.created_at DESC"
@@ -54,9 +61,10 @@ def alle(session_id):
**{
k: v
for k, v in dict(r).items()
if k not in ("session_id", "user_id", "genres")
if k not in ("session_id", "user_id", "genres", "images")
},
"genres": json.loads(r["genres"]) if r["genres"] else [],
"images": json.loads(r["images"]) if r["images"] else [],
"mine": gehoert_mir(r, session_id, ich),
}
for r in rows