This commit is contained in:
team3
2026-09-30 01:16:47 +02:00
parent c589e389df
commit 70b37054ba
4 changed files with 146 additions and 31 deletions

View File

@@ -234,16 +234,22 @@ async def tage_mit_aufnahme():
return sorted(tage) return sorted(tage)
async def aufnahme_des_abends(watched_at): async def aufnahmen_des_abends(watched_at):
"""Die Aufnahme zu einem Abend: alles vom Mittag des Tages bis zum """Die Aufnahmen zu einem Abend: alles vom Mittag des Tages bis zum
nächsten Mittag — so gehört auch die Nacht nach Mitternacht dazu. 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) tag = datetime.fromisoformat(watched_at[:10]).replace(tzinfo=timezone.utc)
mittag = tag + timedelta(hours=12) mittag = tag + timedelta(hours=12)
segmente = await _segmente(mittag, mittag + timedelta(days=1)) segmente = await _segmente(mittag, mittag + timedelta(days=1))
if not segmente: stuecke = [
return None {
return min(datetime.fromisoformat(s["start"].replace("Z", "+00:00")) for s in segmente) "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): async def aufnahme(um, spanne=SPANNE):

View File

@@ -1,5 +1,5 @@
import asyncio import asyncio
from datetime import timedelta from datetime import datetime, timedelta
import logging import logging
import secrets import secrets
import time import time
@@ -332,25 +332,47 @@ async def clip_aufnahmen():
return {"tage": []} return {"tage": []}
@app.post("/api/clips/von-film") @app.get("/api/clips/von-film/{movie_id}")
async def clip_von_film( async def clips_stuecke(
response: Response, movie_id: int, response: Response, filmabend_sid: str | None = Cookie(None)
movie_id: int = Body(..., embed=True),
filmabend_sid: str | None = Cookie(None),
): ):
"""Ein Clip zu einem alten Abend: die Marke kommt an den Anfang der """Die Aufnahmen zu einem Abend — mehrere, wenn der Stream neu
Aufnahme, das Fenster schiebt man dann im Editor hin.""" gestartet wurde. Daraus wählt man das Stück für den Clip."""
sid = sitzung(response, filmabend_sid) sid = sitzung(response, filmabend_sid)
mitglied(sid) mitglied(sid)
abend = watched.von_film(movie_id) abend = watched.von_film(movie_id)
if not abend: if not abend:
raise HTTPException(status_code=404, detail="Dieser Film steht nicht unter Gesehen.") raise HTTPException(status_code=404, detail="Dieser Film steht nicht unter Gesehen.")
try: try:
beginn = await clips.aufnahme_des_abends(abend["watched_at"]) return {"stuecke": await clips.aufnahmen_des_abends(abend["watched_at"])}
except httpx.HTTPError: except httpx.HTTPError:
beginn = None return {"stuecke": []}
if beginn is None:
raise HTTPException(status_code=404, detail="Zu diesem Abend gibt es keine Aufnahme.")
@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)) clip = clips.markieren(sid, movie_id, beginn + timedelta(seconds=clips.VORHER + 30))
events.senden("clips") events.senden("clips")
return clip return clip

View File

@@ -60,23 +60,52 @@ def test_clip_zu_altem_abend(client, film, monkeypatch):
# Keine Aufnahme an dem Tag # Keine Aufnahme an dem Tag
async def keine(watched_at): 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)}) antwort = client.post("/api/clips/von-film", json={"movie_id": film(4)})
assert antwort.status_code == 404 and "Aufnahme" in antwort.json()["detail"] assert antwort.status_code == 404 and "Aufnahme" in antwort.json()["detail"]
# Mit Aufnahme: die Marke liegt kurz nach ihrem Beginn # Zwei Stücke, weil der Stream neu gestartet wurde
beginn = datetime(2026, 9, 23, 19, 0, tzinfo=timezone.utc) 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): 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() m = client.post("/api/clips/von-film", json={"movie_id": film(4)}).json()
assert m["movie_id"] == film(4) assert m["movie_id"] == film(4)
assert m["marke_at"] == "2026-09-23T19:01:00+00:00" 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): def test_fremde_sehen_nichts_der_host_alles(client, client2, zuschauer, hostmodus):
m = marke(client) m = marke(client)

View File

@@ -15,15 +15,21 @@ const props = defineProps({
}) })
// "Clip erstellen": erst den Abend wählen, dann öffnet der Editor. // "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 wahl = ref(false)
const wahlFehler = ref('') const wahlFehler = ref('')
const tage = ref(null) const tage = 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 tage.value = null
gewaehlt.value = null
stuecke.value = null
try { try {
const resp = await fetch('/api/clips/aufnahmen') const resp = await fetch('/api/clips/aufnahmen')
tage.value = resp.ok ? (await resp.json()).tage : [] 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))) : [], 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 = '' wahlFehler.value = ''
const resp = await fetch('/api/clips/von-film', { const resp = await fetch('/api/clips/von-film', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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) { if (!resp.ok) {
const daten = await resp.json().catch(() => ({})) const daten = await resp.json().catch(() => ({}))
@@ -119,17 +145,37 @@ onUnmounted(() => abbestellen?.())
<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>Zu welchem Abend?</span> <span>{{ stuecke ? 'Welche Aufnahme?' : 'Zu welchem Abend?' }}</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="tage === null" class="abendwahl-hinweis">Suche Aufnahmen …</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"> <p v-else-if="!abendeMitAufnahme.length" class="abendwahl-hinweis">
Zu keinem Abend gibt es eine Aufnahme. Zu keinem Abend gibt es eine Aufnahme.
</p> </p>
<ul> <ul v-else>
<li v-for="a in abendeMitAufnahme" :key="a.id"> <li v-for="a in abendeMitAufnahme" :key="a.id">
<button type="button" @click="erstellen(a)"> <button type="button" @click="abendWaehlen(a)">
<img v-if="a.poster_path" :src="posterUrl(a.poster_path, 'w154')" alt="" /> <img v-if="a.poster_path" :src="posterUrl(a.poster_path, 'w154')" alt="" />
<span class="abend-text"> <span class="abend-text">
<span class="abend-titel">{{ a.title }}</span> <span class="abend-titel">{{ a.title }}</span>
@@ -358,6 +404,18 @@ 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;