update
This commit is contained in:
@@ -258,6 +258,10 @@ in `docker-compose.yml` als Volume). Gelöscht wird nichts von selbst —
|
|||||||
wer Platz braucht, räumt den Ordner auf oder schiebt ihn per rclone auf
|
wer Platz braucht, räumt den Ordner auf oder schiebt ihn per rclone auf
|
||||||
eine Storage Box. Rund 5 GB je Abend bei 1080p.
|
eine Storage Box. Rund 5 GB je Abend bei 1080p.
|
||||||
|
|
||||||
|
Löschen kann der Host in der Verwaltung, Stück für Stück: die App
|
||||||
|
schickt das an die MediaMTX-API (Port 9997, nur intern, angemeldet als
|
||||||
|
`sender` mit dem Sendepasswort), die die Segmente wegräumt.
|
||||||
|
|
||||||
Daraus kommen die Clips. Im Stream-Reiter setzt die Taste C (oder der
|
Daraus kommen die Clips. Im Stream-Reiter setzt die Taste C (oder der
|
||||||
Knopf „Clip") eine Marke: nur der Zeitpunkt, vier Sekunden vor jetzt,
|
Knopf „Clip") eine Marke: nur der Zeitpunkt, vier Sekunden vor jetzt,
|
||||||
weil der Player so weit hinter der Live-Kante hängt. Der Film ist der
|
weil der Player so weit hinter der Live-Kante hängt. Der Film ist der
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from .config import CLIPS_DIR, MEDIAMTX_PLAYBACK_URL
|
from .config import CLIPS_DIR, MEDIAMTX_API_URL, MEDIAMTX_PLAYBACK_URL, MTX_SENDEPASSWORT
|
||||||
from .db import connect
|
from .db import connect
|
||||||
from .users import WEM, besitzer, gehoert_mir
|
from .users import WEM, besitzer, gehoert_mir
|
||||||
|
|
||||||
@@ -252,6 +252,50 @@ async def aufnahmen_des_abends(watched_at):
|
|||||||
return sorted(stuecke, key=lambda s: s["start"])
|
return sorted(stuecke, key=lambda s: s["start"])
|
||||||
|
|
||||||
|
|
||||||
|
def _stueck(s):
|
||||||
|
return {
|
||||||
|
"start": _iso(datetime.fromisoformat(s["start"].replace("Z", "+00:00"))),
|
||||||
|
"dauer": round(s["duration"], 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def aufnahmen_alle():
|
||||||
|
"""Alle Aufnahmen, neueste zuerst — der Host räumt damit auf."""
|
||||||
|
segmente = await _segmente(datetime(2000, 1, 1, tzinfo=timezone.utc), _jetzt())
|
||||||
|
return sorted((_stueck(s) for s in segmente), key=lambda s: s["start"], reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _im_stueck(segmente, start, dauer):
|
||||||
|
"""Die Segmente (aus der API), die zu einem Stück der Aufnahme gehören:
|
||||||
|
ihr Beginn liegt zwischen dessen Start und Ende. Eine Sekunde Luft an
|
||||||
|
beiden Enden — Playback rundet die Zeiten anders als die API."""
|
||||||
|
von = datetime.fromisoformat(start) - timedelta(seconds=1)
|
||||||
|
bis = datetime.fromisoformat(start) + timedelta(seconds=dauer + 1)
|
||||||
|
aus = []
|
||||||
|
for s in segmente:
|
||||||
|
beginn = datetime.fromisoformat(s["start"].replace("Z", "+00:00"))
|
||||||
|
if von <= beginn <= bis:
|
||||||
|
aus.append(s["start"])
|
||||||
|
return aus
|
||||||
|
|
||||||
|
|
||||||
|
async def aufnahme_loeschen(start, dauer):
|
||||||
|
"""Ein Stück der Aufnahme löschen — MediaMTX räumt die Segmente weg.
|
||||||
|
Gibt zurück, wie viele es waren."""
|
||||||
|
auth = ("sender", MTX_SENDEPASSWORT)
|
||||||
|
async with httpx.AsyncClient(timeout=30.0, auth=auth) as http:
|
||||||
|
resp = await http.get(f"{MEDIAMTX_API_URL}/v3/recordings/get/{STREAM_PFAD}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
starts = _im_stueck(resp.json().get("segments") or [], start, dauer)
|
||||||
|
for s in starts:
|
||||||
|
resp = await http.delete(
|
||||||
|
f"{MEDIAMTX_API_URL}/v3/recordings/deletesegment",
|
||||||
|
params={"path": STREAM_PFAD, "start": s},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return len(starts)
|
||||||
|
|
||||||
|
|
||||||
async def aufnahme(um, spanne=SPANNE):
|
async def aufnahme(um, spanne=SPANNE):
|
||||||
"""Welche Stücke der Aufnahme es um einen Zeitpunkt herum gibt — für
|
"""Welche Stücke der Aufnahme es um einen Zeitpunkt herum gibt — für
|
||||||
den Zeitstrahl im Editor. Relativ zur Marke, in Sekunden."""
|
den Zeitstrahl im Editor. Relativ zur Marke, in Sekunden."""
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ PROVIDER_NETFLIX = 8
|
|||||||
MEDIAMTX_URL = os.getenv("MEDIAMTX_URL", "http://127.0.0.1:8890")
|
MEDIAMTX_URL = os.getenv("MEDIAMTX_URL", "http://127.0.0.1:8890")
|
||||||
# Der Playback-Server von MediaMTX: liefert Ausschnitte der Aufnahme
|
# Der Playback-Server von MediaMTX: liefert Ausschnitte der Aufnahme
|
||||||
MEDIAMTX_PLAYBACK_URL = os.getenv("MEDIAMTX_PLAYBACK_URL", "http://127.0.0.1:9996")
|
MEDIAMTX_PLAYBACK_URL = os.getenv("MEDIAMTX_PLAYBACK_URL", "http://127.0.0.1:9996")
|
||||||
|
# Die Steuer-API von MediaMTX, zum Löschen von Aufnahmen. Angemeldet
|
||||||
|
# wird sich als der Sender-Nutzer mit dem Sendepasswort aus der .env.
|
||||||
|
MEDIAMTX_API_URL = os.getenv("MEDIAMTX_API_URL", "http://127.0.0.1:9997")
|
||||||
|
MTX_SENDEPASSWORT = os.getenv("MTX_SENDEPASSWORT", "")
|
||||||
# Wohin die geschnittenen Clips kommen
|
# Wohin die geschnittenen Clips kommen
|
||||||
CLIPS_DIR = Path(os.getenv("CLIPS_DIR", ROOT / "clips"))
|
CLIPS_DIR = Path(os.getenv("CLIPS_DIR", ROOT / "clips"))
|
||||||
REGION = "DE"
|
REGION = "DE"
|
||||||
|
|||||||
@@ -332,6 +332,33 @@ async def clip_aufnahmen():
|
|||||||
return {"tage": []}
|
return {"tage": []}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/aufnahmen")
|
||||||
|
async def aufnahmen_liste(response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||||
|
"""Alle Stücke der Aufnahme, neueste zuerst — für die Verwaltung."""
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
if not users.ist_host(sid):
|
||||||
|
raise HTTPException(status_code=403, detail="Nur für den Host")
|
||||||
|
try:
|
||||||
|
return {"stuecke": await clips.aufnahmen_alle()}
|
||||||
|
except httpx.HTTPError:
|
||||||
|
raise HTTPException(status_code=502, detail="Die Aufnahmen sind gerade nicht erreichbar.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/aufnahmen")
|
||||||
|
async def aufnahme_loeschen(
|
||||||
|
response: Response, start: str, dauer: float, filmabend_sid: str | None = Cookie(None)
|
||||||
|
):
|
||||||
|
"""Ein Stück der Aufnahme löschen — nur der Host."""
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
if not users.ist_host(sid):
|
||||||
|
raise HTTPException(status_code=403, detail="Nur für den Host")
|
||||||
|
try:
|
||||||
|
anzahl = await clips.aufnahme_loeschen(start, dauer)
|
||||||
|
except httpx.HTTPError:
|
||||||
|
raise HTTPException(status_code=502, detail="Löschen hat nicht geklappt.")
|
||||||
|
return {"segmente": anzahl}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/clips/von-film/{movie_id}")
|
@app.get("/api/clips/von-film/{movie_id}")
|
||||||
async def clips_stuecke(
|
async def clips_stuecke(
|
||||||
movie_id: int, response: Response, filmabend_sid: str | None = Cookie(None)
|
movie_id: int, response: Response, filmabend_sid: str | None = Cookie(None)
|
||||||
|
|||||||
@@ -204,3 +204,42 @@ def test_loeschen_nimmt_die_datei_mit(client):
|
|||||||
|
|
||||||
def test_zuschauer_darf_keine_marke_setzen(zuschauer):
|
def test_zuschauer_darf_keine_marke_setzen(zuschauer):
|
||||||
assert zuschauer.post("/api/clips").status_code == 403
|
assert zuschauer.post("/api/clips").status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_segmente_eines_stuecks():
|
||||||
|
segmente = [
|
||||||
|
{"start": "2026-09-23T19:00:00.5Z"},
|
||||||
|
{"start": "2026-09-23T19:00:30.5Z"},
|
||||||
|
{"start": "2026-09-23T19:01:00.5Z"},
|
||||||
|
{"start": "2026-09-23T21:00:00Z"},
|
||||||
|
]
|
||||||
|
# Das Stück von 19:00 mit 61 s Dauer: drei Segmente, das von 21:00 nicht
|
||||||
|
assert clips._im_stueck(segmente, "2026-09-23T19:00:00+00:00", 61.0) == [
|
||||||
|
"2026-09-23T19:00:00.5Z",
|
||||||
|
"2026-09-23T19:00:30.5Z",
|
||||||
|
"2026-09-23T19:01:00.5Z",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_aufnahmen_verwaltet_nur_der_host(client, zuschauer, hostmodus, monkeypatch):
|
||||||
|
stuecke = [{"start": "2026-09-23T19:00:00+00:00", "dauer": 60.0}]
|
||||||
|
|
||||||
|
async def alle():
|
||||||
|
return stuecke
|
||||||
|
|
||||||
|
geloescht = []
|
||||||
|
|
||||||
|
async def loeschen(start, dauer):
|
||||||
|
geloescht.append((start, dauer))
|
||||||
|
return 2
|
||||||
|
|
||||||
|
monkeypatch.setattr(clips, "aufnahmen_alle", alle)
|
||||||
|
monkeypatch.setattr(clips, "aufnahme_loeschen", loeschen)
|
||||||
|
assert client.get("/api/aufnahmen").status_code == 403
|
||||||
|
assert client.delete("/api/aufnahmen", params={"start": stuecke[0]["start"], "dauer": 60}).status_code == 403
|
||||||
|
|
||||||
|
hostmodus(zuschauer)
|
||||||
|
assert zuschauer.get("/api/aufnahmen").json()["stuecke"] == stuecke
|
||||||
|
antwort = zuschauer.delete("/api/aufnahmen", params={"start": stuecke[0]["start"], "dauer": 60})
|
||||||
|
assert antwort.json() == {"segmente": 2}
|
||||||
|
assert geloescht == [(stuecke[0]["start"], 60.0)]
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ services:
|
|||||||
DB_PATH: /data/horror.db
|
DB_PATH: /data/horror.db
|
||||||
MEDIAMTX_URL: http://mediamtx:8890
|
MEDIAMTX_URL: http://mediamtx:8890
|
||||||
MEDIAMTX_PLAYBACK_URL: http://mediamtx:9996
|
MEDIAMTX_PLAYBACK_URL: http://mediamtx:9996
|
||||||
|
MEDIAMTX_API_URL: http://mediamtx:9997
|
||||||
depends_on:
|
depends_on:
|
||||||
- mediamtx
|
- mediamtx
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import FilmSuche from './FilmSuche.vue'
|
import FilmSuche from './FilmSuche.vue'
|
||||||
import { posterUrl } from '../images'
|
import { posterUrl } from '../images'
|
||||||
import PersonName from './PersonName.vue'
|
import PersonName from './PersonName.vue'
|
||||||
@@ -71,6 +71,47 @@ async function hostFilmSetzen(movieId) {
|
|||||||
hostFilmDialog.value = false
|
hostFilmDialog.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Die Aufnahmen der Abende — der Host löscht, was weg kann. Erst ein
|
||||||
|
// zweiter Klick auf denselben Knopf löscht wirklich.
|
||||||
|
const aufnahmen = ref(null)
|
||||||
|
const aufnahmenFehler = ref('')
|
||||||
|
const loeschFrage = ref(null)
|
||||||
|
|
||||||
|
async function aufnahmenLaden() {
|
||||||
|
if (!props.host) return
|
||||||
|
aufnahmenFehler.value = ''
|
||||||
|
loeschFrage.value = null
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/aufnahmen')
|
||||||
|
if (!resp.ok) throw new Error()
|
||||||
|
aufnahmen.value = (await resp.json()).stuecke
|
||||||
|
} catch {
|
||||||
|
aufnahmen.value = []
|
||||||
|
aufnahmenFehler.value = 'Die Aufnahmen sind gerade nicht erreichbar.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function aufnahmeLoeschen(s) {
|
||||||
|
if (loeschFrage.value !== s.start) {
|
||||||
|
loeschFrage.value = s.start
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams({ start: s.start, dauer: s.dauer })
|
||||||
|
const resp = await fetch(`/api/aufnahmen?${params}`, { method: 'DELETE' })
|
||||||
|
if (!resp.ok) aufnahmenFehler.value = 'Löschen hat nicht geklappt.'
|
||||||
|
await aufnahmenLaden()
|
||||||
|
}
|
||||||
|
|
||||||
|
function aufnahmeText(s) {
|
||||||
|
const d = new Date(s.start)
|
||||||
|
const datum = d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
const zeit = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
return `${datum} · ${zeit} Uhr · ${Math.max(1, Math.round(s.dauer / 60))} Min`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(aufnahmenLaden)
|
||||||
|
watch(() => props.host, aufnahmenLaden)
|
||||||
|
|
||||||
async function anlegen() {
|
async function anlegen() {
|
||||||
fehler.value = ''
|
fehler.value = ''
|
||||||
const resp = await fetch('/api/users', {
|
const resp = await fetch('/api/users', {
|
||||||
@@ -193,6 +234,26 @@ async function entfernen(user) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="host" class="aufnahmen">
|
||||||
|
<h3>Aufnahmen</h3>
|
||||||
|
<p v-if="aufnahmenFehler" class="fehler">{{ aufnahmenFehler }}</p>
|
||||||
|
<p v-if="aufnahmen === null" class="leer">Suche Aufnahmen …</p>
|
||||||
|
<p v-else-if="!aufnahmen.length" class="leer">Keine Aufnahmen.</p>
|
||||||
|
<ul v-else>
|
||||||
|
<li v-for="s in aufnahmen" :key="s.start" class="zeile">
|
||||||
|
<span>{{ aufnahmeText(s) }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="klein"
|
||||||
|
:class="{ warnung: loeschFrage === s.start }"
|
||||||
|
@click="aufnahmeLoeschen(s)"
|
||||||
|
>
|
||||||
|
{{ loeschFrage === s.start ? 'Wirklich löschen?' : 'Löschen' }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="hostFilmDialog" class="hintergrund" @click.self="hostFilmDialog = false">
|
<div v-if="hostFilmDialog" class="hintergrund" @click.self="hostFilmDialog = false">
|
||||||
<div class="fenster" role="dialog" aria-modal="true">
|
<div class="fenster" role="dialog" aria-modal="true">
|
||||||
@@ -472,6 +533,27 @@ h3 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.aufnahmen ul {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aufnahmen .zeile {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.8rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.klein.warnung {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.hostfilm .zeile {
|
.hostfilm .zeile {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ rtmpAddress: :1935
|
|||||||
playback: yes
|
playback: yes
|
||||||
playbackAddress: :9996
|
playbackAddress: :9996
|
||||||
|
|
||||||
|
# Über die API löscht der Host alte Aufnahmen — Segment für Segment.
|
||||||
|
# Auch nur intern erreichbar; Zugang hat nur der Sender-Nutzer unten.
|
||||||
|
api: yes
|
||||||
|
apiAddress: :9997
|
||||||
|
|
||||||
# Nicht gebraucht, also zu
|
# Nicht gebraucht, also zu
|
||||||
webrtc: no
|
webrtc: no
|
||||||
rtsp: no
|
rtsp: no
|
||||||
@@ -39,6 +44,7 @@ authInternalUsers:
|
|||||||
ips: []
|
ips: []
|
||||||
permissions:
|
permissions:
|
||||||
- action: publish
|
- action: publish
|
||||||
|
- action: api
|
||||||
- user: any
|
- user: any
|
||||||
pass:
|
pass:
|
||||||
ips: []
|
ips: []
|
||||||
|
|||||||
Reference in New Issue
Block a user