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>
192 lines
7.4 KiB
Python
192 lines
7.4 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
|
||
|
||
# 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],
|
||
}
|