Compare commits

...

43 Commits

Author SHA1 Message Date
team3
c940173a13 update 2026-10-09 22:27:56 +02:00
team3
b3b6715d1c update 2026-10-09 22:23:13 +02:00
team3
0ab3d53023 update 2026-10-09 22:14:01 +02:00
team3
5e34cb4406 Clips als Volume: Dateien überleben den Neubau des Containers
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEtzAhNR5eKBxz2jBVnTHQ
2026-10-08 16:20:11 +02:00
team3
93fe2219b9 update 2026-10-08 16:03:27 +02:00
team3
3f12b0742d update 2026-10-08 14:29:05 +02:00
team3
9fdfe27fb9 update 2026-10-07 16:57:36 +02:00
team3
e415c3ac0d update 2026-10-07 15:47:07 +02:00
team3
2f771a9c30 update 2026-10-07 15:41:41 +02:00
team3
b65f4d75f4 update 2026-10-01 00:45:19 +02:00
team3
bd1694528a update 2026-10-01 00:09:16 +02:00
team3
8ddd8208f5 update 2026-09-30 19:53:27 +02:00
team3
09ae21c4b2 update 2026-09-30 15:15:00 +02:00
team3
817f39a42c update 2026-09-30 14:53:23 +02:00
team3
fd7893b5d2 update 2026-09-30 14:31:50 +02:00
team3
624df7e7ee update 2026-09-30 01:34:44 +02:00
team3
ae5b1d0b1d update 2026-09-30 01:24:49 +02:00
team3
70b37054ba update 2026-09-30 01:16:47 +02:00
team3
c589e389df update 2026-09-30 00:59:59 +02:00
team3
7bf9667007 update 2026-09-29 13:47:36 +02:00
team3
be59afd5e2 update 2026-09-29 01:18:06 +02:00
team3
997c681103 update 2026-09-29 01:01:15 +02:00
team3
f80b8c5826 update 2026-09-29 00:54:58 +02:00
team3
f7c9017946 update 2026-09-19 13:32:35 +02:00
team3
a90044b0a7 update 2026-09-18 23:47:25 +02:00
team3
db77c1f985 update 2026-09-18 22:12:01 +02:00
team3
33b3975b61 update 2026-09-17 19:10:10 +02:00
team3
ea54a86db4 update 2026-09-17 15:40:28 +02:00
team3
a1684cf54f update 2026-09-17 12:14:40 +02:00
team3
b42cf64782 update 2026-09-16 17:55:23 +02:00
team3
cb78225e86 update 2026-09-16 17:00:35 +02:00
team3
bfaf0313c1 update 2026-09-16 15:03:56 +02:00
team3
a639e40a23 update 2026-09-16 13:21:50 +02:00
team3
90fffbe077 update 2026-09-16 12:09:38 +02:00
team3
aa5c64ae23 update 2026-09-10 18:00:51 +02:00
Team3
7191388787 update 2026-09-09 19:57:45 +02:00
team3
d51c160ce6 update 2026-09-09 18:27:21 +02:00
team3
3dc091d04a update 2026-09-09 18:15:10 +02:00
team3
847b747870 update 2026-09-09 17:58:52 +02:00
team3
2bd03000e8 update 2026-09-09 10:01:58 +02:00
team3
446edc85fb update 2026-09-08 23:56:34 +02:00
team3
00cb299f31 update 2026-09-08 23:50:25 +02:00
team3
08c2e8f885 update 2026-09-08 23:39:04 +02:00
89 changed files with 13325 additions and 1672 deletions

View File

@@ -1,3 +1,4 @@
TMDB_TOKEN=
MINIMAX_KEY=
MTX_SENDEPASSWORT=
HOST_SCHLUESSEL=

7
.gitignore vendored
View File

@@ -6,3 +6,10 @@ node_modules/
frontend/dist/
backend/static/
data/
backend/backups/
data/backups/
# Aufnahmen der Abende und geschnittene Clips — groß, nicht ins Git
aufnahmen/
clips/

View File

@@ -23,4 +23,4 @@ VOLUME /data
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--timeout-graceful-shutdown", "2"]

View File

@@ -1,27 +1,37 @@
.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 test
install:
cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
cd frontend && npm install
test:
cd backend && .venv/bin/python -m pytest -q
# 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
@echo "MediaMTX läuft: RTMP auf :1935, HLS auf :8890"
-@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" \
-v "$(PWD)/aufnahmen:/recordings" \
bluenviron/mediamtx:latest > /dev/null
@echo "MediaMTX läuft: RTMP auf :1935, HLS auf :8890, Aufnahme in aufnahmen/"
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 backend && .venv/bin/uvicorn app.main:app --reload --port 8000 --timeout-graceful-shutdown 2 &
@cd frontend && npx vite --port 5173
stop: mtx-stop stream-stop
@@ -37,10 +47,16 @@ build:
cd frontend && npm run build
# ── Produktion (auf dem Server ausführen, im Projektverzeichnis) ──────────────
# --force-recreate: auch MediaMTX kommt neu hoch und liest die
# mediamtx.yml frisch — sonst bleibt eine Konfigänderung ohne Wirkung
prod:
docker compose up -d --build
docker compose up -d --build --force-recreate
@echo "horror läuft: https://horror.marha.de"
# Der Sync läuft im Container, ein backend/.venv gibt es dort nicht
sync-prod:
docker compose exec horror python -m app.sync
logs:
docker compose logs -f
@@ -54,8 +70,10 @@ ZIEL ?= horror.marha.de
# Räumt vorher auf: ein noch laufendes OBS belegt sonst die Sendestrecke,
# und der Tonweg wird sauber neu aufgebaut.
# Der Stream bekommt den vollen Ton, dieser Rechner bleibt still.
# Zum Mitschauen am selben Gerät: make stream MITHOEREN=1
stream: stream-stop
@scripts/stream.sh $(ZIEL)
@MITHOEREN=$(MITHOEREN) scripts/stream.sh $(ZIEL)
# Zum Ausprobieren ohne Server, gegen das lokale MediaMTX
stream-local: mtx
@@ -66,7 +84,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
@@ -75,14 +93,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'

View File

@@ -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=<HOST_SCHLUESSEL>` 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
@@ -56,19 +87,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:
@@ -183,6 +214,12 @@ Ohne Passwort weist MediaMTX die Verbindung ab. Zuschauen braucht keins.
das nicht — dort liefert `getDisplayMedia` unter Linux nur Tab-Ton, und
Firefox gar keinen.
Der sendende Rechner bleibt dabei still. Der Ton läuft über eine Null-Senke,
deren Monitor OBS abgreift; auf die Lautsprecher geht nichts. Am Pegel im
Stream ändert das nichts, weil PipeWire die Monitor-Ports vom Regler der
Senke unberührt lässt. `make stream MITHOEREN=1` legt den Ton zusätzlich auf
dein gewohntes Gerät.
**Warum alle dasselbe Bild sehen**
Jeder Player hält 4 Sekunden Abstand zur Live-Kante (`liveSyncDuration`).
@@ -213,6 +250,35 @@ Bild — damit lässt sich der Gleichlauf im Betrieb prüfen.
Filmabend genügt das; bei mehr Last gehört eine Traefik-Route direkt auf
MediaMTX davor.
## Aufnahme und Clips
MediaMTX schneidet jeden Abend mit: ohne Neukodierung, als fMP4 in
30-Sekunden-Segmenten unter `aufnahmen/` (im Container `/recordings`,
in `docker-compose.yml` als Volume). Gelöscht wird nichts von selbst —
wer Platz braucht, räumt den Ordner auf oder schiebt ihn per rclone auf
eine Storage Box. Rund 5 GB je Abend bei 1080p.
Löschen kann der Host in der Verwaltung, Stück für Stück: die App
schickt das an die MediaMTX-API (Port 9997, nur intern, angemeldet als
`sender` mit dem Sendepasswort), die die Segmente wegräumt.
Jede Aufnahme hat ihren Film: der Host weist ihn in der Verwaltung zu,
sonst gilt der Abend mit dem gleichen Datum, sonst läuft sie unter
„Sonstige". Daraus kommen die Clips. Im Stream-Reiter setzt die Taste C
(oder der Knopf „Clip") eine Marke: nur der Zeitpunkt, vier Sekunden vor
jetzt, weil der Player so weit hinter der Live-Kante hängt. Der Film ist
der der laufenden Aufnahme, sonst der zuletzt gezogene. „Clip erstellen"
im Reiter „Clips" setzt eine Marke an den Anfang einer beliebigen
Aufnahme. Dort zieht man das Fenster um die Marke, sieht es sich an und
schneidet; die MP4 liegt dann unter `clips/`. Jeder sieht nur seine
Clips, der Host alle — er räumt auch auf.
Die Ausschnitte liefert der Playback-Server von MediaMTX (Port 9996,
nur intern): `/get?path=filmabend&start=<RFC3339>&duration=<s>&format=mp4`.
Er fügt Segmente selbst zusammen und liefert auch aus dem gerade
laufenden Segment. Die App reicht das als Vorschau durch und legt beim
Schneiden die Datei ab; ffmpeg braucht es dafür nicht.
## Vorschläge und Rad
Im Reiter **Rad** landen die Filmvorschläge. Jede Sitzung darf einen Film

39
backend/app/backup.py Normal file
View File

@@ -0,0 +1,39 @@
"""Nächtliche Kopie der Datenbank.
Sammlungen, Wertungen und Wünsche liegen nur in dieser einen Datei. Die
Kopie entsteht über SQLites eigene Backup-Schnittstelle — die ist auch
dann konsistent, wenn gerade jemand schreibt. Es bleiben die letzten
vierzehn Stände, jeder Tag einer.
"""
import logging
import sqlite3
from datetime import date
from .config import DB_PATH
log = logging.getLogger("app.backup")
BEHALTEN = 14
def sichern():
ordner = DB_PATH.parent / "backups"
ordner.mkdir(parents=True, exist_ok=True)
ziel = ordner / f"{DB_PATH.stem}-{date.today():%Y-%m-%d}{DB_PATH.suffix}"
quelle = sqlite3.connect(DB_PATH)
kopie = sqlite3.connect(ziel)
try:
quelle.backup(kopie)
finally:
kopie.close()
quelle.close()
# Die Namen sortieren nach Datum, das Alte steht vorn
alte = sorted(ordner.glob(f"{DB_PATH.stem}-*{DB_PATH.suffix}"))[:-BEHALTEN]
for alt in alte:
alt.unlink()
log.info("Datenbank gesichert: %s", ziel.name)
return ziel

364
backend/app/clips.py Normal file
View File

@@ -0,0 +1,364 @@
"""Clips aus der Aufnahme des Abends.
Eine Marke ist erst nur ein Zeitpunkt: Taste C, fertig, nichts lenkt ab.
Wann immer man will, zieht man ein Fenster darum, sieht es sich an und
schneidet. Dann holt die App den Ausschnitt vom Playback-Server von
MediaMTX und legt ihn als MP4 ab. Jeder sieht nur seine eigenen Clips,
der Host alle — er räumt auf.
"""
from datetime import datetime, timedelta, timezone
import httpx
from .config import CLIPS_DIR, MEDIAMTX_API_URL, MEDIAMTX_PLAYBACK_URL, MTX_SENDEPASSWORT
from .db import connect
from .users import WEM, besitzer, gehoert_mir
SCHEMA = """
CREATE TABLE IF NOT EXISTS clips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
user_id INTEGER,
movie_id INTEGER,
marke_at TEXT NOT NULL,
vorher REAL NOT NULL,
dauer REAL NOT NULL,
titel TEXT,
datei TEXT,
created_at TEXT NOT NULL
);
-- Der Film einer Aufnahme, vom Host gesetzt. Ohne Eintrag gilt der Abend
-- mit dem gleichen Datum — oder keiner.
CREATE TABLE IF NOT EXISTS aufnahme_film (
start TEXT PRIMARY KEY,
movie_id INTEGER NOT NULL
);
"""
STREAM_PFAD = "filmabend"
# Der Player hängt so weit hinter der Live-Kante — die Marke zeigt auf
# das, was die Person gerade gesehen hat, nicht auf das, was gerade ankommt
PLAYER_LATENZ = 4
# Das Fenster um die Marke: Standard und Grenzen
VORHER = 30
DAUER = 35
# Die Marke ist nur ein Anker — das Fenster darf überall auf dem
# Zeitstrahl liegen, auch weit nach der Marke (vorher negativ)
MAX_VORHER = 4 * 3600
MIN_DAUER = 3
MAX_DAUER = 180
MAX_TITEL = 80
def init():
with connect() as conn:
conn.executescript(SCHEMA)
def _jetzt():
return datetime.now(timezone.utc)
def _iso(zeit):
return zeit.isoformat(timespec="seconds")
def markieren(session_id, movie_id, zeit=None):
"""Eine Marke setzen — vier Sekunden vor jetzt, wegen der Latenz.
Oder zu einem gegebenen Zeitpunkt, für Clips aus alten Aufnahmen."""
with connect() as conn:
clip_id = conn.execute(
"INSERT INTO clips (session_id, user_id, movie_id, marke_at, vorher, dauer, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
session_id,
besitzer(session_id),
movie_id,
_iso(zeit or _jetzt() - timedelta(seconds=PLAYER_LATENZ)),
VORHER,
DAUER,
_iso(_jetzt()),
),
).lastrowid
# Erst nach dem Commit lesen — sonst sieht die neue Verbindung nichts
return holen(clip_id, session_id)
def _oeffentlich(row, session_id, ich):
clip = {k: v for k, v in dict(row).items() if k not in ("session_id", "user_id", "freigegeben")}
clip["geschnitten"] = clip.pop("datei") is not None
clip["mine"] = gehoert_mir(row, session_id, ich)
return clip
def holen(clip_id, session_id):
with connect() as conn:
row = conn.execute(
"SELECT c.*, u.name, m.title, m.poster_path FROM clips c "
"LEFT JOIN users u ON u.id = c.user_id "
"LEFT JOIN movies m ON m.id = c.movie_id WHERE c.id = ?",
(clip_id,),
).fetchone()
return _oeffentlich(row, session_id, besitzer(session_id)) if row else None
def alle(session_id, host=False):
"""Alle Clips, für jeden. Ändern darf nur der Besitzer — und der Host,
der aufräumt; dafür steht an jedem Clip, ob er einem gehört."""
ich = besitzer(session_id)
with connect() as conn:
rows = conn.execute(
"SELECT c.*, u.name, m.title, m.poster_path FROM clips c "
"LEFT JOIN users u ON u.id = c.user_id "
"LEFT JOIN movies m ON m.id = c.movie_id "
"ORDER BY c.marke_at DESC"
).fetchall()
return [_oeffentlich(r, session_id, ich) for r in rows]
def _zeile(conn, clip_id):
return conn.execute("SELECT * FROM clips WHERE id = ?", (clip_id,)).fetchone()
def _darf(row, session_id, host):
return row is not None and (host or gehoert_mir(row, session_id, besitzer(session_id)))
def aendern(clip_id, session_id, host, titel=None, vorher=None, dauer=None):
"""Titel oder Fenster. Ein neues Fenster macht den Schnitt ungültig —
die Datei fliegt, bis neu geschnitten wird."""
with connect() as conn:
row = _zeile(conn, clip_id)
if not _darf(row, session_id, host):
return None
felder = {}
if titel is not None:
felder["titel"] = titel.strip()[:MAX_TITEL] or None
if vorher is not None:
felder["vorher"] = max(-MAX_VORHER, min(MAX_VORHER, float(vorher)))
if dauer is not None:
felder["dauer"] = max(MIN_DAUER, min(MAX_DAUER, float(dauer)))
fenster_neu = any(
k in felder and felder[k] != row[k] for k in ("vorher", "dauer")
)
if fenster_neu and row["datei"]:
_datei_weg(row["datei"])
felder["datei"] = None
if felder:
setzt = ", ".join(f"{k} = ?" for k in felder)
conn.execute(f"UPDATE clips SET {setzt} WHERE id = ?", (*felder.values(), clip_id))
return holen(clip_id, session_id)
def loeschen(clip_id, session_id, host):
with connect() as conn:
row = _zeile(conn, clip_id)
if not _darf(row, session_id, host):
return False
if row["datei"]:
_datei_weg(row["datei"])
conn.execute("DELETE FROM clips WHERE id = ?", (clip_id,))
return True
def _datei_weg(name):
pfad = CLIPS_DIR / name
if pfad.exists():
pfad.unlink()
def fenster(row, vorher=None, dauer=None):
"""Start (RFC3339) und Dauer des Ausschnitts um die Marke."""
marke = datetime.fromisoformat(row["marke_at"])
v = row["vorher"] if vorher is None else max(-MAX_VORHER, min(MAX_VORHER, float(vorher)))
d = row["dauer"] if dauer is None else max(MIN_DAUER, min(MAX_DAUER, float(dauer)))
return _iso(marke - timedelta(seconds=v)), d
def playback_url(row, vorher=None, dauer=None):
start, d = fenster(row, vorher, dauer)
return (
f"{MEDIAMTX_PLAYBACK_URL}/get",
{"path": STREAM_PFAD, "start": start, "duration": d, "format": "mp4"},
)
def zeile(clip_id, session_id, host):
"""Die rohe Zeile zum Ansehen — Clips sieht jeder. Ändern und
Löschen prüfen gesondert, ob es der Besitzer oder der Host ist."""
with connect() as conn:
return _zeile(conn, clip_id)
async def schneiden(clip_id, session_id, host):
"""Den Ausschnitt vom Playback-Server holen und als Datei ablegen.
Gibt None zurück, wenn der Clip nicht gehört; wirft httpx-Fehler,
wenn MediaMTX nicht liefert."""
with connect() as conn:
row = _zeile(conn, clip_id)
if not _darf(row, session_id, host):
return None
url, params = playback_url(row)
CLIPS_DIR.mkdir(parents=True, exist_ok=True)
name = f"{clip_id}.mp4"
ziel = CLIPS_DIR / name
async with httpx.AsyncClient(timeout=120.0) as http:
async with http.stream("GET", url, params=params) as resp:
resp.raise_for_status()
with open(ziel, "wb") as f:
async for stueck in resp.aiter_bytes():
f.write(stueck)
with connect() as conn:
conn.execute("UPDATE clips SET datei = ? WHERE id = ?", (name, clip_id))
return holen(clip_id, session_id)
# Ein Abend dauert ein paar Stunden — so weit reicht der Zeitstrahl
SPANNE = 4 * 3600
async def _segmente(von, bis):
async with httpx.AsyncClient(timeout=15.0) as http:
resp = await http.get(
f"{MEDIAMTX_PLAYBACK_URL}/list",
params={"path": STREAM_PFAD, "start": _iso(von), "end": _iso(bis)},
)
resp.raise_for_status()
return resp.json()
# ── Aufnahmen: die Stücke aus MediaMTX, jedes mit seinem Film ──────────
# Eine Aufnahme hängt an keinem Abend. Der Host weist ihr einen Film zu;
# ohne Zuweisung gilt der Abend mit dem gleichen Datum, sonst keiner.
def _stueck(s):
return {
"start": _iso(datetime.fromisoformat(s["start"].replace("Z", "+00:00"))),
"dauer": round(s["duration"], 1),
}
def _abendtag(iso):
"""Der Abend reicht bis zum nächsten Mittag — die Nacht gehört dazu."""
zeit = datetime.fromisoformat(iso.replace("Z", "+00:00"))
return (zeit - timedelta(hours=12)).strftime("%Y-%m-%d")
def film_zuweisen(start, movie_id):
"""Der Host hängt einen Film an eine Aufnahme — oder nimmt ihn weg."""
with connect() as conn:
if movie_id is None:
conn.execute("DELETE FROM aufnahme_film WHERE start = ?", (start,))
else:
conn.execute(
"INSERT OR REPLACE INTO aufnahme_film (start, movie_id) VALUES (?, ?)",
(start, movie_id),
)
def _filme(conn):
"""Zuweisungen je Start und Abende je Tag — um Aufnahmen ihren Film zu geben."""
zugewiesen = {
r["start"]: {"id": r["movie_id"], "title": r["title"], "poster_path": r["poster_path"]}
for r in conn.execute(
"SELECT a.start, a.movie_id, m.title, m.poster_path FROM aufnahme_film a "
"LEFT JOIN movies m ON m.id = a.movie_id"
)
}
abende = {}
for w in conn.execute(
"SELECT movie_id, title, poster_path, watched_at FROM watched ORDER BY id"
):
abende.setdefault(
_abendtag(w["watched_at"]),
{"id": w["movie_id"], "title": w["title"], "poster_path": w["poster_path"]},
)
return zugewiesen, abende
def _mit_film(stueck, zugewiesen, abende):
film = zugewiesen.get(stueck["start"])
return {
**stueck,
"film": film or abende.get(_abendtag(stueck["start"])),
"zugewiesen": film is not None,
}
async def aufnahmen_alle():
"""Alle Aufnahmen, neueste zuerst, jede mit ihrem Film."""
segmente = await _segmente(datetime(2000, 1, 1, tzinfo=timezone.utc), _jetzt())
with connect() as conn:
zugewiesen, abende = _filme(conn)
stuecke = [_mit_film(_stueck(s), zugewiesen, abende) for s in segmente]
return sorted(stuecke, key=lambda s: s["start"], reverse=True)
def film_der_aufnahme(start):
"""Nur die id — für neue Marken."""
with connect() as conn:
zugewiesen, abende = _filme(conn)
film = _mit_film({"start": start}, zugewiesen, abende)["film"]
return film["id"] if film else None
async def film_live():
"""Der Film der gerade laufenden Aufnahme — für die Taste C. None,
wenn nichts aufgenommen wird oder MediaMTX nicht antwortet."""
try:
segmente = await _segmente(_jetzt() - timedelta(hours=6), _jetzt())
except httpx.HTTPError:
return None
for s in segmente:
stueck = _stueck(s)
ende = datetime.fromisoformat(stueck["start"]) + timedelta(seconds=stueck["dauer"])
if ende >= _jetzt() - timedelta(seconds=90):
return film_der_aufnahme(stueck["start"])
return None
def _im_stueck(segmente, start, dauer):
"""Die Segmente (aus der API), die zu einem Stück der Aufnahme gehören:
ihr Beginn liegt zwischen dessen Start und Ende. Eine Sekunde Luft an
beiden Enden — Playback rundet die Zeiten anders als die API."""
von = datetime.fromisoformat(start) - timedelta(seconds=1)
bis = datetime.fromisoformat(start) + timedelta(seconds=dauer + 1)
aus = []
for s in segmente:
beginn = datetime.fromisoformat(s["start"].replace("Z", "+00:00"))
if von <= beginn <= bis:
aus.append(s["start"])
return aus
async def aufnahme_loeschen(start, dauer):
"""Ein Stück der Aufnahme löschen — MediaMTX räumt die Segmente weg.
Gibt zurück, wie viele es waren."""
auth = ("sender", MTX_SENDEPASSWORT)
async with httpx.AsyncClient(timeout=30.0, auth=auth) as http:
resp = await http.get(f"{MEDIAMTX_API_URL}/v3/recordings/get/{STREAM_PFAD}")
resp.raise_for_status()
starts = _im_stueck(resp.json().get("segments") or [], start, dauer)
for s in starts:
resp = await http.delete(
f"{MEDIAMTX_API_URL}/v3/recordings/deletesegment",
params={"path": STREAM_PFAD, "start": s},
)
resp.raise_for_status()
return len(starts)
async def aufnahme(um, spanne=SPANNE):
"""Welche Stücke der Aufnahme es um einen Zeitpunkt herum gibt — für
den Zeitstrahl im Editor. Relativ zur Marke, in Sekunden."""
marke = datetime.fromisoformat(um)
segmente = await _segmente(marke - timedelta(seconds=spanne), marke + timedelta(seconds=spanne))
aus = []
for s in segmente:
start = datetime.fromisoformat(s["start"].replace("Z", "+00:00"))
von = (start - marke).total_seconds()
aus.append({"von": round(von, 1), "bis": round(von + s["duration"], 1)})
return aus

View File

@@ -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=<schlüssel> 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"
@@ -20,6 +25,14 @@ PROVIDER_NETFLIX = 8
# MediaMTX liefert den Live-Stream; die Adresse zeigt im Container auf den
# Dienstnamen, lokal auf localhost
MEDIAMTX_URL = os.getenv("MEDIAMTX_URL", "http://127.0.0.1:8890")
# Der Playback-Server von MediaMTX: liefert Ausschnitte der Aufnahme
MEDIAMTX_PLAYBACK_URL = os.getenv("MEDIAMTX_PLAYBACK_URL", "http://127.0.0.1:9996")
# Die Steuer-API von MediaMTX, zum Löschen von Aufnahmen. Angemeldet
# wird sich als der Sender-Nutzer mit dem Sendepasswort aus der .env.
MEDIAMTX_API_URL = os.getenv("MEDIAMTX_API_URL", "http://127.0.0.1:9997")
MTX_SENDEPASSWORT = os.getenv("MTX_SENDEPASSWORT", "")
# Wohin die geschnittenen Clips kommen
CLIPS_DIR = Path(os.getenv("CLIPS_DIR", ROOT / "clips"))
REGION = "DE"
LANGUAGE = "de-DE"

View File

@@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS movies (
netflix_id TEXT,
netflix_url TEXT,
on_netflix INTEGER DEFAULT 0,
rentable INTEGER,
canon_group INTEGER,
canon_pos INTEGER,
collection_id INTEGER,
@@ -77,6 +78,7 @@ def init():
("on_netflix", "INTEGER DEFAULT 0"),
("canon_group", "INTEGER"),
("canon_pos", "INTEGER"),
("rentable", "INTEGER"),
):
if column not in existing:
conn.execute(f"ALTER TABLE movies ADD COLUMN {column} {kind}")
@@ -116,10 +118,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)

View File

@@ -19,16 +19,26 @@ log = logging.getLogger("events")
PUFFER = 16
_abonnenten: set[asyncio.Queue] = set()
# Welche Sitzung hinter einer Verbindung steckt — daraus ergibt sich,
# wer gerade online ist: eine offene Seite mit gewähltem Namen
_sitzungen: dict[asyncio.Queue, str] = {}
def anmelden():
def anmelden(session_id=None):
queue = asyncio.Queue(maxsize=PUFFER)
_abonnenten.add(queue)
if session_id:
_sitzungen[queue] = session_id
return queue
def abmelden(queue):
_abonnenten.discard(queue)
_sitzungen.pop(queue, None)
def sitzungen_online():
return set(_sitzungen.values())
def anzahl():
@@ -60,3 +70,5 @@ async def stream(queue, request):
yield ": ping\n\n"
finally:
abmelden(queue)
# Die anderen Seiten sollen den grünen Punkt ausmachen
senden("users")

View File

@@ -1,18 +1,48 @@
"""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 WEM, besitzer, gehoert_mir
SCHEMA = """
CREATE TABLE IF NOT EXISTS features (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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
);
"""
@@ -20,24 +50,74 @@ 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 "
"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"
"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.done_at IS NOT NULL, votes DESC, f.created_at DESC"
).fetchall()
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 [
{
**{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),
"voted": r["id"] in meine_stimmen,
"notes": je_wunsch.get(r["id"], []),
}
for r in rows
]
@@ -50,12 +130,9 @@ def anlegen(session_id, 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"),
),
"INSERT INTO features (session_id, user_id, text, created_at) "
"VALUES (?, ?, ?, ?)",
(session_id, besitzer(session_id), text, _jetzt()),
)
return alle(session_id)
@@ -71,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

259
backend/app/ki.py Normal file
View File

@@ -0,0 +1,259 @@
"""Filmvorschläge von MiniMax.
Das Modell kennt Filme nur aus seinem Training — keine Datenbank, kein
Netz. Es bekommt eine Beschreibung und auf Wunsch die eigene Sammlung,
nennt Titel mit Jahr und einem Satz Begründung. Jeder Titel wird danach
bei TMDB nachgeschlagen. Was es dort nicht gibt, fällt weg: so bleiben
erfundene oder verwechselte Filme draußen.
"""
import asyncio
import json
from difflib import SequenceMatcher
import httpx
from . import search
from .config import LANGUAGE, MINIMAX_KEY, MINIMAX_MODEL, MINIMAX_URL
from .teaser import THINK_BLOCK
ANZAHL = 12
MAX_ANZAHL = 100
MAX_BESCHREIBUNG = 1000
SYSTEM = (
"Du bist ein belesener Filmkenner mit Schwerpunkt Horror und Genrekino. "
"Du schlägst Filme vor, die zu dem passen, was gesucht wird. "
"Nenne genau {anzahl} Filme, keine Serien. Nur Filme, die es wirklich gibt. "
"Nichts vorschlagen, was in der Sammlung steht oder schon gesehen wurde. "
"Was in der Frage genannt ist, kennt die Person schon — auch nicht vorschlagen. "
"Mische Bekanntes mit Geheimtipps. "
"Antworte nur mit JSON in dieser Form, ohne Text davor oder danach: "
'{{"merkmale": ["drei Stichpunkte: was macht das Gesuchte aus"], '
'"kandidaten": ["{kandidaten} Originaltitel mit Jahr, die dazu passen könnten"], '
'"filme": [{{"titel": "Originaltitel", "jahr": 1999, "grund": "..."}}]}}\n'
# Der Umweg über Merkmale und Kandidaten ersetzt das Nachdenken des
# Modells: es sortiert erst, dann wählt es — und trifft deutlich besser.
"Erst merkmale, dann kandidaten, dann die {anzahl} besten davon als filme.\n"
# Die Begründung lesen Leute, die einfach einen Film für den Abend
# suchen — nicht Kritiker. Sie muss auf vier kurze Zeilen passen.
"Für grund gilt: Höchstens 10 Wörter und 90 Zeichen, Deutsch in lateinischer "
"Schrift, für Laien. "
"Sag klar, was der Film mit dem Gesuchten gemeinsam hat. "
"Keine Fachwörter wie Whodunit, Neo-Noir, Ensemble, Slasher, Body-Horror, "
"Meta, Hommage, Genre-Namen auf Englisch. Keine Inhaltsangabe, kein Titel, "
"kein Land. Ein Regisseur nur, wenn er der Grund ist. "
"Jede Begründung nennt einen anderen Punkt, keine zwei gleich. "
'Beispiel — gesucht "Ich mochte Knives Out": '
'"Wieder rätselt eine zerstrittene Familie um einen Mord, mit Witz."'
)
# Auf der Karte hat die Begründung vier Zeilen à gut zwanzig Zeichen
MAX_GRUND = 95
def kuerzen(grund):
"""Das Modell zählt ungenau. Was nicht auf die Karte passt, endet am
letzten Komma oder Wort davor — ein halber Satz ist besser als ein
abgeschnittener."""
grund = grund.strip()
if len(grund) <= MAX_GRUND:
return grund
kopf = grund[:MAX_GRUND]
schnitt = max(kopf.rfind(","), kopf.rfind(";"), kopf.rfind(" –"), kopf.rfind("."))
if schnitt < 40:
schnitt = kopf.rfind(" ")
return kopf[:schnitt].rstrip(",; –.") + " …"
def system(anzahl):
"""Der Systemprompt für eine gewünschte Anzahl Filme. Die Kandidaten-
liste bleibt ein Stück länger als das Ergebnis."""
return SYSTEM.format(anzahl=anzahl, kandidaten=max(20, anzahl + 8))
def etikett(film):
"""Titel (Jahr) — so kommt ein Film in den Prompt."""
jahr = film.get("year")
return f"{film['title']} ({jahr})" if jahr else film["title"]
def _spanne(unten, oben, einheit="", ab="mindestens", bis="höchstens"):
if unten is not None and oben is not None:
return f"zwischen {unten}{einheit} und {oben}{einheit}"
if unten is not None:
return f"{ab} {unten}{einheit}"
if oben is not None:
return f"{bis} {oben}{einheit}"
return ""
def vorgaben(filter_):
"""Die Filterleiste als Sätze für den Prompt. Nur was verstellt ist,
kommt vor — die Leiste schickt auch nur das."""
f = filter_ or {}
zeilen = []
if f.get("include"):
zeilen.append("Genre: " + ", ".join(f["include"]))
if f.get("exclude"):
zeilen.append("Auf keinen Fall: " + ", ".join(f["exclude"]))
for text in (
_spanne(f.get("jahr_min"), f.get("jahr_max"), ab="ab", bis="bis"),
_spanne(f.get("note_min"), f.get("note_max"), " von 10 auf TMDB"),
_spanne(f.get("dauer_min"), f.get("dauer_max"), " Minuten"),
_spanne(f.get("stimmen_min"), f.get("stimmen_max"), " Stimmen auf TMDB"),
):
if text:
zeilen.append(text[0].upper() + text[1:])
return zeilen
def prompt(beschreibung, sammlung, gesehen, filter_=None):
teile = []
if beschreibung:
teile.append(f"Gesucht: {beschreibung}")
# In der Nutzernachricht hält sich das Modell eher daran als im
# Systemprompt — sonst steht die Fortsetzung des Gesuchten vorn
teile.append(
"Nicht vorschlagen: Filme, die oben genannt sind, und ihre Fortsetzungen."
)
regeln = vorgaben(filter_)
if regeln:
teile.append("Vorgaben, unbedingt einhalten:\n" + "\n".join(f"- {z}" for z in regeln))
if sammlung:
teile.append("Meine Sammlung, so etwas mag ich:\n" + "\n".join(f"- {t}" for t in sammlung))
if not beschreibung and sammlung:
teile.append("Schlag vor, was zu dieser Sammlung passt.")
elif not beschreibung:
teile.append("Schlag vor, was zu den Vorgaben passt.")
if gesehen:
teile.append("Schon gesehen, nicht vorschlagen:\n" + "\n".join(f"- {t}" for t in gesehen))
return "\n\n".join(teile)
def parsen(text, anzahl=ANZAHL):
"""Das JSON aus der Antwort ziehen. Das Modell hängt gern einen
Denkblock oder einen Satz davor — es zählt, was zwischen der ersten
und der letzten Klammer steht."""
text = THINK_BLOCK.sub("", text)
a, b = text.find("{"), text.rfind("}")
if a < 0 or b < a:
return []
try:
daten = json.loads(text[a : b + 1])
except ValueError:
return []
filme = daten.get("filme") if isinstance(daten, dict) else None
aus = []
for f in filme or []:
if not isinstance(f, dict):
continue
titel = str(f.get("titel") or "").strip()
if not titel:
continue
jahr = str(f.get("jahr") or "")
aus.append(
{
"titel": titel,
"jahr": int(jahr) if jahr.isdigit() else None,
"grund": kuerzen(str(f.get("grund") or "")),
}
)
return aus[:anzahl]
async def fragen(beschreibung, sammlung, gesehen, filter_=None, anzahl=ANZAHL):
payload = {
"model": MINIMAX_MODEL,
"messages": [
{"role": "system", "content": system(anzahl)},
{"role": "user", "content": prompt(beschreibung, sammlung, gesehen, filter_)},
],
# Ohne Denkblock: M3 grübelt sonst minutenlang über zwölf Filme
# und liefert am Token-Limit gar nichts. Die Merkmale- und
# Kandidatenfelder im Prompt leisten das Sortieren stattdessen,
# in gut zehn Sekunden. Niedrige Temperatur: weniger Ausreißer.
"thinking": {"type": "disabled"},
# Rund 40 Token je Film, dazu Merkmale und Kandidaten
"max_tokens": 2000 + 60 * anzahl,
"temperature": 0.3,
}
async with httpx.AsyncClient(
headers={"Authorization": f"Bearer {MINIMAX_KEY}"}, timeout=120.0
) as http:
# Hin und wieder kommt kein brauchbares JSON zurück. Ein zweiter
# Anlauf kostet wenige Sekunden und sitzt fast immer.
for _ in range(2):
resp = await http.post(MINIMAX_URL, json=payload)
resp.raise_for_status()
filme = parsen(resp.json()["choices"][0]["message"]["content"], anzahl)
if filme:
return filme
return []
def _aehnlich(gesucht, treffer):
"""Passt der TMDB-Treffer zum genannten Titel? Die Suche liefert
auch Streifschüsse — "The Afterparty" findet ein Winx-Special. Der
deutsche oder der Originaltitel muss dem Genannten nahekommen."""
soll = search.ohne_zeichen(gesucht).lower()
return any(
SequenceMatcher(None, soll, search.ohne_zeichen(t or "").lower()).ratio() >= 0.6
for t in (treffer.get("title"), treffer.get("original_title"))
)
def _dauer_passt(karte, filter_):
dauer = karte.get("runtime")
if dauer is None:
return True
unten, oben = filter_.get("dauer_min"), filter_.get("dauer_max")
return (unten is None or dauer >= unten) and (oben is None or dauer <= oben)
async def aufloesen(vorschlaege, filter_=None, ausser=()):
"""Jeden Titel bei TMDB suchen. Der Treffer mit dem genannten Jahr
gewinnt — das Modell liegt gern ein Jahr daneben, also mit Spiel.
Sonst der beliebteste. Ohne Treffer war der Film wohl erfunden.
Die Filter der Leiste greifen hier noch einmal hart: was die KI
trotz Vorgabe danebenlegt, sieben die TMDB-Daten aus."""
filter_ = filter_ or {}
async def einer(http, v):
try:
resp = await http.get(
"/search/movie",
params={"query": v["titel"], "language": LANGUAGE, "include_adult": "false"},
)
resp.raise_for_status()
treffer = [
t for t in resp.json().get("results", [])
if t.get("poster_path")
and _aehnlich(v["titel"], t)
and search.passt_zur_leiste(t, filter_)
]
except httpx.HTTPError:
return None
if v["jahr"]:
passend = [
t for t in treffer
if (t.get("release_date") or "")[:4].isdigit()
and abs(int(t["release_date"][:4]) - v["jahr"]) <= 1
]
treffer = passend or treffer
if not treffer:
return None
bester = max(treffer, key=lambda t: t.get("popularity") or 0)
return {**search.as_result(bester), "grund": v["grund"]}
async with search.client() as http:
gefunden = await asyncio.gather(*(einer(http, v) for v in vorschlaege))
karten, dabei = [], set(ausser)
for k in gefunden:
if k and k["id"] not in dabei:
dabei.add(k["id"])
karten.append(k)
await search.details_nachladen(http, karten)
# Die Laufzeit kennt erst die Detailantwort
return [k for k in karten if _dauer_passt(k, filter_)]

View File

@@ -1,19 +1,27 @@
import asyncio
from datetime import datetime, timedelta
import logging
import secrets
import time
from apscheduler.schedulers.asyncio import AsyncIOScheduler
import httpx
from fastapi import Body, Cookie, FastAPI, HTTPException, Request, Response
from fastapi.responses import StreamingResponse
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.staticfiles import StaticFiles
from . import (
backup,
clips,
punkte,
db,
events,
features,
ki,
search,
spenden,
streamproxy,
suggestions,
sync,
@@ -21,7 +29,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.
@@ -69,6 +77,11 @@ app.add_middleware(
)
async def sicherung():
# Die Kopie blockiert kurz — nicht im Ereignisschleifen-Thread
await asyncio.to_thread(backup.sichern)
async def guarded_sync():
if sync_lock.locked():
log.info("Sync läuft bereits")
@@ -80,16 +93,30 @@ async def guarded_sync():
@app.on_event("startup")
async def startup():
db.init()
users.init()
suggestions.init()
watched.init()
users.init()
clips.init()
wishlist.init()
features.init()
spenden.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,
# und Filme, bei denen noch nicht steht, ob man sie leihen kann
asyncio.create_task(sync.katalog_nachtragen())
asyncio.create_task(sync.leihbarkeit_nachtragen())
@app.on_event("shutdown")
@@ -138,15 +165,16 @@ def movie(movie_id: int):
@app.get("/api/search")
async def search_movies(q: str):
async def search_movies(q: str, limit: int = 24):
"""Freie TMDB-Suche für Filme außerhalb des Katalogs."""
if len(q.strip()) < 2:
return []
return await search.search(q.strip())
return await search.search(q.strip(), limit=max(1, min(search.MAX_TREFFER, limit)))
@app.get("/api/discover")
async def stoebern(
limit: int = 40,
sort: str = "rating",
include: str = "",
exclude: str = "",
@@ -173,7 +201,8 @@ async def stoebern(
"jahr_max": jahr_max,
"note_min": note_min,
"note_max": note_max,
}
},
limit=max(1, min(search.MAX_TREFFER, limit)),
)
@@ -214,6 +243,277 @@ async def aehnliche_filme(
return []
@app.post("/api/ki-suche")
async def ki_suche(
response: Response,
beschreibung: str = Body("", embed=True),
mit_sammlung: bool = Body(False, embed=True),
ohne_gesehene: bool = Body(True, embed=True),
include: str = Body("", embed=True),
exclude: str = Body("", embed=True),
stimmen_min: int | None = Body(None, embed=True),
stimmen_max: int | None = Body(None, embed=True),
dauer_min: int | None = Body(None, embed=True),
dauer_max: int | None = Body(None, embed=True),
jahr_min: int | None = Body(None, embed=True),
jahr_max: int | None = Body(None, embed=True),
note_min: float | None = Body(None, embed=True),
note_max: float | None = Body(None, embed=True),
limit: int = Body(ki.ANZAHL, embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Vorschläge von der KI, bei TMDB nachgeschlagen. Die eigene Sammlung
geht auf Wunsch mit, die gesehenen Filme als Ausschluss. Die Filter
der Leiste sind Vorgaben an die KI und danach ein Sieb."""
sid = sitzung(response, filmabend_sid)
filter_ = {
"include": [g for g in include.split(",") if g],
"exclude": [g for g in exclude.split(",") if g],
"stimmen_min": stimmen_min,
"stimmen_max": stimmen_max,
"dauer_min": dauer_min,
"dauer_max": dauer_max,
"jahr_min": jahr_min,
"jahr_max": jahr_max,
"note_min": note_min,
"note_max": note_max,
}
beschreibung = beschreibung.strip()[: ki.MAX_BESCHREIBUNG]
sammlung = (
[ki.etikett(e) for e in wishlist.alle(sid) if e["mine"]] if mit_sammlung else []
)
if not beschreibung and not sammlung and not ki.vorgaben(filter_):
raise HTTPException(
status_code=400, detail="Beschreib, was du suchst, oder gib die Sammlung mit."
)
gesehen = watched.alle(sid)
try:
vorschlaege = await ki.fragen(
beschreibung,
sammlung,
[ki.etikett(g) for g in gesehen],
filter_,
max(1, min(ki.MAX_ANZAHL, limit)),
)
except (httpx.HTTPError, KeyError, IndexError):
raise HTTPException(status_code=502, detail="Die KI antwortet gerade nicht.")
ausser = {g["movie_id"] for g in gesehen} if ohne_gesehene else set()
return await ki.aufloesen(vorschlaege, filter_, ausser=ausser)
# ── Clips: Marken setzen, Fenster ziehen, schneiden, freigeben ──────────
def clip_oder_404(clip):
if clip is None:
raise HTTPException(status_code=404, detail="Clip nicht gefunden oder nicht deiner")
return clip
@app.post("/api/clips")
async def marke_setzen(response: Response, filmabend_sid: str | None = Cookie(None)):
"""Taste C: der Moment wird gemerkt, mehr nicht. Der Film ist der der
laufenden Aufnahme, sonst der zuletzt gezogene, sonst der neueste
Abend unter Gesehen."""
sid = sitzung(response, filmabend_sid)
mitglied(sid)
movie_id = await clips.film_live()
gezogen = letzter_dreh["ergebnis"]
if movie_id is None and gezogen:
movie_id = gezogen["gewinner"]["movie_id"]
if movie_id is None:
abend = watched.juengster()
movie_id = abend["movie_id"] if abend else None
clip = clips.markieren(sid, movie_id)
events.senden("clips")
return clip
@app.get("/api/aufnahmen")
async def aufnahmen_liste():
"""Alle Aufnahmen, neueste zuerst, jede mit ihrem Film — für
„Clip erstellen" und die Verwaltung."""
try:
return {"stuecke": await clips.aufnahmen_alle()}
except httpx.HTTPError:
raise HTTPException(status_code=502, detail="Die Aufnahmen sind gerade nicht erreichbar.")
@app.put("/api/aufnahmen/film")
async def aufnahme_film_setzen(
response: Response,
start: str = Body(..., embed=True),
movie_id: int | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Der Host sagt, welcher Film in einer Aufnahme lief — oder nimmt es zurück."""
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid):
raise HTTPException(status_code=403, detail="Nur für den Host")
if movie_id is not None:
# Suchtreffer stehen nicht im Katalog — Titel und Poster müssen aber her
await film_holen(movie_id)
clips.film_zuweisen(start, movie_id)
return {"zugewiesen": movie_id is not None}
@app.delete("/api/aufnahmen")
async def aufnahme_loeschen(
response: Response, start: str, dauer: float, filmabend_sid: str | None = Cookie(None)
):
"""Ein Stück der Aufnahme löschen — nur der Host."""
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid):
raise HTTPException(status_code=403, detail="Nur für den Host")
try:
anzahl = await clips.aufnahme_loeschen(start, dauer)
except httpx.HTTPStatusError as e:
raise HTTPException(
status_code=502, detail=f"MediaMTX lehnt ab ({e.response.status_code})."
)
except httpx.HTTPError:
raise HTTPException(status_code=502, detail="Die MediaMTX-API ist nicht erreichbar.")
clips.film_zuweisen(start, None)
return {"segmente": anzahl}
@app.post("/api/clips/von-aufnahme")
def clip_von_aufnahme(
response: Response,
start: str = Body(..., embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Ein Clip aus einer alten Aufnahme: die Marke kommt kurz nach deren
Anfang, das Fenster schiebt man dann im Editor hin. Den Film bringt
die Aufnahme mit — oder keinen."""
sid = sitzung(response, filmabend_sid)
mitglied(sid)
beginn = datetime.fromisoformat(start)
clip = clips.markieren(
sid, clips.film_der_aufnahme(start), beginn + timedelta(seconds=clips.VORHER + 30)
)
events.senden("clips")
return clip
@app.get("/api/clips")
def clips_liste(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
return clips.alle(sid, users.ist_host(sid))
@app.patch("/api/clips/{clip_id}")
def clip_aendern(
clip_id: int,
response: Response,
titel: str | None = Body(None, embed=True),
vorher: float | None = Body(None, embed=True),
dauer: float | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
clip = clip_oder_404(clips.aendern(clip_id, sid, users.ist_host(sid), titel, vorher, dauer))
events.senden("clips")
return clip
@app.delete("/api/clips/{clip_id}")
def clip_loeschen(clip_id: int, response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
if not clips.loeschen(clip_id, sid, users.ist_host(sid)):
raise HTTPException(status_code=404, detail="Clip nicht gefunden oder nicht deiner")
events.senden("clips")
return {"removed": 1}
@app.post("/api/clips/{clip_id}/schneiden")
async def clip_schneiden(clip_id: int, response: Response, filmabend_sid: str | None = Cookie(None)):
"""Den Ausschnitt aus der Aufnahme als Datei ablegen."""
sid = sitzung(response, filmabend_sid)
try:
clip = clip_oder_404(await clips.schneiden(clip_id, sid, users.ist_host(sid)))
except httpx.HTTPError:
raise HTTPException(status_code=502, detail="Die Aufnahme liefert diesen Ausschnitt nicht.")
events.senden("clips")
return clip
@app.get("/api/clips/{clip_id}/aufnahme")
async def clip_aufnahme(clip_id: int, response: Response, filmabend_sid: str | None = Cookie(None)):
"""Die Stücke der Aufnahme rund um die Marke, für den Zeitstrahl."""
sid = sitzung(response, filmabend_sid)
row = clip_oder_404(clips.zeile(clip_id, sid, users.ist_host(sid)))
try:
return await clips.aufnahme(row["marke_at"])
except httpx.HTTPError:
return []
@app.get("/api/clips/{clip_id}/vorschau")
async def clip_vorschau(
clip_id: int,
response: Response,
vorher: float | None = None,
dauer: float | None = None,
filmabend_sid: str | None = Cookie(None),
):
"""Ein Fenster um die Marke ansehen, ohne zu schneiden — beim Ziehen
der Regler. Kommt direkt vom Playback-Server durch."""
sid = sitzung(response, filmabend_sid)
row = clip_oder_404(clips.zeile(clip_id, sid, users.ist_host(sid)))
url, params = clips.playback_url(row, vorher, dauer)
http = httpx.AsyncClient(timeout=120.0)
try:
antwort = await http.send(http.build_request("GET", url, params=params), stream=True)
except httpx.HTTPError:
await http.aclose()
raise HTTPException(status_code=502, detail="Die Aufnahme liefert diesen Ausschnitt nicht.")
if antwort.status_code != 200:
await antwort.aclose()
await http.aclose()
raise HTTPException(status_code=502, detail="Die Aufnahme liefert diesen Ausschnitt nicht.")
async def weiterreichen():
try:
async for stueck in antwort.aiter_bytes():
yield stueck
finally:
await antwort.aclose()
await http.aclose()
return StreamingResponse(weiterreichen(), media_type="video/mp4")
@app.get("/api/clips/{clip_id}/video")
def clip_video(clip_id: int, response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
row = clip_oder_404(clips.zeile(clip_id, sid, users.ist_host(sid)))
if not row["datei"] or not (clips.CLIPS_DIR / row["datei"]).exists():
raise HTTPException(status_code=404, detail="Noch nicht geschnitten")
return FileResponse(clips.CLIPS_DIR / row["datei"], media_type="video/mp4")
@app.get("/api/movies/{movie_id}/credits")
async def film_credits(movie_id: int):
"""Regie und Besetzung, live von TMDB."""
return await search.credits(movie_id)
@app.get("/api/personen")
async def personen_suchen(q: str):
"""Schauspieler und Regisseure bei TMDB suchen."""
if len(q.strip()) < 2:
return []
return await search.personen(q.strip())
@app.get("/api/personen/{person_id}/filme")
async def personen_filme(person_id: int):
"""Was jemand gespielt hat und wo er Regie geführt hat."""
try:
return await search.person_filme(person_id)
except httpx.HTTPError:
raise HTTPException(status_code=502, detail="TMDB antwortet gerade nicht.")
@app.get("/api/search/{movie_id}")
async def search_detail(movie_id: int):
"""Volle Daten zu einem Suchtreffer. Erst der Katalog, dann TMDB."""
@@ -241,13 +541,35 @@ def sitzung(response: Response, sid):
return neu
# Der letzte Dreh, damit eine Seite mitten in der Animation nachziehen kann
letzter_dreh = {"ergebnis": None, "start": 0.0}
def mitglied(sid):
"""Wer keinen Namen gewählt hat, darf zusehen, aber nichts eintragen.
Vorschläge, Sammlungen, Wertungen und Wünsche gehören einer Person.
Ohne Namen hingen sie nur am Cookie: im geteilten Browser schriebe
jeder auf dasselbe Konto, und nach dem Löschen der Cookies wäre alles
weg. Die Knöpfe blendet die Oberfläche aus — das hier ist der Riegel.
Der Host braucht dafür ebenfalls einen Namen. Seine eigenen Knöpfe
(drehen, eintragen, aufräumen) laufen über andere Routen.
"""
ich = users.besitzer(sid)
if ich is None:
raise HTTPException(
status_code=403, detail="Dafür musst du oben deinen Namen wählen."
)
return ich
# Der letzte Dreh, damit eine Seite mitten in der Animation nachziehen
# kann — samt Anwesenheit der Runde, die beim Eintragen am Abend landet
letzter_dreh = {"ergebnis": None, "start": 0.0, "teilnehmer": []}
@app.get("/api/events")
async def ereignisse(request: Request):
queue = events.anmelden()
async def ereignisse(request: Request, filmabend_sid: str | None = Cookie(None)):
queue = events.anmelden(filmabend_sid)
# Die anderen Seiten sollen den grünen Punkt anmachen
events.senden("users")
return StreamingResponse(
events.stream(queue, request),
media_type="text/event-stream",
@@ -260,7 +582,7 @@ def drehen():
"""Einer dreht, alle sehen dasselbe Ergebnis."""
laeuft = (
letzter_dreh["ergebnis"]
and time.monotonic() - letzter_dreh["start"] < suggestions.DREH_DAUER
and time.monotonic() - letzter_dreh["start"] < letzter_dreh["ergebnis"]["dauer"]
)
if laeuft:
return {"started": False, "reason": "dreht bereits"}
@@ -271,6 +593,7 @@ def drehen():
letzter_dreh["ergebnis"] = ergebnis
letzter_dreh["start"] = time.monotonic()
letzter_dreh["teilnehmer"] = suggestions.teilnehmer()
events.senden("spin", ergebnis)
return {"started": True, **ergebnis}
@@ -283,7 +606,7 @@ def letzter():
return {"running": False, "result": None}
vergangen = time.monotonic() - letzter_dreh["start"]
return {
"running": vergangen < suggestions.DREH_DAUER,
"running": vergangen < ergebnis["dauer"],
# Wer später dazukommt, überspringt den bereits gelaufenen Teil
"elapsed": round(vergangen, 2),
"result": ergebnis,
@@ -294,18 +617,34 @@ def letzter():
def liste_vorschlaege(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
eintraege = suggestions.alle()
ich = users.besitzer(sid)
dabei = suggestions.dabei_alle()
return {
"items": [
{k: v for k, v in e.items() if k != "session_id"} for e in eintraege
{k: v for k, v in e.items() if k not in ("session_id", "user_id")}
for e in eintraege
],
# Damit die Oberfläche den eigenen Vorschlag hervorheben kann
"mine": next(
(e["id"] for e in eintraege if e["session_id"] == sid),
(e["id"] for e in eintraege if users.gehoert_mir(e, sid, ich)),
None,
),
# Wer ohne Vorschlag dabei ist
"dabei": [d["name"] or "Anonym" for d in dabei],
"dabei_mine": any(users.gehoert_mir(d, sid, ich) for d in dabei),
}
@app.post("/api/dabei")
def dabei(response: Response, filmabend_sid: str | None = Cookie(None)):
"""Dabei ohne Vorschlag — an oder wieder aus."""
sid = sitzung(response, filmabend_sid)
mitglied(sid)
steht = suggestions.dabei_umschalten(sid)
events.senden("suggestions")
return {"dabei": steht}
@app.post("/api/suggestions")
async def vorschlagen(
response: Response,
@@ -313,16 +652,20 @@ async def vorschlagen(
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
mitglied(sid)
movie = await film_holen(movie_id)
# Ein zweiter Vorschlag ersetzt den ersten, statt abgelehnt zu werden
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"}
return {
k: v for k, v in eintrag.items() if k not in ("session_id", "user_id")
}
@app.delete("/api/suggestions")
def vorschlag_loeschen(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
mitglied(sid)
entfernt = suggestions.loeschen(sid)
if entfernt:
events.senden("suggestions")
@@ -330,14 +673,101 @@ 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
# ── Info: Bezahlen und Regeln ───────────────────────────────────────────
INFO_MAX = 8000
@app.get("/api/info")
def info():
"""Ein Markdown-Text des Hosts: PayPal, IBAN, Regeln, was man wissen
muss. Solange keiner da ist, steht die Vorlage mit den Punkteregeln."""
return {"text": db.get_meta("info_text") or punkte.vorlage()}
@app.put("/api/info")
def info_setzen(
response: Response,
text: str = Body(..., embed=True),
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid):
raise HTTPException(status_code=403, detail="Nur für den Host")
with db.connect() as conn:
db.set_meta(conn, "info_text", text.strip()[:INFO_MAX])
events.senden("info")
return {"text": db.get_meta("info_text")}
# ── Spendenbaum ─────────────────────────────────────────────────────────
@app.get("/api/spenden")
def spenden_liste():
return spenden.alle()
@app.post("/api/spenden")
def spende_eintragen(
response: Response,
user_id: int = Body(..., embed=True),
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid):
raise HTTPException(status_code=403, detail="Nur für den Host")
spenden.eintragen(user_id)
events.senden("spenden")
return spenden.alle()
@app.get("/api/kontostand")
def kontostand():
"""Was auf dem Filmabend-Konto liegt — der Host trägt die Zahl ein."""
wert = db.get_meta("kontostand")
return {"betrag": float(wert) if wert else None}
@app.put("/api/kontostand")
def kontostand_setzen(
response: Response,
betrag: float | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid):
raise HTTPException(status_code=403, detail="Nur für den Host")
with db.connect() as conn:
db.set_meta(conn, "kontostand", "" if betrag is None else round(betrag, 2))
events.senden("spenden")
return kontostand()
@app.delete("/api/spenden/{user_id}")
def spende_entfernen(
user_id: int, response: Response, filmabend_sid: str | None = Cookie(None)
):
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid):
raise HTTPException(status_code=403, detail="Nur für den Host")
if spenden.entfernen(user_id):
events.senden("spenden")
return spenden.alle()
@app.get("/api/features")
@@ -353,6 +783,7 @@ def wunsch_anlegen(
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
mitglied(sid)
liste = features.anlegen(sid, text)
if liste is None:
raise HTTPException(status_code=400, detail="Kein Text")
@@ -376,6 +807,56 @@ 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)
mitglied(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)
mitglied(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."""
@@ -390,6 +871,7 @@ async def merken(
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
mitglied(sid)
return wishlist.merken(sid, await film_holen(movie_id))
@@ -400,13 +882,23 @@ def vergessen(
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
mitglied(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)}
staende = punkte.alle()
leer = punkte.stand(0)
online = users.online_ids(events.sitzungen_online())
return {
"items": [
{**u, **staende.get(u["id"], leer), "online": u["id"] in online} for u in users.alle()
],
"mine": users.aktuell(sid),
"host": users.ist_host(sid),
}
@app.post("/api/users")
@@ -427,37 +919,208 @@ 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)
# Ob es einen Host-Film gibt, darf jeder wissen — nur nicht welchen.
# Ohne Film hat die Tastenkombination nichts zu fragen.
return {"host": users.ist_host(sid), "film": users.host_film() is not None}
@app.post("/api/host")
def host_modus(
response: Response,
an: bool = Body(..., embed=True),
schluessel: str | None = Body(None, embed=True),
movie_id: int | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Die Sitzung als Host freischalten — oder die Rechte abgeben.
Freischalten geht mit dem Schlüssel aus der .env, einmal über
/?host=<schlüssel>, oder mit dem Host-Film: die Tastenkombination
fragt danach, wie beim Anmeldefilm eines Namens. Ist kein Host-Film
gesetzt, reicht die Tastenkombination allein.
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)}
film = users.host_film()
darf = (
users.ist_host(sid)
or (bool(HOST_SCHLUESSEL) and secrets.compare_digest(schluessel or "", HOST_SCHLUESSEL))
or film is None
or movie_id == film
)
if not darf:
raise HTTPException(status_code=403, detail="Kein Hostschlüssel")
return {"host": users.host_setzen(sid, True)}
@app.get("/api/host/film")
async def host_film_zeigen(response: Response, filmabend_sid: str | None = Cookie(None)):
"""Welcher Film den Hostmodus öffnet — nur der Host sieht ihn."""
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid):
raise HTTPException(status_code=403, detail="Nur für den Host")
movie_id = users.host_film()
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/host/film")
def host_film_setzen(
response: Response,
movie_id: int | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Den Host-Film setzen oder aufheben — nur als Host."""
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid):
raise HTTPException(status_code=403, detail="Nur für den Host")
users.host_film_setzen(movie_id)
return {"gesetzt": movie_id is not None}
@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)
events.senden("users")
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")
events.senden("users")
return {"mine": benutzer}
@app.get("/api/watched")
def liste_gesehen(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
return watched.alle(sid)
return watched.alle(sid, users.ist_host(sid))
@app.post("/api/watched")
async def als_gesehen(movie_id: int = Body(..., embed=True)):
"""Der Host trägt ein, was am Abend lief."""
"""Der Host trägt ein, was am Abend lief. Ist es der gezogene Film,
hängt die Runde vom Rad als Anwesenheit am Abend."""
eintrag = watched.markieren(await film_holen(movie_id))
gezogen = letzter_dreh["ergebnis"]
if gezogen and gezogen["gewinner"]["movie_id"] == movie_id and letzter_dreh["teilnehmer"]:
watched.dabei_setzen(eintrag["id"], letzter_dreh["teilnehmer"])
events.senden("watched")
return eintrag
@app.post("/api/watched/{watched_id}/dabei")
def dabei_aendern(watched_id: int, user_ids: list[int] = Body(..., embed=True)):
"""Der Host berichtigt, wer dabei war — etwa wer spät kam."""
watched.dabei_setzen(watched_id, user_ids)
events.senden("watched")
return {"dabei": user_ids}
@app.post("/api/watched/hearts")
def herz_setzen(
response: Response,
note_id: int = Body(..., embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Ein Klick setzt das Herz, der nächste nimmt es zurück."""
sid = sitzung(response, filmabend_sid)
mitglied(sid)
steht = watched.herz(sid, note_id)
if steht is None:
raise HTTPException(status_code=404, detail="Kommentar nicht gefunden")
events.senden("watched")
return {"hearted": steht}
@app.delete("/api/watched/{watched_id}")
def gesehen_entfernen(watched_id: int):
entfernt = watched.entfernen(watched_id)
@@ -492,6 +1155,7 @@ def bewerten(
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))
@@ -510,6 +1174,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."""

95
backend/app/punkte.py Normal file
View File

@@ -0,0 +1,95 @@
"""Erfahrung und Level — berechnet, nicht gebucht.
Punkte sind eine Formel über dem, was gerade in der Datenbank steht:
wer dabei war, bewertet, kommentiert, geantwortet und ein Herz gegeben
hat. Nimmt jemand seine Wertung zurück, sind die Punkte weg. Es gibt
kein Konto, das man pflegen oder austricksen könnte.
Je Film zählt jede Art nur einmal. Level nach Dreieckszahlen: Level L
ist erreicht, sobald L·(L−1)/2 Punkte da sind — anfangs schnell, dann
stetig langsamer, aber immer in Reichweite.
"""
from math import isqrt
from .db import connect
SCHAUEN = 10
BEWERTEN = 3
KOMMENTAR = 5
ANTWORT = 2
HERZ = 1
# Für den Info-Reiter: was zählt, mit der Zahl — aus denselben Konstanten
REGELN = (
("Beim Filmabend dabei", SCHAUEN),
("Film mit Sternen bewertet", BEWERTEN),
("Kommentar geschrieben", KOMMENTAR),
("Auf einen Kommentar geantwortet", ANTWORT),
("Herz vergeben", HERZ),
)
ZAEHLUNGEN = (
(SCHAUEN, "SELECT user_id, COUNT(*) AS n FROM watched_dabei GROUP BY user_id"),
(
BEWERTEN,
"SELECT user_id, COUNT(DISTINCT watched_id) AS n FROM ratings "
"WHERE user_id IS NOT NULL GROUP BY user_id",
),
(
KOMMENTAR,
"SELECT user_id, COUNT(DISTINCT watched_id) AS n FROM watched_notes "
"WHERE user_id IS NOT NULL AND parent_id IS NULL GROUP BY user_id",
),
(
ANTWORT,
"SELECT user_id, COUNT(DISTINCT watched_id) AS n FROM watched_notes "
"WHERE user_id IS NOT NULL AND parent_id IS NOT NULL GROUP BY user_id",
),
(
HERZ,
"SELECT h.user_id, COUNT(DISTINCT n.watched_id) AS n FROM watched_hearts h "
"JOIN watched_notes n ON n.id = h.note_id "
"WHERE h.user_id IS NOT NULL GROUP BY h.user_id",
),
)
def vorlage():
"""Der Info-Text, solange der Host keinen eigenen geschrieben hat —
die Regeln mit ihren Zahlen und die ersten Levelstufen, als Markdown."""
zeilen = ["## Erfahrung", ""]
zeilen += [f"- {was}: **+{p}**" for was, p in REGELN]
zeilen += ["", "Je Film zählt jede Art nur einmal.", "", "## Level", ""]
stufen = ", ".join(f"Level {stufe} ab {stufe * (stufe - 1) // 2}" for stufe in range(2, 8))
zeilen.append(f"{stufen} … Jedes weitere Level kostet einen Punkt mehr als das davor.")
return "\n".join(zeilen)
def level(punkte):
"""Größtes L mit L·(L−1)/2 ≤ Punkte. Null Punkte sind Level 1."""
stufe = (1 + isqrt(1 + 8 * punkte)) // 2
while stufe * (stufe - 1) // 2 > punkte:
stufe -= 1
return max(1, stufe)
def stand(punkte):
stufe = level(punkte)
return {
"punkte": punkte,
"level": stufe,
# Wo das aktuelle Level begann und wo das nächste anfängt — für den Balken
"von": stufe * (stufe - 1) // 2,
"bis": stufe * (stufe + 1) // 2,
}
def alle():
"""Punktestand je user_id."""
summen = {}
with connect() as conn:
for wert, sql in ZAEHLUNGEN:
for row in conn.execute(sql):
summen[row["user_id"]] = summen.get(row["user_id"], 0) + wert * row["n"]
return {user_id: stand(p) for user_id, p in summen.items()}

View File

@@ -7,6 +7,8 @@ landen nicht in der Datenbank.
import asyncio
import random
import time
import unicodedata
import httpx
@@ -15,6 +17,7 @@ from .tmdb import (
DETAIL_PARAMS,
pick_backdrops,
pick_overview,
pick_rentable,
pick_title,
pick_trailer,
)
@@ -66,7 +69,15 @@ async def details_nachladen(http, treffer, je_film=4):
Das alles steckt in der Detailansicht, ein Abruf je Film reicht.
"""
# Nicht alle auf einmal: bei einer langen Filmografie wären das
# über hundert gleichzeitige Abrufe
bremse = asyncio.Semaphore(8)
async def hole(film):
async with bremse:
await hole_einen(film)
async def hole_einen(film):
try:
resp = await http.get(
f"/movie/{film['id']}",
@@ -105,6 +116,27 @@ SORTIERUNG = {
# Weiter hinten liegen nur noch Filme, die kaum jemand kennt
MAX_SEITE = 20
# TMDB liefert zwanzig je Seite; bis hundert sind es fünf Abrufe
MAX_TREFFER = 100
JE_SEITE = 20
async def seiten(http, pfad, params, limit, start=1):
"""So viele Seiten, wie das Limit braucht — alle auf einmal. Was
fehlschlägt, fehlt eben; die Reihenfolge der Seiten bleibt."""
anzahl = -(-limit // JE_SEITE)
async def eine(nummer):
try:
resp = await http.get(pfad, params={**params, "page": nummer})
resp.raise_for_status()
return resp.json()
except httpx.HTTPError:
return {}
antworten = await asyncio.gather(*(eine(start + i) for i in range(anzahl)))
results = [r for a in antworten for r in a.get("results", [])]
return results, antworten[0] if antworten else {}
async def discover(filter_, limit=40):
@@ -148,22 +180,16 @@ async def discover(filter_, limit=40):
params["primary_release_date.lte"] = f"{filter_['jahr_max']}-12-31"
async with client() as http:
resp = await http.get("/discover/movie", params=params)
resp.raise_for_status()
daten = resp.json()
results, erste = await seiten(http, "/discover/movie", params, limit)
# Bei "Zufällig" nochmal von einer beliebigen Seite holen — sonst
# stehen bei jedem Aufruf dieselben zwanzig Filme oben
if filter_.get("sort") == "random":
seiten = min(daten.get("total_pages") or 1, MAX_SEITE)
if seiten > 1:
params["page"] = random.randint(1, seiten)
resp = await http.get("/discover/movie", params=params)
resp.raise_for_status()
daten = resp.json()
results = daten.get("results", [])
# Bei "Zufällig" nochmal von einer beliebigen Stelle holen — sonst
# stehen bei jedem Aufruf dieselben Filme oben
if filter_.get("sort") == "random":
gesamt = min(erste.get("total_pages") or 1, MAX_SEITE)
noetig = -(-limit // JE_SEITE)
if gesamt > noetig:
start = random.randint(1, gesamt - noetig + 1)
results, _ = await seiten(http, "/discover/movie", params, limit, start)
random.shuffle(results)
usable = [r for r in results if r.get("poster_path")]
@@ -172,14 +198,37 @@ async def discover(filter_, limit=40):
return treffer
def ohne_zeichen(text):
"""Umlaute und Akzente auf den Grundbuchstaben zurückführen."""
zerlegt = unicodedata.normalize("NFD", text)
return "".join(z for z in zerlegt if not unicodedata.combining(z))
async def search(query, limit=24):
async with client() as http:
resp = await http.get(
# TMDB findet angefangene Wörter nur ohne Umlaut: "Wilden Hüh" liefert
# nichts, "Wilden Huh" die drei Hühner-Filme. Wer beim Tippen sucht,
# steht sonst vor einer leeren Seite. Also beide Schreibweisen fragen.
schlicht = ohne_zeichen(query)
begriffe = [query] if schlicht == query else [query, schlicht]
async def einmal(begriff):
results, _ = await seiten(
http,
"/search/movie",
params={"query": query, "language": LANGUAGE, "include_adult": "false"},
{"query": begriff, "language": LANGUAGE, "include_adult": "false"},
limit,
)
resp.raise_for_status()
results = resp.json().get("results", [])
return results
async with client() as http:
results = []
gesehen = set()
# Die Treffer der wörtlichen Suche stehen vorn
for liste in await asyncio.gather(*(einmal(b) for b in begriffe)):
for eintrag in liste:
if eintrag["id"] not in gesehen:
gesehen.add(eintrag["id"])
results.append(eintrag)
# Ohne Poster taugt eine Kachel nichts, und Namenlose sind fast immer
# Dubletten oder Making-ofs
@@ -313,6 +362,114 @@ async def aehnliche(movie_id, limit=12, voll=False, filter_=None):
return treffer
# ── Personen: Schauspieler und Regisseure ────────────────────────────────
MAX_PERSONEN = 12
# Laufzeit und Szenenbilder kosten je Film einen Abruf — bei einer langen
# Filmografie ein paar Sekunden. Einmal geholt, bleibt es einen Tag im
# Speicher; so oft ändert sich eine Filmografie nicht.
PERSONEN_CACHE = {}
PERSONEN_FRIST = 24 * 3600
FACH = {
"Acting": "Schauspiel",
"Directing": "Regie",
"Writing": "Drehbuch",
"Production": "Produktion",
}
async def personen(query):
"""Leute suchen. Wer ein Foto hat, steht vorn — sonst die Bekannten."""
async with client() as http:
try:
resp = await http.get(
"/search/person",
params={"query": query, "language": LANGUAGE, "include_adult": "false"},
)
resp.raise_for_status()
roh = resp.json().get("results", [])
except httpx.HTTPError:
return []
roh.sort(key=lambda p: (not p.get("profile_path"), -(p.get("popularity") or 0)))
return [
{
"id": p["id"],
"name": p.get("name"),
"fach": FACH.get(p.get("known_for_department"), p.get("known_for_department") or ""),
"profile_path": p.get("profile_path"),
"bekannt_fuer": [
k.get("title") or k.get("name")
for k in p.get("known_for", [])
if k.get("title") or k.get("name")
][:3],
}
for p in roh[:MAX_PERSONEN]
]
def credits_aufbereiten(daten):
"""Aus den movie_credits zwei Listen: was jemand gespielt und wo er
Regie geführt hat. Ohne Poster oder doppelt fliegt raus, die
bekanntesten Filme stehen vorn. Kurzfilme und Making-ofs bleiben
drin — die sortiert die Filterleiste aus, und zwar sichtbar."""
def liste(eintraege):
raus, dabei = [], set()
for e in sorted(eintraege, key=lambda e: -(e.get("popularity") or 0)):
if not e.get("poster_path") or e["id"] in dabei:
continue
dabei.add(e["id"])
raus.append(as_result(e))
return raus
return {
"gespielt": liste(daten.get("cast", [])),
"regie": liste([c for c in daten.get("crew", []) if c.get("job") == "Director"]),
}
# Mehr Namen passen nicht in die Filmansicht, und ab da sind es Nebenrollen
MAX_CAST = 8
async def credits(movie_id):
"""Regie und die wichtigsten Darsteller — als Sprungbrett zur
Personensuche. Fehlt etwas, bleiben die Listen leer."""
async with client() as http:
try:
# Ohne Sprache: auf Deutsch kämen russische Namen in Kyrillisch
# und eingedeutschte Umschriften wie "Ilja Wiktorowitsch"
resp = await http.get(f"/movie/{movie_id}/credits", params={"language": "en-US"})
resp.raise_for_status()
daten = resp.json()
except httpx.HTTPError:
return {"regie": [], "cast": []}
def person(p, fach):
return {"id": p["id"], "name": p.get("name"), "fach": fach, "profile_path": p.get("profile_path")}
regie, dabei = [], set()
for c in daten.get("crew", []):
if c.get("job") == "Director" and c["id"] not in dabei:
dabei.add(c["id"])
regie.append(person(c, "Regie"))
cast = sorted(daten.get("cast", []), key=lambda c: c.get("order", 999))[:MAX_CAST]
return {"regie": regie, "cast": [person(c, "Schauspiel") for c in cast]}
async def person_filme(person_id):
gemerkt = PERSONEN_CACHE.get(person_id)
if gemerkt and time.monotonic() - gemerkt[0] < PERSONEN_FRIST:
return gemerkt[1]
async with client() as http:
resp = await http.get(f"/person/{person_id}/movie_credits", params={"language": LANGUAGE})
resp.raise_for_status()
rollen = credits_aufbereiten(resp.json())
await details_nachladen(http, rollen["gespielt"] + rollen["regie"])
PERSONEN_CACHE[person_id] = (time.monotonic(), rollen)
return rollen
async def titel_richten(http, film):
"""Originalschrift durch den englischen Titel ersetzen."""
if not any(
@@ -357,6 +514,7 @@ async def detail(movie_id):
"images": pick_backdrops(data)[:12],
"trailer_key": pick_trailer(data),
"imdb_id": data.get("imdb_id"),
"rentable": pick_rentable(data),
"netflix_id": None,
"netflix_url": None,
"on_netflix": 0,

44
backend/app/spenden.py Normal file
View File

@@ -0,0 +1,44 @@
"""Der Spendenbaum: wer schon einmal etwas überwiesen hat.
Ein Blatt je Person, ohne Betrag. Der Host trägt ein und nimmt weg.
"""
from datetime import datetime, timezone
from .db import connect
SCHEMA = """
CREATE TABLE IF NOT EXISTS spenden (
user_id INTEGER PRIMARY KEY,
created_at TEXT NOT NULL
);
"""
def init():
with connect() as conn:
conn.executescript(SCHEMA)
def alle():
"""Die Blätter, das neueste zuerst."""
with connect() as conn:
rows = conn.execute(
"SELECT s.user_id, u.name, s.created_at FROM spenden s "
"JOIN users u ON u.id = s.user_id ORDER BY s.created_at DESC, s.user_id DESC"
).fetchall()
return [dict(r) for r in rows]
def eintragen(user_id):
"""Ein zweites Mal ändert nichts — ein Blatt je Person."""
with connect() as conn:
conn.execute(
"INSERT OR IGNORE INTO spenden (user_id, created_at) VALUES (?, ?)",
(user_id, datetime.now(timezone.utc).isoformat(timespec="seconds")),
)
def entfernen(user_id):
with connect() as conn:
return conn.execute("DELETE FROM spenden WHERE user_id = ?", (user_id,)).rowcount > 0

View File

@@ -1,4 +1,4 @@
"""Filmvorschläge fürs Rad.
"""Filmvorschläge für die Ziehung.
Jede Sitzung darf einen Film vorschlagen. Die Sitzung erkennt ein Cookie —
ohne Anmeldung geht es nicht genauer, und für einen Filmabend im Freundeskreis
@@ -14,12 +14,15 @@ import sqlite3
import uuid
from datetime import datetime, timezone
from . import users
from .db import connect
from .users import WEM, besitzer
SCHEMA = """
CREATE TABLE IF NOT EXISTS suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
session_id TEXT NOT NULL,
user_id INTEGER,
movie_id INTEGER NOT NULL,
title TEXT NOT NULL,
year INTEGER,
@@ -29,17 +32,43 @@ CREATE TABLE IF NOT EXISTS suggestions (
);
"""
# Wer am Abend da ist, aber nichts vorschlagen will. Zusammen mit den
# Vorschlagenden ergibt das die Anwesenheit — die zählt beim Dreh.
DABEI = """
CREATE TABLE IF NOT EXISTS dabei (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
user_id INTEGER,
created_at TEXT NOT NULL
);
"""
COOKIE = "filmabend_sid"
# So lange läuft die Animation im Browser. Der Server merkt sich das
# Ergebnis mit, damit eine Seite, die mitten im Dreh geladen wird, es
# trotzdem bekommt.
DREH_DAUER = 4.2
DREH_DAUER = 11.2
# Die Ziehung läuft wie das Öffnen einer Lootbox: ein Streifen aus
# Postern rast vorbei und bremst auf dem Gewinner aus. Der Streifen ist
# so lang, und an dieser Stelle steht der Treffer.
STREIFEN_LAENGE = 64
STREIFEN_ZIEL = 56
# Manchmal bricht nach dem Halt die Halterung: die Bahn rutscht ein paar
# Poster zurück, und ein anderer gewinnt. So lange dauert das im Browser
# zusätzlich (Ruhe plus Rückfahrt — die Werte stehen auch in WheelTab.vue).
BRUCH_DAUER = 3.7
BRUCH_ANTEIL = 0.5
def init():
with connect() as conn:
conn.executescript(SCHEMA)
conn.executescript(SCHEMA + DABEI)
# Ein Vorschlag je Person
users.je_person(conn, "suggestions", SCHEMA)
users.je_person(conn, "dabei", DABEI)
def neue_session():
@@ -49,15 +78,27 @@ 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
]
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 +107,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 +119,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,46 +132,116 @@ 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)
def _versatz():
"""Wo genau im Poster die Marke stehen bleibt — nie exakt mittig.
Jedes zweite Mal ganz knapp an der Kante: fast der andere."""
if random.random() < 0.5:
return round(random.choice((-1, 1)) * random.uniform(0.34, 0.46), 3)
return round(random.uniform(-0.3, 0.3), 3)
def ziehen():
"""Server lost aus, damit alle Seiten dasselbe Ergebnis sehen.
Der Zielwinkel gehört dazu: sonst dreht jede Seite woanders hin und
der Zeiger stünde am Ende auf einem anderen Segment.
Der Streifen gehört dazu: sonst zeigt jede Seite andere Poster
vorbeirauschen, und der Treffer stünde am Ende woanders.
"""
eintraege = alle()
if not eintraege:
return None
treffer = 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))
# Schon gelaufene Filme überspringen. Sind alle Vorschläge gesehen,
# bleibt es bei der vollen Auswahl — sonst gäbe es keinen Gewinner.
with connect() as conn:
gesehen = {
r["movie_id"] for r in conn.execute("SELECT movie_id FROM watched")
}
frisch = [i for i, e in enumerate(eintraege) if e["movie_id"] not in gesehen]
treffer = random.choice(frisch) if frisch else random.randrange(len(eintraege))
# Blockweise gemischt: jeder Vorschlag kommt gleich oft vorbei
streifen = []
while len(streifen) < STREIFEN_LAENGE:
block = list(range(len(eintraege)))
random.shuffle(block)
streifen.extend(block)
streifen = streifen[:STREIFEN_LAENGE]
# Wo die Bahn hält — beim Bruch zweimal, und erst der zweite Halt zählt
stopps = [{"position": STREIFEN_ZIEL, "versatz": _versatz()}]
ziel = STREIFEN_ZIEL
if random.random() < BRUCH_ANTEIL:
ziel = STREIFEN_ZIEL - random.randint(1, 4)
stopps.append({"position": ziel, "versatz": _versatz()})
# Der erste Halt zeigt einen anderen — sonst wäre der Bruch egal
andere = [i for i in range(len(eintraege)) if i != treffer]
streifen[STREIFEN_ZIEL] = random.choice(andere) if andere else treffer
streifen[ziel] = treffer
def karte(e):
return {k: v for k, v in e.items() if k not in ("session_id", "user_id")}
return {
"index": treffer,
"winkel": winkel,
"dauer": DREH_DAUER,
"gewinner": {
k: v for k, v in eintraege[treffer].items() if k != "session_id"
},
"anzahl": len(eintraege),
"streifen": streifen,
"stopps": stopps,
"dauer": DREH_DAUER + (BRUCH_DAUER if len(stopps) > 1 else 0),
# Der Streifen zeigt diesen Stand, auch wenn danach jemand einen
# Vorschlag dazutut
"karten": [karte(e) for e in eintraege],
"gewinner": karte(eintraege[treffer]),
}
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
def alles_loeschen():
"""Host setzt die Runde zurück."""
with connect() as conn:
conn.execute("DELETE FROM dabei")
return conn.execute("DELETE FROM suggestions").rowcount
def dabei_umschalten(session_id):
"""Dabei ohne Vorschlag — der zweite Klick nimmt es zurück.
Gibt zurück, ob es jetzt gesetzt ist."""
ich = besitzer(session_id)
with connect() as conn:
weg = conn.execute(
f"DELETE FROM dabei WHERE {WEM}", (ich, ich, session_id)
).rowcount
if weg:
return False
conn.execute(
"INSERT INTO dabei (session_id, user_id, created_at) VALUES (?, ?, ?)",
(session_id, ich, datetime.now(timezone.utc).isoformat(timespec="seconds")),
)
return True
def dabei_alle():
"""Wer sich ohne Vorschlag eingetragen hat — Namen und Kennungen."""
with connect() as conn:
rows = conn.execute(
"SELECT d.*, u.name FROM dabei d LEFT JOIN users u ON u.id = d.user_id "
"ORDER BY d.created_at ASC"
).fetchall()
return [dict(r) for r in rows]
def teilnehmer():
"""user_ids aller Anwesenden: Vorschlagende und Eingetragene."""
with connect() as conn:
ids = {r["user_id"] for r in conn.execute("SELECT user_id FROM suggestions")}
ids |= {r["user_id"] for r in conn.execute("SELECT user_id FROM dabei")}
return sorted(i for i in ids if i is not None)

View File

@@ -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")
@@ -36,6 +36,7 @@ def build_movie(data):
"images": tmdb.pick_backdrops(data),
"trailer_key": tmdb.pick_trailer(data),
"imdb_id": data.get("imdb_id"),
"rentable": tmdb.pick_rentable(data),
# Die echte Titel-ID trägt fill_netflix_links nach
"netflix_id": None,
"netflix_url": tmdb.pick_watch_link(data)
@@ -144,6 +145,71 @@ 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 leihbarkeit_nachtragen():
"""Filme, bei denen noch nicht steht, ob man sie leihen kann — der
Altbestand von vor dieser Spalte. Der tägliche Sync hält es danach
aktuell, hier geht es nur um das erste Mal."""
with db.connect() as conn:
offen = [r["id"] for r in conn.execute("SELECT id FROM movies WHERE rentable IS NULL")]
if not offen:
return 0
bremse = asyncio.Semaphore(TMDB_CONCURRENCY)
async def hole(http, movie_id):
async with bremse:
try:
resp = await http.get(f"/movie/{movie_id}/watch/providers")
resp.raise_for_status()
return movie_id, tmdb.pick_rentable({"watch/providers": resp.json()})
except Exception:
return movie_id, None
async with tmdb.client() as http:
stand = await asyncio.gather(*(hole(http, i) for i in offen))
with db.connect() as conn:
conn.executemany(
"UPDATE movies SET rentable = ? WHERE id = ?",
[(wert, i) for i, wert in stand if wert is not None],
)
log.info("Leihbarkeit für %s Filme nachgetragen", sum(1 for _, w in stand if w is not None))
return len(offen)
async def run():
log.info("Sync startet")
canon_places = await resolve_canon()
@@ -180,15 +246,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 +265,7 @@ async def run():
"netflix_links": linked,
"new_teasers": new_teasers,
"removed": removed,
"nachgetragen": nachgetragen,
}

View File

@@ -135,6 +135,15 @@ def pick_watch_link(data):
return (providers.get(REGION) or {}).get("link")
def pick_rentable(data):
"""Ob man den Film in Deutschland leihen kann — laut JustWatch, über
TMDB. None, wenn gar keine Anbieterdaten mitkamen."""
results = data.get("watch/providers", {}).get("results")
if results is None:
return None
return 1 if (results.get(REGION) or {}).get("rent") else 0
def pick_trailer(data):
"""Trailer bevorzugt, sonst Teaser. Sprache egal."""
videos = [

View File

@@ -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
@@ -12,6 +16,7 @@ einem Textfeld, in das jeder schreiben kann, was er will.
import sqlite3
from datetime import datetime, timezone
from . import db
from .db import connect
SCHEMA = """
@@ -26,18 +31,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 +83,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 +125,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 +144,128 @@ 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
# Der Host-Film: das Passwort für den Hostmodus, wie der Anmeldefilm
# für einen Namen. Steht in der meta-Tabelle, gesetzt aus der Verwaltung.
def host_film_setzen(movie_id):
with connect() as conn:
db.set_meta(conn, "host_film", str(movie_id) if movie_id else "")
def host_film():
wert = db.get_meta("host_film")
return int(wert) if wert 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 online_ids(session_ids):
"""Die Namen hinter offenen Verbindungen — wer gerade da ist."""
return {besitzer(sid) for sid in session_ids} - {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
# Dasselbe als WHERE-Klausel. Parameter: (user_id, user_id, session_id)
WEM = "(user_id = ? OR (? IS NULL AND user_id IS NULL AND session_id = ?))"
def user_id_nachruesten(conn, tabelle):
"""Spalte ergänzen 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 je_person(conn, tabelle, schema, *schluessel):
"""Eindeutig je Person statt je Sitzung.
Früher stand das UNIQUE auf der Sitzung — dann blockierte der Eintrag
des einen Namens den des nächsten, sobald beide denselben Browser
benutzten. Mit Namen zählt jetzt der Name, ohne Namen die Sitzung.
Das alte UNIQUE steckt in der Spaltendefinition und lässt sich nicht
ändern — die Tabelle wird dafür nach `schema` neu gebaut. Hat ein Name
aus mehreren Browsern doppelt eingetragen, bleibt der jüngste Eintrag.
"""
user_id_nachruesten(conn, tabelle)
alt = conn.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (tabelle,)
).fetchone()
if alt and "UNIQUE" in alt["sql"]:
spalten = ", ".join(
r[1] for r in conn.execute(f"PRAGMA table_info({tabelle})")
)
conn.executescript(
f"ALTER TABLE {tabelle} RENAME TO {tabelle}_alt;"
f"{schema}"
f"INSERT INTO {tabelle} ({spalten}) "
f"SELECT {spalten} FROM {tabelle}_alt;"
f"DROP TABLE {tabelle}_alt;"
)
mit_name = ", ".join((*schluessel, "user_id"))
ohne_name = ", ".join((*schluessel, "session_id"))
conn.execute(
f"DELETE FROM {tabelle} WHERE user_id IS NOT NULL AND id NOT IN "
f"(SELECT MAX(id) FROM {tabelle} GROUP BY {mit_name})"
)
conn.executescript(
f"CREATE UNIQUE INDEX IF NOT EXISTS {tabelle}_je_benutzer "
f"ON {tabelle} ({mit_name}) WHERE user_id IS NOT NULL;"
f"CREATE UNIQUE INDEX IF NOT EXISTS {tabelle}_je_sitzung "
f"ON {tabelle} ({ohne_name}) WHERE user_id IS NULL;"
)
def name_von(session_id):

View File

@@ -1,16 +1,21 @@
"""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
from .db import connect
from .users import WEM, besitzer, gehoert_mir
SCHEMA = """
CREATE TABLE IF NOT EXISTS watched (
@@ -22,73 +27,226 @@ CREATE TABLE IF NOT EXISTS watched (
watched_at TEXT NOT NULL,
hidden INTEGER NOT NULL DEFAULT 0
);
"""
RATINGS = """
CREATE TABLE IF NOT EXISTS ratings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
watched_id INTEGER NOT NULL,
session_id TEXT NOT NULL,
user_id INTEGER,
stars INTEGER NOT NULL,
name TEXT,
created_at TEXT NOT NULL,
UNIQUE (watched_id, session_id)
text TEXT,
created_at TEXT NOT NULL
);
"""
# 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
);
"""
# Ein Herz je Person und Kommentar: "sehe ich auch so". Alle sehen die
# Zahl, wer es gegeben hat, sieht nur der Host.
HEARTS = """
CREATE TABLE IF NOT EXISTS watched_hearts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
session_id TEXT NOT NULL,
user_id INTEGER,
created_at TEXT NOT NULL
);
"""
# Wer am Abend dabei war — kommt aus der Runde am Rad, sobald der Host
# den gezogenen Film einträgt. Daraus rechnen sich die Punkte.
DABEI = """
CREATE TABLE IF NOT EXISTS watched_dabei (
watched_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
PRIMARY KEY (watched_id, user_id)
);
"""
MAX_STERNE = 5
# Manche schreiben eine ganze Rezension — rund 600 Wörter passen hinein
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)
conn.executescript(SCHEMA + RATINGS + NOTES + HEARTS + DABEI)
# Eine Stimme je Person und Film, ein Herz je Person und Kommentar
users.je_person(conn, "ratings", RATINGS, "watched_id")
users.je_person(conn, "watched_hearts", HEARTS, "note_id")
# 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:
conn.execute(
"ALTER TABLE watched ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0"
)
# Ein Satz zur Wertung kam später dazu
spalten = {r["name"] for r in conn.execute("PRAGMA table_info(ratings)")}
if "text" not in spalten:
conn.execute("ALTER TABLE ratings ADD COLUMN text TEXT")
def _jetzt():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def alle(session_id):
"""Zuletzt geschaut zuerst, mit Schnitt und der eigenen Stimme."""
def alle(session_id, host=False):
"""Zuletzt geschaut zuerst, mit Schnitt, eigener Stimme, Kommentaren
und wer dabei war. Die laufende Nummer zählt vom ersten Abend.
Wer wie gewertet und wer ein Herz gegeben hat, sieht nur der Host —
für die Runde bleibt die Wertung anonym, das nimmt Hemmung."""
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, stars, name FROM ratings "
"ORDER BY created_at ASC"
"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()
herzen = conn.execute(
"SELECT h.note_id, h.session_id, h.user_id, u.name FROM watched_hearts h "
"LEFT JOIN users u ON u.id = h.user_id ORDER BY h.created_at ASC"
).fetchall()
anwesend = conn.execute(
"SELECT d.watched_id, u.id, u.name FROM watched_dabei d "
"JOIN users u ON u.id = d.user_id ORDER BY u.name COLLATE NOCASE"
).fetchall()
ich = besitzer(session_id)
herz_je_notiz = {}
for h in herzen:
herz_je_notiz.setdefault(h["note_id"], []).append(h)
dabei_je_film = {}
for d in anwesend:
dabei_je_film.setdefault(d["watched_id"], []).append({"id": d["id"], "name": d["name"]})
def notiz(n):
meine_herzen = herz_je_notiz.get(n["id"], [])
return {
"id": n["id"],
"text": n["text"],
"name": n["name"],
"created_at": n["created_at"],
"mine": gehoert_mir(n, session_id, ich),
"replies": [],
"hearts": len(meine_herzen),
"hearted": any(gehoert_mir(h, session_id, ich) for h in meine_herzen),
"hearted_by": [h["name"] or "Anonym" for h in meine_herzen] if host else [],
}
# 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"]}
{
"id": s["id"],
"stars": s["stars"],
"name": s["name"],
"created_at": s["created_at"],
"mine": gehoert_mir(s, session_id, ich),
}
for s in eigene
],
]
if host
else [],
"dabei": dabei_je_film.get(film["id"], []),
"notes": kommentare.get(film["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),
None,
),
"mine": meine["stars"] if meine else None,
}
)
return out
def juengster():
"""Der zuletzt geschaute Abend — für Marken ohne Dreh."""
with connect() as conn:
row = conn.execute(
"SELECT * FROM watched ORDER BY watched_at DESC, id DESC LIMIT 1"
).fetchone()
return dict(row) if row else None
def von_film(movie_id):
with connect() as conn:
row = conn.execute("SELECT * FROM watched WHERE movie_id = ?", (movie_id,)).fetchone()
return dict(row) if row else None
def markieren(movie):
"""Film als geschaut eintragen. Ein zweiter Klick ändert nichts."""
with connect() as conn:
@@ -110,8 +268,32 @@ def markieren(movie):
return dict(row)
def entfernen(watched_id):
def dabei_setzen(watched_id, user_ids):
"""Die Anwesenheit eines Abends — ersetzt die bisherige Liste."""
with connect() as conn:
conn.execute("DELETE FROM watched_dabei WHERE watched_id = ?", (watched_id,))
conn.executemany(
"INSERT OR IGNORE INTO watched_dabei (watched_id, user_id) VALUES (?, ?)",
[(watched_id, i) for i in user_ids],
)
def _herzen_weg(conn, note_ids):
conn.executemany(
"DELETE FROM watched_hearts WHERE note_id = ?", [(i,) for i in note_ids]
)
def entfernen(watched_id):
"""Samt Wertungen, Kommentaren, Herzen und Anwesenheit."""
with connect() as conn:
notizen = [
r["id"]
for r in conn.execute("SELECT id FROM watched_notes WHERE watched_id = ?", (watched_id,))
]
_herzen_weg(conn, notizen)
conn.execute("DELETE FROM watched_dabei WHERE watched_id = ?", (watched_id,))
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,)
@@ -138,28 +320,25 @@ def sichtbarkeit(watched_id, versteckt):
def bewerten(watched_id, session_id, stars, name=None):
"""Gibt None zurück, wenn der Film gar nicht in der Liste steht."""
"""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,)
).fetchone()
if not da:
return None
# Eine Stimme je Sitzung — eine neue Bewertung ersetzt die alte
ich = besitzer(session_id)
# Eine Stimme je Person — die eigene alte fliegt vorher raus
conn.execute(
f"DELETE FROM ratings WHERE watched_id = ? AND {WEM}",
(watched_id, ich, ich, session_id),
)
conn.execute(
"INSERT INTO ratings "
"(watched_id, session_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",
(
watched_id,
session_id,
stars,
(name or "").strip()[:40] or None,
_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}
@@ -172,6 +351,93 @@ 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)]
_herzen_weg(conn, alle_ids)
conn.executemany(
"DELETE FROM watched_notes WHERE id = ?", [(i,) for i in alle_ids]
)
return len(alle_ids)
def herz(session_id, note_id):
"""Herz setzen oder zurücknehmen. None, wenn es den Kommentar nicht
gibt, sonst ob das Herz jetzt steht."""
ich = besitzer(session_id)
with connect() as conn:
weg = conn.execute(
f"DELETE FROM watched_hearts WHERE note_id = ? AND {WEM}",
(note_id, ich, ich, session_id),
).rowcount
if weg:
return False
da = conn.execute("SELECT 1 FROM watched_notes WHERE id = ?", (note_id,)).fetchone()
if not da:
return None
conn.execute(
"INSERT INTO watched_hearts (note_id, session_id, user_id, created_at) "
"VALUES (?, ?, ?, ?)",
(note_id, session_id, ich, _jetzt()),
)
return True
def ids():
"""movie_ids der geschauten Filme, für die Detailansicht."""
with connect() as conn:

View File

@@ -8,21 +8,24 @@ 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 . import users
from .db import connect
from .users import WEM, besitzer, gehoert_mir
SCHEMA = """
CREATE TABLE IF NOT EXISTS wishlist (
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,
created_at TEXT NOT NULL,
UNIQUE (session_id, movie_id)
created_at TEXT NOT NULL
);
"""
@@ -30,21 +33,39 @@ CREATE TABLE IF NOT EXISTS wishlist (
def init():
with connect() as conn:
conn.executescript(SCHEMA)
# Jeder Film einmal je Person
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(
"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 "
# Genres kommen aus dem Katalog. Ein Film, der nie darin stand,
# hat keine — das Rad behandelt ihn dann als "nicht Horror".
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"
).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", "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
]
@@ -56,10 +77,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"),
@@ -73,19 +95,22 @@ def merken(session_id, movie):
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),
)
]

BIN
backend/horror.db.bak Normal file

Binary file not shown.

3
backend/pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
pythonpath = .

View File

@@ -0,0 +1 @@
pytest==8.4.2

121
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,121 @@
"""Testumgebung: eigene Datenbank, kein Netz.
Die Umgebung steht, bevor app.* importiert wird — config.py liest DB_PATH
beim Import. Die Schlüssel prüft config.py nur auf Vorhandensein.
"""
import os
import tempfile
from pathlib import Path
_ORDNER = Path(tempfile.mkdtemp(prefix="filmabend-tests-"))
os.environ["DB_PATH"] = str(_ORDNER / "test.db")
os.environ["CLIPS_DIR"] = str(_ORDNER / "clips")
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
from app import clips, db, features, main, spenden, suggestions, users, watched, wishlist # noqa: E402
# Zählt, ob der Startup doch einen Sync anstoßen wollte
_SYNC_AUFRUFE = []
async def kein_sync():
_SYNC_AUFRUFE.append(True)
@pytest.fixture(scope="session", autouse=True)
def app_start():
"""Startup einmal je Lauf. last_sync steht vorher, damit main.startup
keinen TMDB-Sync startet; guarded_sync ist zur Sicherheit ersetzt."""
db.init()
with db.connect() as conn:
db.set_meta(conn, "last_sync", "test")
main.guarded_sync = kein_sync
with TestClient(main.app):
yield
@pytest.fixture
def sync_aufrufe():
return _SYNC_AUFRUFE
@pytest.fixture(autouse=True)
def frische_db(app_start):
"""Jeder Test beginnt mit leeren Tabellen und ohne laufende Ziehung."""
db.DB_PATH.unlink(missing_ok=True)
for modul in (db, users, suggestions, watched, clips, wishlist, features, spenden):
modul.init()
main.letzter_dreh.update(ergebnis=None, start=0.0)
def _anmelden(client, name):
"""Namen anlegen, falls nötig, und für diesen Client wählen."""
antwort = client.post("/api/users", json={"name": name})
if antwort.status_code == 200:
user_id = antwort.json()["id"]
else:
runde = client.get("/api/users").json()["items"]
user_id = next(u["id"] for u in runde if u["name"] == name)
return client.post("/api/users/waehlen", json={"user_id": user_id}).json()["mine"]
# Ein Client je Person — jeder hält sein eigenes Cookie. Beide stehen in
# der Runde, denn ohne Namen darf niemand etwas eintragen.
@pytest.fixture
def client():
c = TestClient(main.app)
_anmelden(c, "Anna")
return c
@pytest.fixture
def client2():
c = TestClient(main.app)
_anmelden(c, "Ben")
return c
@pytest.fixture
def zuschauer():
"""Ohne gewählten Namen: darf alles lesen, nichts schreiben."""
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."""
def anlegen(movie_id, title=None):
with db.connect() as conn:
db.upsert(conn, {"id": movie_id, "title": title or f"Film {movie_id}"})
return movie_id
return anlegen
@pytest.fixture
def anmelden():
"""Client wählt einen Namen; legt ihn an, falls es ihn noch nicht gibt."""
return _anmelden

255
backend/tests/test_clips.py Normal file
View File

@@ -0,0 +1,255 @@
"""Clips: Marke setzen, Fenster ziehen, schneiden."""
import httpx
from app import clips, db, main, punkte
def marke(client):
antwort = client.post("/api/clips")
assert antwort.status_code == 200, antwort.text
return antwort.json()
def test_marke_ist_nur_ein_zeitpunkt(client, film):
"""Ohne Dreh und ohne Abend gehört die Marke keinem Film; das Fenster
hat Standardwerte."""
m = marke(client)
assert m["movie_id"] is None
assert (m["vorher"], m["dauer"]) == (clips.VORHER, clips.DAUER)
assert m["mine"] is True and m["geschnitten"] is False
assert m["name"] == "Anna"
def test_marke_haengt_am_gezogenen_film(client, zuschauer, hostmodus, film):
client.post("/api/suggestions", json={"movie_id": film(1)})
hostmodus(zuschauer)
zuschauer.post("/api/spin")
m = marke(client)
assert m["movie_id"] == film(1)
assert m["title"] == "Film 1"
main.letzter_dreh["ergebnis"] = None
zuschauer.delete("/api/suggestions/alle")
def test_ohne_dreh_zaehlt_der_neueste_abend(client, film):
client.post("/api/watched", json={"movie_id": film(1)})
client.post("/api/watched", json={"movie_id": film(2)})
assert marke(client)["movie_id"] == film(2)
def stuecke_mocken(monkeypatch):
async def segmente(von, bis):
# Ein Abend mit Stück vor und nach Mitternacht, ein weiterer am Tag danach
return [
{"start": "2026-09-23T20:00:00Z", "duration": 30},
{"start": "2026-09-24T00:30:00Z", "duration": 30},
{"start": "2026-09-25T19:00:00Z", "duration": 30},
]
monkeypatch.setattr(clips, "_segmente", segmente)
def test_aufnahmen_bekommen_ihren_film(client, zuschauer, hostmodus, film, monkeypatch):
"""Zuweisung schlägt den Abend am gleichen Datum; sonst kein Film."""
stuecke_mocken(monkeypatch)
client.post("/api/watched", json={"movie_id": film(1)})
with db.connect() as conn:
conn.execute("UPDATE watched SET watched_at = '2026-09-23T20:41:00+00:00'")
liste = client.get("/api/aufnahmen").json()["stuecke"]
assert [s["start"] for s in liste] == [
"2026-09-25T19:00:00+00:00",
"2026-09-24T00:30:00+00:00",
"2026-09-23T20:00:00+00:00",
]
# Der Abend vom 23. gilt auch für die Nacht danach
assert liste[2]["film"]["id"] == film(1) and liste[2]["zugewiesen"] is False
assert liste[1]["film"]["id"] == film(1)
assert liste[0]["film"] is None
# Zuweisen darf nur der Host
zuweisung = {"start": "2026-09-25T19:00:00+00:00", "movie_id": film(2)}
assert client.put("/api/aufnahmen/film", json=zuweisung).status_code == 403
hostmodus(zuschauer)
assert zuschauer.put("/api/aufnahmen/film", json=zuweisung).json() == {"zugewiesen": True}
liste = client.get("/api/aufnahmen").json()["stuecke"]
assert liste[0]["film"]["title"] == "Film 2" and liste[0]["zugewiesen"] is True
# Die Zuweisung schlägt den Abend
zuschauer.put("/api/aufnahmen/film", json={"start": liste[2]["start"], "movie_id": film(2)})
assert client.get("/api/aufnahmen").json()["stuecke"][2]["film"]["id"] == film(2)
# Aufheben
zuschauer.put("/api/aufnahmen/film", json={"start": zuweisung["start"], "movie_id": None})
assert client.get("/api/aufnahmen").json()["stuecke"][0]["film"] is None
def test_clip_aus_aufnahme(client, zuschauer, hostmodus, film):
hostmodus(zuschauer)
start = "2026-09-23T19:00:00+00:00"
zuschauer.put("/api/aufnahmen/film", json={"start": start, "movie_id": film(4)})
m = client.post("/api/clips/von-aufnahme", json={"start": start}).json()
assert m["movie_id"] == film(4) and m["title"] == "Film 4"
# Die Marke liegt kurz nach dem Anfang der Aufnahme
assert m["marke_at"] == "2026-09-23T19:01:00+00:00"
# Ohne Film gibt es die Marke trotzdem — unter Sonstige
m = client.post("/api/clips/von-aufnahme", json={"start": "2026-09-25T19:00:00+00:00"}).json()
assert m["movie_id"] is None
def test_taste_c_nimmt_den_film_der_laufenden_aufnahme(client, zuschauer, hostmodus, film, monkeypatch):
from datetime import timedelta
start = clips._iso(clips._jetzt() - timedelta(seconds=100))
async def segmente(von, bis):
return [{"start": start, "duration": 60}]
monkeypatch.setattr(clips, "_segmente", segmente)
# Es läuft eine Aufnahme, aber ohne Film: dann wie bisher der neueste Abend
client.post("/api/watched", json={"movie_id": film(1)})
with db.connect() as conn:
conn.execute("UPDATE watched SET watched_at = '2020-01-01T20:00:00+00:00'")
assert marke(client)["movie_id"] == film(1)
# Zugewiesen: der Film der Aufnahme
hostmodus(zuschauer)
zuschauer.put("/api/aufnahmen/film", json={"start": start, "movie_id": film(3)})
assert marke(client)["movie_id"] == film(3)
def test_alle_sehen_clips_aendern_nur_besitzer_und_host(client, client2, zuschauer, hostmodus):
m = marke(client)
fremd = [c for c in client2.get("/api/clips").json() if c["id"] == m["id"]]
assert len(fremd) == 1 and fremd[0]["mine"] is False
assert client2.patch(f"/api/clips/{m['id']}", json={"titel": "x"}).status_code == 404
assert client2.delete(f"/api/clips/{m['id']}").status_code == 404
hostmodus(zuschauer)
assert zuschauer.patch(f"/api/clips/{m['id']}", json={"titel": "Vom Host"}).json()["titel"] == "Vom Host"
assert zuschauer.get("/api/clips").json()[0]["id"] == m["id"]
def test_fenster_wird_begrenzt_und_macht_schnitt_ungueltig(client, monkeypatch):
m = marke(client)
geaendert = client.patch(f"/api/clips/{m['id']}", json={"vorher": 99999, "dauer": 1, "titel": " Hallo "}).json()
assert (geaendert["vorher"], geaendert["dauer"], geaendert["titel"]) == (clips.MAX_VORHER, clips.MIN_DAUER, "Hallo")
# Das Fenster darf auch nach der Marke liegen — bis vier Stunden weit
assert client.patch(f"/api/clips/{m['id']}", json={"vorher": -60}).json()["vorher"] == -60
assert client.patch(f"/api/clips/{m['id']}", json={"vorher": -99999}).json()["vorher"] == -clips.MAX_VORHER
client.patch(f"/api/clips/{m['id']}", json={"vorher": clips.MAX_VORHER, "dauer": 1})
# Schneiden: der Playback-Server wird nachgestellt
class Antwort:
status_code = 200
def raise_for_status(self):
pass
async def aiter_bytes(self):
yield b"mp4-daten"
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
aufrufe = []
class Client:
def __init__(self, **kw):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
def stream(self, methode, url, params=None):
aufrufe.append(params)
return Antwort()
monkeypatch.setattr(clips.httpx, "AsyncClient", Client)
fertig = client.post(f"/api/clips/{m['id']}/schneiden").json()
assert fertig["geschnitten"] is True
assert aufrufe[0]["path"] == "filmabend" and aufrufe[0]["duration"] == clips.MIN_DAUER
assert (clips.CLIPS_DIR / f"{m['id']}.mp4").read_bytes() == b"mp4-daten"
video = client.get(f"/api/clips/{m['id']}/video")
assert video.status_code == 200 and video.content == b"mp4-daten"
# Neues Fenster: Datei weg, muss neu geschnitten werden
neu = client.patch(f"/api/clips/{m['id']}", json={"dauer": 20}).json()
assert neu["geschnitten"] is False
assert not (clips.CLIPS_DIR / f"{m['id']}.mp4").exists()
assert client.get(f"/api/clips/{m['id']}/video").status_code == 404
def test_schneiden_ohne_aufnahme_meldet_502(client, monkeypatch):
m = marke(client)
class Client:
def __init__(self, **kw):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
def stream(self, *a, **kw):
raise httpx.ConnectError("weg")
monkeypatch.setattr(clips.httpx, "AsyncClient", Client)
assert client.post(f"/api/clips/{m['id']}/schneiden").status_code == 502
def test_loeschen_nimmt_die_datei_mit(client):
m = marke(client)
clips.CLIPS_DIR.mkdir(parents=True, exist_ok=True)
(clips.CLIPS_DIR / f"{m['id']}.mp4").write_bytes(b"x")
from app import db
with db.connect() as conn:
conn.execute("UPDATE clips SET datei = ? WHERE id = ?", (f"{m['id']}.mp4", m["id"]))
assert client.delete(f"/api/clips/{m['id']}").json()["removed"] == 1
assert not (clips.CLIPS_DIR / f"{m['id']}.mp4").exists()
assert client.get("/api/clips").json() == []
def test_zuschauer_darf_keine_marke_setzen(zuschauer):
assert zuschauer.post("/api/clips").status_code == 403
def test_segmente_eines_stuecks():
segmente = [
{"start": "2026-09-23T19:00:00.5Z"},
{"start": "2026-09-23T19:00:30.5Z"},
{"start": "2026-09-23T19:01:00.5Z"},
{"start": "2026-09-23T21:00:00Z"},
]
# Das Stück von 19:00 mit 61 s Dauer: drei Segmente, das von 21:00 nicht
assert clips._im_stueck(segmente, "2026-09-23T19:00:00+00:00", 61.0) == [
"2026-09-23T19:00:00.5Z",
"2026-09-23T19:00:30.5Z",
"2026-09-23T19:01:00.5Z",
]
def test_aufnahme_loescht_nur_der_host(client, zuschauer, hostmodus, film, monkeypatch):
geloescht = []
async def loeschen(start, dauer):
geloescht.append((start, dauer))
return 2
monkeypatch.setattr(clips, "aufnahme_loeschen", loeschen)
start = "2026-09-23T19:00:00+00:00"
assert client.delete("/api/aufnahmen", params={"start": start, "dauer": 60}).status_code == 403
hostmodus(zuschauer)
zuschauer.put("/api/aufnahmen/film", json={"start": start, "movie_id": film(1)})
antwort = zuschauer.delete("/api/aufnahmen", params={"start": start, "dauer": 60})
assert antwort.json() == {"segmente": 2}
assert geloescht == [(start, 60.0)]
# Mit der Aufnahme geht auch die Zuweisung
assert clips.film_der_aufnahme(start) is None

View File

@@ -0,0 +1,74 @@
"""Wünsche: Stimmen, Abhaken, Anmerkungen."""
from app import db
def wunsch(client, text="Dunkles Design"):
liste = client.post("/api/features", json={"text": text}).json()
return next(w for w in liste if w["text"] == text)
def test_leerer_wunsch_wird_abgelehnt(client):
assert client.post("/api/features", json={"text": " "}).status_code == 400
def test_stimme_toggelt(client):
w = wunsch(client)
assert w["mine"] is True
assert w["votes"] == 0
assert w["voted"] is False
assert client.post(f"/api/features/{w['id']}/vote").json() == {"voted": True}
[w] = client.get("/api/features").json()
assert w["votes"] == 1
assert w["voted"] is True
assert client.post(f"/api/features/{w['id']}/vote").json() == {"voted": False}
[w] = client.get("/api/features").json()
assert w["votes"] == 0
assert w["voted"] is False
def test_erledigt_rutscht_ans_ende(client):
erster = wunsch(client, "A")
wunsch(client, "B")
# Mit Stimme steht A vorn
client.post(f"/api/features/{erster['id']}/vote")
assert [w["text"] for w in client.get("/api/features").json()] == ["A", "B"]
antwort = client.patch(f"/api/features/{erster['id']}/done", json={"done": True})
assert antwort.json() == {"ok": True}
liste = client.get("/api/features").json()
assert [w["text"] for w in liste] == ["B", "A"]
assert liste[1]["done_at"] is not None
client.patch(f"/api/features/{erster['id']}/done", json={"done": False})
assert [w["text"] for w in client.get("/api/features").json()] == ["A", "B"]
def test_anmerkungen_anlegen_und_loeschen(client, client2):
w = wunsch(client)
antwort = client.post(f"/api/features/{w['id']}/notes", json={"text": "Mit Kontrast"})
assert antwort.json() == {"ok": True}
[w] = client.get("/api/features").json()
[notiz] = w["notes"]
assert notiz["text"] == "Mit Kontrast"
assert notiz["mine"] is True
[fremd] = client2.get("/api/features").json()
assert fremd["notes"][0]["mine"] is False
assert client.delete(f"/api/feature-notes/{notiz['id']}").json() == {"removed": 1}
assert client.get("/api/features").json()[0]["notes"] == []
def test_loeschen_raeumt_stimmen_und_anmerkungen_mit(client):
w = wunsch(client)
client.post(f"/api/features/{w['id']}/vote")
client.post(f"/api/features/{w['id']}/notes", json={"text": "Anmerkung"})
assert client.delete(f"/api/features/{w['id']}").json() == {"removed": 1}
assert client.get("/api/features").json() == []
with db.connect() as conn:
for tabelle in ("feature_votes", "feature_notes"):
assert conn.execute(f"SELECT COUNT(*) FROM {tabelle}").fetchone()[0] == 0

View File

@@ -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})
return next(f for f in client.get("/api/watched").json() if f["id"] == watched_id)
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["average"] == 5.0 and f["mine"] == 5
# Die Namen an den Wertungen sieht nur der Host
assert f["ratings"] == []
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

View File

@@ -0,0 +1,22 @@
"""Der Info-Reiter: ein Markdown-Text des Hosts, vorbelegt mit den Regeln."""
from app import punkte
def test_vorlage_traegt_die_regeln_aus_dem_code(client):
text = client.get("/api/info").json()["text"]
assert text == punkte.vorlage()
assert f"- Beim Filmabend dabei: **+{punkte.SCHAUEN}**" in text
assert f"- Herz vergeben: **+{punkte.HERZ}**" in text
# Dreieckszahlen: Level 2 ab 1, Level 3 ab 3, Level 4 ab 6
assert "Level 2 ab 1, Level 3 ab 3, Level 4 ab 6" in text
def test_text_schreibt_nur_der_host(client, zuschauer, hostmodus):
assert client.put("/api/info", json={"text": "PayPal: x"}).status_code == 403
hostmodus(zuschauer)
assert zuschauer.put("/api/info", json={"text": " PayPal: x "}).json()["text"] == "PayPal: x"
assert client.get("/api/info").json()["text"] == "PayPal: x"
# Leer gespeichert: wieder die Vorlage
zuschauer.put("/api/info", json={"text": ""})
assert client.get("/api/info").json()["text"] == punkte.vorlage()

146
backend/tests/test_ki.py Normal file
View File

@@ -0,0 +1,146 @@
"""KI-Suche: Antwort des Modells lesen, Titel bei TMDB bestätigen."""
from app import ki, main
def test_parsen_zieht_json_aus_geschwaetz():
text = (
"<think>Der Nutzer mag ruhige Filme.</think>\n"
"Hier meine Vorschläge:\n"
'{"filme": [{"titel": "The Witch", "jahr": 2015, "grund": "Langsam, düster."},'
' {"titel": "", "jahr": 1, "grund": "leer"},'
' {"titel": "Lamb", "jahr": "2021"}]}\nViel Spaß!'
)
assert ki.parsen(text) == [
{"titel": "The Witch", "jahr": 2015, "grund": "Langsam, düster."},
{"titel": "Lamb", "jahr": 2021, "grund": ""},
]
def test_lange_begruendung_endet_am_komma():
kurz = "Wieder rätselt eine Familie um einen Mord, mit Witz."
assert ki.kuerzen(kurz) == kurz
lang = (
"Zwei chaotische Ermittler stolpern durch einen Fall voller Wendungen, "
"schräger Figuren und schlagfertiger Sprüche im Los Angeles der Siebziger."
)
gekuerzt = ki.kuerzen(lang)
assert gekuerzt == "Zwei chaotische Ermittler stolpern durch einen Fall voller Wendungen …"
assert len(gekuerzt) <= ki.MAX_GRUND + 2
# Ohne Komma bricht es am letzten Wort
ohne = "Wort " * 30
assert ki.kuerzen(ohne).endswith("Wort …") and len(ki.kuerzen(ohne)) <= ki.MAX_GRUND + 2
def test_parsen_bei_muell_leer():
assert ki.parsen("Keine Ahnung.") == []
assert ki.parsen('{"filme": "nix"}') == []
assert ki.parsen("{kaputt") == []
def test_prompt_nennt_sammlung_und_gesehenes():
text = ki.prompt("blutig", ["Alien (1979)"], ["Nobody (2021)"])
assert "Gesucht: blutig" in text
assert "- Alien (1979)" in text
assert "- Nobody (2021)" in text
# Ohne Beschreibung soll die Sammlung den Ton angeben
assert "zu dieser Sammlung passt" in ki.prompt("", ["Alien (1979)"], [])
def test_vorgaben_aus_der_leiste():
zeilen = ki.vorgaben(
{"include": ["Horror", "Thriller"], "exclude": ["Komödie"],
"jahr_min": 2010, "note_min": 6.5, "dauer_max": 120, "stimmen_min": None}
)
assert zeilen == [
"Genre: Horror, Thriller",
"Auf keinen Fall: Komödie",
"Ab 2010",
"Mindestens 6.5 von 10 auf TMDB",
"Höchstens 120 Minuten",
]
assert ki.vorgaben({"jahr_min": 1980, "jahr_max": 1989}) == ["Zwischen 1980 und 1989"]
assert ki.vorgaben({}) == []
assert "unbedingt einhalten" in ki.prompt("x", [], [], {"include": ["Horror"]})
assert "Nenne genau 30 Filme" in ki.system(30)
assert "38 Originaltitel" in ki.system(30)
assert '{"merkmale"' in ki.system(12)
def test_titel_muss_zum_treffer_passen():
assert ki._aehnlich("Clue", {"title": "Alle Mörder sind schon da", "original_title": "Clue"})
assert ki._aehnlich("The Hateful 8", {"title": "The Hateful 8", "original_title": "The Hateful Eight"})
assert ki._aehnlich("Murder by Death", {"title": "Eine Leiche zum Dessert", "original_title": "Murder by Death"})
assert not ki._aehnlich(
"The Afterparty",
{"title": "Fate: The Winx Saga - Die Afterparty", "original_title": "Fate: The Winx Saga - The Afterparty"},
)
def test_route_fragt_und_loest_auf(client, film, monkeypatch):
aufrufe = {}
async def fragen(beschreibung, sammlung, gesehen, filter_, anzahl):
aufrufe.update(
beschreibung=beschreibung, sammlung=sammlung, gesehen=gesehen, filter=filter_, anzahl=anzahl
)
return [{"titel": "The Witch", "jahr": 2015, "grund": "Passt."}]
async def aufloesen(vorschlaege, filter_, ausser=()):
aufrufe["ausser"] = set(ausser)
return [{"id": 310131, "title": "The Witch", "grund": v["grund"]} for v in vorschlaege]
monkeypatch.setattr(main.ki, "fragen", fragen)
monkeypatch.setattr(main.ki, "aufloesen", aufloesen)
# Ein Film in der Sammlung, einer gesehen
client.post("/api/wishlist", json={"movie_id": film(1, "Alien")})
client.post("/api/watched", json={"movie_id": film(2)})
antwort = client.post(
"/api/ki-suche",
json={"beschreibung": " Folk-Horror ", "mit_sammlung": True,
"include": "Horror,Mystery", "jahr_min": 2010},
)
assert antwort.status_code == 200, antwort.text
assert antwort.json()[0]["grund"] == "Passt."
assert aufrufe["beschreibung"] == "Folk-Horror"
assert aufrufe["sammlung"] == ["Alien"]
assert len(aufrufe["gesehen"]) == 1
assert aufrufe["ausser"] == {film(2)}
assert aufrufe["filter"]["include"] == ["Horror", "Mystery"]
assert aufrufe["filter"]["jahr_min"] == 2010
assert aufrufe["filter"]["note_max"] is None
assert aufrufe["anzahl"] == 12
client.post("/api/ki-suche", json={"beschreibung": "x", "limit": 40})
assert aufrufe["anzahl"] == 40
# Gesehene dürfen auch drinbleiben — dann zeigt die Karte es an
client.post("/api/ki-suche", json={"beschreibung": "x", "ohne_gesehene": False})
assert aufrufe["ausser"] == set()
def test_nur_filter_reichen_als_frage(client, monkeypatch):
async def fragen(beschreibung, sammlung, gesehen, filter_, anzahl):
return []
async def aufloesen(vorschlaege, filter_, ausser=()):
return []
monkeypatch.setattr(main.ki, "fragen", fragen)
monkeypatch.setattr(main.ki, "aufloesen", aufloesen)
assert client.post("/api/ki-suche", json={"include": "Horror"}).status_code == 200
def test_route_ohne_beschreibung_und_sammlung(client):
assert client.post("/api/ki-suche", json={"beschreibung": " "}).status_code == 400
def test_route_wenn_die_ki_schweigt(client, monkeypatch):
import httpx
async def fragen(*_):
raise httpx.ConnectError("weg")
monkeypatch.setattr(main.ki, "fragen", fragen)
assert client.post("/api/ki-suche", json={"beschreibung": "x"}).status_code == 502

View File

@@ -0,0 +1,20 @@
"""Ob ein Film leihbar ist, kommt aus den Anbieterdaten von TMDB."""
from app import db, tmdb
def test_leihbar_aus_anbieterdaten():
de = lambda eintrag: {"watch/providers": {"results": {"DE": eintrag}}}
assert tmdb.pick_rentable(de({"rent": [{"provider_name": "Amazon Video"}]})) == 1
assert tmdb.pick_rentable(de({"flatrate": [{"provider_name": "Netflix"}]})) == 0
assert tmdb.pick_rentable({"watch/providers": {"results": {}}}) == 0
# Ohne Anbieterdaten bleibt es offen — nicht fälschlich "nicht leihbar"
assert tmdb.pick_rentable({}) is None
def test_leihbarkeit_kommt_mit_dem_film(client):
with db.connect() as conn:
db.upsert(conn, {"id": 7, "title": "Abo-Film", "rentable": 0})
db.upsert(conn, {"id": 8, "title": "Unbekannt"})
assert client.get("/api/movies/7").json()["rentable"] == 0
assert client.get("/api/movies/8").json()["rentable"] is None

222
backend/tests/test_login.py Normal file
View File

@@ -0,0 +1,222 @@
"""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 hostfilm_setzen(client, hostmodus, movie_id):
"""Ein Host setzt den Host-Film und gibt die Rechte wieder ab."""
hostmodus(client)
client.post("/api/host/film", json={"movie_id": movie_id})
client.post("/api/host", json={"an": False})
def test_ohne_hostfilm_reicht_die_tastenkombination(zuschauer):
assert zuschauer.post("/api/host", json={"an": True}).json()["host"] is True
def test_mit_hostfilm_kein_hostmodus_ohne_film(zuschauer, hostmodus, film):
hostfilm_setzen(zuschauer, hostmodus, film(1))
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, hostmodus, film):
hostfilm_setzen(zuschauer, hostmodus, film(1))
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, film):
hostfilm_setzen(zuschauer, hostmodus, film(1))
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
def test_hostfilm_oeffnet_den_hostmodus(zuschauer, client, hostmodus, film):
# Nur der Host darf den Host-Film setzen oder sehen
assert client.post("/api/host/film", json={"movie_id": film(1)}).status_code == 403
assert client.get("/api/host/film").status_code == 403
# Ob es einen gibt, sieht jeder — welcher, nicht
assert client.get("/api/host").json() == {"host": False, "film": False}
hostmodus(zuschauer)
assert zuschauer.post("/api/host/film", json={"movie_id": film(1)}).json()["gesetzt"] is True
assert zuschauer.get("/api/host/film").json()["film"]["id"] == film(1)
assert client.get("/api/host").json() == {"host": False, "film": True}
# Falscher Film: nichts; richtiger Film: Host
assert client.post("/api/host", json={"an": True, "movie_id": film(2)}).status_code == 403
assert client.get("/api/host").json()["host"] is False
assert client.post("/api/host", json={"an": True, "movie_id": film(1)}).json()["host"] is True
assert client.get("/api/host").json()["host"] is True
# Aufheben: ohne Host-Film reicht die Tastenkombination
zuschauer.post("/api/host/film", json={"movie_id": None})
assert zuschauer.get("/api/host/film").json()["film"] is None
client.post("/api/host", json={"an": False})
assert client.post("/api/host", json={"an": True}).json()["host"] is True

View File

@@ -0,0 +1,58 @@
"""Personensuche: Schauspieler und Regisseure, dazu ihre Filme."""
from app import main, search
def test_credits_werden_nach_rolle_getrennt():
daten = {
"cast": [
{"id": 1, "title": "Klein", "poster_path": "/a.jpg", "popularity": 2, "genre_ids": [27], "vote_count": 80},
{"id": 2, "title": "Groß", "poster_path": "/b.jpg", "popularity": 9, "release_date": "2001-05-01", "vote_count": 900},
{"id": 3, "title": "Ohne Poster", "poster_path": None, "popularity": 50, "vote_count": 500},
{"id": 2, "title": "Groß nochmal", "poster_path": "/b.jpg", "popularity": 9, "vote_count": 900},
{"id": 6, "title": "Cameo in Doku", "poster_path": "/e.jpg", "popularity": 99, "vote_count": 3},
],
"crew": [
{"id": 4, "title": "Regiearbeit", "job": "Director", "poster_path": "/c.jpg", "popularity": 1, "vote_count": 60},
{"id": 5, "title": "Nur Drehbuch", "job": "Writer", "poster_path": "/d.jpg", "popularity": 1, "vote_count": 60},
],
}
rollen = search.credits_aufbereiten(daten)
# Auch der Cameo bleibt — was raus soll, entscheidet die Filterleiste
assert [f["title"] for f in rollen["gespielt"]] == ["Cameo in Doku", "Groß", "Klein"]
assert rollen["gespielt"][1]["year"] == 2001
assert rollen["gespielt"][2]["genres"] == ["Horror"]
assert [f["title"] for f in rollen["regie"]] == ["Regiearbeit"]
def test_filmografie_kommt_aus_dem_speicher(client):
import asyncio
import time
search.PERSONEN_CACHE[99] = (time.monotonic(), {"gespielt": [{"id": 1}], "regie": []})
# Ohne Netz — käme der Abruf durch, flöge hier ein Fehler
assert asyncio.run(search.person_filme(99))["gespielt"] == [{"id": 1}]
assert client.get("/api/personen/99/filme").json()["gespielt"] == [{"id": 1}]
del search.PERSONEN_CACHE[99]
def test_credits_route(client, monkeypatch):
async def credits(movie_id):
return {"regie": [{"id": 1, "name": "R", "fach": "Regie", "profile_path": None}], "cast": []}
monkeypatch.setattr(main.search, "credits", credits)
assert client.get("/api/movies/5/credits").json()["regie"][0]["fach"] == "Regie"
def test_routen_reichen_durch(client, monkeypatch):
async def personen(query):
return [{"id": 7, "name": query, "fach": "Regie", "profile_path": None, "bekannt_fuer": []}]
async def person_filme(person_id):
return {"gespielt": [], "regie": [{"id": person_id, "title": "x"}]}
monkeypatch.setattr(main.search, "personen", personen)
monkeypatch.setattr(main.search, "person_filme", person_filme)
assert client.get("/api/personen?q=D").json() == []
assert client.get("/api/personen?q=Duvivier").json()[0]["name"] == "Duvivier"
assert client.get("/api/personen/7/filme").json()["regie"][0]["id"] == 7

View File

@@ -0,0 +1,106 @@
"""Erfahrung und Level: berechnet aus dem Bestand, nicht gebucht."""
from app import punkte
def abend(client, film, movie_id=1):
return client.post("/api/watched", json={"movie_id": film(movie_id)}).json()["id"]
def stand(client, name):
return next(u for u in client.get("/api/users").json()["items"] if u["name"] == name)
def test_level_nach_dreieckszahlen():
assert punkte.level(0) == 1
assert punkte.level(1) == 2
assert punkte.level(2) == 2
assert punkte.level(3) == 3
assert punkte.level(20) == 6
assert punkte.level(21) == 7
assert punkte.level(1081) == 47
assert punkte.stand(21) == {"punkte": 21, "level": 7, "von": 21, "bis": 28}
def test_punkte_kommen_und_gehen_mit_dem_bestand(client, client2, film):
watched_id = abend(client, film)
assert stand(client, "Anna")["punkte"] == 0
client.post(f"/api/watched/{watched_id}/rating", json={"stars": 4})
assert stand(client, "Anna")["punkte"] == punkte.BEWERTEN
oben = client.post(f"/api/watched/{watched_id}/notes", json={"text": "gut"}).json()["id"]
client.post(f"/api/watched/{watched_id}/notes", json={"text": "nochmal"})
# Zwei Kommentare, einer zählt
assert stand(client, "Anna")["punkte"] == punkte.BEWERTEN + punkte.KOMMENTAR
client2.post(f"/api/watched/{watched_id}/notes", json={"text": "finde ich nicht", "parent_id": oben})
client2.post("/api/watched/hearts", json={"note_id": oben})
assert stand(client2, "Ben")["punkte"] == punkte.ANTWORT + punkte.HERZ
# Wertung weg, Punkte weg — nichts wird gebucht
client.post(f"/api/watched/{watched_id}/rating", json={"stars": 4})
assert stand(client, "Anna")["level"] == punkte.level(punkte.BEWERTEN + punkte.KOMMENTAR)
client.delete(f"/api/watched/{watched_id}")
assert stand(client, "Anna")["punkte"] == 0
assert stand(client2, "Ben")["punkte"] == 0
def test_anwesenheit_kommt_vom_rad(client, client2, zuschauer, hostmodus, film):
"""Wer vorschlägt oder sich einträgt, war dabei — sobald der Host den
gezogenen Film einträgt."""
from app import main
client.post("/api/suggestions", json={"movie_id": film(1)})
assert client2.post("/api/dabei").json()["dabei"] is True
liste = client2.get("/api/suggestions").json()
assert liste["dabei"] == ["Ben"] and liste["dabei_mine"] is True
hostmodus(zuschauer)
assert zuschauer.post("/api/spin").json()["started"] is True
main.letzter_dreh["start"] = 0.0 # Animation vorbei
watched_id = zuschauer.post("/api/watched", json={"movie_id": film(1)}).json()["id"]
f = next(x for x in client.get("/api/watched").json() if x["id"] == watched_id)
assert sorted(d["name"] for d in f["dabei"]) == ["Anna", "Ben"]
assert stand(client, "Anna")["punkte"] == punkte.SCHAUEN
assert stand(client, "Ben")["level"] == punkte.level(punkte.SCHAUEN)
# Der Host berichtigt: Ben kam doch nicht
anna = stand(client, "Anna")["id"]
zuschauer.post(f"/api/watched/{watched_id}/dabei", json={"user_ids": [anna]})
f = next(x for x in client.get("/api/watched").json() if x["id"] == watched_id)
assert [d["name"] for d in f["dabei"]] == ["Anna"]
# Reset leert auch die Eingetragenen
zuschauer.delete("/api/suggestions/alle")
assert client2.get("/api/suggestions").json()["dabei"] == []
assert client2.post("/api/dabei").json()["dabei"] is True
assert client2.post("/api/dabei").json()["dabei"] is False
def test_wertungen_und_herzen_sieht_nur_der_host(client, client2, zuschauer, hostmodus, film):
watched_id = abend(client, film)
client.post(f"/api/watched/{watched_id}/rating", json={"stars": 5})
note_id = client.post(f"/api/watched/{watched_id}/notes", json={"text": "x"}).json()["id"]
client2.post("/api/watched/hearts", json={"note_id": note_id})
def film_von(c):
return next(x for x in c.get("/api/watched").json() if x["id"] == watched_id)
# Ben sieht Schnitt und Herzzahl, aber keine Namen
f = film_von(client2)
assert f["ratings"] == [] and f["average"] == 5.0 and f["count"] == 1
assert f["notes"][0]["hearts"] == 1 and f["notes"][0]["hearted"] is True
assert f["notes"][0]["hearted_by"] == []
# Anna sieht ihre eigenen Sterne trotzdem
assert film_von(client)["mine"] == 5
# Der Host sieht alles
hostmodus(zuschauer)
f = film_von(zuschauer)
assert f["ratings"][0]["name"] == "Anna"
assert f["notes"][0]["hearted_by"] == ["Ben"]
# Herz zurück, Zähler runter; Zuschauer ohne Namen darf kein Herz geben
assert client2.post("/api/watched/hearts", json={"note_id": note_id}).json()["hearted"] is False
assert film_von(client)["notes"][0]["hearts"] == 0
assert client.post("/api/watched/hearts", json={"note_id": 999}).status_code == 404

View File

@@ -0,0 +1,27 @@
"""Der Spendenbaum: ein Blatt je Person, nur der Host pflegt ihn."""
def test_ein_blatt_je_person_nur_vom_host(client, zuschauer, hostmodus, anmelden):
anmelden(client, "Anna")
anna = client.get("/api/users").json()["mine"]["id"]
assert client.get("/api/spenden").json() == []
assert client.post("/api/spenden", json={"user_id": anna}).status_code == 403
hostmodus(zuschauer)
blaetter = zuschauer.post("/api/spenden", json={"user_id": anna}).json()
assert [b["name"] for b in blaetter] == ["Anna"]
# Ein zweites Mal: immer noch ein Blatt
assert len(zuschauer.post("/api/spenden", json={"user_id": anna}).json()) == 1
assert client.get("/api/spenden").json()[0]["name"] == "Anna"
assert client.delete(f"/api/spenden/{anna}").status_code == 403
assert zuschauer.delete(f"/api/spenden/{anna}").json() == []
def test_kontostand_setzt_nur_der_host(client, zuschauer, hostmodus):
assert client.get("/api/kontostand").json() == {"betrag": None}
assert client.put("/api/kontostand", json={"betrag": 12.5}).status_code == 403
hostmodus(zuschauer)
assert zuschauer.put("/api/kontostand", json={"betrag": 12.5}).json() == {"betrag": 12.5}
assert client.get("/api/kontostand").json() == {"betrag": 12.5}
assert zuschauer.put("/api/kontostand", json={"betrag": None}).json() == {"betrag": None}

View File

@@ -0,0 +1,6 @@
"""Der Startup läuft im Test ohne TMDB-Sync."""
def test_startup_startet_keinen_sync(client, sync_aufrufe):
assert client.get("/api/health").json() == {"ok": True}
assert sync_aufrufe == []

View File

@@ -0,0 +1,94 @@
"""Die Ziehung: Streifen, Stopps, Gewinner."""
from app import suggestions
from app.suggestions import BRUCH_DAUER, DREH_DAUER, STREIFEN_LAENGE, STREIFEN_ZIEL
def vorschlagen(client, movie_id):
antwort = client.post("/api/suggestions", json={"movie_id": movie_id})
assert antwort.status_code == 200
return antwort.json()
def test_zweiter_vorschlag_ersetzt_den_ersten(client, film):
vorschlagen(client, film(1))
neu = vorschlagen(client, film(2))
liste = client.get("/api/suggestions").json()
assert [e["movie_id"] for e in liste["items"]] == [2]
assert liste["mine"] == neu["id"]
def test_dreh_liefert_streifen_und_gewinner(client, client2, film):
vorschlagen(client, film(1))
vorschlagen(client2, film(2))
ergebnis = client.post("/api/spin").json()
assert ergebnis["started"] is True
assert [k["movie_id"] for k in ergebnis["karten"]] == [1, 2]
assert len(ergebnis["streifen"]) == STREIFEN_LAENGE
assert set(ergebnis["streifen"]) <= {0, 1}
stopps = ergebnis["stopps"]
assert len(stopps) in (1, 2)
assert all(set(s) == {"position", "versatz"} for s in stopps)
assert stopps[0]["position"] == STREIFEN_ZIEL
assert ergebnis["gewinner"] == ergebnis["karten"][ergebnis["index"]]
def test_ziehung_haelt_auf_dem_gewinner(client, client2, film):
vorschlagen(client, film(1))
vorschlagen(client2, film(2))
stopp_anzahlen = set()
for _ in range(60):
e = suggestions.ziehen()
stopps = e["stopps"]
stopp_anzahlen.add(len(stopps))
# Der letzte Halt zeigt den Gewinner
assert e["streifen"][stopps[-1]["position"]] == e["index"]
assert all(-0.5 < s["versatz"] < 0.5 for s in stopps)
if len(stopps) == 2:
# Bruch: ein paar Poster zurück, der erste Halt zeigt einen anderen
assert 1 <= stopps[0]["position"] - stopps[1]["position"] <= 4
assert e["streifen"][stopps[0]["position"]] != e["index"]
assert e["dauer"] == DREH_DAUER + BRUCH_DAUER
else:
assert e["dauer"] == DREH_DAUER
# Beide Fälle kamen vor
assert stopp_anzahlen == {1, 2}
def test_zweiter_dreh_waehrend_laufender_ziehung(client, film):
vorschlagen(client, film(1))
assert client.post("/api/spin").json()["started"] is True
assert client.post("/api/spin").json()["started"] is False
stand = client.get("/api/spin").json()
assert stand["running"] is True
assert stand["elapsed"] >= 0
assert stand["result"]["gewinner"]["movie_id"] == 1
def test_ohne_vorschlaege_kein_dreh(client):
assert client.post("/api/spin").status_code == 400
assert client.get("/api/spin").json() == {"running": False, "result": None}
def test_gesehene_filme_gewinnen_nicht(client, client2, film):
vorschlagen(client, film(1))
vorschlagen(client2, film(2))
client.post("/api/watched", json={"movie_id": 1})
for _ in range(50):
assert suggestions.ziehen()["gewinner"]["movie_id"] == 2
def test_runde_zuruecksetzen(client, film):
vorschlagen(client, film(1))
client.post("/api/spin")
assert client.delete("/api/suggestions/alle").json() == {"removed": 1}
assert client.get("/api/spin").json()["running"] is False
assert client.get("/api/suggestions").json()["items"] == []

View File

@@ -0,0 +1,68 @@
"""Besitz: Einträge hängen am Namen, ohne Namen an der Sitzung."""
def test_gleicher_name_in_anderem_browser_sieht_eigene_eintraege(
client, client2, anmelden, film
):
anmelden(client, "Anna")
vorschlag = client.post("/api/suggestions", json={"movie_id": film(1)}).json()
client.post("/api/wishlist", json={"movie_id": film(2)})
client.post("/api/features", json={"text": "Dunkles Design"})
# Anna am zweiten Gerät
anmelden(client2, "Anna")
assert client2.get("/api/suggestions").json()["mine"] == vorschlag["id"]
assert client2.get("/api/wishlist").json()[0]["mine"] is True
assert client2.get("/api/features").json()[0]["mine"] is True
# Ben nicht
anmelden(client2, "Ben")
assert client2.get("/api/suggestions").json()["mine"] is None
assert client2.get("/api/wishlist").json()[0]["mine"] is False
assert client2.get("/api/features").json()[0]["mine"] is False
def test_namenswechsel_laesst_alte_eintraege_beim_alten_namen(client, anmelden, film):
anmelden(client, "Anna")
als_anna = client.post("/api/suggestions", json={"movie_id": film(1)}).json()
assert als_anna["name"] == "Anna"
anmelden(client, "Ben")
assert client.get("/api/suggestions").json()["mine"] is None
# Bens Vorschlag ersetzt Annas nicht — einer je Name, nicht je Browser
als_ben = client.post("/api/suggestions", json={"movie_id": film(2)}).json()
liste = client.get("/api/suggestions").json()
assert [e["name"] for e in liste["items"]] == ["Anna", "Ben"]
assert liste["mine"] == als_ben["id"]
anmelden(client, "Anna")
assert client.get("/api/suggestions").json()["mine"] == als_anna["id"]
def test_abwaehlen_macht_wieder_zum_zuschauer(client, film):
client.post("/api/suggestions", json={"movie_id": film(1)})
client.post("/api/users/waehlen", json={"user_id": None})
assert client.get("/api/users").json()["mine"] is None
# Lesen geht weiter, Schreiben nicht mehr
assert client.get("/api/suggestions").json()["mine"] is None
assert client.post("/api/suggestions", json={"movie_id": film(2)}).status_code == 403
def test_online_ist_wer_eine_seite_offen_hat(client, anmelden):
from app import events
anmelden(client, "Anna")
sid = client.cookies.get("filmabend_sid")
ich = client.get("/api/users").json()["mine"]["id"]
def online():
return {u["id"]: u["online"] for u in client.get("/api/users").json()["items"]}
assert online()[ich] is False
queue = events.anmelden(sid)
assert online()[ich] is True
# Namenswechsel im selben Browser: der alte Name ist offline
anmelden(client, "Ben")
assert online()[ich] is False
events.abmelden(queue)
assert True not in online().values()

View File

@@ -0,0 +1,69 @@
"""Wertungen: Sterne, Text, Schnitt."""
def gesehen(client, film, movie_id=1):
"""Film anlegen und als gesehen eintragen. Gibt die watched-id zurück."""
film(movie_id)
return client.post("/api/watched", json={"movie_id": movie_id}).json()["id"]
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
# Wer wie gewertet hat, sieht nur der Host
assert eintrag["ratings"] == []
assert "mine_text" not in eintrag
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
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
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()
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):
wid = gesehen(client, film)
client.post(f"/api/watched/{wid}/rating", json={"stars": 4})
[eintrag] = client2.get("/api/watched").json()
assert eintrag["mine"] is None
assert eintrag["count"] == 1
assert eintrag["ratings"] == []
assert eintrag["count"] == 1
client2.post(f"/api/watched/{wid}/rating", json={"stars": 2})
[eintrag] = client2.get("/api/watched").json()
assert eintrag["average"] == 3.0
assert eintrag["count"] == 2
assert eintrag["mine"] == 2

View File

@@ -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

View File

@@ -0,0 +1,74 @@
"""Der Zuschauer: kein Name gewählt, also nur mitlesen.
Gedacht für alle, die noch nicht in der Runde stehen. Sie sollen den
Katalog, das Rad und die Wertungen der anderen sehen, aber nichts
eintragen — sonst hinge ihr Kram allein am Cookie.
"""
import pytest
# Jede Zeile: wie die Anfrage aussieht, wenn ein Zuschauer sie stellt
SCHREIBEN = [
("post", "/api/suggestions", {"movie_id": 1}),
("delete", "/api/suggestions", None),
("post", "/api/wishlist", {"movie_id": 1}),
("delete", "/api/wishlist/1", None),
("post", "/api/features", {"text": "Dunkles Design"}),
]
@pytest.mark.parametrize("methode,pfad,daten", SCHREIBEN)
def test_zuschauer_darf_nicht_schreiben(zuschauer, film, methode, pfad, daten):
film(1)
# DELETE nimmt bei TestClient keinen json-Parameter
anfrage = getattr(zuschauer, methode)
antwort = anfrage(pfad, json=daten) if daten is not None else anfrage(pfad)
assert antwort.status_code == 403
def test_zuschauer_darf_nicht_bewerten(zuschauer, client, film):
eintrag = client.post("/api/watched", json={"movie_id": film(1)}).json()
antwort = zuschauer.post(
f"/api/watched/{eintrag['id']}/rating", json={"stars": 5, "text": "stark"}
)
assert antwort.status_code == 403
assert zuschauer.get("/api/watched").json()[0]["count"] == 0
def test_zuschauer_darf_nicht_abstimmen_oder_ergaenzen(zuschauer, client):
wunsch = client.post("/api/features", json={"text": "Dunkles Design"}).json()[0]
assert zuschauer.post(f"/api/features/{wunsch['id']}/vote").status_code == 403
assert (
zuschauer.post(
f"/api/features/{wunsch['id']}/notes", json={"text": "dazu"}
).status_code
== 403
)
assert zuschauer.get("/api/features").json()[0]["votes"] == 0
def test_zuschauer_darf_alles_lesen(zuschauer, client, film):
client.post("/api/suggestions", json={"movie_id": film(1)})
client.post("/api/wishlist", json={"movie_id": film(2)})
client.post("/api/features", json={"text": "Dunkles Design"})
client.post("/api/watched", json={"movie_id": film(3)})
for pfad in ("/api/movies", "/api/suggestions", "/api/wishlist",
"/api/features", "/api/watched", "/api/users", "/api/spin"):
assert zuschauer.get(pfad).status_code == 200
# Fremdes gehört ihm nicht
assert zuschauer.get("/api/suggestions").json()["mine"] is None
assert zuschauer.get("/api/wishlist").json()[0]["mine"] is False
assert zuschauer.get("/api/features").json()[0]["mine"] is False
def test_host_kommt_ohne_namen_an_seine_knoepfe(zuschauer, client, film):
"""Den Hostmodus kennt nur der Browser — seine Routen bleiben offen."""
client.post("/api/suggestions", json={"movie_id": film(1)})
eintrag = zuschauer.post("/api/watched", json={"movie_id": film(2)}).json()
assert zuschauer.patch(
f"/api/watched/{eintrag['id']}", json={"hidden": True}
).status_code == 200
assert zuschauer.post("/api/spin").json()["started"] is True
assert zuschauer.delete("/api/suggestions/alle").status_code == 200

View File

@@ -18,9 +18,14 @@ services:
- web
volumes:
- ./data:/data
# Geschnittene Clips überleben den Neubau des Containers
- ./clips:/data/clips
environment:
DB_PATH: /data/horror.db
CLIPS_DIR: /data/clips
MEDIAMTX_URL: http://mediamtx:8890
MEDIAMTX_PLAYBACK_URL: http://mediamtx:9996
MEDIAMTX_API_URL: http://mediamtx:9997
depends_on:
- mediamtx
labels:
@@ -41,6 +46,8 @@ services:
MTX_AUTHINTERNALUSERS_0_PASS: ${MTX_SENDEPASSWORT}
volumes:
- ./mediamtx/mediamtx.yml:/mediamtx.yml:ro
# Die Aufnahmen der Abende — bleiben über Neustarts erhalten
- ./aufnahmen:/recordings
# RTMP nach außen für OBS; HLS bleibt intern und läuft über die App
ports:
- "1935:1935"

View File

@@ -516,9 +516,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -620,9 +617,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -637,9 +631,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -654,9 +645,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -671,9 +659,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -688,9 +673,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -705,9 +687,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -722,9 +701,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -739,9 +715,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -756,9 +729,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -773,9 +743,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -790,9 +757,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -807,9 +771,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -824,9 +785,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,661 @@
<script setup>
// Eine Doppelseite im Buch: ein Abend, ein Film. Links das Cover mit Titel,
// Datum, dem Urteil der Runde und den Sternen — die Momentaufnahme nach dem
// Abspann. Rechts das Gespräch danach: Rezensionen und Kommentare, mit
// eigenem Scrollen, das Cover bleibt stehen. Auf dem Handy liegen die
// Hälften untereinander.
import { computed, ref } from 'vue'
import { posterUrl } from '../images'
import { rendern } from '../markdown'
import Kommentar from './Kommentar.vue'
import MarkdownFeld from './MarkdownFeld.vue'
import PersonName from './PersonName.vue'
const props = defineProps({
item: { type: Object, required: true },
host: Boolean,
// Die Runde — der Host hakt darin ab, wer dabei war
runde: { type: Array, default: () => [] },
angemeldet: Boolean,
})
const emit = defineEmits(['open'])
const STERNE = [1, 2, 3, 4, 5]
const fehler = ref('')
const cover = computed(() =>
props.item.poster_path ? posterUrl(props.item.poster_path, 'w780') : null
)
function datum(iso) {
return new Date(iso).toLocaleDateString('de-DE', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
async function oeffnen() {
const resp = await fetch(`/api/search/${props.item.movie_id}`)
if (resp.ok) emit('open', await resp.json())
}
// ── Links: die Momentaufnahme ──────────────────────────────────────────
const schwebt = ref(0)
function gefuellt(stern) {
return stern <= (schwebt.value || props.item.mine || 0)
}
async function bewerten(sterne) {
try {
await fetch(`/api/watched/${props.item.id}/rating`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stars: sterne }),
})
} catch {
fehler.value = 'Bewerten hat nicht geklappt.'
}
}
// Die eigene zuerst
const wertungen = computed(() =>
[...props.item.ratings].sort((a, b) => b.mine - a.mine)
)
async function wertungEntfernen(wertung) {
await fetch(`/api/watched/rating/${wertung.id}`, { method: 'DELETE' })
}
// ── Rechts: Schreiben ──────────────────────────────────────────────────
// Unter der Diskussion klappt auf Klick ein Feld auf; Absenden klappt es zu
const schreibt = ref(false)
const kommentarEntwurf = ref('')
// ── Rechts: die Diskussion ─────────────────────────────────────────────
async function kommentieren() {
const text = kommentarEntwurf.value.trim()
if (!text) return
const resp = await fetch(`/api/watched/${props.item.id}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
})
if (resp.ok) {
kommentarEntwurf.value = ''
schreibt.value = false
} else fehler.value = 'Schreiben hat nicht geklappt.'
}
function zaehlen(liste) {
return liste.reduce((n, k) => n + 1 + zaehlen(k.replies), 0)
}
const anzahl = computed(() => zaehlen(props.item.notes))
// ── Host ───────────────────────────────────────────────────────────────
function alsFeld(iso) {
const d = new Date(iso)
const zwei = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${zwei(d.getMonth() + 1)}-${zwei(d.getDate())}`
}
async function datumSetzen(tag) {
if (!tag) return
await fetch(`/api/watched/${props.item.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ watched_at: tag }),
})
}
async function sichtbarkeit() {
await fetch(`/api/watched/${props.item.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hidden: !props.item.hidden }),
})
}
async function dabeiUmschalten(userId) {
const ids = props.item.dabei.map((d) => d.id)
const neu = ids.includes(userId) ? ids.filter((i) => i !== userId) : [...ids, userId]
await fetch(`/api/watched/${props.item.id}/dabei`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_ids: neu }),
})
}
async function entfernen() {
await fetch(`/api/watched/${props.item.id}`, { method: 'DELETE' })
}
</script>
<template>
<article class="doppelseite" :class="{ versteckt: item.hidden }">
<!-- ── Linke Seite: das Cover ─────────────────────────────────── -->
<section class="links">
<div class="cover" :style="cover ? { backgroundImage: `url(${cover})` } : null"></div>
<div class="momentaufnahme">
<header>
<p class="wann">
Abend {{ item.nummer }} · {{ datum(item.watched_at) }}
<span v-if="item.hidden" class="marke">ausgeblendet</span>
</p>
<h2 @click="oeffnen">{{ item.title }}</h2>
<p class="meta">
<span v-if="item.year">{{ item.year }}</span>
<span v-if="item.runtime">{{ item.runtime }} Min.</span>
<span v-if="item.genres?.length">{{ item.genres.join(' · ') }}</span>
</p>
<!-- Eine Zeile: Schnitt, Stimmenzahl in Klammern, dann die
eigenen Sterne zum Klicken -->
<p class="urteil">
<template v-if="item.count">
<span class="wert">{{ item.average.toFixed(1) }}</span>
<span class="von" :title="`${item.count} ${item.count === 1 ? 'Stimme' : 'Stimmen'}`">
({{ item.count }})
</span>
</template>
<span v-else-if="!angemeldet" class="von">Noch keine Wertung</span>
<span v-if="angemeldet" class="wertung" @mouseleave="schwebt = 0">
<button
v-for="s in STERNE"
:key="s"
type="button"
class="stern"
:class="{ an: gefuellt(s) }"
:aria-label="`${s} von 5`"
:title="item.mine ? 'Deine Wertung' : 'Bewerten'"
@mouseenter="schwebt = s"
@click="bewerten(s)"
>
★
</button>
</span>
</p>
</header>
<p v-if="fehler" class="fehler">{{ fehler }}</p>
<!-- Nur in der Hostansicht: in der Gastansicht bleibt die Wertung
anonym, auch wenn die Sitzung freigeschaltet ist -->
<ul v-if="host && wertungen.length" class="stimmen">
<li v-for="r in wertungen" :key="r.id">
<span class="punkte">{{ '★'.repeat(r.stars) }}</span>
<span class="wer" :class="{ eigen: r.mine }"><PersonName :name="r.name" /></span>
<button
v-if="host"
type="button"
class="weg"
title="Wertung entfernen"
@click="wertungEntfernen(r)"
>
×
</button>
</li>
</ul>
<!-- Wer dabei war. Der Host tippt Namen an und aus, alle anderen
sehen nur die Liste. -->
<div v-if="host || item.dabei.length" class="dabei">
<template v-if="host">
<button
v-for="u in runde"
:key="u.id"
type="button"
class="chip"
:class="{ an: item.dabei.some((d) => d.id === u.id) }"
@click="dabeiUmschalten(u.id)"
>
<PersonName :name="u.name" :level="u.level" />
</button>
</template>
<span v-else class="dabei-namen">
<PersonName v-for="d in item.dabei" :key="d.id" :name="d.name" />
</span>
</div>
<div v-if="host" class="host-zeile">
<label>
Geschaut am
<input type="date" :value="alsFeld(item.watched_at)" @change="datumSetzen($event.target.value)" />
</label>
<button type="button" class="klein" @click="sichtbarkeit">
{{ item.hidden ? 'Einblenden' : 'Ausblenden' }}
</button>
<button type="button" class="klein loeschen" @click="entfernen">Löschen</button>
</div>
</div>
</section>
<!-- ── Rechte Seite: das Gespräch ─────────────────────────────── -->
<section class="rechts">
<p v-if="!anzahl" class="leer">Noch nichts gesagt.</p>
<ul v-else class="liste">
<Kommentar
v-for="k in item.notes"
:key="k.id"
:kommentar="k"
:watched-id="item.id"
:angemeldet="angemeldet"
:host="host"
/>
</ul>
<div v-if="angemeldet" class="schreiben">
<button v-if="!schreibt" type="button" class="schreib-knopf" @click="schreibt = true">
Schreiben
</button>
<MarkdownFeld
v-else
v-model="kommentarEntwurf"
platzhalter="Was sagst du dazu?"
knopf="Schreiben"
:maxlaenge="4000"
@senden="kommentieren"
@abbrechen="schreibt = false"
/>
</div>
</section>
</article>
</template>
<style scoped>
.doppelseite {
display: flex;
height: 100%;
background: var(--bg);
}
/* ── Links ─────────────────────────────────────────────────────────── */
.links {
position: relative;
display: flex;
flex: 0 0 50%;
flex-direction: column;
/* Die Momentaufnahme steht unten auf dem Cover */
justify-content: flex-end;
min-width: 0;
overflow-y: auto;
scrollbar-width: thin;
}
.versteckt .cover {
opacity: 0.5;
}
.cover {
position: absolute;
top: 0;
right: 0;
left: 0;
aspect-ratio: 2 / 3;
background: var(--bg-soft) center / cover no-repeat;
}
/* Ab der Mitte wird das Cover dunkler, unten ist es der Hintergrund */
.cover::after {
position: absolute;
inset: 0;
content: '';
background: linear-gradient(
to bottom,
rgba(10, 10, 12, 0) 35%,
rgba(10, 10, 12, 0.75) 62%,
var(--bg) 100%
);
}
.momentaufnahme {
position: relative;
display: flex;
flex-direction: column;
gap: 0.9rem;
/* Der Text beginnt gut unterhalb der Covermitte */
padding: 70% 1.5rem 1.5rem;
}
header {
display: flex;
flex-direction: column;
gap: 0.35rem;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.9);
}
.wann {
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.marke {
margin-left: 0.5rem;
padding: 0.1rem 0.45rem;
color: var(--accent);
border: 1px solid var(--accent);
border-radius: 4px;
}
h2 {
font-size: 2rem;
line-height: 1.1;
cursor: pointer;
}
h2:hover {
color: var(--accent);
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
color: var(--muted);
font-size: 0.88rem;
}
.urteil {
display: flex;
gap: 0.4rem;
align-items: center;
margin-top: 0.3rem;
}
.wert {
color: #f5c518;
font-size: 2rem;
font-weight: 700;
line-height: 1;
}
.von {
color: var(--muted);
font-size: 0.88rem;
}
.wertung {
display: inline-flex;
gap: 0.05rem;
align-items: center;
margin-left: 0.6rem;
}
.stern {
padding: 0 0.05rem;
color: var(--line);
font-size: 1.35rem;
line-height: 1;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8);
}
.stern.an {
color: #f5c518;
}
.link {
padding: 0;
color: var(--muted);
font-size: 0.82rem;
text-decoration: underline;
text-underline-offset: 2px;
}
.link:hover {
color: var(--text);
}
.stimmen {
display: flex;
flex-direction: column;
gap: 0.3rem;
list-style: none;
}
.stimmen li {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.punkte {
color: #f5c518;
letter-spacing: 0.05em;
}
.wer {
color: var(--muted);
font-size: 0.85rem;
}
.wer.eigen,
.eigen {
color: var(--accent);
font-weight: 600;
}
.weg {
padding: 0 0.3rem;
color: var(--muted);
font-size: 1rem;
line-height: 1;
}
.weg:hover {
color: var(--accent);
}
.dabei {
display: flex;
flex-wrap: wrap;
gap: 0.2rem 0.7rem;
align-items: center;
margin-top: 0.6rem;
}
.dabei-namen {
display: flex;
flex-wrap: wrap;
gap: 0.2rem 0.7rem;
font-size: 0.88rem;
}
.chip {
padding: 0.15rem 0.55rem;
color: var(--muted);
font-size: 0.78rem;
border: 1px solid var(--line);
border-radius: 20px;
}
.chip.an {
color: var(--text);
border-color: var(--accent);
}
.host-zeile {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
padding-top: 0.6rem;
color: var(--muted);
font-size: 0.8rem;
border-top: 1px solid var(--line);
}
.host-zeile label {
display: flex;
gap: 0.4rem;
align-items: center;
}
.host-zeile input[type='date'] {
padding: 0.25rem 0.45rem;
font-size: 0.8rem;
}
.klein {
padding: 0.3rem 0.65rem;
color: var(--muted);
font-size: 0.78rem;
border: 1px solid var(--line);
border-radius: 6px;
}
.klein:hover:not(:disabled) {
color: var(--text);
border-color: var(--accent);
}
.klein.loeschen:hover {
color: var(--accent);
}
.fehler,
.leer {
color: var(--muted);
font-size: 0.88rem;
}
.fehler {
color: var(--accent);
}
/* ── Rechts ────────────────────────────────────────────────────────── */
.rechts {
flex: 0 0 50%;
min-width: 0;
padding: 1.25rem 1.25rem 2rem;
overflow-y: auto;
scrollbar-width: thin;
background: var(--bg-soft);
/* Der Falz: ein Schatten, der aus der Mitte kommt */
border-left: 1px solid var(--line);
box-shadow: inset 14px 0 18px -14px rgba(0, 0, 0, 0.9);
}
.schreiben {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-top: 1.25rem;
}
/* Zurückhaltend — die Diskussion steht im Vordergrund, nicht der Knopf */
.schreib-knopf {
align-self: flex-start;
padding: 0.45rem 0.95rem;
color: var(--muted);
font-size: 0.84rem;
font-weight: 600;
border: 1px solid var(--line);
border-radius: 8px;
}
.schreib-knopf:hover {
color: var(--text);
border-color: var(--accent);
}
/* Zeilen mit Trennlinie, wie bei den Wünschen */
.liste {
list-style: none;
}
.zeile {
display: flex;
gap: 0.8rem;
align-items: flex-start;
padding: 0.55rem 0.5rem;
border-bottom: 1px solid var(--line);
}
.pille {
flex-shrink: 0;
min-width: 3rem;
margin-top: 0.1rem;
padding: 0.18rem 0.55rem;
font-size: 0.8rem;
font-weight: 600;
text-align: center;
white-space: nowrap;
border: 1px solid var(--line);
border-radius: 20px;
}
.inhalt {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 0.15rem;
}
.text {
font-size: 0.95rem;
line-height: 1.45;
white-space: pre-wrap;
}
.zeile .meta {
display: block;
color: var(--muted);
font-size: 0.76rem;
line-height: 1.4;
}
.punkt {
margin: 0 0.35rem;
}
/* ── Handy: beide Hälften untereinander ────────────────────────────── */
@media (max-width: 640px), (pointer: coarse) {
.stern {
padding: 0.1rem 0.2rem;
font-size: 1.8rem;
}
}
@media (max-width: 640px) {
.doppelseite {
flex-direction: column;
height: auto;
}
.links,
.rechts {
flex: none;
overflow: visible;
}
.links {
/* Posterformat, der Inhalt kann darüber hinauswachsen */
min-height: calc(100vw * 1.5);
}
.momentaufnahme {
padding: 80% 1rem 1.25rem;
}
.rechts {
padding: 1rem 1rem 1.5rem;
box-shadow: none;
border-top: 1px solid var(--line);
border-left: 0;
}
h2 {
font-size: 1.6rem;
}
}
</style>

View File

@@ -1,14 +1,17 @@
<script setup>
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import MovieCard from './MovieCard.vue'
import { filterParameter } from '../filter.js'
// Der Ausgangsfilm steht als erste Karte davor, macht einundzwanzig
const TREFFER = 20
// Eine Detailkarte nimmt die halbe Breite ein, eine kompakte ein Achtel.
// Gleich viele Vorschläge wären mal eine Reihe, mal zehn.
const VORSCHLAEGE = { kompakt: 20, details: 8 }
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 },
})
@@ -16,7 +19,9 @@ const props = defineProps({
const emit = defineEmits(['open', 'gestartet'])
const query = ref('')
const vorschlaege = ref([])
const gefunden = ref([])
// Beim Wechsel der Ansicht wird nur neu geschnitten, nicht neu gesucht
const vorschlaege = computed(() => gefunden.value.slice(0, VORSCHLAEGE[props.ansicht] ?? 8))
const gewaehlt = ref(null)
const treffer = ref([])
const laden = ref(false)
@@ -32,7 +37,7 @@ let laufendeTreffer = 0
// dazu passt.
async function suchen(term) {
if (term.trim().length < 2) {
vorschlaege.value = []
gefunden.value = []
return
}
const meine = ++laufende
@@ -41,7 +46,7 @@ async function suchen(term) {
if (!resp.ok) throw new Error('Suche fehlgeschlagen')
const daten = await resp.json()
if (meine !== laufende) return
vorschlaege.value = daten.slice(0, 8)
gefunden.value = daten
} catch (err) {
if (meine === laufende) fehler.value = err.message
}
@@ -50,7 +55,7 @@ async function suchen(term) {
async function waehlen(film) {
const meine = ++laufendeWahl
gewaehlt.value = film
vorschlaege.value = []
gefunden.value = []
query.value = ''
nachladen()
@@ -76,8 +81,8 @@ async function nachladen() {
laden.value = true
fehler.value = ''
try {
// Die Anzahl kommt aus der Leiste, mit filterParameter als 'limit'
const params = new URLSearchParams({
limit: TREFFER,
voll: 'true',
...filterParameter(props.filter),
})
@@ -97,6 +102,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
@@ -112,7 +118,7 @@ watch(
waehlen(film)
emit('gestartet')
},
{ immediate: true }
{ immediate: true },
)
// Am Schieberegler hängen viele Ereignisse — erst abwarten, dann holen
@@ -123,7 +129,7 @@ watch(
clearTimeout(filterTimer)
filterTimer = setTimeout(nachladen, 350)
},
{ deep: true }
{ deep: true },
)
watch(query, (term) => {
@@ -149,13 +155,7 @@ async function oeffnen(movie) {
placeholder="Von welchem Film ausgehen?"
autofocus
/>
<button
v-if="query"
type="button"
class="leeren"
title="Suche leeren"
@click="query = ''"
>
<button v-if="query" type="button" class="leeren" title="Suche leeren" @click="query = ''">
×
</button>
</div>
@@ -169,6 +169,7 @@ async function oeffnen(movie) {
:key="film.id"
:movie="film"
:ansicht="ansicht"
:gesehen="gesehenIds.has(film.id)"
@open="waehlen"
/>
</div>
@@ -184,25 +185,17 @@ async function oeffnen(movie) {
</p>
<div v-if="!laden" class="grid" :class="ansicht">
<MovieCard
:movie="gewaehlt"
:ansicht="ansicht"
class="ausgangsfilm"
@open="oeffnen"
/>
<MovieCard :movie="gewaehlt" :ansicht="ansicht" class="ausgangsfilm" @open="oeffnen" />
<MovieCard
v-for="film in treffer.filter(passt)"
:key="film.id"
:movie="film"
:ansicht="ansicht"
:gesehen="gesehenIds.has(film.id)"
@open="oeffnen"
/>
</div>
</template>
<p v-else class="note">
Such einen Film. Danach zeigt dir dieser Reiter, was dazu passt.
</p>
</div>
</template>
@@ -268,10 +261,19 @@ async function oeffnen(movie) {
}
.grid.details {
grid-template-columns: repeat(
auto-fill,
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
);
grid-template-columns: repeat(auto-fill, min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem)));
}
/* Auf dem Handy wie im Katalog: drei Poster nebeneinander */
@media (max-width: 640px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(105px, 1fr));
gap: 0.6rem;
}
.grid.details {
grid-template-columns: 1fr;
}
}
.aehnlich-tab > .note {

View File

@@ -0,0 +1,58 @@
<script setup>
// Das Vorschaubild eines geschnittenen Clips: der erste Frame des Videos.
// Ein Video-Element im Hintergrund lädt den Anfang, der Frame wandert
// auf ein Canvas und von dort als Bild in die Karte. So gibt es keine
// Player-Steuerung, keine Bild-in-Bild-Hinweise und kein schwarzes Feld.
import { onMounted, onUnmounted, ref } from 'vue'
const props = defineProps({
src: { type: String, required: true },
})
const bild = ref(null)
let video = null
onMounted(() => {
video = document.createElement('video')
video.muted = true
video.playsInline = true
video.preload = 'auto'
video.addEventListener('loadeddata', () => {
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
canvas.getContext('2d').drawImage(video, 0, 0)
try {
bild.value = canvas.toDataURL('image/jpeg', 0.82)
} catch {
bild.value = null
}
// Das Video wird nicht mehr gebraucht
video.removeAttribute('src')
video.load()
})
video.src = props.src
})
onUnmounted(() => {
if (video) {
video.removeAttribute('src')
video.load()
}
})
</script>
<template>
<img v-if="bild" :src="bild" alt="" />
<span v-else class="leer"></span>
</template>
<style scoped>
img,
.leer {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
</style>

View File

@@ -0,0 +1,223 @@
<script setup>
// Ein Clip im Großen: Vorschau, der Zeitstrahl zum Zuschneiden, Titel,
// Schneiden. Liegt als Fenster über der Seite.
import { onMounted, onUnmounted, ref } from 'vue'
import ClipTrimmer from './ClipTrimmer.vue'
const props = defineProps({
clip: { type: Object, required: true },
// Besitzer und Host dürfen schneiden, ändern und löschen — alle
// anderen schauen nur
darf: Boolean,
})
const emit = defineEmits(['zu', 'geaendert'])
const vorher = ref(props.clip.vorher)
const dauer = ref(props.clip.dauer)
const titel = ref(props.clip.titel || '')
const segmente = ref([])
const vorschauUrl = ref('')
const schneidet = ref(false)
const fehler = ref('')
function vorschauLaden() {
vorschauUrl.value = `/api/clips/${props.clip.id}/vorschau?vorher=${vorher.value}&dauer=${dauer.value}&t=${Date.now()}`
}
async function patch(felder) {
const resp = await fetch(`/api/clips/${props.clip.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(felder),
})
if (!resp.ok) fehler.value = 'Das hat nicht geklappt.'
else emit('geaendert')
return resp.ok
}
async function schneiden() {
schneidet.value = true
fehler.value = ''
await patch({ vorher: vorher.value, dauer: dauer.value, titel: titel.value })
const resp = await fetch(`/api/clips/${props.clip.id}/schneiden`, { method: 'POST' })
if (!resp.ok) fehler.value = 'Die Aufnahme liefert diesen Ausschnitt nicht.'
else emit('geaendert')
schneidet.value = false
}
async function loeschen() {
await fetch(`/api/clips/${props.clip.id}`, { method: 'DELETE' })
emit('geaendert')
emit('zu')
}
function escape(e) {
if (e.key === 'Escape') emit('zu')
}
onMounted(async () => {
window.addEventListener('keydown', escape)
vorschauLaden()
try {
const resp = await fetch(`/api/clips/${props.clip.id}/aufnahme`)
if (resp.ok) segmente.value = await resp.json()
} catch {
/* dann ohne Aufnahmebalken */
}
})
onUnmounted(() => window.removeEventListener('keydown', escape))
</script>
<template>
<Teleport to="body">
<div class="editor-overlay" @click.self="emit('zu')">
<div class="editor">
<header>
<input
v-if="darf"
v-model="titel"
type="text"
maxlength="80"
placeholder="Titel des Clips"
class="titel"
@change="patch({ titel })"
/>
<h2 v-else class="titel">{{ clip.titel || 'Ohne Titel' }}</h2>
<button type="button" class="zu" title="Schließen" @click="emit('zu')">×</button>
</header>
<!-- Wer nur schaut, bekommt bei geschnittenen Clips das fertige Video -->
<video
:src="!darf && clip.geschnitten ? `/api/clips/${clip.id}/video` : vorschauUrl"
controls
playsinline
class="vorschau"
></video>
<ClipTrimmer
v-if="darf"
v-model:vorher="vorher"
v-model:dauer="dauer"
:segmente="segmente"
:marke="clip.marke_at"
@ende="vorschauLaden"
/>
<p v-if="fehler" class="fehler">{{ fehler }}</p>
<div class="knoepfe">
<button v-if="darf" type="button" class="haupt" :disabled="schneidet" @click="schneiden">
{{ schneidet ? 'Schneidet …' : clip.geschnitten ? 'Speichern' : 'Schneiden' }}
</button>
<a v-if="clip.geschnitten" :href="`/api/clips/${clip.id}/video`" download class="link"
>Herunterladen</a
>
<span class="rest"></span>
<button v-if="darf" type="button" class="weg" @click="loeschen">Löschen</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.editor-overlay {
position: fixed;
inset: 0;
z-index: 60;
display: grid;
place-items: center;
padding: 1.5rem;
background: rgba(0, 0, 0, 0.8);
}
.editor {
display: flex;
width: min(100%, 960px);
max-height: 95vh;
flex-direction: column;
gap: 0.8rem;
padding: 1rem;
overflow-y: auto;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 12px;
}
header {
display: flex;
gap: 0.6rem;
align-items: center;
}
.titel {
flex: 1;
padding: 0.5rem 0.7rem;
font-size: 1rem;
font-weight: 600;
}
.zu {
color: var(--muted);
font-size: 1.8rem;
line-height: 1;
}
.zu:hover {
color: var(--text);
}
.vorschau {
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
border-radius: 8px;
}
.fehler {
color: var(--accent);
font-size: 0.85rem;
}
.knoepfe {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.knoepfe button,
.knoepfe .link {
padding: 0.5rem 1rem;
color: var(--text);
font-size: 0.88rem;
font-weight: 600;
text-decoration: none;
border: 1px solid var(--line);
border-radius: 8px;
}
.knoepfe button:hover,
.knoepfe .link:hover {
border-color: var(--accent);
}
.knoepfe .haupt {
color: #fff;
background: var(--accent);
border-color: var(--accent);
}
.knoepfe .haupt:disabled {
opacity: 0.5;
}
.rest {
flex: 1;
}
.weg {
color: var(--muted);
}
</style>

View File

@@ -0,0 +1,362 @@
<script setup>
// Der Zeitstrahl der Aufnahme rund um die Marke. Ein Balken zeigt das
// Fenster des Clips; an seinen Enden zieht man Anfang und Ende, in der
// Mitte schiebt man das ganze Fenster. Daneben zieht man den Strahl
// selbst nach links und rechts. Zeiten in Sekunden relativ zur Marke:
// 0 ist der Moment, an dem C gedrückt wurde.
import { computed, onMounted, onUnmounted, ref } from 'vue'
const props = defineProps({
// Stücke der Aufnahme: [{ von, bis }] relativ zur Marke
segmente: { type: Array, default: () => [] },
maxVorher: { type: Number, default: 4 * 3600 },
minDauer: { type: Number, default: 3 },
maxDauer: { type: Number, default: 180 },
// Uhrzeit der Marke, für die Beschriftung
marke: { type: String, default: '' },
})
const vorher = defineModel('vorher', { type: Number, default: 30 })
const dauer = defineModel('dauer', { type: Number, default: 35 })
const emit = defineEmits(['ende'])
// Der sichtbare Ausschnitt des Strahls, zoombar
const ZOOMS = [120, 300, 900, 1800, 7200, 14400]
const zoom = ref(1)
const mitte = ref(0)
const spanne = computed(() => ZOOMS[zoom.value])
const links = computed(() => mitte.value - spanne.value / 2)
const rechts = computed(() => mitte.value + spanne.value / 2)
const strahl = ref(null)
function prozent(t) {
return ((t - links.value) / spanne.value) * 100
}
const start = computed(() => -vorher.value)
const ende = computed(() => -vorher.value + dauer.value)
// Wo die Aufnahme überhaupt Material hat — so weit lässt sich der Strahl schieben
const grenzen = computed(() => {
if (!props.segmente.length) return { von: -props.maxVorher, bis: props.maxVorher }
return {
von: Math.min(...props.segmente.map((s) => s.von)),
bis: Math.max(...props.segmente.map((s) => s.bis)),
}
})
// Das Stück der Aufnahme, in dem das Fenster liegt. Zwischen zwei
// Stücken ist Loch — dort gibt es kein Bild, dorthin darf das Fenster
// nicht. Liegt es in einem Loch, gilt das nähere Stück.
function abstand(s, t) {
return t < s.von ? s.von - t : t > s.bis ? t - s.bis : 0
}
function stueckUm(t) {
if (!props.segmente.length) return { von: -props.maxVorher, bis: props.maxVorher }
const s = props.segmente.reduce((a, b) => (abstand(a, t) <= abstand(b, t) ? a : b))
return { von: Math.max(s.von, -props.maxVorher), bis: Math.min(s.bis, props.maxVorher) }
}
function zoomen(schritt) {
zoom.value = Math.max(0, Math.min(ZOOMS.length - 1, zoom.value + schritt))
mitte.value = (start.value + ende.value) / 2
}
// Beschriftung: runde Schritte, etwa sechs bis acht Striche
const striche = computed(() => {
const schritt = [10, 30, 60, 300, 900, 1800][zoom.value]
const aus = []
for (let t = Math.ceil(links.value / schritt) * schritt; t <= rechts.value; t += schritt) {
aus.push({ t, text: uhrzeit(t) })
}
return aus
})
function uhrzeit(t) {
if (!props.marke) return `${t >= 0 ? '+' : '−'}${Math.abs(t)} s`
const d = new Date(new Date(props.marke).getTime() + t * 1000)
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
// ── Ziehen ────────────────────────────────────────────────────────────
let griff = null
let ausgang = null
// Ob sich die Hand seit dem Anfassen bewegt hat — sonst war es ein Klick
let bewegt = false
function zeitBei(clientX) {
const box = strahl.value.getBoundingClientRect()
return links.value + ((clientX - box.left) / box.width) * spanne.value
}
// Das Fenster an eine Stelle setzen: sein Anfang liegt dort, es bleibt im Stück
function setzen(t) {
const g = stueckUm(t)
const d = Math.max(props.minDauer, Math.min(dauer.value, g.bis - g.von))
const s = Math.max(g.von, Math.min(g.bis - d, t))
vorher.value = Math.round(-s)
dauer.value = Math.round(d)
emit('ende')
}
function anfassen(e, was) {
griff = was
bewegt = false
ausgang = {
x: e.clientX,
vorher: vorher.value,
dauer: dauer.value,
mitte: mitte.value,
// Das Stück, in dem das Fenster beim Anfassen liegt — darin bleibt es
stueck: stueckUm(-vorher.value + dauer.value / 2),
}
e.currentTarget.setPointerCapture(e.pointerId)
}
function ziehen(e) {
if (!griff || !strahl.value) return
const dt = ((e.clientX - ausgang.x) / strahl.value.clientWidth) * spanne.value
if (Math.abs(e.clientX - ausgang.x) > 4) bewegt = true
const g = ausgang.stueck
if (griff === 'bahn') {
// Den Strahl selbst schieben — die Zeiten laufen der Hand entgegen
const r = grenzen.value
mitte.value = Math.max(r.von, Math.min(r.bis, ausgang.mitte - dt))
} else if (griff === 'links') {
// Der Anfang wandert, das Ende bleibt stehen
const altesEnde = -ausgang.vorher + ausgang.dauer
let neuerStart = -ausgang.vorher + dt
neuerStart = Math.max(g.von, neuerStart)
neuerStart = Math.min(
altesEnde - props.minDauer,
Math.max(altesEnde - props.maxDauer, neuerStart),
)
vorher.value = Math.round(-neuerStart)
dauer.value = Math.round(altesEnde - neuerStart)
} else if (griff === 'rechts') {
let neuesEnde = -ausgang.vorher + ausgang.dauer + dt
neuesEnde = Math.min(g.bis, neuesEnde)
const d = Math.max(props.minDauer, Math.min(props.maxDauer, neuesEnde + ausgang.vorher))
dauer.value = Math.round(d)
} else {
// Das ganze Fenster verschieben, aber nicht aus dem Stück hinaus
let neuerStart = -ausgang.vorher + dt
neuerStart = Math.max(g.von, Math.min(g.bis - ausgang.dauer, neuerStart))
vorher.value = Math.round(-neuerStart)
}
}
function loslassen(e) {
if (!griff) return
const war = griff
griff = null
e.currentTarget.releasePointerCapture?.(e.pointerId)
if (war === 'bahn') {
// Ein Klick ohne Ziehen setzt das Fenster hierhin; Schieben braucht keine neue Vorschau
if (!bewegt) setzen(zeitBei(e.clientX))
return
}
emit('ende')
}
// Mausrad: zoomen, der Punkt unter dem Zeiger bleibt stehen
function rad(e) {
const neu = Math.max(0, Math.min(ZOOMS.length - 1, zoom.value + (e.deltaY > 0 ? 1 : -1)))
if (neu === zoom.value || !strahl.value) return
const box = strahl.value.getBoundingClientRect()
const anteil = (e.clientX - box.left) / box.width
const t = links.value + anteil * spanne.value
zoom.value = neu
const r = grenzen.value
mitte.value = Math.max(r.von, Math.min(r.bis, t - (anteil - 0.5) * ZOOMS[neu]))
}
// Pfeiltasten: 1 s, mit Shift 10 s. Die Vorschau lädt erst, wenn Ruhe ist
let tastenTimer = null
function taste(e) {
if (e.target instanceof Element && e.target.matches('input, textarea')) return
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
e.preventDefault()
const schritt = (e.shiftKey ? 10 : 1) * (e.key === 'ArrowLeft' ? -1 : 1)
const g = stueckUm(start.value + dauer.value / 2)
const s = Math.max(g.von, Math.min(g.bis - dauer.value, start.value + schritt))
vorher.value = Math.round(-s)
clearTimeout(tastenTimer)
tastenTimer = setTimeout(() => emit('ende'), 400)
}
onMounted(() => window.addEventListener('keydown', taste))
onUnmounted(() => {
window.removeEventListener('keydown', taste)
clearTimeout(tastenTimer)
})
</script>
<template>
<div class="trimmer">
<div class="kopf">
<span class="fenster">
{{ uhrzeit(start) }} – {{ uhrzeit(ende) }} · <b>{{ dauer }} s</b>
</span>
<span class="zoom">
<button type="button" :disabled="zoom === 0" title="Näher" @click="zoomen(-1)">+</button>
<button
type="button"
:disabled="zoom === ZOOMS.length - 1"
title="Weiter"
@click="zoomen(1)"
>
−
</button>
</span>
</div>
<div
ref="strahl"
class="strahl"
@pointerdown="anfassen($event, 'bahn')"
@wheel.prevent="rad"
@pointermove="ziehen"
@pointerup="loslassen"
@pointercancel="loslassen"
>
<!-- Wo Aufnahme liegt -->
<span
v-for="(s, i) in segmente"
:key="i"
class="aufnahme"
:style="{ left: prozent(s.von) + '%', width: prozent(s.bis) - prozent(s.von) + '%' }"
></span>
<!-- Die Marke: der Moment, an dem C gedrückt wurde -->
<span class="marke" :style="{ left: prozent(0) + '%' }" title="Marke"></span>
<!-- Das Fenster des Clips mit seinen Griffen -->
<span
class="bereich"
:style="{ left: prozent(start) + '%', width: prozent(ende) - prozent(start) + '%' }"
@pointerdown.stop="anfassen($event, 'mitte')"
>
<span class="griff links" @pointerdown.stop="anfassen($event, 'links')"></span>
<span class="griff rechts" @pointerdown.stop="anfassen($event, 'rechts')"></span>
</span>
<span v-for="s in striche" :key="s.t" class="strich" :style="{ left: prozent(s.t) + '%' }">
<span class="text">{{ s.text }}</span>
</span>
</div>
</div>
</template>
<style scoped>
.trimmer {
display: flex;
flex-direction: column;
gap: 0.4rem;
user-select: none;
}
.kopf {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--muted);
font-size: 0.82rem;
font-variant-numeric: tabular-nums;
}
.fenster b {
color: var(--text);
}
.zoom button {
width: 1.7rem;
height: 1.7rem;
color: var(--text);
border: 1px solid var(--line);
border-radius: 6px;
}
.zoom button:disabled {
opacity: 0.4;
}
.strahl {
position: relative;
height: 3.4rem;
overflow: hidden;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 8px;
cursor: grab;
touch-action: none;
}
.aufnahme {
position: absolute;
top: 0.4rem;
height: 1.6rem;
background: rgba(255, 255, 255, 0.1);
}
.marke {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
margin-left: -1px;
background: #f5c518;
}
.bereich {
position: absolute;
top: 0.4rem;
/* Bei vier Stunden Ansicht wäre das Fenster ein Strich — so bleibt es greifbar */
min-width: 2.2rem;
height: 1.6rem;
background: rgba(229, 9, 20, 0.35);
border: 2px solid var(--accent);
border-radius: 4px;
cursor: grab;
}
.griff {
position: absolute;
top: -2px;
bottom: -2px;
width: 0.7rem;
background: var(--accent);
cursor: ew-resize;
}
.griff.links {
left: -2px;
border-radius: 4px 0 0 4px;
}
.griff.rechts {
right: -2px;
border-radius: 0 4px 4px 0;
}
.strich {
position: absolute;
top: 2.2rem;
bottom: 0;
width: 1px;
background: var(--line);
pointer-events: none;
}
.text {
position: absolute;
top: 0.3rem;
left: 0.25rem;
color: var(--muted);
font-size: 0.68rem;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,422 @@
<script setup>
// Die eigenen Marken und Clips als Karten, nach Film gruppiert. Ein Klick
// öffnet den Editor mit Zeitstrahl. Der Host sieht die Clips aller.
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { abonnieren } from '../live'
import { backdropUrl, posterUrl } from '../images'
import ClipEditor from './ClipEditor.vue'
import ClipBild from './ClipBild.vue'
import PersonName from './PersonName.vue'
const props = defineProps({
angemeldet: Boolean,
host: Boolean,
})
// "Clip erstellen": eine Aufnahme wählen, dann öffnet der Editor. Jede
// Aufnahme bringt ihren Film mit — oder keinen, dann läuft der Clip
// unter „Sonstige".
const wahl = ref(false)
const wahlFehler = ref('')
const aufnahmen = ref(null)
async function wahlOeffnen() {
wahl.value = true
wahlFehler.value = ''
aufnahmen.value = null
try {
const resp = await fetch('/api/aufnahmen')
aufnahmen.value = resp.ok ? (await resp.json()).stuecke : []
} catch {
aufnahmen.value = []
}
}
function minuten(sekunden) {
return `${Math.max(1, Math.round(sekunden / 60))} Min`
}
async function erstellen(aufnahme) {
wahlFehler.value = ''
const resp = await fetch('/api/clips/von-aufnahme', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ start: aufnahme.start }),
})
if (!resp.ok) {
const daten = await resp.json().catch(() => ({}))
wahlFehler.value = daten.detail || 'Das hat nicht geklappt.'
return
}
wahl.value = false
offen.value = await resp.json()
laden_()
}
const clips = ref([])
const laden = ref(true)
const fehler = ref('')
const offen = ref(null)
async function laden_() {
try {
const resp = await fetch('/api/clips')
if (!resp.ok) throw new Error('Clips konnten nicht geladen werden')
clips.value = await resp.json()
fehler.value = ''
// Der offene Clip soll den frischen Stand zeigen
if (offen.value) offen.value = clips.value.find((c) => c.id === offen.value.id) || null
} catch (err) {
fehler.value = err.message
} finally {
laden.value = false
}
}
// Nach Film gruppiert, der jüngste Abend zuerst
const gruppen = computed(() => {
const nach = new Map()
for (const c of clips.value) {
const key = c.movie_id ?? 0
if (!nach.has(key)) nach.set(key, { key, titel: c.title || 'Sonstige', clips: [] })
nach.get(key).clips.push(c)
}
return [...nach.values()]
})
function uhrzeit(iso) {
return new Date(iso).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
}
function datum(iso) {
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
let abbestellen = null
onMounted(() => {
laden_()
abbestellen = abonnieren((nachricht) => {
if (nachricht.type === 'clips') laden_()
})
})
onUnmounted(() => abbestellen?.())
</script>
<template>
<div class="clips-tab">
<div v-if="angemeldet" class="oben">
<button type="button" class="erstellen" @click="wahlOeffnen">Clip erstellen</button>
</div>
<!-- Aufnahme wählen: mit Film oder ohne -->
<Teleport to="body">
<div v-if="wahl" class="abendwahl-overlay" @click.self="wahl = false">
<div class="abendwahl">
<header>
<span>Welche Aufnahme?</span>
<button type="button" class="zu" @click="wahl = false">×</button>
</header>
<p v-if="wahlFehler" class="abendwahl-fehler">{{ wahlFehler }}</p>
<p v-if="aufnahmen === null" class="abendwahl-hinweis">Suche Aufnahmen …</p>
<p v-else-if="!aufnahmen.length" class="abendwahl-hinweis">Es gibt keine Aufnahmen.</p>
<ul v-else>
<li v-for="s in aufnahmen" :key="s.start">
<button type="button" @click="erstellen(s)">
<img
v-if="s.film?.poster_path"
:src="posterUrl(s.film.poster_path, 'w154')"
alt=""
/>
<span v-else class="ohne-cover"></span>
<span class="abend-text">
<span class="abend-titel">{{ s.film?.title || 'Sonstige Aufnahme' }}</span>
<span class="abend-datum">
{{ datum(s.start) }} · {{ uhrzeit(s.start) }} Uhr · {{ minuten(s.dauer) }}
</span>
</span>
</button>
</li>
</ul>
</div>
</div>
</Teleport>
<p v-if="laden" class="note">Lade Clips…</p>
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
<p v-else-if="!clips.length" class="note">
Noch keine Clips. Im Stream-Reiter setzt die Taste C eine Marke.
</p>
<section v-for="g in gruppen" :key="g.key">
<h3>{{ g.titel }}</h3>
<div class="grid">
<button v-for="c in g.clips" :key="c.id" type="button" class="karte" @click="offen = c">
<!-- Geschnitten: das Video selbst als Bild. Sonst das Filmbild
mit dem Hinweis, dass hier erst eine Marke steht. -->
<span class="bild">
<ClipBild v-if="c.geschnitten" :src="`/api/clips/${c.id}/video`" />
<img v-else-if="c.poster_path" :src="backdropUrl(c.poster_path, 'w342')" alt="" />
<span v-else class="leer"></span>
<span class="dauer">{{ Math.round(c.dauer) }} s</span>
<span v-if="!c.geschnitten" class="stand">Marke</span>
</span>
<span class="text">
<span class="titel">{{ c.titel || 'Ohne Titel' }}</span>
<span class="meta">
<PersonName :name="c.name" /><span
>· {{ datum(c.marke_at) }} · {{ uhrzeit(c.marke_at) }}</span
>
</span>
</span>
</button>
</div>
</section>
<ClipEditor
v-if="offen"
:clip="offen"
:darf="offen.mine || host"
@zu="offen = null"
@geaendert="laden_"
/>
</div>
</template>
<style scoped>
.oben {
display: flex;
justify-content: flex-end;
margin-bottom: 1.2rem;
}
.erstellen {
padding: 0.55rem 1.1rem;
color: #fff;
font-size: 0.88rem;
font-weight: 600;
background: var(--accent);
border-radius: 8px;
}
.erstellen:hover {
filter: brightness(1.15);
}
section {
margin-bottom: 2rem;
}
h3 {
margin-bottom: 0.7rem;
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
}
.karte {
display: flex;
flex-direction: column;
overflow: hidden;
text-align: left;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 12px;
transition:
transform 0.15s ease,
border-color 0.15s ease;
}
.karte:hover {
transform: translateY(-3px);
border-color: var(--accent);
}
.bild {
position: relative;
display: block;
aspect-ratio: 16 / 9;
/* Sonst zieht ein hochkantiges Poster die Karte in die Länge */
overflow: hidden;
background: #000;
}
.bild video,
.bild img,
.bild .leer {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.bild img {
opacity: 0.5;
}
.dauer,
.stand {
position: absolute;
bottom: 0.45rem;
padding: 0.1rem 0.45rem;
font-size: 0.72rem;
font-weight: 600;
background: rgba(0, 0, 0, 0.75);
border-radius: 4px;
}
.dauer {
right: 0.45rem;
}
.stand {
left: 0.45rem;
color: #f5c518;
}
.text {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.7rem 0.85rem 0.8rem;
}
.titel {
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 0 0.3rem;
align-items: center;
color: var(--muted);
font-size: 0.78rem;
}
.note {
padding: 2.5rem 0;
color: var(--muted);
text-align: center;
}
.note.error {
color: var(--accent);
}
@media (max-width: 640px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>
<style>
/* Die Abendwahl liegt per Teleport im body — daher ohne Scope */
.abendwahl-overlay {
position: fixed;
inset: 0;
z-index: 60;
display: grid;
place-items: center;
padding: 1.5rem;
background: rgba(0, 0, 0, 0.8);
}
.abendwahl {
display: flex;
width: min(100%, 520px);
max-height: 90vh;
flex-direction: column;
gap: 0.6rem;
padding: 1rem;
overflow-y: auto;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 12px;
}
.abendwahl header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
}
.abendwahl .zu {
color: var(--muted);
font-size: 1.6rem;
line-height: 1;
}
.abendwahl-fehler {
color: var(--accent);
font-size: 0.85rem;
}
.abendwahl-hinweis {
padding: 1rem 0;
color: var(--muted);
font-size: 0.9rem;
text-align: center;
}
.abendwahl ul {
list-style: none;
}
.abendwahl li button {
display: flex;
gap: 0.8rem;
align-items: center;
width: 100%;
padding: 0.45rem 0.5rem;
text-align: left;
border-radius: 8px;
}
.abendwahl li button:hover {
background: rgba(255, 255, 255, 0.06);
}
.abendwahl li img {
width: 40px;
height: 60px;
object-fit: cover;
border-radius: 4px;
}
/* Ohne Film: ein leeres Cover an Stelle des Posters */
.abendwahl li .ohne-cover {
flex: 0 0 40px;
height: 60px;
background: var(--line);
border-radius: 4px;
}
.abend-text {
display: flex;
flex-direction: column;
}
.abend-datum {
color: var(--muted);
font-size: 0.8rem;
}
</style>

View File

@@ -0,0 +1,541 @@
<script setup>
// Eine Einladungskarte zum Verschicken: Titel, Datum, Uhrzeit, Genre, Ort,
// Adresse der Seite — als Bild fürs Handy. Zwei Designs: Horror und kein
// Horror. Jede Zeile ist ein Textfeld. Das Bild entsteht hier im Browser
// auf einem Canvas, der Host lädt es herunter und schickt es selbst herum.
import { onMounted, onUnmounted, reactive, ref, watch } from 'vue'
const emit = defineEmits(['zu'])
const GEMERKT = 'filmabend_karte'
const B = 1080
const H = 1350
function naechsterMittwoch() {
const d = new Date()
d.setDate(d.getDate() + ((3 - d.getDay() + 7) % 7 || 7))
return d.toISOString().slice(0, 10)
}
const form = reactive({
stil: 'horror',
vorzeile: 'Einladung zum',
titel: 'Filmabend',
datum: naechsterMittwoch(),
uhrzeit: '20:00',
genre: 'Horror',
ort: 'Bananarama - Wohnzimmer',
url: 'horror.marha.de',
})
try {
Object.assign(form, JSON.parse(localStorage.getItem(GEMERKT) || '{}'))
} catch {
// Kaputte Erinnerung — dann eben die Vorgaben
}
const leinwand = ref(null)
function stilWaehlen(stil) {
form.stil = stil
form.genre = stil === 'horror' ? 'Horror' : 'Kein Horror'
}
// ── Malen ───────────────────────────────────────────────────────────
function datumText() {
const d = new Date(form.datum + 'T12:00:00')
if (Number.isNaN(d.getTime())) return form.datum
return d.toLocaleDateString('de-DE', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
// Text umbrechen, damit lange Sätze nicht aus dem Bild laufen
function zeilen(ctx, text, maxBreite) {
const aus = []
let zeile = ''
for (const wort of text.split(/\s+/)) {
const probe = zeile ? `${zeile} ${wort}` : wort
if (ctx.measureText(probe).width > maxBreite && zeile) {
aus.push(zeile)
zeile = wort
} else {
zeile = probe
}
}
if (zeile) aus.push(zeile)
return aus
}
function rundeck(ctx, x, y, b, h, r) {
ctx.beginPath()
ctx.roundRect(x, y, b, h, r)
ctx.closePath()
}
// Feste Zufallsfolge: Körnung und Spritzer bleiben bei jedem Tastendruck gleich
function zufall(saat) {
let z = saat
return () => {
z = (z * 1664525 + 1013904223) % 4294967296
return z / 4294967296
}
}
const SCHRIFT = 'system-ui, -apple-system, "Segoe UI", sans-serif'
const FARBEN = {
horror: { text: '#f1ecec', leise: '#9a8f92', akzent: '#e50914' },
nett: { text: '#1b2a41', leise: '#7d6b50', akzent: '#e0532f' },
}
// ── Horror: Dunkelheit, Körnung, Spinnennetz, Blut ──────────────────
function spinnennetz(ctx, cx, cy, radius, von, bis) {
ctx.save()
ctx.strokeStyle = 'rgba(255, 255, 255, 0.26)'
ctx.lineWidth = 2
const speichen = 7
const winkel = []
for (let i = 0; i <= speichen; i++) winkel.push(von + ((bis - von) * i) / speichen)
for (const w of winkel) {
ctx.beginPath()
ctx.moveTo(cx, cy)
ctx.lineTo(cx + Math.cos(w) * radius, cy + Math.sin(w) * radius)
ctx.stroke()
}
// Die Querfäden hängen leicht durch
for (let r = radius / 6; r <= radius; r += radius / 6) {
ctx.beginPath()
winkel.forEach((w, i) => {
const x = cx + Math.cos(w) * r
const y = cy + Math.sin(w) * r
if (i === 0) return ctx.moveTo(x, y)
const m = (winkel[i - 1] + w) / 2
ctx.quadraticCurveTo(cx + Math.cos(m) * r * 0.9, cy + Math.sin(m) * r * 0.9, x, y)
})
ctx.stroke()
}
ctx.restore()
}
function blut(ctx) {
ctx.fillStyle = '#b3060f'
ctx.fillRect(0, 0, B, 30)
const tropfen = [
[50, 70, 16],
[150, 140, 22],
[230, 50, 14],
[330, 180, 26],
[430, 90, 18],
[520, 220, 24],
[640, 60, 16],
[720, 150, 20],
[820, 110, 22],
[910, 200, 26],
[1000, 80, 18],
]
for (const [x, laenge, breite] of tropfen) {
rundeck(ctx, x - breite / 2, 10, breite, laenge, breite / 2)
ctx.fill()
ctx.beginPath()
ctx.arc(x, 10 + laenge, breite * 0.7, 0, Math.PI * 2)
ctx.fill()
}
const r = zufall(3)
for (let i = 0; i < 40; i++) {
ctx.beginPath()
ctx.arc(r() * B, 30 + r() * 260, 2 + r() * 5, 0, Math.PI * 2)
ctx.fill()
}
}
function horrorHintergrund(ctx) {
ctx.fillStyle = '#07060a'
ctx.fillRect(0, 0, B, H)
const glut = ctx.createRadialGradient(B / 2, H * 0.55, 60, B / 2, H * 0.55, H * 0.75)
glut.addColorStop(0, 'rgba(150, 10, 20, 0.35)')
glut.addColorStop(1, 'rgba(0, 0, 0, 0)')
ctx.fillStyle = glut
ctx.fillRect(0, 0, B, H)
const r = zufall(7)
ctx.fillStyle = 'rgba(255, 255, 255, 0.05)'
for (let i = 0; i < 6000; i++) ctx.fillRect(r() * B, r() * H, 2, 2)
const rand = ctx.createRadialGradient(B / 2, H / 2, H * 0.35, B / 2, H / 2, H * 0.85)
rand.addColorStop(0, 'rgba(0, 0, 0, 0)')
rand.addColorStop(1, 'rgba(0, 0, 0, 0.85)')
ctx.fillStyle = rand
ctx.fillRect(0, 0, B, H)
spinnennetz(ctx, B, 0, 380, Math.PI / 2, Math.PI)
spinnennetz(ctx, 0, H, 300, -Math.PI / 2, 0)
blut(ctx)
// Ein dünner Strich zwischen Wann und Was
ctx.fillStyle = 'rgba(229, 9, 20, 0.5)'
ctx.fillRect(300, 670, B - 600, 2)
}
// ── Kein Horror: ein Kinoticket auf einer Markise ───────────────────
const TICKET = { x: 70, y: 90, b: B - 140, h: H - 180, r: 36 }
function filmstreifen(ctx, y) {
ctx.fillStyle = FARBEN.nett.text
ctx.fillRect(TICKET.x, y, TICKET.b, 70)
ctx.fillStyle = '#fff4dc'
for (let x = TICKET.x + 30; x < TICKET.x + TICKET.b - 30; x += 70) {
rundeck(ctx, x, y + 20, 36, 30, 6)
ctx.fill()
}
}
function nettHintergrund(ctx) {
// Markise: schräge Streifen
ctx.fillStyle = '#f2bd3a'
ctx.fillRect(0, 0, B, H)
ctx.save()
ctx.translate(B / 2, H / 2)
ctx.rotate(-Math.PI / 4)
ctx.fillStyle = '#e9b02c'
for (let x = -H; x < H; x += 80) ctx.fillRect(x, -H, 40, 2 * H)
ctx.restore()
// Das Ticket mit Schatten
ctx.save()
ctx.shadowColor = 'rgba(0, 0, 0, 0.3)'
ctx.shadowBlur = 40
ctx.shadowOffsetY = 16
ctx.fillStyle = '#fff4dc'
rundeck(ctx, TICKET.x, TICKET.y, TICKET.b, TICKET.h, TICKET.r)
ctx.fill()
ctx.restore()
ctx.save()
rundeck(ctx, TICKET.x, TICKET.y, TICKET.b, TICKET.h, TICKET.r)
ctx.clip()
filmstreifen(ctx, TICKET.y)
filmstreifen(ctx, TICKET.y + TICKET.h - 70)
ctx.restore()
// Abrisskante mit Kerben
const ky = 670
ctx.fillStyle = '#f2bd3a'
for (const x of [TICKET.x, TICKET.x + TICKET.b]) {
ctx.beginPath()
ctx.arc(x, ky, 36, 0, Math.PI * 2)
ctx.fill()
}
ctx.save()
ctx.strokeStyle = FARBEN.nett.text
ctx.globalAlpha = 0.35
ctx.lineWidth = 4
ctx.setLineDash([18, 14])
ctx.beginPath()
ctx.moveTo(TICKET.x + 60, ky)
ctx.lineTo(TICKET.x + TICKET.b - 60, ky)
ctx.stroke()
ctx.restore()
}
// ── Text ────────────────────────────────────────────────────────────
function stempel(ctx, text, y, farbe) {
ctx.save()
ctx.translate(B / 2, y)
ctx.rotate(-0.06)
ctx.font = `bold 58px ${SCHRIFT}`
ctx.letterSpacing = '6px'
ctx.textBaseline = 'middle'
const breite = Math.min(ctx.measureText(text).width + 70, B - 160)
ctx.strokeStyle = farbe
ctx.lineWidth = 6
rundeck(ctx, -breite / 2, -52, breite, 96, 12)
ctx.stroke()
ctx.fillStyle = farbe
ctx.fillText(text, 3, 2, breite - 40)
ctx.restore()
}
function malen() {
const ctx = leinwand.value?.getContext('2d')
if (!ctx) return
const horror = form.stil === 'horror'
const farbe = FARBEN[form.stil]
if (horror) horrorHintergrund(ctx)
else nettHintergrund(ctx)
ctx.textAlign = 'center'
ctx.textBaseline = 'alphabetic'
// Vorzeile und Überschrift
ctx.fillStyle = farbe.leise
ctx.font = `600 36px ${SCHRIFT}`
ctx.letterSpacing = '8px'
ctx.fillText(form.vorzeile.toUpperCase(), B / 2, 345, B - 160)
ctx.letterSpacing = '0px'
ctx.save()
if (horror) {
ctx.shadowColor = 'rgba(229, 9, 20, 0.7)'
ctx.shadowBlur = 40
}
ctx.fillStyle = horror ? farbe.akzent : farbe.text
ctx.font = `bold 104px ${SCHRIFT}`
ctx.letterSpacing = horror ? '10px' : '2px'
const titel = horror ? form.titel.toUpperCase() : form.titel
ctx.fillText(titel, B / 2, 440, B - 160)
ctx.restore()
// Wann
ctx.fillStyle = farbe.text
ctx.font = `600 50px ${SCHRIFT}`
ctx.fillText(datumText(), B / 2, 540, B - 160)
ctx.fillStyle = farbe.leise
ctx.font = `42px ${SCHRIFT}`
if (form.uhrzeit) ctx.fillText(`${form.uhrzeit} Uhr`, B / 2, 600, B - 160)
// Genre als Stempel
stempel(ctx, form.genre.toUpperCase(), 770, farbe.akzent)
// Kleine Titel über Discord und Webseite
const titelZeile = (text, y) => {
ctx.fillStyle = farbe.leise
ctx.font = `600 30px ${SCHRIFT}`
ctx.letterSpacing = '4px'
ctx.fillText(text, B / 2, y)
ctx.letterSpacing = '0px'
}
titelZeile('DISCORD', 890)
ctx.fillStyle = farbe.text
ctx.font = `46px ${SCHRIFT}`
let y = 950
for (const z of zeilen(ctx, form.ort, B - 200)) {
ctx.fillText(z, B / 2, y)
y += 56
}
titelZeile('WEBSEITE', y + 50)
ctx.fillStyle = farbe.text
ctx.font = `48px ${SCHRIFT}`
ctx.fillText(form.url, B / 2, y + 110, B - 200)
}
function speichern() {
leinwand.value.toBlob((blob) => {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `einladung-${form.datum}.png`
a.click()
URL.revokeObjectURL(url)
}, 'image/png')
}
watch(form, () => {
localStorage.setItem(GEMERKT, JSON.stringify(form))
malen()
})
function escape(e) {
if (e.key === 'Escape') emit('zu')
}
onMounted(() => {
window.addEventListener('keydown', escape)
malen()
})
onUnmounted(() => window.removeEventListener('keydown', escape))
</script>
<template>
<Teleport to="body">
<div class="hintergrund" @click.self="emit('zu')">
<div class="fenster" role="dialog" aria-modal="true">
<div class="formular">
<p class="label">Einladung</p>
<div class="stil">
<button
type="button"
:class="{ an: form.stil === 'horror' }"
@click="stilWaehlen('horror')"
>
Horror
</button>
<button
type="button"
:class="{ an: form.stil === 'nett' }"
@click="stilWaehlen('nett')"
>
Kein Horror
</button>
</div>
<div class="zeile">
<label>
Vorzeile
<input v-model="form.vorzeile" type="text" maxlength="30" />
</label>
<label>
Überschrift
<input v-model="form.titel" type="text" maxlength="24" />
</label>
</div>
<div class="zeile">
<label>
Datum
<input v-model="form.datum" type="date" />
</label>
<label>
Uhrzeit
<input v-model="form.uhrzeit" type="time" />
</label>
</div>
<label>
Genre
<input v-model="form.genre" type="text" maxlength="24" />
</label>
<label>
Discord
<input v-model="form.ort" type="text" maxlength="60" />
</label>
<label>
Webseite
<input v-model="form.url" type="text" maxlength="60" />
</label>
<div class="fuss">
<button type="button" class="speichern" @click="speichern">Als Bild speichern</button>
<button type="button" class="abbrechen" @click="emit('zu')">Schließen</button>
</div>
</div>
<canvas ref="leinwand" class="vorschau" :width="B" :height="H"></canvas>
</div>
</div>
</Teleport>
</template>
<style scoped>
.hintergrund {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.8);
}
.fenster {
display: grid;
grid-template-columns: 1fr 320px;
gap: 1.5rem;
width: min(52rem, 100%);
max-height: 92vh;
padding: 1.5rem 1.75rem;
overflow-y: auto;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 14px;
}
.formular {
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.label {
color: var(--accent);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.stil {
display: flex;
gap: 0.4rem;
}
.stil button {
flex: 1;
padding: 0.5rem;
color: var(--muted);
font-weight: 600;
border: 1px solid var(--line);
border-radius: 8px;
}
.stil button.an {
color: var(--text);
border-color: var(--accent);
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
color: var(--muted);
font-size: 0.8rem;
}
.zeile {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.6rem;
}
input[type='text'],
input[type='date'],
input[type='time'] {
padding: 0.45rem 0.6rem;
color: var(--text);
font-size: 0.95rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 8px;
color-scheme: dark;
}
.fuss {
display: flex;
gap: 0.5rem;
margin-top: auto;
padding-top: 0.6rem;
}
.speichern {
padding: 0.6rem 1.2rem;
color: #fff;
font-weight: 600;
background: var(--accent);
border-radius: 8px;
}
.abbrechen {
padding: 0.6rem 1rem;
color: var(--muted);
border: 1px solid var(--line);
border-radius: 8px;
}
/* Die Vorschau: das volle Bild, nur kleiner gezeigt */
.vorschau {
width: 100%;
height: auto;
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);
}
@media (max-width: 760px) {
.fenster {
grid-template-columns: 1fr;
}
.vorschau {
max-width: 320px;
margin: 0 auto;
}
}
</style>

View File

@@ -0,0 +1,151 @@
<script setup>
// Anmelden per Film: Wer einen Anmeldefilm hinterlegt hat, muss ihn erst
// in der Suche finden. Welcher es ist, verrät der Server nicht — hier
// steht nur, ob der Klick gesessen hat.
import { onMounted, onUnmounted, ref } from 'vue'
import FilmSuche from './FilmSuche.vue'
const props = defineProps({
name: { type: String, required: true },
// Gibt zurück, ob der geklickte Film der richtige war
pruefen: { type: Function, required: true },
})
const emit = defineEmits(['abbrechen'])
const fehler = ref('')
const laeuft = ref(false)
// Nach zwei Fehlversuchen steht da, dass es der Host auch richten kann
const versuche = ref(0)
async function probieren(film) {
if (laeuft.value) return
laeuft.value = true
fehler.value = ''
const ok = await props.pruefen(film.id)
laeuft.value = false
if (ok) return
versuche.value += 1
fehler.value = 'Das ist nicht der Film.'
}
function beiTaste(e) {
if (e.key === 'Escape') emit('abbrechen')
}
onMounted(() => document.addEventListener('keydown', beiTaste))
onUnmounted(() => document.removeEventListener('keydown', beiTaste))
</script>
<template>
<Teleport to="body">
<div class="hintergrund" @click.self="emit('abbrechen')">
<div class="fenster" role="dialog" aria-modal="true">
<p class="label">Anmelden als</p>
<h2>{{ name }}</h2>
<p class="frage">Welchen Film hast du hinterlegt?</p>
<FilmSuche platzhalter="Dein Anmeldefilm …" @waehlen="probieren" />
<p v-if="fehler" class="fehler">
{{ fehler }}
<span v-if="versuche >= 2" class="tipp">
Der Host kann dir in der Verwaltung einen neuen setzen.
</span>
</p>
<div class="fuss">
<button type="button" class="abbrechen" @click="emit('abbrechen')">
Abbrechen
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.hintergrund {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.8);
}
.fenster {
display: flex;
flex-direction: column;
gap: 0.6rem;
width: min(38rem, 100%);
max-height: min(42rem, 90vh);
padding: 1.5rem 1.75rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 14px;
}
.label {
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
h2 {
font-size: 1.35rem;
line-height: 1.2;
}
.frage {
color: var(--muted);
font-size: 0.9rem;
}
.fehler {
color: var(--accent);
font-size: 0.88rem;
}
.tipp {
display: block;
margin-top: 0.15rem;
color: var(--muted);
font-size: 0.82rem;
}
.fuss {
display: flex;
justify-content: flex-end;
}
.abbrechen {
padding: 0.55rem 1.1rem;
color: var(--muted);
font-size: 0.88rem;
font-weight: 600;
border: 1px solid var(--line);
border-radius: 8px;
}
.abbrechen:hover {
color: var(--text);
}
@media (max-width: 640px) {
.hintergrund {
padding: 0;
}
.fenster {
width: 100%;
height: 100%;
max-height: none;
padding: 1rem;
border: 0;
border-radius: 0;
}
}
</style>

View File

@@ -0,0 +1,178 @@
<script setup>
// Suchfeld plus Posterraster — dieselbe Suche wie im Reiter, nur klein.
// Wird zweimal gebraucht: beim Anmelden und beim Hinterlegen des Films.
import { onMounted, ref, watch } from 'vue'
import { posterUrl } from '../images'
defineProps({
platzhalter: { type: String, default: 'Film suchen …' },
})
const emit = defineEmits(['waehlen'])
const feld = ref(null)
const query = ref('')
const treffer = ref([])
const laden = ref(false)
let timer = null
let laufende = 0
async function suchen(begriff) {
const wort = begriff.trim()
if (wort.length < 2) {
treffer.value = []
return
}
const meine = ++laufende
laden.value = true
try {
const resp = await fetch(`/api/search?q=${encodeURIComponent(wort)}`)
const daten = resp.ok ? await resp.json() : []
// Eine ältere Anfrage darf ein neueres Ergebnis nicht überschreiben
if (meine !== laufende) return
treffer.value = daten
} catch {
if (meine === laufende) treffer.value = []
} finally {
if (meine === laufende) laden.value = false
}
}
// Beim Tippen bremsen, bei Enter sofort
watch(query, (wort) => {
clearTimeout(timer)
timer = setTimeout(() => suchen(wort), 350)
})
function jetzt() {
clearTimeout(timer)
suchen(query.value)
}
onMounted(() => feld.value?.focus())
</script>
<template>
<div class="film-suche">
<input
ref="feld"
v-model="query"
type="search"
:placeholder="platzhalter"
@keyup.enter="jetzt"
/>
<p v-if="laden" class="note">Suche läuft…</p>
<p v-else-if="query.trim().length < 2" class="note">
Tipp den Titel — dann klick den richtigen Film an.
</p>
<p v-else-if="!treffer.length" class="note">Nichts gefunden.</p>
<div v-else class="raster">
<button
v-for="film in treffer"
:key="film.id"
type="button"
class="kachel"
@click="emit('waehlen', film)"
>
<img
v-if="posterUrl(film.poster_path, 'w185')"
:src="posterUrl(film.poster_path, 'w185')"
:alt="film.title"
loading="lazy"
/>
<span v-else class="kein-cover">Kein Cover</span>
<span class="titel">{{ film.title }}</span>
<span v-if="film.year" class="jahr">{{ film.year }}</span>
</button>
</div>
</div>
</template>
<style scoped>
.film-suche {
display: flex;
flex-direction: column;
gap: 0.8rem;
min-height: 0;
}
input {
width: 100%;
padding: 0.7rem 0.9rem;
font-size: 0.95rem;
}
.note {
padding: 1.5rem 0;
color: var(--muted);
font-size: 0.88rem;
text-align: center;
}
.raster {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
gap: 0.6rem;
overflow-y: auto;
padding-right: 0.2rem;
}
.kachel {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0;
text-align: left;
}
.kachel img,
.kein-cover {
width: 100%;
aspect-ratio: 2 / 3;
object-fit: cover;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 8px;
}
.kein-cover {
display: grid;
place-items: center;
padding: 0.4rem;
color: var(--muted);
font-size: 0.7rem;
text-align: center;
}
.kachel:hover img,
.kachel:hover .kein-cover,
.kachel:focus-visible img {
border-color: var(--accent);
}
.titel {
overflow: hidden;
font-size: 0.78rem;
line-height: 1.25;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.jahr {
color: var(--muted);
font-size: 0.72rem;
}
@media (max-width: 640px), (pointer: coarse) {
input {
font-size: 16px;
}
.raster {
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
}
}
</style>

View File

@@ -12,6 +12,12 @@ const props = defineProps({
ohneSuche: Boolean,
// Nur wo TMDB die Reihenfolge liefert: Suche und Ähnlichkeitsuche
mitRelevanz: Boolean,
// Die KI-Suche hat keine Sortierung — ihre Reihenfolge ist die Relevanz
ohneSort: Boolean,
// Der Katalog hat zwei Listen zur Wahl: [{ id, label, count }]
quellen: { type: Array, default: () => [] },
// Die Suchen lassen wählen, wie viele Treffer es sein sollen
mitAnzahl: Boolean,
})
const emit = defineEmits(['toggle', 'all', 'reset'])
@@ -19,7 +25,13 @@ 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: () => ({}) })
const quelle = defineModel('quelle', { type: String, default: '' })
const anzahl = defineModel('anzahl', { type: Number, default: 24 })
// Auch ein verschobener Regler zählt als aktiver Filter
const etwasAktiv = computed(
@@ -30,7 +42,7 @@ const etwasAktiv = computed(
const spanne = ranges.value[r.key]
const grenze = props.grenzen[r.key]
return spanne && grenze && (spanne[0] > grenze.min || spanne[1] < grenze.max)
})
}),
)
function beschriften(regler) {
@@ -44,19 +56,26 @@ function beschriften(regler) {
return zahl + (regler.einheit || '')
}
}
</script>
<template>
<aside class="sidebar">
<div class="controls">
<input
v-if="!ohneSuche"
v-model="search"
type="search"
placeholder="Titel suchen…"
/>
<select v-model="sort">
<div v-if="quellen.length" class="ansicht">
<button
v-for="q in quellen"
:key="q.id"
type="button"
:class="{ an: quelle === q.id }"
@click="quelle = q.id"
>
{{ q.label }}
<span v-if="q.count != null" class="zahl">{{ q.count }}</span>
</button>
</div>
<input v-if="!ohneSuche" v-model="search" type="search" placeholder="Titel suchen…" />
<select v-if="!ohneSort" v-model="sort">
<option v-if="mitRelevanz" value="relevanz">Wie auf IMDb</option>
<option value="random">Zufällig</option>
<option value="rating">Beste Bewertung</option>
@@ -65,22 +84,24 @@ function beschriften(regler) {
<option value="title">Titel A–Z</option>
</select>
<label v-if="mitAnzahl" class="anzahl">
<span>Ergebnisse <b>{{ anzahl }}</b></span>
<input v-model.number="anzahl" type="range" min="1" max="100" />
</label>
<div class="ansicht">
<button
type="button"
:class="{ an: ansicht === 'kompakt' }"
@click="ansicht = 'kompakt'"
>
<button type="button" :class="{ an: ansicht === 'kompakt' }" @click="ansicht = 'kompakt'">
Kompakt
</button>
<button
type="button"
:class="{ an: ansicht === 'details' }"
@click="ansicht = 'details'"
>
<button type="button" :class="{ an: ansicht === 'details' }" @click="ansicht = 'details'">
Details
</button>
</div>
<label class="gesehen-schalter">
<input v-model="ohneGesehene" type="checkbox" />
Gesehene ausblenden
</label>
</div>
<div class="head">
@@ -158,6 +179,25 @@ function beschriften(regler) {
</template>
<style scoped>
.gesehen-schalter {
display: flex;
gap: 0.5rem;
align-items: center;
/* Eigene Zeile unter Kompakt/Details, sonst quetscht er sich daneben */
width: 100%;
white-space: nowrap;
color: var(--muted);
font-size: 0.82rem;
cursor: pointer;
user-select: none;
}
/* .controls input zieht sonst auf volle Breite — die Checkbox bleibt schmal */
.controls .gesehen-schalter input {
width: auto;
accent-color: var(--accent);
}
.sidebar {
display: flex;
flex-direction: column;
@@ -266,11 +306,34 @@ h4 {
text-decoration: line-through;
}
.anzahl {
display: flex;
flex-direction: column;
gap: 0.2rem;
color: var(--muted);
font-size: 0.82rem;
}
.anzahl b {
color: var(--text);
}
.controls .anzahl input {
padding: 0;
accent-color: var(--accent);
}
.ansicht {
display: flex;
gap: 0.3rem;
}
.ansicht .zahl {
margin-left: 0.25rem;
font-size: 0.7rem;
opacity: 0.7;
}
.ansicht button {
flex: 1;
padding: 0.4rem 0.5rem;
@@ -290,4 +353,47 @@ h4 {
font-weight: 600;
border-color: var(--accent);
}
/* Finger statt Maus: alles etwas größer. 16 px in den Feldern, sonst
zoomt iOS beim Tippen in die Seite. */
@media (max-width: 640px), (pointer: coarse) {
.sidebar {
gap: 1.4rem;
padding: 1rem;
}
.controls input,
.controls select {
padding: 0.7rem 0.85rem;
font-size: 16px;
}
.controls .gesehen-schalter {
font-size: 0.95rem;
}
.controls .gesehen-schalter input {
width: 1.25rem;
height: 1.25rem;
}
.ansicht button {
padding: 0.65rem 0.5rem;
font-size: 0.9rem;
}
.badge {
padding: 0.5rem 0.85rem;
font-size: 0.9rem;
}
.badges {
gap: 0.45rem;
}
.reset {
padding: 0.4rem 0;
font-size: 0.85rem;
}
}
</style>

View File

@@ -0,0 +1,473 @@
<script setup>
// Ein Markdown-Text, mehr nicht: PayPal, IBAN, Regeln, was man wissen
// muss. Der Host schreibt ihn hier direkt; bis dahin steht die Vorlage
// mit den Punkteregeln.
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { abonnieren } from '../live'
import { rendern } from '../markdown'
import MarkdownFeld from './MarkdownFeld.vue'
const props = defineProps({
host: Boolean,
// Die Runde — daraus wählt der Host, wer ein Blatt bekommt
runde: { type: Array, default: () => [] },
})
const text = ref('')
const laden = ref(true)
const bearbeitet = ref(false)
const entwurf = ref('')
const fehler = ref('')
async function laden_() {
try {
const resp = await fetch('/api/info')
if (!resp.ok) throw new Error('Infos konnten nicht geladen werden')
text.value = (await resp.json()).text
fehler.value = ''
} catch (err) {
fehler.value = err.message
} finally {
laden.value = false
}
}
function bearbeiten() {
entwurf.value = text.value
bearbeitet.value = true
}
async function speichern() {
const resp = await fetch('/api/info', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: entwurf.value }),
})
if (!resp.ok) {
fehler.value = 'Speichern hat nicht geklappt.'
return
}
bearbeitet.value = false
laden_()
}
// ── Spendenbaum: ein Blatt je Person, das neueste oben ─────────────────
const blaetter = ref([])
const gewaehlt = ref('')
// Der Kontostand läuft als Erstes über den Bildschirm
const kontostand = ref(null)
const standEntwurf = ref('')
async function baumLaden() {
try {
const resp = await fetch('/api/spenden')
if (resp.ok) blaetter.value = await resp.json()
const stand = await fetch('/api/kontostand')
if (stand.ok) kontostand.value = (await stand.json()).betrag
} catch {
// Der Abspann ist Beiwerk — ohne ihn geht die Seite trotzdem
}
}
// Die Wartezeit vor dem Abspann gilt nur für den ersten Durchlauf. Nach
// jedem Durchlauf die Animation neu ansetzen — dann wartet sie wieder.
function neuAnsetzen(e) {
const el = e.target
el.style.animationName = 'none'
void el.offsetHeight
el.style.animationName = ''
mischen()
}
function euro(betrag) {
return betrag.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })
}
async function standSetzen() {
const betrag = standEntwurf.value === '' ? null : Number(standEntwurf.value)
const resp = await fetch('/api/kontostand', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ betrag }),
})
if (resp.ok) kontostand.value = (await resp.json()).betrag
standEntwurf.value = ''
}
// Im Abspann stehen die Namen bei jedem Durchlauf in anderer Reihenfolge —
// niemand steht immer oben
const gemischt = ref([])
function mischen() {
const liste = [...blaetter.value]
for (let i = liste.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[liste[i], liste[j]] = [liste[j], liste[i]]
}
gemischt.value = liste
}
watch(blaetter, mischen)
// Wer noch kein Blatt hat
const ohneBlatt = computed(() =>
props.runde.filter((u) => !blaetter.value.some((b) => b.user_id === u.id)),
)
async function blattSetzen() {
if (!gewaehlt.value) return
const resp = await fetch('/api/spenden', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: Number(gewaehlt.value) }),
})
if (resp.ok) blaetter.value = await resp.json()
gewaehlt.value = ''
}
async function blattWeg(userId) {
const resp = await fetch(`/api/spenden/${userId}`, { method: 'DELETE' })
if (resp.ok) blaetter.value = await resp.json()
}
let abbestellen = null
onMounted(() => {
laden_()
baumLaden()
abbestellen = abonnieren((nachricht) => {
if (nachricht.type === 'info') laden_()
else if (nachricht.type === 'spenden') baumLaden()
})
})
onUnmounted(() => abbestellen?.())
</script>
<template>
<div class="info-tab">
<div class="text">
<p v-if="laden" class="note">Lade …</p>
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
<MarkdownFeld
v-else-if="bearbeitet"
v-model="entwurf"
platzhalter="PayPal, IBAN, Regeln, was man wissen muss …"
knopf="Speichern"
:maxlaenge="8000"
@senden="speichern"
@abbrechen="bearbeitet = false"
/>
<template v-else>
<div class="oben">
<button v-if="host" type="button" class="klein" @click="bearbeiten">Bearbeiten</button>
</div>
<div class="markdown" v-html="rendern(text)"></div>
</template>
</div>
<aside class="abspann">
<!-- Ein Röhrenfernseher: im Bild läuft der Abspann mit den Namen -->
<div class="tv">
<svg class="gehaeuse" viewBox="0 0 320 262" aria-hidden="true">
<rect
x="6"
y="6"
width="308"
height="218"
rx="24"
fill="#2b2522"
stroke="#171311"
stroke-width="3"
/>
<rect x="22" y="22" width="240" height="182" rx="24" fill="#0a0a0c" />
<circle cx="288" cy="64" r="13" fill="#3c3430" stroke="#171311" stroke-width="2" />
<rect x="287" y="53" width="2" height="8" fill="#171311" />
<circle cx="288" cy="106" r="13" fill="#3c3430" stroke="#171311" stroke-width="2" />
<rect x="287" y="95" width="2" height="8" fill="#171311" />
<g fill="#171311">
<rect x="278" y="140" width="20" height="3" rx="1.5" />
<rect x="278" y="148" width="20" height="3" rx="1.5" />
<rect x="278" y="156" width="20" height="3" rx="1.5" />
<rect x="278" y="164" width="20" height="3" rx="1.5" />
<rect x="278" y="172" width="20" height="3" rx="1.5" />
</g>
<rect x="56" y="224" width="26" height="34" rx="5" fill="#171311" />
<rect x="238" y="224" width="26" height="34" rx="5" fill="#171311" />
</svg>
<div class="schirm">
<!-- Der Abspann: der Kontostand führt ihn an. Er steht beim Start
schon im Bild, wartet kurz und läuft dann nach oben hinaus -->
<div class="lauf">
<div
v-if="blaetter.length || kontostand !== null"
class="rolle"
:style="{ animationDuration: `1s, ${5 + blaetter.length * 1.5}s` }"
@animationiteration="neuAnsetzen"
>
<template v-if="kontostand !== null">
<p class="vorspann">Kontostand</p>
<p class="betrag">{{ euro(kontostand) }}</p>
</template>
<template v-if="blaetter.length">
<p class="vorspann">Supporter</p>
<p v-for="b in gemischt" :key="b.user_id" class="name">{{ b.name }}</p>
</template>
</div>
<p v-else class="niemand">Noch niemand</p>
</div>
<span class="scanlines"></span>
</div>
</div>
<template v-if="host">
<form class="eintrag" @submit.prevent="standSetzen">
<input
v-model="standEntwurf"
type="number"
step="0.01"
:placeholder="kontostand === null ? 'Kontostand in €' : euro(kontostand)"
/>
<button type="submit" class="klein">Speichern</button>
</form>
<form class="eintrag" @submit.prevent="blattSetzen">
<select v-model="gewaehlt">
<option value="">Name …</option>
<option v-for="u in ohneBlatt" :key="u.id" :value="u.id">{{ u.name }}</option>
</select>
<button type="submit" class="klein" :disabled="!gewaehlt">Eintragen</button>
</form>
<ul v-if="blaetter.length" class="liste">
<li v-for="b in blaetter" :key="b.user_id">
{{ b.name }}
<button type="button" class="weg" title="Entfernen" @click="blattWeg(b.user_id)">
×
</button>
</li>
</ul>
</template>
</aside>
</div>
</template>
<style scoped>
.info-tab {
display: grid;
/* Zwei gleiche Spalten, links der Text, rechts der Baum — als Block
in der Mitte, mit Luft zu den Rändern */
grid-template-columns: 1fr 1fr;
gap: 4rem;
align-items: start;
max-width: 1200px;
margin: 0 auto;
padding: 1rem 2rem;
}
.text {
max-width: 640px;
}
.abspann {
/* Feste Breite: mit automatischem Rand schrumpft ein Rasterfeld sonst
auf die Eigengröße des SVG zusammen */
width: min(100%, 450px);
margin: 0 auto;
}
/* ── Der Fernseher: Gehäuse als SVG, der Bildschirm liegt darüber ───── */
.tv {
position: relative;
aspect-ratio: 320 / 262;
}
.gehaeuse {
display: block;
width: 100%;
height: 100%;
}
/* Deckt genau die Bildschirmmulde des SVG ab (22/22, 240×182 von 320×262) */
.schirm {
position: absolute;
top: 8.4%;
left: 6.9%;
width: 75%;
height: 69.5%;
display: flex;
flex-direction: column;
overflow: hidden;
background: radial-gradient(ellipse at center, #171a1f 0%, #0a0a0c 75%);
border-radius: 9% / 12%;
box-shadow: inset 0 0 40px rgba(0, 0, 0, 0.9);
}
/* Die Zeilenstruktur einer Röhre */
.scanlines {
position: absolute;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.28) 0 2px, transparent 2px 4px);
}
/* Der Abspann: steht beim Start im Bild, läuft nach oben hinaus, von vorn */
.lauf {
position: relative;
flex: 1;
overflow: hidden;
}
.rolle {
position: absolute;
top: 50%;
left: 0;
width: 100%;
text-align: center;
/* Die erste Zeile startet in der Bildmitte und blendet eine Sekunde
lang ein, dann rollt alles nach oben hinaus. Die Dauern setzt die
Vorlage: 1 s fürs Einblenden, der Rest nach Anzahl der Namen */
animation:
einblenden ease-out both,
abspann linear infinite;
animation-delay: 0s, 1s;
}
@keyframes einblenden {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.schirm:hover .rolle {
animation-play-state: paused;
}
/* Am Ende liegt die Unterkante über dem Bildrand: top auf 0, dazu die
eigene Höhe nach oben */
@keyframes abspann {
from {
top: 50%;
transform: translateY(0);
}
to {
top: 0;
transform: translateY(-100%);
}
}
.vorspann {
margin-bottom: 1.2rem;
color: var(--muted);
font-size: 0.9rem;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.name {
margin-bottom: 1rem;
color: #d9dde2;
font-size: 1.3rem;
letter-spacing: 0.04em;
}
.betrag {
margin-bottom: 2rem;
color: #d9dde2;
font-size: 2rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.niemand {
display: grid;
height: 100%;
place-items: center;
color: var(--muted);
font-size: 0.8rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.eintrag {
display: flex;
gap: 0.4rem;
margin-top: 1rem;
}
.eintrag select,
.eintrag input {
flex: 1;
min-width: 0;
padding: 0.3rem 0.5rem;
color: var(--text);
font-size: 0.85rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 6px;
}
/* Die Namen zum Entfernen — nur der Host sieht die Liste */
.liste {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.8rem;
list-style: none;
}
.liste li {
display: inline-flex;
gap: 0.3rem;
align-items: center;
padding: 0.2rem 0.5rem;
font-size: 0.85rem;
border: 1px solid var(--line);
border-radius: 6px;
}
.weg {
color: var(--muted);
font-size: 0.95rem;
line-height: 1;
}
.weg:hover {
color: var(--accent);
}
@media (max-width: 900px) {
.info-tab {
grid-template-columns: 1fr;
gap: 2rem;
}
}
.oben {
margin-bottom: 0.6rem;
}
.klein {
padding: 0.2rem 0.55rem;
color: var(--muted);
font-size: 0.72rem;
border: 1px solid var(--line);
border-radius: 6px;
}
.klein:hover {
color: var(--text);
}
.note {
padding: 2.5rem 0;
color: var(--muted);
text-align: center;
}
.note.error {
color: var(--accent);
}
</style>

View File

@@ -0,0 +1,222 @@
<script setup>
// Filme finden, indem man beschreibt, was man sucht. Die KI schlägt vor,
// TMDB bestätigt — was es dort nicht gibt, kommt nicht an. Auf Wunsch
// geht die eigene Sammlung mit, dann kennt die KI den Geschmack. Die
// Filterleiste geht als Vorgabe mit und siebt hinterher noch einmal.
import { computed, ref } from 'vue'
import MovieCard from './MovieCard.vue'
import { filterParameter } from '../filter.js'
const props = defineProps({
angemeldet: Boolean,
gesehenIds: { type: Set, default: () => new Set() },
ohneGesehene: { type: Boolean, default: true },
filter: { type: Object, default: () => ({}) },
ansicht: { type: String, default: 'kompakt' },
})
const emit = defineEmits(['open'])
const MAX = 1000
const beschreibung = ref('')
const mitSammlung = ref(false)
const vorschlaege = ref([])
const laden = ref(false)
const fehler = ref('')
const gefragt = ref(false)
// Eine verstellte Leiste ist schon eine Frage: "Horror ab 2015" reicht
const leisteVerstellt = computed(() => {
const { sort, limit, ...rest } = filterParameter(props.filter)
return Object.values(rest).some((w) => w !== '' && w != null)
})
const kannFragen = computed(
() =>
!laden.value &&
(beschreibung.value.trim().length > 0 || mitSammlung.value || leisteVerstellt.value),
)
async function fragen() {
if (!kannFragen.value) return
laden.value = true
fehler.value = ''
try {
const resp = await fetch('/api/ki-suche', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
beschreibung: beschreibung.value.trim(),
mit_sammlung: props.angemeldet && mitSammlung.value,
ohne_gesehene: props.ohneGesehene,
...filterParameter(props.filter),
}),
})
if (!resp.ok) {
const daten = await resp.json().catch(() => ({}))
throw new Error(daten.detail || 'Das hat nicht geklappt.')
}
vorschlaege.value = await resp.json()
gefragt.value = true
} catch (err) {
fehler.value = err.message
} finally {
laden.value = false
}
}
async function open(movie) {
const resp = await fetch(`/api/search/${movie.id}`)
if (resp.ok) emit('open', await resp.json())
}
</script>
<template>
<div class="ki-tab">
<div class="frage">
<textarea
v-model="beschreibung"
:maxlength="MAX"
rows="3"
placeholder="Was suchst du?"
@keydown.ctrl.enter="fragen"
></textarea>
<div class="zeile">
<label v-if="angemeldet" class="schalter">
<input v-model="mitSammlung" type="checkbox" />
Meine Sammlung mitgeben
</label>
<span class="rest"></span>
<button type="button" class="los" :disabled="!kannFragen" @click="fragen">
{{ laden ? 'Sucht …' : 'Suchen' }}
</button>
</div>
</div>
<p v-if="laden" class="note">Die KI überlegt. Das dauert ein paar Sekunden.</p>
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
<p v-else-if="gefragt && !vorschlaege.length" class="note">
Nichts Brauchbares gefunden. Beschreib es anders.
</p>
<!-- Wo sonst die Inhaltsangabe steht, sagt hier die KI, warum der
Film passt -->
<div class="grid" :class="ansicht">
<MovieCard
v-for="movie in vorschlaege"
:key="movie.id"
:movie="{ ...movie, teaser: movie.grund }"
:ansicht="ansicht"
:gesehen="gesehenIds.has(movie.id)"
@open="open"
/>
</div>
</div>
</template>
<style scoped>
.ki-tab {
min-width: 0;
}
.frage {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-bottom: 1.5rem;
}
textarea {
width: 100%;
min-height: 5.5rem;
padding: 0.9rem 1.1rem;
color: var(--text);
font: inherit;
font-size: 1.05rem;
line-height: 1.5;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 8px;
resize: vertical;
}
textarea:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
}
.zeile {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: center;
}
.schalter {
display: flex;
gap: 0.45rem;
align-items: center;
color: var(--muted);
font-size: 0.9rem;
cursor: pointer;
}
.schalter input {
accent-color: var(--accent);
}
.rest {
flex: 1;
}
.los {
padding: 0.6rem 1.4rem;
color: #fff;
font-size: 0.95rem;
font-weight: 600;
background: var(--accent);
border-radius: 8px;
}
.los:hover:not(:disabled) {
filter: brightness(1.15);
}
.los:disabled {
opacity: 0.5;
cursor: default;
}
/* Wie in der Suche: Poster im Raster, Detailkarten zu zweit nebeneinander */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 1.1rem;
}
.grid.details {
grid-template-columns: repeat(auto-fill, min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem)));
}
.note {
padding: 2.5rem 0;
color: var(--muted);
text-align: center;
}
.note.error {
color: var(--accent);
}
@media (max-width: 640px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(105px, 1fr));
gap: 0.6rem;
}
.grid.details {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,279 @@
<script setup>
// Ein Kommentar in der Diskussion: der Text, darunter Herz, Name, Datum
// und „Kommentieren“. Antworten rücken darunter ein — die Komponente ruft sich dafür selbst auf, so tief
// es geht. Ab der vierten Ebene nicht weiter, sonst bleibt auf dem Handy
// kein Platz für den Text.
import { computed, ref } from 'vue'
import { rendern } from '../markdown'
import MarkdownFeld from './MarkdownFeld.vue'
import PersonName from './PersonName.vue'
const props = defineProps({
kommentar: { type: Object, required: true },
watchedId: { type: Number, required: true },
angemeldet: Boolean,
host: Boolean,
tiefe: { type: Number, default: 0 },
})
const antwortet = ref(false)
const entwurf = ref('')
async function antworten() {
const text = entwurf.value.trim()
if (!text) return
const resp = await fetch(`/api/watched/${props.watchedId}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, parent_id: props.kommentar.id }),
})
if (resp.ok) {
entwurf.value = ''
antwortet.value = false
}
}
async function herz() {
if (!props.angemeldet) return
await fetch('/api/watched/hearts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note_id: props.kommentar.id }),
})
}
// Wer das Herz gegeben hat, sieht nur der Host — als Tooltip
const herzTitel = computed(() => {
const namen = props.host ? props.kommentar.hearted_by || [] : []
if (namen.length) return namen.join(', ')
return props.kommentar.hearted ? 'Herz zurücknehmen' : 'Sehe ich auch so'
})
async function loeschen() {
await fetch(`/api/watched-notes/${props.kommentar.id}`, { method: 'DELETE' })
}
function datum(iso) {
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
</script>
<template>
<li class="zeile">
<div class="inhalt">
<div class="markdown" v-html="rendern(kommentar.text)"></div>
<p class="meta">
<button
type="button"
class="pille"
:class="{ an: kommentar.hearted, hat: kommentar.hearts > 0 }"
:disabled="!angemeldet"
:title="herzTitel"
@click="herz"
>
♥ {{ kommentar.hearts }}
</button>
<span :class="{ eigen: kommentar.mine }"><PersonName :name="kommentar.name" /></span>
<span class="punkt">·</span>{{ datum(kommentar.created_at) }}
<template v-if="angemeldet">
<span class="punkt">·</span>
<button type="button" class="link" :class="{ offen: antwortet }" @click="antwortet = !antwortet">
Kommentieren
</button>
</template>
</p>
<MarkdownFeld
v-if="antwortet"
v-model="entwurf"
kompakt
class="neu"
:platzhalter="`Antwort an ${kommentar.name || 'Anonym'} …`"
knopf="Antworten"
@senden="antworten"
@abbrechen="antwortet = false"
/>
<ul v-if="kommentar.replies.length" class="antworten" :class="{ flach: tiefe >= 3 }">
<Kommentar
v-for="k in kommentar.replies"
:key="k.id"
:kommentar="k"
:watched-id="watchedId"
:angemeldet="angemeldet"
:host="host"
:tiefe="tiefe + 1"
/>
</ul>
</div>
<!-- Erst beim Überfahren sichtbar, sonst ist jede Zeile voller Knöpfe -->
<div class="aktionen">
<button
v-if="kommentar.mine || host"
type="button"
class="weg"
:title="kommentar.replies.length ? 'Samt allen Antworten darunter' : 'Kommentar entfernen'"
@click="loeschen"
>
×
</button>
</div>
</li>
</template>
<style scoped>
.zeile {
display: flex;
gap: 0.8rem;
align-items: flex-start;
padding: 0.55rem 0.5rem;
border-bottom: 1px solid var(--line);
border-radius: 6px;
}
.zeile:hover {
background: rgba(255, 255, 255, 0.02);
}
/* Das Herz klein vor dem Namen, in der Zeile mit Datum und Antworten */
.pille {
margin-right: 0.5rem;
padding: 0 0.35rem;
color: var(--muted);
font-size: 0.68rem;
font-weight: 600;
line-height: 1.5;
white-space: nowrap;
border: 1px solid var(--line);
border-radius: 20px;
}
.pille:hover:not(:disabled) {
color: var(--text);
border-color: var(--accent);
}
.pille.hat {
color: var(--text);
}
.pille.an {
color: var(--accent);
border-color: var(--accent);
}
.pille:disabled {
cursor: default;
}
.inhalt {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 0.15rem;
}
/* Herz, Name, Datum und Antworten mittig auf einer Linie */
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
color: var(--muted);
font-size: 0.76rem;
line-height: 1.4;
}
.punkt {
margin: 0 0.35rem;
}
.eigen {
color: var(--accent);
font-weight: 600;
}
.link {
padding: 0;
color: var(--muted);
font-size: inherit;
text-decoration: underline;
text-decoration-color: transparent;
text-underline-offset: 2px;
}
.link:hover,
.link.offen {
color: var(--text);
text-decoration-color: currentColor;
}
.neu {
margin-top: 0.4rem;
}
/* Antworten hängen eingerückt unter dem Kommentar */
.antworten {
margin-top: 0.4rem;
padding-left: 0.4rem;
list-style: none;
border-left: 2px solid var(--line);
}
.antworten .zeile {
padding-right: 0;
border-bottom: 0;
}
/* Ab der vierten Ebene nicht weiter einrücken */
.antworten.flach {
padding-left: 0;
border-left: 0;
}
.aktionen {
display: flex;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.12s;
}
.zeile:hover > .aktionen,
.zeile:focus-within > .aktionen {
opacity: 1;
}
.weg {
padding: 0 0.25rem;
color: var(--muted);
font-size: 1.05rem;
line-height: 1.2;
}
.weg:hover {
color: var(--accent);
}
@media (hover: none) {
.aktionen {
opacity: 1;
}
}
@media (max-width: 640px), (pointer: coarse) {
.pille {
padding: 0.25rem 0.55rem;
font-size: 0.82rem;
}
.link,
.weg {
padding: 0.3rem 0.4rem;
}
}
</style>

View File

@@ -0,0 +1,282 @@
<script setup>
// Ein Textfeld mit den paar Handgriffen, die man beim Schreiben braucht:
// fett, kursiv, Überschrift, Liste, Zitat — als Markdown, mit Vorschau.
// Klappt dort auf, wo man schreibt, statt über der Seite zu schweben.
// Dient für Rezensionen, Kommentare und Antworten; „kompakt“ macht es
// für Antworten eine Nummer kleiner.
import { computed, nextTick, onMounted, ref } from 'vue'
import { rendern } from '../markdown'
const props = defineProps({
platzhalter: { type: String, default: 'Schreib etwas …' },
knopf: { type: String, default: 'Senden' },
maxlaenge: { type: Number, default: 1000 },
kompakt: Boolean,
// Senden geht nur, wenn außerdem das hier stimmt — etwa Sterne gesetzt
bereit: { type: Boolean, default: true },
hinweis: { type: String, default: '' },
})
const emit = defineEmits(['senden', 'abbrechen'])
const text = defineModel({ type: String, default: '' })
const vorschau = ref(false)
const feld = ref(null)
const gerendert = computed(() => rendern(text.value))
const kannSenden = computed(() => props.bereit && text.value.trim().length > 0)
// ── Handgriffe: Markdown an der Cursorstelle einfügen ─────────────────
async function umschliessen(vor, nach = vor, platzhalter = 'Text') {
const el = feld.value
const a = el.selectionStart
const b = el.selectionEnd
const gewaehlt = text.value.slice(a, b) || platzhalter
text.value = text.value.slice(0, a) + vor + gewaehlt + nach + text.value.slice(b)
await nextTick()
el.focus()
el.setSelectionRange(a + vor.length, a + vor.length + gewaehlt.length)
}
// Etwas an den Anfang jeder gewählten Zeile setzen — oder wieder weg
async function zeilenanfang(praefix) {
const el = feld.value
const a = el.selectionStart
const b = el.selectionEnd
const t = text.value
const start = t.lastIndexOf('\n', a - 1) + 1
let ende = t.indexOf('\n', b)
if (ende === -1) ende = t.length
const neu = t
.slice(start, ende)
.split('\n')
.map((z) => (z.startsWith(praefix) ? z.slice(praefix.length) : praefix + z))
.join('\n')
text.value = t.slice(0, start) + neu + t.slice(ende)
await nextTick()
el.focus()
el.setSelectionRange(start, start + neu.length)
}
function senden() {
if (kannSenden.value) emit('senden')
}
function beiTaste(e) {
if (e.key === 'Escape') emit('abbrechen')
else if (e.ctrlKey && e.key === 'Enter') senden()
else if (e.ctrlKey && e.key === 'b') {
e.preventDefault()
umschliessen('**')
} else if (e.ctrlKey && e.key === 'i') {
e.preventDefault()
umschliessen('*')
}
}
// Das Feld wächst mit dem Text. Hat man es selbst größer gezogen, bleibt
// das so — es wird nie kleiner als es gerade ist.
function wachsen() {
const el = feld.value
if (!el) return
const gewuenscht = Math.min(el.scrollHeight + 2, props.kompakt ? 320 : 640)
if (gewuenscht > el.offsetHeight) el.style.height = `${gewuenscht}px`
}
onMounted(async () => {
await nextTick()
wachsen()
feld.value?.focus()
})
</script>
<template>
<div class="markdown-feld" :class="{ kompakt }">
<div class="leiste">
<button type="button" title="Fett (Strg+B)" @click="umschliessen('**')"><b>F</b></button>
<button type="button" title="Kursiv (Strg+I)" @click="umschliessen('*')"><i>K</i></button>
<span class="trenner"></span>
<button v-if="!kompakt" type="button" title="Überschrift" @click="zeilenanfang('## ')">Ü</button>
<button type="button" title="Aufzählung" @click="zeilenanfang('- ')">•</button>
<button type="button" title="Nummerierung" @click="zeilenanfang('1. ')">1.</button>
<button type="button" title="Zitat" @click="zeilenanfang('> ')">„</button>
<span class="rest"></span>
<button type="button" class="umschalter" :class="{ an: vorschau }" @click="vorschau = !vorschau">
{{ vorschau ? 'Schreiben' : 'Vorschau' }}
</button>
</div>
<textarea
v-show="!vorschau"
ref="feld"
v-model="text"
:maxlength="maxlaenge"
:placeholder="platzhalter"
spellcheck="true"
@input="wachsen"
@keydown="beiTaste"
></textarea>
<div
v-show="vorschau"
class="vorschau markdown"
v-html="gerendert || '<p><em>Noch nichts geschrieben.</em></p>'"
></div>
<div class="fuss">
<span class="zaehler">{{ text.length }} / {{ maxlaenge }}</span>
<span v-if="hinweis && !bereit" class="hinweis">{{ hinweis }}</span>
<button type="button" class="abbrechen" @click="emit('abbrechen')">Abbrechen</button>
<button type="button" class="senden" :disabled="!kannSenden" @click="senden">
{{ knopf }}
</button>
</div>
</div>
</template>
<style scoped>
.markdown-feld {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.6rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 10px;
}
.leiste {
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
align-items: center;
}
.leiste button {
min-width: 2rem;
height: 2rem;
padding: 0 0.45rem;
color: var(--muted);
font-size: 0.92rem;
border-radius: 6px;
}
.leiste button:hover {
color: var(--text);
background: var(--bg-soft);
}
.trenner {
width: 1px;
height: 1.2rem;
margin: 0 0.25rem;
background: var(--line);
}
.rest {
flex: 1;
}
.umschalter {
font-size: 0.8rem;
border: 1px solid var(--line);
}
.umschalter.an {
color: var(--text);
border-color: var(--accent);
}
textarea,
.vorschau {
width: 100%;
min-height: 14rem;
padding: 0.7rem 0.85rem;
color: var(--text);
font-family: inherit;
font-size: 0.95rem;
line-height: 1.55;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 8px;
}
/* Am Griff unten rechts zieht man es so groß, wie man will */
textarea {
overflow-y: auto;
resize: vertical;
}
textarea:focus {
outline: none;
border-color: var(--accent);
}
.vorschau {
max-height: 30rem;
overflow-y: auto;
}
.kompakt textarea,
.kompakt .vorschau {
min-height: 6rem;
font-size: 0.9rem;
}
.fuss {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.zaehler,
.hinweis {
color: var(--muted);
font-size: 0.76rem;
white-space: nowrap;
}
.zaehler {
flex: 1;
}
.hinweis {
color: var(--accent);
}
.abbrechen,
.senden {
padding: 0.45rem 0.9rem;
font-size: 0.85rem;
font-weight: 600;
border-radius: 8px;
}
.abbrechen {
color: var(--muted);
border: 1px solid var(--line);
}
.abbrechen:hover {
color: var(--text);
}
.senden {
color: #fff;
background: var(--accent);
}
.senden:disabled {
opacity: 0.5;
cursor: default;
}
@media (max-width: 640px), (pointer: coarse) {
textarea {
font-size: 16px;
}
.leiste button {
min-width: 2.4rem;
height: 2.4rem;
}
}
</style>

View File

@@ -1,40 +1,59 @@
<script setup>
import { computed } from 'vue'
import { posterUrl } from '../images'
import MovieCard from './MovieCard.vue'
const props = defineProps({
items: { type: Array, default: () => [] },
ich: { type: Object, default: null },
// Eine eigene Sammlung hat nur, wer in der Runde steht
angemeldet: Boolean,
gesehenIds: { type: Set, default: () => new Set() },
ansicht: { type: String, default: 'kompakt' },
// Leer heißt: alle Sammlungen zeigen
leute: { type: Array, default: () => [] },
// null = alles, true = nur Horror, false = alles außer Horror
horror: { default: null },
})
const emit = defineEmits(['open', 'changed'])
function passt(item) {
if (props.horror === null) return true
return (item.genres || []).includes('Horror') === props.horror
}
// Nach Person gruppiert, die eigene Sammlung zuerst
const gruppen = computed(() => {
const nach = new Map()
for (const item of props.items) {
const schluessel = item.mine ? '\u0000eigene' : item.name || 'Anonym'
if (!nach.has(schluessel)) {
nach.set(schluessel, {
titel: item.mine
? `${props.ich?.name || 'Anonym'} (Deine)`
: item.name || 'Anonym',
if (!passt(item)) continue
const name = item.name || 'Anonym'
if (props.leute.length && !props.leute.includes(name)) continue
if (!nach.has(name)) {
nach.set(name, {
name,
titel: item.mine ? `${name} (Deine)` : name,
mine: item.mine,
filme: [],
})
}
nach.get(schluessel).filme.push(item)
nach.get(name).filme.push(item)
}
return [...nach.values()].sort((a, b) => b.mine - a.mine)
})
const emit = defineEmits(['open', 'changed'])
// Die Karte braucht die Kennung des Films, nicht die des Eintrags
function alsFilm(item) {
return { ...item, id: item.movie_id }
}
async function vergessen(item) {
await fetch(`/api/wishlist/${item.movie_id}`, { method: 'DELETE' })
emit('changed')
}
async function oeffnen(item) {
const resp = await fetch(`/api/search/${item.movie_id}`)
async function oeffnen(film) {
const resp = await fetch(`/api/search/${film.id}`)
if (resp.ok) emit('open', await resp.json())
}
</script>
@@ -44,91 +63,48 @@ async function oeffnen(item) {
<p v-if="!items.length" class="note">
Noch nichts gemerkt. Öffne einen Film und klicke auf „Zur Sammlung“.
</p>
<p v-else-if="!gruppen.length" class="note">
Nichts gefunden. Stell die Filter weiter.
</p>
<section v-for="gruppe in gruppen" :key="gruppe.titel">
<section v-for="gruppe in gruppen" :key="gruppe.name">
<h3>
{{ gruppe.titel }}
<span class="anzahl">{{ gruppe.filme.length }}</span>
</h3>
<div class="grid">
<article v-for="item in gruppe.filme" :key="item.id" class="karte">
<button type="button" class="cover" @click="oeffnen(item)">
<img
v-if="posterUrl(item.poster_path, 'w342')"
:src="posterUrl(item.poster_path, 'w342')"
:alt="item.title"
loading="lazy"
/>
<span v-else class="kein-cover">Kein Cover</span>
<div class="grid" :class="ansicht">
<!-- Der Entfernen-Knopf liegt über der Karte statt darin: eine
Karte ist selbst ein Knopf, und Knöpfe schachtelt man nicht -->
<div v-for="item in gruppe.filme" :key="item.id" class="halter">
<MovieCard
:movie="alsFilm(item)"
:ansicht="ansicht"
:gesehen="gesehenIds.has(item.movie_id)"
@open="oeffnen"
/>
<button
v-if="item.mine"
type="button"
class="weg"
title="Aus deiner Sammlung nehmen"
@click="vergessen(item)"
>
×
</button>
<div class="text">
<h4 @click="oeffnen(item)">{{ item.title }}</h4>
<p v-if="item.year" class="jahr">{{ item.year }}</p>
<button
v-if="item.mine"
type="button"
class="weg"
@click="vergessen(item)"
>
Entfernen
</button>
</div>
</article>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.karte {
display: flex;
gap: 0.9rem;
align-items: flex-start;
padding: 0.8rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 12px;
}
.cover {
flex-shrink: 0;
width: 82px;
padding: 0;
}
.cover img,
.kein-cover {
width: 82px;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 8px;
}
.kein-cover {
display: grid;
place-items: center;
color: var(--muted);
font-size: 0.7rem;
background: var(--bg);
}
.text {
display: flex;
flex-direction: column;
gap: 0.3rem;
.merkliste-tab {
min-width: 0;
}
section {
margin-bottom: 1.75rem;
margin-bottom: 2rem;
}
h3 {
@@ -151,34 +127,58 @@ h3 {
border-radius: 20px;
}
h4 {
font-size: 0.98rem;
line-height: 1.25;
cursor: pointer;
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 1.1rem;
}
h4:hover {
color: var(--accent);
/* Eine Detailkarte ist so breit wie vier kompakte Karten nebeneinander —
oder die halbe Reihe, falls die schmaler ist. */
.grid.details {
grid-template-columns: repeat(
auto-fill,
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
);
}
.jahr {
color: var(--muted);
font-size: 0.8rem;
.halter {
position: relative;
min-width: 0;
}
.weg {
align-self: flex-start;
margin-top: 0.35rem;
padding: 0.28rem 0.6rem;
color: var(--muted);
font-size: 0.75rem;
border: 1px solid var(--line);
border-radius: 6px;
position: absolute;
top: 0.4rem;
left: 0.4rem;
z-index: 2;
display: grid;
place-items: center;
width: 1.7rem;
height: 1.7rem;
color: #fff;
font-size: 1.2rem;
line-height: 1;
opacity: 0;
transition: opacity 0.12s;
background: rgba(0, 0, 0, 0.75);
border-radius: 50%;
}
.halter:hover .weg,
.weg:focus-visible {
opacity: 1;
}
.weg:hover {
color: var(--accent);
border-color: var(--accent);
}
/* Ohne Maus gibt es kein Überfahren — dann steht der Knopf einfach da */
@media (hover: none) {
.weg {
opacity: 1;
}
}
.note {
@@ -186,4 +186,15 @@ h4:hover {
color: var(--muted);
text-align: center;
}
@media (max-width: 640px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(105px, 1fr));
gap: 0.6rem;
}
.grid.details {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -5,6 +5,8 @@ import { backdropUrl, posterUrl } from '../images'
const props = defineProps({
movie: { type: Object, required: true },
aufDemRad: { type: Boolean, default: false },
// Schon gelaufen — die Karte tritt dann zurück
gesehen: { type: Boolean, default: false },
ansicht: { type: String, default: 'kompakt' },
})
@@ -30,7 +32,7 @@ const meta = computed(() =>
<template>
<button
class="card"
:class="[ansicht, { 'auf-dem-rad': aufDemRad }]"
:class="[ansicht, { 'auf-dem-rad': aufDemRad, gesehen }]"
type="button"
@click="$emit('open', movie)"
>
@@ -69,6 +71,18 @@ const meta = computed(() =>
</template>
<style scoped>
/* Schon gelaufen — die Karte tritt zurück, bleibt aber lesbar.
Beim Überfahren kommt sie zurück. */
.card.gesehen {
opacity: 0.45;
filter: grayscale(1);
}
.card.gesehen:hover {
opacity: 1;
filter: none;
}
/* Details: breite Karte, Poster links, alles lesbar statt abgeschnitten */
.haupt {
display: flex;
@@ -244,4 +258,49 @@ h2 {
.card.details .shots {
overflow: hidden;
}
/* Schmal und kompakt: nur Poster, Titel und Jahr */
@media (max-width: 640px) {
.card.kompakt .body {
gap: 0.2rem;
padding: 0.5rem;
}
.card.kompakt h2 {
-webkit-line-clamp: 2;
line-clamp: 2;
min-height: calc(2 * 1.25em);
font-size: 0.82rem;
line-height: 1.25;
}
.card.kompakt .meta {
font-size: 0.72rem;
}
.card.kompakt .teaser {
display: none;
}
.rating {
font-size: 0.72rem;
}
}
/* Schmal: Cover und Text nebeneinander, die Bilder in einer Reihe darunter */
@media (max-width: 700px) {
.card.details {
grid-template-columns: 110px minmax(0, 1fr);
grid-template-rows: auto auto;
}
.card.details .poster {
width: 110px;
}
.card.details .shots {
grid-column: 1 / -1;
grid-template-columns: repeat(4, 1fr);
}
}
</style>

View File

@@ -7,12 +7,14 @@ const props = defineProps({
series: { type: Array, default: () => [] },
meinVorschlag: { type: Object, default: null },
host: { type: Boolean, default: false },
// Ohne gewählten Namen gibt es keine Sammlung und keinen Vorschlag
angemeldet: { type: Boolean, default: false },
gesehen: { type: Boolean, default: false },
gemerkt: { type: Boolean, default: false },
})
const emit = defineEmits([
'close', 'open', 'suggested', 'watched', 'gemerkt', 'aehnlich',
'close', 'open', 'suggested', 'watched', 'gemerkt', 'aehnlich', 'person',
])
const vorschlagen = ref({ zustand: 'bereit' })
@@ -107,11 +109,25 @@ async function ladeAehnliche(id) {
}
}
// Regie und Besetzung — jeder Name führt zur Personensuche
const leute = ref({ regie: [], cast: [] })
async function ladeLeute(id) {
leute.value = { regie: [], cast: [] }
try {
const resp = await fetch(`/api/movies/${id}/credits`)
if (resp.ok && props.movie.id === id) leute.value = await resp.json()
} catch {
/* dann eben ohne */
}
}
watch(
() => props.movie.id,
(id) => {
vorschlagen.value = { zustand: 'bereit' }
ladeAehnliche(id)
ladeLeute(id)
},
{ immediate: true }
)
@@ -174,7 +190,13 @@ onUnmounted(() => {
<header>
<img v-if="poster" class="poster" :src="poster" :alt="movie.title" />
<div class="head-text">
<h2>{{ movie.title }}</h2>
<h2>
{{ movie.title }}
<!-- Läuft nur im Abo: dann bleibt fürs Leihen nichts übrig -->
<span v-if="movie.rentable != null && !movie.rentable" class="nicht-leihbar">
Nicht leihbar
</span>
</h2>
<p v-if="movie.original_title !== movie.title" class="original">
{{ movie.original_title }}
</p>
@@ -186,6 +208,22 @@ onUnmounted(() => {
</span>
</p>
<p class="genres">{{ (movie.genres || []).join(' · ') }}</p>
<p v-for="[titel, liste] in [['Regie', leute.regie], ['Mit', leute.cast]]" :key="titel">
<template v-if="liste.length">
<span class="leute-titel">{{ titel }}</span>
<button
v-for="p in liste"
:key="p.id"
type="button"
class="person"
:title="`Filme mit ${p.name}`"
@click="$emit('person', p)"
>
{{ p.name }}
</button>
</template>
</p>
<p v-if="movie.teaser" class="teaser">{{ movie.teaser }}</p>
<div class="links">
<a
@@ -206,6 +244,16 @@ onUnmounted(() => {
>
IMDb
</a>
<!-- TMDBs Anbieterliste für Deutschland: wo man leihen, kaufen
oder streamen kann. IMDb zeigt davon nur Amazon. -->
<a
class="tmdb"
:href="`https://www.themoviedb.org/movie/${movie.id}/watch?locale=DE`"
target="_blank"
rel="noopener"
>
TMDB
</a>
</div>
<div class="vorschlag">
@@ -213,7 +261,7 @@ onUnmounted(() => {
Ähnliche finden
</button>
<button
v-if="!host"
v-if="!host && angemeldet"
type="button"
class="merken"
:class="{ an: gemerkt }"
@@ -221,7 +269,7 @@ onUnmounted(() => {
>
{{ gemerkt ? 'In Sammlung' : 'Zur Sammlung' }}
</button>
<template v-if="!host">
<template v-if="!host && angemeldet">
<button
type="button"
class="merken"
@@ -235,14 +283,15 @@ onUnmounted(() => {
Hat nicht geklappt.
</span>
</template>
<!-- Ohne Namen fehlen hier zwei Knöpfe — sonst sucht man
vergeblich, warum -->
<p v-if="!host && !angemeldet" class="hinweis">
Zum Merken und Vorschlagen wähle oben rechts deinen Namen.
</p>
</div>
<div v-if="host || gesehen" class="gesehen">
<span v-if="gesehen || merken.zustand === 'fertig'" class="ok">
Steht unter „Gesehen“.
</span>
<div v-if="host && !gesehen && merken.zustand !== 'fertig'" class="gesehen">
<button
v-else-if="host"
type="button"
:disabled="merken.zustand === 'laeuft'"
@click="alsGesehen"
@@ -400,7 +449,13 @@ onUnmounted(() => {
@media (max-width: 1100px) {
.sheet {
grid-template-columns: 1fr;
/* minmax(0, …): sonst hält die Spalte die Breite ihres breitesten
Kindes und die ganze Ansicht läuft seitlich aus dem Schirm */
grid-template-columns: minmax(0, 1fr);
}
.sheet > * {
min-width: 0;
}
.sheet > header,
@@ -472,6 +527,26 @@ h2 {
font-size: 0.82rem;
}
.leute-titel {
margin-right: 0.35rem;
color: var(--muted);
font-size: 0.78rem;
}
/* Namen als kleine Pillen, ein Klick führt zur Personensuche */
.person {
margin: 0 0.3rem 0.3rem 0;
padding: 0.15rem 0.55rem;
color: var(--text);
font-size: 0.78rem;
border: 1px solid var(--line);
border-radius: 20px;
}
.person:hover {
border-color: var(--accent);
}
.teaser {
color: #cfcfda;
font-size: 0.98rem;
@@ -485,7 +560,8 @@ h2 {
}
.netflix,
.imdb {
.imdb,
.tmdb {
padding: 0.55rem 1.1rem;
font-size: 0.9rem;
font-weight: 600;
@@ -497,11 +573,29 @@ h2 {
background: var(--accent);
}
.tmdb {
color: #0d253f;
background: #01b4e4;
}
.imdb {
color: #111;
background: #f5c518;
}
.nicht-leihbar {
display: inline-block;
margin-left: 0.5rem;
padding: 0.15rem 0.45rem;
color: #fff;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.03em;
vertical-align: middle;
background: var(--accent);
border-radius: 4px;
}
/* Der Textblock richtet seine Kinder linksbündig aus, dadurch war der
Trennstrich nur so breit wie die Knöpfe darunter */
.vorschlag {
@@ -596,7 +690,8 @@ h2 {
}
.netflix:hover,
.imdb:hover {
.imdb:hover,
.tmdb:hover {
filter: brightness(1.15);
}
@@ -701,6 +796,46 @@ h3 {
}
@media (max-width: 620px) {
.backdrop {
padding: 0;
}
.sheet {
min-height: 100%;
padding: 1rem;
border: 0;
border-radius: 0;
}
/* Der Schließen-Knopf bleibt beim Scrollen im Bild */
.close {
position: fixed;
top: 0.6rem;
right: 0.6rem;
z-index: 2;
display: grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
color: #fff;
background: rgba(0, 0, 0, 0.65);
border-radius: 50%;
}
h2 {
padding-right: 2.5rem;
font-size: 1.35rem;
}
.vorschlag button,
.gesehen button,
.merken,
.netflix,
.imdb,
.tmdb {
padding: 0.65rem 1rem;
}
header {
flex-direction: column;
}
@@ -712,6 +847,10 @@ h3 {
.shots {
grid-template-columns: 1fr;
}
.lupe {
padding: 0.5rem;
}
}
.shot {

View File

@@ -1,21 +1,78 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import FilmLogin from './FilmLogin.vue'
import PersonName from './PersonName.vue'
const props = defineProps({
items: { type: Array, default: () => [] },
mine: { type: Object, default: null },
// Der Host wechselt frei, ohne den Anmeldefilm zu suchen
host: Boolean,
// (user_id, movie_id) => Promise<ok>. Bei falschem Film kommt false.
anmelden: { type: Function, required: true },
})
const emit = defineEmits(['waehlen'])
// Firefox zeichnet die Optionsliste eines <select> unter Linux im
// System-Theme, hell und blau. Deshalb ein eigenes Menü.
const offen = ref(false)
const halter = ref(null)
function waehlen(id) {
// Wer einen Anmeldefilm hinterlegt hat, muss ihn erst finden
const anmeldung = ref(null)
// Der eigene Eintrag aus der Runde — der trägt Punkte und Level
const meins = computed(() => props.items.find((u) => u.id === props.mine?.id) || null)
// Wie weit der Balken zum nächsten Level gefüllt ist
function anteil(u) {
const weg = u.bis - u.von
return weg > 0 ? Math.round(((u.punkte - u.von) / weg) * 100) : 0
}
// Kommen Punkte dazu, schwebt die Zahl kurz unter dem Knopf davon.
// Steigt das Level, sprühen Funken und der Badge pulst.
const zuwachs = ref(null)
const feuerwerk = ref(false)
let vorher = null
let zuwachsTimer = null
let feuerTimer = null
watch(meins, (jetzt) => {
if (!jetzt) {
vorher = null
return
}
if (vorher && vorher.id === jetzt.id) {
const diff = jetzt.punkte - vorher.punkte
if (diff !== 0) {
zuwachs.value = { key: Date.now(), wert: diff }
clearTimeout(zuwachsTimer)
zuwachsTimer = setTimeout(() => (zuwachs.value = null), 1400)
}
if (jetzt.level > vorher.level) {
feuerwerk.value = true
clearTimeout(feuerTimer)
feuerTimer = setTimeout(() => (feuerwerk.value = false), 1200)
}
}
vorher = { id: jetzt.id, punkte: jetzt.punkte, level: jetzt.level }
})
function waehlen(benutzer) {
offen.value = false
emit('waehlen', id)
if (!benutzer) return props.anmelden(null)
// Beim eigenen Namen fragt der Server nicht noch einmal nach
if (benutzer.geschuetzt && !props.host && props.mine?.id !== benutzer.id) {
anmeldung.value = benutzer
return
}
props.anmelden(benutzer.id)
}
async function pruefen(movieId) {
const ok = await props.anmelden(anmeldung.value.id, movieId)
if (ok) anmeldung.value = null
return ok
}
function daneben(e) {
@@ -39,10 +96,53 @@ onUnmounted(() => {
<template>
<div ref="halter" class="wahl">
<button type="button" class="knopf" :class="{ offen }" @click="offen = !offen">
{{ mine ? mine.name : 'Anonym' }}
<button
type="button"
class="knopf"
:class="{ offen, schlicht: !meins }"
:title="meins ? `Erfahrung: ${meins.punkte - meins.von} / ${meins.bis - meins.von}` : ''"
@click="offen = !offen"
>
<!-- Wie ein Spiel-HUD: links der Levelkreis, rechts oben der Name,
darunter die Erfahrungsleiste, die am Kreis hängt -->
<template v-if="meins">
<span class="kreis" :class="{ puls: feuerwerk }">
<svg width="34" height="34" viewBox="0 0 34 34" aria-hidden="true">
<circle cx="17" cy="17" r="16" fill="#6e1418" stroke="rgba(255, 255, 255, 0.45)" stroke-width="1" />
<text
x="17"
y="17"
text-anchor="middle"
dominant-baseline="central"
:font-size="meins.level >= 100 ? 12 : 15"
font-weight="700"
fill="#f3d9db"
text-rendering="geometricPrecision"
>
{{ meins.level }}
</text>
</svg>
<span v-if="feuerwerk" class="feuerwerk" aria-hidden="true">
<span v-for="i in 12" :key="i" class="funke" :style="{ '--winkel': `${i * 30}deg` }"></span>
</span>
</span>
<span class="rechts">
<span class="name">{{ mine.name }}</span>
<span class="leiste">
<span
class="balken"
:style="{ width: `calc(var(--kreisrand) + (100% - var(--kreisrand)) * ${anteil(meins) / 100})` }"
></span>
</span>
</span>
</template>
<template v-else>Anonym</template>
</button>
<span v-if="zuwachs" :key="zuwachs.key" class="zuwachs" :class="{ minus: zuwachs.wert < 0 }">
{{ zuwachs.wert > 0 ? '+' : '−' }}{{ Math.abs(zuwachs.wert) }}
</span>
<ul v-if="offen" class="menue">
<li>
<button type="button" :class="{ aktiv: !mine }" @click="waehlen(null)">
@@ -50,15 +150,222 @@ onUnmounted(() => {
</button>
</li>
<li v-for="u in items" :key="u.id">
<button type="button" :class="{ aktiv: mine?.id === u.id }" @click="waehlen(u.id)">
{{ u.name }}
<button type="button" :class="{ aktiv: mine?.id === u.id }" @click="waehlen(u)">
<span class="zeile" :title="`Erfahrung: ${u.punkte - u.von} / ${u.bis - u.von}`">
<PersonName :name="u.name" :level="u.level" />
<span v-if="u.online" class="online" title="Gerade da"></span>
</span>
</button>
</li>
</ul>
<FilmLogin
v-if="anmeldung"
:name="anmeldung.name"
:pruefen="pruefen"
@abbrechen="anmeldung = null"
/>
</div>
</template>
<style scoped>
/* Das Level als kleine Zahl im Badge */
.badge {
display: inline-block;
min-width: 1.3rem;
margin-left: 0.45rem;
padding: 0.05rem 0.35rem;
color: #fff;
font-size: 0.7rem;
font-weight: 700;
line-height: 1.3;
text-align: center;
background: var(--accent);
border-radius: 20px;
}
.zeile {
display: flex;
align-items: center;
}
.zeile .badge {
margin-left: auto;
padding-left: 0.35rem;
}
/* Der Fortschritt zum nächsten Level, als Strich am unteren Rand */
/* Kein Kasten: Kreis und Leiste stehen frei in der Kopfzeile */
.knopf:not(.schlicht) {
display: flex;
align-items: center;
padding: 0;
background: none;
border: none;
}
/* Der Levelkreis, groß und vorn — liegt über dem Anfang der Leiste */
.kreis {
position: relative;
z-index: 1;
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
/* Farben zeichnet das SVG. Der Ring in Hintergrundfarbe hält die
Leiste einen Pixel vom Kreis weg. */
border-radius: 50%;
box-shadow: 0 0 0 1px var(--bg);
}
.kreis svg {
display: block;
}
/* Rechts vom Kreis: der Name oben, die Leiste darunter */
.rechts {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.name {
padding-left: 0.5rem;
color: var(--text);
font-size: 0.9rem;
font-weight: 600;
line-height: 1.1;
white-space: nowrap;
}
.knopf:hover .name,
.knopf.offen .name {
color: #fff;
}
/* Die Erfahrungsleiste schmiegt sich an den Kreis: sie beginnt unter
ihm, der Ring des Kreises schneidet seine Rundung hinein. Die Füllung
startet erst am Kreisrand — so ist jeder Punkt Fortschritt zu sehen. */
.leiste {
/* So weit reicht die Leiste unter den Kreis */
--unter-kreis: 0.9rem;
/* Wo der Kreisrand die Leistenmitte schneidet — ab hier zählt die
Füllung. Folgt aus Kreisradius 1.05rem und der Lage der Leiste. */
--kreisrand: 0.72rem;
position: relative;
width: calc(3.75rem + var(--unter-kreis));
height: 0.3rem;
margin-left: calc(-1 * var(--unter-kreis));
overflow: hidden;
background: rgba(255, 255, 255, 0.16);
border-radius: 0 999px 999px 0;
/* Die Kreisform samt einem Pixel Luft aus Leiste und Füllung schneiden */
mask: radial-gradient(
circle at -0.15rem -0.45rem,
transparent calc(1.05rem + 2px),
#000 calc(1.05rem + 3px)
);
}
/* Die Füllung beginnt ebenfalls unter dem Kreis — die Maske gibt ihr
dieselbe Rundung, sichtbar wird sie ab dem Kreisrand */
.balken {
position: absolute;
top: 0;
bottom: 0;
left: 0;
background: var(--accent);
transition: width 0.6s ease;
}
/* Die gewonnenen Punkte sinken unter dem Knopf davon */
.zuwachs {
position: absolute;
top: 100%;
left: 50%;
z-index: 41;
color: var(--accent);
font-size: 0.95rem;
font-weight: 700;
pointer-events: none;
animation: schweben 1.4s ease-out forwards;
}
.zuwachs.minus {
color: var(--muted);
}
@keyframes schweben {
0% {
opacity: 0;
transform: translate(-50%, -4px);
}
15% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(-50%, 26px);
}
}
/* Level-Up: Funken aus dem Kreis, der Kreis pulst */
.feuerwerk {
position: absolute;
top: 50%;
left: 50%;
z-index: 41;
pointer-events: none;
}
.funke {
position: absolute;
width: 5px;
height: 5px;
background: var(--accent);
border-radius: 50%;
transform: rotate(var(--winkel)) translateX(0);
animation: spruehen 1s ease-out forwards;
}
.funke:nth-child(3n) {
background: #f5c518;
}
.funke:nth-child(3n + 1) {
background: #fff;
}
@keyframes spruehen {
0% {
opacity: 1;
transform: rotate(var(--winkel)) translateX(0) scale(1);
}
100% {
opacity: 0;
transform: rotate(var(--winkel)) translateX(48px) scale(0.3);
}
}
.kreis.puls {
animation: puls 0.7s ease-out;
}
@keyframes puls {
0% {
transform: scale(1);
}
40% {
transform: scale(1.6);
box-shadow: 0 0 12px var(--accent);
}
100% {
transform: scale(1);
}
}
.wahl {
position: relative;
align-self: center;
@@ -66,9 +373,10 @@ onUnmounted(() => {
}
.knopf {
padding: 0.32rem 0.7rem;
padding: 0.3rem 0.8rem 0.3rem 0.4rem;
color: var(--text);
font-size: 0.82rem;
text-align: left;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 6px;
@@ -113,4 +421,28 @@ onUnmounted(() => {
color: var(--accent);
font-weight: 600;
}
/* Der Punkt zeigt: hier fragt die Seite nach dem Anmeldefilm */
/* Grüner Punkt: eine Seite mit diesem Namen ist gerade offen */
.online {
display: inline-block;
width: 0.5rem;
height: 0.5rem;
margin-left: 0.35rem;
vertical-align: middle;
background: #3ddc84;
border-radius: 50%;
}
@media (max-width: 640px), (pointer: coarse) {
.knopf {
padding: 0.45rem 0.85rem;
font-size: 0.9rem;
}
.menue button {
padding: 0.65rem 0.8rem;
font-size: 0.95rem;
}
}
</style>

View File

@@ -0,0 +1,55 @@
<script setup>
// Ein Name aus der Runde, mit dem Level als kleinem Badge davor. Das
// Level kommt aus der Runde, die App stellt sie bereit — so muss keine
// Liste durch alle Ebenen gereicht werden. Unbekannte Namen (Anonym,
// gelöscht) stehen ohne Badge.
import { computed, inject } from 'vue'
const props = defineProps({
name: { type: String, default: '' },
level: { type: Number, default: null },
})
const levelVon = inject('levelVon', () => null)
const stufe = computed(() => props.level ?? levelVon(props.name))
</script>
<template>
<span class="person">
<!-- Als SVG: die Ziffer sitzt auf der geometrischen Mitte, fest
18 px auf ganzen Pixeln -->
<svg v-if="stufe" class="lvl" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
<circle cx="9" cy="9" r="8.5" fill="#6e1418" stroke="rgba(255, 255, 255, 0.45)" stroke-width="1" />
<text
x="9"
y="9"
text-anchor="middle"
dominant-baseline="central"
:font-size="stufe >= 100 ? 7.5 : 10"
font-weight="700"
fill="#f3d9db"
text-rendering="geometricPrecision"
>
{{ stufe }}
</text>
</svg>
{{ name || 'Anonym' }}
</span>
</template>
<style scoped>
/* Badge und Name mittig auf einer Linie, egal wie groß die Schrift ist */
.person {
display: inline-flex;
gap: 0.3em;
align-items: center;
white-space: nowrap;
}
/* Weinrot statt Signalrot, mit hellem Rand für den Kontrast zum Grund —
die Farben stehen im SVG selbst */
.lvl {
display: block;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,360 @@
<script setup>
// Nach Leuten suchen: einen Namen tippen, eine Person wählen, dann
// stehen ihre Filme da — getrennt nach gespielt und Regie geführt.
import { computed, ref, watch } from 'vue'
import MovieCard from './MovieCard.vue'
import { posterUrl } from '../images'
import { passtZurLeiste } from '../filter.js'
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: mit dieser Person soll es losgehen
start: { type: Object, default: null },
})
const emit = defineEmits(['open', 'gestartet'])
const query = ref('')
const personen = ref([])
const personenLaden = ref(false)
const gewaehlt = ref(null)
const rollen = ref({ gespielt: [], regie: [] })
const laden = ref(false)
const fehler = ref('')
let timer = null
let laufende = 0
let laufendeWahl = 0
async function suchen(term) {
if (term.trim().length < 2) {
personen.value = []
return
}
const meine = ++laufende
personenLaden.value = true
try {
const resp = await fetch(`/api/personen?q=${encodeURIComponent(term.trim())}`)
if (!resp.ok) throw new Error('Suche fehlgeschlagen')
const daten = await resp.json()
if (meine !== laufende) return
personen.value = daten
} catch {
if (meine === laufende) personen.value = []
} finally {
if (meine === laufende) personenLaden.value = false
}
}
watch(query, () => {
clearTimeout(timer)
timer = setTimeout(() => suchen(query.value), 350)
})
async function waehlen(person) {
const meine = ++laufendeWahl
gewaehlt.value = person
query.value = ''
personen.value = []
laden.value = true
fehler.value = ''
try {
const resp = await fetch(`/api/personen/${person.id}/filme`)
if (!resp.ok) throw new Error('Das hat nicht geklappt.')
const daten = await resp.json()
if (meine !== laufendeWahl) return
rollen.value = daten
} catch (err) {
if (meine === laufendeWahl) fehler.value = err.message
} finally {
if (meine === laufendeWahl) laden.value = false
}
}
function passt(movie) {
if (props.ohneGesehene && props.gesehenIds.has(movie.id)) return false
return passtZurLeiste(movie, props.filter)
}
// Die Reihenfolge aus der Leiste. "Wie auf IMDb" ist hier die von
// TMDB: die bekanntesten Filme zuerst.
const wuerfel = new Map()
function zufall(movie) {
if (!wuerfel.has(movie.id)) wuerfel.set(movie.id, Math.random())
return wuerfel.get(movie.id)
}
const SORTS = {
random: (a, b) => zufall(a) - zufall(b),
rating: (a, b) => (b.vote_average || 0) - (a.vote_average || 0),
newest: (a, b) => (b.year || 0) - (a.year || 0),
oldest: (a, b) => (a.year || 9999) - (b.year || 9999),
title: (a, b) => a.title.localeCompare(b.title, 'de'),
}
function sortiert(liste) {
const sort = SORTS[props.filter.sort]
return sort ? [...liste].sort(sort) : liste
}
const abschnitte = computed(() =>
[
['Als Schauspieler', rollen.value.gespielt],
['Als Regisseur', rollen.value.regie],
]
.map(([titel, liste]) => [titel, sortiert(liste.filter(passt))])
.filter(([, liste]) => liste.length),
)
watch(
() => props.start,
(person) => {
if (!person) return
waehlen(person)
emit('gestartet')
},
{ immediate: true }
)
async function oeffnen(movie) {
const resp = await fetch(`/api/search/${movie.id}`)
if (resp.ok) emit('open', await resp.json())
}
</script>
<template>
<div class="personen-tab">
<div class="suchzeile">
<input
v-model="query"
type="search"
class="big-search"
placeholder="Wen suchst du?"
autofocus
/>
<button v-if="query" type="button" class="leeren" title="Suche leeren" @click="query = ''">
×
</button>
</div>
<!-- Tippt jemand, stehen die Leute vorn, bis einer gewählt ist -->
<template v-if="query.trim().length >= 2">
<p v-if="!personen.length && !personenLaden" class="note">Nichts gefunden.</p>
<ul v-else class="leute">
<li v-for="p in personen" :key="p.id">
<button type="button" @click="waehlen(p)">
<img v-if="p.profile_path" :src="posterUrl(p.profile_path, 'w185')" :alt="p.name" />
<span v-else class="kein-bild"></span>
<span class="text">
<strong>{{ p.name }}</strong>
<span class="fach">{{ p.fach }}</span>
<span v-if="p.bekannt_fuer.length" class="bekannt">
{{ p.bekannt_fuer.join(' · ') }}
</span>
</span>
</button>
</li>
</ul>
</template>
<template v-else-if="gewaehlt">
<div class="kopf">
<img
v-if="gewaehlt.profile_path"
:src="posterUrl(gewaehlt.profile_path, 'w185')"
:alt="gewaehlt.name"
/>
<div>
<h3>{{ gewaehlt.name }}</h3>
<p class="fach">{{ gewaehlt.fach }}</p>
</div>
</div>
<p v-if="laden" class="note">Suche läuft…</p>
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
<p v-else-if="!abschnitte.length" class="note">Nichts gefunden. Stell die Filter weiter.</p>
<section v-for="[titel, liste] in abschnitte" :key="titel">
<p class="label">
{{ titel }}
<span class="anzahl">{{ liste.length }}</span>
</p>
<div class="grid" :class="ansicht">
<MovieCard
v-for="film in liste"
:key="film.id"
:movie="film"
:ansicht="ansicht"
:gesehen="gesehenIds.has(film.id)"
@open="oeffnen"
/>
</div>
</section>
</template>
</div>
</template>
<style scoped>
.personen-tab {
min-width: 0;
}
.suchzeile {
position: relative;
margin-bottom: 1.5rem;
}
.big-search {
width: 100%;
padding: 0.9rem 2.6rem 0.9rem 1.1rem;
font-size: 1.05rem;
}
.leeren {
position: absolute;
top: 50%;
right: 0.9rem;
color: var(--muted);
font-size: 1.4rem;
line-height: 1;
transform: translateY(-50%);
}
.leeren:hover {
color: var(--text);
}
/* Die Trefferliste: Foto, Name, Fach und wofür man die Person kennt */
.leute {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.7rem;
list-style: none;
}
.leute button {
display: flex;
gap: 0.8rem;
align-items: center;
width: 100%;
padding: 0.6rem;
text-align: left;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 10px;
}
.leute button:hover {
border-color: var(--accent);
}
.leute img,
.kein-bild {
flex-shrink: 0;
width: 56px;
height: 84px;
object-fit: cover;
background: #1c1c22;
border-radius: 6px;
}
.text {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.15rem;
}
.fach,
.bekannt {
color: var(--muted);
font-size: 0.8rem;
}
.bekannt {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kopf {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 1.5rem;
}
.kopf img {
width: 64px;
height: 96px;
object-fit: cover;
border-radius: 8px;
}
.kopf h3 {
font-size: 1.3rem;
}
section {
margin-bottom: 2rem;
}
.label {
display: flex;
gap: 0.45rem;
align-items: center;
margin-bottom: 1.1rem;
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.anzahl {
padding: 0.1rem 0.45rem;
letter-spacing: 0;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 20px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 1.1rem;
}
.grid.details {
grid-template-columns: repeat(auto-fill, min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem)));
}
.note {
padding: 2rem 0;
color: var(--muted);
font-size: 0.95rem;
text-align: center;
}
.note.error {
color: var(--accent);
}
@media (max-width: 640px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(105px, 1fr));
gap: 0.6rem;
}
.grid.details {
grid-template-columns: 1fr;
}
.leute {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -156,4 +156,30 @@ const aktiv = computed(
.aktiv .regler input::-moz-range-thumb {
background: var(--accent);
}
@media (max-width: 640px), (pointer: coarse) {
.kopf {
font-size: 0.88rem;
}
.regler,
.regler input {
height: 1.8rem;
}
.regler input::-webkit-slider-runnable-track {
margin-top: 0.75rem;
}
.regler input::-webkit-slider-thumb {
width: 1.5rem;
height: 1.5rem;
margin-top: -0.65rem;
}
.regler input::-moz-range-thumb {
width: 1.5rem;
height: 1.5rem;
}
}
</style>

View File

@@ -0,0 +1,255 @@
<script setup>
// Die Leiste rechts im Sammlungen-Reiter. Gleiche Bauart wie die
// Filterleiste im Katalog, nur mit dem, was hier zählt: wessen Sammlung,
// Horror oder nicht, und wie groß die Karten sind.
import { computed } from 'vue'
import PersonName from './PersonName.vue'
const props = defineProps({
// [{ name, anzahl }] — wer überhaupt etwas gesammelt hat
leute: { type: Array, default: () => [] },
})
const gewaehlt = defineModel('gewaehlt', { type: Array, default: () => [] })
// null = alles, true = nur Horror, false = alles außer Horror
const horror = defineModel('horror', { default: null })
const ansicht = defineModel('ansicht', { type: String, default: 'kompakt' })
const alleGewaehlt = computed(() => gewaehlt.value.length === 0)
function umschalten(name) {
gewaehlt.value = gewaehlt.value.includes(name)
? gewaehlt.value.filter((n) => n !== name)
: [...gewaehlt.value, name]
}
// Ein zweiter Klick auf denselben Knopf hebt die Auswahl wieder auf
function horrorSetzen(wert) {
horror.value = horror.value === wert ? null : wert
}
const etwasAktiv = computed(() => !alleGewaehlt.value || horror.value !== null)
function zuruecksetzen() {
gewaehlt.value = []
horror.value = null
}
</script>
<template>
<aside class="sidebar">
<div class="controls">
<div class="ansicht">
<button
type="button"
:class="{ an: ansicht === 'kompakt' }"
@click="ansicht = 'kompakt'"
>
Kompakt
</button>
<button
type="button"
:class="{ an: ansicht === 'details' }"
@click="ansicht = 'details'"
>
Details
</button>
</div>
</div>
<div class="head">
<h3>Filter</h3>
<button v-if="etwasAktiv" type="button" class="reset" @click="zuruecksetzen">
zurücksetzen
</button>
</div>
<section>
<h4>Genre</h4>
<div class="badges">
<button
type="button"
class="badge"
:class="{ on: horror === true }"
@click="horrorSetzen(true)"
>
Horror
</button>
<button
type="button"
class="badge"
:class="{ on: horror === false }"
@click="horrorSetzen(false)"
>
Kein Horror
</button>
</div>
</section>
<section>
<h4>Sammlungen</h4>
<div class="badges">
<button
type="button"
class="badge alle"
:class="{ on: alleGewaehlt }"
@click="gewaehlt = []"
>
alle
</button>
<button
v-for="person in leute"
:key="person.name"
type="button"
class="badge"
:class="{ on: gewaehlt.includes(person.name) }"
@click="umschalten(person.name)"
>
<PersonName :name="person.name" />
<span class="count">{{ person.anzahl }}</span>
</button>
</div>
</section>
</aside>
</template>
<style scoped>
.sidebar {
display: flex;
flex-direction: column;
gap: 1.1rem;
padding: 1rem 0.9rem;
}
.controls {
display: flex;
flex-direction: column;
gap: 0.45rem;
padding-bottom: 1.1rem;
border-bottom: 1px solid var(--line);
}
.head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
}
h3 {
color: var(--text);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
h4 {
margin-bottom: 0.5rem;
color: var(--muted);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.reset {
color: var(--muted);
font-size: 0.75rem;
text-decoration: underline;
}
.reset:hover {
color: var(--text);
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 0.32rem;
}
.badge {
display: flex;
align-items: center;
gap: 0.32rem;
padding: 0.28rem 0.6rem;
color: var(--muted);
font-size: 0.8rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 20px;
}
.badge:hover {
color: var(--text);
border-color: var(--muted);
}
.count {
font-size: 0.7rem;
opacity: 0.6;
}
.badge.alle {
font-style: italic;
}
.badge.on {
color: #fff;
background: var(--accent);
border-color: var(--accent);
}
.ansicht {
display: flex;
gap: 0.3rem;
}
.ansicht button {
flex: 1;
padding: 0.4rem 0.5rem;
color: var(--muted);
font-size: 0.8rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 6px;
}
.ansicht button:hover {
color: var(--text);
}
.ansicht button.an {
color: var(--text);
font-weight: 600;
border-color: var(--accent);
}
/* Finger statt Maus: alles etwas größer */
@media (max-width: 640px), (pointer: coarse) {
.sidebar {
gap: 1.4rem;
padding: 1rem;
}
.ansicht button {
padding: 0.65rem 0.5rem;
font-size: 0.9rem;
}
.badge {
padding: 0.5rem 0.85rem;
font-size: 0.9rem;
}
.badges {
gap: 0.45rem;
}
.reset {
padding: 0.4rem 0;
font-size: 0.85rem;
}
}
</style>

View File

@@ -7,6 +7,8 @@ const props = defineProps({
// Was die Filterleiste eingestellt hat
filter: { type: Object, default: () => ({}) },
ansicht: { type: String, default: 'kompakt' },
gesehenIds: { type: Set, default: () => new Set() },
ohneGesehene: { type: Boolean, default: true },
})
const emit = defineEmits(['open'])
@@ -43,6 +45,11 @@ function passt(movie) {
)
}
// Schon gelaufene Filme fallen weg, solange der Schalter in der Leiste steht
function nichtGesehen(movie) {
return !props.ohneGesehene || !props.gesehenIds.has(movie.id)
}
async function run(term) {
const meine = ++laufende
loading.value = true
@@ -51,13 +58,13 @@ async function run(term) {
try {
const url = stoebern
? `/api/discover?${new URLSearchParams(filterParameter(props.filter))}`
: `/api/search?q=${encodeURIComponent(term.trim())}`
: `/api/search?q=${encodeURIComponent(term.trim())}&limit=${props.filter.anzahl || 24}`
const resp = await fetch(url)
if (!resp.ok) throw new Error('Suche fehlgeschlagen')
const data = await resp.json()
// Eine ältere Anfrage darf ein neueres Ergebnis nicht überschreiben
if (meine !== laufende) return
results.value = stoebern ? data : data.filter(passt)
results.value = (stoebern ? data : data.filter(passt)).filter(nichtGesehen)
gestoebert.value = stoebern
} catch (err) {
if (meine === laufende) error.value = err.message
@@ -84,6 +91,7 @@ function leeren() {
watch(query, spaeterSuchen)
watch(() => props.filter, spaeterSuchen, { deep: true, immediate: true })
watch(() => props.ohneGesehene, spaeterSuchen)
async function open(movie) {
const resp = await fetch(`/api/search/${movie.id}`)
@@ -105,15 +113,15 @@ async function open(movie) {
<button v-if="query" type="button" class="leeren" title="Suche leeren" @click="leeren">
×
</button>
<button type="button" class="neu" title="Neu suchen" @click="jetztSuchen">
Suchen
</button>
<button type="button" class="neu" title="Neu suchen" @click="jetztSuchen">Suchen</button>
</div>
<p v-if="loading" class="note">Suche läuft…</p>
<p v-else-if="error" class="note error">{{ error }}</p>
<p v-else-if="!results.length" class="note">
{{ gestoebert ? 'Nichts gefunden. Stell die Filter weiter.' : `Nichts gefunden für „${query}“.` }}
{{
gestoebert ? 'Nichts gefunden. Stell die Filter weiter.' : `Nichts gefunden für „${query}“.`
}}
</p>
<div class="grid" :class="ansicht">
@@ -122,6 +130,7 @@ async function open(movie) {
:key="movie.id"
:movie="movie"
:ansicht="ansicht"
:gesehen="gesehenIds.has(movie.id)"
@open="open"
/>
</div>
@@ -142,6 +151,9 @@ async function open(movie) {
.big-search {
flex: 1;
/* Sonst hält das Feld seine Mindestbreite und schiebt den Knopf aus
dem Bild, wenn der Schirm schmal ist */
min-width: 0;
padding: 0.9rem 2.6rem 0.9rem 1.1rem;
font-size: 1.05rem;
}
@@ -161,7 +173,10 @@ async function open(movie) {
}
.neu {
flex-shrink: 0;
padding: 0 1.4rem;
/* Der Leeren-Knopf im Feld richtet sich nach dieser Breite */
width: 7.2rem;
font-size: 0.95rem;
font-weight: 600;
background: var(--bg-soft);
@@ -180,10 +195,19 @@ async function open(movie) {
}
.grid.details {
grid-template-columns: repeat(
auto-fill,
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
);
grid-template-columns: repeat(auto-fill, min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem)));
}
/* Auf dem Handy wie im Katalog: drei Poster nebeneinander */
@media (max-width: 640px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(105px, 1fr));
gap: 0.6rem;
}
.grid.details {
grid-template-columns: 1fr;
}
}
.note {

View File

@@ -7,6 +7,8 @@ const props = defineProps({
// Die Karte öffnet mit dem stärksten Teil, nicht mit dem ersten
start: { type: Number, default: 0 },
aufDemRad: { type: Array, default: () => [] },
// Kennungen der schon gelaufenen Filme
gesehenIds: { type: Set, default: () => new Set() },
ansicht: { type: String, default: 'kompakt' },
})
@@ -21,6 +23,8 @@ const rating = computed(() =>
const stehtAufDemRad = computed(() => props.aufDemRad.includes(current.value.id))
const schonGesehen = computed(() => props.gesehenIds.has(current.value.id))
const shots = computed(() =>
(current.value.images || []).slice(0, 4).map((pfad) => backdropUrl(pfad, 'w300'))
)
@@ -38,7 +42,7 @@ function step(delta) {
</script>
<template>
<article class="card" :class="[ansicht, { 'auf-dem-rad': stehtAufDemRad }]">
<article class="card" :class="[ansicht, { 'auf-dem-rad': stehtAufDemRad, gesehen: schonGesehen }]">
<span v-if="stehtAufDemRad" class="rad-marke" title="Steht auf dem Rad">Rad</span>
<div class="haupt">
<button class="poster" type="button" @click="$emit('open', current)">
@@ -91,6 +95,18 @@ function step(delta) {
</template>
<style scoped>
/* Schon gelaufen — die Karte tritt zurück, bleibt aber lesbar.
Beim Überfahren kommt sie zurück. */
.card.gesehen {
opacity: 0.45;
filter: grayscale(1);
}
.card.gesehen:hover {
opacity: 1;
filter: none;
}
.card {
position: relative;
display: flex;
@@ -344,4 +360,53 @@ h2:hover {
.card.details .shots {
overflow: hidden;
}
/* Schmal und kompakt: nur Poster, Titel und Jahr */
@media (max-width: 640px) {
.card.kompakt .body {
gap: 0.2rem;
padding: 0.5rem;
}
.card.kompakt h2 {
-webkit-line-clamp: 2;
line-clamp: 2;
min-height: calc(2 * 1.25em);
font-size: 0.82rem;
line-height: 1.25;
}
.card.kompakt .meta {
font-size: 0.72rem;
}
.card.kompakt .teaser {
display: none;
}
.rating {
font-size: 0.72rem;
}
}
/* Schmal: Cover und Text nebeneinander, die Bilder in einer Reihe darunter */
@media (max-width: 700px) {
.card.details {
grid-template-columns: 110px minmax(0, 1fr);
grid-template-rows: auto auto;
}
.card.details .poster {
width: 110px;
}
.card.details .shots {
grid-area: 2 / 1 / 3 / 3;
grid-template-columns: repeat(4, 1fr);
}
.card.details .body {
grid-area: 1 / 2;
}
}
</style>

View File

@@ -2,9 +2,14 @@
import { onMounted, onUnmounted, ref } from 'vue'
import Hls from 'hls.js'
// Alle Zuschauer regeln auf dieselbe Distanz zur Live-Kante. Damit laufen
// sie synchron, ohne dass ein Server sie taktet. hls.js passt dafür die
// Abspielgeschwindigkeit an (maxLiveSyncPlaybackRate).
// Alle Zuschauer halten dieselbe Distanz zur Live-Kante. Damit laufen sie
// synchron, ohne dass ein Server sie taktet. Ausgeglichen wird nur über
// Sprünge, nicht über die Geschwindigkeit — siehe maxLiveSyncPlaybackRate.
const props = defineProps({
// Marken setzen darf nur, wer einen Namen hat
angemeldet: Boolean,
})
const ZIEL_LATENZ = 4
const video = ref(null)
@@ -86,6 +91,31 @@ function tasteAbfangen(e) {
if (e.code === 'Space' && e.target === video.value) e.preventDefault()
}
// Eine Marke setzen: der Moment wird gemerkt, den Clip schneidet man
// später im Clips-Reiter. Taste C, damit nichts vom Film ablenkt.
const markeStand = ref('')
let markeTimer = null
async function markieren() {
if (!props.angemeldet || markeStand.value === 'läuft') return
markeStand.value = 'läuft'
try {
const resp = await fetch('/api/clips', { method: 'POST' })
markeStand.value = resp.ok ? 'gesetzt' : 'fehler'
} catch {
markeStand.value = 'fehler'
}
clearTimeout(markeTimer)
markeTimer = setTimeout(() => (markeStand.value = ''), 1800)
}
function tasteC(e) {
if (e.key !== 'c' && e.key !== 'C') return
if (e.ctrlKey || e.metaKey || e.altKey) return
if (e.target instanceof Element && e.target.matches('input, textarea')) return
markieren()
}
function verbinden() {
const quelle = '/stream/filmabend/index.m3u8'
@@ -108,11 +138,13 @@ function verbinden() {
// ewig mit 1,5-fachem Tempo aufzuholen. Zu dicht an ZIEL_LATENZ
// führt laut Doku zu Stockern.
liveMaxLatencyDuration: ZIEL_LATENZ + 3,
// Über 1 erlaubt hls.js, die Geschwindigkeit anzuheben, um die
// Ziel-Latenz zu halten. 1,5 wäre deutlich hörbar, wenn der Player sie
// ausreizt; 1,1 reicht für normale Schwankungen und fällt nicht auf.
// Bleibt der Rückstand größer, springt liveMaxLatencyDuration ohnehin.
maxLiveSyncPlaybackRate: 1.1,
// Kein Aufholen über die Geschwindigkeit. Mit 1,1 wechselte der Player
// gemessene 112-mal in 104 Sekunden das Tempo und lief 37 % der Zeit
// schneller als normal — jeder Wechsel ist ein Übergang im Zeitdehner
// und knackst hörbar. Der Puffer lief dabei nie leer (nie unter 2 s),
// die Regelung war also gar nicht nötig. Bleibt der Rückstand doch
// einmal groß, springt liveMaxLatencyDuration.
maxLiveSyncPlaybackRate: 1,
backBufferLength: 30,
})
@@ -161,6 +193,7 @@ function vollbildGewechselt() {
}
onMounted(() => {
window.addEventListener('keydown', tasteC)
verbinden()
wacheStarten()
document.addEventListener('fullscreenchange', vollbildGewechselt)
@@ -168,6 +201,7 @@ onMounted(() => {
})
onUnmounted(() => {
window.removeEventListener('keydown', tasteC)
document.removeEventListener('fullscreenchange', vollbildGewechselt)
window.removeEventListener('keydown', tasteAbfangen)
window.clearInterval(wache)
@@ -212,6 +246,16 @@ onUnmounted(() => {
@input="lautstaerkeSetzen(Number($event.target.value))"
/>
<span class="live">live</span>
<button
v-if="angemeldet"
type="button"
class="knopf clip"
:class="{ an: markeStand === 'gesetzt' }"
title="Marke setzen (Taste C)"
@click="markieren"
>
{{ markeStand === 'gesetzt' ? '✓ Marke' : markeStand === 'fehler' ? 'Fehler' : 'Clip' }}
</button>
<button
type="button"
class="knopf"
@@ -255,6 +299,7 @@ onUnmounted(() => {
.rahmen {
position: relative;
max-width: 100%;
/* Sonst ragt das Bild unter den Fensterrand und die Leiste mit ihm */
max-height: calc(100vh - 5.5rem);
aspect-ratio: 16 / 9;
@@ -296,6 +341,25 @@ onUnmounted(() => {
opacity: 1;
}
@media (hover: none) {
.leiste {
opacity: 1;
}
}
.knopf.clip {
padding: 0 0.7rem;
font-size: 0.8rem;
font-weight: 600;
border: 1px solid var(--line);
border-radius: 6px;
}
.knopf.clip.an {
color: #6bbf7a;
border-color: #6bbf7a;
}
.knopf {
padding: 0.1rem 0.3rem;
font-size: 1.05rem;

View File

@@ -1,13 +1,140 @@
<script setup>
import { ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import FilmSuche from './FilmSuche.vue'
import Einladung from './Einladung.vue'
import { posterUrl } from '../images'
import PersonName from './PersonName.vue'
defineProps({
const props = defineProps({
items: { type: Array, default: () => [] },
// Der Host pflegt die ganze Runde, alle anderen nur den eigenen Zugang
host: Boolean,
ich: { type: Object, default: null },
})
const liste = computed(() =>
props.host ? props.items : props.items.filter((u) => u.id === props.ich?.id),
)
const neuerName = ref('')
const fehler = ref('')
// Der Anmeldefilm ist das Passwort: wer den Namen wählt, muss ihn in der
// Suche finden. Welcher es ist, gibt der Server nicht heraus — hier steht
// nur, ob überhaupt einer gesetzt ist.
const setzt = ref(null)
async function schutzSetzen(user, movieId) {
await fetch(`/api/users/${user.id}/schutz`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ movie_id: movieId }),
})
delete gezeigt[user.id]
setzt.value = null
}
// Wer den Film ändern darf, darf ihn auch nachschlagen — sonst weiß man
// nicht, was man gerade überschreibt. Sichtbar wird er erst auf Klick.
const gezeigt = reactive({})
async function schutzZeigen(user) {
if (gezeigt[user.id]) {
delete gezeigt[user.id]
return
}
gezeigt[user.id] = { laden: true }
const resp = await fetch(`/api/users/${user.id}/schutz`)
gezeigt[user.id] = resp.ok ? await resp.json() : { fehler: true }
}
// Die Einladungskarte — ein eigenes Fenster
const einladung = ref(false)
// Der Host-Film: das Passwort für den Hostmodus, gesetzt vom Host
const hostFilmDialog = ref(false)
const hostFilm = ref(null)
async function hostFilmZeigen() {
if (hostFilm.value) {
hostFilm.value = null
return
}
hostFilm.value = { laden: true }
const resp = await fetch('/api/host/film')
hostFilm.value = resp.ok ? await resp.json() : { fehler: true }
}
async function hostFilmSetzen(movieId) {
await fetch('/api/host/film', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ movie_id: movieId }),
})
hostFilm.value = null
hostFilmDialog.value = false
}
// Die Aufnahmen der Abende — der Host löscht, was weg kann. Erst ein
// zweiter Klick auf denselben Knopf löscht wirklich.
const aufnahmen = ref(null)
const aufnahmenFehler = ref('')
const loeschFrage = ref(null)
async function aufnahmenLaden() {
if (!props.host) return
loeschFrage.value = null
try {
const resp = await fetch('/api/aufnahmen')
if (!resp.ok) throw new Error()
aufnahmen.value = (await resp.json()).stuecke
} catch {
aufnahmen.value = []
aufnahmenFehler.value = 'Die Aufnahmen sind gerade nicht erreichbar.'
}
}
async function aufnahmeLoeschen(s) {
if (loeschFrage.value !== s.start) {
loeschFrage.value = s.start
return
}
aufnahmenFehler.value = ''
const params = new URLSearchParams({ start: s.start, dauer: s.dauer })
const resp = await fetch(`/api/aufnahmen?${params}`, { method: 'DELETE' })
if (!resp.ok) {
// Die Meldung vom Server sagt, woran es lag — sie bleibt stehen
const daten = await resp.json().catch(() => ({}))
aufnahmenFehler.value = daten.detail || 'Löschen hat nicht geklappt.'
}
await aufnahmenLaden()
}
// Welcher Film in einer Aufnahme lief, sagt der Host — per Filmsuche
const filmDialogFuer = ref(null)
async function filmZuweisen(start, movieId) {
aufnahmenFehler.value = ''
const resp = await fetch('/api/aufnahmen/film', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ start, movie_id: movieId }),
})
if (!resp.ok) aufnahmenFehler.value = 'Zuweisen hat nicht geklappt.'
filmDialogFuer.value = null
await aufnahmenLaden()
}
function aufnahmeText(s) {
const d = new Date(s.start)
const datum = d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
const zeit = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
return `${datum} · ${zeit} Uhr · ${Math.max(1, Math.round(s.dauer / 60))} Min`
}
onMounted(aufnahmenLaden)
watch(() => props.host, aufnahmenLaden)
async function anlegen() {
fehler.value = ''
const resp = await fetch('/api/users', {
@@ -27,9 +154,14 @@ async function entfernen(user) {
<template>
<div class="verwaltung-tab">
<section>
<h3>Benutzer</h3>
<h3>{{ host ? 'Benutzer' : 'Dein Zugang' }}</h3>
<div class="anlegen">
<p v-if="!host" class="erklaerung">
Hinterleg einen Film, dann fragt die Seite beim Wechsel auf deinen Namen danach. Ohne Film
bleibt dein Name für alle offen.
</p>
<div v-if="host" class="anlegen">
<input
v-model="neuerName"
type="text"
@@ -41,27 +173,218 @@ async function entfernen(user) {
</div>
<p v-if="fehler" class="fehler">{{ fehler }}</p>
<ul v-if="items.length" class="liste">
<li v-for="u in items" :key="u.id">
<span class="name">{{ u.name }}</span>
<button
type="button"
class="weg"
title="Aus der Runde nehmen"
@click="entfernen(u)"
>
×
</button>
<ul v-if="liste.length" class="liste">
<li v-for="u in liste" :key="u.id">
<div class="zeile">
<span class="name"><PersonName :name="u.name" :level="u.level" /></span>
<button
v-if="u.geschuetzt"
type="button"
class="auge"
:class="{ an: gezeigt[u.id] }"
:title="gezeigt[u.id] ? 'Film verbergen' : 'Hinterlegten Film zeigen'"
@click="schutzZeigen(u)"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M1 12s4-7.5 11-7.5S23 12 23 12s-4 7.5-11 7.5S1 12 1 12z" />
<circle cx="12" cy="12" r="3.2" />
<line v-if="!gezeigt[u.id]" x1="3" y1="21" x2="21" y2="3" />
</svg>
</button>
<button type="button" class="klein" @click="setzt = u">
{{ u.geschuetzt ? 'Ändern' : 'Anmeldefilm' }}
</button>
<button
v-if="u.geschuetzt"
type="button"
class="klein"
title="Namen wieder für alle öffnen"
@click="schutzSetzen(u, null)"
>
Aufheben
</button>
<button
v-if="host"
type="button"
class="weg"
title="Aus der Runde nehmen"
@click="entfernen(u)"
>
×
</button>
</div>
<p v-if="gezeigt[u.id]" class="verraten">
<template v-if="gezeigt[u.id].laden">Moment…</template>
<template v-else-if="gezeigt[u.id].film">
<img
v-if="posterUrl(gezeigt[u.id].film.poster_path, 'w92')"
:src="posterUrl(gezeigt[u.id].film.poster_path, 'w92')"
:alt="gezeigt[u.id].film.title"
/>
<span>
{{ gezeigt[u.id].film.title }}
<span v-if="gezeigt[u.id].film.year" class="jahr">
{{ gezeigt[u.id].film.year }}
</span>
</span>
</template>
<template v-else>Der Film ließ sich nicht laden.</template>
</p>
</li>
</ul>
<p v-else class="leer">Noch niemand in der Runde.</p>
</section>
<div v-if="host" class="rechts">
<section class="einladung">
<h3>Einladung</h3>
<p class="erklaerung">
Eine Karte zum Verschicken: Datum, Genre, Discord, Webseite. Als Bild fürs Handy.
</p>
<button type="button" class="klein" @click="einladung = true">Einladung erstellen</button>
</section>
<Einladung v-if="einladung" @zu="einladung = false" />
<section class="hostfilm">
<h3>Host-Film</h3>
<p class="erklaerung">
Die Tastenkombination fragt ohne Freischaltung nach diesem Film. Wer ihn anklickt, ist
Host — ohne Link.
</p>
<div class="zeile">
<button type="button" class="klein" @click="hostFilmDialog = true">Film setzen</button>
<button type="button" class="klein" title="Anzeigen" @click="hostFilmZeigen">👁</button>
<button type="button" class="klein" @click="hostFilmSetzen(null)">Aufheben</button>
<span v-if="hostFilm" class="film-anzeige">
<template v-if="hostFilm.laden">…</template>
<template v-else-if="hostFilm.fehler">nicht lesbar</template>
<template v-else-if="hostFilm.film"
>{{ hostFilm.film.title }} ({{ hostFilm.film.year }})</template
>
<template v-else>keiner gesetzt</template>
</span>
</div>
</section>
<section class="aufnahmen">
<h3>Aufnahmen</h3>
<p v-if="aufnahmenFehler" class="fehler">{{ aufnahmenFehler }}</p>
<p v-if="aufnahmen === null" class="leer">Suche Aufnahmen …</p>
<p v-else-if="!aufnahmen.length" class="leer">Keine Aufnahmen.</p>
<ul v-else>
<li v-for="s in aufnahmen" :key="s.start" class="zeile">
<span>
{{ aufnahmeText(s) }} ·
<strong v-if="s.film">{{ s.film.title }}</strong>
<em v-else>kein Film</em>
</span>
<span class="knoepfe">
<button type="button" class="klein" @click="filmDialogFuer = s.start">Film</button>
<button
v-if="s.zugewiesen"
type="button"
class="klein"
@click="filmZuweisen(s.start, null)"
>
Aufheben
</button>
<button
type="button"
class="klein"
:class="{ warnung: loeschFrage === s.start }"
@click="aufnahmeLoeschen(s)"
>
{{ loeschFrage === s.start ? 'Wirklich löschen?' : 'Löschen' }}
</button>
</span>
</li>
</ul>
</section>
</div>
<Teleport to="body">
<div v-if="filmDialogFuer" class="hintergrund" @click.self="filmDialogFuer = null">
<div class="fenster" role="dialog" aria-modal="true">
<p class="label">Aufnahme</p>
<h2>Welcher Film lief?</h2>
<FilmSuche
platzhalter="Film suchen …"
@waehlen="filmZuweisen(filmDialogFuer, $event.id)"
/>
<div class="fuss">
<button type="button" class="abbrechen" @click="filmDialogFuer = null">
Abbrechen
</button>
</div>
</div>
</div>
</Teleport>
<Teleport to="body">
<div v-if="hostFilmDialog" class="hintergrund" @click.self="hostFilmDialog = false">
<div class="fenster" role="dialog" aria-modal="true">
<p class="label">Host-Film</p>
<h2>Passwort für den Hostmodus</h2>
<p class="frage">Diesen Film klickt man nach der Tastenkombination an.</p>
<FilmSuche platzhalter="Film als Passwort …" @waehlen="hostFilmSetzen($event.id)" />
<div class="fuss">
<button type="button" class="abbrechen" @click="hostFilmDialog = false">
Abbrechen
</button>
</div>
</div>
</div>
</Teleport>
<Teleport to="body">
<div v-if="setzt" class="hintergrund" @click.self="setzt = null">
<div class="fenster" role="dialog" aria-modal="true">
<p class="label">Anmeldefilm für</p>
<h2>{{ setzt.name }}</h2>
<p class="frage">
{{
setzt.id === ich?.id
? 'Diesen Film musst du beim Wechsel auf deinen Namen anklicken.'
: `Diesen Film muss ${setzt.name} beim Wechsel anklicken.`
}}
</p>
<FilmSuche platzhalter="Film als Passwort …" @waehlen="schutzSetzen(setzt, $event.id)" />
<div class="fuss">
<button type="button" class="abbrechen" @click="setzt = null">Abbrechen</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
.verwaltung-tab {
max-width: 26rem;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 2.5rem 3.5rem;
align-items: start;
max-width: 72rem;
}
/* Mit Host-Rechten: Benutzer links, der Rest rechts */
.verwaltung-tab:has(.rechts) {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.rechts {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
@media (max-width: 900px) {
.verwaltung-tab:has(.rechts) {
grid-template-columns: minmax(0, 1fr);
}
}
h3 {
@@ -99,20 +422,73 @@ h3 {
}
.liste {
display: flex;
flex-direction: column;
gap: 0.3rem;
list-style: none;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 8px;
}
.liste li {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.4rem 0.7rem;
}
.liste li + li {
border-top: 1px solid var(--line);
}
.zeile {
display: flex;
gap: 0.5rem;
align-items: center;
padding: 0.55rem 0.85rem;
background: var(--bg-soft);
}
/* Der hinterlegte Film, aufgeklappt */
.verraten {
display: flex;
gap: 0.6rem;
align-items: center;
padding-top: 0.5rem;
color: var(--text);
font-size: 0.9rem;
border-top: 1px solid var(--line);
}
.verraten img {
width: 40px;
border-radius: 4px;
}
.jahr {
color: var(--muted);
font-size: 0.82rem;
}
.auge {
display: grid;
place-items: center;
width: 1.8rem;
height: 1.8rem;
color: var(--muted);
border: 1px solid var(--line);
border-radius: 8px;
border-radius: 6px;
}
.auge svg {
width: 1rem;
height: 1rem;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
}
.auge:hover,
.auge.an {
color: var(--text);
border-color: var(--accent);
}
.name {
@@ -132,13 +508,161 @@ h3 {
}
.leer,
.fehler {
.fehler,
.erklaerung {
color: var(--muted);
font-size: 0.88rem;
}
.erklaerung {
margin-bottom: 0.8rem;
line-height: 1.5;
}
.fehler {
margin-bottom: 0.8rem;
color: var(--accent);
}
.marke {
padding: 0.1rem 0.4rem;
color: var(--muted);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
border: 1px solid var(--line);
border-radius: 4px;
}
.klein {
padding: 0.25rem 0.55rem;
color: var(--muted);
font-size: 0.78rem;
border: 1px solid var(--line);
border-radius: 6px;
}
.klein:hover {
color: var(--text);
border-color: var(--accent);
}
/* ── Fenster zum Setzen ──────────────────────────────────────────── */
.hintergrund {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.8);
}
.fenster {
display: flex;
flex-direction: column;
gap: 0.6rem;
width: min(38rem, 100%);
max-height: min(42rem, 90vh);
padding: 1.5rem 1.75rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 14px;
}
.label {
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.fenster h2 {
font-size: 1.35rem;
line-height: 1.2;
}
.frage {
color: var(--muted);
font-size: 0.9rem;
}
.fuss {
display: flex;
justify-content: flex-end;
}
.abbrechen {
padding: 0.55rem 1.1rem;
color: var(--muted);
font-size: 0.88rem;
font-weight: 600;
border: 1px solid var(--line);
border-radius: 8px;
}
.abbrechen:hover {
color: var(--text);
}
@media (max-width: 640px) {
.hintergrund {
padding: 0;
}
.fenster {
width: 100%;
height: 100%;
max-height: none;
padding: 1rem;
border: 0;
border-radius: 0;
}
}
.aufnahmen ul {
display: flex;
flex-direction: column;
gap: 0.4rem;
list-style: none;
}
.aufnahmen .zeile {
display: flex;
gap: 0.8rem;
align-items: center;
justify-content: space-between;
font-size: 0.9rem;
}
.aufnahmen .knoepfe {
display: flex;
flex-shrink: 0;
gap: 0.4rem;
}
.aufnahmen em {
color: var(--muted);
font-style: normal;
}
.klein.warnung {
color: #fff;
background: var(--accent);
border-color: var(--accent);
}
.hostfilm .zeile {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.film-anzeige {
color: var(--muted);
font-size: 0.88rem;
}
</style>

View File

@@ -0,0 +1,333 @@
<script setup>
import { computed, ref } from 'vue'
import { posterUrl } from '../images'
import { FARBEN } from '../composables/useZiehung'
import PersonName from './PersonName.vue'
const props = defineProps({
items: { type: Array, default: () => [] },
laden: Boolean,
host: Boolean,
// Der Würfel zieht aus der eigenen Sammlung — die gibt es nur mit Namen
angemeldet: Boolean,
merkliste: { type: Array, default: () => [] },
mine: { type: Number, default: null },
gesehenIds: { type: Set, default: () => new Set() },
// Wer ohne Vorschlag dabei ist, und ob ich das bin
dabei: { type: Array, default: () => [] },
dabeiMine: Boolean,
})
const emit = defineEmits(['open', 'fehler'])
// 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 = computed(() => props.merkliste.filter((e) => e.mine))
const wuerfelt = ref(false)
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)
// Dabei sein zählt auch ohne Film — für die Anwesenheit beim Dreh
async function dabeiUmschalten() {
await fetch('/api/dabei', { method: 'POST' })
}
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 {
emit('fehler', 'Eintragen hat nicht geklappt.')
} finally {
wuerfelt.value = false
}
}
</script>
<template>
<aside class="liste">
<div v-if="!host && angemeldet" 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>
<button
v-if="angemeldet && mine === null"
type="button"
class="dabei-knopf"
:class="{ an: dabeiMine }"
@click="dabeiUmschalten"
>
{{ dabeiMine ? 'Ich bin dabei ✓' : 'Dabei ohne Vorschlag' }}
</button>
<h3>Vorschläge <span class="anzahl">{{ items.length }}</span></h3>
<p v-if="!items.length && !laden" class="leer">Noch nichts vorgeschlagen.</p>
<ul>
<li v-for="(item, i) in items" :key="item.id">
<button
type="button"
class="eintrag"
:class="{ eigen: item.id === mine, gesehen: gesehenIds.has(item.movie_id) }"
@click="emit('open', item)"
>
<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"><PersonName :name="item.name" /></span>
</span>
</button>
</li>
</ul>
<p v-if="dabei.length" class="dabei">
<span class="dabei-titel">Dabei ohne Vorschlag</span>
<span class="dabei-namen">
<PersonName v-for="n in dabei" :key="n" :name="n" />
</span>
</p>
</aside>
</template>
<style scoped>
.dabei-knopf {
width: 100%;
margin-bottom: 1.2rem;
padding: 0.5rem 0.8rem;
color: var(--muted);
font-size: 0.85rem;
font-weight: 600;
border: 1px solid var(--line);
border-radius: 8px;
}
.dabei-knopf:hover {
color: var(--text);
border-color: var(--accent);
}
.dabei-knopf.an {
color: var(--text);
border-color: #6bbf7a;
}
.dabei {
margin-top: 1rem;
font-size: 0.85rem;
}
.dabei-namen {
display: flex;
flex-wrap: wrap;
gap: 0.2rem 0.7rem;
}
.dabei-titel {
display: block;
margin-bottom: 0.2rem;
color: var(--muted);
font-size: 0.72rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.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;
}
.leer {
color: var(--muted);
font-size: 0.85rem;
}
h3 {
margin-bottom: 0.7rem;
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.anzahl {
padding: 0.1rem 0.45rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 20px;
}
ul {
display: flex;
flex-direction: column;
gap: 0.2rem;
list-style: none;
}
.eintrag {
display: flex;
gap: 0.7rem;
align-items: flex-start;
width: 100%;
padding: 0.55rem;
color: var(--text);
font-size: 0.86rem;
text-align: left;
border-radius: 8px;
}
/* Schon gelaufen — die Ziehung überspringt diesen Film */
.eintrag.gesehen {
opacity: 0.45;
filter: grayscale(1);
}
.eintrag.gesehen:hover {
opacity: 1;
filter: none;
}
.eintrag:hover {
background: var(--bg-soft);
}
/* Der farbige Rand verbindet den Eintrag mit seinen Kacheln auf der Bahn */
.cover {
display: block;
width: 46px;
flex-shrink: 0;
overflow: hidden;
background: var(--bg-soft);
border-left: 3px solid;
border-radius: 3px;
}
.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 {
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 {
margin-top: 0.15rem;
font-size: 0.72rem;
opacity: 0.75;
}
</style>

View File

@@ -1,29 +1,221 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { posterUrl } from '../images'
// Das Buch der Abende. Jede Seite ein Film, die Seiten liegen nebeneinander.
// Ziehen blättert frei und rollt aus, ein Klick auf eine Seite holt sie in
// die Mitte, die Pfeiltasten gehen Seite für Seite. Kein Einrasten durch
// den Browser — das käme der eigenen Bewegung in die Quere. Der jüngste
// Abend liegt ganz links und ist beim Öffnen aufgeschlagen.
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { abonnieren } from '../live'
import Abend from './Abend.vue'
const props = defineProps({ host: Boolean })
const props = defineProps({
host: Boolean,
// Eine Wertung gehört einer Person — ohne Namen gäbe es keine
angemeldet: Boolean,
runde: { type: Array, default: () => [] },
// Wer gerade gewählt ist — wechselt der Name, gehören die "Du:"-Sterne
// einer anderen Person und müssen neu geladen werden
ich: { type: Object, default: null },
})
const emit = defineEmits(['open', 'changed'])
const items = ref([])
// Ausgeblendete Filme bleiben für den Host sichtbar, damit er sie
// zurückholen kann
const sichtbar = computed(() =>
props.host ? items.value : items.value.filter((e) => !e.hidden)
)
const fehler = ref('')
const laden = ref(true)
const STERNE = [1, 2, 3, 4, 5]
// Ausgeblendete Filme bleiben für den Host sichtbar, damit er sie
// zurückholen kann. Die Liste kommt jüngster zuerst — so liegt er links.
const seiten = computed(() => (props.host ? items.value : items.value.filter((e) => !e.hidden)))
// Beim Schweben füllen sich alle Sterne bis zum Zeiger, wie gewohnt
const schwebt = ref({ id: null, wert: 0 })
const buch = ref(null)
const aktuell = ref(0)
function gefuellt(item, stern) {
if (schwebt.value.id === item.id) return stern <= schwebt.value.wert
return item.mine >= stern
function seitenbreite() {
return buch.value?.querySelector('.blatt')?.offsetWidth || 1
}
// Welche Seite liegt gerade in der Mitte — für Anzeige und Tastatur
function beimBlaettern() {
if (!buch.value) return
aktuell.value = Math.round(buch.value.scrollLeft / seitenbreite())
}
function blaettern(schritt) {
// Die Seite direkt aus der Position lesen — der Zähler hinkt beim
// Ausrollen einen Tick hinterher
const jetzt = Math.round((buch.value?.scrollLeft || 0) / seitenbreite())
const ziel = Math.max(0, Math.min(seiten.value.length - 1, jetzt + schritt))
gleiten(ziel * seitenbreite())
}
// ── Bewegung ───────────────────────────────────────────────────────────
let animation = 0
function anhalten() {
cancelAnimationFrame(animation)
animation = 0
}
// Gleitet zu einer Zielposition: schnell los, sanft ankommen
function gleiten(ziel) {
const el = buch.value
if (!el) return
anhalten()
const start = el.scrollLeft
const weg = ziel - start
if (Math.abs(weg) < 1) return
const dauer = Math.min(600, Math.max(250, Math.abs(weg) * 0.6))
const beginn = performance.now()
function schritt(jetzt) {
const t = Math.min(1, (jetzt - beginn) / dauer)
const p = 1 - (1 - t) ** 3
el.scrollLeft = start + weg * p
if (t < 1) animation = requestAnimationFrame(schritt)
else animation = 0
}
animation = requestAnimationFrame(schritt)
}
// Rollt mit der Geschwindigkeit der Hand aus, mit Reibung, und bleibt
// liegen, wo es steht — wie ein Blatt, das man anschubst
function ausrollen(tempo) {
const el = buch.value
if (!el || Math.abs(tempo) < 0.05) return
anhalten()
// Nach links gezogen heißt: die Bahn läuft weiter nach rechts
let v = -tempo
let vorher = performance.now()
const ende = el.scrollWidth - el.clientWidth
function schritt(jetzt) {
const dt = Math.min(50, jetzt - vorher)
vorher = jetzt
el.scrollLeft = Math.max(0, Math.min(ende, el.scrollLeft + v * dt))
// Reibung je Millisekunde — nach etwa einer Sekunde steht es
v *= 0.994 ** dt
const amRand = el.scrollLeft <= 0 || el.scrollLeft >= ende
if (Math.abs(v) > 0.02 && !amRand) animation = requestAnimationFrame(schritt)
else animation = 0
}
animation = requestAnimationFrame(schritt)
}
// Beim ersten Laden ganz nach links: dort liegt der jüngste Abend
let aufgeschlagen = false
watch(seiten, async (liste) => {
if (aufgeschlagen || !liste.length) return
aufgeschlagen = true
await nextTick()
if (buch.value) buch.value.scrollLeft = 0
beimBlaettern()
})
function tastatur(e) {
if (e.target instanceof Element && e.target.matches('input, textarea')) return
if (e.key === 'ArrowLeft') blaettern(-1)
else if (e.key === 'ArrowRight') blaettern(1)
}
// Mit der Maus ziehen wie mit dem Finger. Die Bahn folgt der Hand eins
// zu eins und rollt beim Loslassen aus.
let zieht = false
let startX = 0
let startScroll = 0
let gezogen = false
// Die letzten Bewegungen, um beim Loslassen das Tempo zu kennen
let spur = []
function ziehenStart(e) {
// Finger und Stift macht der Browser selbst, und Knöpfe oder Felder
// sollen ihren Klick behalten
if (e.pointerType !== 'mouse' || e.button !== 0) return
if (e.target instanceof Element && e.target.closest('input, textarea, button, a')) return
const el = buch.value
anhalten()
el.style.userSelect = 'none'
el.style.cursor = 'grabbing'
startX = e.clientX
startScroll = el.scrollLeft
gezogen = false
zieht = true
spur = [[e.clientX, performance.now()]]
}
function ziehen(e) {
if (!zieht) return
// Wurde die Taste außerhalb des Buchs losgelassen, ist der Zug vorbei
if (!(e.buttons & 1)) return ziehenEnde(e)
const dx = e.clientX - startX
// Erst ab ein paar Pixeln zählt es als Ziehen, nicht als Klick
if (!gezogen && Math.abs(dx) < 5) return
if (!gezogen) {
gezogen = true
// Erst jetzt die Maus festhalten, damit der Zug auch außerhalb des
// Buchs weitergeht. Beim Drücken wäre es zu früh: mit gehaltenem
// Zeiger käme der Klick beim Buch an statt bei der Seite.
try {
buch.value.setPointerCapture(e.pointerId)
} catch {
/* ohne geht es auch, solange die Maus im Buch bleibt */
}
}
buch.value.scrollLeft = startScroll - dx
spur.push([e.clientX, performance.now()])
// Nur die letzten 100 ms zählen fürs Tempo
const grenze = performance.now() - 100
while (spur.length > 2 && spur[0][1] < grenze) spur.shift()
}
function ziehenEnde(e) {
if (!zieht) return
zieht = false
const el = buch.value
try {
el.releasePointerCapture(e.pointerId)
} catch {
/* war nicht gehalten */
}
el.style.userSelect = ''
el.style.cursor = ''
if (!gezogen) return
// Der Klick, der den Zug beendet, kommt sofort nach dem Loslassen und
// wird abgefangen. Danach darf der nächste wieder durch — auch wenn
// der Zug ohne Klick endete, etwa weil das Fenster den Fokus verlor.
setTimeout(() => {
gezogen = false
}, 0)
// Tempo in Pixel je Millisekunde aus den letzten Bewegungen
const [x0, t0] = spur[0]
const [x1, t1] = spur[spur.length - 1]
// Steht die Hand beim Loslassen schon, rollt nichts nach
const stand = performance.now() - t1 > 80
ausrollen(stand || t1 <= t0 ? 0 : (x1 - x0) / (t1 - t0))
}
// Ein Klick auf eine Seite, die nicht in der Mitte liegt, holt sie hinein.
// Knöpfe, Felder und der Titel behalten ihren eigenen Klick.
function seiteWaehlen(e) {
if (gezogen) return
if (e.target instanceof Element && e.target.closest('input, textarea, button, a, h2')) return
const blatt = e.target instanceof Element ? e.target.closest('.blatt') : null
if (!blatt) return
const index = [...buch.value.querySelectorAll('.blatt')].indexOf(blatt)
const ziel = index * seitenbreite()
if (Math.abs(buch.value.scrollLeft - ziel) > 4) gleiten(ziel)
}
// Nach einem Zug darf der Klick nicht mehr durchgehen, sonst öffnet er,
// worauf die Maus beim Loslassen stand
function klickAbfangen(e) {
if (gezogen) {
e.stopPropagation()
e.preventDefault()
gezogen = false
}
}
let abbestellen = null
@@ -40,67 +232,14 @@ async function laden_() {
}
}
async function bewerten(item, sterne) {
// Sofort anzeigen, der Live-Kanal liefert gleich die richtigen Zahlen
item.mine = sterne
try {
await fetch(`/api/watched/${item.id}/rating`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stars: sterne }),
})
} catch {
fehler.value = 'Bewerten hat nicht geklappt.'
}
}
async function sichtbarkeit(item) {
await fetch(`/api/watched/${item.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hidden: !item.hidden }),
})
}
async function wertungEntfernen(wertung) {
await fetch(`/api/watched/rating/${wertung.id}`, { method: 'DELETE' })
}
async function entfernen(item) {
await fetch(`/api/watched/${item.id}`, { method: 'DELETE' })
}
async function oeffnen(item) {
const resp = await fetch(`/api/search/${item.movie_id}`)
if (resp.ok) emit('open', await resp.json())
}
// Für das Datumsfeld zählt der lokale Tag, nicht der in UTC
function alsFeld(iso) {
const d = new Date(iso)
const zwei = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${zwei(d.getMonth() + 1)}-${zwei(d.getDate())}`
}
async function datumSetzen(item, tag) {
if (!tag) return
await fetch(`/api/watched/${item.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ watched_at: tag }),
})
}
function datum(iso) {
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
watch(
() => props.ich?.id,
() => laden_(),
)
onMounted(() => {
laden_()
window.addEventListener('keydown', tastatur)
abbestellen = abonnieren((nachricht) => {
if (nachricht.type !== 'watched') return
laden_()
@@ -108,306 +247,140 @@ onMounted(() => {
})
})
onUnmounted(() => abbestellen?.())
onUnmounted(() => {
window.removeEventListener('keydown', tastatur)
abbestellen?.()
})
</script>
<template>
<div class="watched-tab">
<p v-if="fehler" class="note error">{{ fehler }}</p>
<p v-else-if="!laden && !sichtbar.length" class="note">
Noch kein Film geschaut.
</p>
<p v-else-if="!laden && !seiten.length" class="note">Noch kein Film geschaut.</p>
<div class="grid">
<article
v-for="item in sichtbar"
:key="item.id"
class="karte"
:class="{ versteckt: item.hidden }"
<template v-else-if="seiten.length">
<div
ref="buch"
class="buch"
@scroll.passive="beimBlaettern"
@pointerdown="ziehenStart"
@pointermove="ziehen"
@pointerup="ziehenEnde"
@pointercancel="ziehenEnde"
@click.capture="klickAbfangen"
@click="seiteWaehlen"
>
<button type="button" class="cover" @click="oeffnen(item)">
<img
v-if="posterUrl(item.poster_path, 'w342')"
:src="posterUrl(item.poster_path, 'w342')"
:alt="item.title"
loading="lazy"
/>
<span v-else class="kein-cover">Kein Cover</span>
<Abend
v-for="item in seiten"
:key="item.id"
class="blatt"
:item="item"
:host="host"
:angemeldet="angemeldet"
:runde="runde"
@open="emit('open', $event)"
/>
</div>
<p class="stand">
<button type="button" :disabled="aktuell <= 0" aria-label="Zurück" @click="blaettern(-1)">
‹
</button>
<div class="text">
<h3 @click="oeffnen(item)">{{ item.title }}</h3>
<p class="meta">
<span v-if="item.year">{{ item.year }}</span>
<span v-if="host" class="datum-feld">
Geschaut am
<input
type="date"
:value="alsFeld(item.watched_at)"
@change="datumSetzen(item, $event.target.value)"
/>
</span>
<span v-else>Geschaut am {{ datum(item.watched_at) }}</span>
</p>
<p class="schnitt">
<template v-if="item.count">
<span class="wert">{{ item.average.toFixed(1) }}</span>
<span class="von">von 5 ({{ item.count }})</span>
</template>
<span v-else class="von">Noch keine Wertung</span>
</p>
<div class="wertung" @mouseleave="schwebt = { id: null, wert: 0 }">
<button
v-for="s in STERNE"
:key="s"
type="button"
class="stern"
:class="{ an: gefuellt(item, s) }"
:aria-label="`${s} von 5`"
@mouseenter="schwebt = { id: item.id, wert: s }"
@click="bewerten(item, s)"
>
★
</button>
</div>
<ul v-if="item.ratings.length" class="stimmen">
<li v-for="r in item.ratings" :key="r.id">
<span class="wer">{{ r.name || 'Anonym' }}</span>
<span class="punkte">{{ '★'.repeat(r.stars) }}</span>
<button
v-if="host"
type="button"
class="weg-klein"
title="Diese Wertung entfernen"
@click="wertungEntfernen(r)"
>
×
</button>
</li>
</ul>
<div v-if="host" class="aktionen">
<button
type="button"
class="icon"
:title="item.hidden ? 'Wieder einblenden' : 'Ausblenden'"
@click="sichtbarkeit(item)"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M1 12s4-7.5 11-7.5S23 12 23 12s-4 7.5-11 7.5S1 12 1 12z" />
<circle cx="12" cy="12" r="3.2" />
<line v-if="item.hidden" x1="3" y1="21" x2="21" y2="3" />
</svg>
</button>
<button
type="button"
class="icon loeschen"
title="Samt Bewertungen löschen"
@click="entfernen(item)"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<line x1="6" y1="6" x2="18" y2="18" />
<line x1="18" y1="6" x2="6" y2="18" />
</svg>
</button>
</div>
</div>
</article>
</div>
<span>{{ aktuell + 1 }} / {{ seiten.length }}</span>
<button
type="button"
:disabled="aktuell >= seiten.length - 1"
aria-label="Weiter"
@click="blaettern(1)"
>
›
</button>
</p>
</template>
</div>
</template>
<style scoped>
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.1rem;
.watched-tab {
/* Eine Doppelseite: links das Cover im Posterformat, rechts genauso
breit die Texte. So breit, dass sie in die Höhe passt, aber nie
breiter als das Fenster. */
--seite: min(100%, calc((100vh - 9.5rem) * 4 / 3));
position: relative;
/* Kopfzeile, Polster des Reiters und der Seitenzähler darunter —
das Buch bekommt den Rest, ohne dass die Seite selbst scrollt */
height: calc(100vh - 8rem);
}
.karte {
.buch {
display: flex;
gap: 0.9rem;
align-items: flex-start;
padding: 0.8rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 12px;
height: 100%;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.cover {
flex-shrink: 0;
width: 96px;
padding: 0;
.buch::-webkit-scrollbar {
display: none;
}
.cover img,
.kein-cover {
width: 96px;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 8px;
/* Die Maus zeigt an, dass man ziehen kann; beim Ziehen setzt das Skript
den greifenden Zeiger direkt am Element */
.buch {
cursor: grab;
}
.kein-cover {
display: grid;
place-items: center;
color: var(--muted);
font-size: 0.72rem;
background: var(--bg);
.buch .blatt {
cursor: inherit;
}
.text {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
/* Abstandhalter, damit auch die erste und die letzte Seite in der Mitte
liegen können */
.buch::before,
.buch::after {
flex: 0 0 calc((100% - var(--seite)) / 2);
content: '';
}
h3 {
font-size: 1rem;
line-height: 1.25;
cursor: pointer;
}
h3:hover {
color: var(--accent);
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
color: var(--muted);
font-size: 0.78rem;
}
.datum-feld {
display: flex;
gap: 0.35rem;
align-items: center;
}
.datum-feld input {
padding: 0.15rem 0.35rem;
color: var(--text);
font-size: 0.78rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 5px;
}
.schnitt {
display: flex;
gap: 0.35rem;
align-items: baseline;
margin-top: 0.2rem;
}
.wert {
color: #f5c518;
font-size: 1.05rem;
font-weight: 700;
}
.von {
color: var(--muted);
font-size: 0.78rem;
}
.wertung {
display: flex;
gap: 0.1rem;
align-items: center;
}
.stern {
padding: 0 0.05rem;
color: var(--line);
font-size: 1.25rem;
line-height: 1;
}
.stern.an {
color: #f5c518;
}
.stimmen {
display: flex;
flex-direction: column;
gap: 0.1rem;
margin-top: 0.25rem;
list-style: none;
}
.stimmen li {
display: flex;
gap: 0.5rem;
font-size: 0.76rem;
}
.wer {
color: var(--muted);
.blatt {
flex: 0 0 var(--seite);
height: 100%;
/* Die beiden Hälften scrollen für sich, das Blatt selbst nicht */
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
border-right: 1px solid var(--line);
border-left: 1px solid var(--line);
}
.punkte {
color: #f5c518;
}
.weg-klein {
padding: 0 0.25rem;
.stand {
position: absolute;
bottom: -1.7rem;
left: 50%;
display: flex;
gap: 0.4rem;
align-items: center;
padding: 0.15rem 0.4rem;
color: var(--muted);
font-size: 0.9rem;
font-size: 0.75rem;
transform: translateX(-50%);
background: rgba(10, 10, 12, 0.8);
border-radius: 10px;
}
.stand button {
padding: 0 0.3rem;
color: var(--muted);
font-size: 1.1rem;
line-height: 1;
}
.weg-klein:hover {
color: var(--accent);
}
.aktionen {
display: flex;
gap: 0.35rem;
margin-top: 0.5rem;
}
.icon {
display: grid;
place-items: center;
width: 1.9rem;
height: 1.9rem;
color: var(--muted);
border: 1px solid var(--line);
border-radius: 6px;
}
.icon svg {
width: 1.05rem;
height: 1.05rem;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
}
.icon:hover {
.stand button:hover:not(:disabled) {
color: var(--text);
border-color: var(--muted);
}
.icon.loeschen:hover {
color: var(--accent);
border-color: var(--accent);
}
/* Der Host sieht sofort, was für die anderen weg ist */
.karte.versteckt {
opacity: 0.55;
.stand button:disabled {
opacity: 0.3;
cursor: default;
}
.note {
@@ -419,4 +392,17 @@ h3:hover {
.note.error {
color: var(--accent);
}
@media (max-width: 640px) {
.watched-tab {
--seite: 100%;
height: calc(100dvh - 7.5rem);
}
/* Auf dem Handy liegen die Hälften untereinander, das Blatt scrollt */
.blatt {
overflow-y: auto;
border: 0;
}
}
</style>

View File

@@ -1,31 +1,35 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { posterUrl } from '../images'
import { abonnieren } from '../live'
import { klang } from '../klang'
import { useZiehung } from '../composables/useZiehung'
import VorschlagsListe from './VorschlagsListe.vue'
import PersonName from './PersonName.vue'
defineProps({ host: Boolean })
const props = defineProps({
host: Boolean,
angemeldet: Boolean,
// Wer gerade gewählt ist — "Dabei ohne Vorschlag" gehört zur Person
ich: { type: Object, default: null },
// Beides hält die App schon aktuell — auch nach einem Namenswechsel
merkliste: { type: Array, default: () => [] },
mine: { type: Number, default: null },
gesehenIds: { type: Set, default: () => new Set() },
})
const emit = defineEmits(['open', 'reset'])
const items = ref([])
const mine = ref(null)
// Wer ohne Vorschlag dabei ist — zählt beim Dreh als anwesend
const dabei = ref([])
const dabeiMine = ref(false)
const laden = ref(true)
const fehler = ref('')
const winkel = ref(0)
const dreht = ref(false)
const gewinner = ref(null)
const dauer = ref(4.2)
const GROESSE = 420
const MITTE = GROESSE / 2
const RADIUS = MITTE - 6
// Kräftige, gut unterscheidbare Töne — nicht die Akzentfarbe, sonst
// verschwimmt der Zeiger mit dem Rad
const FARBEN = [
'#b3202a', '#1f6f8b', '#c9772b', '#4c6b2f', '#6d3f8f',
'#9c2d5c', '#2b7a6f', '#8a6a1f', '#3a4a8f', '#7a3520',
]
const {
masse, ergebnis, dreht, gewinner, dauer, x, anlauf, bruch, kurve, bahn,
kacheln, ruhen, abspielen, laufendenDrehHolen, zurueckgesetzt,
} = useZiehung({ items })
// Ohne Server-Ziehung sähe jede Seite ein anderes Ergebnis
let abbestellen = null
@@ -34,49 +38,12 @@ function verbinden() {
abbestellen = abonnieren((nachricht) => {
if (nachricht.type === 'suggestions') laden_()
else if (nachricht.type === 'spin') abspielen(nachricht.data)
else if (nachricht.type === 'reset') zurueckgesetzt()
})
}
function abspielen(ergebnis, uebersprungen = 0) {
gewinner.value = null
dreht.value = true
const rest = Math.max(0, ergebnis.dauer - uebersprungen)
if (uebersprungen > 0) {
// Wer mitten im Dreh dazukommt, springt an die passende Stelle
dauer.value = rest
} else {
dauer.value = ergebnis.dauer
}
// Immer vorwärts drehen, egal wie oft schon gedreht wurde
winkel.value = winkel.value - (winkel.value % 360) + ergebnis.winkel
window.setTimeout(() => {
dreht.value = false
gewinner.value = ergebnis.gewinner
}, rest * 1000)
}
async function laufendenDrehHolen() {
try {
const resp = await fetch('/api/spin')
if (!resp.ok) return
const daten = await resp.json()
if (!daten.result) return
if (daten.running) {
abspielen(daten.result, daten.elapsed)
} else {
// Dreh ist durch: Rad ohne Animation auf die Endstellung setzen,
// sonst zeigt der Zeiger woanders hin als die Gewinnerkarte
dauer.value = 0
winkel.value = daten.result.winkel
gewinner.value = daten.result.gewinner
else if (nachricht.type === 'reset') {
zurueckgesetzt()
emit('reset')
laden_()
}
} catch {
/* dann eben ohne */
}
})
}
async function laden_() {
@@ -87,7 +54,9 @@ async function laden_() {
if (!resp.ok) throw new Error('Vorschläge konnten nicht geladen werden')
const daten = await resp.json()
items.value = daten.items
mine.value = daten.mine
dabei.value = daten.dabei || []
dabeiMine.value = !!daten.dabei_mine
ruhen()
} catch (err) {
fehler.value = err.message
} finally {
@@ -95,86 +64,19 @@ async function laden_() {
}
}
onMounted(async () => {
await laden_()
await laufendenDrehHolen()
verbinden()
})
onUnmounted(() => abbestellen?.())
defineExpose({ neu: laden_ })
const segmente = computed(() => {
const n = items.value.length
if (!n) return []
const schritt = 360 / n
return items.value.map((item, i) => {
const start = i * schritt
const ende = start + schritt
const mitte = start + schritt / 2
// Auf der linken Hälfte stünde die Schrift kopfüber — dort läuft sie
// von außen nach innen
const gespiegelt = mitte > 180
return {
item,
farbe: FARBEN[i % FARBEN.length],
pfad: kreisstueck(start, ende),
textDreh: gespiegelt ? mitte + 90 : mitte - 90,
gespiegelt,
// Unter dieser Segmentbreite passt kein lesbares Wort mehr hinein
breit: schritt > 9,
kurz: kuerzen(item.title, schritt),
}
})
})
function kuerzen(titel, schritt) {
// Je schmaler das Segment, desto weniger Zeichen bleiben lesbar
const max = schritt > 30 ? 24 : schritt > 18 ? 18 : 13
return titel.length > max ? titel.slice(0, max - 1) + '…' : titel
}
function punkt(grad) {
const rad = ((grad - 90) * Math.PI) / 180
return [MITTE + RADIUS * Math.cos(rad), MITTE + RADIUS * Math.sin(rad)]
}
function kreisstueck(von, bis) {
// Ein einzelner Vorschlag ergäbe einen Vollkreis, den ein Pfad nicht
// zeichnen kann — dann bleibt es bei zwei Halbkreisen
if (bis - von >= 360) {
return `M ${MITTE} ${MITTE - RADIUS} A ${RADIUS} ${RADIUS} 0 1 1 ${MITTE} ${
MITTE + RADIUS
} A ${RADIUS} ${RADIUS} 0 1 1 ${MITTE} ${MITTE - RADIUS} Z`
}
const [x1, y1] = punkt(von)
const [x2, y2] = punkt(bis)
const gross = bis - von > 180 ? 1 : 0
return `M ${MITTE} ${MITTE} L ${x1} ${y1} A ${RADIUS} ${RADIUS} 0 ${gross} 1 ${x2} ${y2} Z`
}
async function drehen() {
if (dreht.value || !items.value.length) return
// Der Server lost aus und schickt das Ergebnis an alle — auch an uns
// Der Host hat geklickt — jetzt darf der Browser Ton. Der Server lost
// aus und schickt das Ergebnis an alle, auch an uns.
klang()
try {
const resp = await fetch('/api/spin', { method: 'POST' })
if (!resp.ok) fehler.value = 'Drehen hat nicht geklappt.'
if (!resp.ok) fehler.value = 'Ziehen hat nicht geklappt.'
} catch {
fehler.value = 'Drehen hat nicht geklappt.'
fehler.value = 'Ziehen hat nicht geklappt.'
}
}
function zurueckgesetzt() {
// Ohne Dauer 0 würde das Rad die Strecke zurück sichtbar abfahren
dauer.value = 0
winkel.value = 0
gewinner.value = null
dreht.value = false
emit('reset')
laden_()
}
async function zuruecksetzen() {
// Der Server meldet den Reset an alle zurück, auch an uns
try {
@@ -188,6 +90,21 @@ async function oeffnen(item) {
const resp = await fetch(`/api/search/${item.movie_id}`)
if (resp.ok) emit('open', await resp.json())
}
watch(
() => props.ich?.id,
() => laden_()
)
onMounted(async () => {
await laden_()
await laufendenDrehHolen()
verbinden()
})
onUnmounted(() => abbestellen?.())
defineExpose({ neu: laden_ })
</script>
<template>
@@ -195,38 +112,48 @@ async function oeffnen(item) {
<div class="buehne">
<p v-if="fehler" class="note error">{{ fehler }}</p>
<div class="rad-halter">
<div class="zeiger"></div>
<svg
class="rad"
<div
class="fenster"
:style="{ '--kachel': `${masse.kachel}px`, '--luecke': `${masse.luecke}px` }"
>
<div class="marke" :class="{ anlauf, bruch }"></div>
<div
ref="bahn"
class="bahn"
:class="{ dreht }"
:viewBox="`0 0 ${GROESSE} ${GROESSE}`"
:style="{
transform: `rotate(${winkel}deg)`,
transform: `translateX(${x}px)`,
transitionDuration: `${dauer}s`,
transitionTimingFunction: kurve,
}"
>
<circle :cx="MITTE" :cy="MITTE" :r="RADIUS" fill="var(--bg-soft)" />
<g v-for="(seg, i) in segmente" :key="seg.item.id">
<path :d="seg.pfad" :fill="seg.farbe" stroke="#0a0a0c" stroke-width="2" />
<text
v-if="seg.breit"
:transform="`rotate(${seg.textDreh} ${MITTE} ${MITTE})`"
:x="seg.gespiegelt ? MITTE - 40 : MITTE + 40"
:y="MITTE + 4"
:text-anchor="seg.gespiegelt ? 'end' : 'start'"
class="beschriftung"
>
{{ seg.kurz }}
</text>
</g>
<circle :cx="MITTE" :cy="MITTE" r="26" fill="#0a0a0c" />
</svg>
<div
v-for="k in kacheln"
:key="k.key"
class="kachel"
:class="{
treffer: k.treffer && !dreht && gewinner,
gesehen: gesehenIds.has(k.film.movie_id),
}"
:style="{ borderColor: k.farbe }"
>
<img
v-if="posterUrl(k.film.poster_path, 'w342')"
:src="posterUrl(k.film.poster_path, 'w342')"
:alt="k.film.title"
draggable="false"
/>
<span v-else class="ohne-cover">{{ k.film.title }}</span>
</div>
</div>
<p v-if="!items.length && !laden && !ergebnis" class="leer-bahn">
Noch nichts vorgeschlagen.
</p>
</div>
<div v-if="host" class="knoepfe">
<button class="dreh" type="button" :disabled="dreht || !items.length" @click="drehen">
{{ dreht ? 'dreht…' : 'Drehen' }}
{{ dreht ? 'läuft…' : 'Ziehen' }}
</button>
<button
class="reset"
@@ -248,30 +175,25 @@ async function oeffnen(item) {
<p class="label">Der Film des Abends</p>
<h2>{{ gewinner.title }}</h2>
<p class="jahr">
{{ gewinner.year }}<span v-if="gewinner.name"> · von {{ gewinner.name }}</span>
{{ gewinner.year }}<span v-if="gewinner.name"> · von <PersonName :name="gewinner.name" /></span>
</p>
</div>
</div>
</div>
<aside class="liste">
<h3>Vorschläge <span class="anzahl">{{ items.length }}</span></h3>
<p v-if="!items.length && !laden" class="leer">Noch nichts vorgeschlagen.</p>
<ul>
<li v-for="(item, i) in items" :key="item.id">
<button
type="button"
class="eintrag"
: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>
</button>
</li>
</ul>
</aside>
<VorschlagsListe
:items="items"
:laden="laden"
:host="host"
:angemeldet="angemeldet"
:merkliste="merkliste"
:mine="mine"
:gesehen-ids="gesehenIds"
:dabei="dabei"
:dabei-mine="dabeiMine"
@open="oeffnen"
@fehler="fehler = $event"
/>
</div>
</template>
@@ -288,41 +210,163 @@ async function oeffnen(item) {
flex-direction: column;
align-items: center;
gap: 1.25rem;
min-width: 0;
}
.rad-halter {
/* ── Die Bahn ────────────────────────────────────────────────────── */
.fenster {
position: relative;
width: min(420px, 80vw);
align-self: stretch;
/* Ein Poster ist 2:3, plus Luft für den Glow des Gewinners */
height: calc(var(--kachel) * 1.5 + 40px);
overflow: hidden;
/* Links und rechts läuft der Streifen ins Dunkel aus */
mask-image: linear-gradient(to right, transparent, #000 12%, #000 88%, transparent);
}
.rad {
.bahn {
position: absolute;
top: 20px;
left: 50%;
display: flex;
gap: var(--luecke);
will-change: transform;
/* Die Kurve setzt das Skript je Phase */
transition-property: transform;
}
.kachel {
position: relative;
flex: 0 0 var(--kachel);
height: calc(var(--kachel) * 1.5);
overflow: hidden;
background: var(--bg-soft);
border: 2px solid;
border-radius: 8px;
transition: transform 0.4s, box-shadow 0.4s;
}
.kachel img {
display: block;
width: 100%;
/* Ohne Übergang springt das Rad einfach zum Ergebnis */
transition-property: transform;
transition-timing-function: cubic-bezier(0.17, 0.67, 0.12, 1);
height: 100%;
object-fit: cover;
}
.beschriftung {
fill: #fff;
font-size: 13px;
.ohne-cover {
display: grid;
height: 100%;
padding: 0.6rem;
place-items: center;
color: var(--muted);
font-size: 0.85rem;
font-weight: 600;
paint-order: stroke;
stroke: rgba(0, 0, 0, 0.45);
stroke-width: 3px;
text-align: center;
}
.zeiger {
/* Schon gelaufen — die Ziehung überspringt diesen Film */
.kachel.gesehen {
opacity: 0.45;
filter: grayscale(1);
}
/* Der Gewinner blitzt auf, sobald die Bahn steht */
.kachel.treffer {
z-index: 1;
transform: scale(1.06);
box-shadow: 0 0 0 3px var(--accent), 0 0 34px var(--accent);
animation: aufblitzen 0.7s ease-out;
}
@keyframes aufblitzen {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 #fff, 0 0 0 #fff;
}
35% {
transform: scale(1.14);
box-shadow: 0 0 0 4px #fff, 0 0 60px #fff;
}
100% {
transform: scale(1.06);
box-shadow: 0 0 0 3px var(--accent), 0 0 34px var(--accent);
}
}
.marke {
position: absolute;
top: -6px;
top: 0;
bottom: 0;
left: 50%;
z-index: 2;
width: 3px;
margin-left: -1.5px;
background: var(--accent);
box-shadow: 0 0 10px var(--accent);
pointer-events: none;
}
.marke::before,
.marke::after {
position: absolute;
left: 50%;
width: 0;
height: 0;
margin-left: -11px;
border-top: 20px solid var(--text);
border-right: 11px solid transparent;
border-left: 11px solid transparent;
margin-left: -9px;
content: '';
border-right: 9px solid transparent;
border-left: 9px solid transparent;
}
.marke::before {
top: 0;
border-top: 14px solid var(--accent);
}
.marke::after {
bottom: 0;
border-bottom: 14px solid var(--accent);
}
/* Im Anlauf pulst die Marke dreimal — gleich geht es los */
.marke.anlauf {
animation: puls 0.33s ease-in-out 3;
}
@keyframes puls {
50% {
transform: scaleX(2.5);
box-shadow: 0 0 26px var(--accent), 0 0 6px #fff;
}
}
/* Die Halterung bricht: die Marke schlackert kurz */
.marke.bruch {
transform-origin: top center;
animation: wackeln 0.6s ease-in-out;
}
@keyframes wackeln {
20% {
transform: rotate(9deg);
}
45% {
transform: rotate(-7deg);
}
70% {
transform: rotate(4deg);
}
100% {
transform: none;
}
}
.leer-bahn {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--muted);
}
.knoepfe {
@@ -330,6 +374,21 @@ async function oeffnen(item) {
gap: 0.7rem;
}
.dreh {
padding: 0.7rem 2.2rem;
color: #fff;
font-size: 1rem;
font-weight: 600;
background: var(--accent);
border-radius: 8px;
}
.dreh:disabled {
background: var(--line);
color: var(--muted);
cursor: default;
}
.reset {
padding: 0.7rem 1.4rem;
color: var(--muted);
@@ -349,37 +408,29 @@ async function oeffnen(item) {
cursor: default;
}
.leer {
color: var(--muted);
font-size: 0.85rem;
}
.dreh {
padding: 0.7rem 2.2rem;
color: #fff;
font-size: 1rem;
font-weight: 600;
background: var(--accent);
border-radius: 8px;
}
.dreh:disabled {
background: var(--line);
color: var(--muted);
cursor: default;
}
.gewinner {
display: flex;
gap: 1rem;
align-items: center;
max-width: 420px;
max-width: 640px;
padding: 0.9rem;
text-align: left;
cursor: pointer;
background: var(--bg-soft);
border: 1px solid var(--accent);
border-radius: 12px;
animation: aufsteigen 0.45s ease-out;
}
@keyframes aufsteigen {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: none;
}
}
.gewinner img {
@@ -406,69 +457,6 @@ async function oeffnen(item) {
font-size: 0.85rem;
}
.liste h3 {
margin-bottom: 0.7rem;
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.anzahl {
padding: 0.1rem 0.45rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 20px;
}
.liste ul {
display: flex;
flex-direction: column;
gap: 0.2rem;
list-style: none;
}
.eintrag {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.4rem 0.5rem;
color: var(--text);
font-size: 0.86rem;
text-align: left;
border-radius: 6px;
}
.eintrag:hover {
background: var(--bg-soft);
}
.eintrag.eigen .titel {
color: var(--accent);
font-weight: 600;
}
.punkt {
width: 0.6rem;
height: 0.6rem;
flex-shrink: 0;
border-radius: 50%;
}
.titel {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.von {
color: var(--muted);
font-size: 0.76rem;
}
.note {
padding: 3rem 0;
color: var(--muted);

View File

@@ -0,0 +1,183 @@
// 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, movieId = null) {
try {
const resp = await fetch('/api/host', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ an, schluessel, movie_id: movieId }),
})
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, film: false }
} catch {
return { host: false, film: false }
}
}
const TAB_IDS = [
'katalog',
'suche',
'wheel',
'merkliste',
'watched',
'wuensche',
'info',
'verwaltung',
'stream',
'clips',
]
// Netflix und Kanon waren einmal zwei Reiter — heute eine Quelle im Katalog
const QUELLEN = ['netflix', 'canon']
// Ebenso die drei Suchen: heute ein Reiter mit Umschalter darüber
const SUCHARTEN = ['search', 'aehnlich', 'personen', 'ki']
function gemerkt(schluessel, standard) {
const wert = ref(localStorage.getItem(schluessel) ?? standard)
watch(wert, (neu) => localStorage.setItem(schluessel, neu))
return wert
}
export function useEinstellungen() {
// Kompakt zeigt viele Karten nebeneinander, Details dafür mehr je Film
const ansicht = gemerkt('filmabend_ansicht', 'kompakt')
// 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 ohneGesehene = ref(localStorage.getItem('filmabend_ohne_gesehene') !== 'aus')
watch(ohneGesehene, (an) => localStorage.setItem('filmabend_ohne_gesehene', an ? 'an' : 'aus'))
// Nach dem Neuladen soll derselbe Reiter offen sein. Ein gemerkter
// alter Reiter "netflix" oder "canon" landet im Katalog.
const zuletzt = localStorage.getItem('filmabend_tab')
const activeTab = ref(TAB_IDS.includes(zuletzt) ? zuletzt : 'katalog')
watch(activeTab, (id) => localStorage.setItem('filmabend_tab', id))
// Welche Liste der Katalog zeigt: Netflix oder den Horror-Kanon
const quelle = gemerkt('filmabend_quelle', QUELLEN.includes(zuletzt) ? zuletzt : 'netflix')
if (!QUELLEN.includes(quelle.value)) quelle.value = 'netflix'
// Welche Suche offen ist: Titel, Ähnliche oder KI
const suchArt = gemerkt('filmabend_suchart', SUCHARTEN.includes(zuletzt) ? zuletzt : 'search')
if (!SUCHARTEN.includes(suchArt.value)) suchArt.value = 'search'
if (SUCHARTEN.includes(zuletzt)) activeTab.value = 'suche'
// 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)
// Der Host-Link: /?host=<schlüssel>. 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()).host
})
watch(anzeige, (an) => localStorage.setItem('filmabend_host', an ? '1' : '0'))
// Ohne Freischaltung fragt die Tastenkombination nach dem Host-Film —
// wie der Anmeldefilm bei einem Namen
const hostLogin = ref(false)
async function hostAnmelden(movieId) {
const an = await hostSetzen(true, null, movieId)
if (!an) return false
freigeschaltet.value = true
anzeige.value = true
hostLogin.value = false
return true
}
async function umschalten() {
if (freigeschaltet.value) anzeige.value = !anzeige.value
// Ohne Host-Film kommt man so rein; lehnt der Server ab, gibt es einen
else if (!(await hostAnmelden(null))) hostLogin.value = true
}
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()
umschalten()
}
}
// Am Handy gibt es keine Tastenkombination: fünf Sekunden mit einem
// Finger den Bildschirm halten tut dasselbe
const DRUCK_MS = 5000
let druck = null
let druckStart = null
function fingerAb() {
clearTimeout(druck)
druck = null
}
function fingerAuf(e) {
fingerAb()
if (e.touches.length !== 1) return
druckStart = { x: e.touches[0].clientX, y: e.touches[0].clientY }
druck = setTimeout(umschalten, DRUCK_MS)
}
function fingerBewegt(e) {
if (!druck) return
const t = e.touches[0]
// Ein bisschen Zittern ist kein Wischen
if (Math.hypot(t.clientX - druckStart.x, t.clientY - druckStart.y) > 15) fingerAb()
}
const finger = {
touchstart: fingerAuf,
touchmove: fingerBewegt,
touchend: fingerAb,
touchcancel: fingerAb,
}
onMounted(() => {
window.addEventListener('keydown', tastatur)
for (const [ev, fn] of Object.entries(finger))
window.addEventListener(ev, fn, { passive: true })
})
onUnmounted(() => {
window.removeEventListener('keydown', tastatur)
for (const [ev, fn] of Object.entries(finger)) window.removeEventListener(ev, fn)
fingerAb()
})
return { ansicht, ohneGesehene, activeTab, quelle, suchArt, host, hostLogin, hostAnmelden }
}

View File

@@ -0,0 +1,252 @@
// Die Filterleiste: Genres, Regler, Sortierung, Suche — und die Prädikate,
// mit denen der Katalog danach gesiebt wird.
import { computed, ref, watch } from 'vue'
// Die Skalen richten sich nach dem ungefilterten Bestand des Reiters —
// sonst wandern die Enden bei jeder Auswahl
export const REGLER = [
{
key: 'vote_count',
label: 'Bewertungen',
feld: 'vote_count',
step: 1,
gestaucht: true,
},
{
key: 'runtime',
label: 'Dauer',
feld: 'runtime',
step: 1,
einheit: ' Min.',
},
{ key: 'year', label: 'Jahr', feld: 'year', step: 1, roh: true },
{
key: 'vote_average',
label: 'Bewertung',
feld: 'vote_average',
step: 0.1,
nachkomma: 1,
},
]
// Bei der Suche steht kein Katalog dahinter, aus dem sich Grenzen ableiten
// ließen — TMDB reicht von den Anfängen bis heute
const SUCH_GRENZEN = {
vote_count: { min: 0, max: 50000 },
runtime: { min: 0, max: 240 },
year: { min: 1900, max: new Date().getFullYear() + 1 },
vote_average: { min: 0, max: 10 },
}
// Die 19 Genres, die TMDB kennt
const SUCH_GENRES = [
'Abenteuer',
'Action',
'Animation',
'Dokumentarfilm',
'Drama',
'Familie',
'Fantasy',
'Historie',
'Horror',
'Komödie',
'Krimi',
'Kriegsfilm',
'Liebesfilm',
'Musik',
'Mystery',
'Science Fiction',
'TV-Film',
'Thriller',
'Western',
].map((genre) => ({ genre, count: null }))
export function useFilter({
activeTab,
quelle,
suchArt,
netflixMovies,
canonMovies,
gesehenIds,
ohneGesehene,
}) {
const search = ref('')
const sort = ref('random')
// Jede Suche hat ihre eigene sinnvolle Reihenfolge: die Titelsuche die
// von TMDB (wie auf IMDb), Ähnliche gemischt, bei Personen das Neueste
// zuerst. Die Wahl bleibt je Suche erhalten.
const suchSorts = ref({
search: 'relevanz',
aehnlich: 'random',
personen: 'newest',
})
const suchSort = computed({
get: () => suchSorts.value[suchArt.value] ?? 'relevanz',
set: (wert) => {
suchSorts.value[suchArt.value] = wert
},
})
// Wie viele Treffer eine Suche bringen soll — je Suche gemerkt. Bei
// Personen gibt es das nicht: da sind es eben alle Filme.
const suchAnzahlen = ref({ search: 24, aehnlich: 20, ki: 12 })
const suchAnzahl = computed({
get: () => suchAnzahlen.value[suchArt.value] ?? 24,
set: (wert) => {
suchAnzahlen.value[suchArt.value] = wert
},
})
const include = ref([])
const exclude = ref([])
const suchReiter = () => activeTab.value === 'suche'
const pool = () => (quelle.value === 'netflix' ? netflixMovies.value : canonMovies.value)
const grenzen = computed(() => {
if (suchReiter()) return SUCH_GRENZEN
const out = {}
for (const regler of REGLER) {
const werte = pool()
.map((m) => m[regler.feld])
.filter((v) => v != null)
const min = werte.length ? Math.min(...werte) : 0
const max = werte.length ? Math.max(...werte) : 0
out[regler.key] = {
min: regler.step < 1 ? Math.floor(min * 10) / 10 : Math.floor(min),
max: regler.step < 1 ? Math.ceil(max * 10) / 10 : Math.ceil(max),
}
}
return out
})
const ranges = ref({})
// Im Suche-Reiter starten zwei Regler nicht am Anschlag: unter 60
// Minuten sind es Kurzfilme, unter 50 Stimmen Making-ofs und Cameos.
// Wer das sehen will, zieht den Regler runter — bewusst.
const SUCH_START = { runtime: 60, vote_count: 50 }
function resetRanges() {
ranges.value = Object.fromEntries(
Object.entries(grenzen.value).map(([k, v]) => [
k,
[suchReiter() && SUCH_START[k] != null ? SUCH_START[k] : v.min, v.max],
]),
)
}
// Beim Reiterwechsel passen die alten Werte nicht mehr zur neuen Skala
watch(grenzen, resetRanges, { immediate: true })
function imBereich(movie) {
for (const regler of REGLER) {
const spanne = ranges.value[regler.key]
const grenze = grenzen.value[regler.key]
if (!spanne) continue
// Ein unbegrenzter Regler darf Filme ohne Wert nicht aussortieren
if (spanne[0] === grenze.min && spanne[1] === grenze.max) continue
const wert = movie[regler.feld]
if (wert == null || wert < spanne[0] || wert > spanne[1]) return false
}
return true
}
// Nur Genres anbieten, die in der gewählten Quelle auch Treffer haben
const genreOptions = computed(() => {
if (suchReiter()) return SUCH_GENRES
const counts = new Map()
for (const movie of pool()) {
for (const genre of movie.genres || []) counts.set(genre, (counts.get(genre) || 0) + 1)
}
// Alphabetisch, damit man ein Genre an derselben Stelle wiederfindet.
// Nach Häufigkeit sortiert wanderten die Badges bei jedem Reiterwechsel.
return [...counts.entries()]
.sort((a, b) => a[0].localeCompare(b[0], 'de'))
.map(([genre, count]) => ({ genre, count }))
})
function toggleGenre(kind, genre) {
const liste = kind === 'include' ? include : exclude
const andere = kind === 'include' ? exclude : include
const at = liste.value.indexOf(genre)
if (at === -1) {
liste.value = [...liste.value, genre]
// Ein Genre kann nicht gleichzeitig gezeigt und ausgeblendet werden
andere.value = andere.value.filter((g) => g !== genre)
} else {
liste.value = liste.value.filter((g) => g !== genre)
}
}
function toggleAll(kind) {
const liste = kind === 'include' ? include : exclude
const andere = kind === 'include' ? exclude : include
const alle = genreOptions.value.map((o) => o.genre)
// Sind schon alle drin, ist der zweite Klick ein Leeren
if (liste.value.length === alle.length) {
liste.value = []
} else {
liste.value = alle
andere.value = []
}
}
function resetGenres() {
include.value = []
exclude.value = []
resetRanges()
}
// 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 hasGenre(movie) {
const genres = movie.genres || []
if (exclude.value.length && genres.some((g) => exclude.value.includes(g))) {
return false
}
return !include.value.length || genres.some((g) => include.value.includes(g))
}
function nichtGesehen(movie) {
return !ohneGesehene.value || !gesehenIds.value.has(movie.id)
}
function passt(movie) {
return nichtGesehen(movie) && hasGenre(movie) && imBereich(movie)
}
// Was die Leiste eingestellt hat — der Suche-Reiter schickt es an TMDB
const suchFilter = computed(() => {
const spanne = (key) => ranges.value[key] || []
return {
sort: suchSort.value,
anzahl: suchAnzahl.value,
include: include.value,
exclude: exclude.value,
stimmen: spanne('vote_count'),
dauer: spanne('runtime'),
jahr: spanne('year'),
note: spanne('vote_average'),
grenzen: grenzen.value,
}
})
return {
search,
sort,
suchSort,
suchAnzahl,
include,
exclude,
grenzen,
ranges,
genreOptions,
toggleGenre,
toggleAll,
resetGenres,
nichtGesehen,
passt,
suchFilter,
}
}

View File

@@ -0,0 +1,150 @@
// Der Katalog: alle Filme aus der Datenbank, aufgeteilt in Netflix und
// Kanon, zu Reihen gruppiert und sortierbar.
import { computed, ref } from 'vue'
// Gewichtete Note: ohne sie stehen Filme mit 10.0 aus zwei Stimmen ganz oben.
// Je weniger Stimmen, desto stärker zieht der Katalogschnitt die Note zu sich.
const MIN_VOTES = 150
// Ein fester Wurf je Film. Mit Math.random() im Vergleich springen die
// Karten bei jedem Filtern neu durcheinander.
const wuerfel = new Map()
function zufall(movie) {
if (!wuerfel.has(movie.id)) wuerfel.set(movie.id, Math.random())
return wuerfel.get(movie.id)
}
// Reihen bleiben zusammen, damit eine Karte durch ihre Teile blättert.
// Grundlage ist TMDBs Collection — Herr der Ringe belegt sonst drei Kacheln.
function groupBy(movies, schluessel) {
const byKey = new Map()
const einzeln = []
for (const movie of movies) {
const key = schluessel(movie)
if (key === null || key === undefined) {
einzeln.push(movie)
continue
}
if (!byKey.has(key)) byKey.set(key, [])
byKey.get(key).push(movie)
}
const gruppen = []
for (const [key, parts] of byKey) {
// Eine Reihe mit nur einem verfügbaren Teil ist keine Reihe
if (parts.length === 1) einzeln.push(parts[0])
else gruppen.push({ group: key, parts })
}
return { gruppen, einzeln }
}
export function matches(movie, term) {
return (
movie.title.toLowerCase().includes(term) ||
(movie.original_title || '').toLowerCase().includes(term)
)
}
export function useKatalog() {
const movies = ref([])
const status = ref(null)
const loading = ref(true)
const error = ref('')
async function load() {
loading.value = true
error.value = ''
try {
const [moviesRes, statusRes] = await Promise.all([
fetch('/api/movies'),
fetch('/api/status'),
])
if (!moviesRes.ok) throw new Error('Filme konnten nicht geladen werden')
movies.value = await moviesRes.json()
status.value = await statusRes.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
const netflixMovies = computed(() => movies.value.filter((m) => m.on_netflix))
const canonMovies = computed(() =>
movies.value.filter((m) => m.canon_group !== null)
)
const average = computed(() => {
const rated = movies.value.filter((m) => m.vote_count)
if (!rated.length) return 0
return rated.reduce((sum, m) => sum + m.vote_average, 0) / rated.length
})
function score(movie) {
const votes = movie.vote_count || 0
return (
(votes / (votes + MIN_VOTES)) * (movie.vote_average || 0) +
(MIN_VOTES / (votes + MIN_VOTES)) * average.value
)
}
const SORTS = {
random: (a, b) => zufall(a) - zufall(b),
rating: (a, b) => score(b) - score(a),
newest: (a, b) => (b.year || 0) - (a.year || 0),
oldest: (a, b) => (a.year || 9999) - (b.year || 9999),
title: (a, b) => a.title.localeCompare(b.title, 'de'),
}
function sortiereReihe(parts) {
const sorted = [...parts].sort(
(a, b) =>
(a.release_date || '').localeCompare(b.release_date || '') ||
(a.canon_pos ?? 0) - (b.canon_pos ?? 0)
)
// Die Karte startet beim stärksten Teil, danach sortiert auch die
// Kachelreihenfolge — sonst entscheidet ein schwacher Auftakt
let best = 0
sorted.forEach((part, i) => {
if (score(part) > score(sorted[best])) best = i
})
return { parts: sorted, best }
}
// Ein Kanon-Eintrag ist ein Film oder eine ganze Reihe.
// Die Vorlage gruppiert das Conjuring-Universum nach Sub-Reihen statt nach
// Jahr — hier zählt das Erscheinungsjahr.
const canonGroups = computed(() => {
const byGroup = new Map()
for (const movie of canonMovies.value) {
if (!byGroup.has(movie.canon_group)) byGroup.set(movie.canon_group, [])
byGroup.get(movie.canon_group).push(movie)
}
return [...byGroup.entries()]
.sort((a, b) => a[0] - b[0])
.map(([group, parts]) => ({ group, ...sortiereReihe(parts) }))
})
// Im Netflix-Reiter bilden TMDBs Collections die Reihen
const netflixEntries = computed(() => {
const { gruppen, einzeln } = groupBy(netflixMovies.value, (m) => m.collection_id)
return [
...gruppen.map((g) => ({ group: 'c' + g.group, ...sortiereReihe(g.parts) })),
...einzeln.map((m) => ({ group: 'm' + m.id, parts: [m], best: 0 })),
]
})
return {
movies,
status,
loading,
error,
load,
netflixMovies,
canonMovies,
canonGroups,
netflixEntries,
SORTS,
}
}

View File

@@ -0,0 +1,81 @@
// Eine Liste mit tausenden Karten: gerendert wird nur der Anfang, der Rest
// kommt beim Scrollen nach. Dazu der Knopf, der wieder nach oben bringt.
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
// Alle 2554 Karten auf einmal sind in der Detailansicht über zehntausend
// Bilder — der Browser meldet dann "Seite reagiert nicht".
const STAPEL = 100
// Eigene Animation statt behavior: 'smooth'. Das rechnet mit fester
// Geschwindigkeit und bräuchte über 300.000 Pixel Seitenhöhe Minuten.
const OBEN_DAUER = 450
export function useLangeListe({ liste, ansicht }) {
const gezeigt = ref(STAPEL)
const sichtbar = computed(() => liste.value.slice(0, gezeigt.value))
// Jede neue Suche, jeder Filter und jeder Reiterwechsel fängt oben an
watch([liste, ansicht], () => {
gezeigt.value = STAPEL
})
// Der Fuß der Liste meldet sich, sobald er in Sichtweite kommt. Ein
// Scroll-Handler müsste bei jedem Pixel rechnen, das hier nicht.
const nachschub = ref(null)
const beobachter = new IntersectionObserver(
(eintraege) => {
if (eintraege.some((e) => e.isIntersecting)) mehrLaden()
},
// Vorlauf, damit der Nachschub schon da ist, bevor die Liste endet
{ rootMargin: '600px' }
)
async function mehrLaden() {
if (gezeigt.value >= liste.value.length) return
gezeigt.value += STAPEL
// Der Beobachter meldet sich nur beim Wechsel sichtbar/unsichtbar. Bleibt
// der Fuß nach dem Nachladen im Blick — großer Bildschirm, kurze Liste —,
// käme sonst nie ein zweiter Stapel.
await nextTick()
const el = nachschub.value
if (el && el.getBoundingClientRect().top < window.innerHeight + 600) mehrLaden()
}
// Der Fuß verschwindet und kommt wieder, je nach Filter — also bei jedem
// Wechsel neu anmelden
watch(nachschub, (el, vorher) => {
if (vorher) beobachter.unobserve(vorher)
if (el) beobachter.observe(el)
})
// Bei 2500 Karten kommt man sonst nicht mehr nach oben
const obenKnopf = ref(false)
function beimScrollen() {
obenKnopf.value = window.scrollY > 800
}
function nachOben() {
const start = window.scrollY
const beginn = performance.now()
function schritt(jetzt) {
const t = Math.min(1, (jetzt - beginn) / OBEN_DAUER)
// Sanft an- und auslaufend
const p = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2
window.scrollTo(0, start * (1 - p))
if (t < 1) requestAnimationFrame(schritt)
}
requestAnimationFrame(schritt)
}
onMounted(() => window.addEventListener('scroll', beimScrollen, { passive: true }))
onUnmounted(() => {
window.removeEventListener('scroll', beimScrollen)
beobachter.disconnect()
})
return { gezeigt, sichtbar, nachschub, obenKnopf, nachOben }
}

View File

@@ -0,0 +1,130 @@
// Wer ich bin und was an mir hängt: die Runde, meine Sammlung, mein
// Vorschlag, die gesehenen Filme und die Wünsche. Alles wird beim Start
// geladen und über den Live-Kanal frisch gehalten.
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { abonnieren, neuVerbinden } from '../live'
async function hole(url) {
try {
const resp = await fetch(url)
return resp.ok ? await resp.json() : null
} catch {
return null
}
}
export function useMeins({ host }) {
// 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)
async function ladeRunde() {
const daten = await hole('/api/users')
if (!daten) return
runde.value = daten.items
ich.value = daten.mine
}
// 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, movie_id: movieId }),
})
if (!resp.ok) return false
neuVerbinden()
// 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()
return true
}
// Die geschauten Filme — die Karten und die Detailansicht zeigen damit an,
// ob ein Film schon lief
const gesehen = ref([])
async function ladeGesehen() {
gesehen.value = (await hole('/api/watched')) ?? gesehen.value
}
// Für die Karten: nur die Kennungen, als Menge zum schnellen Nachschlagen
const gesehenIds = computed(() => new Set(gesehen.value.map((e) => e.movie_id)))
// Ausgeblendete Filme zählen für Gäste nicht mit
const gesehenZahl = computed(() => gesehen.value.filter((e) => host.value || !e.hidden).length)
// Wer schon vorgeschlagen hat, soll das in der Detailansicht sehen —
// und alle Filme auf der Bahn, damit die Karten es zeigen können
const meinVorschlag = ref(null)
const aufDemRad = ref([])
async function ladeVorschlag() {
const daten = await hole('/api/suggestions')
if (!daten) {
meinVorschlag.value = null
aufDemRad.value = []
return
}
meinVorschlag.value = daten.items.find((i) => i.id === daten.mine) || null
aufDemRad.value = daten.items.map((i) => i.movie_id)
}
// Die eigene Merkliste — was man selbst sehen möchte, unabhängig vom Rad
const merkliste = ref([])
async function ladeMerkliste() {
merkliste.value = (await hole('/api/wishlist')) ?? merkliste.value
}
// Was der Seite noch fehlt
const wuensche = ref([])
async function ladeWuensche() {
wuensche.value = (await hole('/api/features')) ?? wuensche.value
}
let abbestellen = null
onMounted(() => {
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()
// Punkte und Level hängen an Wertungen, Kommentaren und Herzen —
// die Runde trägt sie, also mit nachladen
if (nachricht.type === 'watched') ladeRunde()
})
})
onUnmounted(() => abbestellen?.())
return {
runde,
ich,
michSetzen,
gesehen,
gesehenIds,
gesehenZahl,
ladeGesehen,
meinVorschlag,
aufDemRad,
ladeVorschlag,
merkliste,
ladeMerkliste,
wuensche,
ladeWuensche,
}
}

View File

@@ -0,0 +1,287 @@
// Die Ziehung läuft wie das Öffnen einer Lootbox: ein Streifen aus Postern
// rast unter der Marke durch und bremst auf dem Gewinner aus.
//
// Phasen: Anlauf (die Marke pulst), Fahrt (die Bahn rast los und kriecht am
// Ende). Jedes zweite Mal bricht danach die Halterung: kurze Ruhe, dann
// rollt die Bahn ein paar Poster zurück. Der Server nennt die Gesamtdauer
// und die Halte, die Phasenlängen stehen hier — Ruhe plus Rückfahrt müssen
// zu BRUCH_DAUER im Backend passen.
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { anlaufTon, fanfare, knacken, tick } from '../klang'
const ANLAUF = 1.0
const BRUCH_RUHE = 1.2
const BRUCH_FAHRT = 2.5
// Kurze Ruhe zwischen Stillstand und Enthüllung — der Moment vor dem Glow
const ENTHUELLUNG = 0.6
// Fahrt: schnell weg, lange kriechen. Bruch: sanft ausrollen.
const KURVEN = {
fahrt: 'cubic-bezier(0.15, 0.7, 0.3, 1)',
bruch: 'cubic-bezier(0.2, 0.6, 0.3, 1)',
}
// Auf dem Handy sind die Poster kleiner, sonst passen keine drei ins Bild
const MASSE = {
breit: { kachel: 220, luecke: 10 },
schmal: { kachel: 140, luecke: 8 },
}
const SCHMAL = '(max-width: 640px)'
// Kräftige, gut unterscheidbare Töne — nicht die Akzentfarbe, sonst
// verschwimmt die Marke mit dem Rand
export const FARBEN = [
'#b3202a', '#1f6f8b', '#c9772b', '#4c6b2f', '#6d3f8f',
'#9c2d5c', '#2b7a6f', '#8a6a1f', '#3a4a8f', '#7a3520',
]
export function useZiehung({ items }) {
const schmal = ref(window.matchMedia(SCHMAL).matches)
const masse = computed(() => {
const m = schmal.value ? MASSE.schmal : MASSE.breit
return { ...m, schritt: m.kachel + m.luecke }
})
const ergebnis = ref(null)
const dreht = ref(false)
const gewinner = ref(null)
const dauer = ref(0)
const x = ref(0)
const anlauf = ref(false)
const bruch = ref(false)
const kurve = ref(KURVEN.fahrt)
const bahn = ref(null)
let timer = null
let enthuellung = null
// Die Bahn hängt mit ihrem Anfang an der Fenstermitte. So landet die
// Zielkachel unter der Marke, egal wie breit das Fenster ist.
function stoppX(stopp) {
const { kachel, schritt } = masse.value
return -(stopp.position * schritt + kachel / 2 + stopp.versatz * kachel)
}
// Vor der ersten Ziehung stehen die Vorschläge einfach in einer Reihe
function ruheX() {
const { luecke, schritt } = masse.value
return -(items.value.length * schritt - luecke) / 2
}
function fahren(sekunden, k, ziel) {
kurve.value = k
dauer.value = sekunden
x.value = ziel
}
function ruhen() {
if (!ergebnis.value) fahren(0, KURVEN.fahrt, ruheX())
}
// Liest die laufende Position aus dem Übergang und tickt bei jedem
// Kachelwechsel unter der Marke. Das Tempo kommt aus zwei Frames.
let zuletzt = -1
let letzteX = 0
let letzteZeit = 0
function ticken(zeit) {
if (!dreht.value || !bahn.value) return
const { luecke, schritt } = masse.value
const m = new DOMMatrixReadOnly(getComputedStyle(bahn.value).transform)
const idx = Math.floor((-m.m41 + luecke / 2) / schritt)
const dt = (zeit - letzteZeit) / 1000
const tempo = letzteZeit && dt > 0 ? Math.abs(m.m41 - letzteX) / dt : 0
letzteX = m.m41
letzteZeit = zeit
if (zuletzt === -1) {
// Die Startkachel zählt nicht — sonst tickt es vor dem Losfahren
zuletzt = idx
} else if (idx !== zuletzt) {
zuletzt = idx
tick(tempo)
}
requestAnimationFrame(ticken)
}
function ticksStarten() {
zuletzt = -1
letzteZeit = 0
requestAnimationFrame(ticken)
}
// Jede Phase hat eine Dauer, einen Start (mit der Restzeit, die sie noch
// hat) und ein Ende. Wer später dazukommt, überspringt die vorderen.
function phasen(erg) {
const erst = erg.stopps[0]
const ende = erg.stopps.at(-1)
const extra = erg.stopps.length > 1 ? BRUCH_RUHE + BRUCH_FAHRT : 0
const fahrt = erg.dauer - ANLAUF - extra
const liste = [
{
d: ANLAUF,
start(rest) {
anlauf.value = true
if (rest > 0) anlaufTon(rest)
},
ende() {
anlauf.value = false
},
},
{
d: fahrt,
start(rest) {
fahren(rest, KURVEN.fahrt, stoppX(erst))
ticksStarten()
},
},
]
if (erg.stopps.length > 1) {
liste.push(
{ d: BRUCH_RUHE },
{
d: BRUCH_FAHRT,
start(rest) {
bruch.value = true
if (rest > 0) knacken()
fahren(rest, KURVEN.bruch, stoppX(ende))
},
ende() {
bruch.value = false
},
}
)
}
return liste
}
function ablauf(erg, liste, i, uebersprungen) {
if (i >= liste.length) {
dreht.value = false
// Erst Stille, dann der Glow
enthuellung = window.setTimeout(() => {
gewinner.value = erg.gewinner
fanfare()
}, ENTHUELLUNG * 1000)
return
}
const ph = liste[i]
if (uebersprungen >= ph.d) {
// Schon vorbei: nur den Endzustand setzen
ph.start?.(0)
ph.ende?.()
ablauf(erg, liste, i + 1, uebersprungen - ph.d)
return
}
const rest = ph.d - uebersprungen
ph.start?.(rest)
timer = window.setTimeout(() => {
ph.ende?.()
ablauf(erg, liste, i + 1, 0)
}, rest * 1000)
}
function abbrechen() {
clearTimeout(timer)
clearTimeout(enthuellung)
}
async function abspielen(erg, uebersprungen = 0) {
abbrechen()
gewinner.value = null
ergebnis.value = erg
dreht.value = true
// Erst ohne Übergang an den Anfang, sonst fährt die Bahn vom letzten
// Ergebnis aus los
fahren(0, KURVEN.fahrt, -3 * masse.value.schritt)
await nextTick()
void bahn.value?.offsetWidth
ablauf(erg, phasen(erg), 0, uebersprungen)
}
async function laufendenDrehHolen() {
try {
const resp = await fetch('/api/spin')
if (!resp.ok) return
const daten = await resp.json()
if (!daten.result) return
if (daten.running) {
abspielen(daten.result, daten.elapsed)
} else {
// Ziehung ist durch: Bahn ohne Animation auf die Endstellung setzen,
// sonst steht die Marke woanders als die Gewinnerkarte
ergebnis.value = daten.result
fahren(0, KURVEN.fahrt, stoppX(daten.result.stopps.at(-1)))
gewinner.value = daten.result.gewinner
}
} catch {
/* dann eben ohne */
}
}
function zurueckgesetzt() {
abbrechen()
anlauf.value = false
bruch.value = false
ergebnis.value = null
gewinner.value = null
dreht.value = false
}
// Läuft eine Ziehung, zeigt die Bahn deren Streifen — sonst die Vorschläge
const kacheln = computed(() => {
const erg = ergebnis.value
if (erg) {
const ziel = erg.stopps.at(-1).position
return erg.streifen.map((i, k) => ({
key: k,
film: erg.karten[i],
farbe: FARBEN[i % FARBEN.length],
treffer: k === ziel,
}))
}
return items.value.map((item, i) => ({
key: i,
film: item,
farbe: FARBEN[i % FARBEN.length],
treffer: false,
}))
})
// Dreht jemand das Handy, ändern sich die Maße — die Bahn rückt nach
const medien = window.matchMedia(SCHMAL)
const beiWechsel = (e) => {
schmal.value = e.matches
}
watch(schmal, () => {
if (dreht.value) return
if (ergebnis.value) fahren(0, KURVEN.fahrt, stoppX(ergebnis.value.stopps.at(-1)))
else ruhen()
})
onMounted(() => medien.addEventListener('change', beiWechsel))
onUnmounted(() => {
abbrechen()
medien.removeEventListener('change', beiWechsel)
})
return {
masse,
ergebnis,
dreht,
gewinner,
dauer,
x,
anlauf,
bruch,
kurve,
bahn,
kacheln,
ruhen,
abspielen,
laufendenDrehHolen,
zurueckgesetzt,
}
}

View File

@@ -11,9 +11,33 @@ function spanne(filter, name, schluessel, ziel) {
return raus
}
// Die Filter der Leiste auf einen Treffer anwenden, den TMDB schon
// geliefert hat — ein Regler auf Anschlag schneidet nichts weg
export function passtZurLeiste(movie, filter) {
const g = filter.grenzen || {}
const drin = (werte, wert, key) => {
if (!werte || wert == null) return true
const grenze = g[key]
if (!grenze) return true
if (werte[0] > grenze.min && wert < werte[0]) return false
if (werte[1] < grenze.max && wert > werte[1]) return false
return true
}
const genres = movie.genres || []
if ((filter.include || []).length && !genres.some((x) => filter.include.includes(x))) return false
if ((filter.exclude || []).some((x) => genres.includes(x))) return false
return (
drin(filter.stimmen, movie.vote_count, 'vote_count') &&
drin(filter.dauer, movie.runtime, 'runtime') &&
drin(filter.jahr, movie.year, 'year') &&
drin(filter.note, movie.vote_average, 'vote_average')
)
}
export function filterParameter(filter) {
return {
sort: filter.sort || 'rating',
...(filter.anzahl ? { limit: filter.anzahl } : {}),
include: (filter.include || []).join(','),
exclude: (filter.exclude || []).join(','),
...spanne(filter, 'stimmen', 'vote_count', 'stimmen'),

50
frontend/src/klang.js Normal file
View File

@@ -0,0 +1,50 @@
// Die Geräusche der Ziehung: ein Tick je Poster, das unter der Marke
// durchläuft, ein Aufladen im Anlauf, ein Knacken beim Bruch und ein Klang
// zur Enthüllung. Alles synthetisch, keine Dateien.
//
// Der Browser gibt Ton erst nach einer Eingabe frei — wer nur zuschaut und
// nie geklickt hat, hört nichts, sieht aber alles.
let audio = null
export function klang() {
if (!audio) audio = new AudioContext()
if (audio.state === 'suspended') audio.resume()
return audio
}
function ton(frequenz, dauer, lautstaerke, art = 'triangle', start = 0, bis = null) {
const a = klang()
const t = a.currentTime + start
const osc = a.createOscillator()
const gain = a.createGain()
osc.type = art
osc.frequency.value = frequenz
if (bis) osc.frequency.linearRampToValueAtTime(bis, t + dauer)
gain.gain.setValueAtTime(lautstaerke, t)
gain.gain.exponentialRampToValueAtTime(0.0005, t + dauer)
osc.connect(gain).connect(a.destination)
osc.start(t)
osc.stop(t + dauer)
}
// Schnell: hell und knapp. Beim Kriechen: tiefer, leiser, länger —
// man hört das Bremsen.
export function tick(tempo) {
const a = Math.min(1, tempo / 3000)
ton(700 + 800 * a, 0.04 + 0.06 * (1 - a), 0.05 + 0.07 * a)
}
export function anlaufTon(sekunden) {
ton(150, sekunden, 0.08, 'sine', 0, 520)
}
export function knacken() {
ton(120, 0.12, 0.25, 'square')
ton(80, 0.3, 0.2, 'sawtooth', 0.08)
}
export function fanfare() {
ton(660, 0.5, 0.12, 'sine')
ton(990, 0.7, 0.1, 'sine', 0.12)
}

View File

@@ -4,15 +4,26 @@
let quelle = null
const hoerer = new Set()
function oeffnen() {
quelle = new EventSource('/api/events')
quelle.onmessage = (ereignis) => {
const nachricht = JSON.parse(ereignis.data)
hoerer.forEach((h) => h(nachricht))
}
// Der Browser verbindet nach einem Abbruch von selbst neu
}
export function abonnieren(fn) {
hoerer.add(fn)
if (!quelle) {
quelle = new EventSource('/api/events')
quelle.onmessage = (ereignis) => {
const nachricht = JSON.parse(ereignis.data)
hoerer.forEach((h) => h(nachricht))
}
// Der Browser verbindet nach einem Abbruch von selbst neu
}
if (!quelle) oeffnen()
return () => hoerer.delete(fn)
}
// Der Server merkt sich je Verbindung die Sitzung — daran hängt der
// grüne Punkt. In einem frischen Browser steht die Verbindung, bevor
// das Cookie da ist. Nach dem Namenswechsel darum einmal neu verbinden.
export function neuVerbinden() {
if (!quelle) return
quelle.close()
oeffnen()
}

78
frontend/src/markdown.js Normal file
View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function inline(s) {
return entschaerfen(s)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*\n]+?)\*/g, '$1<em>$2</em>')
.replace(/`([^`\n]+?)`/g, '<code>$1</code>')
}
export function rendern(text) {
const out = []
let absatz = []
let liste = null
let zitat = []
const absatzZu = () => {
if (absatz.length) out.push(`<p>${absatz.map(inline).join('<br>')}</p>`)
absatz = []
}
const listeZu = () => {
if (liste) out.push(`</${liste}>`)
liste = null
}
const zitatZu = () => {
if (zitat.length) out.push(`<blockquote>${zitat.map(inline).join('<br>')}</blockquote>`)
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(`<h${stufe}>${inline(ueberschrift[2])}</h${stufe}>`)
} else if (punkt || nummer) {
absatzZu()
zitatZu()
const art = punkt ? 'ul' : 'ol'
if (liste !== art) {
listeZu()
out.push(`<${art}>`)
liste = art
}
out.push(`<li>${inline((punkt || nummer)[1])}</li>`)
} else if (zit) {
absatzZu()
listeZu()
zitat.push(zit[1])
} else if (!zeile.trim()) {
allesZu()
} else {
listeZu()
zitatZu()
absatz.push(zeile)
}
}
allesZu()
return out.join('')
}

View File

@@ -28,6 +28,9 @@ button {
cursor: pointer;
background: none;
border: none;
/* Kein Doppeltipp-Zoom, kein blauer Blitz beim Tippen */
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
input,
@@ -49,3 +52,61 @@ select:focus {
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,
select,
textarea {
font-size: 16px;
}
}

View File

@@ -20,6 +20,16 @@ hlsAlwaysRemux: yes
rtmp: yes
rtmpAddress: :1935
# Liefert Zeitbereiche der Aufnahme als MP4 — daraus schneidet die App
# die Clips. Nur intern erreichbar, nicht nach außen geöffnet.
playback: yes
playbackAddress: :9996
# Über die API löscht der Host alte Aufnahmen — Segment für Segment.
# Auch nur intern erreichbar; Zugang hat nur der Sender-Nutzer unten.
api: yes
apiAddress: :9997
# Nicht gebraucht, also zu
webrtc: no
rtsp: no
@@ -34,11 +44,22 @@ authInternalUsers:
ips: []
permissions:
- action: publish
- action: api
- user: any
pass:
ips: []
permissions:
- action: read
# Ausschnitte der Aufnahme holen — der Port ist nur intern erreichbar
- action: playback
paths:
filmabend:
# Der Abend wird mitgeschnitten, ohne Neukodierung, in 30-Sekunden-
# Segmenten. Kurze Segmente, damit ein Clip kurz nach der Marke schon
# geschnitten werden kann. Gelöscht wird nichts von selbst.
record: yes
recordPath: /recordings/%path/%Y-%m-%d_%H-%M-%S-%f
recordFormat: fmp4
recordSegmentDuration: 30s
recordDeleteAfter: 0s

View File

@@ -28,7 +28,8 @@ if ! timeout 3 bash -c "</dev/tcp/${ZIEL}/1935" 2>/dev/null; then
exit 1
fi
scripts/ton.sh auf
# Der Ton geht nur in den Stream. MITHOEREN=1 legt ihn zusätzlich auf dein Gerät
scripts/ton.sh auf ${MITHOEREN:+mithoeren}
mkdir -p "$P" "$BASIS/scenes"

View File

@@ -6,8 +6,10 @@
# nach fünf Minuten sind es Sekunden. Eine Null-Senke hängt an der Systemuhr
# und liefert 48 kHz, also genau das, was der Encoder erwartet.
#
# ton.sh auf Senke anlegen, zum Standard machen, Mithören einrichten
# ton.sh ab Standard zurückstellen, Senke entfernen
# ton.sh auf Senke anlegen und zum Standard machen. Der Ton
# geht nur in den Stream, der Rechner bleibt still.
# ton.sh auf mithoeren zusätzlich zurück auf dein gewohntes Gerät
# ton.sh ab Standard zurückstellen, Senke entfernen
set -euo pipefail
SENKE="filmabend"
@@ -56,6 +58,20 @@ auf)
sleep 1
fi
# Der Regelfall: der Ton geht nur in den Stream, hier bleibt es still.
# Am Pegel ändert das nichts — PipeWire lässt die Monitor-Ports vom Regler
# der Senke unberührt (monitor.channel-volumes ist aus), OBS bekommt also
# immer den vollen Ton. Genau deshalb macht Stummschalten allein auch nicht
# leise: das Mithören unten hängt am selben Monitor.
if [ "${2:-}" != "mithoeren" ]; then
# Eine Brücke aus einem früheren Lauf spielte sonst weiter auf die
# Lautsprecher — die Senke allein macht noch nicht still
pkill -f "node.name=filmabend-mithoeren" 2>/dev/null || true
wpctl set-default "$(id_von "$SENKE")"
echo "Ton geht nur in den Stream, $ZURUECK bleibt still."
exit 0
fi
# Damit du selbst weiter über dein gewohntes Gerät hörst.
# setsid, sonst nimmt der Prozess das Ende dieser Shell mit und du
# hörst mitten im Film nichts mehr.
@@ -103,7 +119,7 @@ ab)
;;
*)
echo "Aufruf: ton.sh auf|ab"
echo "Aufruf: ton.sh auf [mithoeren] | ab"
exit 1
;;
esac