diff --git a/.env.example b/.env.example index bcff852..70b8647 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,10 @@ XDG_RUNTIME_DIR=/run/user/1000 BIND_IP=0.0.0.0 ANNOUNCER_PORT=8090 +# Tidssone for logg og visning. Planlagte sendinger lagres som absolutt +# tid, så denne kan ikke gjøre dem feil. +TZ=Europe/Oslo + # Lydutgang. Tom = systemets standard. Finn navn med: pactl list short sinks ANNOUNCER_SINK= diff --git a/AGENTS.md b/AGENTS.md index c2e59cd..afb1756 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,10 +18,11 @@ Filer: | `announcer.py` | server, kø, TTS, jingler, ruter | | `auth.py` | `Principal`, leverandørene, OIDC-rutene | | `config.py` | `config.toml` + miljøvariabler, standardverdier | +| `schedule.py` | planlagte sendinger, lagret i `data/scheduled.json` | | `index.html` | hele sida, inline CSS/JS, ingen byggesteg | | `config.example.toml` | kommentert mal; kopier til `config.toml` | | `docs/crew-integration.md` | kontrakten mot crew-panelet | -| `tests/test_auth.py` | `uv run pytest` | +| `tests/test_auth.py` + `test_schedule.py` | `uv run pytest` – 80 tester | | `Dockerfile` + `compose.yaml` | **drift** – appen kjøres i Docker | | `.env.example` | bruker-ID, portbinding og hemmeligheter | | `compose.test.yaml` | testoppsett med dummy-IdP, se under | @@ -66,6 +67,7 @@ jingler i `data/chimes/` uten å gå inn i containeren): | `data/chimes/` | egne jinglefiler | | `data/logs/` | revisjonsloggen | | `data/instance/` | sesjonsnøkkelen | +| `data/scheduled.json` | planlagte sendinger. Lesbar med `cat` | | `data/config.toml` | valgfri. Uten den er sida åpen | ### Lyden ut av containeren @@ -201,6 +203,46 @@ veien for å prøve en endring kjapt; `uv run pytest` for testene. Men det er ryddet bort. Skal noe prøves med innlogging, bruk `compose.test.yaml`. +## Planlagte sendinger + +`schedule.py` er den første delen av appen som **må** ligge på disk. Kø og +historikk har alltid bare levd i minnet, og det går bra fordi en melding +spilles med en gang. En melding planlagt til 18:00 må overleve at containeren +startes om 17:45, så den lagres i `data/scheduled.json`, skrevet til side og +flyttet på plass så fila aldri er halvskrevet. + +Ingen database, med vilje: dette er en håndfull meldinger på et arrangement, +og en fil man kan lese med `cat` er verdt mer her enn indekser. + +Modulen vet ingenting om Flask eller avspilling. Den lagrer, og den svarer på +«hva skal sendes nå?». `_scheduler()` i `announcer.py` eier tråden som spør +hvert 20. sekund og legger forfalte meldinger i den vanlige køen – derfra +oppfører de seg nøyaktig som en melding sendt for hånd. + +**Bare engangs**, ikke gjentakende. Derfor sender klienten et absolutt +tidspunkt (epoch), og hele tidssoneproblemet forsvinner: containeren kan stå +i UTC uten at det gjør noe. `TZ` settes likevel fra `.env`, ellers står +loggen i UTC mens du står i CEST. Skal gjentakelse inn senere, må «09:00» +tolkes i en tidssone, og da begynner `TZ` å bety noe. + +**Gikk den glipp av, spilles den ikke.** `due()` skiller på `grace_seconds` +(standard 120): innenfor den sendes meldingen, utenfor markeres den `missed`. +Nådefristen er slingringsmonn for at tråden våkner med mellomrom og for en +kjapp omstart – ikke en policy om å ta igjen etterslep. «Turneringen starter +om ti minutter» skal ikke ut på anlegget en time etter at maskina kom +tilbake. `due()` endrer ingenting selv; `announcer.py` markerer først etter at +meldingen faktisk er lagt i kø, så ingenting kan stå som «sendt» uten å ha +blitt det. + +Forhåndslytten gir en bonus: planlegging er låst til den samme nøkkelen som +«Send ut», så man planlegger bare det man har hørt – og siden forhåndslytt +varmer `cache/`, ligger WAV-en ferdig på disk. **En planlagt edge-tts-melding +spiller altså kl. 18:00 selv om nettet er nede da.** + +Sine egne planlagte sendinger kan man alltid avbryte. Andres krever `clear` – +samme rett som å tømme køen, siden det er samme slags inngrep i noe andre har +satt i gang. + ## Hvordan det henger sammen - `/api/config` er alt sida trenger for å tegne seg selv: branding, stemmer, @@ -262,6 +304,11 @@ Vil Kent ha enda bedre lyd: ElevenLabs (nøkkel, best norsk) eller OpenAI - `player = Player()` står på modulnivå, så avspillingstråden starter ved `import announcer` – også i `install.sh`, som importerer modulen for å laste ned stemmer. Harmløst, men greit å vite. +- **Oppgradering:** `schedule` er en ny handling. Har et oppsett et eksplisitt + `[roles]` i `config.toml`, får `crew` den *ikke* automatisk – eksplisitt + konfigurasjon overstyrer standardverdiene, og da blir svaret 403 «Rollen din + har ikke lov til dette». Legg `"schedule"` inn selv. Dette traff + `docker/config.test.toml` under testing. - `tests/conftest.py` nuller ut alle `ANNOUNCER_*`-variabler før hver test. Uten den arver testene maskinens miljø – systemd-enheten setter `ANNOUNCER_PORT`, og compose setter JWT- og OIDC-hemmeligheter, noe som diff --git a/Dockerfile b/Dockerfile index 492f078..85f8e74 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ FROM python:3.13-slim # velger selv hvilken som finnes (player_cmd()), så begge lydservere virker. RUN apt-get update \ && apt-get install -y --no-install-recommends \ - ffmpeg ca-certificates pipewire-bin pulseaudio-utils \ + ffmpeg ca-certificates tzdata pipewire-bin pulseaudio-utils \ && rm -rf /var/lib/apt/lists/* COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv @@ -25,7 +25,7 @@ COPY pyproject.toml uv.lock ./ RUN uv sync --frozen --no-dev # Koden sist, så avhengighetslaget gjenbrukes når bare appen endres. -COPY announcer.py auth.py config.py index.html ./ +COPY announcer.py auth.py config.py schedule.py index.html ./ # Alt som skal overleve en ny container ligger under /data, som monteres # inn fra verten. Stemmene er ~670 MB og lastes ned ved første oppstart. @@ -34,6 +34,7 @@ ENV ANNOUNCER_VOICES=/data/voices \ ANNOUNCER_CHIMES=/data/chimes \ ANNOUNCER_LOG=/data/logs/announcements.jsonl \ ANNOUNCER_STATE=/data/instance \ + ANNOUNCER_SCHEDULE=/data/scheduled.json \ ANNOUNCER_CONFIG=/data/config.toml \ ANNOUNCER_PORT=8090 diff --git a/announcer.py b/announcer.py index 19f37c2..66abcc1 100644 --- a/announcer.py +++ b/announcer.py @@ -31,6 +31,7 @@ from flask import Flask, Response, abort, jsonify, request, send_file import auth import config +import schedule as sched from auth import require HERE = Path(__file__).resolve().parent @@ -38,6 +39,7 @@ CFG = config.load() VOICE_DIR = CFG.path("voices") CACHE_DIR = CFG.path("cache") LOG_PATH = CFG.path("log") +SCHEDULE_PATH = CFG.path("schedule") SINK = str(CFG.get("audio", "sink", "")).strip() # pw-play --target MAX_CHARS = int(CFG.app["max_chars"]) @@ -443,6 +445,55 @@ class Player: player = Player() +# -------------------------------------------------------------------------- +# Planlagte sendinger +# -------------------------------------------------------------------------- + + +schedule = sched.Schedule( + SCHEDULE_PATH, + grace=float(CFG.get("schedule", "grace_seconds", sched.DEFAULT_GRACE)), + max_ahead=float(CFG.get("schedule", "max_days_ahead", 30)) * 86400, +) +TICK = max(5.0, float(CFG.get("schedule", "tick_seconds", 20))) + + +def _scheduler() -> None: + """Flytter forfalte meldinger inn i den vanlige køen. + + Egen tråd, og den gjør med vilje ingenting annet: avspilling, syntese og + feilhåndtering er Players jobb, slik at en planlagt melding oppfører seg + nøyaktig som en sendt for hånd når den først er i kø. + """ + while True: + try: + send, missed = schedule.due() + for item in missed: + schedule.mark(item.id, "missed", "maskina var ikke i gang i tide") + log.warning("planlagt #%s gikk glipp av (%s)", item.id, item.text[:40]) + audit("missed", id=item.id, subject=item.subject, name=item.user, + method=item.method, text=item.text, at=item.at) + for item in send: + queued = player.enqueue( + item.text, item.voice, item.speed, item.chime, + sender="planlagt", + who=auth.Principal(item.subject, item.user, (), item.method, frozenset()), + ) + schedule.mark(item.id, "sent", f"kø #{queued.id}") + log.info("planlagt #%s lagt i kø som #%d", item.id, queued.id) + audit("scheduled_fire", id=item.id, queue_id=queued.id, + subject=item.subject, name=item.user, method=item.method, + text=item.text, voice=item.voice, at=item.at) + except Exception: # noqa: BLE001 + # En feil her skal ikke drepe tråden; da ville alle senere + # planlagte sendinger stilltiende blitt borte. + log.exception("planleggeren feilet") + time.sleep(TICK) + + +threading.Thread(target=_scheduler, daemon=True, name="scheduler").start() + + # -------------------------------------------------------------------------- # HTTP # -------------------------------------------------------------------------- @@ -532,6 +583,10 @@ def client_config() -> Response: "chimes": list_chimes(), "default_chime": DEFAULT_CHIME, "max_chars": MAX_CHARS, + "schedule": { + "max_days_ahead": int(CFG.get("schedule", "max_days_ahead", 30)), + "grace_seconds": int(CFG.get("schedule", "grace_seconds", 120)), + }, "auth": { **AUTH.describe(), "user": None if me.anonymous else me.public(), @@ -568,6 +623,44 @@ def announce() -> Response: return jsonify(item.public(show_ip=me.can("clear"))) +@app.post("/api/schedule") +def schedule_add() -> Response: + me = require("schedule") + text, voice, speed, chime = parse_request() + data = request.get_json(silent=True) or {} + try: + at = float(data["at"]) + except (KeyError, TypeError, ValueError): + abort(400, "Mangler eller ugyldig tidspunkt") + try: + item = schedule.add(at=at, text=text, voice=voice, speed=speed, chime=chime, + user=me.name, subject=me.subject, method=me.method) + except ValueError as e: + abort(400, str(e)) + log.info("planlagt #%s kl. %s av %s: %s", item.id, + time.strftime("%Y-%m-%d %H:%M", time.localtime(at)), me.name or "anonym", text) + audit("schedule", **who(me), id=item.id, at=at, text=text, voice=voice, + speed=speed, chime=chime) + return jsonify(item.public()) + + +@app.post("/api/schedule/cancel") +def schedule_cancel() -> Response: + me = require("schedule") + data = request.get_json(silent=True) or {} + sched_id = str(data.get("id", "")).strip() + item = schedule.get(sched_id) + if item is None or item.status != "pending": + abort(404, "Fant ingen planlagt sending med den id-en") + # Sin egen kan man alltid avbryte. Andres krever den samme retten som + # å tømme køen – det er samme slags inngrep i noe andre har satt i gang. + if item.subject != me.subject and not me.can("clear"): + abort(403, f"{item.user or 'En annen'} planla denne. Du kan bare avbryte dine egne.") + schedule.cancel(sched_id) + audit("schedule_cancel", **who(me), id=sched_id, text=item.text) + return jsonify({"ok": True, "id": sched_id}) + + @app.post("/api/stop") def stop() -> Response: me = require("stop") @@ -589,7 +682,11 @@ def status() -> Response: # Lukket bak «view»: køen inneholder meldingstekstene og IP-ene til dem # som har sendt noe, og det er ikke noe alle utenfra skal få se. me = require("view") - return jsonify(player.snapshot(show_ip=me.can("clear"))) + return jsonify({ + **player.snapshot(show_ip=me.can("clear")), + "scheduled": schedule.snapshot(), + "now": time.time(), # klienten regner «om 20 min» mot serverens klokke + }) @app.errorhandler(400) @@ -636,8 +733,9 @@ def main() -> None: if CFG.source: log.info("konfigurasjon fra %s", CFG.source) methods = ", ".join(m["method"] for m in AUTH.describe()["methods"]) or "åpen (ingen innlogging)" - log.info("klar på http://%s:%d (auth: %s, sink: %s)", - args.host, args.port, methods, SINK or "standard") + venter = len(schedule.snapshot()["pending"]) + log.info("klar på http://%s:%d (auth: %s, sink: %s, planlagt: %d)", + args.host, args.port, methods, SINK or "standard", venter) app.run(host=args.host, port=args.port, threaded=True) diff --git a/compose.test.yaml b/compose.test.yaml index d03d01d..3ef2adf 100644 --- a/compose.test.yaml +++ b/compose.test.yaml @@ -20,12 +20,15 @@ services: ANNOUNCER_CACHE: /data/cache ANNOUNCER_LOG: /data/logs/announcements.jsonl ANNOUNCER_STATE: /data/instance - ANNOUNCER_VOICES: /data/voices + # Gjenbruker stemmene i repoet (bind-mountet på /app), så testriggen + # ikke laster ned 670 MB til en gang. + ANNOUNCER_VOICES: /app/voices ANNOUNCER_CHIMES: /data/chimes ANNOUNCER_JWT_SECRET: test-hemmelighet-kun-for-docker-ikke-bruk-ekte ANNOUNCER_OIDC_CLIENT_SECRET: test-client-secret-kun-for-docker ANNOUNCER_PUBLIC_URL: http://localhost:8091 XDG_RUNTIME_DIR: ${XDG_RUNTIME_DIR:-/run/user/1000} + TZ: ${TZ:-Europe/Oslo} volumes: - .:/app - ./data:/data diff --git a/compose.yaml b/compose.yaml index 4524656..daa8d5e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -32,6 +32,10 @@ services: ANNOUNCER_OIDC_CLIENT_SECRET: ${ANNOUNCER_OIDC_CLIENT_SECRET:-} ANNOUNCER_SECRET_KEY: ${ANNOUNCER_SECRET_KEY:-} XDG_RUNTIME_DIR: ${XDG_RUNTIME_DIR:-/run/user/1000} + # Planlagte sendinger lagres som absolutt tid, så TZ påvirker dem ikke. + # Men uten den står loggen i UTC mens du står i CEST, og da blir det + # vanskelig å se om noe gikk som det skulle. + TZ: ${TZ:-Europe/Oslo} volumes: # Stemmer, buffer, logg, sesjonsnøkkel, egne jingler og config.toml. diff --git a/config.example.toml b/config.example.toml index 2a0969d..c30de33 100644 --- a/config.example.toml +++ b/config.example.toml @@ -44,6 +44,16 @@ behind_tls = false # log = "logs/announcements.jsonl" # state = "instance" +[schedule] +# Slingringsmonn etter planlagt tid. Tråden våkner med noen sekunders +# mellomrom, og en kjapp omstart skal ikke miste en sending. Mer enn dette +# for sent regnes som gått glipp av, og sendes *ikke* – et utrop om noe som +# skjedde for en time siden er feil å spille. +grace_seconds = 120 +# Fanger opp tastefeil i årstall. +max_days_ahead = 30 +tick_seconds = 20 + [audio] # pw-play --target / paplay --device. Tom = systemets standardutgang. # Finn navnet med: pactl list short sinks @@ -112,6 +122,7 @@ label = "Logg inn med crew-kontoen" # view se køen og hva som spilles nå # preview forhåndslytte (synteser lyd) # announce sende ut på anlegget +# schedule planlegge en sending fram i tid, og avbryte sine egne # stop stoppe det som spilles # clear tømme køen # "*" betyr alt. @@ -119,5 +130,5 @@ label = "Logg inn med crew-kontoen" [roles] # Alle som ikke er innlogget. ["view"] er nyttig for en statusskjerm på veggen. anonymous = [] -crew = ["view", "preview", "announce"] +crew = ["view", "preview", "announce", "schedule"] admin = ["*"] diff --git a/config.py b/config.py index 9a74a19..5cfe9cf 100644 --- a/config.py +++ b/config.py @@ -26,7 +26,7 @@ from typing import Any, Callable HERE = Path(__file__).resolve().parent # Alt man kan få lov til å gjøre. Roller er navngitte knipper av disse. -ACTIONS = ("view", "preview", "announce", "stop", "clear") +ACTIONS = ("view", "preview", "announce", "schedule", "stop", "clear") DEFAULTS: dict[str, Any] = { "app": { @@ -46,6 +46,7 @@ DEFAULTS: dict[str, Any] = { "chimes": str(HERE / "chimes"), "log": str(HERE / "logs" / "announcements.jsonl"), "state": str(HERE / "instance"), + "schedule": str(HERE / "data" / "scheduled.json"), }, "audio": { # pw-play --target / paplay --device. Tom = systemets standardutgang. @@ -61,6 +62,16 @@ DEFAULTS: dict[str, Any] = { # Full URL utenfra, trengs for OIDC redirect_uri bak en proxy. "public_url": "", }, + "schedule": { + # Slingringsmonn etter planlagt tid. Tråden våkner med noen sekunders + # mellomrom, og en kjapp omstart skal ikke miste en sending. Mer enn + # dette for sent regnes som gått glipp av, og sendes ikke – et utrop + # om noe som skjedde for en time siden er feil å spille. + "grace_seconds": 120, + "max_days_ahead": 30, + # Hvor ofte vi ser etter forfalte sendinger. + "tick_seconds": 20, + }, "auth": { # Tom liste = alt åpent. Se active_providers(): en leverandør er også # aktiv når den er ferdig konfigurert, så det holder å sette @@ -105,7 +116,7 @@ DEFAULTS: dict[str, Any] = { }, "roles": { "anonymous": [], - "crew": ["view", "preview", "announce"], + "crew": ["view", "preview", "announce", "schedule"], "admin": ["*"], }, } @@ -131,6 +142,7 @@ ENV: dict[str, tuple[tuple[str, str], Callable[[str], Any]]] = { "ANNOUNCER_CACHE": (("paths", "cache"), str), "ANNOUNCER_CHIMES": (("paths", "chimes"), str), "ANNOUNCER_LOG": (("paths", "log"), str), + "ANNOUNCER_SCHEDULE": (("paths", "schedule"), str), "ANNOUNCER_STATE": (("paths", "state"), str), "ANNOUNCER_SINK": (("audio", "sink"), str), "ANNOUNCER_HOST": (("server", "host"), str), diff --git a/docker/config.test.toml b/docker/config.test.toml index cb911f1..054106c 100644 --- a/docker/config.test.toml +++ b/docker/config.test.toml @@ -35,5 +35,5 @@ token_endpoint = "http://idp:9000/oauth/token" [roles] anonymous = ["view"] -crew = ["view", "preview", "announce"] +crew = ["view", "preview", "announce", "schedule"] admin = ["*"] diff --git a/docs/crew-integration.md b/docs/crew-integration.md index 752a3b0..0d961b5 100644 --- a/docs/crew-integration.md +++ b/docs/crew-integration.md @@ -167,13 +167,14 @@ Fem handlinger. `config.toml` kobler rollenavn til dem: | `view` | se køen og hva som spilles nå | | `preview` | forhåndslytte (koster en syntese) | | `announce` | sende ut på anlegget | +| `schedule` | planlegge en sending fram i tid, og avbryte sine egne | | `stop` | stoppe det som spilles | | `clear` | tømme køen (og se IP-adresser i køen) | ```toml [roles] anonymous = [] # ["view"] for en statusskjerm -crew = ["view", "preview", "announce"] +crew = ["view", "preview", "announce", "schedule"] admin = ["*"] ``` diff --git a/index.html b/index.html index b2dcbb8..665f0dc 100644 --- a/index.html +++ b/index.html @@ -64,6 +64,18 @@ #pinbox input { background: var(--panel2); color: var(--fg); border: 1px solid var(--line); border-radius: 8px; padding: 10px; font: inherit; width: 100%; } .hint { color: var(--muted); font-size: 12px; margin-top: 6px; } .hidden { display: none !important; } + .when { display: flex; gap: 8px; align-items: center; margin-top: 12px; flex-wrap: wrap; } + .when input[type=datetime-local] { background: var(--panel2); color: var(--fg); border: 1px solid var(--line); + border-radius: 8px; padding: 10px; font: inherit; } + .when button { flex: 0 1 auto; padding: 10px 14px; font-size: 14px; } + .when .q { display: flex; gap: 6px; } + .when .q button { background: var(--panel2); border: 1px solid var(--line); color: var(--fg); + border-radius: 999px; padding: 6px 10px; font: inherit; font-size: 13px; cursor: pointer; } + .when .q button:hover { border-color: var(--accent); } + ul.list li button.x { background: none; border: none; color: var(--muted); cursor: pointer; + font: inherit; font-size: 13px; padding: 0 4px; } + ul.list li button.x:hover { color: var(--danger); } + ul.list li.cancelled .s, ul.list li.missed .s { color: var(--warn); } @@ -112,10 +124,20 @@ +
+ +
+ +
+ +

Anlegget

@@ -138,7 +160,7 @@ const $ = s => document.querySelector(s); const text = $('#text'), voice = $('#voice'), speed = $('#speed'), chime = $('#chime'); const msg = $('#msg'), audio = $('#player'), btnPreview = $('#btnPreview'), btnAnnounce = $('#btnAnnounce'); -let cfg = {}, can = [], previewedKey = null, pollTimer = null; +let cfg = {}, can = [], previewedKey = null, pollTimer = null, me = null, skew = 0; function key() { return JSON.stringify(payload()); } function payload() { @@ -161,6 +183,44 @@ function updateAnnounce() { (btnAnnounce.disabled ? 'Forhåndslytt først' : ''); btnPreview.disabled = !may('preview'); btnPreview.title = may('preview') ? '' : 'Du har ikke lov til å forhåndslytte'; + + // Planlegging er låst til forhåndslytten på samme måte som «Send ut»: + // ingen skal legge noe i kalenderen de ikke har hørt. + const at = $('#at').value; + $('#whenbox').classList.toggle('hidden', !may('schedule')); + $('#btnSchedule').disabled = !ready || !at || !may('schedule'); + $('#btnSchedule').title = !at ? 'Velg tidspunkt' : + (!ready ? 'Forhåndslytt først' : ''); +} + +// Serverens klokke gjelder. Containeren kan stå i UTC mens nettleseren står +// i CEST, og da ville «om 20 minutter» blitt to timer feil. +function serverNow() { return Date.now() / 1000 + skew; } + +function atEpoch() { + const v = $('#at').value; + if (!v) return null; + const t = new Date(v).getTime(); // datetime-local tolkes lokalt + return Number.isFinite(t) ? t / 1000 : null; +} + +function omTid(ts) { + const s = ts - serverNow(); + if (s < 0) return 'nå'; + if (s < 90) return 'om ' + Math.round(s) + ' s'; + if (s < 5400) return 'om ' + Math.round(s / 60) + ' min'; + if (s < 86400) return 'om ' + (s / 3600).toFixed(1).replace('.0', '') + ' t'; + return 'om ' + Math.round(s / 86400) + ' d'; +} + +function setAtIn(minutes) { + const d = new Date(Date.now() + minutes * 60000); + d.setSeconds(0, 0); + // datetime-local vil ha lokal tid uten sone, ikke ISO i UTC. + const pad = v => String(v).padStart(2, '0'); + $('#at').value = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + + `T${pad(d.getHours())}:${pad(d.getMinutes())}`; + updateAnnounce(); } async function req(path, opts) { @@ -250,6 +310,7 @@ function applyAuth(auth) { if (!$('#pin').value) $('#pin').value = localStorage.getItem('pin') || ''; } + me = auth.user; $('#btnStop').classList.toggle('hidden', !may('stop')); $('#btnClear').classList.toggle('hidden', !may('clear')); updateAnnounce(); @@ -281,10 +342,42 @@ async function init() { if (!chime.value) chime.value = c.default_chime; applyAuth(c.auth || {}); + $('#atquick').replaceChildren(...[ + ['+5 min', 5], ['+15 min', 15], ['+1 t', 60], ['+3 t', 180], + ].map(([label, min]) => { + const b = document.createElement('button'); + b.textContent = label; b.onclick = () => setAtIn(min); + return b; + })); poll(); pollTimer = setInterval(poll, 2000); } +$('#at').addEventListener('input', updateAnnounce); + +$('#btnSchedule').addEventListener('click', async () => { + const at = atEpoch(); + if (!at) return say('Velg et tidspunkt først.', 'err'); + if (at < serverNow()) return say('Tidspunktet er passert.', 'err'); + $('#btnSchedule').disabled = true; + try { + const item = await (await api('/api/schedule', { ...payload(), at })).json(); + say(`Planlagt ${new Date(item.at * 1000).toLocaleString('no', { + weekday: 'short', hour: '2-digit', minute: '2-digit' })}.`, 'ok'); + text.value = ''; text.dispatchEvent(new Event('input')); previewedKey = null; + $('#at').value = ''; + audio.pause(); audio.classList.remove('show'); + poll(); + } catch (e) { onError(e); } + finally { updateAnnounce(); } +}); + +async function cancelSched(id, what) { + if (!confirm(`Avbryte den planlagte meldingen?\n\n${what}`)) return; + try { await api('/api/schedule/cancel', { id }); say('Avbrutt.', 'ok'); poll(); } + catch (e) { onError(e); } +} + let pinDebounce = null; $('#pin').addEventListener('input', e => { localStorage.setItem('pin', e.target.value); @@ -341,10 +434,40 @@ function li(it) { l.querySelector('.s').textContent = (STATUS[it.status] || it.status) + (it.error ? ' · ' + it.error : ''); return l; } +const SCHED_STATUS = { sent: 'sendt', missed: 'gikk glipp av', cancelled: 'avbrutt' }; +function schedLi(it) { + const l = document.createElement('li'); + l.className = it.status; + l.innerHTML = ''; + const when = new Date(it.at * 1000); + l.querySelector('.t').textContent = when.toLocaleTimeString('no', { hour: '2-digit', minute: '2-digit' }); + l.querySelector('.x').textContent = it.text; + l.querySelector('.by').textContent = it.user || ''; + const s = l.querySelector('.s'); + if (it.status === 'pending') { + s.textContent = omTid(it.at) + ' '; + // Egne kan man alltid avbryte; andres krever samme rett som å tømme kø. + if (may('schedule') && (!me || !it.user || may('clear') || it.user === me.name)) { + const b = document.createElement('button'); + b.className = 'x'; b.textContent = '✕'; b.title = 'Avbryt'; + b.onclick = () => cancelSched(it.id, it.text); + s.append(b); + } + } else { + s.textContent = (SCHED_STATUS[it.status] || it.status) + (it.note ? ' · ' + it.note : ''); + } + return l; +} + async function poll() { const now = $('#now'); try { const s = await (await req('/api/status', { headers: headers(false) })).json(); + if (s.now) skew = s.now - Date.now() / 1000; + const sc = s.scheduled || { pending: [], done: [] }; + const rows = [...sc.pending, ...sc.done]; + $('#schedcard').classList.toggle('hidden', rows.length === 0); + $('#sched').replaceChildren(...rows.map(schedLi)); if (s.current) { now.className = 'now'; now.innerHTML = ''; now.append(document.createTextNode(s.current.text)); } else { now.className = 'now idle'; now.textContent = (cfg.app && cfg.app.idle_text) || ''; } $('#queue').replaceChildren(...s.queued.map(li)); diff --git a/install.sh b/install.sh index e006e49..2f7aed7 100755 --- a/install.sh +++ b/install.sh @@ -84,6 +84,7 @@ PUID=$(id -u) PGID=$(id -g) XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)} BIND_IP=$BIND_IP +TZ=$(timedatectl show -p Timezone --value 2>/dev/null || cat /etc/timezone 2>/dev/null || echo Europe/Oslo) ANNOUNCER_PORT=${ANNOUNCER_PORT:-8090} ANNOUNCER_SINK=${ANNOUNCER_SINK:-} ANNOUNCER_TITLE=${ANNOUNCER_TITLE:-} diff --git a/schedule.py b/schedule.py new file mode 100644 index 0000000..04dbcea --- /dev/null +++ b/schedule.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Planlagte sendinger: «send denne kl. 18:00». + +Dette er den første delen av appen som *må* ligge på disk. Kø og historikk +har alltid bare levd i minnet, og det har gått bra fordi en melding spilles +med en gang. En melding planlagt til 18:00 må overleve at containeren +startes om 17:45, så den lagres i en JSON-fil som skrives atomisk. + +Ingen database. Dette er en håndfull meldinger på et arrangement, og en fil +man kan lese med `cat` er verdt mer her enn indekser. + +Modulen vet ingenting om Flask eller om avspilling. Den lagrer, og den svarer +på «hva skal sendes nå?». announcer.py eier tråden som spør. +""" + +from __future__ import annotations + +import json +import logging +import secrets +import threading +import time +from dataclasses import asdict, dataclass, field +from pathlib import Path + +log = logging.getLogger("announcer.schedule") + +# Hvor lenge etter planlagt tid vi fortsatt sender. Dette er slingringsmonn +# for at tråden våkner med noen sekunders mellomrom og for en kjapp omstart – +# ikke en policy om å ta igjen etterslep. Var maskina av en time, skal +# «turneringen starter om ti minutter» ikke ut på anlegget. +DEFAULT_GRACE = 120.0 + +# Hvor langt fram man får planlegge. Fanger opp tastefeil i årstall. +DEFAULT_MAX_AHEAD = 30 * 86400.0 + +# Hvor mange ferdige (sendt/hoppet over/avbrutt) vi beholder for visning. +KEEP_DONE = 50 + +STATUSES = ("pending", "sent", "missed", "cancelled") + + +@dataclass +class Scheduled: + id: str + at: float # epoch, absolutt – derfor ingen tidssone + text: str + voice: str + speed: float + chime: str + user: str = "" + subject: str = "" + method: str = "none" + created: float = field(default_factory=time.time) + status: str = "pending" # pending | sent | missed | cancelled + note: str = "" + + def public(self) -> dict: + d = asdict(self) + d.pop("subject", None) + return d + + +class Schedule: + """Trådsikker liste av planlagte meldinger, lagret i én JSON-fil.""" + + def __init__(self, path: Path, *, grace: float = DEFAULT_GRACE, + max_ahead: float = DEFAULT_MAX_AHEAD) -> None: + self.path = Path(path) + self.grace = float(grace) + self.max_ahead = float(max_ahead) + self._lock = threading.Lock() + self.items: list[Scheduled] = [] + self._load() + + # -- disk -------------------------------------------------------------- + def _load(self) -> None: + if not self.path.is_file(): + return + try: + raw = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, ValueError) as e: + # En ødelagt fil skal ikke hindre appen fra å starte; da er det + # bedre å miste planene og si det i loggen. + log.warning("kunne ikke lese %s (%s) – starter med tom liste", self.path, e) + return + known = {f for f in Scheduled.__dataclass_fields__} + for entry in raw.get("items", []): + if not isinstance(entry, dict): + continue + try: + self.items.append(Scheduled(**{k: v for k, v in entry.items() if k in known})) + except TypeError as e: + log.warning("hopper over ugyldig oppføring (%s)", e) + log.info("leste %d planlagte sendinger fra %s", len(self.items), self.path) + + def _save(self) -> None: + """Skriv til side og flytt på plass, så fila aldri er halvskrevet.""" + try: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_suffix(".tmp") + tmp.write_text( + json.dumps({"items": [asdict(i) for i in self.items]}, + ensure_ascii=False, indent=1), + encoding="utf-8", + ) + tmp.replace(self.path) + except OSError as e: + log.error("fikk ikke lagret %s: %s", self.path, e) + + def _trim(self) -> None: + done = [i for i in self.items if i.status != "pending"] + if len(done) > KEEP_DONE: + drop = {id(i) for i in sorted(done, key=lambda i: i.at)[:len(done) - KEEP_DONE]} + self.items = [i for i in self.items if id(i) not in drop] + + # -- endringer --------------------------------------------------------- + def add(self, *, at: float, text: str, voice: str, speed: float, chime: str, + user: str = "", subject: str = "", method: str = "none") -> Scheduled: + """Legger til en melding. Kaster ValueError på et ubrukelig tidspunkt.""" + at = float(at) + now = time.time() + if at < now - self.grace: + raise ValueError("Tidspunktet er passert") + if at > now + self.max_ahead: + raise ValueError(f"Mer enn {int(self.max_ahead / 86400)} dager fram i tid") + item = Scheduled( + id=secrets.token_hex(4), at=at, text=text, voice=voice, speed=speed, + chime=chime, user=user, subject=subject, method=method, + ) + with self._lock: + self.items.append(item) + self._trim() + self._save() + return item + + def cancel(self, sched_id: str) -> Scheduled | None: + with self._lock: + for i in self.items: + if i.id == sched_id and i.status == "pending": + i.status = "cancelled" + self._trim() + self._save() + return i + return None + + def get(self, sched_id: str) -> Scheduled | None: + with self._lock: + return next((i for i in self.items if i.id == sched_id), None) + + def mark(self, sched_id: str, status: str, note: str = "") -> None: + assert status in STATUSES, status + with self._lock: + for i in self.items: + if i.id == sched_id: + i.status = status + i.note = note + # Også her, ikke bare i add(): ellers vokser lista av + # ferdige ubegrenset så lenge ingen legger til noe nytt. + self._trim() + self._save() + return + + # -- oppslag ----------------------------------------------------------- + def due(self, now: float | None = None) -> tuple[list[Scheduled], list[Scheduled]]: + """Returnerer (skal sendes nå, for sent – hopp over). + + Kaller ikke _save(); announcer.py markerer dem etterpå, så en melding + ikke kan bli stående som «sendt» uten at den faktisk ble lagt i kø. + """ + now = time.time() if now is None else now + send: list[Scheduled] = [] + missed: list[Scheduled] = [] + with self._lock: + for i in self.items: + if i.status != "pending" or i.at > now: + continue + (send if now - i.at <= self.grace else missed).append(i) + return send, missed + + def snapshot(self) -> dict: + with self._lock: + pending = sorted((i for i in self.items if i.status == "pending"), + key=lambda i: i.at) + done = sorted((i for i in self.items if i.status != "pending"), + key=lambda i: i.at, reverse=True)[:15] + return { + "pending": [i.public() for i in pending], + "done": [i.public() for i in done], + } diff --git a/tests/test_auth.py b/tests/test_auth.py index 199974a..6ffef76 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -358,12 +358,13 @@ def test_nestet_claim_hentes(): def test_rolle_til_handling(): cfg = config.load("/finnes-ikke.toml") assert cfg.permissions(("admin",)) == frozenset(config.ACTIONS) - assert cfg.permissions(("crew",)) == frozenset({"view", "preview", "announce"}) + assert cfg.permissions(("crew",)) == frozenset({"view", "preview", "announce", "schedule"}) assert cfg.permissions(("ukjent-rolle",)) == frozenset() assert cfg.permissions(()) == frozenset() # Roller legges sammen cfg.data["roles"]["bare_stopp"] = ["stop"] - assert cfg.permissions(("crew", "bare_stopp")) == frozenset({"view", "preview", "announce", "stop"}) + assert cfg.permissions(("crew", "bare_stopp")) == frozenset( + {"view", "preview", "announce", "schedule", "stop"}) def test_ukjente_handlinger_i_roles_ignoreres(): diff --git a/tests/test_schedule.py b/tests/test_schedule.py new file mode 100644 index 0000000..3890d92 --- /dev/null +++ b/tests/test_schedule.py @@ -0,0 +1,235 @@ +"""Tester for planlagte sendinger. + +Det som er verdt å prøve her er ikke at en melding kan legges i en liste, men +de to tingene som faktisk kan svikte: at lista overlever en omstart, og at en +melding som skulle gått for en time siden *ikke* spilles når maskina kommer +tilbake. +""" + +from __future__ import annotations + +import json +import sys +import time +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import schedule as sched # noqa: E402 + + +def mk(tmp_path: Path, **kw) -> sched.Schedule: + return sched.Schedule(tmp_path / "scheduled.json", **kw) + + +def add(s: sched.Schedule, *, om: float = 3600, text: str = "melding", **kw): + """Legger til en melding «om» så mange sekunder.""" + return s.add(at=time.time() + om, text=text, voice="finn", speed=1.0, + chime="none", **kw) + + +# -------------------------------------------------------------------------- +# Lagring – grunnen til at modulen finnes +# -------------------------------------------------------------------------- + + +def test_planen_overlever_omstart(tmp_path): + s = mk(tmp_path) + item = add(s, om=1800, text="Pizzaen kommer", user="Kari", subject="jwt:7") + # Ny instans, som etter en restart av containeren + s2 = mk(tmp_path) + assert len(s2.items) == 1 + got = s2.items[0] + assert (got.id, got.text, got.user, got.subject) == (item.id, "Pizzaen kommer", "Kari", "jwt:7") + assert got.status == "pending" + + +def test_fila_er_lesbar_json(tmp_path): + """Poenget med fil framfor database: man skal kunne cat'e den.""" + s = mk(tmp_path) + add(s, text="Hallo") + raw = json.loads((tmp_path / "scheduled.json").read_text()) + assert raw["items"][0]["text"] == "Hallo" + + +def test_ingen_tmp_fil_ligger_igjen(tmp_path): + s = mk(tmp_path) + add(s) + assert not list(tmp_path.glob("*.tmp")), "atomisk skriving skal rydde etter seg" + + +def test_odelagt_fil_hindrer_ikke_oppstart(tmp_path): + (tmp_path / "scheduled.json").write_text("{dette er ikke json") + s = mk(tmp_path) # skal ikke kaste + assert s.items == [] + add(s, text="virker likevel") + assert len(mk(tmp_path).items) == 1 + + +def test_ukjente_felt_i_fila_ignoreres(tmp_path): + """Så en nyere versjon av fila ikke velter en eldre app.""" + (tmp_path / "scheduled.json").write_text(json.dumps({"items": [{ + "id": "abc", "at": time.time() + 60, "text": "x", "voice": "finn", + "speed": 1.0, "chime": "none", "noe_nytt": 42, + }]})) + s = mk(tmp_path) + assert len(s.items) == 1 and s.items[0].id == "abc" + + +def test_tom_mappe_er_greit(tmp_path): + s = mk(tmp_path / "finnes" / "ikke" / "enda") + add(s) + assert len(s.items) == 1 + + +# -------------------------------------------------------------------------- +# Validering +# -------------------------------------------------------------------------- + + +def test_tidspunkt_i_fortiden_avvises(tmp_path): + s = mk(tmp_path) + with pytest.raises(ValueError, match="passert"): + add(s, om=-3600) + + +def test_tidspunkt_rett_bak_oss_godtas(tmp_path): + """Innenfor nådefristen; ellers ville «send nå» vært en kappløpssituasjon.""" + s = mk(tmp_path, grace=120) + assert add(s, om=-30) is not None + + +def test_for_langt_fram_avvises(tmp_path): + s = mk(tmp_path, max_ahead=30 * 86400) + with pytest.raises(ValueError, match="dager"): + add(s, om=400 * 86400) # typisk tastefeil i årstall + + +# -------------------------------------------------------------------------- +# due(): skal den sendes, eller gikk den glipp av? +# -------------------------------------------------------------------------- + + +def test_ikke_forfalt_blir_liggende(tmp_path): + s = mk(tmp_path) + add(s, om=600) + send, missed = s.due() + assert (send, missed) == ([], []) + + +def test_forfalt_innenfor_nadefrist_sendes(tmp_path): + s = mk(tmp_path, grace=120) + item = add(s, om=600) + # Som om klokka har gått fram til litt etter planlagt tid. + send, missed = s.due(now=item.at + 30) + assert [i.id for i in send] == [item.id] + assert missed == [] + + +def test_for_sent_gar_glipp_av_og_spilles_ikke(tmp_path): + """Kjernen i det Kent valgte: hopp over, men vis det. + + Forløpet er: meldingen ble lagt inn i god tid, maskina var av da den + skulle gått, og nå er den tilbake en time for sent. «Turneringen starter + om ti minutter» skal ikke ut på anlegget da.""" + s = mk(tmp_path, grace=120) + item = add(s, om=600) + send, missed = s.due(now=item.at + 3600) + assert send == [] + assert [i.id for i in missed] == [item.id] + + +def test_due_endrer_ingenting_selv(tmp_path): + """announcer.py markerer først etter at meldingen faktisk er i kø, så en + melding ikke kan stå som «sendt» uten å ha blitt det.""" + s = mk(tmp_path) + item = add(s, om=600) + s.due(now=item.at + 10) + assert mk(tmp_path).items[0].status == "pending" + + +def test_avbrutt_forfaller_ikke(tmp_path): + s = mk(tmp_path) + item = add(s, om=600) + s.cancel(item.id) + assert s.due(now=item.at + 10) == ([], []) + + +def test_sendt_forfaller_ikke_igjen(tmp_path): + s = mk(tmp_path) + item = add(s, om=600) + s.mark(item.id, "sent", "kø #4") + assert s.due(now=item.at + 10) == ([], []) + + +# -------------------------------------------------------------------------- +# Avbryting og markering +# -------------------------------------------------------------------------- + + +def test_avbryting_lagres(tmp_path): + s = mk(tmp_path) + item = add(s) + assert s.cancel(item.id) is not None + assert mk(tmp_path).items[0].status == "cancelled" + + +def test_avbryting_to_ganger_gir_none(tmp_path): + s = mk(tmp_path) + item = add(s) + s.cancel(item.id) + assert s.cancel(item.id) is None + + +def test_avbryting_av_ukjent_id_gir_none(tmp_path): + assert mk(tmp_path).cancel("finnes-ikke") is None + + +def test_mark_lagres(tmp_path): + s = mk(tmp_path) + item = add(s) + s.mark(item.id, "missed", "maskina var av") + got = mk(tmp_path).items[0] + assert (got.status, got.note) == ("missed", "maskina var av") + + +# -------------------------------------------------------------------------- +# Visning +# -------------------------------------------------------------------------- + + +def test_snapshot_sorterer_ventende_kronologisk(tmp_path): + s = mk(tmp_path) + add(s, om=7200, text="sist") + add(s, om=600, text="først") + add(s, om=3600, text="midt") + assert [i["text"] for i in s.snapshot()["pending"]] == ["først", "midt", "sist"] + + +def test_snapshot_skiller_ventende_fra_ferdige(tmp_path): + s = mk(tmp_path) + a = add(s, om=600, text="venter") + add(s, om=1200, text="venter også") + s.mark(a.id, "sent") + snap = s.snapshot() + assert [i["text"] for i in snap["pending"]] == ["venter også"] + assert [i["text"] for i in snap["done"]] == ["venter"] + + +def test_subject_lekker_ikke_ut_til_klienten(tmp_path): + """Navnet skal vises, bruker-id-en er intern.""" + s = mk(tmp_path) + add(s, user="Kari", subject="jwt:7") + pub = s.snapshot()["pending"][0] + assert pub["user"] == "Kari" + assert "subject" not in pub + + +def test_ferdige_hoper_seg_ikke_opp(tmp_path): + s = mk(tmp_path) + for i in range(sched.KEEP_DONE + 20): + item = add(s, om=600 + i, text=f"nr {i}") + s.mark(item.id, "sent") + assert len([i for i in s.items if i.status != "pending"]) == sched.KEEP_DONE