update
This commit is contained in:
19
README.md
19
README.md
@@ -262,15 +262,16 @@ 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
|
schickt das an die MediaMTX-API (Port 9997, nur intern, angemeldet als
|
||||||
`sender` mit dem Sendepasswort), die die Segmente wegräumt.
|
`sender` mit dem Sendepasswort), die die Segmente wegräumt.
|
||||||
|
|
||||||
Daraus kommen die Clips. Im Stream-Reiter setzt die Taste C (oder der
|
Jede Aufnahme hat ihren Film: der Host weist ihn in der Verwaltung zu,
|
||||||
Knopf „Clip") eine Marke: nur der Zeitpunkt, vier Sekunden vor jetzt,
|
sonst gilt der Abend mit dem gleichen Datum, sonst läuft sie unter
|
||||||
weil der Player so weit hinter der Live-Kante hängt. Der Film ist der
|
„Sonstige". Daraus kommen die Clips. Im Stream-Reiter setzt die Taste C
|
||||||
zuletzt gezogene. Im Reiter „Clips" zieht man später das Fenster um die
|
(oder der Knopf „Clip") eine Marke: nur der Zeitpunkt, vier Sekunden vor
|
||||||
Marke, sieht es sich an, schneidet und gibt frei. Geschnittene Clips
|
jetzt, weil der Player so weit hinter der Live-Kante hängt. Der Film ist
|
||||||
liegen als MP4 unter `clips/`, freigegebene erscheinen im Buch auf der
|
der der laufenden Aufnahme, sonst der zuletzt gezogene. „Clip erstellen"
|
||||||
Seite des Abends. Bis zur Freigabe sieht sie nur, wer sie gesetzt hat —
|
im Reiter „Clips" setzt eine Marke an den Anfang einer beliebigen
|
||||||
und der Host, der auch aufräumen darf. Ein freigegebener Clip bringt
|
Aufnahme. Dort zieht man das Fenster um die Marke, sieht es sich an und
|
||||||
3 Punkte, einmal je Film.
|
schneidet; die MP4 liegt dann unter `clips/`. Jeder sieht nur seine
|
||||||
|
Clips, der Host alle — er räumt auch auf.
|
||||||
|
|
||||||
Die Ausschnitte liefert der Playback-Server von MediaMTX (Port 9996,
|
Die Ausschnitte liefert der Playback-Server von MediaMTX (Port 9996,
|
||||||
nur intern): `/get?path=filmabend&start=<RFC3339>&duration=<s>&format=mp4`.
|
nur intern): `/get?path=filmabend&start=<RFC3339>&duration=<s>&format=mp4`.
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ CREATE TABLE IF NOT EXISTS clips (
|
|||||||
datei TEXT,
|
datei TEXT,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Der Film einer Aufnahme, vom Host gesetzt. Ohne Eintrag gilt der Abend
|
||||||
|
-- mit dem gleichen Datum — oder keiner.
|
||||||
|
CREATE TABLE IF NOT EXISTS aufnahme_film (
|
||||||
|
start TEXT PRIMARY KEY,
|
||||||
|
movie_id INTEGER NOT NULL
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
STREAM_PFAD = "filmabend"
|
STREAM_PFAD = "filmabend"
|
||||||
@@ -223,33 +230,9 @@ async def _segmente(von, bis):
|
|||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
async def tage_mit_aufnahme():
|
# ── Aufnahmen: die Stücke aus MediaMTX, jedes mit seinem Film ──────────
|
||||||
"""An welchen Abenden es Aufnahmen gibt — als Datum, wobei der Abend
|
# Eine Aufnahme hängt an keinem Abend. Der Host weist ihr einen Film zu;
|
||||||
bis zum nächsten Mittag reicht. Für die Auswahl im Clips-Reiter."""
|
# ohne Zuweisung gilt der Abend mit dem gleichen Datum, sonst keiner.
|
||||||
segmente = await _segmente(datetime(2000, 1, 1, tzinfo=timezone.utc), _jetzt())
|
|
||||||
tage = set()
|
|
||||||
for s in segmente:
|
|
||||||
start = datetime.fromisoformat(s["start"].replace("Z", "+00:00"))
|
|
||||||
tage.add((start - timedelta(hours=12)).strftime("%Y-%m-%d"))
|
|
||||||
return sorted(tage)
|
|
||||||
|
|
||||||
|
|
||||||
async def aufnahmen_des_abends(watched_at):
|
|
||||||
"""Die Aufnahmen zu einem Abend: alles vom Mittag des Tages bis zum
|
|
||||||
nächsten Mittag — so gehört auch die Nacht nach Mitternacht dazu.
|
|
||||||
Wurde der Stream zwischendurch beendet, sind es mehrere Stücke,
|
|
||||||
nach Beginn geordnet."""
|
|
||||||
tag = datetime.fromisoformat(watched_at[:10]).replace(tzinfo=timezone.utc)
|
|
||||||
mittag = tag + timedelta(hours=12)
|
|
||||||
segmente = await _segmente(mittag, mittag + timedelta(days=1))
|
|
||||||
stuecke = [
|
|
||||||
{
|
|
||||||
"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"])
|
|
||||||
|
|
||||||
|
|
||||||
def _stueck(s):
|
def _stueck(s):
|
||||||
@@ -259,10 +242,83 @@ def _stueck(s):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _abendtag(iso):
|
||||||
|
"""Der Abend reicht bis zum nächsten Mittag — die Nacht gehört dazu."""
|
||||||
|
zeit = datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
||||||
|
return (zeit - timedelta(hours=12)).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def film_zuweisen(start, movie_id):
|
||||||
|
"""Der Host hängt einen Film an eine Aufnahme — oder nimmt ihn weg."""
|
||||||
|
with connect() as conn:
|
||||||
|
if movie_id is None:
|
||||||
|
conn.execute("DELETE FROM aufnahme_film WHERE start = ?", (start,))
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO aufnahme_film (start, movie_id) VALUES (?, ?)",
|
||||||
|
(start, movie_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _filme(conn):
|
||||||
|
"""Zuweisungen je Start und Abende je Tag — um Aufnahmen ihren Film zu geben."""
|
||||||
|
zugewiesen = {
|
||||||
|
r["start"]: {"id": r["movie_id"], "title": r["title"], "poster_path": r["poster_path"]}
|
||||||
|
for r in conn.execute(
|
||||||
|
"SELECT a.start, a.movie_id, m.title, m.poster_path FROM aufnahme_film a "
|
||||||
|
"LEFT JOIN movies m ON m.id = a.movie_id"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
abende = {}
|
||||||
|
for w in conn.execute(
|
||||||
|
"SELECT movie_id, title, poster_path, watched_at FROM watched ORDER BY id"
|
||||||
|
):
|
||||||
|
abende.setdefault(
|
||||||
|
_abendtag(w["watched_at"]),
|
||||||
|
{"id": w["movie_id"], "title": w["title"], "poster_path": w["poster_path"]},
|
||||||
|
)
|
||||||
|
return zugewiesen, abende
|
||||||
|
|
||||||
|
|
||||||
|
def _mit_film(stueck, zugewiesen, abende):
|
||||||
|
film = zugewiesen.get(stueck["start"])
|
||||||
|
return {
|
||||||
|
**stueck,
|
||||||
|
"film": film or abende.get(_abendtag(stueck["start"])),
|
||||||
|
"zugewiesen": film is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def aufnahmen_alle():
|
async def aufnahmen_alle():
|
||||||
"""Alle Aufnahmen, neueste zuerst — der Host räumt damit auf."""
|
"""Alle Aufnahmen, neueste zuerst, jede mit ihrem Film."""
|
||||||
segmente = await _segmente(datetime(2000, 1, 1, tzinfo=timezone.utc), _jetzt())
|
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)
|
with connect() as conn:
|
||||||
|
zugewiesen, abende = _filme(conn)
|
||||||
|
stuecke = [_mit_film(_stueck(s), zugewiesen, abende) for s in segmente]
|
||||||
|
return sorted(stuecke, key=lambda s: s["start"], reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def film_der_aufnahme(start):
|
||||||
|
"""Nur die id — für neue Marken."""
|
||||||
|
with connect() as conn:
|
||||||
|
zugewiesen, abende = _filme(conn)
|
||||||
|
film = _mit_film({"start": start}, zugewiesen, abende)["film"]
|
||||||
|
return film["id"] if film else None
|
||||||
|
|
||||||
|
|
||||||
|
async def film_live():
|
||||||
|
"""Der Film der gerade laufenden Aufnahme — für die Taste C. None,
|
||||||
|
wenn nichts aufgenommen wird oder MediaMTX nicht antwortet."""
|
||||||
|
try:
|
||||||
|
segmente = await _segmente(_jetzt() - timedelta(hours=6), _jetzt())
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return None
|
||||||
|
for s in segmente:
|
||||||
|
stueck = _stueck(s)
|
||||||
|
ende = datetime.fromisoformat(stueck["start"]) + timedelta(seconds=stueck["dauer"])
|
||||||
|
if ende >= _jetzt() - timedelta(seconds=90):
|
||||||
|
return film_der_aufnahme(stueck["start"])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _im_stueck(segmente, start, dauer):
|
def _im_stueck(segmente, start, dauer):
|
||||||
|
|||||||
@@ -307,15 +307,17 @@ def clip_oder_404(clip):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/clips")
|
@app.post("/api/clips")
|
||||||
def marke_setzen(response: Response, filmabend_sid: str | None = Cookie(None)):
|
async def marke_setzen(response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||||
"""Taste C: der Moment wird gemerkt, mehr nicht. Der Film ist der
|
"""Taste C: der Moment wird gemerkt, mehr nicht. Der Film ist der der
|
||||||
zuletzt gezogene, sonst der neueste Abend unter Gesehen."""
|
laufenden Aufnahme, sonst der zuletzt gezogene, sonst der neueste
|
||||||
|
Abend unter Gesehen."""
|
||||||
sid = sitzung(response, filmabend_sid)
|
sid = sitzung(response, filmabend_sid)
|
||||||
mitglied(sid)
|
mitglied(sid)
|
||||||
|
movie_id = await clips.film_live()
|
||||||
gezogen = letzter_dreh["ergebnis"]
|
gezogen = letzter_dreh["ergebnis"]
|
||||||
if gezogen:
|
if movie_id is None and gezogen:
|
||||||
movie_id = gezogen["gewinner"]["movie_id"]
|
movie_id = gezogen["gewinner"]["movie_id"]
|
||||||
else:
|
if movie_id is None:
|
||||||
abend = watched.juengster()
|
abend = watched.juengster()
|
||||||
movie_id = abend["movie_id"] if abend else None
|
movie_id = abend["movie_id"] if abend else None
|
||||||
clip = clips.markieren(sid, movie_id)
|
clip = clips.markieren(sid, movie_id)
|
||||||
@@ -323,27 +325,34 @@ def marke_setzen(response: Response, filmabend_sid: str | None = Cookie(None)):
|
|||||||
return clip
|
return clip
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/clips/aufnahmen")
|
|
||||||
async def clip_aufnahmen():
|
|
||||||
"""Zu welchen Abenden es eine Aufnahme gibt."""
|
|
||||||
try:
|
|
||||||
return {"tage": await clips.tage_mit_aufnahme()}
|
|
||||||
except httpx.HTTPError:
|
|
||||||
return {"tage": []}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/aufnahmen")
|
@app.get("/api/aufnahmen")
|
||||||
async def aufnahmen_liste(response: Response, filmabend_sid: str | None = Cookie(None)):
|
async def aufnahmen_liste():
|
||||||
"""Alle Stücke der Aufnahme, neueste zuerst — für die Verwaltung."""
|
"""Alle Aufnahmen, neueste zuerst, jede mit ihrem Film — für
|
||||||
sid = sitzung(response, filmabend_sid)
|
„Clip erstellen" und die Verwaltung."""
|
||||||
if not users.ist_host(sid):
|
|
||||||
raise HTTPException(status_code=403, detail="Nur für den Host")
|
|
||||||
try:
|
try:
|
||||||
return {"stuecke": await clips.aufnahmen_alle()}
|
return {"stuecke": await clips.aufnahmen_alle()}
|
||||||
except httpx.HTTPError:
|
except httpx.HTTPError:
|
||||||
raise HTTPException(status_code=502, detail="Die Aufnahmen sind gerade nicht erreichbar.")
|
raise HTTPException(status_code=502, detail="Die Aufnahmen sind gerade nicht erreichbar.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/aufnahmen/film")
|
||||||
|
async def aufnahme_film_setzen(
|
||||||
|
response: Response,
|
||||||
|
start: str = Body(..., embed=True),
|
||||||
|
movie_id: int | None = Body(None, embed=True),
|
||||||
|
filmabend_sid: str | None = Cookie(None),
|
||||||
|
):
|
||||||
|
"""Der Host sagt, welcher Film in einer Aufnahme lief — oder nimmt es zurück."""
|
||||||
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
if not users.ist_host(sid):
|
||||||
|
raise HTTPException(status_code=403, detail="Nur für den Host")
|
||||||
|
if movie_id is not None:
|
||||||
|
# Suchtreffer stehen nicht im Katalog — Titel und Poster müssen aber her
|
||||||
|
await film_holen(movie_id)
|
||||||
|
clips.film_zuweisen(start, movie_id)
|
||||||
|
return {"zugewiesen": movie_id is not None}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/aufnahmen")
|
@app.delete("/api/aufnahmen")
|
||||||
async def aufnahme_loeschen(
|
async def aufnahme_loeschen(
|
||||||
response: Response, start: str, dauer: float, filmabend_sid: str | None = Cookie(None)
|
response: Response, start: str, dauer: float, filmabend_sid: str | None = Cookie(None)
|
||||||
@@ -360,51 +369,25 @@ async def aufnahme_loeschen(
|
|||||||
)
|
)
|
||||||
except httpx.HTTPError:
|
except httpx.HTTPError:
|
||||||
raise HTTPException(status_code=502, detail="Die MediaMTX-API ist nicht erreichbar.")
|
raise HTTPException(status_code=502, detail="Die MediaMTX-API ist nicht erreichbar.")
|
||||||
|
clips.film_zuweisen(start, None)
|
||||||
return {"segmente": anzahl}
|
return {"segmente": anzahl}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/clips/von-film/{movie_id}")
|
@app.post("/api/clips/von-aufnahme")
|
||||||
async def clips_stuecke(
|
def clip_von_aufnahme(
|
||||||
movie_id: int, response: Response, filmabend_sid: str | None = Cookie(None)
|
|
||||||
):
|
|
||||||
"""Die Aufnahmen zu einem Abend — mehrere, wenn der Stream neu
|
|
||||||
gestartet wurde. Daraus wählt man das Stück für den Clip."""
|
|
||||||
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.")
|
|
||||||
try:
|
|
||||||
return {"stuecke": await clips.aufnahmen_des_abends(abend["watched_at"])}
|
|
||||||
except httpx.HTTPError:
|
|
||||||
return {"stuecke": []}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/clips/von-film")
|
|
||||||
async def clip_von_film(
|
|
||||||
response: Response,
|
response: Response,
|
||||||
movie_id: int = Body(..., embed=True),
|
start: str = Body(..., embed=True),
|
||||||
start: str | None = Body(None, embed=True),
|
|
||||||
filmabend_sid: str | None = Cookie(None),
|
filmabend_sid: str | None = Cookie(None),
|
||||||
):
|
):
|
||||||
"""Ein Clip zu einem alten Abend: die Marke kommt an den Anfang des
|
"""Ein Clip aus einer alten Aufnahme: die Marke kommt kurz nach deren
|
||||||
gewählten Stücks — ohne Wahl an den des ersten. Das Fenster schiebt
|
Anfang, das Fenster schiebt man dann im Editor hin. Den Film bringt
|
||||||
man dann im Editor hin."""
|
die Aufnahme mit — oder keinen."""
|
||||||
sid = sitzung(response, filmabend_sid)
|
sid = sitzung(response, filmabend_sid)
|
||||||
mitglied(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)
|
beginn = datetime.fromisoformat(start)
|
||||||
clip = clips.markieren(sid, movie_id, beginn + timedelta(seconds=clips.VORHER + 30))
|
clip = clips.markieren(
|
||||||
|
sid, clips.film_der_aufnahme(start), beginn + timedelta(seconds=clips.VORHER + 30)
|
||||||
|
)
|
||||||
events.senden("clips")
|
events.senden("clips")
|
||||||
return clip
|
return clip
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app import clips, main, punkte
|
from app import clips, db, main, punkte
|
||||||
|
|
||||||
|
|
||||||
def marke(client):
|
def marke(client):
|
||||||
@@ -38,7 +38,7 @@ def test_ohne_dreh_zaehlt_der_neueste_abend(client, film):
|
|||||||
assert marke(client)["movie_id"] == film(2)
|
assert marke(client)["movie_id"] == film(2)
|
||||||
|
|
||||||
|
|
||||||
def test_tage_mit_aufnahme(client, monkeypatch):
|
def stuecke_mocken(monkeypatch):
|
||||||
async def segmente(von, bis):
|
async def segmente(von, bis):
|
||||||
# Ein Abend mit Stück vor und nach Mitternacht, ein weiterer am Tag danach
|
# Ein Abend mit Stück vor und nach Mitternacht, ein weiterer am Tag danach
|
||||||
return [
|
return [
|
||||||
@@ -48,63 +48,72 @@ def test_tage_mit_aufnahme(client, monkeypatch):
|
|||||||
]
|
]
|
||||||
|
|
||||||
monkeypatch.setattr(clips, "_segmente", segmente)
|
monkeypatch.setattr(clips, "_segmente", segmente)
|
||||||
assert client.get("/api/clips/aufnahmen").json()["tage"] == ["2026-09-23", "2026-09-25"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_clip_zu_altem_abend(client, film, monkeypatch):
|
def test_aufnahmen_bekommen_ihren_film(client, zuschauer, hostmodus, film, monkeypatch):
|
||||||
from datetime import datetime, timezone
|
"""Zuweisung schlägt den Abend am gleichen Datum; sonst kein Film."""
|
||||||
|
stuecke_mocken(monkeypatch)
|
||||||
|
client.post("/api/watched", json={"movie_id": film(1)})
|
||||||
|
with db.connect() as conn:
|
||||||
|
conn.execute("UPDATE watched SET watched_at = '2026-09-23T20:41:00+00:00'")
|
||||||
|
|
||||||
client.post("/api/watched", json={"movie_id": film(4)})
|
liste = client.get("/api/aufnahmen").json()["stuecke"]
|
||||||
# Kein Abend zu diesem Film
|
assert [s["start"] for s in liste] == [
|
||||||
assert client.post("/api/clips/von-film", json={"movie_id": 999}).status_code == 404
|
"2026-09-25T19:00:00+00:00",
|
||||||
|
"2026-09-24T00:30:00+00:00",
|
||||||
# Keine Aufnahme an dem Tag
|
"2026-09-23T20:00:00+00:00",
|
||||||
async def keine(watched_at):
|
|
||||||
return []
|
|
||||||
|
|
||||||
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)})
|
|
||||||
assert antwort.status_code == 404 and "Aufnahme" in antwort.json()["detail"]
|
|
||||||
|
|
||||||
# Zwei Stücke, weil der Stream neu gestartet wurde
|
|
||||||
stuecke = [
|
|
||||||
{"start": "2026-09-23T19:00:00+00:00", "dauer": 1800.0},
|
|
||||||
{"start": "2026-09-23T20:00:00+00:00", "dauer": 600.0},
|
|
||||||
]
|
]
|
||||||
|
# Der Abend vom 23. gilt auch für die Nacht danach
|
||||||
|
assert liste[2]["film"]["id"] == film(1) and liste[2]["zugewiesen"] is False
|
||||||
|
assert liste[1]["film"]["id"] == film(1)
|
||||||
|
assert liste[0]["film"] is None
|
||||||
|
|
||||||
async def da(watched_at):
|
# Zuweisen darf nur der Host
|
||||||
return stuecke
|
zuweisung = {"start": "2026-09-25T19:00:00+00:00", "movie_id": film(2)}
|
||||||
|
assert client.put("/api/aufnahmen/film", json=zuweisung).status_code == 403
|
||||||
|
hostmodus(zuschauer)
|
||||||
|
assert zuschauer.put("/api/aufnahmen/film", json=zuweisung).json() == {"zugewiesen": True}
|
||||||
|
liste = client.get("/api/aufnahmen").json()["stuecke"]
|
||||||
|
assert liste[0]["film"]["title"] == "Film 2" and liste[0]["zugewiesen"] is True
|
||||||
|
# Die Zuweisung schlägt den Abend
|
||||||
|
zuschauer.put("/api/aufnahmen/film", json={"start": liste[2]["start"], "movie_id": film(2)})
|
||||||
|
assert client.get("/api/aufnahmen").json()["stuecke"][2]["film"]["id"] == film(2)
|
||||||
|
# Aufheben
|
||||||
|
zuschauer.put("/api/aufnahmen/film", json={"start": zuweisung["start"], "movie_id": None})
|
||||||
|
assert client.get("/api/aufnahmen").json()["stuecke"][0]["film"] is None
|
||||||
|
|
||||||
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
|
def test_clip_aus_aufnahme(client, zuschauer, hostmodus, film):
|
||||||
m = client.post("/api/clips/von-film", json={"movie_id": film(4)}).json()
|
hostmodus(zuschauer)
|
||||||
assert m["movie_id"] == film(4)
|
start = "2026-09-23T19:00:00+00:00"
|
||||||
|
zuschauer.put("/api/aufnahmen/film", json={"start": start, "movie_id": film(4)})
|
||||||
|
m = client.post("/api/clips/von-aufnahme", json={"start": start}).json()
|
||||||
|
assert m["movie_id"] == film(4) and m["title"] == "Film 4"
|
||||||
|
# Die Marke liegt kurz nach dem Anfang der Aufnahme
|
||||||
assert m["marke_at"] == "2026-09-23T19:01:00+00:00"
|
assert m["marke_at"] == "2026-09-23T19:01:00+00:00"
|
||||||
|
# Ohne Film gibt es die Marke trotzdem — unter Sonstige
|
||||||
# Mit Wahl: nach dem Beginn des gewählten Stücks
|
m = client.post("/api/clips/von-aufnahme", json={"start": "2026-09-25T19:00:00+00:00"}).json()
|
||||||
m = client.post(
|
assert m["movie_id"] is None
|
||||||
"/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):
|
def test_taste_c_nimmt_den_film_der_laufenden_aufnahme(client, zuschauer, hostmodus, film, monkeypatch):
|
||||||
import asyncio
|
from datetime import timedelta
|
||||||
|
|
||||||
|
start = clips._iso(clips._jetzt() - timedelta(seconds=100))
|
||||||
|
|
||||||
async def segmente(von, bis):
|
async def segmente(von, bis):
|
||||||
return [
|
return [{"start": start, "duration": 60}]
|
||||||
{"start": "2026-09-23T21:00:00Z", "duration": 600.0},
|
|
||||||
{"start": "2026-09-23T19:00:00Z", "duration": 1800.04},
|
|
||||||
]
|
|
||||||
|
|
||||||
monkeypatch.setattr(clips, "_segmente", segmente)
|
monkeypatch.setattr(clips, "_segmente", segmente)
|
||||||
assert asyncio.run(clips.aufnahmen_des_abends("2026-09-23T20:00:00+00:00")) == [
|
# Es läuft eine Aufnahme, aber ohne Film: dann wie bisher der neueste Abend
|
||||||
{"start": "2026-09-23T19:00:00+00:00", "dauer": 1800.0},
|
client.post("/api/watched", json={"movie_id": film(1)})
|
||||||
{"start": "2026-09-23T21:00:00+00:00", "dauer": 600.0},
|
with db.connect() as conn:
|
||||||
]
|
conn.execute("UPDATE watched SET watched_at = '2020-01-01T20:00:00+00:00'")
|
||||||
|
assert marke(client)["movie_id"] == film(1)
|
||||||
|
# Zugewiesen: der Film der Aufnahme
|
||||||
|
hostmodus(zuschauer)
|
||||||
|
zuschauer.put("/api/aufnahmen/film", json={"start": start, "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_fremde_sehen_nichts_der_host_alles(client, client2, zuschauer, hostmodus):
|
||||||
@@ -221,25 +230,21 @@ def test_segmente_eines_stuecks():
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_aufnahmen_verwaltet_nur_der_host(client, zuschauer, hostmodus, monkeypatch):
|
def test_aufnahme_loescht_nur_der_host(client, zuschauer, hostmodus, film, monkeypatch):
|
||||||
stuecke = [{"start": "2026-09-23T19:00:00+00:00", "dauer": 60.0}]
|
|
||||||
|
|
||||||
async def alle():
|
|
||||||
return stuecke
|
|
||||||
|
|
||||||
geloescht = []
|
geloescht = []
|
||||||
|
|
||||||
async def loeschen(start, dauer):
|
async def loeschen(start, dauer):
|
||||||
geloescht.append((start, dauer))
|
geloescht.append((start, dauer))
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
monkeypatch.setattr(clips, "aufnahmen_alle", alle)
|
|
||||||
monkeypatch.setattr(clips, "aufnahme_loeschen", loeschen)
|
monkeypatch.setattr(clips, "aufnahme_loeschen", loeschen)
|
||||||
assert client.get("/api/aufnahmen").status_code == 403
|
start = "2026-09-23T19:00:00+00:00"
|
||||||
assert client.delete("/api/aufnahmen", params={"start": stuecke[0]["start"], "dauer": 60}).status_code == 403
|
assert client.delete("/api/aufnahmen", params={"start": start, "dauer": 60}).status_code == 403
|
||||||
|
|
||||||
hostmodus(zuschauer)
|
hostmodus(zuschauer)
|
||||||
assert zuschauer.get("/api/aufnahmen").json()["stuecke"] == stuecke
|
zuschauer.put("/api/aufnahmen/film", json={"start": start, "movie_id": film(1)})
|
||||||
antwort = zuschauer.delete("/api/aufnahmen", params={"start": stuecke[0]["start"], "dauer": 60})
|
antwort = zuschauer.delete("/api/aufnahmen", params={"start": start, "dauer": 60})
|
||||||
assert antwort.json() == {"segmente": 2}
|
assert antwort.json() == {"segmente": 2}
|
||||||
assert geloescht == [(stuecke[0]["start"], 60.0)]
|
assert geloescht == [(start, 60.0)]
|
||||||
|
# Mit der Aufnahme geht auch die Zuweisung
|
||||||
|
assert clips.film_der_aufnahme(start) is None
|
||||||
|
|||||||
@@ -434,12 +434,7 @@ onMounted(load)
|
|||||||
|
|
||||||
<StreamTab v-else-if="activeTab === 'stream'" :angemeldet="angemeldet" />
|
<StreamTab v-else-if="activeTab === 'stream'" :angemeldet="angemeldet" />
|
||||||
|
|
||||||
<ClipsTab
|
<ClipsTab v-else-if="activeTab === 'clips'" :angemeldet="angemeldet" :host="host" />
|
||||||
v-else-if="activeTab === 'clips'"
|
|
||||||
:angemeldet="angemeldet"
|
|
||||||
:host="host"
|
|
||||||
:abende="gesehen"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-else class="layout">
|
<div v-else class="layout">
|
||||||
<div class="results">
|
<div class="results">
|
||||||
|
|||||||
@@ -10,64 +10,37 @@ import PersonName from './PersonName.vue'
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
angemeldet: Boolean,
|
angemeldet: Boolean,
|
||||||
host: Boolean,
|
host: Boolean,
|
||||||
// Die gesehenen Abende — für Clips aus alten Aufnahmen
|
|
||||||
abende: { type: Array, default: () => [] },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// "Clip erstellen": erst den Abend wählen, dann öffnet der Editor.
|
// "Clip erstellen": eine Aufnahme wählen, dann öffnet der Editor. Jede
|
||||||
// Zur Wahl stehen nur Abende, zu denen es eine Aufnahme gibt. Wurde der
|
// Aufnahme bringt ihren Film mit — oder keinen, dann läuft der Clip
|
||||||
// Stream an dem Abend neu gestartet, fragt ein zweiter Schritt nach dem
|
// unter „Sonstige".
|
||||||
// Stück.
|
|
||||||
const wahl = ref(false)
|
const wahl = ref(false)
|
||||||
const wahlFehler = ref('')
|
const wahlFehler = ref('')
|
||||||
const tage = ref(null)
|
const aufnahmen = 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
|
aufnahmen.value = null
|
||||||
gewaehlt.value = null
|
|
||||||
stuecke.value = null
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/clips/aufnahmen')
|
const resp = await fetch('/api/aufnahmen')
|
||||||
tage.value = resp.ok ? (await resp.json()).tage : []
|
aufnahmen.value = resp.ok ? (await resp.json()).stuecke : []
|
||||||
} catch {
|
} catch {
|
||||||
tage.value = []
|
aufnahmen.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const abendeMitAufnahme = computed(() =>
|
|
||||||
tage.value ? props.abende.filter((a) => tage.value.includes(a.watched_at.slice(0, 10))) : [],
|
|
||||||
)
|
|
||||||
|
|
||||||
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) {
|
function minuten(sekunden) {
|
||||||
return `${Math.max(1, Math.round(sekunden / 60))} Min`
|
return `${Math.max(1, Math.round(sekunden / 60))} Min`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function erstellen(abend, start = null) {
|
async function erstellen(aufnahme) {
|
||||||
wahlFehler.value = ''
|
wahlFehler.value = ''
|
||||||
const resp = await fetch('/api/clips/von-film', {
|
const resp = await fetch('/api/clips/von-aufnahme', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ movie_id: abend.movie_id, start }),
|
body: JSON.stringify({ start: aufnahme.start }),
|
||||||
})
|
})
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const daten = await resp.json().catch(() => ({}))
|
const daten = await resp.json().catch(() => ({}))
|
||||||
@@ -104,7 +77,7 @@ const gruppen = computed(() => {
|
|||||||
const nach = new Map()
|
const nach = new Map()
|
||||||
for (const c of clips.value) {
|
for (const c of clips.value) {
|
||||||
const key = c.movie_id ?? 0
|
const key = c.movie_id ?? 0
|
||||||
if (!nach.has(key)) nach.set(key, { key, titel: c.title || 'Ohne Film', clips: [] })
|
if (!nach.has(key)) nach.set(key, { key, titel: c.title || 'Sonstige', clips: [] })
|
||||||
nach.get(key).clips.push(c)
|
nach.get(key).clips.push(c)
|
||||||
}
|
}
|
||||||
return [...nach.values()]
|
return [...nach.values()]
|
||||||
@@ -140,46 +113,31 @@ onUnmounted(() => abbestellen?.())
|
|||||||
<button type="button" class="erstellen" @click="wahlOeffnen">Clip erstellen</button>
|
<button type="button" class="erstellen" @click="wahlOeffnen">Clip erstellen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Abend wählen: nur, wo es eine Aufnahme gibt, klappt es -->
|
<!-- Aufnahme wählen: mit Film oder ohne -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<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>{{ stuecke ? 'Welche Aufnahme?' : 'Zu welchem Abend?' }}</span>
|
<span>Welche Aufnahme?</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="aufnahmen === null" class="abendwahl-hinweis">Suche Aufnahmen …</p>
|
||||||
<!-- Zweiter Schritt: der Stream lief an dem Abend mehrmals -->
|
<p v-else-if="!aufnahmen.length" class="abendwahl-hinweis">Es gibt keine Aufnahmen.</p>
|
||||||
<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">
|
|
||||||
Zu keinem Abend gibt es eine Aufnahme.
|
|
||||||
</p>
|
|
||||||
<ul v-else>
|
<ul v-else>
|
||||||
<li v-for="a in abendeMitAufnahme" :key="a.id">
|
<li v-for="s in aufnahmen" :key="s.start">
|
||||||
<button type="button" @click="abendWaehlen(a)">
|
<button type="button" @click="erstellen(s)">
|
||||||
<img v-if="a.poster_path" :src="posterUrl(a.poster_path, 'w154')" alt="" />
|
<img
|
||||||
|
v-if="s.film?.poster_path"
|
||||||
|
:src="posterUrl(s.film.poster_path, 'w154')"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<span v-else class="ohne-cover"></span>
|
||||||
<span class="abend-text">
|
<span class="abend-text">
|
||||||
<span class="abend-titel">{{ a.title }}</span>
|
<span class="abend-titel">{{ s.film?.title || 'Sonstige Aufnahme' }}</span>
|
||||||
<span class="abend-datum">{{ datum(a.watched_at) }}</span>
|
<span class="abend-datum">
|
||||||
|
{{ datum(s.start) }} · {{ uhrzeit(s.start) }} Uhr · {{ minuten(s.dauer) }}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -290,6 +248,8 @@ h3 {
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: block;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
|
/* Sonst zieht ein hochkantiges Poster die Karte in die Länge */
|
||||||
|
overflow: hidden;
|
||||||
background: #000;
|
background: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,18 +364,6 @@ 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;
|
||||||
@@ -453,6 +401,14 @@ h3 {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ohne Film: ein leeres Cover an Stelle des Posters */
|
||||||
|
.abendwahl li .ohne-cover {
|
||||||
|
flex: 0 0 40px;
|
||||||
|
height: 60px;
|
||||||
|
background: var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.abend-text {
|
.abend-text {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -106,6 +106,21 @@ async function aufnahmeLoeschen(s) {
|
|||||||
await aufnahmenLaden()
|
await aufnahmenLaden()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Welcher Film in einer Aufnahme lief, sagt der Host — per Filmsuche
|
||||||
|
const filmDialogFuer = ref(null)
|
||||||
|
|
||||||
|
async function filmZuweisen(start, movieId) {
|
||||||
|
aufnahmenFehler.value = ''
|
||||||
|
const resp = await fetch('/api/aufnahmen/film', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ start, movie_id: movieId }),
|
||||||
|
})
|
||||||
|
if (!resp.ok) aufnahmenFehler.value = 'Zuweisen hat nicht geklappt.'
|
||||||
|
filmDialogFuer.value = null
|
||||||
|
await aufnahmenLaden()
|
||||||
|
}
|
||||||
|
|
||||||
function aufnahmeText(s) {
|
function aufnahmeText(s) {
|
||||||
const d = new Date(s.start)
|
const d = new Date(s.start)
|
||||||
const datum = d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
const datum = d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
@@ -245,19 +260,52 @@ async function entfernen(user) {
|
|||||||
<p v-else-if="!aufnahmen.length" class="leer">Keine Aufnahmen.</p>
|
<p v-else-if="!aufnahmen.length" class="leer">Keine Aufnahmen.</p>
|
||||||
<ul v-else>
|
<ul v-else>
|
||||||
<li v-for="s in aufnahmen" :key="s.start" class="zeile">
|
<li v-for="s in aufnahmen" :key="s.start" class="zeile">
|
||||||
<span>{{ aufnahmeText(s) }}</span>
|
<span>
|
||||||
<button
|
{{ aufnahmeText(s) }} ·
|
||||||
type="button"
|
<strong v-if="s.film">{{ s.film.title }}</strong>
|
||||||
class="klein"
|
<em v-else>kein Film</em>
|
||||||
:class="{ warnung: loeschFrage === s.start }"
|
</span>
|
||||||
@click="aufnahmeLoeschen(s)"
|
<span class="knoepfe">
|
||||||
>
|
<button type="button" class="klein" @click="filmDialogFuer = s.start">Film</button>
|
||||||
{{ loeschFrage === s.start ? 'Wirklich löschen?' : 'Löschen' }}
|
<button
|
||||||
</button>
|
v-if="s.zugewiesen"
|
||||||
|
type="button"
|
||||||
|
class="klein"
|
||||||
|
@click="filmZuweisen(s.start, null)"
|
||||||
|
>
|
||||||
|
Aufheben
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="klein"
|
||||||
|
:class="{ warnung: loeschFrage === s.start }"
|
||||||
|
@click="aufnahmeLoeschen(s)"
|
||||||
|
>
|
||||||
|
{{ loeschFrage === s.start ? 'Wirklich löschen?' : 'Löschen' }}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="filmDialogFuer" class="hintergrund" @click.self="filmDialogFuer = null">
|
||||||
|
<div class="fenster" role="dialog" aria-modal="true">
|
||||||
|
<p class="label">Aufnahme</p>
|
||||||
|
<h2>Welcher Film lief?</h2>
|
||||||
|
<FilmSuche
|
||||||
|
platzhalter="Film suchen …"
|
||||||
|
@waehlen="filmZuweisen(filmDialogFuer, $event.id)"
|
||||||
|
/>
|
||||||
|
<div class="fuss">
|
||||||
|
<button type="button" class="abbrechen" @click="filmDialogFuer = null">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<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">
|
||||||
@@ -552,6 +600,17 @@ h3 {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.aufnahmen .knoepfe {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aufnahmen em {
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
.klein.warnung {
|
.klein.warnung {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
|
|||||||
Reference in New Issue
Block a user