update
This commit is contained in:
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user