diff --git a/backend/app/features.py b/backend/app/features.py new file mode 100644 index 0000000..444709a --- /dev/null +++ b/backend/app/features.py @@ -0,0 +1,80 @@ +"""Was der Seite noch fehlt. + +Jeder darf aufschreiben, was er sich wünscht. Alle sehen alle Wünsche, +ändern darf jeder nur die eigenen — wie bei den Filmsammlungen. +""" + +from datetime import datetime, timezone + +from .db import connect + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS features ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + text TEXT NOT NULL, + created_at TEXT NOT NULL +); +""" + +MAX_LAENGE = 500 + + +def init(): + with connect() as conn: + conn.executescript(SCHEMA) + + +def alle(session_id): + """Alle Wünsche, jeder mit dem Namen seines Verfassers.""" + with connect() as conn: + rows = conn.execute( + "SELECT f.*, u.name FROM features f " + "LEFT JOIN user_sessions s ON s.session_id = f.session_id " + "LEFT JOIN users u ON u.id = s.user_id " + "ORDER BY f.created_at DESC" + ).fetchall() + return [ + { + **{k: v for k, v in dict(r).items() if k != "session_id"}, + "mine": r["session_id"] == session_id, + } + for r in rows + ] + + +def anlegen(session_id, text): + """Gibt None zurück, wenn nichts dasteht.""" + text = (text or "").strip()[:MAX_LAENGE] + if not text: + return None + with connect() as conn: + conn.execute( + "INSERT INTO features (session_id, text, created_at) VALUES (?, ?, ?)", + ( + session_id, + text, + datetime.now(timezone.utc).isoformat(timespec="seconds"), + ), + ) + return alle(session_id) + + +def aendern(feature_id, text): + """Der Host räumt auf: Tippfehler, Dopplungen, unklare Formulierungen.""" + text = (text or "").strip()[:MAX_LAENGE] + if not text: + return 0 + with connect() as conn: + return conn.execute( + "UPDATE features SET text = ? WHERE id = ?", (text, feature_id) + ).rowcount + + +def loeschen(feature_id): + """Ohne Sitzungsbindung — den Knopf zeigt die Oberfläche nur dem + Verfasser und dem Host.""" + with connect() as conn: + return conn.execute( + "DELETE FROM features WHERE id = ?", (feature_id,) + ).rowcount diff --git a/backend/app/main.py b/backend/app/main.py index e57dae9..6ce4c3b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,7 +9,18 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.staticfiles import StaticFiles -from . import db, events, search, streamproxy, suggestions, sync, watched +from . import ( + db, + events, + features, + search, + streamproxy, + suggestions, + sync, + users, + watched, + wishlist, +) from .config import IMAGE_BASE, STATIC_DIR logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") @@ -71,6 +82,9 @@ async def startup(): db.init() suggestions.init() watched.init() + users.init() + wishlist.init() + features.init() scheduler.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync") scheduler.start() if not db.get_meta("last_sync"): @@ -227,13 +241,12 @@ def liste_vorschlaege(response: Response, filmabend_sid: str | None = Cookie(Non async def vorschlagen( response: Response, movie_id: int = Body(..., embed=True), - name: str | None = Body(None, embed=True), filmabend_sid: str | None = Cookie(None), ): sid = sitzung(response, filmabend_sid) movie = await film_holen(movie_id) # Ein zweiter Vorschlag ersetzt den ersten, statt abgelehnt zu werden - eintrag = suggestions.ersetzen(sid, movie, name) + eintrag = suggestions.ersetzen(sid, movie, users.name_von(sid)) events.senden("suggestions") return {k: v for k, v in eintrag.items() if k != "session_id"} @@ -258,6 +271,110 @@ async def film_holen(movie_id: int): raise HTTPException(status_code=404, detail="Film nicht gefunden") +@app.get("/api/features") +def wuensche(response: Response, filmabend_sid: str | None = Cookie(None)): + sid = sitzung(response, filmabend_sid) + return features.alle(sid) + + +@app.post("/api/features") +def wunsch_anlegen( + response: Response, + text: str = Body(..., embed=True), + filmabend_sid: str | None = Cookie(None), +): + sid = sitzung(response, filmabend_sid) + liste = features.anlegen(sid, text) + if liste is None: + raise HTTPException(status_code=400, detail="Kein Text") + events.senden("features") + return liste + + +@app.patch("/api/features/{feature_id}") +def wunsch_aendern(feature_id: int, text: str = Body(..., embed=True)): + if not features.aendern(feature_id, text): + raise HTTPException(status_code=404, detail="Wunsch nicht gefunden") + events.senden("features") + return {"ok": True} + + +@app.delete("/api/features/{feature_id}") +def wunsch_loeschen(feature_id: int): + entfernt = features.loeschen(feature_id) + if entfernt: + events.senden("features") + return {"removed": entfernt} + + +@app.get("/api/wishlist") +def merkliste(response: Response, filmabend_sid: str | None = Cookie(None)): + """Was diese Sitzung sich vorgemerkt hat.""" + sid = sitzung(response, filmabend_sid) + return wishlist.alle(sid) + + +@app.post("/api/wishlist") +async def merken( + response: Response, + movie_id: int = Body(..., embed=True), + filmabend_sid: str | None = Cookie(None), +): + sid = sitzung(response, filmabend_sid) + return wishlist.merken(sid, await film_holen(movie_id)) + + +@app.delete("/api/wishlist/{movie_id}") +def vergessen( + movie_id: int, + response: Response, + filmabend_sid: str | None = Cookie(None), +): + sid = sitzung(response, filmabend_sid) + return {"removed": wishlist.vergessen(sid, movie_id)} + + +@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)} + + +@app.post("/api/users") +def name_anlegen(name: str = Body(..., embed=True)): + """Der Host erweitert die Runde.""" + benutzer = users.anlegen(name) + if not benutzer: + raise HTTPException(status_code=400, detail="Name leer oder schon vergeben") + events.senden("users") + return benutzer + + +@app.delete("/api/users/{user_id}") +def name_entfernen(user_id: int): + if not users.entfernen(user_id): + raise HTTPException(status_code=404, detail="Unbekannter Name") + events.senden("users") + return {"removed": 1} + + +@app.post("/api/users/waehlen") +def name_waehlen( + response: Response, + user_id: int | None = Body(None, embed=True), + filmabend_sid: str | None = Cookie(None), +): + """Wer bin ich? Ohne user_id wieder anonym.""" + sid = sitzung(response, filmabend_sid) + if user_id is None: + users.abwaehlen(sid) + return {"mine": None} + benutzer = users.waehlen(sid, user_id) + if not benutzer: + raise HTTPException(status_code=404, detail="Unbekannter Name") + return {"mine": benutzer} + + @app.get("/api/watched") def liste_gesehen(response: Response, filmabend_sid: str | None = Cookie(None)): sid = sitzung(response, filmabend_sid) @@ -303,12 +420,12 @@ def bewerten( watched_id: int, response: Response, stars: int = Body(..., embed=True), - name: str | None = Body(None, embed=True), filmabend_sid: str | None = Cookie(None), ): sid = sitzung(response, filmabend_sid) stars = max(1, min(watched.MAX_STERNE, stars)) - eintrag = watched.bewerten(watched_id, sid, stars, name) + # Der Name kommt aus der Runde, nicht aus der Anfrage + 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") diff --git a/backend/app/users.py b/backend/app/users.py new file mode 100644 index 0000000..42ade8b --- /dev/null +++ b/backend/app/users.py @@ -0,0 +1,112 @@ +"""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. + +Die Zuordnung hängt an derselben Cookie-Sitzung wie Vorschläge und +Wertungen. Damit steht der Name serverseitig fest und kommt nicht mehr aus +einem Textfeld, in das jeder schreiben kann, was er will. +""" + +import sqlite3 +from datetime import datetime, timezone + +from .db import connect + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_sessions ( + session_id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + seit TEXT NOT NULL +); +""" + + +def init(): + with connect() as conn: + conn.executescript(SCHEMA) + + +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] + + +def anlegen(name): + """Gibt None zurück, wenn der Name schon vergeben ist.""" + name = (name or "").strip()[:40] + if not name: + return None + try: + with connect() as conn: + conn.execute( + "INSERT INTO users (name, created_at) VALUES (?, ?)", + (name, datetime.now(timezone.utc).isoformat(timespec="seconds")), + ) + except sqlite3.IntegrityError: + return None + return von_name(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 + + +def entfernen(user_id): + with connect() as conn: + conn.execute("DELETE FROM user_sessions WHERE user_id = ?", (user_id,)) + return conn.execute("DELETE FROM users WHERE id = ?", (user_id,)).rowcount + + +def waehlen(session_id, user_id): + """Gibt None zurück, wenn es den Namen nicht gibt.""" + with connect() as conn: + da = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + if not da: + return None + conn.execute( + "INSERT INTO user_sessions (session_id, user_id, seit) VALUES (?, ?, ?) " + "ON CONFLICT (session_id) DO UPDATE SET " + "user_id = excluded.user_id, seit = excluded.seit", + ( + session_id, + user_id, + datetime.now(timezone.utc).isoformat(timespec="seconds"), + ), + ) + return dict(da) + + +def abwaehlen(session_id): + with connect() as conn: + return conn.execute( + "DELETE FROM user_sessions WHERE session_id = ?", (session_id,) + ).rowcount + + +def aktuell(session_id): + """Der Benutzer dieser Sitzung, oder None.""" + with connect() as conn: + row = conn.execute( + "SELECT u.* FROM users u " + "JOIN user_sessions s ON s.user_id = u.id " + "WHERE s.session_id = ?", + (session_id,), + ).fetchone() + return dict(row) if row else None + + +def name_von(session_id): + """Nur der Name, für Vorschläge und Wertungen.""" + benutzer = aktuell(session_id) + return benutzer["name"] if benutzer else None diff --git a/backend/app/wishlist.py b/backend/app/wishlist.py new file mode 100644 index 0000000..ce32119 --- /dev/null +++ b/backend/app/wishlist.py @@ -0,0 +1,91 @@ +"""Was jeder für sich sehen möchte. + +Alle sehen alle Sammlungen — man merkt so, worauf die anderen Lust haben. +Ändern darf jeder nur die eigene. Der Vorschlag fürs Rad ist eine andere +Sache: davon gibt es einen je Person, und er landet im Glücksrad. + +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 sqlite3 +from datetime import datetime, timezone + +from .db import connect + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS wishlist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + movie_id INTEGER NOT NULL, + title TEXT NOT NULL, + year INTEGER, + poster_path TEXT, + created_at TEXT NOT NULL, + UNIQUE (session_id, movie_id) +); +""" + + +def init(): + with connect() as conn: + conn.executescript(SCHEMA) + + +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 " + "LEFT JOIN user_sessions s ON s.session_id = w.session_id " + "LEFT JOIN users u ON u.id = s.user_id " + "ORDER BY w.created_at DESC" + ).fetchall() + return [ + { + **{k: v for k, v in dict(r).items() if k != "session_id"}, + "mine": r["session_id"] == session_id, + } + for r in rows + ] + + +def merken(session_id, movie): + """Ein zweites Mal merken ändert nichts.""" + try: + with connect() as conn: + conn.execute( + "INSERT INTO wishlist " + "(session_id, movie_id, title, year, poster_path, created_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + ( + session_id, + movie["id"], + movie["title"], + movie.get("year"), + movie.get("poster_path"), + datetime.now(timezone.utc).isoformat(timespec="seconds"), + ), + ) + except sqlite3.IntegrityError: + pass + return alle(session_id) + + +def vergessen(session_id, movie_id): + with connect() as conn: + return conn.execute( + "DELETE FROM wishlist WHERE session_id = ? AND movie_id = ?", + (session_id, movie_id), + ).rowcount + + +def ids(session_id): + """movie_ids der gemerkten Filme, für die Detailansicht.""" + with connect() as conn: + return [ + r["movie_id"] + for r in conn.execute( + "SELECT movie_id FROM wishlist WHERE session_id = ?", (session_id,) + ) + ] diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 0a64c3e..6c5b9ae 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -3,7 +3,11 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { abonnieren } from './live' import MovieCard from './components/MovieCard.vue' import FilterSidebar from './components/FilterSidebar.vue' +import MerklisteTab from './components/MerklisteTab.vue' import MovieDetail from './components/MovieDetail.vue' +import NamensWahl from './components/NamensWahl.vue' +import VerwaltungTab from './components/VerwaltungTab.vue' +import WuenscheTab from './components/WuenscheTab.vue' import SearchTab from './components/SearchTab.vue' import StreamTab from './components/StreamTab.vue' import WatchedTab from './components/WatchedTab.vue' @@ -16,10 +20,19 @@ const loading = ref(true) const error = ref('') const search = ref('') const sort = ref('random') + +// Kompakt zeigt viele Karten nebeneinander, Details dafür mehr je Film +const ANSICHT_SCHLUESSEL = 'filmabend_ansicht' +const ansicht = ref(localStorage.getItem(ANSICHT_SCHLUESSEL) || 'kompakt') + +watch(ansicht, (wert) => localStorage.setItem(ANSICHT_SCHLUESSEL, wert)) const selected = ref(null) // Nach dem Neuladen soll derselbe Reiter offen sein const TAB_SCHLUESSEL = 'filmabend_tab' -const TAB_IDS = ['netflix', 'canon', 'search', 'wheel', 'watched', 'stream'] +const TAB_IDS = [ + 'netflix', 'canon', 'search', 'wheel', 'merkliste', 'watched', 'wuensche', + 'verwaltung', 'stream', +] const gemerkterTab = localStorage.getItem(TAB_SCHLUESSEL) const activeTab = ref(TAB_IDS.includes(gemerkterTab) ? gemerkterTab : 'netflix') @@ -200,9 +213,14 @@ const tabs = computed(() => [ { id: 'netflix', label: 'Netflix', count: netflixMovies.value.length }, { id: 'canon', label: 'Horror-Kanon', count: canonGroups.value.length }, { id: 'search', label: 'Suche', count: null }, + ...(host.value + ? [] + : [{ id: 'merkliste', label: 'Sammlungen', count: null }]), { id: 'wheel', label: 'Rad', count: null }, - { id: 'watched', label: 'Gesehen', count: gesehenZahl.value }, { id: 'stream', label: 'Stream', count: null }, + { id: 'watched', label: 'Gesehen', count: gesehenZahl.value }, + { id: 'wuensche', label: 'Wünsche', count: wuensche.value.length }, + ...(host.value ? [{ id: 'verwaltung', label: 'Verwaltung', count: null }] : []), ]) const rad = ref(null) @@ -227,16 +245,43 @@ const gesehenZahl = computed( // Wer den Abend leitet, darf drehen und die Runde leeren. Kein Login — // die Tastenkombination reicht, die Seite steht ohnehin nur Freunden offen. -// Ein Name für alles: Vorschläge und Wertungen. Ohne Namen läuft es -// anonym, das ist ausdrücklich erlaubt. -const NAME_SCHLUESSEL = 'filmabend_name' -const name = ref(localStorage.getItem(NAME_SCHLUESSEL) || '') +// Die Runde: der Host pflegt die Namen, alle anderen wählen sich einen aus. +// Den Namen setzt danach der Server an Vorschläge und Wertungen. +const runde = ref([]) +const ich = ref(null) -watch(name, (wert) => localStorage.setItem(NAME_SCHLUESSEL, wert)) +async function michSetzen(id) { + await fetch('/api/users/waehlen', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: id ?? null }), + }) + ladeRunde() +} + +async function ladeRunde() { + try { + const resp = await fetch('/api/users') + if (!resp.ok) return + const daten = await resp.json() + runde.value = daten.items + ich.value = daten.mine + } catch { + /* dann eben ohne */ + } +} const HOST_SCHLUESSEL = 'filmabend_host' const host = ref(localStorage.getItem(HOST_SCHLUESSEL) === '1') +// Die Verwaltung verschwindet mit dem Hostmodus. Bleibt sie offen, sieht +// ein Gast sie weiter, ohne dass ein Reiter markiert wäre. +watch(host, (an) => { + // Verwaltung sieht nur der Host, Vorschläge nur die Gäste + const weg = an ? 'merkliste' : 'verwaltung' + if (activeTab.value === weg) activeTab.value = 'netflix' +}) + function tastatur(e) { // e.code statt e.key: mit drei Zusatztasten liefert das Layout sonst // je nach System ein anderes Zeichen @@ -253,6 +298,30 @@ const meinVorschlag = ref(null) // Alle Filme auf dem Rad, damit die Karten es zeigen können const aufDemRad = ref([]) +// Die eigene Merkliste — was man selbst sehen möchte, unabhängig vom Rad +const merkliste = ref([]) + +// Was der Seite noch fehlt +const wuensche = ref([]) + +async function ladeWuensche() { + try { + const resp = await fetch('/api/features') + if (resp.ok) wuensche.value = await resp.json() + } catch { + /* dann eben ohne */ + } +} + +async function ladeMerkliste() { + try { + const resp = await fetch('/api/wishlist') + if (resp.ok) merkliste.value = await resp.json() + } catch { + /* dann eben ohne */ + } +} + async function ladeVorschlag() { try { const resp = await fetch('/api/suggestions') @@ -397,9 +466,14 @@ onMounted(() => { load() ladeVorschlag() ladeGesehen() + ladeRunde() + ladeMerkliste() + ladeWuensche() // Schlägt jemand anders etwas vor, sollen es auch die Karten zeigen abbestellen = abonnieren((nachricht) => { if (nachricht.type === 'suggestions' || nachricht.type === 'reset') ladeVorschlag() + else if (nachricht.type === 'users') ladeRunde() + else if (nachricht.type === 'features') ladeWuensche() }) window.addEventListener('keydown', tastatur) }) @@ -411,7 +485,11 @@ onUnmounted(() => {