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 @@
+
+
+
+
+
+
+
Planlagt
+
+
+
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