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
104
announcer.py
104
announcer.py
|
|
@ -31,6 +31,7 @@ from flask import Flask, Response, abort, jsonify, request, send_file
|
|||
|
||||
import auth
|
||||
import config
|
||||
import schedule as sched
|
||||
from auth import require
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
|
@ -38,6 +39,7 @@ CFG = config.load()
|
|||
VOICE_DIR = CFG.path("voices")
|
||||
CACHE_DIR = CFG.path("cache")
|
||||
LOG_PATH = CFG.path("log")
|
||||
SCHEDULE_PATH = CFG.path("schedule")
|
||||
SINK = str(CFG.get("audio", "sink", "")).strip() # pw-play --target
|
||||
MAX_CHARS = int(CFG.app["max_chars"])
|
||||
|
||||
|
|
@ -443,6 +445,55 @@ class Player:
|
|||
player = Player()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Planlagte sendinger
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
schedule = sched.Schedule(
|
||||
SCHEDULE_PATH,
|
||||
grace=float(CFG.get("schedule", "grace_seconds", sched.DEFAULT_GRACE)),
|
||||
max_ahead=float(CFG.get("schedule", "max_days_ahead", 30)) * 86400,
|
||||
)
|
||||
TICK = max(5.0, float(CFG.get("schedule", "tick_seconds", 20)))
|
||||
|
||||
|
||||
def _scheduler() -> None:
|
||||
"""Flytter forfalte meldinger inn i den vanlige køen.
|
||||
|
||||
Egen tråd, og den gjør med vilje ingenting annet: avspilling, syntese og
|
||||
feilhåndtering er Players jobb, slik at en planlagt melding oppfører seg
|
||||
nøyaktig som en sendt for hånd når den først er i kø.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
send, missed = schedule.due()
|
||||
for item in missed:
|
||||
schedule.mark(item.id, "missed", "maskina var ikke i gang i tide")
|
||||
log.warning("planlagt #%s gikk glipp av (%s)", item.id, item.text[:40])
|
||||
audit("missed", id=item.id, subject=item.subject, name=item.user,
|
||||
method=item.method, text=item.text, at=item.at)
|
||||
for item in send:
|
||||
queued = player.enqueue(
|
||||
item.text, item.voice, item.speed, item.chime,
|
||||
sender="planlagt",
|
||||
who=auth.Principal(item.subject, item.user, (), item.method, frozenset()),
|
||||
)
|
||||
schedule.mark(item.id, "sent", f"kø #{queued.id}")
|
||||
log.info("planlagt #%s lagt i kø som #%d", item.id, queued.id)
|
||||
audit("scheduled_fire", id=item.id, queue_id=queued.id,
|
||||
subject=item.subject, name=item.user, method=item.method,
|
||||
text=item.text, voice=item.voice, at=item.at)
|
||||
except Exception: # noqa: BLE001
|
||||
# En feil her skal ikke drepe tråden; da ville alle senere
|
||||
# planlagte sendinger stilltiende blitt borte.
|
||||
log.exception("planleggeren feilet")
|
||||
time.sleep(TICK)
|
||||
|
||||
|
||||
threading.Thread(target=_scheduler, daemon=True, name="scheduler").start()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# HTTP
|
||||
# --------------------------------------------------------------------------
|
||||
|
|
@ -532,6 +583,10 @@ def client_config() -> Response:
|
|||
"chimes": list_chimes(),
|
||||
"default_chime": DEFAULT_CHIME,
|
||||
"max_chars": MAX_CHARS,
|
||||
"schedule": {
|
||||
"max_days_ahead": int(CFG.get("schedule", "max_days_ahead", 30)),
|
||||
"grace_seconds": int(CFG.get("schedule", "grace_seconds", 120)),
|
||||
},
|
||||
"auth": {
|
||||
**AUTH.describe(),
|
||||
"user": None if me.anonymous else me.public(),
|
||||
|
|
@ -568,6 +623,44 @@ def announce() -> Response:
|
|||
return jsonify(item.public(show_ip=me.can("clear")))
|
||||
|
||||
|
||||
@app.post("/api/schedule")
|
||||
def schedule_add() -> Response:
|
||||
me = require("schedule")
|
||||
text, voice, speed, chime = parse_request()
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
at = float(data["at"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
abort(400, "Mangler eller ugyldig tidspunkt")
|
||||
try:
|
||||
item = schedule.add(at=at, text=text, voice=voice, speed=speed, chime=chime,
|
||||
user=me.name, subject=me.subject, method=me.method)
|
||||
except ValueError as e:
|
||||
abort(400, str(e))
|
||||
log.info("planlagt #%s kl. %s av %s: %s", item.id,
|
||||
time.strftime("%Y-%m-%d %H:%M", time.localtime(at)), me.name or "anonym", text)
|
||||
audit("schedule", **who(me), id=item.id, at=at, text=text, voice=voice,
|
||||
speed=speed, chime=chime)
|
||||
return jsonify(item.public())
|
||||
|
||||
|
||||
@app.post("/api/schedule/cancel")
|
||||
def schedule_cancel() -> Response:
|
||||
me = require("schedule")
|
||||
data = request.get_json(silent=True) or {}
|
||||
sched_id = str(data.get("id", "")).strip()
|
||||
item = schedule.get(sched_id)
|
||||
if item is None or item.status != "pending":
|
||||
abort(404, "Fant ingen planlagt sending med den id-en")
|
||||
# Sin egen kan man alltid avbryte. Andres krever den samme retten som
|
||||
# å tømme køen – det er samme slags inngrep i noe andre har satt i gang.
|
||||
if item.subject != me.subject and not me.can("clear"):
|
||||
abort(403, f"{item.user or 'En annen'} planla denne. Du kan bare avbryte dine egne.")
|
||||
schedule.cancel(sched_id)
|
||||
audit("schedule_cancel", **who(me), id=sched_id, text=item.text)
|
||||
return jsonify({"ok": True, "id": sched_id})
|
||||
|
||||
|
||||
@app.post("/api/stop")
|
||||
def stop() -> Response:
|
||||
me = require("stop")
|
||||
|
|
@ -589,7 +682,11 @@ def status() -> Response:
|
|||
# Lukket bak «view»: køen inneholder meldingstekstene og IP-ene til dem
|
||||
# som har sendt noe, og det er ikke noe alle utenfra skal få se.
|
||||
me = require("view")
|
||||
return jsonify(player.snapshot(show_ip=me.can("clear")))
|
||||
return jsonify({
|
||||
**player.snapshot(show_ip=me.can("clear")),
|
||||
"scheduled": schedule.snapshot(),
|
||||
"now": time.time(), # klienten regner «om 20 min» mot serverens klokke
|
||||
})
|
||||
|
||||
|
||||
@app.errorhandler(400)
|
||||
|
|
@ -636,8 +733,9 @@ def main() -> None:
|
|||
if CFG.source:
|
||||
log.info("konfigurasjon fra %s", CFG.source)
|
||||
methods = ", ".join(m["method"] for m in AUTH.describe()["methods"]) or "åpen (ingen innlogging)"
|
||||
log.info("klar på http://%s:%d (auth: %s, sink: %s)",
|
||||
args.host, args.port, methods, SINK or "standard")
|
||||
venter = len(schedule.snapshot()["pending"])
|
||||
log.info("klar på http://%s:%d (auth: %s, sink: %s, planlagt: %d)",
|
||||
args.host, args.port, methods, SINK or "standard", venter)
|
||||
app.run(host=args.host, port=args.port, threaded=True)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue