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 schickt das an die MediaMTX-API (Port 9997, nur intern, angemeldet als
`sender` mit dem Sendepasswort), die die Segmente wegräumt. `sender` mit dem Sendepasswort), die die Segmente wegräumt.
Daraus kommen die Clips. Im Stream-Reiter setzt die Taste C (oder der Jede Aufnahme hat ihren Film: der Host weist ihn in der Verwaltung zu,
Knopf „Clip") eine Marke: nur der Zeitpunkt, vier Sekunden vor jetzt, sonst gilt der Abend mit dem gleichen Datum, sonst läuft sie unter
weil der Player so weit hinter der Live-Kante hängt. Der Film ist der „Sonstige". Daraus kommen die Clips. Im Stream-Reiter setzt die Taste C
zuletzt gezogene. Im Reiter „Clips" zieht man später das Fenster um die (oder der Knopf „Clip") eine Marke: nur der Zeitpunkt, vier Sekunden vor
Marke, sieht es sich an, schneidet und gibt frei. Geschnittene Clips jetzt, weil der Player so weit hinter der Live-Kante hängt. Der Film ist
liegen als MP4 unter `clips/`, freigegebene erscheinen im Buch auf der der der laufenden Aufnahme, sonst der zuletzt gezogene. „Clip erstellen"
Seite des Abends. Bis zur Freigabe sieht sie nur, wer sie gesetzt hat — im Reiter „Clips" setzt eine Marke an den Anfang einer beliebigen
und der Host, der auch aufräumen darf. Ein freigegebener Clip bringt Aufnahme. Dort zieht man das Fenster um die Marke, sieht es sich an und
3 Punkte, einmal je Film. 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, Die Ausschnitte liefert der Playback-Server von MediaMTX (Port 9996,
nur intern): `/get?path=filmabend&start=<RFC3339>&duration=<s>&format=mp4`. 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, datei TEXT,
created_at TEXT NOT NULL 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" STREAM_PFAD = "filmabend"
@@ -223,33 +230,9 @@ async def _segmente(von, bis):
return resp.json() return resp.json()
async def tage_mit_aufnahme(): # ── Aufnahmen: die Stücke aus MediaMTX, jedes mit seinem Film ──────────
"""An welchen Abenden es Aufnahmen gibt — als Datum, wobei der Abend # Eine Aufnahme hängt an keinem Abend. Der Host weist ihr einen Film zu;
bis zum nächsten Mittag reicht. Für die Auswahl im Clips-Reiter.""" # ohne Zuweisung gilt der Abend mit dem gleichen Datum, sonst keiner.
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"])
def _stueck(s): 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(): 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()) 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): def _im_stueck(segmente, start, dauer):

View File

@@ -307,15 +307,17 @@ def clip_oder_404(clip):
@app.post("/api/clips") @app.post("/api/clips")
def marke_setzen(response: Response, filmabend_sid: str | None = Cookie(None)): async def marke_setzen(response: Response, filmabend_sid: str | None = Cookie(None)):
"""Taste C: der Moment wird gemerkt, mehr nicht. Der Film ist der """Taste C: der Moment wird gemerkt, mehr nicht. Der Film ist der der
zuletzt gezogene, sonst der neueste Abend unter Gesehen.""" laufenden Aufnahme, sonst der zuletzt gezogene, sonst der neueste
Abend unter Gesehen."""
sid = sitzung(response, filmabend_sid) sid = sitzung(response, filmabend_sid)
mitglied(sid) mitglied(sid)
movie_id = await clips.film_live()
gezogen = letzter_dreh["ergebnis"] gezogen = letzter_dreh["ergebnis"]
if gezogen: if movie_id is None and gezogen:
movie_id = gezogen["gewinner"]["movie_id"] movie_id = gezogen["gewinner"]["movie_id"]
else: if movie_id is None:
abend = watched.juengster() abend = watched.juengster()
movie_id = abend["movie_id"] if abend else None movie_id = abend["movie_id"] if abend else None
clip = clips.markieren(sid, movie_id) clip = clips.markieren(sid, movie_id)
@@ -323,27 +325,34 @@ def marke_setzen(response: Response, filmabend_sid: str | None = Cookie(None)):
return clip 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") @app.get("/api/aufnahmen")
async def aufnahmen_liste(response: Response, filmabend_sid: str | None = Cookie(None)): async def aufnahmen_liste():
"""Alle Stücke der Aufnahme, neueste zuerst — für die Verwaltung.""" """Alle Aufnahmen, neueste zuerst, jede mit ihrem Film — für
sid = sitzung(response, filmabend_sid) „Clip erstellen" und die Verwaltung."""
if not users.ist_host(sid):
raise HTTPException(status_code=403, detail="Nur für den Host")
try: try:
return {"stuecke": await clips.aufnahmen_alle()} return {"stuecke": await clips.aufnahmen_alle()}
except httpx.HTTPError: except httpx.HTTPError:
raise HTTPException(status_code=502, detail="Die Aufnahmen sind gerade nicht erreichbar.") 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") @app.delete("/api/aufnahmen")
async def aufnahme_loeschen( async def aufnahme_loeschen(
response: Response, start: str, dauer: float, filmabend_sid: str | None = Cookie(None) response: Response, start: str, dauer: float, filmabend_sid: str | None = Cookie(None)
@@ -360,51 +369,25 @@ async def aufnahme_loeschen(
) )
except httpx.HTTPError: except httpx.HTTPError:
raise HTTPException(status_code=502, detail="Die MediaMTX-API ist nicht erreichbar.") raise HTTPException(status_code=502, detail="Die MediaMTX-API ist nicht erreichbar.")
clips.film_zuweisen(start, None)
return {"segmente": anzahl} return {"segmente": anzahl}
@app.get("/api/clips/von-film/{movie_id}") @app.post("/api/clips/von-aufnahme")
async def clips_stuecke( def clip_von_aufnahme(
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(
response: Response, response: Response,
movie_id: int = Body(..., embed=True), start: str = Body(..., embed=True),
start: str | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None), filmabend_sid: str | None = Cookie(None),
): ):
"""Ein Clip zu einem alten Abend: die Marke kommt an den Anfang des """Ein Clip aus einer alten Aufnahme: die Marke kommt kurz nach deren
gewählten Stücks — ohne Wahl an den des ersten. Das Fenster schiebt Anfang, das Fenster schiebt man dann im Editor hin. Den Film bringt
man dann im Editor hin.""" die Aufnahme mit — oder keinen."""
sid = sitzung(response, filmabend_sid) sid = sitzung(response, filmabend_sid)
mitglied(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) 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") events.senden("clips")
return clip return clip

View File

@@ -2,7 +2,7 @@
import httpx import httpx
from app import clips, main, punkte from app import clips, db, main, punkte
def marke(client): def marke(client):
@@ -38,7 +38,7 @@ def test_ohne_dreh_zaehlt_der_neueste_abend(client, film):
assert marke(client)["movie_id"] == film(2) assert marke(client)["movie_id"] == film(2)
def test_tage_mit_aufnahme(client, monkeypatch): def stuecke_mocken(monkeypatch):
async def segmente(von, bis): async def segmente(von, bis):
# Ein Abend mit Stück vor und nach Mitternacht, ein weiterer am Tag danach # Ein Abend mit Stück vor und nach Mitternacht, ein weiterer am Tag danach
return [ return [
@@ -48,63 +48,72 @@ def test_tage_mit_aufnahme(client, monkeypatch):
] ]
monkeypatch.setattr(clips, "_segmente", segmente) 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): def test_aufnahmen_bekommen_ihren_film(client, zuschauer, hostmodus, film, monkeypatch):
from datetime import datetime, timezone """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)}) liste = client.get("/api/aufnahmen").json()["stuecke"]
# Kein Abend zu diesem Film assert [s["start"] for s in liste] == [
assert client.post("/api/clips/von-film", json={"movie_id": 999}).status_code == 404 "2026-09-25T19:00:00+00:00",
"2026-09-24T00:30:00+00:00",
# Keine Aufnahme an dem Tag "2026-09-23T20:00:00+00:00",
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},
] ]
# 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): # Zuweisen darf nur der Host
return stuecke 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 def test_clip_aus_aufnahme(client, zuschauer, hostmodus, film):
m = client.post("/api/clips/von-film", json={"movie_id": film(4)}).json() hostmodus(zuschauer)
assert m["movie_id"] == film(4) 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" assert m["marke_at"] == "2026-09-23T19:01:00+00:00"
# Ohne Film gibt es die Marke trotzdem — unter Sonstige
# Mit Wahl: nach dem Beginn des gewählten Stücks m = client.post("/api/clips/von-aufnahme", json={"start": "2026-09-25T19:00:00+00:00"}).json()
m = client.post( assert m["movie_id"] is None
"/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"
def test_stuecke_eines_abends_nach_beginn_geordnet(monkeypatch): def test_taste_c_nimmt_den_film_der_laufenden_aufnahme(client, zuschauer, hostmodus, film, monkeypatch):
import asyncio from datetime import timedelta
start = clips._iso(clips._jetzt() - timedelta(seconds=100))
async def segmente(von, bis): async def segmente(von, bis):
return [ return [{"start": start, "duration": 60}]
{"start": "2026-09-23T21:00:00Z", "duration": 600.0},
{"start": "2026-09-23T19:00:00Z", "duration": 1800.04},
]
monkeypatch.setattr(clips, "_segmente", segmente) monkeypatch.setattr(clips, "_segmente", segmente)
assert asyncio.run(clips.aufnahmen_des_abends("2026-09-23T20:00:00+00:00")) == [ # Es läuft eine Aufnahme, aber ohne Film: dann wie bisher der neueste Abend
{"start": "2026-09-23T19:00:00+00:00", "dauer": 1800.0}, client.post("/api/watched", json={"movie_id": film(1)})
{"start": "2026-09-23T21:00:00+00:00", "dauer": 600.0}, 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): 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): def test_aufnahme_loescht_nur_der_host(client, zuschauer, hostmodus, film, monkeypatch):
stuecke = [{"start": "2026-09-23T19:00:00+00:00", "dauer": 60.0}]
async def alle():
return stuecke
geloescht = [] geloescht = []
async def loeschen(start, dauer): async def loeschen(start, dauer):
geloescht.append((start, dauer)) geloescht.append((start, dauer))
return 2 return 2
monkeypatch.setattr(clips, "aufnahmen_alle", alle)
monkeypatch.setattr(clips, "aufnahme_loeschen", loeschen) monkeypatch.setattr(clips, "aufnahme_loeschen", loeschen)
assert client.get("/api/aufnahmen").status_code == 403 start = "2026-09-23T19:00:00+00:00"
assert client.delete("/api/aufnahmen", params={"start": stuecke[0]["start"], "dauer": 60}).status_code == 403 assert client.delete("/api/aufnahmen", params={"start": start, "dauer": 60}).status_code == 403
hostmodus(zuschauer) hostmodus(zuschauer)
assert zuschauer.get("/api/aufnahmen").json()["stuecke"] == stuecke zuschauer.put("/api/aufnahmen/film", json={"start": start, "movie_id": film(1)})
antwort = zuschauer.delete("/api/aufnahmen", params={"start": stuecke[0]["start"], "dauer": 60}) antwort = zuschauer.delete("/api/aufnahmen", params={"start": start, "dauer": 60})
assert antwort.json() == {"segmente": 2} 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" /> <StreamTab v-else-if="activeTab === 'stream'" :angemeldet="angemeldet" />
<ClipsTab <ClipsTab v-else-if="activeTab === 'clips'" :angemeldet="angemeldet" :host="host" />
v-else-if="activeTab === 'clips'"
:angemeldet="angemeldet"
:host="host"
:abende="gesehen"
/>
<div v-else class="layout"> <div v-else class="layout">
<div class="results"> <div class="results">

View File

@@ -10,64 +10,37 @@ import PersonName from './PersonName.vue'
const props = defineProps({ const props = defineProps({
angemeldet: Boolean, angemeldet: Boolean,
host: 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. // "Clip erstellen": eine Aufnahme wählen, dann öffnet der Editor. Jede
// Zur Wahl stehen nur Abende, zu denen es eine Aufnahme gibt. Wurde der // Aufnahme bringt ihren Film mit — oder keinen, dann läuft der Clip
// Stream an dem Abend neu gestartet, fragt ein zweiter Schritt nach dem // unter „Sonstige".
// Stück.
const wahl = ref(false) const wahl = ref(false)
const wahlFehler = ref('') const wahlFehler = ref('')
const tage = ref(null) const aufnahmen = ref(null)
const gewaehlt = ref(null)
const stuecke = ref(null)
async function wahlOeffnen() { async function wahlOeffnen() {
wahl.value = true wahl.value = true
wahlFehler.value = '' wahlFehler.value = ''
tage.value = null aufnahmen.value = null
gewaehlt.value = null
stuecke.value = null
try { try {
const resp = await fetch('/api/clips/aufnahmen') const resp = await fetch('/api/aufnahmen')
tage.value = resp.ok ? (await resp.json()).tage : [] aufnahmen.value = resp.ok ? (await resp.json()).stuecke : []
} catch { } 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) { function minuten(sekunden) {
return `${Math.max(1, Math.round(sekunden / 60))} Min` return `${Math.max(1, Math.round(sekunden / 60))} Min`
} }
async function erstellen(abend, start = null) { async function erstellen(aufnahme) {
wahlFehler.value = '' wahlFehler.value = ''
const resp = await fetch('/api/clips/von-film', { const resp = await fetch('/api/clips/von-aufnahme', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ movie_id: abend.movie_id, start }), body: JSON.stringify({ start: aufnahme.start }),
}) })
if (!resp.ok) { if (!resp.ok) {
const daten = await resp.json().catch(() => ({})) const daten = await resp.json().catch(() => ({}))
@@ -104,7 +77,7 @@ const gruppen = computed(() => {
const nach = new Map() const nach = new Map()
for (const c of clips.value) { for (const c of clips.value) {
const key = c.movie_id ?? 0 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) nach.get(key).clips.push(c)
} }
return [...nach.values()] return [...nach.values()]
@@ -140,46 +113,31 @@ onUnmounted(() => abbestellen?.())
<button type="button" class="erstellen" @click="wahlOeffnen">Clip erstellen</button> <button type="button" class="erstellen" @click="wahlOeffnen">Clip erstellen</button>
</div> </div>
<!-- Abend wählen: nur, wo es eine Aufnahme gibt, klappt es --> <!-- Aufnahme wählen: mit Film oder ohne -->
<Teleport to="body"> <Teleport to="body">
<div v-if="wahl" class="abendwahl-overlay" @click.self="wahl = false"> <div v-if="wahl" class="abendwahl-overlay" @click.self="wahl = false">
<div class="abendwahl"> <div class="abendwahl">
<header> <header>
<span>{{ stuecke ? 'Welche Aufnahme?' : 'Zu welchem Abend?' }}</span> <span>Welche Aufnahme?</span>
<button type="button" class="zu" @click="wahl = false">×</button> <button type="button" class="zu" @click="wahl = false">×</button>
</header> </header>
<p v-if="wahlFehler" class="abendwahl-fehler">{{ wahlFehler }}</p> <p v-if="wahlFehler" class="abendwahl-fehler">{{ wahlFehler }}</p>
<p v-if="aufnahmen === null" class="abendwahl-hinweis">Suche Aufnahmen …</p>
<!-- Zweiter Schritt: der Stream lief an dem Abend mehrmals --> <p v-else-if="!aufnahmen.length" class="abendwahl-hinweis">Es gibt keine Aufnahmen.</p>
<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>
<ul v-else> <ul v-else>
<li v-for="a in abendeMitAufnahme" :key="a.id"> <li v-for="s in aufnahmen" :key="s.start">
<button type="button" @click="abendWaehlen(a)"> <button type="button" @click="erstellen(s)">
<img v-if="a.poster_path" :src="posterUrl(a.poster_path, 'w154')" alt="" /> <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-text">
<span class="abend-titel">{{ a.title }}</span> <span class="abend-titel">{{ s.film?.title || 'Sonstige Aufnahme' }}</span>
<span class="abend-datum">{{ datum(a.watched_at) }}</span> <span class="abend-datum">
{{ datum(s.start) }} · {{ uhrzeit(s.start) }} Uhr · {{ minuten(s.dauer) }}
</span>
</span> </span>
</button> </button>
</li> </li>
@@ -290,6 +248,8 @@ h3 {
position: relative; position: relative;
display: block; display: block;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
/* Sonst zieht ein hochkantiges Poster die Karte in die Länge */
overflow: hidden;
background: #000; background: #000;
} }
@@ -404,18 +364,6 @@ h3 {
line-height: 1; 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 { .abendwahl-fehler {
color: var(--accent); color: var(--accent);
font-size: 0.85rem; font-size: 0.85rem;
@@ -453,6 +401,14 @@ h3 {
border-radius: 4px; 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 { .abend-text {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -106,6 +106,21 @@ async function aufnahmeLoeschen(s) {
await aufnahmenLaden() 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) { function aufnahmeText(s) {
const d = new Date(s.start) const d = new Date(s.start)
const datum = d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) 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> <p v-else-if="!aufnahmen.length" class="leer">Keine Aufnahmen.</p>
<ul v-else> <ul v-else>
<li v-for="s in aufnahmen" :key="s.start" class="zeile"> <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 <button
type="button" type="button"
class="klein" class="klein"
@@ -254,10 +283,29 @@ async function entfernen(user) {
> >
{{ loeschFrage === s.start ? 'Wirklich löschen?' : 'Löschen' }} {{ loeschFrage === s.start ? 'Wirklich löschen?' : 'Löschen' }}
</button> </button>
</span>
</li> </li>
</ul> </ul>
</section> </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"> <Teleport to="body">
<div v-if="hostFilmDialog" class="hintergrund" @click.self="hostFilmDialog = false"> <div v-if="hostFilmDialog" class="hintergrund" @click.self="hostFilmDialog = false">
<div class="fenster" role="dialog" aria-modal="true"> <div class="fenster" role="dialog" aria-modal="true">
@@ -552,6 +600,17 @@ h3 {
font-size: 0.9rem; font-size: 0.9rem;
} }
.aufnahmen .knoepfe {
display: flex;
flex-shrink: 0;
gap: 0.4rem;
}
.aufnahmen em {
color: var(--muted);
font-style: normal;
}
.klein.warnung { .klein.warnung {
color: #fff; color: #fff;
background: var(--accent); background: var(--accent);