diff --git a/backend/app/clips.py b/backend/app/clips.py index 76657cc..cadc495 100644 --- a/backend/app/clips.py +++ b/backend/app/clips.py @@ -234,16 +234,22 @@ async def tage_mit_aufnahme(): return sorted(tage) -async def aufnahme_des_abends(watched_at): - """Die Aufnahme zu einem Abend: alles vom Mittag des Tages bis zum +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. - Liefert den Beginn des ersten Stücks, oder None ohne Aufnahme.""" + 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)) - if not segmente: - return None - return min(datetime.fromisoformat(s["start"].replace("Z", "+00:00")) for s in segmente) + 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"]) async def aufnahme(um, spanne=SPANNE): diff --git a/backend/app/main.py b/backend/app/main.py index 50ea39f..239b9b6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,5 +1,5 @@ import asyncio -from datetime import timedelta +from datetime import datetime, timedelta import logging import secrets import time @@ -332,25 +332,47 @@ async def clip_aufnahmen(): return {"tage": []} -@app.post("/api/clips/von-film") -async def clip_von_film( - response: Response, - movie_id: int = Body(..., embed=True), - filmabend_sid: str | None = Cookie(None), +@app.get("/api/clips/von-film/{movie_id}") +async def clips_stuecke( + movie_id: int, response: Response, filmabend_sid: str | None = Cookie(None) ): - """Ein Clip zu einem alten Abend: die Marke kommt an den Anfang der - Aufnahme, das Fenster schiebt man dann im Editor hin.""" + """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: - beginn = await clips.aufnahme_des_abends(abend["watched_at"]) + return {"stuecke": await clips.aufnahmen_des_abends(abend["watched_at"])} except httpx.HTTPError: - beginn = None - if beginn is None: - raise HTTPException(status_code=404, detail="Zu diesem Abend gibt es keine Aufnahme.") + return {"stuecke": []} + + +@app.post("/api/clips/von-film") +async def clip_von_film( + response: Response, + movie_id: int = Body(..., embed=True), + start: str | None = Body(None, 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.""" + 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)) events.senden("clips") return clip diff --git a/backend/tests/test_clips.py b/backend/tests/test_clips.py index 7907fb2..7fb9c17 100644 --- a/backend/tests/test_clips.py +++ b/backend/tests/test_clips.py @@ -60,23 +60,52 @@ def test_clip_zu_altem_abend(client, film, monkeypatch): # Keine Aufnahme an dem Tag async def keine(watched_at): - return None + return [] - monkeypatch.setattr(clips, "aufnahme_des_abends", keine) + 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"] - # Mit Aufnahme: die Marke liegt kurz nach ihrem Beginn - beginn = datetime(2026, 9, 23, 19, 0, tzinfo=timezone.utc) + # 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}, + ] async def da(watched_at): - return beginn + return stuecke - monkeypatch.setattr(clips, "aufnahme_des_abends", da) + 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) 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" + + +def test_stuecke_eines_abends_nach_beginn_geordnet(monkeypatch): + import asyncio + + async def segmente(von, bis): + return [ + {"start": "2026-09-23T21:00:00Z", "duration": 600.0}, + {"start": "2026-09-23T19:00:00Z", "duration": 1800.04}, + ] + + 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}, + ] + def test_fremde_sehen_nichts_der_host_alles(client, client2, zuschauer, hostmodus): m = marke(client) diff --git a/frontend/src/components/ClipsTab.vue b/frontend/src/components/ClipsTab.vue index f6f4b52..7a47cb3 100644 --- a/frontend/src/components/ClipsTab.vue +++ b/frontend/src/components/ClipsTab.vue @@ -15,15 +15,21 @@ const props = defineProps({ }) // "Clip erstellen": erst den Abend wählen, dann öffnet der Editor. -// Zur Wahl stehen nur Abende, zu denen es eine Aufnahme gibt. +// 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. const wahl = ref(false) const wahlFehler = ref('') const tage = ref(null) +const gewaehlt = ref(null) +const stuecke = ref(null) async function wahlOeffnen() { wahl.value = true wahlFehler.value = '' tage.value = null + gewaehlt.value = null + stuecke.value = null try { const resp = await fetch('/api/clips/aufnahmen') tage.value = resp.ok ? (await resp.json()).tage : [] @@ -36,12 +42,32 @@ const abendeMitAufnahme = computed(() => tage.value ? props.abende.filter((a) => tage.value.includes(a.watched_at.slice(0, 10))) : [], ) -async function erstellen(abend) { +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) { wahlFehler.value = '' const resp = await fetch('/api/clips/von-film', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ movie_id: abend.movie_id }), + body: JSON.stringify({ movie_id: abend.movie_id, start }), }) if (!resp.ok) { const daten = await resp.json().catch(() => ({})) @@ -119,17 +145,37 @@ onUnmounted(() => abbestellen?.())