This commit is contained in:
team3
2026-10-08 16:03:27 +02:00
parent 3f12b0742d
commit 93fe2219b9
6 changed files with 94 additions and 25 deletions

View File

@@ -105,7 +105,8 @@ def holen(clip_id, session_id):
def alle(session_id, host=False):
"""Die eigenen Clips; der Host sieht alle — er räumt auf."""
"""Alle Clips, für jeden. Ändern darf nur der Besitzer — und der Host,
der aufräumt; dafür steht an jedem Clip, ob er einem gehört."""
ich = besitzer(session_id)
with connect() as conn:
rows = conn.execute(
@@ -114,11 +115,7 @@ def alle(session_id, host=False):
"LEFT JOIN movies m ON m.id = c.movie_id "
"ORDER BY c.marke_at DESC"
).fetchall()
return [
_oeffentlich(r, session_id, ich)
for r in rows
if host or gehoert_mir(r, session_id, ich)
]
return [_oeffentlich(r, session_id, ich) for r in rows]
def _zeile(conn, clip_id):
@@ -189,10 +186,10 @@ def playback_url(row, vorher=None, dauer=None):
def zeile(clip_id, session_id, host):
"""Die rohe Zeile, wenn man sie sehen darf: Besitzer oder Host."""
"""Die rohe Zeile zum Ansehen — Clips sieht jeder. Ändern und
Löschen prüfen gesondert, ob es der Besitzer oder der Host ist."""
with connect() as conn:
row = _zeile(conn, clip_id)
return row if _darf(row, session_id, host) else None
return _zeile(conn, clip_id)
async def schneiden(clip_id, session_id, host):

View File

@@ -116,11 +116,12 @@ def test_taste_c_nimmt_den_film_der_laufenden_aufnahme(client, zuschauer, hostmo
assert marke(client)["movie_id"] == film(3)
def test_fremde_sehen_nichts_der_host_alles(client, client2, zuschauer, hostmodus):
def test_alle_sehen_clips_aendern_nur_besitzer_und_host(client, client2, zuschauer, hostmodus):
m = marke(client)
assert [c for c in client2.get("/api/clips").json() if c["id"] == m["id"]] == []
fremd = [c for c in client2.get("/api/clips").json() if c["id"] == m["id"]]
assert len(fremd) == 1 and fremd[0]["mine"] is False
assert client2.patch(f"/api/clips/{m['id']}", json={"titel": "x"}).status_code == 404
assert client2.get(f"/api/clips/{m['id']}/video").status_code == 404
assert client2.delete(f"/api/clips/{m['id']}").status_code == 404
hostmodus(zuschauer)
assert zuschauer.patch(f"/api/clips/{m['id']}", json={"titel": "Vom Host"}).json()["titel"] == "Vom Host"
assert zuschauer.get("/api/clips").json()[0]["id"] == m["id"]

View File

@@ -144,7 +144,7 @@ const tabs = computed(() => [
{ id: 'merkliste', label: 'Sammlungen', count: null },
{ id: 'wheel', label: 'Rad', count: null },
{ id: 'stream', label: 'Stream', count: null },
...(angemeldet.value || host.value ? [{ id: 'clips', label: 'Clips', count: null }] : []),
{ id: 'clips', label: 'Clips', count: null },
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
{
id: 'wuensche',

View File

@@ -0,0 +1,58 @@
<script setup>
// Das Vorschaubild eines geschnittenen Clips: der erste Frame des Videos.
// Ein Video-Element im Hintergrund lädt den Anfang, der Frame wandert
// auf ein Canvas und von dort als Bild in die Karte. So gibt es keine
// Player-Steuerung, keine Bild-in-Bild-Hinweise und kein schwarzes Feld.
import { onMounted, onUnmounted, ref } from 'vue'
const props = defineProps({
src: { type: String, required: true },
})
const bild = ref(null)
let video = null
onMounted(() => {
video = document.createElement('video')
video.muted = true
video.playsInline = true
video.preload = 'auto'
video.addEventListener('loadeddata', () => {
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
canvas.getContext('2d').drawImage(video, 0, 0)
try {
bild.value = canvas.toDataURL('image/jpeg', 0.82)
} catch {
bild.value = null
}
// Das Video wird nicht mehr gebraucht
video.removeAttribute('src')
video.load()
})
video.src = props.src
})
onUnmounted(() => {
if (video) {
video.removeAttribute('src')
video.load()
}
})
</script>
<template>
<img v-if="bild" :src="bild" alt="" />
<span v-else class="leer"></span>
</template>
<style scoped>
img,
.leer {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
</style>

View File

@@ -6,6 +6,9 @@ import ClipTrimmer from './ClipTrimmer.vue'
const props = defineProps({
clip: { type: Object, required: true },
// Besitzer und Host dürfen schneiden, ändern und löschen — alle
// anderen schauen nur
darf: Boolean,
})
const emit = defineEmits(['zu', 'geaendert'])
@@ -72,6 +75,7 @@ onUnmounted(() => window.removeEventListener('keydown', escape))
<div class="editor">
<header>
<input
v-if="darf"
v-model="titel"
type="text"
maxlength="80"
@@ -79,12 +83,20 @@ onUnmounted(() => window.removeEventListener('keydown', escape))
class="titel"
@change="patch({ titel })"
/>
<h2 v-else class="titel">{{ clip.titel || 'Ohne Titel' }}</h2>
<button type="button" class="zu" title="Schließen" @click="emit('zu')">×</button>
</header>
<video :src="vorschauUrl" controls playsinline class="vorschau"></video>
<!-- Wer nur schaut, bekommt bei geschnittenen Clips das fertige Video -->
<video
:src="!darf && clip.geschnitten ? `/api/clips/${clip.id}/video` : vorschauUrl"
controls
playsinline
class="vorschau"
></video>
<ClipTrimmer
v-if="darf"
v-model:vorher="vorher"
v-model:dauer="dauer"
:segmente="segmente"
@@ -95,14 +107,14 @@ onUnmounted(() => window.removeEventListener('keydown', escape))
<p v-if="fehler" class="fehler">{{ fehler }}</p>
<div class="knoepfe">
<button type="button" class="haupt" :disabled="schneidet" @click="schneiden">
<button v-if="darf" type="button" class="haupt" :disabled="schneidet" @click="schneiden">
{{ schneidet ? 'Schneidet …' : clip.geschnitten ? 'Speichern' : 'Schneiden' }}
</button>
<a v-if="clip.geschnitten" :href="`/api/clips/${clip.id}/video`" download class="link"
>Herunterladen</a
>
<span class="rest"></span>
<button type="button" class="weg" @click="loeschen">Löschen</button>
<button v-if="darf" type="button" class="weg" @click="loeschen">Löschen</button>
</div>
</div>
</div>

View File

@@ -5,6 +5,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { abonnieren } from '../live'
import { backdropUrl, posterUrl } from '../images'
import ClipEditor from './ClipEditor.vue'
import ClipBild from './ClipBild.vue'
import PersonName from './PersonName.vue'
const props = defineProps({
@@ -149,7 +150,7 @@ onUnmounted(() => abbestellen?.())
<p v-if="laden" class="note">Lade Clips…</p>
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
<p v-else-if="!clips.length" class="note">
Noch keine Marken. Im Stream-Reiter setzt die Taste C eine.
Noch keine Clips. Im Stream-Reiter setzt die Taste C eine Marke.
</p>
<section v-for="g in gruppen" :key="g.key">
@@ -159,13 +160,7 @@ onUnmounted(() => abbestellen?.())
<!-- Geschnitten: das Video selbst als Bild. Sonst das Filmbild
mit dem Hinweis, dass hier erst eine Marke steht. -->
<span class="bild">
<video
v-if="c.geschnitten"
:src="`/api/clips/${c.id}/video#t=0.5`"
preload="metadata"
muted
playsinline
></video>
<ClipBild v-if="c.geschnitten" :src="`/api/clips/${c.id}/video`" />
<img v-else-if="c.poster_path" :src="backdropUrl(c.poster_path, 'w342')" alt="" />
<span v-else class="leer"></span>
<span class="dauer">{{ Math.round(c.dauer) }} s</span>
@@ -183,7 +178,13 @@ onUnmounted(() => abbestellen?.())
</div>
</section>
<ClipEditor v-if="offen" :clip="offen" @zu="offen = null" @geaendert="laden_" />
<ClipEditor
v-if="offen"
:clip="offen"
:darf="offen.mine || host"
@zu="offen = null"
@geaendert="laden_"
/>
</div>
</template>