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): 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) ich = besitzer(session_id)
with connect() as conn: with connect() as conn:
rows = conn.execute( rows = conn.execute(
@@ -114,11 +115,7 @@ def alle(session_id, host=False):
"LEFT JOIN movies m ON m.id = c.movie_id " "LEFT JOIN movies m ON m.id = c.movie_id "
"ORDER BY c.marke_at DESC" "ORDER BY c.marke_at DESC"
).fetchall() ).fetchall()
return [ return [_oeffentlich(r, session_id, ich) for r in rows]
_oeffentlich(r, session_id, ich)
for r in rows
if host or gehoert_mir(r, session_id, ich)
]
def _zeile(conn, clip_id): def _zeile(conn, clip_id):
@@ -189,10 +186,10 @@ def playback_url(row, vorher=None, dauer=None):
def zeile(clip_id, session_id, host): 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: with connect() as conn:
row = _zeile(conn, clip_id) return _zeile(conn, clip_id)
return row if _darf(row, session_id, host) else None
async def schneiden(clip_id, session_id, host): 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) 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) 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.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) hostmodus(zuschauer)
assert zuschauer.patch(f"/api/clips/{m['id']}", json={"titel": "Vom Host"}).json()["titel"] == "Vom Host" 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"] 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: 'merkliste', label: 'Sammlungen', count: null },
{ id: 'wheel', label: 'Rad', count: null }, { id: 'wheel', label: 'Rad', count: null },
{ id: 'stream', label: 'Stream', 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: 'watched', label: 'Gesehen', count: gesehenZahl.value },
{ {
id: 'wuensche', 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({ const props = defineProps({
clip: { type: Object, required: true }, 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']) const emit = defineEmits(['zu', 'geaendert'])
@@ -72,6 +75,7 @@ onUnmounted(() => window.removeEventListener('keydown', escape))
<div class="editor"> <div class="editor">
<header> <header>
<input <input
v-if="darf"
v-model="titel" v-model="titel"
type="text" type="text"
maxlength="80" maxlength="80"
@@ -79,12 +83,20 @@ onUnmounted(() => window.removeEventListener('keydown', escape))
class="titel" class="titel"
@change="patch({ 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> <button type="button" class="zu" title="Schließen" @click="emit('zu')">×</button>
</header> </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 <ClipTrimmer
v-if="darf"
v-model:vorher="vorher" v-model:vorher="vorher"
v-model:dauer="dauer" v-model:dauer="dauer"
:segmente="segmente" :segmente="segmente"
@@ -95,14 +107,14 @@ onUnmounted(() => window.removeEventListener('keydown', escape))
<p v-if="fehler" class="fehler">{{ fehler }}</p> <p v-if="fehler" class="fehler">{{ fehler }}</p>
<div class="knoepfe"> <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' }} {{ schneidet ? 'Schneidet …' : clip.geschnitten ? 'Speichern' : 'Schneiden' }}
</button> </button>
<a v-if="clip.geschnitten" :href="`/api/clips/${clip.id}/video`" download class="link" <a v-if="clip.geschnitten" :href="`/api/clips/${clip.id}/video`" download class="link"
>Herunterladen</a >Herunterladen</a
> >
<span class="rest"></span> <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> </div>
</div> </div>

View File

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