From 847b747870dfaba45a1001018faea20d77b644db Mon Sep 17 00:00:00 2001 From: team3 Date: Wed, 9 Sep 2026 17:58:52 +0200 Subject: [PATCH] update --- Makefile | 18 ++- backend/app/features.py | 28 +++- backend/app/suggestions.py | 97 +++++++++++-- backend/app/users.py | 22 +++ backend/app/watched.py | 42 +++++- backend/app/wishlist.py | 49 ++++++- frontend/src/App.vue | 9 +- frontend/src/components/WheelTab.vue | 209 ++++++++++++++++++++++++--- 8 files changed, 419 insertions(+), 55 deletions(-) diff --git a/Makefile b/Makefile index 3bfd55d..bbedb9c 100644 --- a/Makefile +++ b/Makefile @@ -6,20 +6,26 @@ install: # MediaMTX nimmt den Stream entgegen. Ohne ihn läuft der Stream-Reiter ins # Leere und OBS findet keinen Server. +# Räumt immer erst auf: ein gestoppter Container von vorhin belegt sonst +# den Namen, und "docker ps" sieht ihn nicht. mtx: - @docker ps --filter name=horror-mtx-dev --format '{{.Names}}' | grep -q . || \ - docker run -d --name horror-mtx-dev --network host \ - --env-file .env \ - -e MTX_AUTHINTERNALUSERS_0_PASS="$$(grep '^MTX_SENDEPASSWORT=' .env | cut -d= -f2-)" \ - -v "$(PWD)/mediamtx/mediamtx.yml:/mediamtx.yml:ro" \ - bluenviron/mediamtx:latest > /dev/null + -@docker rm -f horror-mtx-dev 2>/dev/null > /dev/null + @docker run -d --rm --name horror-mtx-dev --network host \ + --env-file .env \ + -e MTX_AUTHINTERNALUSERS_0_PASS="$$(grep '^MTX_SENDEPASSWORT=' .env | cut -d= -f2-)" \ + -v "$(PWD)/mediamtx/mediamtx.yml:/mediamtx.yml:ro" \ + bluenviron/mediamtx:latest > /dev/null @echo "MediaMTX läuft: RTMP auf :1935, HLS auf :8890" mtx-stop: -@docker rm -f horror-mtx-dev 2>/dev/null > /dev/null @echo "MediaMTX gestoppt." +# Startet unabhängig davon, was gerade läuft. Die eckige Klammer im Muster +# sorgt dafür, dass pkill nicht die eigene Zeile trifft. dev: mtx + -@pkill -f "[u]vicorn app.main:app" 2>/dev/null + -@pkill -f "[v]ite --port 5173" 2>/dev/null @echo "Backend: http://localhost:8000 Frontend: http://localhost:5173" @cd backend && .venv/bin/uvicorn app.main:app --reload --port 8000 & @cd frontend && npx vite --port 5173 diff --git a/backend/app/features.py b/backend/app/features.py index 444709a..008ddd5 100644 --- a/backend/app/features.py +++ b/backend/app/features.py @@ -7,6 +7,7 @@ Jeder darf aufschreiben, was er sich wünscht. Alle sehen alle Wünsche, from datetime import datetime, timezone from .db import connect +from .users import besitzer, gehoert_mir SCHEMA = """ CREATE TABLE IF NOT EXISTS features ( @@ -20,9 +21,23 @@ CREATE TABLE IF NOT EXISTS features ( MAX_LAENGE = 500 +def _spalte_user_id(conn, tabelle): + """Nachrüsten und aus der Sitzung füllen — einmalig je Datenbank.""" + spalten = {r[1] for r in conn.execute(f"PRAGMA table_info({tabelle})")} + if "user_id" in spalten: + return + conn.execute(f"ALTER TABLE {tabelle} ADD COLUMN user_id INTEGER") + conn.execute( + f"UPDATE {tabelle} SET user_id = (" + " SELECT user_id FROM user_sessions s WHERE s.session_id = " + f" {tabelle}.session_id)" + ) + + def init(): with connect() as conn: conn.executescript(SCHEMA) + _spalte_user_id(conn, "features") def alle(session_id): @@ -34,10 +49,15 @@ def alle(session_id): "LEFT JOIN users u ON u.id = s.user_id " "ORDER BY f.created_at DESC" ).fetchall() + ich = besitzer(session_id) return [ { - **{k: v for k, v in dict(r).items() if k != "session_id"}, - "mine": r["session_id"] == session_id, + **{ + k: v + for k, v in dict(r).items() + if k not in ("session_id", "user_id") + }, + "mine": gehoert_mir(r, session_id, ich), } for r in rows ] @@ -50,9 +70,11 @@ def anlegen(session_id, text): return None with connect() as conn: conn.execute( - "INSERT INTO features (session_id, text, created_at) VALUES (?, ?, ?)", + "INSERT INTO features (session_id, user_id, text, created_at) " + "VALUES (?, ?, ?, ?)", ( session_id, + besitzer(session_id), text, datetime.now(timezone.utc).isoformat(timespec="seconds"), ), diff --git a/backend/app/suggestions.py b/backend/app/suggestions.py index ea62593..0135230 100644 --- a/backend/app/suggestions.py +++ b/backend/app/suggestions.py @@ -15,11 +15,12 @@ import uuid from datetime import datetime, timezone from .db import connect +from .users import besitzer SCHEMA = """ CREATE TABLE IF NOT EXISTS suggestions ( id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL UNIQUE, + session_id TEXT NOT NULL, movie_id INTEGER NOT NULL, title TEXT NOT NULL, year INTEGER, @@ -37,9 +38,67 @@ COOKIE = "filmabend_sid" DREH_DAUER = 4.2 +# Ein Vorschlag je Person. Früher stand das UNIQUE auf der Sitzung — dann +# blockierte der Vorschlag des einen Namens den des nächsten, sobald beide +# denselben Browser benutzten. +INDIZES = """ +CREATE UNIQUE INDEX IF NOT EXISTS suggestions_je_benutzer + ON suggestions (user_id) WHERE user_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS suggestions_je_sitzung + ON suggestions (session_id) WHERE user_id IS NULL; +""" + + +def _unique_loesen(conn): + """Das alte UNIQUE steckt in der Spaltendefinition — Tabelle neu bauen.""" + sql = conn.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='suggestions'" + ).fetchone() + if not sql or "UNIQUE" not in sql["sql"]: + return + conn.executescript( + """ + ALTER TABLE suggestions RENAME TO suggestions_alt; + CREATE TABLE suggestions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + user_id INTEGER, + movie_id INTEGER NOT NULL, + title TEXT NOT NULL, + year INTEGER, + poster_path TEXT, + name TEXT, + created_at TEXT NOT NULL + ); + INSERT INTO suggestions + (id, session_id, user_id, movie_id, title, year, poster_path, + name, created_at) + SELECT id, session_id, user_id, movie_id, title, year, poster_path, + name, created_at FROM suggestions_alt; + DROP TABLE suggestions_alt; + """ + ) + + +def _spalte_user_id(conn, tabelle): + """Nachrüsten und aus der Sitzung füllen — einmalig je Datenbank.""" + spalten = {r[1] for r in conn.execute(f"PRAGMA table_info({tabelle})")} + if "user_id" in spalten: + return + conn.execute(f"ALTER TABLE {tabelle} ADD COLUMN user_id INTEGER") + conn.execute( + f"UPDATE {tabelle} SET user_id = (" + " SELECT user_id FROM user_sessions s WHERE s.session_id = " + f" {tabelle}.session_id)" + ) + + def init(): with connect() as conn: conn.executescript(SCHEMA) + _spalte_user_id(conn, "suggestions") + _unique_loesen(conn) + conn.executescript(INDIZES) def neue_session(): @@ -49,15 +108,31 @@ def neue_session(): def alle(): with connect() as conn: rows = conn.execute( - "SELECT * FROM suggestions ORDER BY created_at ASC" + # Laufzeit und Genres stehen im Katalog. Ein Vorschlag von + # außerhalb hat sie nicht — die Liste zeigt dann nur den Titel. + "SELECT s.*, m.runtime, m.genres FROM suggestions s " + "LEFT JOIN movies m ON m.id = s.movie_id " + "ORDER BY s.created_at ASC" ).fetchall() - return [dict(r) for r in rows] + return [ + { + **{k: v for k, v in dict(r).items() if k != "genres"}, + "genres": json.loads(r["genres"]) if r["genres"] else [], + } + for r in rows + ] + + +# Mit Namen zählt der Name, ohne Namen die Sitzung +WEM = "(user_id = ? OR (? IS NULL AND user_id IS NULL AND session_id = ?))" def von_session(session_id): + """Der eigene Vorschlag — auch aus einem anderen Browser.""" + ich = besitzer(session_id) with connect() as conn: row = conn.execute( - "SELECT * FROM suggestions WHERE session_id = ?", (session_id,) + f"SELECT * FROM suggestions WHERE {WEM}", (ich, ich, session_id) ).fetchone() return dict(row) if row else None @@ -66,6 +141,7 @@ def anlegen(session_id, movie, name=None): """Gibt den Vorschlag zurück, oder None wenn die Sitzung schon einen hat.""" eintrag = { "session_id": session_id, + "user_id": besitzer(session_id), "movie_id": movie["id"], "title": movie["title"], "year": movie.get("year"), @@ -77,9 +153,10 @@ def anlegen(session_id, movie, name=None): with connect() as conn: conn.execute( "INSERT INTO suggestions " - "(session_id, movie_id, title, year, poster_path, name, created_at) " - "VALUES (:session_id, :movie_id, :title, :year, :poster_path, " - ":name, :created_at)", + "(session_id, user_id, movie_id, title, year, poster_path, " + "name, created_at) " + "VALUES (:session_id, :user_id, :movie_id, :title, :year, " + ":poster_path, :name, :created_at)", eintrag, ) except sqlite3.IntegrityError: @@ -89,8 +166,7 @@ def anlegen(session_id, movie, name=None): def ersetzen(session_id, movie, name=None): """Wer schon vorgeschlagen hat, darf seinen Vorschlag ändern.""" - with connect() as conn: - conn.execute("DELETE FROM suggestions WHERE session_id = ?", (session_id,)) + loeschen(session_id) return anlegen(session_id, movie, name) @@ -122,9 +198,10 @@ def ziehen(): def loeschen(session_id): + ich = besitzer(session_id) with connect() as conn: return conn.execute( - "DELETE FROM suggestions WHERE session_id = ?", (session_id,) + f"DELETE FROM suggestions WHERE {WEM}", (ich, ich, session_id) ).rowcount diff --git a/backend/app/users.py b/backend/app/users.py index 42ade8b..bab57d6 100644 --- a/backend/app/users.py +++ b/backend/app/users.py @@ -106,6 +106,28 @@ def aktuell(session_id): return dict(row) if row else None +def besitzer(session_id): + """Die user_id dieser Sitzung, oder None ohne gewählten Namen. + + Sammlungen, Vorschläge und Wertungen werden damit beschriftet. Ein + Namenswechsel im selben Browser lässt die alten Einträge dann in Ruhe — + sie gehören weiter dem Namen, unter dem sie entstanden sind. + """ + with connect() as conn: + row = conn.execute( + "SELECT user_id FROM user_sessions WHERE session_id = ?", + (session_id,), + ).fetchone() + return row["user_id"] if row else None + + +def gehoert_mir(zeile, session_id, user_id): + """Ein Eintrag gehört mir, wenn der Name passt — sonst die Sitzung.""" + if zeile["user_id"] is not None or user_id is not None: + return zeile["user_id"] == user_id + return zeile["session_id"] == session_id + + def name_von(session_id): """Nur der Name, für Vorschläge und Wertungen.""" benutzer = aktuell(session_id) diff --git a/backend/app/watched.py b/backend/app/watched.py index 0c7da39..b1658d7 100644 --- a/backend/app/watched.py +++ b/backend/app/watched.py @@ -11,6 +11,7 @@ auch wenn der Film später aus dem Katalog fällt. from datetime import datetime, timezone from .db import connect +from .users import besitzer, gehoert_mir SCHEMA = """ CREATE TABLE IF NOT EXISTS watched ( @@ -37,9 +38,23 @@ CREATE TABLE IF NOT EXISTS ratings ( MAX_STERNE = 5 +def _spalte_user_id(conn, tabelle): + """Nachrüsten und aus der Sitzung füllen — einmalig je Datenbank.""" + spalten = {r[1] for r in conn.execute(f"PRAGMA table_info({tabelle})")} + if "user_id" in spalten: + return + conn.execute(f"ALTER TABLE {tabelle} ADD COLUMN user_id INTEGER") + conn.execute( + f"UPDATE {tabelle} SET user_id = (" + " SELECT user_id FROM user_sessions s WHERE s.session_id = " + f" {tabelle}.session_id)" + ) + + def init(): with connect() as conn: conn.executescript(SCHEMA) + _spalte_user_id(conn, "ratings") # 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: @@ -59,10 +74,11 @@ def alle(session_id): "SELECT * FROM watched ORDER BY watched_at DESC, id DESC" ).fetchall() stimmen = conn.execute( - "SELECT id, watched_id, session_id, stars, name FROM ratings " + "SELECT id, watched_id, session_id, user_id, stars, name FROM ratings " "ORDER BY created_at ASC" ).fetchall() + ich = besitzer(session_id) je_film = {} for s in stimmen: je_film.setdefault(s["watched_id"], []).append(s) @@ -81,7 +97,11 @@ def alle(session_id): "average": round(sum(werte) / len(werte), 1) if werte else None, "count": len(werte), "mine": next( - (s["stars"] for s in eigene if s["session_id"] == session_id), + ( + s["stars"] + for s in eigene + if gehoert_mir(s, session_id, ich) + ), None, ), } @@ -145,17 +165,25 @@ def bewerten(watched_id, session_id, stars, name=None): ).fetchone() if not da: return None - # Eine Stimme je Sitzung — eine neue Bewertung ersetzt die alte + # Eine Stimme je Person — die eigene alte fliegt vorher raus. + # Mit Namen zählt der Name, ohne Namen die Sitzung. + ich = besitzer(session_id) + conn.execute( + "DELETE FROM ratings WHERE watched_id = ? AND " + "(user_id = ? OR (? IS NULL AND user_id IS NULL AND session_id = ?))", + (watched_id, ich, ich, session_id), + ) conn.execute( "INSERT INTO ratings " - "(watched_id, session_id, stars, name, created_at) " - "VALUES (?, ?, ?, ?, ?) " + "(watched_id, session_id, user_id, stars, name, created_at) " + "VALUES (?, ?, ?, ?, ?, ?) " "ON CONFLICT (watched_id, session_id) DO UPDATE SET " - "stars = excluded.stars, name = excluded.name, " - "created_at = excluded.created_at", + "user_id = excluded.user_id, stars = excluded.stars, " + "name = excluded.name, created_at = excluded.created_at", ( watched_id, session_id, + ich, stars, (name or "").strip()[:40] or None, _jetzt(), diff --git a/backend/app/wishlist.py b/backend/app/wishlist.py index ce32119..33a1f87 100644 --- a/backend/app/wishlist.py +++ b/backend/app/wishlist.py @@ -8,10 +8,12 @@ Titel und Poster kommen mit in die Zeile. Ein gemerkter Film soll lesbar bleiben, auch wenn er nie im Katalog stand oder Netflix ihn abgibt. """ +import json import sqlite3 from datetime import datetime, timezone from .db import connect +from .users import besitzer, gehoert_mir SCHEMA = """ CREATE TABLE IF NOT EXISTS wishlist ( @@ -27,24 +29,47 @@ CREATE TABLE IF NOT EXISTS wishlist ( """ +def _spalte_user_id(conn, tabelle): + """Nachrüsten und aus der Sitzung füllen — einmalig je Datenbank.""" + spalten = {r[1] for r in conn.execute(f"PRAGMA table_info({tabelle})")} + if "user_id" in spalten: + return + conn.execute(f"ALTER TABLE {tabelle} ADD COLUMN user_id INTEGER") + conn.execute( + f"UPDATE {tabelle} SET user_id = (" + " SELECT user_id FROM user_sessions s WHERE s.session_id = " + f" {tabelle}.session_id)" + ) + + def init(): with connect() as conn: conn.executescript(SCHEMA) + _spalte_user_id(conn, "wishlist") def alle(session_id): """Alle Sammlungen, jede mit dem Namen ihres Besitzers.""" with connect() as conn: rows = conn.execute( - "SELECT w.*, u.name FROM wishlist w " + # 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 " "LEFT JOIN user_sessions s ON s.session_id = w.session_id " "LEFT JOIN users u ON u.id = s.user_id " + "LEFT JOIN movies m ON m.id = w.movie_id " "ORDER BY w.created_at DESC" ).fetchall() + ich = besitzer(session_id) return [ { - **{k: v for k, v in dict(r).items() if k != "session_id"}, - "mine": r["session_id"] == session_id, + **{ + k: v + for k, v in dict(r).items() + if k not in ("session_id", "user_id", "genres") + }, + "genres": json.loads(r["genres"]) if r["genres"] else [], + "mine": gehoert_mir(r, session_id, ich), } for r in rows ] @@ -56,10 +81,11 @@ def merken(session_id, movie): with connect() as conn: conn.execute( "INSERT INTO wishlist " - "(session_id, movie_id, title, year, poster_path, created_at) " - "VALUES (?, ?, ?, ?, ?, ?)", + "(session_id, user_id, movie_id, title, year, poster_path, " + "created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", ( session_id, + besitzer(session_id), movie["id"], movie["title"], movie.get("year"), @@ -72,20 +98,27 @@ def merken(session_id, movie): return alle(session_id) +# Mit Namen zählt der Name, ohne Namen die Sitzung +WEM = "(user_id = ? OR (? IS NULL AND user_id IS NULL AND session_id = ?))" + + def vergessen(session_id, movie_id): + ich = besitzer(session_id) with connect() as conn: return conn.execute( - "DELETE FROM wishlist WHERE session_id = ? AND movie_id = ?", - (session_id, movie_id), + f"DELETE FROM wishlist WHERE {WEM} AND movie_id = ?", + (ich, ich, session_id, movie_id), ).rowcount def ids(session_id): """movie_ids der gemerkten Filme, für die Detailansicht.""" + ich = besitzer(session_id) with connect() as conn: return [ r["movie_id"] for r in conn.execute( - "SELECT movie_id FROM wishlist WHERE session_id = ?", (session_id,) + f"SELECT movie_id FROM wishlist WHERE {WEM}", + (ich, ich, session_id), ) ] diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1f7d12f..c9f21b9 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -349,7 +349,13 @@ async function michSetzen(id) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: id ?? null }), }) - ladeRunde() + // Sammlung, Vorschlag, Wertungen und Wünsche gehören zum Namen. Ohne + // Nachladen zeigt die Seite weiter die des vorigen Benutzers. + await ladeRunde() + ladeVorschlag() + ladeMerkliste() + ladeGesehen() + ladeWuensche() } async function ladeRunde() { @@ -688,6 +694,7 @@ onUnmounted(() => { v-else-if="activeTab === 'wheel'" ref="rad" :host="host" + :ich="ich" @open="selected = $event" @reset="meinVorschlag = null" /> diff --git a/frontend/src/components/WheelTab.vue b/frontend/src/components/WheelTab.vue index 77cce40..64ec8d9 100644 --- a/frontend/src/components/WheelTab.vue +++ b/frontend/src/components/WheelTab.vue @@ -1,9 +1,13 @@