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

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