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

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