# Anthias Viewer

> Viewer & server application internals for Anthias — the server-rendered Django/ASGI + Redis pub/sub architecture, streaming-under-ASGI, SQLite WAL playlist reload, viewer memory/OOM behavior, upload codec gate, Sentry-noise-vs-real-bug triage, remote media, webview C++ features, PulseAudio, and the content-import framework. Read before touching viewer scheduling, messaging, streaming, upload/backup/import, or webview D-Bus code.

- Skill: `screenly/anthias-viewer` (Agent Skill)
- Install (CLI): `npx skillmds@latest add screenly/anthias-viewer`
- Raw SKILL.md: https://api.skillmd.com/api/skills/screenly/anthias-viewer/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: screenly (https://skillmd.com/u/screenly)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/screenly/anthias-viewer

---


# Anthias application & viewer architecture knowledge

Durable engineering findings on the viewer/server internals and the subtle bugs
that bit them. Each bullet carries the mechanism plus the fixing PR/CalVer where
known; OPEN items are flagged.

## Application architecture (current layout)

- The web UI is **server-rendered Django templates** (`src/anthias_server/app/templates/`) with **Alpine.js + htmx + flatpickr** — there is **no React/Redux frontend**. TypeScript is thin page bundles (`home.ts`, `apps.ts`, `splash.ts`, `vendor.ts`) under `src/anthias_server/app/static/src/`, built by bun. Don't grep for React components — look in `_asset_modal.html` / `home.ts`, etc.
- Layout: server code under `src/anthias_server/` (`app`, `api`, `django_project`, `lib`); shared code in `src/anthias_common/`; viewer in `src/anthias_viewer/`; webview C++ in `src/anthias_webview/`; host agent in `src/anthias_host_agent/`.
- Integration tests (`tests/test_app.py`, Playwright sync API) run inside the `docker-compose.test.yml` `anthias-test` container against uvicorn on `localhost:8080`, started by `bin/prepare_test_environment.sh -s`. uvicorn does **not** auto-reload — restart it after editing server code, and never `pkill -f uvicorn` from a wrapper whose own cmdline matches.
- `anthias_common.board` is the canonical low-RAM system: `is_low_ram_device()` + `LOW_RAM_THRESHOLD_KB = 1_572_864` (1.5 GiB), reading host_agent-published `host:total_mem_kb` from **Redis** (keeps server/viewer consistent). Reuse it — do NOT reintroduce a private `/proc/meminfo` reader. It gates the processing.py 1080p video pixel cap and the Low-RAM badge, on both eglfs and cage/wayland stacks.

## Streaming under ASGI (backup download / large responses)

- **A sync `StreamingHttpResponse` generator does NOT stream under Django ASGI/uvicorn.** Django 5.2's `__aiter__` hits the sync-iterator branch and does `await sync_to_async(list)(self.streaming_content)` — draining the WHOLE generator into a RAM list before the first body chunk. The browser shows a download dialog stuck at 0 B until the entire build finishes, then times out. Also an OOM risk on 1 GB SBCs.
- This was issue **#3073**: the #3005 backup-streaming fix (shipped v2026.06.3) was fully neutralized because `stream_backup()` (`src/anthias_server/lib/backup_helper.py`) is a sync generator. Unit tests called it directly and never traversed ASGI `__aiter__`, so they passed while production buffered everything.
- **Fix:** the iterator must be async so Django sets `is_async=True` and uses the `awrapper()` streaming path. Wrap a sync producer with `async def` + `await sync_to_async(next, thread_sensitive=False)(it, sentinel)` per chunk. Regression tests MUST drive the real ASGI view via `aiter(response)` and assert the first chunk arrives before the generator is exhausted.
- **Backup E2E validated** (2026-07-08) on three 1 GB boards with a real ~640 MB archive: RSS peaked ~70-170 MiB, TTFB ~30 ms. Two surfaces:
  - **#3074 backup DOWNLOAD** = `POST /settings/backup/` (HTML view, CSRF-gated → fetch csrftoken from `GET /settings/` first; `StreamingHttpResponse(astream_backup())`). NOT the v2 API.
  - **#3143 recover UPLOAD** = `POST /api/v2/recover` (DRF, CSRF-exempt), streams to `static/<uuid>.tar.gz` via `file_upload.chunks()`.

## Viewer ↔ Redis pub/sub messaging

- **Command channel is `anthias.viewer`** (Redis pub/sub); server↔viewer request-reply uses `anthias.viewer` publish + BLPOP on `anthias.reply.<correlation-id>`.
- **Viewer subscriber can die silently and keep playing (OPEN).** Seen on a 1 GB arm64 board (~16 h uptime): `redis-cli pubsub channels` listed only `hostcmd`, **no `anthias.viewer`**, so `publish anthias.viewer "viewer blank"` returned `subscribers=0` and went nowhere — board deaf to next/previous/stop/blank, yet still cycling assets. `docker restart` restores it. **Diagnose with `redis-cli pubsub channels`, not the ready flag.** `viewer-subscriber-ready` lies: set once on subscribe, only cleared on `redis.ConnectionError`, so it stays `1` when the thread dies any other way. Suspected: `_consume()` raising anything that isn't `redis.ConnectionError` escapes `run()`'s except and kills `ViewerSubscriber.run()` for the process lifetime. No issue filed.
- **Subscriber topic-prefix trap:** the viewer subscriber splits commands via `data.partition(' ')` and drops anything without the `viewer ` topic prefix. `processing.py` celery publishes raw `'reload'` (no prefix) which would be dropped — unverified suspect.
- **stop/blank pause path — NOT a bug (issue #3136).** QA reported `stop`/`blank` not halting rotation on headless pi5, but it **did not reproduce** on the real pi5 RC (2026-07-08). The `global loop_is_stopped` fix (#3065) is an ancestor of the RC. The QA symptom was a measurement artifact (4 s durations → the natural `Showing asset...` line fires within ~20 ms of the publish). Recommend closing #3136 as not-reproducible.

## Playlist reload / SQLite WAL

- **First asset on a fresh install never auto-displays** (out-of-the-box bug, RESOLVED #3062). `scheduling.py` `refresh_playlist()` only calls `update_playlist()` when `get_db_mtime() > last_update_db_mtime`. `get_db_mtime()` stat'd the **main `anthias.db` file only**, but the DB runs in **WAL mode** — writes land in `anthias.db-wal`, so the main file's mtime stays frozen until a checkpoint → the trigger never fires. With an empty starting playlist `_compute_deadline()` returns `None` so the deadline trigger never fires either. `_handle_reload()` reloads settings only, NOT the playlist. And v2 create returned 201 WITHOUT `send_to_viewer('reload')`. Net: first asset never picked up until viewer restart or a main-db mtime bump (`touch anthias.db` instantly fixed it). **Fix #3062:** `get_db_mtime()` now takes max mtime across `.db`/`-wal`/`-shm`.
- **An asset's `uri` is immutable by design.** The edit modal renders `#edit-uri` `readonly` with no `name` attr (not submitted); neither `assets_update` nor `UpdateAssetSerializer` writes `instance.uri`. To change content you delete + re-create. "Edit the URL" silently no-ops — intended.

## Viewer memory / OOM behavior

- **Viewer stdout accumulation (issue #3138, fixed #3147, 2026-07-08).** `src/anthias_viewer/__init__.py` spawns `AnthiasViewer` via `sh.Command(...)(_bg=True, _err_to_out=True, ...)` with **no `_out` sink**. sh (2.3.0) accumulates the subprocess's entire merged stdout+stderr in viewer-process RAM for the whole session. Chatty decoder spam (e.g. ffmpeg AAC `channel element 0.0 duplicate` per frame) → unbounded RSS growth → swap thrash on 2 GB → multi-minute blank. Fix: a bounded `_BoundedWebviewOutput` as `_out` keeping the last 64 KiB (a **deque of chunks**, not `buf += chunk` reslice). Measured: OLD +76 MB RSS for 12 MB of spam; NEW +0.5 MB.
- **#3147 is NOT the fix for web-page OOM (forum thread 6731).** On a real 1 GB Pi3B+ (787 MB total, **no swap**) cycling web pages, `AnthiasViewer` emits ~304 KB one-time Qt startup dump then **~0 B/s**. The stdout leak needs *continuous* chatty stderr; bounding it changes nothing for web-page workloads. **Real pressure = Qt6 QtWebEngine footprint on a tiny board** — a 64-bit Pi3 OS now lands on the heavier Qt6 `pi3-64` stream (#2985, gated on userspace arch by #3076). **Fix #3178:** force Chromium's low-memory profile, gated on measured RAM via `is_low_ram_device()` (NOT board/DEVICE_TYPE — a 1 GB Pi5 exists). Prepend to `QTWEBENGINE_CHROMIUM_FLAGS`: `--enable-low-end-device-mode --js-flags=--max-old-space-size=64 --renderer-process-limit=1 --process-per-site --disable-dev-shm-usage`. HW-validated: Pi3B+ RSS 225→147 MB. Reverting Qt6→Qt5 is not an option.
- **Upgrade OOM at swapoff (issue #3165, fixed 2026-07-09).** `run_upgrade.sh` aborted at ansible `system : Disable swap` with `rc: -9` (OOM SIGKILL) on 1 GB Pi4: `swapoff --all` faults every used swap page back into RAM at once. Root cause: `bin/install.sh::main()` runs the ENTIRE `site.yml` (dist_upgrade then swapoff) while the **previous-version Docker stack is still running**, so swapoff fires at peak memory. Fix: (1) `stop_docker_stack()` in install.sh BEFORE `run_ansible_playbook` (guarded on the rendered compose file existing); (2) misc.yml memory guard runs swapoff only when `SwapUsed < MemAvailable - 64MiB`, with `retries:3 until rc==0 failed_when:false`. Validated on real 1 GB Pi4.
- **Asset upload must never wedge the device (P0 policy).** Uploading a normal asset pack must never make a device unresponsive. If it does, the asset-processor/walker hardening has a hole and the PR is not production-ready. Baseline hardening (not tunables): cgroup `cpus: 1.0`, CFS quota scaling, walker `--concurrency=1`, nice/ionice (PR #2885, from a Rock Pi 4 wedging on 4 parallel libx265 encodes). The fix belongs in the walker/cgroup/concurrency layer, never a docs note saying "upload one file at a time."

## Upload codec gate (by design, not bugs)

- The upload codec gate in `src/anthias_server/processing.py` (`_HW_DECODE_VIDEO_CODECS` map + `_hw_decoded_codecs()`) rejects any uploaded video whose codec the board can't **hardware**-decode — by design. It surfaces a "Failed" pill + a copy-pasteable ffmpeg re-encode recipe rather than shipping a clip that would SW-decode janky. `'pi5': frozenset({'hevc'})` — Pi 5 has no H.264 HW decode, so H.264 is rejected (Sentry ANTHIAS-1J). Unknown DEVICE_TYPE (catch-all `arm64`, host_agent never published `host:board_subtype`) → empty set → every video rejected (Sentry ANTHIAS-20).
- `UnsupportedVideoCodecError` is a handled user-facing outcome and should NOT reach Sentry. **Fix #3041:** added `throws=(UnsupportedVideoCodecError,)` to the `normalize_video_asset` celery task (sentry-sdk's CeleryIntegration skips `task.throws`).
- Separately, `KeyError: 'WEBP'` on all pi3 (Sentry ANTHIAS-1Y) was a **real** bug: on armv7 Pillow builds from source and links `libwebp7`+`libwebpmux3`+`libwebpdemux2`, but the runtime image only got the first two. **Fix #3042:** add the three webp runtime libs to `base_apt_dependencies` in `tools/image_builder/__main__.py`.

## Sentry noise vs real bugs (triage pattern)

- **Operator input validation leaking into Sentry as ERROR** (ANTHIAS-3D, `AuthSettingsError: New passwords do not match!`). Both settings-save surfaces caught it under broad `except Exception` + `logger.exception(...)`. Sentry's LoggingIntegration turns any ERROR-level log record into an event, so a password typo paged the team. **Fix #3068:** catch `AuthSettingsError` (a `ValueError` subclass with curated operator text) ahead of the generic handler; log at **warning** (no traceback → never a Sentry event) — the WARNING change is what actually stops the event; add to `_sentry_before_send`'s drop list as a backstop. **General rule: for input-validation Sentry noise, log at warning AND add to `before_send`.**
- **Celery 30 s hard-limit SIGKILL trio** (ANTHIAS-A / -9 / -B) are ONE incident fanned into three groups (9 and B carry no task identity). #3017 added `soft_time_limit` only to the asset-probe path, leaving `get_display_power` and `send_telemetry_task` with bare `time_limit=30`. Root cause for telemetry: `requests_post(timeout=5)` bounds connect+read but NOT `getaddrinfo` — a wedged resolver hangs DNS past 30 s → SIGKILL. **Fixed #3063 / #3180:** `soft_time_limit=30`/`time_limit=60` + `SoftTimeLimitExceeded` catch on every periodic task; display_power/telemetry-cooldown keys use a single `SET ... ex=` so a soft-limit signal can't strand them without a TTL.

## Remote media (self-signed HTTPS + preview) — forum thread 6726

- **Bug 1 — self-signed/untrusted HTTPS media (RESOLVED #3176).** Two independent walls: the webview image loader (`view.cpp`, `QNetworkAccessManager`) had no `sslErrors`/`ignoreSslErrors` handler → blank; and server `url_fails` (`src/anthias_common/utils.py`) rejected self-signed → `Asset.is_reachable=False`. `verify_ssl` was **vestigial** (never in UI, `verify=True` even when False since a 2019 regression, and only affected the server probe, never the webview). Fix: surfaced `verify_ssl` in UI + v2 API; fixed the `else: verify=False`; added PER-ASSET `Asset.skip_ssl_verify` (migration 0007) composed with the global; viewer passes effective skip to webview `loadImage`/`loadPage` D-Bus slots; webview `ignoreSslErrors` on the image reply + `certificateError` override/signal → acceptCertificate. Video needs nothing (Qt6 QMediaPlayer/FFmpeg already handshakes self-signed).
- **Bug 2 (OPEN) — remote-media backend preview blank (cert-independent, all remote jpg/mp4).** `assets_preview`/`assets_download` (`app/views.py`) only redirect-to-source for `mimetype in ('webpage','streaming')`. Remote `.jpg`/`.mp4` stored `mimetype='image'/'video'` → fall to `_safe_local_asset_path()` → no local file → 302 to home → blank. Regression from the React→Django rewrite. Not fixed by the SSL branch.

## Webview features (C++ / QWebEngine)

- **Per-asset custom HTTP request headers (#2215, merged #3162).** Stored in `Asset.metadata['headers']` as `{name: value}` (no migration). Path: model helpers (CR/LF-reject to stop header splitting) → v2 serializers → edit-modal textarea → viewer `view_webpage(headers=...)` over a new `setRequestHeaders` D-Bus slot → C++ `RequestHeaderInterceptor` (`QWebEngineUrlRequestInterceptor` on the shared profile). Works identically on **Qt 5.13+ and Qt 6** (one non-gated path). Interceptor is **same-host scoped** (exact host match) so a token never leaks to third-party CDN/analytics. Use case: private Grafana dashboard via a service-account `Authorization` token. Cold-boot race: first `setRequestHeaders` after spawn can lose to D-Bus registration; `view_webpage` caches headers only on success so a single-asset playlist self-heals next tick.
- **pi3-64 gapless in-slot video looping (PR #3174, "reduced-seam").** Switches overlay video+audio pipelines from flushing-seek restart to GStreamer SEGMENT playback (`videoview.cpp`). The original PR's bug: the init seek was issued **synchronously** right after `set_state(PLAYING)` while still prerolling (returns FALSE) → segment mode never engaged. Fix: enter segment mode from `GST_MESSAGE_ASYNC_DONE` (preroll complete), latching flags on seek SUCCESS. In segment mode the pipeline posts `SEGMENT_DONE`, never EOS — a failed non-flushing re-arm seek would freeze forever, so the segment-done handlers fall back to `gstRestartLoop` on failure. Loop drop ~6-10 → ~3 frames; NOT fully gapless (VC-IV decoder re-primes).

## PulseAudio / audio (Qt6 boards)

- **HDMI audio dies after reboot/restart (issue #3112, follow-up to #3001).** Symptom: `Daemon startup failed` → "video will play without audio". Real reason (hidden behind the generic line): `pid.c: Daemon already running` / `pa_pid_file_create() failed` — a **stale `${XDG_RUNTIME_DIR}/pulse/pid`** (`/run/user/1000/pulse/pid`). In Docker, `/run` is NOT a per-boot tmpfs — it's the container's writable layer — so the pid file survives `docker restart` AND host reboot. `--daemonize=yes` self-re-execs and racily reads the stale PID as a live `pulseaudio` → aborts (intermittent). **Fix:** `rm -f "${XDG_RUNTIME_DIR}/pulse/pid"` in `start_pulseaudio()` before the `pulseaudio --daemonize` invocation. Debug: `docker restart` reproduces faster than a host reboot (persistent `/run`); capture the hidden reason with `--log-target=file:... --log-level=debug`.

## Config file ownership (legacy upgrade)

- **anthias.conf root-owned crash-loop (RESOLVED).** On balena devices upgraded from an older root-running version, the viewer (uid 1000) hard crash-looped with `PermissionError: [Errno 13]` writing `/data/.anthias/anthias.conf` at `settings.py` `open(self.conf_file, 'w')`: the file was created root-owned `0600` by the old version. Chowning only the **dir** is insufficient — truncate-opening an existing file checks write permission on the **file**. **Fix:** `bin/start_viewer.sh` runs `chown -Rf viewer /data/.anthias`. Net: ONE `PermissionError` → restart → recursive chown → comes up. One-time self-healing transient, NOT a crash-loop — don't treat the single PermissionError as a blocker.

## Content import framework (inbound migration)

- **Extensible inbound import** (migrate content INTO Anthias from other signage platforms), under `src/anthias_server/lib/integrations/`. `base.py` = `ImportProvider` ABC (`validate_token`/`list_media`/`import_item`) + neutral dataclasses + `ProviderImportError` (transport-agnostic, REST or GraphQL). `ingest.py` = provider-agnostic download → `CreateAssetSerializerV2` → idempotency via `metadata.import_source = {provider, remote_id}`; download auth is **host-scoped** (`auth_host`) — never forward a token to a pre-signed CDN original. `http.py` uses a **neutral browser UA, NOT the Anthias UA** (a self-identifying UA invites vendor blocking). `registry.py` adds a provider in one line. Shared create path = `api/helpers.persist_new_asset`.
- **Scope = media only** (image/video/webpage); **filter non-portable content** (skip audio/documents, apps/internal web content).
- **Shipped providers** (merged 2026-07-08): **Yodeck (#3144)**, **ScreenCloud (#3145)**, **piSignage (#3148)**, **Xibo (#3149)**. **OptiSigns (#3146)** removed — file originals not downloadable at the tested tier. **NoviSign dropped** — no public read/list API. Confirmed auth formats: Yodeck `Authorization: Token <token>` (`/api/v2/`); ScreenCloud `Authorization: Bearer <token>`, region auto-detected by probing `graphql.{eu,us}.screencloud.com`; piSignage `POST /session` → JWT as `x-access-token`; Xibo OAuth2 client-credentials (`POST <cms>/api/authorize/access_token`), Bearer.

## Edge App CLI runtime (Screenly CLI as runtime)

- Edge App support runs the Screenly CLI (`screenly edge-app run`) inside the **viewer** container; the webview loads the loopback URL it prints. Three gotchas (all handled in code, don't regress): (1) **prebuilt CLI binaries are stale** — build the CLI from the pinned git tag for all Qt6 boards; build.rs needs only build-essential/pkg-config/libssl-dev. (2) **`edge-app run` needs auth + xdg-open** — any non-empty `API_TOKEN` works (`anthias-local`); ship a no-op `/usr/local/bin/xdg-open` shim. (3) **mock-data.yml must match the CLI's `MockData` struct exactly** — `metadata` requires all of coordinates/hardware/hostname/location/screen_name/tags; CORS-bypass apps fetch via a localhost CORS proxy (`edge_app_cors_proxy.py`).

## Provisioning

- **The Anthias Raspberry Pi Imager image does NOT enable SSH by default** (follows Raspberry Pi's default — off unless enabled via the Imager OS-customization gear dialog before flashing). balenaHub fleets DO have SSH (balena supervisor runs sshd). Any FAQ/docs/support copy must tell users to enable SSH via Pi Imager's OS customization before flashing.

## Cross-cutting testing/CI notes

- `pull_request` CI (unit tests etc.) runs on the ephemeral **merge** SHA (`refs/pull/N/merge`); Copilot review runs on the **branch-head** SHA. So `check-runs` on the head SHA shows only SonarCloud + copilot — find the test runs via `gh run list`. Copilot only ever COMMENTs (never approves), so merging master-protected PRs needs an admin override (`enforce_admins:false` + admin token → `gh pr merge --squash --admin`).
- SonarCloud gate blocks on `new_security_rating`: an `http://` literal (even in tests) trips S5332 → use `https://` or `# NOSONAR`. mypy runs `uv run mypy .` project-wide.

