announcer/schedule.py
Kent eb27a162d5 Gjennomgang: Stopp under syntese, planstatus, waitress, tester på HTTP-laget
Sju ting fra en gjennomgang av det som ble bygd i går.

Stopp virket ikke mens lyden ble laget. Player.stop() drepte self.proc, men
under syntesen finnes ingen prosess ennå – det er de første sekundene av hver
edge-tts-melding – så knappen gjorde ingenting, og meldingen gikk ut etterpå.
Nå har køen statusen preparing, stop() setter stopped på den, og run_once()
sjekker det før den starter avspilling. UI-et viser «Lager lyd …» i stedet
for å late som det spilles i stillhet.

Planlagte sendinger løy om status. De ble markert «sendt» idet de ble lagt i
kø, og ble stående slik selv om avspillingen feilet. Player har fått en
on_finished-callback, og planen følger nå meldingen helt i mål: sent, failed
eller stopped – det som faktisk skjedde. Statusene i schedule.py er utvidet
tilsvarende (queued er «lagt i kø», sent er «spilt ferdig»).

Flask sin utviklingsserver er byttet ut med waitress. Én prosess med mange
tråder, med vilje: Player og planleggeren er singletons, så gunicorn med
flere workere ville gitt flere køer som snakker i munnen på hverandre.

HTTP-laget hadde null tester. import announcer startet tråder og dro inn
Piper, så rutene lot seg ikke prøve. Trådene startes nå i main(), og
run_once() driver køen ett steg om gangen. tests/test_routes.py dekker
rutene, valideringen, køen, Stopp i begge faser, revisjonsloggen og hele
planleggingsflyten gjennom HTTP. 30 nye tester, 110 totalt.

Kappløp i synthesize(): tmp-fila hadde fast navn, så to som forhåndslyttet
samme tekst samtidig skrev til samme fil, og den ene rename feilet. Unik
tmp-fil per kall.

verify_signature = false er fjernet. Det var en felle: én feilskrevet linje
i config, og alle tokens ble godtatt. Ingen trenger den.

docker compose up uten install.sh krasjet kryptisk ved første utrop, fordi
Docker lagde data/ som root. check_writable() sier nå klart fra ved oppstart,
med hva som må gjøres.

Verifisert live i container: Stopp under edge-tts-syntese gir stopped, ikke
done. Server-headeren er waitress sin, ikke werkzeugs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 01:41:31 +02:00

192 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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
# pending -> queued (lagt i kø) -> sent | failed | stopped
# -> missed (forfalt mens maskina var av) -> cancelled (avbrutt)
STATUSES = ("pending", "queued", "sent", "failed", "stopped", "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" # se STATUSES
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],
}