This commit is contained in:
team3
2026-09-30 14:31:50 +02:00
parent 624df7e7ee
commit fd7893b5d2
7 changed files with 302 additions and 247 deletions

View File

@@ -262,15 +262,16 @@ 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.
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
zuletzt gezogene. Im Reiter „Clips" zieht man später das Fenster um die
Marke, sieht es sich an, schneidet und gibt frei. Geschnittene Clips
liegen als MP4 unter `clips/`, freigegebene erscheinen im Buch auf der
Seite des Abends. Bis zur Freigabe sieht sie nur, wer sie gesetzt hat —
und der Host, der auch aufräumen darf. Ein freigegebener Clip bringt
3 Punkte, einmal je Film.
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`.

View File

@@ -28,6 +28,13 @@ CREATE TABLE IF NOT EXISTS clips (
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"
@@ -223,33 +230,9 @@ async def _segmente(von, bis):
return resp.json()
async def tage_mit_aufnahme():
"""An welchen Abenden es Aufnahmen gibt — als Datum, wobei der Abend
bis zum nächsten Mittag reicht. Für die Auswahl im Clips-Reiter."""
segmente = await _segmente(datetime(2000, 1, 1, tzinfo=timezone.utc), _jetzt())
tage = set()
for s in segmente:
start = datetime.fromisoformat(s["start"].replace("Z", "+00:00"))
tage.add((start - timedelta(hours=12)).strftime("%Y-%m-%d"))
return sorted(tage)
async def aufnahmen_des_abends(watched_at):
"""Die Aufnahmen zu einem Abend: alles vom Mittag des Tages bis zum
nächsten Mittag — so gehört auch die Nacht nach Mitternacht dazu.
Wurde der Stream zwischendurch beendet, sind es mehrere Stücke,
nach Beginn geordnet."""
tag = datetime.fromisoformat(watched_at[:10]).replace(tzinfo=timezone.utc)
mittag = tag + timedelta(hours=12)
segmente = await _segmente(mittag, mittag + timedelta(days=1))
stuecke = [
{
"start": _iso(datetime.fromisoformat(s["start"].replace("Z", "+00:00"))),
"dauer": round(s["duration"], 1),
}
for s in segmente
]
return sorted(stuecke, key=lambda s: s["start"])
# ── 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):
@@ -259,10 +242,83 @@ def _stueck(s):
}
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 — der Host räumt damit auf."""
"""Alle Aufnahmen, neueste zuerst, jede mit ihrem Film."""
segmente = await _segmente(datetime(2000, 1, 1, tzinfo=timezone.utc), _jetzt())
return sorted((_stueck(s) for s in segmente), key=lambda s: s["start"], reverse=True)
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):

View File

@@ -307,15 +307,17 @@ def clip_oder_404(clip):
@app.post("/api/clips")
def marke_setzen(response: Response, filmabend_sid: str | None = Cookie(None)):
"""Taste C: der Moment wird gemerkt, mehr nicht. Der Film ist der
zuletzt gezogene, sonst der neueste Abend unter Gesehen."""
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 gezogen:
if movie_id is None and gezogen:
movie_id = gezogen["gewinner"]["movie_id"]
else:
if movie_id is None:
abend = watched.juengster()
movie_id = abend["movie_id"] if abend else None
clip = clips.markieren(sid, movie_id)
@@ -323,27 +325,34 @@ def marke_setzen(response: Response, filmabend_sid: str | None = Cookie(None)):
return clip
@app.get("/api/clips/aufnahmen")
async def clip_aufnahmen():
"""Zu welchen Abenden es eine Aufnahme gibt."""
try:
return {"tage": await clips.tage_mit_aufnahme()}
except httpx.HTTPError:
return {"tage": []}
@app.get("/api/aufnahmen")
async def aufnahmen_liste(response: Response, filmabend_sid: str | None = Cookie(None)):
"""Alle Stücke der Aufnahme, neueste zuerst — für die Verwaltung."""
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid):
raise HTTPException(status_code=403, detail="Nur für den Host")
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)
@@ -360,51 +369,25 @@ async def aufnahme_loeschen(
)
except httpx.HTTPError:
raise HTTPException(status_code=502, detail="Die MediaMTX-API ist nicht erreichbar.")
clips.film_zuweisen(start, None)
return {"segmente": anzahl}
@app.get("/api/clips/von-film/{movie_id}")
async def clips_stuecke(
movie_id: int, response: Response, filmabend_sid: str | None = Cookie(None)
):
"""Die Aufnahmen zu einem Abend — mehrere, wenn der Stream neu
gestartet wurde. Daraus wählt man das Stück für den Clip."""
sid = sitzung(response, filmabend_sid)
mitglied(sid)
abend = watched.von_film(movie_id)
if not abend:
raise HTTPException(status_code=404, detail="Dieser Film steht nicht unter Gesehen.")
try:
return {"stuecke": await clips.aufnahmen_des_abends(abend["watched_at"])}
except httpx.HTTPError:
return {"stuecke": []}
@app.post("/api/clips/von-film")
async def clip_von_film(
@app.post("/api/clips/von-aufnahme")
def clip_von_aufnahme(
response: Response,
movie_id: int = Body(..., embed=True),
start: str | None = Body(None, embed=True),
start: str = Body(..., embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Ein Clip zu einem alten Abend: die Marke kommt an den Anfang des
gewählten Stücks — ohne Wahl an den des ersten. Das Fenster schiebt
man dann im Editor hin."""
"""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)
abend = watched.von_film(movie_id)
if not abend:
raise HTTPException(status_code=404, detail="Dieser Film steht nicht unter Gesehen.")
if start is None:
try:
stuecke = await clips.aufnahmen_des_abends(abend["watched_at"])
except httpx.HTTPError:
stuecke = []
if not stuecke:
raise HTTPException(status_code=404, detail="Zu diesem Abend gibt es keine Aufnahme.")
start = stuecke[0]["start"]
beginn = datetime.fromisoformat(start)
clip = clips.markieren(sid, movie_id, beginn + timedelta(seconds=clips.VORHER + 30))
clip = clips.markieren(
sid, clips.film_der_aufnahme(start), beginn + timedelta(seconds=clips.VORHER + 30)
)
events.senden("clips")
return clip

View File

@@ -2,7 +2,7 @@
import httpx
from app import clips, main, punkte
from app import clips, db, main, punkte
def marke(client):
@@ -38,7 +38,7 @@ def test_ohne_dreh_zaehlt_der_neueste_abend(client, film):
assert marke(client)["movie_id"] == film(2)
def test_tage_mit_aufnahme(client, monkeypatch):
def stuecke_mocken(monkeypatch):
async def segmente(von, bis):
# Ein Abend mit Stück vor und nach Mitternacht, ein weiterer am Tag danach
return [
@@ -48,63 +48,72 @@ def test_tage_mit_aufnahme(client, monkeypatch):
]
monkeypatch.setattr(clips, "_segmente", segmente)
assert client.get("/api/clips/aufnahmen").json()["tage"] == ["2026-09-23", "2026-09-25"]
def test_clip_zu_altem_abend(client, film, monkeypatch):
from datetime import datetime, timezone
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'")
client.post("/api/watched", json={"movie_id": film(4)})
# Kein Abend zu diesem Film
assert client.post("/api/clips/von-film", json={"movie_id": 999}).status_code == 404
# Keine Aufnahme an dem Tag
async def keine(watched_at):
return []
monkeypatch.setattr(clips, "aufnahmen_des_abends", keine)
assert client.get(f"/api/clips/von-film/{film(4)}").json()["stuecke"] == []
antwort = client.post("/api/clips/von-film", json={"movie_id": film(4)})
assert antwort.status_code == 404 and "Aufnahme" in antwort.json()["detail"]
# Zwei Stücke, weil der Stream neu gestartet wurde
stuecke = [
{"start": "2026-09-23T19:00:00+00:00", "dauer": 1800.0},
{"start": "2026-09-23T20:00:00+00:00", "dauer": 600.0},
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
async def da(watched_at):
return stuecke
# 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
monkeypatch.setattr(clips, "aufnahmen_des_abends", da)
assert client.get(f"/api/clips/von-film/{film(4)}").json()["stuecke"] == stuecke
# Ohne Wahl: die Marke liegt kurz nach dem Beginn des ersten Stücks
m = client.post("/api/clips/von-film", json={"movie_id": film(4)}).json()
assert m["movie_id"] == film(4)
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"
# Mit Wahl: nach dem Beginn des gewählten Stücks
m = client.post(
"/api/clips/von-film", json={"movie_id": film(4), "start": stuecke[1]["start"]}
).json()
assert m["marke_at"] == "2026-09-23T20: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_stuecke_eines_abends_nach_beginn_geordnet(monkeypatch):
import asyncio
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": "2026-09-23T21:00:00Z", "duration": 600.0},
{"start": "2026-09-23T19:00:00Z", "duration": 1800.04},
]
return [{"start": start, "duration": 60}]
monkeypatch.setattr(clips, "_segmente", segmente)
assert asyncio.run(clips.aufnahmen_des_abends("2026-09-23T20:00:00+00:00")) == [
{"start": "2026-09-23T19:00:00+00:00", "dauer": 1800.0},
{"start": "2026-09-23T21:00:00+00:00", "dauer": 600.0},
]
# 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_fremde_sehen_nichts_der_host_alles(client, client2, zuschauer, hostmodus):
@@ -221,25 +230,21 @@ def test_segmente_eines_stuecks():
]
def test_aufnahmen_verwaltet_nur_der_host(client, zuschauer, hostmodus, monkeypatch):
stuecke = [{"start": "2026-09-23T19:00:00+00:00", "dauer": 60.0}]
async def alle():
return stuecke
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, "aufnahmen_alle", alle)
monkeypatch.setattr(clips, "aufnahme_loeschen", loeschen)
assert client.get("/api/aufnahmen").status_code == 403
assert client.delete("/api/aufnahmen", params={"start": stuecke[0]["start"], "dauer": 60}).status_code == 403
start = "2026-09-23T19:00:00+00:00"
assert client.delete("/api/aufnahmen", params={"start": start, "dauer": 60}).status_code == 403
hostmodus(zuschauer)
assert zuschauer.get("/api/aufnahmen").json()["stuecke"] == stuecke
antwort = zuschauer.delete("/api/aufnahmen", params={"start": stuecke[0]["start"], "dauer": 60})
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 == [(stuecke[0]["start"], 60.0)]
assert geloescht == [(start, 60.0)]
# Mit der Aufnahme geht auch die Zuweisung
assert clips.film_der_aufnahme(start) is None

View File

@@ -434,12 +434,7 @@ onMounted(load)
<StreamTab v-else-if="activeTab === 'stream'" :angemeldet="angemeldet" />
<ClipsTab
v-else-if="activeTab === 'clips'"
:angemeldet="angemeldet"
:host="host"
:abende="gesehen"
/>
<ClipsTab v-else-if="activeTab === 'clips'" :angemeldet="angemeldet" :host="host" />
<div v-else class="layout">
<div class="results">

View File

@@ -10,64 +10,37 @@ import PersonName from './PersonName.vue'
const props = defineProps({
angemeldet: Boolean,
host: Boolean,
// Die gesehenen Abende — für Clips aus alten Aufnahmen
abende: { type: Array, default: () => [] },
})
// "Clip erstellen": erst den Abend wählen, dann öffnet der Editor.
// Zur Wahl stehen nur Abende, zu denen es eine Aufnahme gibt. Wurde der
// Stream an dem Abend neu gestartet, fragt ein zweiter Schritt nach dem
// Stück.
// "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 tage = ref(null)
const gewaehlt = ref(null)
const stuecke = ref(null)
const aufnahmen = ref(null)
async function wahlOeffnen() {
wahl.value = true
wahlFehler.value = ''
tage.value = null
gewaehlt.value = null
stuecke.value = null
aufnahmen.value = null
try {
const resp = await fetch('/api/clips/aufnahmen')
tage.value = resp.ok ? (await resp.json()).tage : []
const resp = await fetch('/api/aufnahmen')
aufnahmen.value = resp.ok ? (await resp.json()).stuecke : []
} catch {
tage.value = []
aufnahmen.value = []
}
}
const abendeMitAufnahme = computed(() =>
tage.value ? props.abende.filter((a) => tage.value.includes(a.watched_at.slice(0, 10))) : [],
)
async function abendWaehlen(abend) {
wahlFehler.value = ''
gewaehlt.value = abend
stuecke.value = null
let liste = []
try {
const resp = await fetch(`/api/clips/von-film/${abend.movie_id}`)
liste = resp.ok ? (await resp.json()).stuecke : []
} catch {
liste = []
}
// Ein Stück: direkt los. Sonst wählt man, welches.
if (liste.length <= 1) return erstellen(abend, liste[0]?.start)
stuecke.value = liste
}
function minuten(sekunden) {
return `${Math.max(1, Math.round(sekunden / 60))} Min`
}
async function erstellen(abend, start = null) {
async function erstellen(aufnahme) {
wahlFehler.value = ''
const resp = await fetch('/api/clips/von-film', {
const resp = await fetch('/api/clips/von-aufnahme', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ movie_id: abend.movie_id, start }),
body: JSON.stringify({ start: aufnahme.start }),
})
if (!resp.ok) {
const daten = await resp.json().catch(() => ({}))
@@ -104,7 +77,7 @@ 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 || 'Ohne Film', clips: [] })
if (!nach.has(key)) nach.set(key, { key, titel: c.title || 'Sonstige', clips: [] })
nach.get(key).clips.push(c)
}
return [...nach.values()]
@@ -140,46 +113,31 @@ onUnmounted(() => abbestellen?.())
<button type="button" class="erstellen" @click="wahlOeffnen">Clip erstellen</button>
</div>
<!-- Abend wählen: nur, wo es eine Aufnahme gibt, klappt es -->
<!-- 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>{{ stuecke ? 'Welche Aufnahme?' : 'Zu welchem Abend?' }}</span>
<span>Welche Aufnahme?</span>
<button type="button" class="zu" @click="wahl = false">×</button>
</header>
<p v-if="wahlFehler" class="abendwahl-fehler">{{ wahlFehler }}</p>
<!-- Zweiter Schritt: der Stream lief an dem Abend mehrmals -->
<template v-if="stuecke">
<p class="abendwahl-hinweis">
{{ gewaehlt.title }} · {{ datum(gewaehlt.watched_at) }} · der Stream lief
{{ stuecke.length }}-mal
</p>
<ul>
<li v-for="s in stuecke" :key="s.start">
<button type="button" @click="erstellen(gewaehlt, s.start)">
<span class="abend-text">
<span class="abend-titel">ab {{ uhrzeit(s.start) }} Uhr</span>
<span class="abend-datum">{{ minuten(s.dauer) }}</span>
</span>
</button>
</li>
</ul>
<button type="button" class="zurueck" @click="stuecke = null">← Anderer Abend</button>
</template>
<p v-else-if="tage === null" class="abendwahl-hinweis">Suche Aufnahmen …</p>
<p v-else-if="!abendeMitAufnahme.length" class="abendwahl-hinweis">
Zu keinem Abend gibt es eine Aufnahme.
</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="a in abendeMitAufnahme" :key="a.id">
<button type="button" @click="abendWaehlen(a)">
<img v-if="a.poster_path" :src="posterUrl(a.poster_path, 'w154')" alt="" />
<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">{{ a.title }}</span>
<span class="abend-datum">{{ datum(a.watched_at) }}</span>
<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>
@@ -290,6 +248,8 @@ h3 {
position: relative;
display: block;
aspect-ratio: 16 / 9;
/* Sonst zieht ein hochkantiges Poster die Karte in die Länge */
overflow: hidden;
background: #000;
}
@@ -404,18 +364,6 @@ h3 {
line-height: 1;
}
.abendwahl .zurueck {
align-self: flex-start;
padding: 0.3rem 0.5rem;
color: var(--muted);
font-size: 0.85rem;
border-radius: 6px;
}
.abendwahl .zurueck:hover {
color: var(--text);
}
.abendwahl-fehler {
color: var(--accent);
font-size: 0.85rem;
@@ -453,6 +401,14 @@ h3 {
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;

View File

@@ -106,6 +106,21 @@ async function aufnahmeLoeschen(s) {
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' })
@@ -245,7 +260,21 @@ async function entfernen(user) {
<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) }}</span>
<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"
@@ -254,10 +283,29 @@ async function entfernen(user) {
>
{{ loeschFrage === s.start ? 'Wirklich löschen?' : 'Löschen' }}
</button>
</span>
</li>
</ul>
</section>
<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">
@@ -552,6 +600,17 @@ h3 {
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);