Planlagte sendinger

«Send denne kl. 18:00.» Engangs, ikke gjentakende.

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å schedule.py lagrer 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. En ødelagt fil
hindrer ikke oppstart; da er det bedre å miste planene og si det i loggen.

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.

Gikk den glipp av, spilles den ikke. due() skiller på grace_seconds (120):
innenfor den sendes meldingen, utenfor markeres den missed og vises som det.
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; markeringen skjer først etter at meldingen
faktisk er i kø, så ingenting kan stå som «sendt» uten å ha blitt det.

Klienten sender absolutt tidspunkt (epoch), så tidssoner spiller ingen rolle
for selve planleggingen. TZ settes likevel fra .env, ellers står loggen i UTC
mens man selv står i CEST.

Planlegging er låst til samme forhåndslytt-nøkkel som «Send ut»: man planlegger
bare det man har hørt. Det gir en bonus – forhåndslytt varmer cache/, så WAV-en
ligger ferdig på disk, og en planlagt edge-tts-melding spiller kl. 18:00 selv
om nettet er nede da.

Sine egne 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.

23 nye tester. Verifisert i container: sending fyrer og spiller, overlever en
omstart av containeren, og en melding som forfalt mens maskina var av blir
markert missed uten å bli spilt.

Merk ved oppgradering: schedule er en ny handling, og et oppsett med eksplisitt
[roles] i config.toml får den ikke automatisk. Den må legges inn selv.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kent 2026-10-08 16:29:16 +02:00
parent 6c1253f54c
commit 04b3712ef5
15 changed files with 746 additions and 15 deletions

View file

@ -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=

View file

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

View file

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

View file

@ -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)

View file

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

View file

@ -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.

View file

@ -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 = ["*"]

View file

@ -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),

View file

@ -35,5 +35,5 @@ token_endpoint = "http://idp:9000/oauth/token"
[roles]
anonymous = ["view"]
crew = ["view", "preview", "announce"]
crew = ["view", "preview", "announce", "schedule"]
admin = ["*"]

View file

@ -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 = ["*"]
```

View file

@ -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); }
</style>
</head>
<body>
@ -112,10 +124,20 @@
<button class="btn" id="btnPreview">▶ Forhåndslytt</button>
<button class="btn primary" id="btnAnnounce" disabled>📢 Send ut på anlegget</button>
</div>
<div class="when" id="whenbox">
<input id="at" type="datetime-local" step="60">
<div class="q" id="atquick"></div>
<button class="btn" id="btnSchedule" disabled>🕑 Planlegg</button>
</div>
<audio id="player" controls></audio>
<div class="msg" id="msg"></div>
</div>
<div class="card hidden" id="schedcard">
<div class="status-head"><h2>Planlagt</h2></div>
<ul class="list" id="sched"></ul>
</div>
<div class="card">
<div class="status-head">
<h2>Anlegget</h2>
@ -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 = '<span class="t"></span><span class="x"></span><span class="by"></span><span class="s"></span>';
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 = '<span class="pulse"></span>'; 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));

View file

@ -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:-}

190
schedule.py Normal file
View file

@ -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],
}

View file

@ -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():

235
tests/test_schedule.py Normal file
View file

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