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>
This commit is contained in:
parent
04b3712ef5
commit
eb27a162d5
9 changed files with 565 additions and 81 deletions
19
AGENTS.md
19
AGENTS.md
|
|
@ -22,7 +22,7 @@ Filer:
|
||||||
| `index.html` | hele sida, inline CSS/JS, ingen byggesteg |
|
| `index.html` | hele sida, inline CSS/JS, ingen byggesteg |
|
||||||
| `config.example.toml` | kommentert mal; kopier til `config.toml` |
|
| `config.example.toml` | kommentert mal; kopier til `config.toml` |
|
||||||
| `docs/crew-integration.md` | kontrakten mot crew-panelet |
|
| `docs/crew-integration.md` | kontrakten mot crew-panelet |
|
||||||
| `tests/test_auth.py` + `test_schedule.py` | `uv run pytest` – 80 tester |
|
| `tests/` | `uv run pytest` – 110 tester: auth, planlegging og HTTP-laget |
|
||||||
| `Dockerfile` + `compose.yaml` | **drift** – appen kjøres i Docker |
|
| `Dockerfile` + `compose.yaml` | **drift** – appen kjøres i Docker |
|
||||||
| `.env.example` | bruker-ID, portbinding og hemmeligheter |
|
| `.env.example` | bruker-ID, portbinding og hemmeligheter |
|
||||||
| `compose.test.yaml` | testoppsett med dummy-IdP, se under |
|
| `compose.test.yaml` | testoppsett med dummy-IdP, se under |
|
||||||
|
|
@ -254,6 +254,12 @@ satt i gang.
|
||||||
- `/api/announce` legger meldingen i én FIFO-kø; en bakgrunnstråd spiller én om
|
- `/api/announce` legger meldingen i én FIFO-kø; en bakgrunnstråd spiller én om
|
||||||
gangen med `pw-play`. `/api/stop` dreper gjeldende avspilling, `/api/clear`
|
gangen med `pw-play`. `/api/stop` dreper gjeldende avspilling, `/api/clear`
|
||||||
tømmer køen.
|
tømmer køen.
|
||||||
|
- En melding går `queued → preparing → playing → done | stopped | failed`.
|
||||||
|
`preparing` er syntesen – de første sekundene av hver edge-tts-melding, der
|
||||||
|
det er stille. Stopp virker også der: `Player.stop()` setter statusen, og
|
||||||
|
`run_once()` sjekker den før den starter avspilling. En planlagt sending
|
||||||
|
følger med helt i mål via `on_finished`, så planen viser `sent`, `failed`
|
||||||
|
eller `stopped` – det som faktisk skjedde – ikke bare at den ble lagt i kø.
|
||||||
- `/api/status` krever `view`. Den var åpen før, og lekket både meldingstekster
|
- `/api/status` krever `view`. Den var åpen før, og lekket både meldingstekster
|
||||||
og IP-ene til dem som hadde sendt noe. IP vises nå bare for den som har
|
og IP-ene til dem som hadde sendt noe. IP vises nå bare for den som har
|
||||||
`clear`; alle andre ser navnet.
|
`clear`; alle andre ser navnet.
|
||||||
|
|
@ -301,9 +307,14 @@ Vil Kent ha enda bedre lyd: ElevenLabs (nøkkel, best norsk) eller OpenAI
|
||||||
- `pkill -f announcer.py` fra et skall som selv har `announcer.py` i
|
- `pkill -f announcer.py` fra et skall som selv har `announcer.py` i
|
||||||
kommandolinja dreper skallet. Gjelder utvikling med `uv run`; i drift er
|
kommandolinja dreper skallet. Gjelder utvikling med `uv run`; i drift er
|
||||||
det `docker compose`.
|
det `docker compose`.
|
||||||
- `player = Player()` står på modulnivå, så avspillingstråden starter ved
|
- Trådene (`player.start()`, `start_scheduler()`) startes i `main()`, ikke
|
||||||
`import announcer` – også i `install.sh`, som importerer modulen for å laste
|
ved import. Det er det som gjør at `tests/test_routes.py` kan kjøre rutene
|
||||||
ned stemmer. Harmløst, men greit å vite.
|
med en test_client og drive køen ett steg om gangen med
|
||||||
|
`player.run_once()`. Starter du dem ved import igjen, mister du den
|
||||||
|
muligheten.
|
||||||
|
- **Én prosess.** `main()` kjører waitress med mange tråder, ikke gunicorn
|
||||||
|
med flere workere: `Player` og planleggeren er singletons i prosessen, og
|
||||||
|
flere workere ville gitt flere køer som snakker i munnen på hverandre.
|
||||||
- **Oppgradering:** `schedule` er en ny handling. Har et oppsett et eksplisitt
|
- **Oppgradering:** `schedule` er en ny handling. Har et oppsett et eksplisitt
|
||||||
`[roles]` i `config.toml`, får `crew` den *ikke* automatisk – eksplisitt
|
`[roles]` i `config.toml`, får `crew` den *ikke* automatisk – eksplisitt
|
||||||
konfigurasjon overstyrer standardverdiene, og da blir svaret 403 «Rollen din
|
konfigurasjon overstyrer standardverdiene, og da blir svaret 403 «Rollen din
|
||||||
|
|
|
||||||
158
announcer.py
158
announcer.py
|
|
@ -298,13 +298,19 @@ def synthesize(text: str, voice_name: str, speed: float, chime: str) -> Path:
|
||||||
pcm += chunk.audio_int16_bytes
|
pcm += chunk.audio_int16_bytes
|
||||||
pcm += b"\x00\x00" * int(rate * 0.3)
|
pcm += b"\x00\x00" * int(rate * 0.3)
|
||||||
|
|
||||||
tmp = path.with_suffix(".tmp")
|
# Unik tmp-fil per kall. Med et fast navn ville to som forhåndslytter
|
||||||
|
# samme tekst samtidig skrevet til samme fil, og den ene rename feilet.
|
||||||
|
# Taper den ene kappløpet om selve path, er innholdet uansett likt.
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".tmp", dir=CACHE_DIR, delete=False) as fh:
|
||||||
|
tmp = Path(fh.name)
|
||||||
with wave.open(str(tmp), "wb") as w:
|
with wave.open(str(tmp), "wb") as w:
|
||||||
w.setnchannels(1)
|
w.setnchannels(1)
|
||||||
w.setsampwidth(2)
|
w.setsampwidth(2)
|
||||||
w.setframerate(rate)
|
w.setframerate(rate)
|
||||||
w.writeframes(bytes(pcm))
|
w.writeframes(bytes(pcm))
|
||||||
tmp.rename(path)
|
os.replace(tmp, path)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -325,8 +331,14 @@ class Item:
|
||||||
subject: str = "" # stabil id, f.eks. "jwt:kent"
|
subject: str = "" # stabil id, f.eks. "jwt:kent"
|
||||||
method: str = "none"
|
method: str = "none"
|
||||||
created: float = field(default_factory=time.time)
|
created: float = field(default_factory=time.time)
|
||||||
status: str = "queued" # queued | playing | done | stopped | failed
|
# queued -> preparing (syntese) -> playing -> done | stopped | failed
|
||||||
|
status: str = "queued"
|
||||||
error: str = ""
|
error: str = ""
|
||||||
|
schedule_id: str = "" # satt når meldingen kom fra planleggeren
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active(self) -> bool:
|
||||||
|
return self.status in ("preparing", "playing")
|
||||||
|
|
||||||
def public(self, *, show_ip: bool = False) -> dict:
|
def public(self, *, show_ip: bool = False) -> dict:
|
||||||
return {
|
return {
|
||||||
|
|
@ -360,17 +372,32 @@ def player_cmd(path: Path) -> list[str]:
|
||||||
|
|
||||||
|
|
||||||
class Player:
|
class Player:
|
||||||
def __init__(self) -> None:
|
"""Én FIFO-kø, én avspilling om gangen.
|
||||||
|
|
||||||
|
Tråden startes ikke her, men i start(), som main() kaller. Da kan rutene
|
||||||
|
testes med en test_client uten at noe begynner å spille, og run_once()
|
||||||
|
lar en test kjøre køen ett steg om gangen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, on_finished=None) -> None: # noqa: ANN001
|
||||||
self.q: queue.Queue[Item] = queue.Queue()
|
self.q: queue.Queue[Item] = queue.Queue()
|
||||||
self.history: list[Item] = []
|
self.history: list[Item] = []
|
||||||
self.current: Item | None = None
|
self.current: Item | None = None
|
||||||
self.proc: subprocess.Popen | None = None
|
self.proc: subprocess.Popen | None = None
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
self.seq = 0
|
self.seq = 0
|
||||||
threading.Thread(target=self._loop, daemon=True, name="player").start()
|
# Kalles med hvert ferdige Item (done/stopped/failed). Det er slik
|
||||||
|
# planleggeren får vite hvordan det gikk, uten at Player vet om den.
|
||||||
|
self.on_finished = on_finished
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._thread is None:
|
||||||
|
self._thread = threading.Thread(target=self._loop, daemon=True, name="player")
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
def enqueue(self, text: str, voice: str, speed: float, chime: str, sender: str,
|
def enqueue(self, text: str, voice: str, speed: float, chime: str, sender: str,
|
||||||
who: auth.Principal | None = None) -> Item:
|
who: auth.Principal | None = None, schedule_id: str = "") -> Item:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.seq += 1
|
self.seq += 1
|
||||||
item = Item(
|
item = Item(
|
||||||
|
|
@ -378,6 +405,7 @@ class Player:
|
||||||
user=who.name if who else "",
|
user=who.name if who else "",
|
||||||
subject=who.subject if who else "",
|
subject=who.subject if who else "",
|
||||||
method=who.method if who else "none",
|
method=who.method if who else "none",
|
||||||
|
schedule_id=schedule_id,
|
||||||
)
|
)
|
||||||
self.history.append(item)
|
self.history.append(item)
|
||||||
del self.history[:-50]
|
del self.history[:-50]
|
||||||
|
|
@ -385,11 +413,18 @@ class Player:
|
||||||
return item
|
return item
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
|
"""Stopper det som spilles – eller det som fortsatt lages.
|
||||||
|
|
||||||
|
Under syntesen finnes det ingen prosess å drepe ennå; det er de første
|
||||||
|
sekundene av hver edge-tts-melding. Før var Stopp uten virkning da,
|
||||||
|
og meldingen gikk ut etterpå. Nå settes statusen, og _loop sjekker
|
||||||
|
den før den starter avspilling.
|
||||||
|
"""
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
if self.current and self.current.active:
|
||||||
|
self.current.status = "stopped"
|
||||||
if self.proc and self.proc.poll() is None:
|
if self.proc and self.proc.poll() is None:
|
||||||
self.proc.terminate()
|
self.proc.terminate()
|
||||||
if self.current:
|
|
||||||
self.current.status = "stopped"
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
while True:
|
while True:
|
||||||
|
|
@ -406,22 +441,32 @@ class Player:
|
||||||
"current": self.current.public(show_ip=show_ip) if self.current else None,
|
"current": self.current.public(show_ip=show_ip) if self.current else None,
|
||||||
"queued": [i.public(show_ip=show_ip) for i in self.history if i.status == "queued"],
|
"queued": [i.public(show_ip=show_ip) for i in self.history if i.status == "queued"],
|
||||||
"history": [i.public(show_ip=show_ip) for i in reversed(self.history)
|
"history": [i.public(show_ip=show_ip) for i in reversed(self.history)
|
||||||
if i.status not in ("queued", "playing")][:15],
|
if not i.active and i.status != "queued"][:15],
|
||||||
}
|
}
|
||||||
|
|
||||||
def _loop(self) -> None:
|
def _loop(self) -> None:
|
||||||
while True:
|
while True:
|
||||||
item = self.q.get()
|
self.run_once()
|
||||||
|
|
||||||
|
def run_once(self, timeout: float | None = None) -> Item | None:
|
||||||
|
"""Tar neste melding fra køen og spiller den ferdig. Returnerer den."""
|
||||||
|
try:
|
||||||
|
item = self.q.get(timeout=timeout)
|
||||||
|
except queue.Empty:
|
||||||
|
return None
|
||||||
if item.status != "queued":
|
if item.status != "queued":
|
||||||
continue
|
return item
|
||||||
item.status = "playing"
|
item.status = "preparing"
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.current = item
|
self.current = item
|
||||||
try:
|
try:
|
||||||
path = synthesize(item.text, item.voice, item.speed, item.chime)
|
path = synthesize(item.text, item.voice, item.speed, item.chime)
|
||||||
cmd = player_cmd(path)
|
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
if item.status != "preparing":
|
||||||
|
return item # stoppet mens lyden ble laget
|
||||||
|
item.status = "playing"
|
||||||
|
self.proc = subprocess.Popen(player_cmd(path), stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE)
|
||||||
_, err = self.proc.communicate()
|
_, err = self.proc.communicate()
|
||||||
if item.status == "playing":
|
if item.status == "playing":
|
||||||
if self.proc.returncode == 0:
|
if self.proc.returncode == 0:
|
||||||
|
|
@ -440,9 +485,12 @@ class Player:
|
||||||
audit("played", id=item.id, status=item.status, error=item.error,
|
audit("played", id=item.id, status=item.status, error=item.error,
|
||||||
subject=item.subject, name=item.user, method=item.method,
|
subject=item.subject, name=item.user, method=item.method,
|
||||||
ip=item.sender, text=item.text, voice=item.voice)
|
ip=item.sender, text=item.text, voice=item.voice)
|
||||||
|
if self.on_finished:
|
||||||
|
try:
|
||||||
player = Player()
|
self.on_finished(item)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.exception("on_finished feilet")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
@ -458,15 +506,31 @@ schedule = sched.Schedule(
|
||||||
TICK = max(5.0, float(CFG.get("schedule", "tick_seconds", 20)))
|
TICK = max(5.0, float(CFG.get("schedule", "tick_seconds", 20)))
|
||||||
|
|
||||||
|
|
||||||
def _scheduler() -> None:
|
def _scheduled_finished(item: Item) -> None:
|
||||||
"""Flytter forfalte meldinger inn i den vanlige køen.
|
"""Players on_finished: lar planen følge meldingen helt i mål.
|
||||||
|
|
||||||
Egen tråd, og den gjør med vilje ingenting annet: avspilling, syntese og
|
Før sto en planlagt sending som «sendt» i det den ble lagt i kø, og ble
|
||||||
feilhåndtering er Players jobb, slik at en planlagt melding oppfører seg
|
stående slik selv om avspillingen feilet. Nå ender den som sent, failed
|
||||||
nøyaktig som en sendt for hånd når den først er i kø.
|
eller stopped – det som faktisk skjedde.
|
||||||
|
"""
|
||||||
|
if not item.schedule_id:
|
||||||
|
return
|
||||||
|
status = {"done": "sent", "failed": "failed", "stopped": "stopped"}.get(item.status)
|
||||||
|
if status:
|
||||||
|
schedule.mark(item.schedule_id, status, item.error or f"kø #{item.id}")
|
||||||
|
|
||||||
|
|
||||||
|
player = Player(on_finished=_scheduled_finished)
|
||||||
|
|
||||||
|
|
||||||
|
def scheduler_tick() -> None:
|
||||||
|
"""Ett steg: flytter forfalte meldinger inn i den vanlige køen.
|
||||||
|
|
||||||
|
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ø. Egen funksjon, så tester kan
|
||||||
|
kalle den uten tråd.
|
||||||
"""
|
"""
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
send, missed = schedule.due()
|
send, missed = schedule.due()
|
||||||
for item in missed:
|
for item in missed:
|
||||||
schedule.mark(item.id, "missed", "maskina var ikke i gang i tide")
|
schedule.mark(item.id, "missed", "maskina var ikke i gang i tide")
|
||||||
|
|
@ -478,12 +542,19 @@ def _scheduler() -> None:
|
||||||
item.text, item.voice, item.speed, item.chime,
|
item.text, item.voice, item.speed, item.chime,
|
||||||
sender="planlagt",
|
sender="planlagt",
|
||||||
who=auth.Principal(item.subject, item.user, (), item.method, frozenset()),
|
who=auth.Principal(item.subject, item.user, (), item.method, frozenset()),
|
||||||
|
schedule_id=item.id,
|
||||||
)
|
)
|
||||||
schedule.mark(item.id, "sent", f"kø #{queued.id}")
|
schedule.mark(item.id, "queued", f"kø #{queued.id}")
|
||||||
log.info("planlagt #%s lagt i kø som #%d", item.id, 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,
|
audit("scheduled_fire", id=item.id, queue_id=queued.id,
|
||||||
subject=item.subject, name=item.user, method=item.method,
|
subject=item.subject, name=item.user, method=item.method,
|
||||||
text=item.text, voice=item.voice, at=item.at)
|
text=item.text, voice=item.voice, at=item.at)
|
||||||
|
|
||||||
|
|
||||||
|
def _scheduler_loop() -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
scheduler_tick()
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
# En feil her skal ikke drepe tråden; da ville alle senere
|
# En feil her skal ikke drepe tråden; da ville alle senere
|
||||||
# planlagte sendinger stilltiende blitt borte.
|
# planlagte sendinger stilltiende blitt borte.
|
||||||
|
|
@ -491,7 +562,8 @@ def _scheduler() -> None:
|
||||||
time.sleep(TICK)
|
time.sleep(TICK)
|
||||||
|
|
||||||
|
|
||||||
threading.Thread(target=_scheduler, daemon=True, name="scheduler").start()
|
def start_scheduler() -> None:
|
||||||
|
threading.Thread(target=_scheduler_loop, daemon=True, name="scheduler").start()
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
@ -721,6 +793,29 @@ def not_found(e): # noqa: ANN001
|
||||||
return e
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
def check_writable() -> None:
|
||||||
|
"""Sier klart fra hvis datamappene ikke kan skrives, før noe annet.
|
||||||
|
|
||||||
|
Det vanlige tilfellet: `docker compose up` uten install.sh, så Docker
|
||||||
|
laget data/ som root mens containeren kjører som vanlig bruker. Uten
|
||||||
|
denne sjekken feiler det først ved første utrop, med en traceback.
|
||||||
|
"""
|
||||||
|
for name, path in (("cache", CACHE_DIR), ("logs", LOG_PATH.parent),
|
||||||
|
("instance", CFG.path("state")), ("schedule", SCHEDULE_PATH.parent)):
|
||||||
|
try:
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
probe = path / ".skrivetest"
|
||||||
|
probe.touch()
|
||||||
|
probe.unlink()
|
||||||
|
except OSError as e:
|
||||||
|
raise SystemExit(
|
||||||
|
f"Får ikke skrevet til {path} ({e}).\n"
|
||||||
|
f"Kjører du i Docker: data/ må eies av brukeren i PUID/PGID i .env, "
|
||||||
|
f"ikke root. Fiks med sudo chown -R $(id -u):$(id -g) data/ "
|
||||||
|
f"eller kjør ./install.sh, som lager mappene riktig."
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
ap.add_argument("--host", default=CFG.get("server", "host", "0.0.0.0"))
|
ap.add_argument("--host", default=CFG.get("server", "host", "0.0.0.0"))
|
||||||
|
|
@ -728,15 +823,24 @@ def main() -> None:
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
logging.getLogger("werkzeug").setLevel(logging.WARNING)
|
logging.getLogger("werkzeug").setLevel(logging.WARNING)
|
||||||
|
check_writable()
|
||||||
ensure_voices()
|
ensure_voices()
|
||||||
load_voice("no_NO-talesyntese-medium") # varm opp reserven
|
load_voice("no_NO-talesyntese-medium") # varm opp reserven
|
||||||
if CFG.source:
|
if CFG.source:
|
||||||
log.info("konfigurasjon fra %s", CFG.source)
|
log.info("konfigurasjon fra %s", CFG.source)
|
||||||
methods = ", ".join(m["method"] for m in AUTH.describe()["methods"]) or "åpen (ingen innlogging)"
|
methods = ", ".join(m["method"] for m in AUTH.describe()["methods"]) or "åpen (ingen innlogging)"
|
||||||
venter = len(schedule.snapshot()["pending"])
|
venter = len(schedule.snapshot()["pending"])
|
||||||
|
player.start()
|
||||||
|
start_scheduler()
|
||||||
log.info("klar på http://%s:%d (auth: %s, sink: %s, planlagt: %d)",
|
log.info("klar på http://%s:%d (auth: %s, sink: %s, planlagt: %d)",
|
||||||
args.host, args.port, methods, SINK or "standard", venter)
|
args.host, args.port, methods, SINK or "standard", venter)
|
||||||
app.run(host=args.host, port=args.port, threaded=True)
|
# waitress, ikke Flask sin utviklingsserver – og ikke gunicorn med flere
|
||||||
|
# workere: Player og planleggeren er singletons i denne prosessen, så
|
||||||
|
# flere workere ville gitt flere køer som snakker i munnen på hverandre.
|
||||||
|
# Én prosess, mange tråder.
|
||||||
|
from waitress import serve
|
||||||
|
|
||||||
|
serve(app, host=args.host, port=args.port, threads=8, ident="announcer")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
15
auth.py
15
auth.py
|
|
@ -124,7 +124,6 @@ class TokenVerifier:
|
||||||
self.secret = str(section.get("secret", "")).strip()
|
self.secret = str(section.get("secret", "")).strip()
|
||||||
self.jwks_url = str(section.get("jwks_url", "")).strip()
|
self.jwks_url = str(section.get("jwks_url", "")).strip()
|
||||||
self.leeway = int(section.get("leeway", 30))
|
self.leeway = int(section.get("leeway", 30))
|
||||||
self.verify_signature = bool(section.get("verify_signature", True))
|
|
||||||
# Tom liste: utled av hvordan nøkkelen kommer. Delt hemmelighet er
|
# Tom liste: utled av hvordan nøkkelen kommer. Delt hemmelighet er
|
||||||
# symmetrisk (HS256), JWKS er asymmetrisk (RS256/ES256).
|
# symmetrisk (HS256), JWKS er asymmetrisk (RS256/ES256).
|
||||||
algs = [str(a) for a in section.get("algorithms") or []]
|
algs = [str(a) for a in section.get("algorithms") or []]
|
||||||
|
|
@ -143,9 +142,6 @@ class TokenVerifier:
|
||||||
def verify(self, token: str) -> dict:
|
def verify(self, token: str) -> dict:
|
||||||
import jwt
|
import jwt
|
||||||
|
|
||||||
if not self.verify_signature:
|
|
||||||
# Bare for et crew-panel bak en proxy som alt har validert tokenet.
|
|
||||||
return jwt.decode(token, options={"verify_signature": False})
|
|
||||||
if not (self.secret or self.jwks_url):
|
if not (self.secret or self.jwks_url):
|
||||||
raise AuthError("JWT er ikke konfigurert")
|
raise AuthError("JWT er ikke konfigurert")
|
||||||
try:
|
try:
|
||||||
|
|
@ -306,9 +302,6 @@ class OidcProvider(Provider):
|
||||||
"secret": self.client_secret,
|
"secret": self.client_secret,
|
||||||
"jwks_url": meta.get("jwks_uri", ""),
|
"jwks_url": meta.get("jwks_uri", ""),
|
||||||
"algorithms": [],
|
"algorithms": [],
|
||||||
"verify_signature": bool(
|
|
||||||
self.cfg.get("auth.oidc", "verify_signature", True)
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return self._verifier
|
return self._verifier
|
||||||
|
|
@ -626,6 +619,14 @@ def logout():
|
||||||
return redirect("/")
|
return redirect("/")
|
||||||
|
|
||||||
|
|
||||||
|
def reconfigure(cfg: Config) -> Authenticator:
|
||||||
|
"""Bytter ut leverandørene uten å røre Flask-appen. For tester som vil
|
||||||
|
prøve samme app med og uten innlogging."""
|
||||||
|
global _auth
|
||||||
|
_auth = Authenticator(cfg)
|
||||||
|
return _auth
|
||||||
|
|
||||||
|
|
||||||
def install(app, cfg: Config) -> Authenticator: # noqa: ANN001
|
def install(app, cfg: Config) -> Authenticator: # noqa: ANN001
|
||||||
"""Kobler autentisering på Flask-appen. Returnerer Authenticatoren."""
|
"""Kobler autentisering på Flask-appen. Returnerer Authenticatoren."""
|
||||||
global _auth
|
global _auth
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,6 @@ DEFAULTS: dict[str, Any] = {
|
||||||
"jwks_url": "",
|
"jwks_url": "",
|
||||||
"end_session_endpoint": "",
|
"end_session_endpoint": "",
|
||||||
"label": "Logg inn",
|
"label": "Logg inn",
|
||||||
"verify_signature": True,
|
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"anonymous": [],
|
"anonymous": [],
|
||||||
|
|
|
||||||
16
index.html
16
index.html
|
|
@ -76,6 +76,7 @@
|
||||||
font: inherit; font-size: 13px; padding: 0 4px; }
|
font: inherit; font-size: 13px; padding: 0 4px; }
|
||||||
ul.list li button.x:hover { color: var(--danger); }
|
ul.list li button.x:hover { color: var(--danger); }
|
||||||
ul.list li.cancelled .s, ul.list li.missed .s { color: var(--warn); }
|
ul.list li.cancelled .s, ul.list li.missed .s { color: var(--warn); }
|
||||||
|
.now .t { color: var(--muted); font-size: 13px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -422,7 +423,8 @@ btnAnnounce.addEventListener('click', async () => {
|
||||||
$('#btnStop').addEventListener('click', () => api('/api/stop').then(poll).catch(onError));
|
$('#btnStop').addEventListener('click', () => api('/api/stop').then(poll).catch(onError));
|
||||||
$('#btnClear').addEventListener('click', () => api('/api/clear').then(poll).catch(onError));
|
$('#btnClear').addEventListener('click', () => api('/api/clear').then(poll).catch(onError));
|
||||||
|
|
||||||
const STATUS = { done: 'sendt', failed: 'feilet', stopped: 'stoppet', queued: 'i kø' };
|
const STATUS = { done: 'sendt', failed: 'feilet', stopped: 'stoppet', queued: 'i kø',
|
||||||
|
preparing: 'lager lyd', playing: 'spiller' };
|
||||||
function hhmm(ts) { return new Date(ts * 1000).toLocaleTimeString('no', { hour: '2-digit', minute: '2-digit' }); }
|
function hhmm(ts) { return new Date(ts * 1000).toLocaleTimeString('no', { hour: '2-digit', minute: '2-digit' }); }
|
||||||
function li(it) {
|
function li(it) {
|
||||||
const l = document.createElement('li'); l.className = it.status;
|
const l = document.createElement('li'); l.className = it.status;
|
||||||
|
|
@ -434,7 +436,8 @@ function li(it) {
|
||||||
l.querySelector('.s').textContent = (STATUS[it.status] || it.status) + (it.error ? ' · ' + it.error : '');
|
l.querySelector('.s').textContent = (STATUS[it.status] || it.status) + (it.error ? ' · ' + it.error : '');
|
||||||
return l;
|
return l;
|
||||||
}
|
}
|
||||||
const SCHED_STATUS = { sent: 'sendt', missed: 'gikk glipp av', cancelled: 'avbrutt' };
|
const SCHED_STATUS = { queued: 'i kø', sent: 'sendt', failed: 'feilet', stopped: 'stoppet',
|
||||||
|
missed: 'gikk glipp av', cancelled: 'avbrutt' };
|
||||||
function schedLi(it) {
|
function schedLi(it) {
|
||||||
const l = document.createElement('li');
|
const l = document.createElement('li');
|
||||||
l.className = it.status;
|
l.className = it.status;
|
||||||
|
|
@ -468,7 +471,14 @@ async function poll() {
|
||||||
const rows = [...sc.pending, ...sc.done];
|
const rows = [...sc.pending, ...sc.done];
|
||||||
$('#schedcard').classList.toggle('hidden', rows.length === 0);
|
$('#schedcard').classList.toggle('hidden', rows.length === 0);
|
||||||
$('#sched').replaceChildren(...rows.map(schedLi));
|
$('#sched').replaceChildren(...rows.map(schedLi));
|
||||||
if (s.current) { now.className = 'now'; now.innerHTML = '<span class="pulse"></span>'; now.append(document.createTextNode(s.current.text)); }
|
if (s.current) {
|
||||||
|
// «Lager lyd» er de første sekundene av hver edge-tts-melding: det
|
||||||
|
// er stille, og uten dette så det ut som avspillingen hang.
|
||||||
|
const preparing = s.current.status === 'preparing';
|
||||||
|
now.className = 'now' + (preparing ? ' idle' : '');
|
||||||
|
now.innerHTML = preparing ? '<span class="t">Lager lyd … </span>' : '<span class="pulse"></span>';
|
||||||
|
now.append(document.createTextNode(s.current.text));
|
||||||
|
}
|
||||||
else { now.className = 'now idle'; now.textContent = (cfg.app && cfg.app.idle_text) || ''; }
|
else { now.className = 'now idle'; now.textContent = (cfg.app && cfg.app.idle_text) || ''; }
|
||||||
$('#queue').replaceChildren(...s.queued.map(li));
|
$('#queue').replaceChildren(...s.queued.map(li));
|
||||||
$('#history').replaceChildren(...s.history.map(li));
|
$('#history').replaceChildren(...s.history.map(li));
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ dependencies = [
|
||||||
"flask>=3.1.3",
|
"flask>=3.1.3",
|
||||||
"piper-tts>=1.8.0",
|
"piper-tts>=1.8.0",
|
||||||
"pyjwt[crypto]>=2.10",
|
"pyjwt[crypto]>=2.10",
|
||||||
|
"waitress>=3.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,9 @@ DEFAULT_MAX_AHEAD = 30 * 86400.0
|
||||||
# Hvor mange ferdige (sendt/hoppet over/avbrutt) vi beholder for visning.
|
# Hvor mange ferdige (sendt/hoppet over/avbrutt) vi beholder for visning.
|
||||||
KEEP_DONE = 50
|
KEEP_DONE = 50
|
||||||
|
|
||||||
STATUSES = ("pending", "sent", "missed", "cancelled")
|
# 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
|
@dataclass
|
||||||
|
|
@ -52,7 +54,7 @@ class Scheduled:
|
||||||
subject: str = ""
|
subject: str = ""
|
||||||
method: str = "none"
|
method: str = "none"
|
||||||
created: float = field(default_factory=time.time)
|
created: float = field(default_factory=time.time)
|
||||||
status: str = "pending" # pending | sent | missed | cancelled
|
status: str = "pending" # se STATUSES
|
||||||
note: str = ""
|
note: str = ""
|
||||||
|
|
||||||
def public(self) -> dict:
|
def public(self) -> dict:
|
||||||
|
|
|
||||||
345
tests/test_routes.py
Normal file
345
tests/test_routes.py
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
"""Tester for HTTP-laget i announcer.py: rutene, køen og planleggeren.
|
||||||
|
|
||||||
|
Fram til nå var alt dette uprøvd, fordi `import announcer` startet tråder og
|
||||||
|
dro inn Piper. Nå starter trådene først i main(), og syntese og avspilling
|
||||||
|
byttes ut her – så kan køen kjøres ett steg om gangen med run_once().
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import wave
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import jwt as pyjwt
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
# Må settes før import: announcer.py leser konfigurasjonen på modulnivå.
|
||||||
|
_TMP = Path(tempfile.mkdtemp(prefix="announcer-routes-"))
|
||||||
|
for _k, _v in {
|
||||||
|
"ANNOUNCER_CONFIG": "/finnes-ikke.toml",
|
||||||
|
"ANNOUNCER_CACHE": _TMP / "cache",
|
||||||
|
"ANNOUNCER_LOG": _TMP / "logs" / "a.jsonl",
|
||||||
|
"ANNOUNCER_STATE": _TMP / "instance",
|
||||||
|
"ANNOUNCER_SCHEDULE": _TMP / "scheduled.json",
|
||||||
|
"ANNOUNCER_VOICES": _TMP / "voices",
|
||||||
|
"ANNOUNCER_CHIMES": _TMP / "chimes",
|
||||||
|
}.items():
|
||||||
|
os.environ[_k] = str(_v)
|
||||||
|
|
||||||
|
import announcer # noqa: E402
|
||||||
|
import auth # noqa: E402
|
||||||
|
import config # noqa: E402
|
||||||
|
import schedule as sched # noqa: E402
|
||||||
|
|
||||||
|
SECRET = "test-hemmelighet-som-er-lang-nok-for-hmac-sha256-ok"
|
||||||
|
|
||||||
|
|
||||||
|
def tiny_wav(path: Path) -> Path:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with wave.open(str(path), "wb") as w:
|
||||||
|
w.setnchannels(1)
|
||||||
|
w.setsampwidth(2)
|
||||||
|
w.setframerate(8000)
|
||||||
|
w.writeframes(b"\x00\x00" * 80)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(tmp_path, monkeypatch):
|
||||||
|
"""Frisk kø, frisk plan, falsk syntese og «avspilling» som bare lykkes."""
|
||||||
|
monkeypatch.setattr(announcer, "LOG_PATH", tmp_path / "a.jsonl")
|
||||||
|
monkeypatch.setattr(announcer, "CACHE_DIR", tmp_path / "cache")
|
||||||
|
monkeypatch.setattr(announcer, "schedule", sched.Schedule(tmp_path / "s.json", grace=120))
|
||||||
|
monkeypatch.setattr(announcer, "player", announcer.Player(on_finished=announcer._scheduled_finished))
|
||||||
|
monkeypatch.setattr(announcer, "synthesize",
|
||||||
|
lambda text, voice, speed, chime: tiny_wav(tmp_path / "cache" / "x.wav"))
|
||||||
|
monkeypatch.setattr(announcer, "player_cmd", lambda path: ["true"])
|
||||||
|
auth.reconfigure(config.load("/finnes-ikke.toml"))
|
||||||
|
announcer.app.config["TESTING"] = True
|
||||||
|
return announcer.app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def c(app):
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
def audit_lines(tmp_path) -> list[dict]:
|
||||||
|
p = tmp_path / "a.jsonl"
|
||||||
|
return [json.loads(l) for l in p.read_text().splitlines()] if p.exists() else []
|
||||||
|
|
||||||
|
|
||||||
|
def with_auth(tmp_path, toml: str) -> None:
|
||||||
|
p = tmp_path / "c.toml"
|
||||||
|
p.write_text(f'[paths]\nstate = "{tmp_path}"\n\n{toml}')
|
||||||
|
auth.reconfigure(config.load(p))
|
||||||
|
|
||||||
|
|
||||||
|
def token(sub: str, name: str, roles: list[str]) -> dict:
|
||||||
|
t = pyjwt.encode({"iss": "https://crew.test", "aud": "announcer", "sub": sub,
|
||||||
|
"name": name, "roles": roles, "exp": time.time() + 600},
|
||||||
|
SECRET, algorithm="HS256")
|
||||||
|
return {"Authorization": f"Bearer {t}"}
|
||||||
|
|
||||||
|
|
||||||
|
JWT_CFG = f'[auth.jwt]\nsecret = "{SECRET}"\naudience = "announcer"\nissuer = "https://crew.test"\n'
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Grunnleggende
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_sida_og_konfig(c):
|
||||||
|
assert c.get("/").status_code == 200
|
||||||
|
d = c.get("/api/config").get_json()
|
||||||
|
assert d["app"]["title"] == "Announcer"
|
||||||
|
assert d["auth"]["enabled"] is False
|
||||||
|
assert "schedule" in d["auth"]["can"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ukjent_api_rute_svarer_json(c):
|
||||||
|
r = c.get("/api/finnes-ikke")
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert r.get_json()["error"] == "Finnes ikke"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("body, feil", [
|
||||||
|
({}, "Tom melding"),
|
||||||
|
({"text": " "}, "Tom melding"),
|
||||||
|
({"text": "x" * 601}, "Maks 600 tegn"),
|
||||||
|
({"text": "hei", "voice": "finnes-ikke"}, "Ukjent stemme"),
|
||||||
|
({"text": "hei", "chime": "finnes-ikke"}, "Ukjent jingle"),
|
||||||
|
])
|
||||||
|
def test_ugyldig_melding_gir_400(c, body, feil):
|
||||||
|
r = c.post("/api/announce", json=body)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert r.get_json()["error"] == feil
|
||||||
|
|
||||||
|
|
||||||
|
def test_hastighet_klemmes_inn(c):
|
||||||
|
c.post("/api/announce", json={"text": "fort", "speed": 9})
|
||||||
|
c.post("/api/announce", json={"text": "sakte", "speed": 0.1})
|
||||||
|
assert [i.speed for i in announcer.player.history] == [1.6, 0.6]
|
||||||
|
|
||||||
|
|
||||||
|
def test_whitespace_normaliseres(c):
|
||||||
|
c.post("/api/announce", json={"text": " hei på\n\ndeg "})
|
||||||
|
assert announcer.player.history[0].text == "hei på deg"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Køen
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_announce_legger_i_ko_og_spiller(c, tmp_path):
|
||||||
|
r = c.post("/api/announce", json={"text": "Hallo", "chime": "none"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.get_json()["status"] == "queued"
|
||||||
|
|
||||||
|
item = announcer.player.run_once(timeout=1)
|
||||||
|
assert item.status == "done"
|
||||||
|
actions = [l["action"] for l in audit_lines(tmp_path)]
|
||||||
|
assert actions == ["announce", "played"]
|
||||||
|
assert audit_lines(tmp_path)[1]["status"] == "done"
|
||||||
|
|
||||||
|
|
||||||
|
def test_feilet_avspilling_markeres(c, monkeypatch):
|
||||||
|
monkeypatch.setattr(announcer, "player_cmd", lambda path: ["false"])
|
||||||
|
c.post("/api/announce", json={"text": "x"})
|
||||||
|
assert announcer.player.run_once(timeout=1).status == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_syntesefeil_markeres(c, monkeypatch):
|
||||||
|
def boom(*a):
|
||||||
|
raise RuntimeError("edge-tts nede")
|
||||||
|
monkeypatch.setattr(announcer, "synthesize", boom)
|
||||||
|
c.post("/api/announce", json={"text": "x"})
|
||||||
|
item = announcer.player.run_once(timeout=1)
|
||||||
|
assert item.status == "failed"
|
||||||
|
assert "edge-tts nede" in item.error
|
||||||
|
|
||||||
|
|
||||||
|
def test_stopp_under_syntese_hindrer_avspilling(c, monkeypatch):
|
||||||
|
"""Bug nr. 1 fra gjennomgangen: Stopp gjorde ingenting før lyden var
|
||||||
|
laget, og meldingen gikk ut likevel. Nå skal den aldri nå avspilling."""
|
||||||
|
spilt = []
|
||||||
|
def synth_som_blir_stoppet(text, voice, speed, chime):
|
||||||
|
announcer.player.stop() # noen trykker Stopp midt i syntesen
|
||||||
|
return tiny_wav(announcer.CACHE_DIR / "x.wav")
|
||||||
|
monkeypatch.setattr(announcer, "synthesize", synth_som_blir_stoppet)
|
||||||
|
monkeypatch.setattr(announcer, "player_cmd", lambda p: spilt.append(p) or ["true"])
|
||||||
|
|
||||||
|
c.post("/api/announce", json={"text": "feil melding"})
|
||||||
|
item = announcer.player.run_once(timeout=1)
|
||||||
|
assert item.status == "stopped"
|
||||||
|
assert spilt == [], "avspilling skulle aldri startet"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stopp_under_avspilling(c, monkeypatch):
|
||||||
|
monkeypatch.setattr(announcer, "player_cmd", lambda p: ["sleep", "5"])
|
||||||
|
c.post("/api/announce", json={"text": "lang"})
|
||||||
|
t = threading.Thread(target=announcer.player.run_once, kwargs={"timeout": 1})
|
||||||
|
t.start()
|
||||||
|
for _ in range(50): # vent til prosessen faktisk kjører
|
||||||
|
if announcer.player.proc is not None:
|
||||||
|
break
|
||||||
|
time.sleep(0.02)
|
||||||
|
r = c.post("/api/stop")
|
||||||
|
t.join(timeout=3)
|
||||||
|
assert r.get_json() == {"ok": True}
|
||||||
|
assert announcer.player.history[0].status == "stopped"
|
||||||
|
assert not t.is_alive(), "sleep 5 skulle vært drept"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tom_ko(c):
|
||||||
|
for i in range(3):
|
||||||
|
c.post("/api/announce", json={"text": f"m{i}"})
|
||||||
|
assert c.post("/api/clear").status_code == 200
|
||||||
|
assert [i.status for i in announcer.player.history] == ["stopped"] * 3
|
||||||
|
assert announcer.player.run_once(timeout=0.2) is None, "køen skal være tom"
|
||||||
|
assert c.get("/api/status").get_json()["queued"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_viser_ko_og_historikk(c):
|
||||||
|
c.post("/api/announce", json={"text": "en"})
|
||||||
|
c.post("/api/announce", json={"text": "to"})
|
||||||
|
announcer.player.run_once(timeout=1)
|
||||||
|
s = c.get("/api/status").get_json()
|
||||||
|
assert s["current"] is None
|
||||||
|
assert [i["text"] for i in s["queued"]] == ["to"]
|
||||||
|
assert [i["text"] for i in s["history"]] == ["en"]
|
||||||
|
assert "scheduled" in s and "now" in s
|
||||||
|
|
||||||
|
|
||||||
|
def test_ip_vises_bare_for_den_som_kan_tomme_koen(c, tmp_path):
|
||||||
|
with_auth(tmp_path, JWT_CFG)
|
||||||
|
crew = token("7", "Kari", ["crew"])
|
||||||
|
adm = token("1", "Kent", ["admin"])
|
||||||
|
c.post("/api/announce", json={"text": "x"}, headers=crew)
|
||||||
|
assert c.get("/api/status", headers=crew).get_json()["queued"][0]["sender"] == ""
|
||||||
|
assert c.get("/api/status", headers=adm).get_json()["queued"][0]["sender"] == "127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_401_i_pin_modus_sier_logg_inn(c, tmp_path):
|
||||||
|
with_auth(tmp_path, '[auth.pin]\npin = "1234"\n')
|
||||||
|
r = c.post("/api/announce", json={"text": "x"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
assert r.get_json() == {"error": "Feil eller manglende PIN.", "login": True}
|
||||||
|
assert c.get("/api/status").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Planlagte sendinger gjennom HTTP og planleggeren
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_planlegging_og_fyring(c, tmp_path):
|
||||||
|
at = time.time() + 600
|
||||||
|
r = c.post("/api/schedule", json={"text": "Senere", "chime": "none", "at": at})
|
||||||
|
assert r.status_code == 200
|
||||||
|
sid = r.get_json()["id"]
|
||||||
|
assert [i["id"] for i in c.get("/api/status").get_json()["scheduled"]["pending"]] == [sid]
|
||||||
|
|
||||||
|
# Ikke forfalt: ingenting skjer
|
||||||
|
announcer.scheduler_tick()
|
||||||
|
assert announcer.player.history == []
|
||||||
|
|
||||||
|
# Skru klokka fram: forfalt, innenfor nådefristen
|
||||||
|
announcer.schedule.get(sid).at = time.time() - 10
|
||||||
|
announcer.scheduler_tick()
|
||||||
|
assert announcer.schedule.get(sid).status == "queued"
|
||||||
|
assert announcer.player.history[0].schedule_id == sid
|
||||||
|
|
||||||
|
# Bug nr. 2: statusen skal følge avspillingen helt i mål
|
||||||
|
announcer.player.run_once(timeout=1)
|
||||||
|
assert announcer.schedule.get(sid).status == "sent"
|
||||||
|
assert [l["action"] for l in audit_lines(tmp_path)] == ["schedule", "scheduled_fire", "played"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_planlagt_som_feiler_i_avspilling_star_som_feilet(c, monkeypatch):
|
||||||
|
monkeypatch.setattr(announcer, "player_cmd", lambda p: ["false"])
|
||||||
|
sid = c.post("/api/schedule", json={"text": "x", "at": time.time() + 600}).get_json()["id"]
|
||||||
|
announcer.schedule.get(sid).at = time.time() - 10
|
||||||
|
announcer.scheduler_tick()
|
||||||
|
announcer.player.run_once(timeout=1)
|
||||||
|
assert announcer.schedule.get(sid).status == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_planlagt_som_gikk_glipp_av_spilles_ikke(c, tmp_path):
|
||||||
|
sid = c.post("/api/schedule", json={"text": "for sent", "at": time.time() + 600}).get_json()["id"]
|
||||||
|
announcer.schedule.get(sid).at = time.time() - 3600
|
||||||
|
announcer.scheduler_tick()
|
||||||
|
assert announcer.schedule.get(sid).status == "missed"
|
||||||
|
assert announcer.player.history == []
|
||||||
|
assert [l["action"] for l in audit_lines(tmp_path)] == ["schedule", "missed"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("body, feil", [
|
||||||
|
({"text": "x"}, "Mangler eller ugyldig tidspunkt"),
|
||||||
|
({"text": "x", "at": "snart"}, "Mangler eller ugyldig tidspunkt"),
|
||||||
|
({"text": "x", "at": 1}, "Tidspunktet er passert"),
|
||||||
|
({"text": "x", "at": time.time() + 400 * 86400}, "Mer enn 30 dager fram i tid"),
|
||||||
|
({"text": "", "at": time.time() + 60}, "Tom melding"),
|
||||||
|
])
|
||||||
|
def test_ugyldig_planlegging(c, body, feil):
|
||||||
|
r = c.post("/api/schedule", json=body)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert r.get_json()["error"] == feil
|
||||||
|
|
||||||
|
|
||||||
|
def test_avbryt_egen_planlagt(c, tmp_path):
|
||||||
|
sid = c.post("/api/schedule", json={"text": "x", "at": time.time() + 600}).get_json()["id"]
|
||||||
|
assert c.post("/api/schedule/cancel", json={"id": sid}).status_code == 200
|
||||||
|
assert announcer.schedule.get(sid).status == "cancelled"
|
||||||
|
assert c.post("/api/schedule/cancel", json={"id": sid}).status_code == 404
|
||||||
|
assert c.post("/api/schedule/cancel", json={"id": "nope"}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_andres_planlagte_krever_clear(c, tmp_path):
|
||||||
|
with_auth(tmp_path, JWT_CFG)
|
||||||
|
kari = token("7", "Kari", ["crew"])
|
||||||
|
ola = token("8", "Ola", ["crew"])
|
||||||
|
kent = token("1", "Kent", ["admin"])
|
||||||
|
sid = c.post("/api/schedule", json={"text": "x", "at": time.time() + 600}, headers=kari).get_json()["id"]
|
||||||
|
|
||||||
|
r = c.post("/api/schedule/cancel", json={"id": sid}, headers=ola)
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert "Kari" in r.get_json()["error"]
|
||||||
|
assert c.post("/api/schedule/cancel", json={"id": sid}, headers=kari).status_code == 200
|
||||||
|
|
||||||
|
sid2 = c.post("/api/schedule", json={"text": "y", "at": time.time() + 600}, headers=kari).get_json()["id"]
|
||||||
|
assert c.post("/api/schedule/cancel", json={"id": sid2}, headers=kent).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_krever_rettigheten(c, tmp_path):
|
||||||
|
with_auth(tmp_path, JWT_CFG + '\n[roles]\nlytter = ["view", "announce"]\n')
|
||||||
|
h = token("9", "Lytter", ["lytter"])
|
||||||
|
assert c.post("/api/announce", json={"text": "x"}, headers=h).status_code == 200
|
||||||
|
assert c.post("/api/schedule", json={"text": "x", "at": time.time() + 60}, headers=h).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Oppstart
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_uskrivbar_datamappe_gir_klar_melding(tmp_path, monkeypatch):
|
||||||
|
locked = tmp_path / "laast"
|
||||||
|
locked.mkdir()
|
||||||
|
locked.chmod(0o500)
|
||||||
|
monkeypatch.setattr(announcer, "CACHE_DIR", locked / "cache")
|
||||||
|
with pytest.raises(SystemExit) as e:
|
||||||
|
announcer.check_writable()
|
||||||
|
assert "PUID" in str(e.value) and "chown" in str(e.value)
|
||||||
|
locked.chmod(0o700)
|
||||||
11
uv.lock
generated
11
uv.lock
generated
|
|
@ -155,6 +155,7 @@ dependencies = [
|
||||||
{ name = "flask" },
|
{ name = "flask" },
|
||||||
{ name = "piper-tts" },
|
{ name = "piper-tts" },
|
||||||
{ name = "pyjwt", extra = ["crypto"] },
|
{ name = "pyjwt", extra = ["crypto"] },
|
||||||
|
{ name = "waitress" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
|
|
@ -168,6 +169,7 @@ requires-dist = [
|
||||||
{ name = "flask", specifier = ">=3.1.3" },
|
{ name = "flask", specifier = ">=3.1.3" },
|
||||||
{ name = "piper-tts", specifier = ">=1.8.0" },
|
{ name = "piper-tts", specifier = ">=1.8.0" },
|
||||||
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.10" },
|
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.10" },
|
||||||
|
{ name = "waitress", specifier = ">=3.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
|
|
@ -1030,6 +1032,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "waitress"
|
||||||
|
version = "3.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901, upload-time = "2024-11-16T20:02:35.195Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "werkzeug"
|
name = "werkzeug"
|
||||||
version = "3.1.9"
|
version = "3.1.9"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue