diff --git a/.env.example b/.env.example index 3d1f945..5082203 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ TMDB_TOKEN= MINIMAX_KEY= MTX_SENDEPASSWORT= +HOST_SCHLUESSEL= diff --git a/README.md b/README.md index b5d9bf9..7ad91a4 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,37 @@ cp .env.example .env # TMDB_TOKEN und MINIMAX_KEY eintragen make install ``` +`HOST_SCHLUESSEL` in der `.env` ist frei wählbar, am besten lang und +zufällig: + +```bash +python3 -c "import secrets; print(secrets.token_urlsafe(24))" +``` + +Ohne Eintrag bleiben die Hostknöpfe für alle zu. + +## Namen und Host + +Der Host pflegt in der Verwaltung die Namensliste, alle anderen wählen sich +einen davon aus. Wer keinen Namen wählt, ist Zuschauer: lesen ja, eintragen +nein — keine Vorschläge, keine Sammlung, keine Wertungen, keine Wünsche. + +**Anmeldefilm statt Passwort.** In der Verwaltung hinterlegt jeder für +seinen Namen einen Film. Wer den Namen danach wählt, bekommt die +Filmsuche vorgesetzt und muss den richtigen anklicken. Der Server gibt den +Film nie ungefragt heraus; über das Augensymbol sieht ihn nur der Host und +der Name selbst. Ein Name ohne Film bleibt für alle offen. + +**Hostmodus.** Einmal `https://…/?host=` aufrufen, dann +merkt sich die Sitzung das und der Schlüssel fliegt aus der Adresszeile. +Danach blendet `Strg+Shift+Alt+S` die Hostknöpfe ein und aus — nützlich, +um die Gastansicht zu prüfen. Ohne freigeschaltete Sitzung tut die +Tastenkombination nichts. + +Der Host überspringt den Anmeldefilm und wechselt die Namen frei. Wer den +Link kennt, kann das also auch — für einen Filmabend unter Freunden reicht +das. + ## Sync Holt den Katalog und erzeugt fehlende Teaser. Der erste Lauf dauert einige diff --git a/backend/app/config.py b/backend/app/config.py index a837e7b..e9f6c91 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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= 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" diff --git a/backend/app/db.py b/backend/app/db.py index 9cf535e..c54d536 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index 8c260cf..16caa0d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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=. 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.""" diff --git a/backend/app/sync.py b/backend/app/sync.py index 74f43f0..c64554c 100644 --- a/backend/app/sync.py +++ b/backend/app/sync.py @@ -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, } diff --git a/backend/app/users.py b/backend/app/users.py index ec683f2..15e6a8d 100644 --- a/backend/app/users.py +++ b/backend/app/users.py @@ -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): diff --git a/backend/app/watched.py b/backend/app/watched.py index d1c6ac1..337c4b3 100644 --- a/backend/app/watched.py +++ b/backend/app/watched.py @@ -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: diff --git a/backend/app/wishlist.py b/backend/app/wishlist.py index 62d5a53..5d70976 100644 --- a/backend/app/wishlist.py +++ b/backend/app/wishlist.py @@ -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 diff --git a/backend/horror.db.bak b/backend/horror.db.bak index ca34497..0ce1620 100644 Binary files a/backend/horror.db.bak and b/backend/horror.db.bak differ diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index dc3844e..2197ac8 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -12,6 +12,8 @@ _ORDNER = Path(tempfile.mkdtemp(prefix="filmabend-tests-")) os.environ["DB_PATH"] = str(_ORDNER / "test.db") os.environ.setdefault("TMDB_TOKEN", "test") os.environ.setdefault("MINIMAX_KEY", "test") +# Der Hostmodus hängt am Schlüssel — in den Tests ein fester +os.environ["HOST_SCHLUESSEL"] = "test-host-schluessel" import pytest # noqa: E402 from fastapi.testclient import TestClient # noqa: E402 @@ -85,6 +87,21 @@ def zuschauer(): return TestClient(main.app) +HOST_SCHLUESSEL = "test-host-schluessel" + + +@pytest.fixture +def hostmodus(): + """Schaltet einen Client per Hostschlüssel frei.""" + + def an(client): + return client.post( + "/api/host", json={"an": True, "schluessel": HOST_SCHLUESSEL} + ) + + return an + + @pytest.fixture def film(): """Testfilm direkt in der Datenbank, ohne TMDB. Gibt die id zurück.""" diff --git a/backend/tests/test_gaestebuch.py b/backend/tests/test_gaestebuch.py new file mode 100644 index 0000000..705e0f4 --- /dev/null +++ b/backend/tests/test_gaestebuch.py @@ -0,0 +1,156 @@ +"""Das Buch der Abende: Wertungen als Momentaufnahme, Kommentare als +Diskussion — verschachtelt, so tief man will.""" + + +def abend(client, film, movie_id=1): + """Host trägt einen Film ein, gibt die watched_id zurück.""" + return client.post("/api/watched", json={"movie_id": film(movie_id)}).json()["id"] + + +def wertung(client, watched_id, stars=4): + client.post(f"/api/watched/{watched_id}/rating", json={"stars": stars}) + film = next(f for f in client.get("/api/watched").json() if f["id"] == watched_id) + return next(r for r in film["ratings"] if r["mine"]) + + +def film_von(client, watched_id): + return next(f for f in client.get("/api/watched").json() if f["id"] == watched_id) + + +def kommentar(client, watched_id, text, parent_id=None): + antwort = client.post( + f"/api/watched/{watched_id}/notes", json={"text": text, "parent_id": parent_id} + ) + assert antwort.status_code == 200, antwort.text + return antwort.json()["id"] + + +def test_laufende_nummer_zaehlt_vom_ersten_abend(client, film): + erster = abend(client, film, 1) + client.patch(f"/api/watched/{erster}", json={"watched_at": "2026-01-01"}) + zweiter = abend(client, film, 2) + client.patch(f"/api/watched/{zweiter}", json={"watched_at": "2026-01-08"}) + + liste = client.get("/api/watched").json() + # Jüngster zuerst, trägt aber die höchste Nummer + assert [(f["id"], f["nummer"]) for f in liste] == [(zweiter, 2), (erster, 1)] + + +def test_katalogdaten_haengen_am_abend(client): + from app import db + + with db.connect() as conn: + db.upsert(conn, {"id": 5, "title": "Alien", "backdrop_path": "/b.jpg", + "images": ["/s1.jpg"], "runtime": 117, "genres": ["Horror"]}) + watched_id = client.post("/api/watched", json={"movie_id": 5}).json()["id"] + f = film_von(client, watched_id) + assert f["backdrop_path"] == "/b.jpg" + assert f["images"] == ["/s1.jpg"] + assert f["runtime"] == 117 + assert f["genres"] == ["Horror"] + + +def test_kommentare_bilden_einen_baum(client, client2, film): + watched_id = abend(client, film) + oben = kommentar(client, watched_id, "War super.") + antwort = kommentar(client2, watched_id, "Fand ich nicht.", oben) + tiefer = kommentar(client, watched_id, "Doch!", antwort) + noch_einer = kommentar(client2, watched_id, "Anderes Thema.") + + notes = film_von(client, watched_id)["notes"] + assert [n["text"] for n in notes] == ["War super.", "Anderes Thema."] + assert notes[0]["replies"][0]["text"] == "Fand ich nicht." + assert notes[0]["replies"][0]["name"] == "Ben" + assert notes[0]["replies"][0]["mine"] is False + assert notes[0]["replies"][0]["replies"][0]["text"] == "Doch!" + assert notes[0]["replies"][0]["replies"][0]["mine"] is True + assert notes[1]["replies"] == [] + assert (oben, antwort, tiefer, noch_einer) == tuple(sorted((oben, antwort, tiefer, noch_einer))) + + +def test_zehn_ebenen_tief(client, film): + watched_id = abend(client, film) + vater = None + for stufe in range(10): + vater = kommentar(client, watched_id, f"Stufe {stufe}", vater) + + knoten = film_von(client, watched_id)["notes"][0] + tiefe = 0 + while knoten["replies"]: + knoten = knoten["replies"][0] + tiefe += 1 + assert tiefe == 9 + assert knoten["text"] == "Stufe 9" + + +def test_leerer_text_oder_fremder_vater_werden_abgelehnt(client, film): + watched_id = abend(client, film, 1) + anderer = abend(client, film, 2) + dort = kommentar(client, anderer, "woanders") + + assert client.post(f"/api/watched/{watched_id}/notes", json={"text": " "}).status_code == 400 + # Der Vater gehört zu einem anderen Abend + assert client.post( + f"/api/watched/{watched_id}/notes", json={"text": "x", "parent_id": dort} + ).status_code == 400 + assert client.post( + f"/api/watched/{watched_id}/notes", json={"text": "x", "parent_id": 9999} + ).status_code == 400 + + +def test_wertungen_und_kommentare_sind_unabhaengig(client, film): + watched_id = abend(client, film) + wertung(client, watched_id, 5) + kommentar(client, watched_id, "Diskussion.") + f = film_von(client, watched_id) + assert f["ratings"][0]["stars"] == 5 + assert "text" not in f["ratings"][0] + assert [n["text"] for n in f["notes"]] == ["Diskussion."] + + +def test_eigenen_kommentar_loeschen_fremden_nicht(client, client2, film): + watched_id = abend(client, film) + meiner = kommentar(client, watched_id, "meiner") + + assert client2.delete(f"/api/watched-notes/{meiner}").status_code == 404 + assert client.delete(f"/api/watched-notes/{meiner}").json()["removed"] == 1 + assert film_von(client, watched_id)["notes"] == [] + + +def test_loeschen_nimmt_den_ganzen_ast_mit(client, client2, film): + """Eine Antwort ohne ihren Anlass ergäbe keinen Sinn.""" + watched_id = abend(client, film) + oben = kommentar(client, watched_id, "a") + mitte = kommentar(client2, watched_id, "b", oben) + unten = kommentar(client, watched_id, "c", mitte) + daneben = kommentar(client2, watched_id, "d") + + assert client.delete(f"/api/watched-notes/{oben}").json()["removed"] == 3 + notes = film_von(client, watched_id)["notes"] + assert [n["id"] for n in notes] == [daneben] + + +def test_host_loescht_jeden_kommentar(client, zuschauer, hostmodus, film): + watched_id = abend(client, film) + note_id = kommentar(client, watched_id, "x") + hostmodus(zuschauer) + assert zuschauer.delete(f"/api/watched-notes/{note_id}").json()["removed"] == 1 + + +def test_film_loeschen_raeumt_alles(client, client2, film): + from app import db + + watched_id = abend(client, film) + wertung(client, watched_id, 5) + oben = kommentar(client2, watched_id, "frei") + kommentar(client, watched_id, "antwort", oben) + + client.delete(f"/api/watched/{watched_id}") + with db.connect() as conn: + for tabelle in ("ratings", "watched_notes"): + assert conn.execute(f"SELECT COUNT(*) FROM {tabelle}").fetchone()[0] == 0, tabelle + + +def test_zuschauer_darf_nicht_schreiben(zuschauer, client, film): + watched_id = abend(client, film) + assert zuschauer.post(f"/api/watched/{watched_id}/notes", json={"text": "x"}).status_code == 403 diff --git a/backend/tests/test_login.py b/backend/tests/test_login.py new file mode 100644 index 0000000..dfdabac --- /dev/null +++ b/backend/tests/test_login.py @@ -0,0 +1,185 @@ +"""Anmelden per Film. + +Wer einen Anmeldefilm hinterlegt hat, muss ihn beim Namenswechsel in der +Suche finden. Ohne Film bleibt der Name offen für alle. +""" + + +def namen(client, name="Anna"): + """Legt den Namen an und gibt seine Kennung zurück.""" + antwort = client.post("/api/users", json={"name": name}) + if antwort.status_code == 200: + return antwort.json()["id"] + runde = client.get("/api/users").json()["items"] + return next(u["id"] for u in runde if u["name"] == name) + + +def test_ohne_film_bleibt_der_name_offen(client2, film): + user_id = namen(client2, "Offen") + antwort = client2.post("/api/users/waehlen", json={"user_id": user_id}) + assert antwort.status_code == 200 + assert antwort.json()["mine"]["geschuetzt"] is False + + +def test_falscher_film_kommt_nicht_durch(client, client2, film): + user_id = namen(client, "Anna") + client.post(f"/api/users/{user_id}/schutz", json={"movie_id": film(42)}) + + # Ben kennt den Film nicht + assert ( + client2.post( + "/api/users/waehlen", json={"user_id": user_id, "movie_id": film(7)} + ).status_code + == 403 + ) + # Ganz ohne Film auch nicht + assert ( + client2.post("/api/users/waehlen", json={"user_id": user_id}).status_code == 403 + ) + assert client2.get("/api/users").json()["mine"]["name"] == "Ben" + + +def test_richtiger_film_laesst_durch(client, client2, film): + user_id = namen(client, "Anna") + client.post(f"/api/users/{user_id}/schutz", json={"movie_id": film(42)}) + + antwort = client2.post( + "/api/users/waehlen", json={"user_id": user_id, "movie_id": 42} + ) + assert antwort.status_code == 200 + assert antwort.json()["mine"]["name"] == "Anna" + assert client2.get("/api/users").json()["mine"]["name"] == "Anna" + + +def test_der_film_verlaesst_den_server_nicht(client, film): + user_id = namen(client, "Anna") + client.post(f"/api/users/{user_id}/schutz", json={"movie_id": film(42)}) + + runde = client.get("/api/users").json() + anna = next(u for u in runde["items"] if u["id"] == user_id) + assert anna["geschuetzt"] is True + assert "auth_movie_id" not in anna + assert "auth_movie_id" not in runde["mine"] + + +def test_aufheben_macht_den_namen_wieder_frei(client, client2, film): + user_id = namen(client, "Anna") + client.post(f"/api/users/{user_id}/schutz", json={"movie_id": film(42)}) + client.post(f"/api/users/{user_id}/schutz", json={"movie_id": None}) + + assert client.get("/api/users").json()["items"][0]["geschuetzt"] is False + assert ( + client2.post("/api/users/waehlen", json={"user_id": user_id}).status_code == 200 + ) + + +def test_host_braucht_den_film_nicht(client, zuschauer, film, hostmodus): + user_id = namen(client, "Anna") + client.post(f"/api/users/{user_id}/schutz", json={"movie_id": film(42)}) + + hostmodus(zuschauer) + assert zuschauer.get("/api/users").json()["host"] is True + assert ( + zuschauer.post("/api/users/waehlen", json={"user_id": user_id}).status_code + == 200 + ) + + # Hostmodus aus: die Hürde steht wieder + zuschauer.post("/api/users/waehlen", json={"user_id": None}) + zuschauer.post("/api/host", json={"an": False}) + assert ( + zuschauer.post("/api/users/waehlen", json={"user_id": user_id}).status_code + == 403 + ) + + +def test_wer_schon_angemeldet_ist_muss_nicht_nochmal(client, film): + user_id = namen(client, "Anna") + client.post("/api/users/waehlen", json={"user_id": user_id}) + client.post(f"/api/users/{user_id}/schutz", json={"movie_id": film(42)}) + # Derselbe Name in derselben Sitzung, etwa nach einem Neuladen + assert ( + client.post("/api/users/waehlen", json={"user_id": user_id}).status_code == 200 + ) + + +def test_fremden_schutz_darf_man_nicht_setzen(client, client2, film): + anna = namen(client, "Anna") + assert ( + client2.post(f"/api/users/{anna}/schutz", json={"movie_id": film(42)}).status_code + == 403 + ) + assert client2.get("/api/users").json()["items"][0]["geschuetzt"] is False + + +def test_eigenen_schutz_darf_man_setzen(client, film): + """client ist als Anna angemeldet und ändert ihren eigenen Film.""" + anna = client.get("/api/users").json()["mine"]["id"] + assert ( + client.post(f"/api/users/{anna}/schutz", json={"movie_id": film(42)}).status_code + == 200 + ) + + +def test_hinterlegten_film_darf_man_nachschlagen(client, film): + """Wer ihn ändern darf, darf ihn auch sehen — sonst rät man beim + Überschreiben.""" + anna = client.get("/api/users").json()["mine"]["id"] + client.post(f"/api/users/{anna}/schutz", json={"movie_id": film(42, "Alien")}) + + antwort = client.get(f"/api/users/{anna}/schutz") + assert antwort.status_code == 200 + assert antwort.json()["film"]["title"] == "Alien" + + +def test_ohne_film_kommt_nichts_zurueck(client): + anna = client.get("/api/users").json()["mine"]["id"] + assert client.get(f"/api/users/{anna}/schutz").json()["film"] is None + + +def test_fremden_film_darf_man_nicht_nachschlagen(client, client2, film): + anna = client.get("/api/users").json()["mine"]["id"] + client.post(f"/api/users/{anna}/schutz", json={"movie_id": film(42)}) + assert client2.get(f"/api/users/{anna}/schutz").status_code == 403 + + +def test_host_darf_jeden_film_nachschlagen(client, zuschauer, film, hostmodus): + anna = client.get("/api/users").json()["mine"]["id"] + client.post(f"/api/users/{anna}/schutz", json={"movie_id": film(42, "Alien")}) + + hostmodus(zuschauer) + assert zuschauer.get(f"/api/users/{anna}/schutz").json()["film"]["title"] == "Alien" + + +def test_ohne_schluessel_kein_hostmodus(zuschauer): + """Die Tastenkombination allein reicht nicht mehr.""" + assert zuschauer.post("/api/host", json={"an": True}).status_code == 403 + assert zuschauer.get("/api/host").json()["host"] is False + + +def test_falscher_schluessel_kein_hostmodus(zuschauer): + antwort = zuschauer.post( + "/api/host", json={"an": True, "schluessel": "geraten"} + ) + assert antwort.status_code == 403 + assert zuschauer.get("/api/host").json()["host"] is False + + +def test_schluessel_schaltet_frei_und_bleibt(zuschauer, hostmodus): + assert hostmodus(zuschauer).json()["host"] is True + assert zuschauer.get("/api/host").json()["host"] is True + # Die Freischaltung hält, ein zweiter Aufruf braucht den Schlüssel nicht + assert zuschauer.post("/api/host", json={"an": True}).status_code == 200 + assert zuschauer.get("/api/host").json()["host"] is True + + +def test_ausschalten_geht_ohne_schluessel(zuschauer, hostmodus): + hostmodus(zuschauer) + assert zuschauer.post("/api/host", json={"an": False}).json()["host"] is False + # Jetzt ist die Sitzung wieder gewöhnlich + assert zuschauer.post("/api/host", json={"an": True}).status_code == 403 + + +def test_der_schluessel_gilt_nur_fuer_die_eigene_sitzung(zuschauer, client2, hostmodus): + hostmodus(zuschauer) + assert client2.get("/api/host").json()["host"] is False diff --git a/backend/tests/test_watched.py b/backend/tests/test_watched.py index f2018fe..d9852b6 100644 --- a/backend/tests/test_watched.py +++ b/backend/tests/test_watched.py @@ -1,6 +1,5 @@ """Wertungen: Sterne, Text, Schnitt.""" -from app.watched import MAX_TEXT def gesehen(client, film, movie_id=1): @@ -9,42 +8,47 @@ def gesehen(client, film, movie_id=1): return client.post("/api/watched", json={"movie_id": movie_id}).json()["id"] -def test_wertung_mit_text(client, film): - wid = gesehen(client, film) - - antwort = client.post(f"/api/watched/{wid}/rating", json={"stars": 4, "text": "Gruselig"}) - assert antwort.json() == {"watched_id": wid, "stars": 4, "text": "Gruselig"} - - [eintrag] = client.get("/api/watched").json() +def test_wertung_ist_nur_sterne(client, film): + wid = client.post("/api/watched", json={"movie_id": film(1)}).json()["id"] + antwort = client.post(f"/api/watched/{wid}/rating", json={"stars": 4}) + assert antwort.json() == {"watched_id": wid, "stars": 4} + eintrag = client.get("/api/watched").json()[0] + assert eintrag["mine"] == 4 assert eintrag["average"] == 4.0 assert eintrag["count"] == 1 - assert eintrag["mine"] == 4 - assert eintrag["mine_text"] == "Gruselig" - assert eintrag["ratings"][0]["mine"] is True + assert "text" not in eintrag["ratings"][0] + assert "mine_text" not in eintrag -def test_text_none_behaelt_leer_loescht(client, film): - wid = gesehen(client, film) - client.post(f"/api/watched/{wid}/rating", json={"stars": 4, "text": "Gruselig"}) - - # Nur die Sterne nachjustiert: der Satz bleibt, es bleibt eine Stimme - client.post(f"/api/watched/{wid}/rating", json={"stars": 3}) - [eintrag] = client.get("/api/watched").json() - assert eintrag["mine"] == 3 - assert eintrag["mine_text"] == "Gruselig" +def test_neue_sterne_ersetzen_die_alten(client, film): + wid = client.post("/api/watched", json={"movie_id": film(1)}).json()["id"] + client.post(f"/api/watched/{wid}/rating", json={"stars": 4}) + client.post(f"/api/watched/{wid}/rating", json={"stars": 2}) + eintrag = client.get("/api/watched").json()[0] + assert eintrag["mine"] == 2 assert eintrag["count"] == 1 - client.post(f"/api/watched/{wid}/rating", json={"stars": 3, "text": ""}) - [eintrag] = client.get("/api/watched").json() - assert eintrag["mine_text"] is None +def test_alter_wertungstext_zieht_in_die_diskussion(client, film): + """Vor dem Umbau hing ein Text an den Sternen. Beim Start wandert er + als Kommentar in die Diskussion, mit Verfasser und Datum.""" + from app import db, watched -def test_text_wird_gekuerzt(client, film): - wid = gesehen(client, film) - client.post(f"/api/watched/{wid}/rating", json={"stars": 5, "text": "x" * (MAX_TEXT + 100)}) + wid = client.post("/api/watched", json={"movie_id": film(1)}).json()["id"] + client.post(f"/api/watched/{wid}/rating", json={"stars": 5}) + with db.connect() as conn: + conn.execute("UPDATE ratings SET text = 'Alte Rezension'") + watched.init() - [eintrag] = client.get("/api/watched").json() - assert len(eintrag["mine_text"]) == MAX_TEXT + f = client.get("/api/watched").json()[0] + assert [n["text"] for n in f["notes"]] == ["Alte Rezension"] + assert f["notes"][0]["name"] == "Anna" + assert f["notes"][0]["mine"] is True + with db.connect() as conn: + assert conn.execute("SELECT COUNT(*) FROM ratings WHERE text IS NOT NULL").fetchone()[0] == 0 + # Ein zweiter Start zieht nichts doppelt um + watched.init() + assert len(client.get("/api/watched").json()[0]["notes"]) == 1 def test_fremde_wertung_ist_nicht_meine(client, client2, film): diff --git a/backend/tests/test_wishlist.py b/backend/tests/test_wishlist.py new file mode 100644 index 0000000..d1d53b7 --- /dev/null +++ b/backend/tests/test_wishlist.py @@ -0,0 +1,110 @@ +"""Die Sammlungen. + +Alle sehen alle, ändern darf jeder nur die eigene. Die Karten im +Sammlungen-Reiter zeigen dieselben Daten wie im Katalog — die holt die +Liste per Join nach. +""" + +from app import db + + +def katalogfilm(movie_id, **felder): + """Film mit allem, was die Detailkarte zeigt.""" + with db.connect() as conn: + db.upsert( + conn, + { + "id": movie_id, + "title": "Alien", + "year": 1979, + "teaser": "Im Weltall hört dich niemand schreien.", + "overview": "Die Nostromo fängt ein Signal auf.", + "runtime": 117, + "vote_average": 8.2, + "vote_count": 14000, + "genres": ["Horror", "Science Fiction"], + "images": ["/a.jpg", "/b.jpg"], + **felder, + }, + ) + return movie_id + + +def test_karte_bekommt_die_katalogdaten(client): + client.post("/api/wishlist", json={"movie_id": katalogfilm(42)}) + eintrag = client.get("/api/wishlist").json()[0] + + assert eintrag["teaser"].startswith("Im Weltall") + assert eintrag["runtime"] == 117 + assert eintrag["vote_average"] == 8.2 + assert eintrag["vote_count"] == 14000 + assert eintrag["genres"] == ["Horror", "Science Fiction"] + assert eintrag["images"] == ["/a.jpg", "/b.jpg"] + + +def test_film_ausserhalb_des_katalogs_bleibt_spaerlich(client, film): + """Titel und Poster stehen in der Sammlung selbst, der Rest fehlt.""" + client.post("/api/wishlist", json={"movie_id": film(7, "Irgendwas")}) + eintrag = client.get("/api/wishlist").json()[0] + + assert eintrag["title"] == "Irgendwas" + assert eintrag["teaser"] is None + assert eintrag["genres"] == [] + assert eintrag["images"] == [] + + +def test_merken_und_wieder_vergessen(client): + movie_id = katalogfilm(42) + client.post("/api/wishlist", json={"movie_id": movie_id}) + assert len(client.get("/api/wishlist").json()) == 1 + + assert client.delete(f"/api/wishlist/{movie_id}").json()["removed"] == 1 + assert client.get("/api/wishlist").json() == [] + + +def test_zweimal_merken_legt_nur_einen_an(client): + movie_id = katalogfilm(42) + client.post("/api/wishlist", json={"movie_id": movie_id}) + client.post("/api/wishlist", json={"movie_id": movie_id}) + assert len(client.get("/api/wishlist").json()) == 1 + + +def test_fremde_sammlung_sieht_man_aber_raeumt_sie_nicht(client, client2): + movie_id = katalogfilm(42) + client.post("/api/wishlist", json={"movie_id": movie_id}) + + # Ben sieht Annas Eintrag, aber er gehört ihm nicht + eintrag = client2.get("/api/wishlist").json()[0] + assert eintrag["name"] == "Anna" + assert eintrag["mine"] is False + + # Sein Löschversuch trifft nur die eigene Sammlung, also nichts + assert client2.delete(f"/api/wishlist/{movie_id}").json()["removed"] == 0 + assert len(client.get("/api/wishlist").json()) == 1 + + +def test_jeder_hat_seine_eigene_sammlung(client, client2): + movie_id = katalogfilm(42) + client.post("/api/wishlist", json={"movie_id": movie_id}) + client2.post("/api/wishlist", json={"movie_id": movie_id}) + + eintraege = client.get("/api/wishlist").json() + assert len(eintraege) == 2 + assert sorted(e["name"] for e in eintraege) == ["Anna", "Ben"] + + +def test_sync_verschont_was_jemand_gemerkt_hat(client): + """Der Katalog kennt nur Netflix und den Kanon. Gemerkte Filme müssen + trotzdem drinbleiben — sonst verlieren ihre Karten alle Daten.""" + gemerkt = katalogfilm(42) + egal = katalogfilm(99, title="Niemandsfilm") + client.post("/api/wishlist", json={"movie_id": gemerkt}) + + with db.connect() as conn: + # Der Sync behält nur, was er gerade geholt hat — hier gar nichts + weg = db.drop_missing(conn, []) + uebrig = {r["id"] for r in conn.execute("SELECT id FROM movies")} + + assert weg == 1 + assert uebrig == {gemerkt} + assert egal not in uebrig diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 13f8cf4..5e83897 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -4,6 +4,7 @@ import MovieCard from './components/MovieCard.vue' import FilterSidebar from './components/FilterSidebar.vue' import AehnlichTab from './components/AehnlichTab.vue' import MerklisteTab from './components/MerklisteTab.vue' +import SammlungFilter from './components/SammlungFilter.vue' import MovieDetail from './components/MovieDetail.vue' import NamensWahl from './components/NamensWahl.vue' import VerwaltungTab from './components/VerwaltungTab.vue' @@ -29,6 +30,11 @@ const { wuensche, ladeWuensche, } = useMeins({ host }) +// Ohne gewählten Namen darf man alles lesen, aber nichts eintragen — +// für Zuschauer, die noch nicht in der Runde stehen. Steht vor tabs: die +// Reiterliste fragt danach. +const angemeldet = computed(() => !!ich.value) + const { movies, loading, error, load, netflixMovies, canonMovies, canonGroups, netflixEntries, SORTS, @@ -84,16 +90,42 @@ const tabs = computed(() => [ { id: 'stream', label: 'Stream', count: null }, { id: 'watched', label: 'Gesehen', count: gesehenZahl.value }, { id: 'wuensche', label: 'Wünsche', count: wuensche.value.filter((w) => !w.done_at).length }, - ...(host.value ? [{ id: 'verwaltung', label: 'Verwaltung', count: null }] : []), + ...(host.value || angemeldet.value + ? [{ id: 'verwaltung', label: 'Verwaltung', count: null }] + : []), ]) // Auf dem Handy passt die Filterleiste nicht neben die Karten — dort // legt sie sich über einen Knopf über die Seite const filterOffen = ref(false) const mitFilter = computed(() => - ['netflix', 'canon', 'search', 'aehnlich'].includes(activeTab.value) + ['netflix', 'canon', 'search', 'aehnlich', 'merkliste'].includes(activeTab.value) ) +// Der Sammlungen-Reiter filtert nach Person und Genre, nicht nach Regler — +// dafür hat er eine eigene Leiste +const sammlungLeute = ref([]) +const sammlungHorror = ref(null) + +// Wer überhaupt etwas gesammelt hat, mit Anzahl. Die eigene zuerst. +const leuteMitSammlung = computed(() => { + const nach = new Map() + for (const eintrag of merkliste.value) { + const name = eintrag.name || 'Anonym' + if (!nach.has(name)) nach.set(name, { name, anzahl: 0, mine: eintrag.mine }) + nach.get(name).anzahl += 1 + } + return [...nach.values()].sort( + (a, b) => b.mine - a.mine || a.name.localeCompare(b.name, 'de') + ) +}) + +// Fällt der offene Reiter weg — Hostmodus aus, Name abgewählt —, wäre +// sonst keiner markiert und die Seite bliebe leer +watch(tabs, (liste) => { + if (!liste.some((t) => t.id === activeTab.value)) activeTab.value = 'netflix' +}) + // Passen die Reiter nicht in eine Zeile, soll der offene sichtbar sein const reiter = ref(null) @@ -105,10 +137,6 @@ watch(activeTab, async () => { leiste.querySelector('.active')?.scrollIntoView({ inline: 'center', block: 'nearest' }) }) -// Ohne gewählten Namen darf man alles lesen, aber nichts eintragen — -// für Zuschauer, die noch nicht in der Runde stehen -const angemeldet = computed(() => !!ich.value) - const selected = ref(null) // Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir @@ -193,7 +221,12 @@ onMounted(load) Filter Host - + @@ -256,15 +289,27 @@ onMounted(load) @changed="ladeGesehen" /> - +
+ + + +
- + diff --git a/frontend/src/components/Abend.vue b/frontend/src/components/Abend.vue new file mode 100644 index 0000000..a57d2aa --- /dev/null +++ b/frontend/src/components/Abend.vue @@ -0,0 +1,631 @@ + + + + + diff --git a/frontend/src/components/FilmLogin.vue b/frontend/src/components/FilmLogin.vue new file mode 100644 index 0000000..4f775f5 --- /dev/null +++ b/frontend/src/components/FilmLogin.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/frontend/src/components/FilmSuche.vue b/frontend/src/components/FilmSuche.vue new file mode 100644 index 0000000..0f10866 --- /dev/null +++ b/frontend/src/components/FilmSuche.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/frontend/src/components/Kommentar.vue b/frontend/src/components/Kommentar.vue new file mode 100644 index 0000000..778d8dd --- /dev/null +++ b/frontend/src/components/Kommentar.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/frontend/src/components/MarkdownFeld.vue b/frontend/src/components/MarkdownFeld.vue new file mode 100644 index 0000000..60a4aee --- /dev/null +++ b/frontend/src/components/MarkdownFeld.vue @@ -0,0 +1,282 @@ + + + + + diff --git a/frontend/src/components/MerklisteTab.vue b/frontend/src/components/MerklisteTab.vue index 29a19dd..1338072 100644 --- a/frontend/src/components/MerklisteTab.vue +++ b/frontend/src/components/MerklisteTab.vue @@ -1,62 +1,59 @@ @@ -66,126 +63,48 @@ async function oeffnen(item) {

Noch nichts gemerkt. Öffne einen Film und klicke auf „Zur Sammlung“.

+

+ Nichts gefunden. Stell die Filter weiter. +

-
+

{{ gruppe.titel }} - {{ gefiltert(gruppe).length }} - - - - + {{ gruppe.filme.length }}

-
-
- - -
-
{{ item.title }}
-

{{ item.year }}

-

- {{ item.genres.join(' · ') }} -

- -
-
+
diff --git a/frontend/src/components/NamensWahl.vue b/frontend/src/components/NamensWahl.vue index 45f94ea..86a6298 100644 --- a/frontend/src/components/NamensWahl.vue +++ b/frontend/src/components/NamensWahl.vue @@ -1,21 +1,39 @@ + + + + diff --git a/frontend/src/components/VerwaltungTab.vue b/frontend/src/components/VerwaltungTab.vue index 5e223cc..d8eaec5 100644 --- a/frontend/src/components/VerwaltungTab.vue +++ b/frontend/src/components/VerwaltungTab.vue @@ -1,13 +1,51 @@ @@ -191,432 +249,98 @@ onUnmounted(() => { diff --git a/frontend/src/composables/useEinstellungen.js b/frontend/src/composables/useEinstellungen.js index 6213147..525251e 100644 --- a/frontend/src/composables/useEinstellungen.js +++ b/frontend/src/composables/useEinstellungen.js @@ -1,7 +1,32 @@ -// Was die Seite sich über den Neustart hinaus merkt: Reiter, Ansicht, -// der Schalter für gesehene Filme und der Hostmodus. Alles im localStorage, -// alles je Browser. -import { onMounted, onUnmounted, ref, watch } from 'vue' +// Was die Seite sich über den Neustart hinaus merkt: Reiter, Ansicht und +// der Schalter für gesehene Filme — alles im localStorage, alles je +// Browser. Der Hostmodus nicht: der hängt an der Sitzung auf dem Server. +import { computed, onMounted, onUnmounted, ref, watch } from 'vue' + +// Freigeschaltet wird die Sitzung auf dem Server, und zwar nur mit dem +// Schlüssel aus der .env. Sie bleibt es, bis jemand sie abgibt. +async function hostSetzen(an, schluessel = null) { + try { + const resp = await fetch('/api/host', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ an, schluessel }), + }) + if (!resp.ok) return false + return (await resp.json()).host + } catch { + return false + } +} + +async function hostStand() { + try { + const resp = await fetch('/api/host') + return resp.ok ? (await resp.json()).host : false + } catch { + return false + } +} const TAB_IDS = [ 'netflix', 'canon', 'search', 'aehnlich', 'wheel', 'merkliste', 'watched', @@ -30,24 +55,45 @@ export function useEinstellungen() { const activeTab = ref(TAB_IDS.includes(zuletzt) ? zuletzt : 'netflix') watch(activeTab, (id) => localStorage.setItem('filmabend_tab', id)) - // Wer den Abend leitet, darf ziehen und die Runde leeren. Kein Login — - // die Tastenkombination reicht, die Seite steht ohnehin nur Freunden offen. - const host = ref(localStorage.getItem('filmabend_host') === '1') + // Wer den Abend leitet, darf ziehen und die Runde leeren. Zwei Dinge + // hängen daran: ob die Sitzung überhaupt freigeschaltet ist — das weiß + // der Server — und ob die Knöpfe gerade sichtbar sind. Letzteres schaltet + // die Tastenkombination um, damit der Host die Gastansicht sehen kann. + const freigeschaltet = ref(false) + const anzeige = ref(localStorage.getItem('filmabend_host') !== '0') + const host = computed(() => freigeschaltet.value && anzeige.value) - // Die Verwaltung verschwindet mit dem Hostmodus. Bleibt sie offen, sieht - // ein Gast sie weiter, ohne dass ein Reiter markiert wäre. - watch(host, (an) => { - const weg = an ? 'merkliste' : 'verwaltung' - if (activeTab.value === weg) activeTab.value = 'netflix' + // Der Host-Link: /?host=. Nach dem Einlösen fliegt er aus der + // Adresszeile, damit er nicht im Verlauf oder in einem Screenshot landet. + async function linkEinloesen() { + const adresse = new URL(window.location.href) + const schluessel = adresse.searchParams.get('host') + if (!schluessel) return false + const an = await hostSetzen(true, schluessel) + adresse.searchParams.delete('host') + window.history.replaceState({}, '', adresse) + return an + } + + onMounted(async () => { + // Der Link kommt zuerst: er schaltet frei und zeigt die Knöpfe gleich + if (await linkEinloesen()) { + freigeschaltet.value = true + anzeige.value = true + return + } + freigeschaltet.value = await hostStand() }) + watch(anzeige, (an) => localStorage.setItem('filmabend_host', an ? '1' : '0')) + function tastatur(e) { // e.code statt e.key: mit drei Zusatztasten liefert das Layout sonst // je nach System ein anderes Zeichen if (e.ctrlKey && e.shiftKey && e.altKey && e.code === 'KeyS') { e.preventDefault() - host.value = !host.value - localStorage.setItem('filmabend_host', host.value ? '1' : '0') + // Ohne freigeschaltete Sitzung gibt es nichts umzuschalten + if (freigeschaltet.value) anzeige.value = !anzeige.value } } diff --git a/frontend/src/composables/useMeins.js b/frontend/src/composables/useMeins.js index 4e92e05..47684fe 100644 --- a/frontend/src/composables/useMeins.js +++ b/frontend/src/composables/useMeins.js @@ -26,12 +26,15 @@ export function useMeins({ host }) { ich.value = daten.mine } - async function michSetzen(id) { - await fetch('/api/users/waehlen', { + // Gibt zurück, ob der Wechsel durchging — bei hinterlegtem Anmeldefilm + // lehnt der Server ohne die richtige movie_id ab + async function michSetzen(id, movieId = null) { + const resp = await fetch('/api/users/waehlen', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ user_id: id ?? null }), + body: JSON.stringify({ user_id: id ?? null, movie_id: movieId }), }) + if (!resp.ok) return false // Sammlung, Vorschlag, Wertungen und Wünsche gehören zum Namen. Ohne // Nachladen zeigt die Seite weiter die des vorigen Benutzers. await ladeRunde() @@ -39,6 +42,7 @@ export function useMeins({ host }) { ladeMerkliste() ladeGesehen() ladeWuensche() + return true } // Die geschauten Filme — die Karten und die Detailansicht zeigen damit an, diff --git a/frontend/src/markdown.js b/frontend/src/markdown.js new file mode 100644 index 0000000..84905dd --- /dev/null +++ b/frontend/src/markdown.js @@ -0,0 +1,78 @@ +// Ein kleiner Markdown-Renderer für Rezensionen. Kann, was man beim +// Schreiben über einen Film braucht: Absätze, fett, kursiv, Überschriften, +// Listen, Zitate. Mehr nicht — und keine Abhängigkeit dafür. +// +// Der Text wird zuerst entschärft, dann erst kommen die Tags dazu. So +// landet nie fremdes HTML in der Seite. + +function entschaerfen(s) { + return s.replace(/&/g, '&').replace(//g, '>') +} + +function inline(s) { + return entschaerfen(s) + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/(^|[^*])\*([^*\n]+?)\*/g, '$1$2') + .replace(/`([^`\n]+?)`/g, '$1') +} + +export function rendern(text) { + const out = [] + let absatz = [] + let liste = null + let zitat = [] + + const absatzZu = () => { + if (absatz.length) out.push(`

${absatz.map(inline).join('
')}

`) + absatz = [] + } + const listeZu = () => { + if (liste) out.push(``) + liste = null + } + const zitatZu = () => { + if (zitat.length) out.push(`
${zitat.map(inline).join('
')}
`) + zitat = [] + } + const allesZu = () => { + absatzZu() + listeZu() + zitatZu() + } + + for (const zeile of (text || '').split('\n')) { + const ueberschrift = zeile.match(/^(#{1,3})\s+(.*)$/) + const punkt = zeile.match(/^\s*[-*•]\s+(.*)$/) + const nummer = zeile.match(/^\s*\d+[.)]\s+(.*)$/) + const zit = zeile.match(/^>\s?(.*)$/) + + if (ueberschrift) { + allesZu() + // Eine Stufe kleiner als die Seitentitel + const stufe = ueberschrift[1].length + 3 + out.push(`${inline(ueberschrift[2])}`) + } else if (punkt || nummer) { + absatzZu() + zitatZu() + const art = punkt ? 'ul' : 'ol' + if (liste !== art) { + listeZu() + out.push(`<${art}>`) + liste = art + } + out.push(`
  • ${inline((punkt || nummer)[1])}
  • `) + } else if (zit) { + absatzZu() + listeZu() + zitat.push(zit[1]) + } else if (!zeile.trim()) { + allesZu() + } else { + listeZu() + zitatZu() + absatz.push(zeile) + } + } + allesZu() + return out.join('') +} diff --git a/frontend/src/style.css b/frontend/src/style.css index 3eb4deb..d0959cf 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -53,6 +53,55 @@ a { color: inherit; } +/* Gerenderter Text aus Rezensionen — Editor-Vorschau und Seite gleich */ +.markdown { + font-size: 0.95rem; + line-height: 1.55; +} + +.markdown > * + * { + margin-top: 0.6em; +} + +.markdown h4, +.markdown h5, +.markdown h6 { + font-size: 1.05rem; + line-height: 1.3; +} + +.markdown h5 { + font-size: 1rem; +} + +.markdown h6 { + font-size: 0.95rem; + color: var(--muted); +} + +.markdown ul, +.markdown ol { + padding-left: 1.3em; +} + +.markdown li + li { + margin-top: 0.2em; +} + +.markdown blockquote { + padding-left: 0.8em; + color: var(--muted); + border-left: 3px solid var(--line); +} + +.markdown code { + padding: 0.05em 0.35em; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.9em; + background: rgba(255, 255, 255, 0.06); + border-radius: 4px; +} + /* Unter 16 px zoomt iOS beim Tippen in jedes Feld */ @media (pointer: coarse) { input,