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:
parent
6c1253f54c
commit
04b3712ef5
15 changed files with 746 additions and 15 deletions
190
schedule.py
Normal file
190
schedule.py
Normal 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],
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue