diff --git a/Makefile b/Makefile index cfcf662..dd00542 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install dev stop sync build prod logs down push pull stream stream-local stream-stop mtx mtx-stop +.PHONY: install dev stop sync build prod logs down pull stream stream-local stream-stop mtx mtx-stop install: cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt @@ -78,7 +78,7 @@ stream-stop: -@scripts/ton.sh ab @echo "OBS beendet." -# ── Datenbank hin und her (lokal ausführen) ─────────────────────────────────── +# ── Datenbank vom Server holen (lokal ausführen) ─────────────────────────────────── SERVER = root@178.104.67.87 REMOTE = /var/www/horror LOKAL = backend/horror.db @@ -87,14 +87,6 @@ LOKAL = backend/horror.db # in sich stimmige Kopie ziehen, statt die Datei direkt anzufassen. SICHERN = import sqlite3,sys; q=sqlite3.connect(sys.argv[1]); z=sqlite3.connect(sys.argv[2]); q.backup(z); z.close(); q.close() -push: - @echo "Überschreibt die Datenbank auf dem Server, auch die Vorschläge." - backend/.venv/bin/python -c '$(SICHERN)' $(LOKAL) /tmp/horror-push.db - ssh $(SERVER) 'mkdir -p $(REMOTE)/data && [ ! -f $(REMOTE)/data/horror.db ] || cp $(REMOTE)/data/horror.db $(REMOTE)/data/horror.db.bak' - scp /tmp/horror-push.db $(SERVER):$(REMOTE)/data/horror.db - @rm -f /tmp/horror-push.db - @echo "Fertig. Die alte Datenbank liegt als data/horror.db.bak daneben." - pull: @echo "Überschreibt deine lokale Datenbank." ssh $(SERVER) 'python3 -c "$(SICHERN)" $(REMOTE)/data/horror.db /tmp/horror-pull.db' diff --git a/README.md b/README.md index 1c25b98..b5d9bf9 100644 --- a/README.md +++ b/README.md @@ -56,19 +56,19 @@ make down Die SQLite-Datei liegt im Bind-Mount `./data`, überlebt also jeden Rebuild. -### Datenbank abgleichen +### Datenbank vom Server holen ```bash -make push # lokaler Stand → Server make pull # Server-Stand → lokal ``` -Beide ziehen erst eine in sich stimmige Kopie über SQLites `backup`, statt die -Datei einer laufenden App direkt zu kopieren. Die überschriebene Datenbank +Zieht erst eine in sich stimmige Kopie über SQLites `backup`, statt die Datei +einer laufenden App direkt zu kopieren. Die überschriebene lokale Datenbank bleibt als `.bak` daneben liegen. -`make push` überschreibt auch die Vorschläge auf dem Server. Wer schon einen -Film fürs Rad eingereicht hat, verliert ihn. +Den umgekehrten Weg gibt es absichtlich nicht: Die Sammlungen, Vorschläge und +Wertungen auf dem Server sind die echten. Ein lokaler Stand würde sie +überschreiben. Ohne Docker geht es auch: diff --git a/backend/app/features.py b/backend/app/features.py index 6680381..65d036f 100644 --- a/backend/app/features.py +++ b/backend/app/features.py @@ -1,14 +1,19 @@ """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. +Jeder darf aufschreiben, was er sich wünscht, und für Wünsche anderer +stimmen — eine Stimme je Person. Ergänzungen hängen als Anmerkungen unter +dem Wunsch, damit der ursprüngliche Text stehen bleibt und die Diskussion +nachvollziehbar ist. Der Host hakt ab, was umgesetzt ist: der Wunsch bleibt +sichtbar, rutscht aber ans Ende. + +Ändern darf jeder nur die eigenen, wie bei den Filmsammlungen. """ from datetime import datetime, timezone from . import users from .db import connect -from .users import besitzer, gehoert_mir +from .users import WEM, besitzer, gehoert_mir SCHEMA = """ CREATE TABLE IF NOT EXISTS features ( @@ -16,6 +21,28 @@ CREATE TABLE IF NOT EXISTS features ( session_id TEXT NOT NULL, user_id INTEGER, text TEXT NOT NULL, + created_at TEXT NOT NULL, + done_at TEXT +); +CREATE TABLE IF NOT EXISTS feature_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + feature_id INTEGER NOT NULL, + session_id TEXT NOT NULL, + user_id INTEGER, + text TEXT NOT NULL, + created_at TEXT NOT NULL +); +""" + +# Eigenes Statement, weil users.je_person() die Tabelle daraus neu bauen +# könnte — hier ohne UNIQUE in der Spaltendefinition, die Eindeutigkeit +# kommt aus den Indizes je Person. +VOTES_SCHEMA = """ +CREATE TABLE IF NOT EXISTS feature_votes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + feature_id INTEGER NOT NULL, + session_id TEXT NOT NULL, + user_id INTEGER, created_at TEXT NOT NULL ); """ @@ -23,21 +50,64 @@ CREATE TABLE IF NOT EXISTS features ( MAX_LAENGE = 500 +def _jetzt(): + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + def init(): with connect() as conn: conn.executescript(SCHEMA) + conn.executescript(VOTES_SCHEMA) users.user_id_nachruesten(conn, "features") + # Die Tabelle stand vor der Spalte schon in bestehenden Datenbanken + spalten = {r[1] for r in conn.execute("PRAGMA table_info(features)")} + if "done_at" not in spalten: + conn.execute("ALTER TABLE features ADD COLUMN done_at TEXT") + # Eine Stimme je Person und Wunsch + users.je_person(conn, "feature_votes", VOTES_SCHEMA, "feature_id") def alle(session_id): - """Alle Wünsche, jeder mit dem Namen seines Verfassers.""" + """Alle Wünsche: offene nach Stimmen, erledigte dahinter. + + Jeder Wunsch bringt den Namen des Verfassers, die Stimmenzahl, die + eigene Stimme und seine Anmerkungen mit. + """ + ich = besitzer(session_id) with connect() as conn: rows = conn.execute( - "SELECT f.*, u.name FROM features f " + "SELECT f.*, u.name, " + " (SELECT COUNT(*) FROM feature_votes v WHERE v.feature_id = f.id) " + " AS votes " + "FROM features f " "LEFT JOIN users u ON u.id = f.user_id " - "ORDER BY f.created_at DESC" + "ORDER BY f.done_at IS NOT NULL, votes DESC, f.created_at DESC" ).fetchall() - ich = besitzer(session_id) + meine_stimmen = { + r["feature_id"] + for r in conn.execute( + f"SELECT feature_id FROM feature_votes WHERE {WEM}", + (ich, ich, session_id), + ) + } + notizen = conn.execute( + "SELECT n.*, u.name FROM feature_notes n " + "LEFT JOIN users u ON u.id = n.user_id " + "ORDER BY n.created_at ASC" + ).fetchall() + + je_wunsch = {} + for n in notizen: + je_wunsch.setdefault(n["feature_id"], []).append( + { + "id": n["id"], + "text": n["text"], + "name": n["name"], + "created_at": n["created_at"], + "mine": gehoert_mir(n, session_id, ich), + } + ) + return [ { **{ @@ -46,6 +116,8 @@ def alle(session_id): if k not in ("session_id", "user_id") }, "mine": gehoert_mir(r, session_id, ich), + "voted": r["id"] in meine_stimmen, + "notes": je_wunsch.get(r["id"], []), } for r in rows ] @@ -60,12 +132,7 @@ def anlegen(session_id, text): conn.execute( "INSERT INTO features (session_id, user_id, text, created_at) " "VALUES (?, ?, ?, ?)", - ( - session_id, - besitzer(session_id), - text, - datetime.now(timezone.utc).isoformat(timespec="seconds"), - ), + (session_id, besitzer(session_id), text, _jetzt()), ) return alle(session_id) @@ -81,10 +148,73 @@ def aendern(feature_id, text): ).rowcount +def erledigen(feature_id, erledigt): + """Der Host hakt ab — oder holt einen Wunsch wieder hervor.""" + with connect() as conn: + return conn.execute( + "UPDATE features SET done_at = ? WHERE id = ?", + (_jetzt() if erledigt else None, feature_id), + ).rowcount + + +def abstimmen(feature_id, session_id): + """Stimme setzen oder zurücknehmen. Gibt None zurück, wenn es den + Wunsch nicht gibt, sonst ob die Stimme jetzt steht.""" + ich = besitzer(session_id) + with connect() as conn: + weg = conn.execute( + f"DELETE FROM feature_votes WHERE feature_id = ? AND {WEM}", + (feature_id, ich, ich, session_id), + ).rowcount + if weg: + return False + da = conn.execute( + "SELECT 1 FROM features WHERE id = ?", (feature_id,) + ).fetchone() + if not da: + return None + conn.execute( + "INSERT INTO feature_votes (feature_id, session_id, user_id, created_at) " + "VALUES (?, ?, ?, ?)", + (feature_id, session_id, ich, _jetzt()), + ) + return True + + +def anmerken(feature_id, session_id, text): + """Eine Ergänzung unter den Wunsch hängen.""" + text = (text or "").strip()[:MAX_LAENGE] + if not text: + return False + with connect() as conn: + da = conn.execute( + "SELECT 1 FROM features WHERE id = ?", (feature_id,) + ).fetchone() + if not da: + return False + conn.execute( + "INSERT INTO feature_notes " + "(feature_id, session_id, user_id, text, created_at) " + "VALUES (?, ?, ?, ?, ?)", + (feature_id, session_id, besitzer(session_id), text, _jetzt()), + ) + return True + + +def anmerkung_loeschen(note_id): + """Ohne Sitzungsbindung — den Knopf zeigt die Oberfläche nur dem Host.""" + with connect() as conn: + return conn.execute( + "DELETE FROM feature_notes WHERE id = ?", (note_id,) + ).rowcount + + def loeschen(feature_id): """Ohne Sitzungsbindung — den Knopf zeigt die Oberfläche nur dem - Verfasser und dem Host.""" + Verfasser und dem Host. Stimmen und Anmerkungen gehen mit.""" with connect() as conn: + conn.execute("DELETE FROM feature_votes WHERE feature_id = ?", (feature_id,)) + conn.execute("DELETE FROM feature_notes WHERE feature_id = ?", (feature_id,)) return conn.execute( "DELETE FROM features WHERE id = ?", (feature_id,) ).rowcount diff --git a/backend/app/main.py b/backend/app/main.py index cb1cd19..2be1023 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -380,6 +380,54 @@ def wunsch_loeschen(feature_id: int): return {"removed": entfernt} +@app.post("/api/features/{feature_id}/vote") +def wunsch_abstimmen( + feature_id: int, + response: Response, + filmabend_sid: str | None = Cookie(None), +): + """Ein Klick setzt die Stimme, der nächste nimmt sie zurück.""" + sid = sitzung(response, filmabend_sid) + steht = features.abstimmen(feature_id, sid) + if steht is None: + raise HTTPException(status_code=404, detail="Wunsch nicht gefunden") + events.senden("features") + return {"voted": steht} + + +@app.patch("/api/features/{feature_id}/done") +def wunsch_erledigen(feature_id: int, done: bool = Body(..., embed=True)): + """Der Host hakt ab — den Knopf sieht nur er.""" + if not features.erledigen(feature_id, done): + raise HTTPException(status_code=404, detail="Wunsch nicht gefunden") + events.senden("features") + return {"ok": True} + + +@app.post("/api/features/{feature_id}/notes") +def wunsch_anmerken( + feature_id: int, + response: Response, + text: str = Body(..., embed=True), + filmabend_sid: str | None = Cookie(None), +): + sid = sitzung(response, filmabend_sid) + if not features.anmerken(feature_id, sid, text): + raise HTTPException(status_code=400, detail="Kein Text oder Wunsch unbekannt") + events.senden("features") + return {"ok": True} + + +# Eigener Pfad statt /api/features/notes/{id}: der stünde sonst mit +# /api/features/{feature_id} im Weg +@app.delete("/api/feature-notes/{note_id}") +def anmerkung_loeschen(note_id: int): + entfernt = features.anmerkung_loeschen(note_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.""" diff --git a/backend/app/suggestions.py b/backend/app/suggestions.py index 042c349..c5f6cac 100644 --- a/backend/app/suggestions.py +++ b/backend/app/suggestions.py @@ -132,8 +132,13 @@ def ziehen(): treffer = random.choice(frisch) if frisch else random.randrange(len(eintraege)) schritt = 360 / len(eintraege) runden = 5 + random.randrange(3) - # Der Zeiger steht oben, also muss die Mitte des Zielsegments dorthin - winkel = runden * 360 + (360 - (treffer * schritt + schritt / 2)) + # Der Zeiger steht oben, also muss die Mitte des Zielsegments dorthin. + # Ein einzelner Vorschlag ist ein Vollkreis mit Cover — der soll nach + # dem Dreh aufrecht stehen, also bei 0° enden statt bei 180°. + if len(eintraege) == 1: + winkel = runden * 360 + else: + winkel = runden * 360 + (360 - (treffer * schritt + schritt / 2)) return { "index": treffer, diff --git a/backend/horror.db.bak b/backend/horror.db.bak new file mode 100644 index 0000000..ca34497 Binary files /dev/null and b/backend/horror.db.bak differ diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1292667..9631b11 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -31,6 +31,13 @@ const ANSICHT_SCHLUESSEL = 'filmabend_ansicht' const ansicht = ref(localStorage.getItem(ANSICHT_SCHLUESSEL) || 'kompakt') watch(ansicht, (wert) => localStorage.setItem(ANSICHT_SCHLUESSEL, wert)) + +// Schon gelaufene Filme stehen beim Aussuchen nur im Weg — ab Werk raus. +// Wer sie sehen will, schaltet um; das bleibt über den Neustart erhalten. +const GESEHEN_SCHLUESSEL = 'filmabend_ohne_gesehene' +const ohneGesehene = ref(localStorage.getItem(GESEHEN_SCHLUESSEL) !== 'aus') + +watch(ohneGesehene, (an) => localStorage.setItem(GESEHEN_SCHLUESSEL, an ? 'an' : 'aus')) const selected = ref(null) // Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir @@ -252,8 +259,12 @@ const netflixEntries = computed(() => { }) const tabs = computed(() => [ - { id: 'netflix', label: 'Netflix', count: netflixMovies.value.length }, - { id: 'canon', label: 'Horror-Kanon', count: canonGroups.value.length }, + { id: 'netflix', label: 'Netflix', count: netflixMovies.value.filter(nichtGesehen).length }, + { + id: 'canon', + label: 'Horror-Kanon', + count: canonGroups.value.filter((g) => g.parts.some(nichtGesehen)).length, + }, { id: 'search', label: 'Suche', count: null }, { id: 'aehnlich', label: 'Ähnlichkeitsuche', count: null }, ...(host.value @@ -262,7 +273,7 @@ const tabs = computed(() => [ { id: 'wheel', label: 'Rad', count: null }, { id: 'stream', label: 'Stream', count: null }, { id: 'watched', label: 'Gesehen', count: gesehenZahl.value }, - { id: 'wuensche', label: 'Wünsche', count: wuensche.value.length }, + { id: 'wuensche', label: 'Wünsche', count: wuensche.value.filter((w) => !w.done_at).length }, ...(host.value ? [{ id: 'verwaltung', label: 'Verwaltung', count: null }] : []), ]) @@ -489,8 +500,12 @@ function matches(movie, term) { // Eingeschlossene Genres wirken als ODER: wer Horror und Action wählt, will // beides sehen. Ein ausgeschlossenes Genre sticht das immer — ein Horrorfilm // mit Komödie fliegt raus, wenn Komödie ausgeblendet ist. +function nichtGesehen(movie) { + return !ohneGesehene.value || !gesehenIds.value.has(movie.id) +} + function passt(movie) { - return hasGenre(movie) && imBereich(movie) + return nichtGesehen(movie) && hasGenre(movie) && imBereich(movie) } function hasGenre(movie) { @@ -665,6 +680,7 @@ onUnmounted(() => { { { v-model:search="search" v-model:sort="sort" v-model:ansicht="ansicht" + v-model:ohne-gesehene="ohneGesehene" v-model:ranges="ranges" :regler="REGLER" :grenzen="grenzen" diff --git a/frontend/src/components/AehnlichTab.vue b/frontend/src/components/AehnlichTab.vue index aa68920..6806968 100644 --- a/frontend/src/components/AehnlichTab.vue +++ b/frontend/src/components/AehnlichTab.vue @@ -14,6 +14,7 @@ const props = defineProps({ filter: { type: Object, default: () => ({}) }, ansicht: { type: String, default: 'kompakt' }, gesehenIds: { type: Set, default: () => new Set() }, + ohneGesehene: { type: Boolean, default: true }, // Kommt aus der Filmansicht: von diesem Film soll es losgehen start: { type: Object, default: null }, }) @@ -106,6 +107,7 @@ async function nachladen() { // Genres, Jahr, Stimmen und Note filtert schon das Backend. Übrig bleibt // die Laufzeit: die steht erst in der Detailantwort, nicht in der Liste. function passt(movie) { + if (props.ohneGesehene && props.gesehenIds.has(movie.id)) return false const werte = props.filter.dauer const grenze = props.filter.grenzen?.runtime if (!werte || !grenze || movie.runtime == null) return true diff --git a/frontend/src/components/FilterSidebar.vue b/frontend/src/components/FilterSidebar.vue index 9a3efc5..79b8d40 100644 --- a/frontend/src/components/FilterSidebar.vue +++ b/frontend/src/components/FilterSidebar.vue @@ -19,6 +19,7 @@ const emit = defineEmits(['toggle', 'all', 'reset']) const search = defineModel('search', { type: String, default: '' }) const sort = defineModel('sort', { type: String, default: 'random' }) const ansicht = defineModel('ansicht', { type: String, default: 'kompakt' }) +const ohneGesehene = defineModel('ohneGesehene', { type: Boolean, default: true }) const ranges = defineModel('ranges', { type: Object, default: () => ({}) }) // Auch ein verschobener Regler zählt als aktiver Filter @@ -81,6 +82,11 @@ function beschriften(regler) { Details + +
@@ -158,6 +164,25 @@ function beschriften(regler) {