# tui/events.py Thread-safe event bus between the bot backend thread and the Textual TUI. The bot thread calls `post()`. The TUI drains the queue every 100ms via `_drain_bus()`. Web SSE consumers call `subscribe()` / `unsubscribe()` to get their own broadcast queue. ## Public API ```python from tui import events as bus # from core/ and tui/app.py from tui.events import post, init_bus, get_bus, tui_active from tui.events import subscribe, unsubscribe from tui.events import set_bot_context, signal_channel_changed ``` ### `init_bus() -> queue.Queue` Creates the `queue.Queue`. Called inside `MonitorApp.on_mount()` — **must run on Textual's event loop**, not before `App.run()`. ### `post(event: Any) -> None` Fire-and-forget from any thread. Delivers to the TUI queue **and** all subscriber queues. Uses `queue.Queue.put_nowait()` — never blocks. ### `get_bus() -> queue.Queue | None` Returns the TUI queue for `_drain_bus()` to consume. ### `tui_active: bool` Set to `True` by `init_bus()`. Checked by `core/tdl_downloader.py` to decide whether to pipe tdl output or inherit the terminal. ### `subscribe() -> queue.Queue` Register a new subscriber. Returns a private `queue.Queue` that receives every future `post()`. Thread-safe. Call once per SSE connection. ### `unsubscribe(q: queue.Queue) -> None` Remove a subscriber queue. Safe to call if already removed. Call on SSE disconnect. ### `set_bot_context(loop, event) -> None` Called by `_bot_main()` once the bot asyncio loop and `_ch_changed` event exist. Enables `signal_channel_changed()`. ### `signal_channel_changed() -> None` Wake the bot's `_watch_channels()` coroutine from any thread. Used by web config routes after channel list is updated. --- ## Event types | Class | Fields | Posted by | Consumed by | |-------|--------|-----------|-------------| | `EvDownloadQueued` | `batch_id, filename, size_mb, source, password` | `tdl_downloader`, `scraper` | `DownloadPanel.queued()` | | `EvDownloadStarted` | `batch_id, filename` | `tdl_downloader`, `scraper` | `DownloadPanel.status("downloading")` | | `EvDownloadDone` | `batch_id, filename, via` | `tdl_downloader`, `scraper` | `DownloadPanel.status("done_tdl"\|"done_tel")` | | `EvDownloadFailed` | `batch_id, filename, reason` | `tdl_downloader`, `scraper` | `DownloadPanel.status("failed")` | | `EvTdlOutput` | `line` | `tdl_downloader._relay()` | `DownloadPanel.tdl_line()` | | `EvHit` | `severity, raw, source, filename, reasons` | `notifier.notify()` | `HitsPanel.add_hit()` + `StatsPanel.refresh_stats()` | | `EvChannelAdded` | `channel` | `ChannelPanel.on_button_pressed()` | `_drain_bus` → `_signal_channel_changed()` | | `EvChannelRemoved` | `channel` | `ChannelPanel.on_button_pressed()` | `_drain_bus` → `_signal_channel_changed()` | | `EvStatus` | `text, level` | everywhere | `MonitorApp.notify()` toast | `level` on `EvStatus`: `"info"` (default) · `"warning"` · `"error"` --- ## Threading model ``` Bot thread (own asyncio loop) └─ bus.post(event) ← queue.Queue.put_nowait() [thread-safe] ↓ _queue (TUI) + _subscribers[0..n] (web SSE) ↓ ↓ Textual thread Web thread (uvicorn) _drain_bus() SSE generator: q.get_nowait() ``` Channel changes from TUI: ``` _drain_bus sees EvChannelAdded/Removed → _signal_channel_changed() → loop.call_soon_threadsafe(asyncio.Event.set) → bot thread's _watch_channels() wakes ``` Channel changes from web: ``` PUT /config/channels → config.save_runtime_config() → bus.signal_channel_changed() ← uses stored loop + event → bot thread's _watch_channels() wakes ```