«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>
190 lines
7.2 KiB
Python
190 lines
7.2 KiB
Python
#!/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],
|
||
}
|