update
This commit is contained in:
18
Makefile
18
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
|
||||
|
||||
@@ -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"),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { posterUrl } from '../images'
|
||||
import { abonnieren } from '../live'
|
||||
|
||||
defineProps({ host: Boolean })
|
||||
const props = defineProps({
|
||||
host: Boolean,
|
||||
// Wer gerade angemeldet ist. Beim Wechsel gehört eine andere Sammlung dazu.
|
||||
ich: { type: Object, default: null },
|
||||
})
|
||||
const emit = defineEmits(['open', 'reset'])
|
||||
|
||||
const items = ref([])
|
||||
@@ -95,8 +99,14 @@ async function laden_() {
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.ich?.id, () => {
|
||||
sammlungLaden()
|
||||
laden_()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await laden_()
|
||||
await sammlungLaden()
|
||||
await laufendenDrehHolen()
|
||||
verbinden()
|
||||
})
|
||||
@@ -184,6 +194,50 @@ async function zuruecksetzen() {
|
||||
}
|
||||
}
|
||||
|
||||
// Aus der eigenen Sammlung würfeln. Der Schalter trennt Horror von allem
|
||||
// anderen — für Abende, an denen es keiner gruselig mag.
|
||||
const nurHorror = ref(true)
|
||||
const sammlung = ref([])
|
||||
const wuerfelt = ref(false)
|
||||
|
||||
async function sammlungLaden() {
|
||||
try {
|
||||
const resp = await fetch('/api/wishlist')
|
||||
if (resp.ok) sammlung.value = (await resp.json()).filter((e) => e.mine)
|
||||
} catch {
|
||||
sammlung.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function nachGenre(horror) {
|
||||
return sammlung.value.filter(
|
||||
(e) => (e.genres || []).includes('Horror') === horror
|
||||
)
|
||||
}
|
||||
|
||||
const auswahl = computed(() => nachGenre(nurHorror.value))
|
||||
const anzahlHorror = computed(() => nachGenre(true).length)
|
||||
const anzahlOhne = computed(() => nachGenre(false).length)
|
||||
|
||||
async function wuerfeln() {
|
||||
const treffer = auswahl.value
|
||||
if (!treffer.length || wuerfelt.value) return
|
||||
wuerfelt.value = true
|
||||
const film = treffer[Math.floor(Math.random() * treffer.length)]
|
||||
try {
|
||||
const resp = await fetch('/api/suggestions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ movie_id: film.movie_id }),
|
||||
})
|
||||
if (!resp.ok) throw new Error()
|
||||
} catch {
|
||||
fehler.value = 'Eintragen hat nicht geklappt.'
|
||||
} finally {
|
||||
wuerfelt.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function oeffnen(item) {
|
||||
const resp = await fetch(`/api/search/${item.movie_id}`)
|
||||
if (resp.ok) emit('open', await resp.json())
|
||||
@@ -255,6 +309,25 @@ async function oeffnen(item) {
|
||||
</div>
|
||||
|
||||
<aside class="liste">
|
||||
<div v-if="!host" class="sammlung-wuerfeln">
|
||||
<div class="schalter">
|
||||
<button type="button" :class="{ an: nurHorror }" @click="nurHorror = true">
|
||||
Horror ({{ anzahlHorror }})
|
||||
</button>
|
||||
<button type="button" :class="{ an: !nurHorror }" @click="nurHorror = false">
|
||||
Kein Horror ({{ anzahlOhne }})
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="wuerfeln"
|
||||
:disabled="!auswahl.length || wuerfelt"
|
||||
@click="wuerfeln"
|
||||
>
|
||||
Zufällig aus Sammlung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3>Vorschläge <span class="anzahl">{{ items.length }}</span></h3>
|
||||
<p v-if="!items.length && !laden" class="leer">Noch nichts vorgeschlagen.</p>
|
||||
<ul>
|
||||
@@ -265,9 +338,27 @@ async function oeffnen(item) {
|
||||
:class="{ eigen: item.id === mine }"
|
||||
@click="oeffnen(item)"
|
||||
>
|
||||
<span class="punkt" :style="{ background: FARBEN[i % FARBEN.length] }"></span>
|
||||
<span class="titel">{{ item.title }}</span>
|
||||
<span class="von">{{ item.name || '—' }}</span>
|
||||
<span
|
||||
class="cover"
|
||||
:style="{ borderColor: FARBEN[i % FARBEN.length] }"
|
||||
>
|
||||
<img
|
||||
v-if="posterUrl(item.poster_path, 'w154')"
|
||||
:src="posterUrl(item.poster_path, 'w154')"
|
||||
:alt="item.title"
|
||||
loading="lazy"
|
||||
/>
|
||||
</span>
|
||||
<span class="daten">
|
||||
<span class="titel">{{ item.title }}</span>
|
||||
<span class="meta">
|
||||
{{ item.year }}<template v-if="item.runtime"> · {{ item.runtime }} Min.</template>
|
||||
</span>
|
||||
<span v-if="item.genres?.length" class="genres">
|
||||
{{ item.genres.join(' · ') }}
|
||||
</span>
|
||||
<span class="von">{{ item.name || 'Anonym' }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -369,6 +460,53 @@ async function oeffnen(item) {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sammlung-wuerfeln {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.6rem;
|
||||
}
|
||||
|
||||
.schalter {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.schalter button {
|
||||
flex: 1;
|
||||
padding: 0.45rem 0.9rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.schalter button.an {
|
||||
color: var(--text);
|
||||
background: var(--bg-soft);
|
||||
}
|
||||
|
||||
.wuerfeln {
|
||||
padding: 0.5rem 1.1rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.wuerfeln:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.wuerfeln:disabled {
|
||||
color: var(--muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.gewinner {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -431,42 +569,73 @@ async function oeffnen(item) {
|
||||
|
||||
.eintrag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.7rem;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.5rem;
|
||||
padding: 0.55rem;
|
||||
color: var(--text);
|
||||
font-size: 0.86rem;
|
||||
text-align: left;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.eintrag:hover {
|
||||
background: var(--bg-soft);
|
||||
}
|
||||
|
||||
.eintrag.eigen .titel {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
/* Der farbige Rand verbindet den Eintrag mit seinem Stück auf dem Rad */
|
||||
.cover {
|
||||
display: block;
|
||||
width: 46px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg-soft);
|
||||
border-left: 3px solid;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.punkt {
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
.cover img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.daten {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
padding-top: 0.1rem;
|
||||
}
|
||||
|
||||
.titel {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.eintrag.eigen .titel {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.meta,
|
||||
.genres,
|
||||
.von {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 0.74rem;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.von {
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
margin-top: 0.15rem;
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.note {
|
||||
|
||||
Reference in New Issue
Block a user