Development log
The detailed, chronological build history of Aliran — every milestone as it landed, with its verification narrative (what was proven and how). Oldest first; newest at the bottom. The concise shipped-feature summary lives in CHANGELOG.md.
Project scaffold
- Initial project scaffold:
panel/,broadcaster/,client/structure. - Documentation under
docs/(getting-started, concepts, architecture with sequence diagrams, security model, operator guide, configuration, content management, client build, user management, reference, FAQ/troubleshooting, legal/compliance) + ADRs. ROADMAP.md(alpha → 1.0 milestones + optional modules) and a publishable MkDocs Material site config (mkdocs.yml).- Repository metadata: README, LICENSE (MIT), SECURITY, CONTRIBUTING, Code of Conduct.
v0.1 progress — "it streams" (verified)
- Broadcaster: ffmpeg → live HLS → encrypted Hyperdrive → Hyperswarm seeding
(
broadcaster/src/hls.js,index.js); persisted feed encryption key. - Localhost Range media server over a Hyperdrive replica (
tools/lib/serve-drive.js), ported into the client Bare worklet (client/backend/backend.mjs). - Desktop P2P viewer (
tools/viewer.js) + automated end-to-end test (tools/e2e-stream-test.mjs): a fresh peer discovers the feed over the DHT, replicates the encrypted feed, serves it locally, and ffprobe confirms valid H.264/AAC. P2P transport proven on desktop.
v0.2 progress — secure login (verified)
@aliran/core: OPRF (ristretto255), Argon2id verifiers, X25519 key sealing, proof-of-work — 6 unit tests (npm run test:core).- Panel: signed account/catalog control plane (
admin-cli), and a login RPC with proof-of-work + per-(user,peer) throttling + oblivious OPRF evaluation (panel/src/rpc.js,panel/src/index.js). - Client: runtime-agnostic OPRF login (
client/backend/login.mjs), wired into the Bare worklet; recovers per-user stream keys from the signed DB. - End-to-end test (
npm run test:login): panel + broadcaster + client login → entitlement → P2P playback (ffprobe-validated); wrong password rejected.
v0.2 progress — sessions & device limits (verified)
@aliran/core: per-user Ed25519 auth keypair (authKeyPair/authSign/authVerify) and panel-signed session tokens (token.js); +2 unit tests (8 total).- Panel:
sessionRPC — client proves login by signing the panel's challenge; the panel enforcesmaxDevices(evict oldest), issues a signed token, updates the signed record; revocation viatokenVersion. Enrollment storesauthPub/authPrivEnc. - Client:
login.mjscompletes the session step and verifies the returned token;checkSession()for offline validation. - Tests:
npm run test:session(device eviction, forged-sig rejection, revocation);test:loginnow also asserts a valid session token.
v0.2 progress — broadcaster auto-registration (verified)
- Panel generates a publisher keypair at
init(secret goes in the broadcaster.envasPUBLISHER_KEY). Broadcaster signs aregisterRPC; the panel verifies, writes the public catalog record, and stores the encryption key privately. broadcaster/src/register.js+ wired intoindex.js(auto-registers on start).- Test:
npm run test:register(register → private secret, unauthorized rejected, grant → login recovers the registered key).
v0.2 progress — assets pipeline (verified)
- Panel seeds an assets Hyperdrive; its key is advertised in the signed DB
(
meta/assetsKey).admin-cli upload-art <stream> <poster|backdrop|logo> <file>writes art and updates the catalog. Client opens the assets replica and serves/assets/*over the localhost media server. - Test:
npm run test:assets(upload → P2P replication → localhost serve, bytes match).
Android client progress — Bare backend runs on-device (verified)
client/is a realreact-native-tvos0.83 project (android/native build, app idcom.aliranclient);react-native-bare-kitboots a Bare worklet with JS↔Bare IPC.- The P2P backend bundles with
npm run bundle-backend(bare-pack,--preset android) and the full Holepunch native addon stack loads on Android — sodium-native (4.x and 5.x), udx-native, quickbit/rabin/simdle/crc, fs-native-extensions — packaged per-ABI by bare-kit'sbare-linkgradle step from npm prebuilds (no cross-compiling). - Worklet-runtime gaps shimmed:
node:crypto→@aliran/bare-node-crypto(sodium-backed WebCrypto, wired via the bare-pack global imports map), TextEncoder/TextDecoder/globalThis.cryptopolyfills (client/backend/globals.mjs), and an Android-aware corestore path (worklet cwd is/). - Verified on the emulator: the real
app.bundleworklet boots and reports{type:'ready'}(smoke screen inclient/src/WorkletSmokeTest.tsx).
Android client progress — OTT UI: login + catalog browse (verified)
- Login screen performs the OPRF login through the worklet (retrying while the DHT dials) and navigates Login → Home → Player (react-navigation, screens pinned 4.25.0).
- Home screen: category rails + featured hero + LIVE badges; poster/backdrop art is
replicated P2P from the panel's assets drive and served by the worklet's localhost
server, which now starts at login on one persistent port (asset URLs stay valid
across playbacks) — art fields arrive in
{type:'streams'}as ready-to-use URLs. - Android release builds permit cleartext HTTP only to loopback via a network security config — required for the on-device media/assets server (API 28+ blocks cleartext by default; also needed by the in-app video player).
- Player screen — live P2P playback verified on-device: tapping a channel sends
{streamId}to the worklet, which replicates the encrypted feed over the DHT, re-seeds it, and serves it on the session's localhost port;react-native-video(6.19.2, ExoPlayer HLS) playshttp://127.0.0.1:<port>/index.m3u8with LIVE badge, peer count, buffering indicator, and auto-retry while the live edge replicates. Verified end-to-end on the emulator: origin HLS → desktop broadcaster (ffmpeg → encrypted Hyperdrive) → Hyperswarm → phone worklet → ExoPlayer. - Client backend hardening: the Android store dir is created (not just probed) so a
fresh install or
pm clearcan't strand the worklet; playback errors now surface on the Player screen instead of an endless spinner. - Client store corruption recovery: the on-device Corestore is a disposable replica
cache, so if a crash mid-write leaves it unopenable (
OPLOG_CORRUPTand friends — previously permanent until the user wiped app data), the worklet now purges the store, rebuilds the panel connection, and retries the open once before surfacing an error (client/backend/recover.mjs); in-memory entitlements survive, everything re-replicates from peers. Test:npm run test:corrupt(reproduces the corruption locally, asserts detection + purge + clean reopen). - Android TV support verified on a TV emulator (remote-only): visible D-pad focus
rings on Login inputs/button and Home hero/cards (transparent border on phone, cyan
theme.colors.focusring on TV), and each Home rail is aTVFocusGuideViewwith focus memory (returning from the Player lands on the card you left). The manifest already declaredLEANBACK_LAUNCHER+leanback/touchscreennot-required from the tvos template. Verified on an Android TV (API 36, 1080p) emulator end-to-end with only D-pad key events: focus traversal → sign-in → browse → live P2P playback → Back to Home. @aliran/player-sdk(newsdk/package) — the player engine extracted from the app's Bare worklet into a reusable, runtime-agnosticAliranPlayerclass (connect/login/listStreams/resolve/serveFeed/assetUrl/stop+ eventsready|streams|status|peers|recovered|error). Runtime modules are injected (node:http/node:fsvia the Node entry,bare-http1/bare-fsin the worklet), so one graph runs headless in Node and on-device in Bare.login.mjsandrecover.mjsmoved into the SDK (canonical home); the old client/backend paths re-export them.client/backend/backend.mjsis now a thin IPC shell over the SDK — the IPC protocol and app behavior are unchanged (verified by the full e2e suite and on the Android TV emulator). New tests:sdk/test.mjs(unit) andnpm run test:sdk(headless e2e: real panel + broadcaster → SDK login → resolve → ffprobe-valid HLS over P2P, Range requests, peers ticker). Partial-adoption path: integrators can keep their own catalog and use onlylogin()+resolve()for the video URL.- Hybrid CDN↔P2P failover in the player SDK:
hybridconfig (mode: 'p2p-only'|'hybrid'|'cdn-only',start,cdnUrl(streamId),readyTimeoutMs,rebufferMsToFallback,probeIntervalMs). In hybrid moderesolve()returns the ACTIVE source URL: P2P if the feed playlist is available in time, otherwise the CDN (fallbackevent, reasonstimeout/stall); while on CDN the feed keeps replicating with the DHT lookup re-run each probe, and once the playlist advances across consecutive probes the source switches back (source-changedevent). Health checks are metadata-only (no blob downloads). Defaultp2p-onlykeeps the app worklet's behavior identical. Verified intest:sdk: unseeded stream + tiny timeout → CDN fallback (local HLS file server); broadcaster starts → auto-return to P2P and the local playlist serves. @aliran/react-native(newsdk/react-native/package) — drop-in RN binding:AliranBackendhosts the engine in the Bare worklet (bundle supplied by the host app; caches streams/port/url/source) and<AliranVideo>renders the ACTIVE source on react-native-video with the proven error-retry remount, switching sources onfallback/source-changed. The worklet IPC gained hybrid pass-through ({panelPubKey, hybrid}— cdnUrl as a JSON-safe{streamId}template) plusfallback/source-changedrelays andurl/sourcein the port reply (additive). The app now dogfoods the binding (client/src/worklet.tsis a thin wrapper, PlayerScreen uses<AliranVideo>+ a P2P/CDN source badge). Verified on the Android TV emulator with a hybrid dev descriptor: live P2P playback through the binding → kill the broadcaster →fallback(stall) switches playback to the CDN (video keeps playing) → restart the broadcaster →source-changedreturns to P2P.- Media-server hardening (found by the on-device hybrid test): players abort
in-flight requests routinely (source switch, seek, teardown), and writing into the
closed response was an unhandled stream error that killed the whole Bare worklet
(SIGABRT). The Range handler now tolerates client aborts on both paths, and the
worklet installs a last-resort
uncaughtExceptionguard that reports over IPC instead of crashing the app. - Knowledge base (
docs/kb/): public, field-tested symptom→cause→fix pages — playback & client runtime, operating the panel/broadcaster, Android/RN build traps, and Bare worklet/bundling lore — wired into the MkDocs nav and linked from the README and FAQ.
Catalog live-push (verified)
- Panel catalog edits reach connected clients live — no polling, no re-login: the
player SDK (
sdk/player.js) watches the replicated signed DB'scatalog/range (bee.watch) and re-emitsstreamswith fresh display metadata (title, isLive, art URLs) whenever a record changes; the watch re-arms automatically after a store-recovery purge. The Bare worklet already relaysstreamsover IPC ({type:'streams'}, same shape) and the app's Home screen already re-renders on that message, so updates reach the UI with no app change. Entitlements are untouched: a newly granted stream still appears on the next login. Verified innpm run test:sdk: a catalog record is edited while a headless client is connected and the update must arrive as astreamsre-emit withoutlogin()ever being called again.
Broadcaster control API (verified)
- Runtime start/stoppable channels (
broadcaster/src/channel.js): the broadcaster is now multi-channel — aChannelManagerpersists a channel registry (DATA_DIR/channels.json) and each channel owns its ingest→ffmpeg→encrypted- Hyperdrive→Hyperswarm pipeline with a stable per-channel feed identity (channels/<id>/feed.key). Back-compat: the env-configured stream keeps the legacyDATA_DIR-root store +feed.key, so existing deployments and pre-seeded feed keys keep their identity (proven: same feedKey/encKey across the old layout, a restart, and an API start). - Control HTTP API (
broadcaster/src/control-server.js): opt-in (CONTROL_ENABLED=1, default127.0.0.1:3310), same auth pattern as the panel admin API — admins viacontrol-cli add-admin(Argon2id, localDATA_DIR/secrets/admins.json), session tokens signed with an auto-generated broadcaster-local keypair, login lockout, equal Argon2 work for unknown names. Endpoints: login, status, channels (list with live ffmpeg/peers/registered/playlist status, add, edit, remove, start, stop). Panel registration reuses the publisher-keyregisterRPC unchanged. Verified bynpm run test:broadcaster-api: add+start a channel over HTTP → registers with a real panel over Hyperswarm + a fresh viewer replicates the encrypted feed P2P and ffprobe validates the media → clean stop (ffmpeg down) → restart with the same feed key → lockout enforced.
Broadcaster control UI (verified)
- Web control UI (
broadcaster/control-ui/) served by the control server at/whenCONTROL_ENABLED=1— plain HTML/JS/CSS, no build step, consumes only the control API (panel/admin-ui sibling: token in sessionStorage, free text escaped, any API 401 drops back to the login view). Sign in → status chips (channels / running / panel configured), add-channel form (with the feed-encryption-key dialog), and per-channel cards with live status polled every 5 s — ON AIR badge, ffmpeg health (incl. exit code on silent death), peer count, panel registration (with the register error on failure), playlist presence, uptime — plus start/stop (confirm on stop), metadata/input/HLS editing ("applies on next start" when running), and remove (guarded while running; feed identity kept on disk). Channel art is deliberately absent: it is a panel admin operation (the register RPC carries no art), and the UI says so. Verified intest:broadcaster-api(control UI static files,%2e%2e/backslash/dotfile requests 404, non-GET 404) and end-to-end in a browser: a channel added and started from the UI registered into a live panel's catalog and a freshtools/viewer.jspeer replicated and played the encrypted feed (ffprobe-valid h264/aac), with the card's peer counter showing the viewer.
Panel admin dashboard (verified)
- Web dashboard (
panel/admin-ui/) served by the admin server at/whenADMIN_ENABLED=1— plain HTML/JS/CSS, no build step, consumes only the admin API. Sign in → status chips (users/streams/live/panel key), user management (create, rotate password, disable/enable, device list, device limit, logout-all, grant/revoke chips) and stream management (add — with the one-time encryption-key dialog —, metadata editor, poster/backdrop/logo upload with live preview). Art previews are fetched with the session token via the new authedGET /api/assets/:id/:fileendpoint and rendered as blob URLs; static serving is GET-only from a flat directory (traversal-guarded) with a strict CSP. Verified intest:admin-api(static files, traversal 404, authed assets round-trip) and end-to-end in a browser against a live panel: every dashboard action (user, stream, grant, metadata, poster) was then received by a real viewer login over Hyperswarm, unsealing the exact key the dashboard displayed.
Panel admin API (verified)
- Shared ops layer (
panel/src/ops.js): every admin operation (users, streams, grants, art, admin accounts) has one implementation used by BOTH the CLI and the HTTP API, so the two cannot drift.create-userandadd-streamnow refuse to overwrite existing records (previously a silent overwrite destroyed grants / rotated the stream secret); newset-status(disable revokes sessions) andrevoke <u> <stream>commands. - Admin HTTP API (
panel/src/admin-server.js): opt-in (ADMIN_ENABLED=1, default127.0.0.1:3210) and served from the panel process — the Corestore is single-writer, so a separate admin process would hit ELOCKED. Auth: admin accounts (admin-cli add-admin, Argon2id verifiers in the panel-privateDATA_DIR/secrets/admins.json, never replicated) → panel-signed session tokens (core/token.js,role:'admin', revocable via tokenVersion); login shares the fixed-window lockout from the login RPC, with equal Argon2 work for unknown names. Endpoints: login, status, users (create/password/status/logout-all/max-devices/ devices/grant/revoke), streams (add/meta/art upload/list). Verified bynpm run test:admin-api(bad creds + lockout rejected; every HTTP write asserted in the signed DB/secrets/assets; then a real viewer logs in over Hyperswarm and unseals the granted stream key).
Continuous integration
- GitHub Actions CI (
.github/workflows/ci.yml): every push tomainand every pull request runs the fast deterministic suites (test:core,test:corrupt) as the required job, plus a best-effort second job for the end-to-end suites that use the real Hyperswarm DHT (test:session/test:register/test:assets/test:admin-api, and — with ffmpeg installed —test:sdk/test:broadcaster-api). Each e2e suite retries once and is time-capped (a peer that never connects hangs rather than fails), and the job iscontinue-on-errorso hosted-runner DHT flakiness can never block a merge.
Deploy pack (Docker Compose + systemd + TLS recipe)
- The Docker images build now.
panel/Dockerfileandbroadcaster/Dockerfilepreviously depended on@aliran/coreas a registry package (it is an unpublished workspace) and could never build. They now build from the repo root context, vendorcore/into the image, and resolve it via afile:../coredependency (panel + broadcaster package.json — same pattern the SDK already used). Images pinned tonode:24-bookworm-slim(matches CI); the broadcaster image ships Debian's ffmpeg (includes libsrt). A root.dockerignorekeeps local state, secrets, and the React Native tree out of the build context. docker-compose.ymlusesnetwork_mode: host(deliberate: no double NAT on Hyperswarm hole-punching, dashboards keep their loopback-only default binding, future push-ingest ports need no compose edits) with named data volumes and restart policies; first-run key/admin bootstrap documented inline.- systemd units (
deploy/systemd/aliran-panel.service,aliran-broadcaster.service) for the bare-metal path: auto-restart,EnvironmentFile, sandboxing (ProtectSystem=strict, data-dir-only writes), VAAPI note for GPU transcode. - TLS for the dashboards:
deploy/Caddyfile.example(two-liner reverse proxy, automatic HTTPS) and the no-domain SSH-tunnel alternative, both documented in a rewrittendocs/operator-guide.md(Compose + systemd quickstarts, firewall table — P2P still needs no inbound ports). - CI builds the images: a new non-blocking
docker-buildjob builds both Dockerfiles on every push and smoke-runs them (admin-cli initin a throwaway container; ffmpeg-protocolsmust listsrt).
Verified on a real VPS over the internet
- The whole stack now has an internet-grade proof, not a localhost one. Deployed
via
docker composeon a fresh 1 vCPU / 1 GB Ubuntu VPS (keys + admin accounts bootstrapped with the documented commands, dashboards enabled), two live channels created and started through the control API. From a machine on a different network,tools/acceptance-remote.mjs(new) logged in as a real viewer through the public DHT — no IP, no ports, just the panel public key — replicated both encrypted feeds concurrently, and ffprobe-validated the media (~8–20 s to first playable segment). Catalog assertions (--expect-live) held; both dashboards verified over the documented SSH tunnel;docker compose restartself-heals (channels currently need a manual Start after a broadcaster restart — auto-resume is on the roadmap with the watchdog). tools/acceptance-remote.mjs: reusable remote acceptance harness — oneAliranPlayerper stream (own store = N real concurrent viewers), login retry that never trips the panel throttle (pre-connection failures are local), per-stream PASS/FAIL table (time-to-play, segment bytes, peers),--expect-live/--expect-offcatalog assertions, exit 0 iff everything passed.- SDK teardown race fixed (found by the acceptance run): a swarm connection
whose handshake was in flight when
stop()(or a recovery purge) nulled the store crashed the process —sdk/player.jsnow drops such late sockets. The same class of crash would have taken down the app worklet. - Compose hardening:
DATA_DIR=/datais pinned viaenvironment(wins overenv_file), so a copied bare-metal.env(DATA_DIR=./data) can no longer silently divert container data off the persistent volume. - Operator guide: sizing notes for small VPSes (swap +
ARGON2_MEM_KIB=65536, ~1 vCPU per two test channels), restart note, tunnel/Caddy verified flows.
Broadcaster ingest engine + transcode incl. GPU (verified)
- Push ingest: a channel's
inputis now a typed object —{kind:'rtmp', port, streamKey}(OBS-style FLV publish via-listen 1; one publisher per port),{kind:'srt', port, passphrase?, latencyMs}(the passphrase is enforced by the libsrt handshake — the recommended authenticated push; an RTMP stream key is obscurity, not auth), and{kind:'udp', port, timeoutMs}(raw MPEG-TS) — alongsidetest/file/pull. Strings auto-upgrade everywhere (envINPUT, pre-existingchannels.json, API bodies), soINPUT=rtmpfinally implements the documented default: an RTMP listener onRTMP_PORTwith a generated stream key that is persisted across restarts and printed as a push URL at startup. Push ports are validated 1024–65535, unique across channels, and auto-allocated fromINGEST_PORT_BASE–INGEST_PORT_MAX(default 5000–5999) when omitted;channels.jsonis written mode 0600 now that it holds stream keys/passphrases. - Pull pacing fixed:
-reused to be applied to every URL; live sources (rtsp/rtmp/srt/udp pulls and live.m3u8) pace themselves and now run without it — onlytest, local files and plain-http VOD keep-re. RTSP pulls get-rtsp_transport tcp. - Per-channel
transcode(absent = previous behavior):encoderlibx264|copy|h264_nvenc|h264_qsv|h264_vaapi|h264_amf,resolution(source/1080p/720p/480p/360p),fps(source/24/25/30/50/60),videoBitrateKbps(sets-b:v -maxrate -bufsize 2×; null keeps CRF),audioBitrateKbps,preset fast|balanced|qualitymapped per encoder (x264 veryfast/medium/slow; nvenc p2/p4/p6 +-tune ll; amf speed/balanced/quality). Keyframes are segment-aligned on every encoder via-force_key_frames expr:gte(t,n_forced*hls.time)(replaces-g 60, which only lined up at 30 fps);copypassthrough requires source resolution/fps + no bitrate (audio still transcodes to AAC) — the publisher must send a keyframe everyhls.timeseconds. VAAPI/QSV get their hardware-device init (-init_hw_devicebefore-i; VAAPI adds theformat=nv12,hwuploadchain). - ffmpeg capability probe (
broadcaster/src/capabilities.js), warmed at manager init and cached: protocols from-protocols(rtsp via-demuxers— it is a demuxer, not a protocol), h264 encoders from-encoders, and every listed HW encoder deep-verified by actually encoding 8 test frames — "listed" only means compiled in.start()refuses cleanly (HTTP 400 with the probe's error, no silent fallback) when a channel needs an unavailable protocol/encoder, and bind-tests push ports first so a taken port is an API error instead of an ffmpeg crash loop. - Tests: new
npm run test:args(pure argument/validation table, no ffmpeg);test:broadcaster-apigrew an RTMP push round-trip (a second local ffmpeg publishes → HLS → encrypted P2P feed → ffprobe validates), a UDP-TS push, typed-input validation over HTTP (auto-alloc, uniqueness, PATCH stream-key inheritance) and the capability/port gates. On the dev box the probe deep-verifiedh264_qsv(Intel) — a real QSV channel encode produced spec-exact output — and rejected nvenc/amf/vaapi with their true driver errors.
Panel admin completeness (verified)
- Admin-account management everywhere:
GET/POST /api/admins,DELETE /api/admins/:name,POST /api/admins/:name/passwordon the panel admin API and the broadcaster control API, plus CLIset-admin-password/list-adminson both. A password rotation bumps the admin's tokenVersion, so every session issued under the old password (including the caller's own) dies immediately. - Stream delete = FULL purge (
DELETE /api/streams/:id, CLIdelete-stream): removes the catalog record, the panel-private encryption key, every user's sealed grant, and the stream's art from the assets drive. Clients converge on the next catalog push. Honest caveat: a client that already unsealed the key may have it cached — real revocation of live content is a stream-key rotation — and re-adding the same id mints a fresh key.DELETE /api/users/:u/delete-userremoves an account record (already-issued tokens ride out their offline validity window). - User search + cursor paging:
GET /api/users?prefix&after&limit→{users,next}over a Hyperbee key range (prefix-only by design; substring search would scan the whole replicated DB). - Observability:
GET /api/observability→ uptime, memory, swarm connections/peers, data-dir size + free disk, and an in-memory ring of the last 200 events (viewer sessions, broadcaster registers, every admin mutation — feeds frompanel/src/activity.js; cleared on restart by design). - Curation fields:
order(0–9999, nullable) andfeatured(bool) are typed insetMeta/addStream, and the broadcaster register merge now preserves them (plus art) so a re-register never erases admin curation. - Per-device revoke (cooperative):
DELETE /api/users/:u/devices/:deviceId/ CLIlogout-deviceremoves one enrollment WITHOUT a tokenVersion bump; new SDKsessionLive(db, payload)(companion tocheckSession) notices against the replicated record so well-behaved clients drop to login. Documented plainly as session hygiene, not content protection. - Verified end-to-end by
test:admin-api(admins lifecycle with token revocation, purge reflected in bee+secrets+grants+assets, paging, typed curation incl. register-merge preservation, device revoke +sessionLive, observability shape) - a control-API admins section in
test:broadcaster-api; regressionstest:core/test:session/test:register/test:sdkgreen.
Panel dashboard UI for the admin surface (verified)
- The web dashboard (
panel/admin-ui/, still vanilla no-build HTML/JS) now covers the full admin-API surface: - Admins tab — add/remove admins and rotate passwords from the browser; rotating or removing your own account warns explicitly and signs you out (the API kills the token server-side; the UI drops to the login view).
- Users — prefix search box driving the paged API + a cursor “Load more” button; a delete flow whose confirm dialog states the offline-token caveat. The devices dialog lists enrollments with a revoke ✕ per device and explains the cooperative semantics (no token bump; other devices stay in).
- Streams — inline curation controls (order 0–9999 number input, featured toggle with hero hint; ★ FEATURED badge) and a permanent purge flow that requires typing the stream id and spells out the key-rotation caveat.
- Overview tab — health chips (uptime, rss/heap, swarm connections/peers, data size, disk free) + the last-200 activity feed, polling every 10 s while the tab is open (and only then).
- Verified by the extended
test:admin-apistatic assertions and a live browser session against a temp panel exercising every flow above end-to-end — finishing with a real Hyperswarm viewer login that unsealed the exact key the dashboard displayed once, showed up in the devices dialog and the activity feed, and was then revoked from the UI.
SDK/app curation passthrough (verified)
- The SDK display list (login result AND catalog live-pushes) now carries the panel's
curation hints
order/featured(sdk/login.js,sdk/player.js_display) — still metadata-only, stream keys never leave the engine — and the React NativeStreamtype declares them. - The app consumes them via a new shared module (
client/src/catalog.ts): rails and channel lists sort by(order ?? Infinity, title), the hero/wallpaper pick prefers the first featured live stream, and channel numbers are derived (1..N over the curated sort — never stored). test:sdkasserts the fields flow through login and live-push, and that uncurated records stay bare.
Android app GUI redesign — live-TV-first, white-label (verified on device)
- New information architecture (modeled on a commercial IPTV reference app's
organization — layout only, no assets):
Splash → [Login] → Menu → { Live, Favorites, Search, Settings }. - Splash auto-auth: the app boots behind a branded splash ("Authorizing device…")
and signs in with saved credentials; Login is now the exception path. "Remember me"
credentials + favorites persist in the app-private files dir beside the
disposable store (
aliran-prefs.json— corruption recovery can't wipe them), via new additive worklet IPC (prefs-get/creds-save/creds-clear/favorites-set); the SDK binding exposes them (requestPrefs/saveCredentials/clearCredentials/toggleFavorite) and never logs the prefs payload. Sign-out (Settings) clears them. - Live TV is ONE fullscreen surface (
<AliranVideo>base layer) with overlay panels — playback never stops while browsing: a category rail (accent-underline focus) + a numbered channel list (derived numbers: curation order → title, 1..N, never stored; light row-fill focus; LIVE badges; ★ favorites; now-playing line from the catalog description) and a channel-detail panel (art, chips, synopsis, P2P/CDN source + peer stats, favorite/watch actions, and an honest "No program information" placeholder where an EPG lands later — no fake guides). Selecting a row switches the stream in place; selecting the playing row collapses to fullscreen; D-pad up/down zaps with an auto-hiding number+name OSD; BACK walks detail → list → fullscreen → menu. Phone gets the same IA by tap (D7). - Menu hub: horizontal icon bar over the featured stream's backdrop (panel curation picks it) with a dark scrim; sections are descriptor-driven (favorites/search/settings toggles; Exit is TV-only by default; VOD hidden until it ships). Old Home/Player screens dissolved into the new surfaces.
- White-label wiring:
makeTheme(descriptor)mergesbranding.colorsover defaults (full token set incl. overlay/brand-surface/focus-fill/onPrimary); screens and components contain zero hardcoded brand strings or hex colors (grep-clean — tokens live only intheme.ts; the brand name renders fromservice.json). 10-foot sizing follows the Google TV design guidance (type ramp, overscan-safe margins, three-part focus grammar). - SDK: outbound IPC now queues until the worklet starts (a splash asking for prefs during boot can't crash the binding).
- Docs:
docs/client-build.mdgains the app-structure map, the prefs/white-label contracts, and a gradle gotcha (the release JS-bundle task doesn't trackconfig/*.json— deletegenerated/assets/reactafter descriptor swaps).
Ephemeral feed buffer — live segments stop accumulating (verified)
- The live feed is now a rolling buffer, not an archive. The playlist defines
which segments exist; the drive mirror deletes rotated-out files AND reclaims
their blob storage (
hypercore clear()below the live window's low watermark) — a channel that streams for days occupies O(window) space. Previously the append-only feed grew ~1–2 GB/h/channel and filled a 19 GB VPS disk in a day. - Window deepened for P2P delivery: 16 segments × ~4 s (≈64 s) — new
HLS_TIME/HLS_LIST_SIZEdefaults (was 6 × 2 s), so peers hold a meaningful shareable window to re-seed each other. - RAM session feeds by default (
FEED_BUFFER=ram, per-channelbufferfield): segment data never touches disk; each channel start mints a fresh session feed keypair and re-registers it (re-using a keypair over an emptied store would fork the core and break replicas). The persisted encryption key never rotates on restart, so user grants stay valid.FEED_BUFFER=diskkeeps a stable feedKey across restarts with the same window-bounded storage. - The player SDK follows broadcaster restarts without re-login:
resolve()reads the CURRENTfeedKeyfrom the replicated catalog at play time (the sealed per-user encryption key is unchanged); a re-KEYED stream still requires a fresh login, deliberately. - New deterministic
test:retention(rolling mirror → entries deleted, expired blocks freed, storage bounded across 40 rotations) wired into the required CI lane alongsidetest:args;test:broadcaster-apiupdated to the session-core contract (restart ⇒ new feedKey, catalog follows, encryption key stable).
Feed buffer defaults to disk — faster time-to-play (verified)
FEED_BUFFER=diskis now the default (ramstays available per-env or per channel). A RAM feed mints a fresh keypair every start, so itsfeedKey/DHT topic changes on each restart and viewers re-pay a cold DHT discovery every time (time-to-play ~40–55 s). Disk keeps a stable feed identity: returning viewers rejoin a warm topic and resume their on-disk replica (~10 s), while the same rolling reclaim keeps storage window-bounded.ramremains the choice when the host disk must stay byte-flat.- HLS window default is now 8 × ~2 s (≈16 s) (was 16 × ~4 s in code / 6 × 2 s in
.env.example— the two are now consistent). Short segments cut time-to-first-frame;HLS_LIST_SIZEis the P2P-shareability knob — deepen to 12–16 for large swarms.normalizeMeta/ensureLegacynow inherit the configured HLS default instead of a stale hard-coded2/6. test:broadcaster-apiadds Test F3: adiskchannel resolves its feedKey before first start and keeps it stable across restart (RAM's session-core contract is now asserted with an explicitbuffer: 'ram'). New KB pagedocs/kb/feed-buffer.md(disk vs ram, the cold-DHT explanation, window sizing; clarifies the transport is Hypercore, not WebRTC).
Fix: channel zap no longer hangs on flip-back (verified)
- Re-zapping to a channel already opened in the session wedged
resolve():serveFeedopened a second Hyperdrive over the same store namespace andready()deadlocked against the still-open first drive (it also leaked a drive + swarm topic on every switch).sdk/player.jsnow caches opened feeds by key and reuses them;stop()/recovery-purge close every cached feed. Flip-back is near-instant (the replica is warm) instead of hanging. - Measured warm-session zap over the public DHT: ~1.2 s to a new channel, ~0.3–0.4 s
back to a watched one (
resolve()~1 ms on reuse) — vs an indefinite hang before. test:sdkgains anews → movies → newszap regression (boundedresolve()so a future re-zap hang fails fast). KB: zapping sections added todocs/kb/feed-buffer.mdanddocs/kb/playback.md.
Feat: pre-warm entitled feeds at login — warm first zap (verified on-device)
- New
prewarmoption (AliranPlayer+ the@aliran/react-nativebindingstart()): after login, open+join entitled feeds' DHT topics in the background so the cold discovery is paid upfront and even the FIRST play/zap to a channel is a cache hit, not a cold open.false(SDK default) |true(all) | integer (cap, lowest curated order first). The app enables it (bounded). Bandwidth-cheap: sparse replication warms the connection, not a full download. serveFeedsplit intoserveFeed+_openFeed; the feed cache is now single-flight (stores the open PROMISE), so a prewarm and a concurrent zap of the same feed share one Hyperdrive — never a second open on the same namespace (the deadlock this guards). Feed teardown (stop/purge) closes via a shared_closeFeeds(fire-and-forget, deadlock-safe against an in-flight open whose recovery triggered the purge). Catalog-follow extracted to_currentFeedKey, shared byresolve()andprewarm().test:sdkasserts prewarm opens all entitled feeds at login and that served feeds are then all warm (onlyfeed:ready, neverfeed:open). Verified on the emulator (release APK): first play of ch1 and first zap to ch2 bothfeed:readywith nofeed:open.
Feat: viewers follow a rotated feed WHILE watching — no re-zap (verified headless)
- Completes the broadcaster's auto-rotate-on-source-change (
6e38b90): the SDK already read the catalogfeedKeyatresolve()time, but a viewer already watching a channel kept replicating the OLD feed when the broadcaster rotated it — the new key was only picked up on a manual re-zap.sdk/player.jsnow re-resolves the ACTIVE stream off the replicated catalog watch: when the watched stream'sfeedKeychanges, it opens the new feed (single-flight cache), swaps the served drive behind the unchanged localhost port, and emits a newfeed-changed({streamId, feedKey, url}) event. The per-user encryption key is untouched (a re-KEY still needs a fresh login); a zap mid-open never clobbers the newerresolve()'s drive. - Relayed end to end: worklet IPC (
client/backend/backend.mjs) →@aliran/react-native(feed-changedmessage +<AliranVideo onFeedChanged>, which remounts the player onto the same URL to flush the stale playlist) → the app'sLiveScreen(clears any prior playback error and shows the spinner while the new feed buffers). test:sdkgains a rotation-while-watching case: publish a freshfeedKey(same sealed encryption key) for the stream being served → the SDK emitsfeed-changed, swaps_feedDriveto the rotated feed, and serves its playlist on the same port, all with noresolve()call. The client half needs an APK rebuild to verify on-device.
Live-TV playback polish — immersive, whole-picture fit, cleaner OSD (verified on S22)
- Landscape lock (
AndroidManifestscreenOrientation=sensorLandscape) and the Menu hub's option bar centered — the TV-style UI no longer renders squeezed into portrait. - Sticky-immersive chrome (
MainActivity): both system bars are hidden so nothing distracts from playback; a swipe in from an edge reveals them transiently, then they re-hide. Edge-to-edge, transparent bar colors, re-applied on window-focus changes. LiveScreenIA rework: fullscreen is clean — no LIVE/peer/source/buffering chrome (diagnostics stay in Settings). A tap/OK peeks a bottom OSD that auto-fades; BACK opens the "left menu" (category rail + channel list), which lost its manual close control and now auto-hides after inactivity; BACK from the left menu exits to the hub.AliranVideousesresizeMode="contain"so the whole 16:9 picture and channel bugs stay visible.- Enriched OSD: channel logo (when present) + derived number + title + a live wall clock, separated by a divider; no fake HD/now-next until real EPG data exists.
- Patch react-native-video 6.19.2 (
patches/react-native-video+6.19.2.patch, wired viapatch-packagepostinstall): itsExoPlayerViewhard-codes a top-left red "LIVE" badge that ignorescontrols={false}; the patch keeps it hidden soAliranVideostays chrome-free (its own overlays own all badges).
Feat: broadcaster reliability — auto-resume, isLive:false on stop, ffmpeg log ring (verified)
- Completes the broadcaster-reliability arc (the ffmpeg watchdog and feed-rotation landed earlier in
6e38b90). - Auto-resume on boot. A started channel now persists its desired state
(
desiredRunninginchannels.json);ChannelManager.init()reconciles the registry on boot and restarts channels that were running — no more "the broadcaster rebooted and the channels are dead." The env/legacy channel stays underindex.js's explicit,STREAM_ID-gated start (so it is never double-started). isLive:falseon stop — zero panel changes. The per-channel panel Hyperswarms are replaced by ONE manager-ownedPanelLink(broadcaster/src/panel-link.js) with a per-stream, latest-state-wins op queue.start()enqueues the live register;stop()enqueues{streamId, feedKey, isLive:false}and waits ≤5 s for it to land (the original catalog live-push flips watching clients instantly); a graceful shutdown does the same for running channels while keeping them marked for auto-resume. Register cycles are serialized — the panel keeps one challenge per socket, so interleaved hello→register pairs on a shared socket would fail. A boot catch-up re-assertsisLive:falsefor every non-resumed channel, healing a catalog left stale-LIVE by an unclean crash; a 5-min heartbeat idempotently re-asserts running streams.status.registered/registerErrornow come from the link's per-stream op state (shape unchanged).- Per-channel ffmpeg log ring (
hls.jsonLine→ a 400-line{t,line}ring on the Channel): the raw stderr diagnostics for "why won't this source play." It survives ffmpeg respawns (a watchdog restart appends a marker line) and clears on an operator start. The control-API/UI surface for it lands in the next entry. test:broadcaster-apiadds Test M (log ring populated; watchdog respawns a killed ffmpeg with a restart marker; stop flips the panel catalog toisLive:false) and Test N (a secondChannelManagerover the same dataDir auto-resumes adesiredRunningchannel and catches up a stale-live entry to idle).test:core/test:register/test:sdkregression-clean.
Feat: hybrid art — https URLs alongside the P2P assets drive (verified)
- Catalog art fields (
poster/backdrop/logo) now accept an absolutehttps://URL in addition to theassets/…drive path. The SDK's URL transforms (_display→_artUrlandassetUrl) pass absolute URLs through unchanged — previously an https poster was mangled intohttp://127.0.0.1:<port>/https://…and 404'd. Viewers fetch remote art directly from the operator's web host; uploaded art keeps replicating P2P. The two forms mix freely per stream and per kind. - Panel
setMetanow validates art fields (was: any string):assets/…path orhttps://URL, ≤1024 chars, no line breaks; empty string clears. https is required for remote art — Android blocks cleartext off-loopback, sohttp://would fail silently on devices. - Dashboard: each art slot gains a "url" button (set/clear a remote URL beside the upload); remote art renders directly in the card, drive paths keep the authed-fetch→blob path.
test:assetsextended: validation matrix + the SDK display transform (https passthrough, drive path → localhost).test:sdkregression-clean.
Feat: broadcaster control API + UI for ingest/transcode/logs (verified live)
- API:
GET /api/capabilities(the boot-time ffmpeg probe: protocols + deep-verified encoders);GET /api/channels/:id/logs?lines=N→{lines:[{t,line}], running, restarts, state}(the per-channel log ring, ≤400 lines); channel status gains a top-levelstate(stopped · starting · up · waiting-input · backoff) and, for push channels,ingest.pushUrl(rtmp/srt/udp URL built fromPUBLIC_HOST). - Control UI: add-form ingest-kind selector that hides push protocols the host
ffmpeg lacks; copy-paste push URL on push-channel cards; Edit dialog with
kind-dependent ingest fields (port/streamKey/passphrase/latency/timeout) and the
full transcode form (encoder select from capabilities — unverified encoders
disabled with the probe error as tooltip — resolution/fps/bitrates/preset, hidden
for
copy); per-channel Logs dialog refreshing every 2 s + the last stderr lines inline on an unhealthy card; honest state badges (ON AIR / WAITING FOR PUBLISHER / RETRYING (exit N)); "SRT + passphrase = authenticated push" hint. test:broadcaster-apiadds Test O (capabilities auth+shape, state + ingest.pushUrl, logs API incl.lines=cap and 404,waiting-inputon an idle listener, UI statics). Verified in a live browser session: UI-created RTMP channel → WAITING FOR PUBLISHER → local ffmpeg push → ON AIR → logs dialog streaming → stop → panel catalogisLive:false.
Feat: live-TV UX — tuning indicator, resume, touch nav, bottom control bar (verified on S22)
- Channel-change indicator (
client/src/components/ChannelChangeIndicator.tsx): top-right pill with spinner + channel number/title + a 0→100% progress bar while a zap/select (or a fresh entry) tunes. The % is an optimistic ease toward ~90 that snaps to 100 on the player's real first-frame signal (onBuffering(false)/onReadyForDisplay) — live HLS has no honest mid-switch %, so only "done" is real. - Resume last channel: leaving Live for the Menu and re-entering resumes the last
watched channel fullscreen (session-scoped
lastStreamId; explicit picks from Favorites/Search still win; cold boot falls back to the hero + browse list). - Touch navigation: fullscreen tap/OK now OPENS the left browse menu; BACK from the menu collapses to fullscreen (was: exit); BACK from fullscreen exits to Menu; BACK from channel detail returns to the list. The 6 s browse auto-hide is unchanged.
- Persistent bottom menu (
client/src/components/NowPlayingBar.tsx): replaces the fading OSD in fullscreen — channel number/logo/name/LIVE + wall clock, plus phone-only ☰ Channels / ⓘ Info / ★ Favorite buttons (box-noneso stray taps still reach the open-menu catcher; TV keeps the identity-only bar clear of the D-pad zap focus path). - Debug builds:
android/app/src/debug/res/xml/network_security_config.xmlpermits cleartext (stock RN debug posture) so Metro over LAN works on-device; release keeps the strict loopback-only config. - Verified on the S22 over wireless adb: entry tune 84%→90%, zap 001→002 relabels the pill and bar, Channels/Info buttons, menu-open/hide/back chain, and resume-on-002 after a Menu round-trip (screenshot-driven; tune-to-100% blocked by a VPS feed live-edge stall that day — P2P connected at 1 peer, indicator honestly stayed <100).
Fix: PanelLink self-heals a panel restart — forced topic re-lookup while ops are stranded (verified)
- The incident (2026-07-16 VPS): panel + broadcaster restarted together (
docker compose up -d --build); every channel sat atregistered:false/lastError:nullfor 15+ minutes until a manual broadcaster restart. Root cause: the panel's swarm identity is ephemeral (new Hyperswarm()), so a restarted panel re-announces the registration topic under a brand-new keypair — and hyperswarm re-queries a client-mode topic only every ~10 min, leaving the broadcaster holding a dead pre-restart peer record with queued ops and no delivery attempts. - Fix (
broadcaster/src/panel-link.js): while ops are pending with no panel socket, the link forcesdiscovery.refresh({client:true})— a fresh DHT topic query — on a 5 s → 60 s backoff, standing down the moment a connection lands. No panel changes; a restarted panel is typically re-found in well under a minute. - Status stops lying by omission: once the link has been down ≥10 s with
undelivered state,
registerErrorreadsno panel connection for Ns(any underlying delivery error appended) instead ofnull— surfaces in the existing control-UI badge untouched. NewPanelLink.health()exposes{enabled, connected, disconnectedForMs, pendingOps}. - Tests: new
test:panel-linkunit suite (no network: late-connection delivery, latest-state-wins across an outage, the no-connection message lifecycle, re-lookup backoff + stand-down);test:broadcaster-apiadds Test P — the panel swarm is destroyed and re-announced under a NEW identity with a stop op stranded meanwhile; the strandedisLive:falsemust land with no broadcaster restart. - KB:
docs/kb/operator.mdgains the symptom→cause→fix entry (older builds: restart the broadcaster after a panel restart ifregisteredstays false).
Fix: playback self-heals — tune timeout/evict/re-lookup (SDK) + frozen live edge (client)
Two client-side recovery gaps observed on the S22 against a healthy VPS (2026-07-16):
- Tune that never completes (
sdk/player.js): a zap to a cold feed whose DHT records were stale (broadcaster restarted since the last lookup; hyperswarm re-queries a client topic only every ~10 min) sat at "90 %" for 10+ minutes with no error — and the single-flight open cache handed every retry the same dead open until an app restart. Now a tune watchdog (p2p-only mode) runs while the active feed's playlist hasn't landed: forceddiscovery.refresh()on a 5 s → 60 s backoff (the PanelLink self-heal, applied client-side), attune.timeoutMs(default 30 s) the cached open is evicted and re-opened fresh once (feed:retunebreadcrumb), and a second expiry emits a friendlytune timeouterror the app surfaces.serveFeed()'s open and the sparse cataloggetare bounded too, so a wedged open can no longer hangresolve()or park the catalog watcher; the worklet now relays engineerrorevents over IPC (previously dropped — no background failure could reach the UI). Config:tune {timeoutMs, relookupMinMs, relookupMaxMs}throughStartOptions.tune. - Frozen live edge (
sdk/react-native <AliranVideo>): the live window is short (8×2 s = 16 s on the reference deploy), so a network blip longer than the window slides it past the playhead and react-native-video fires no error — byte-identical frames while peers/heartbeats/UI all stay healthy. Once a mount has played, a playhead still forstallTimeoutMs(default 12 s, not paused) now triggers a remount onto a fresh playlist load at the live edge (what the manual zap-away/back did) + a newonStallcallback;LiveScreenre-shows the tuning pill until the first frame lands. - Tests:
test:sdkgains the tune self-heal cycle — an entitled, cataloged, but UNSEEDED stream must produce forced re-lookups,feed:retune, a friendly error with the cache evicted, and a plain re-zap must open fresh and play once a broadcaster appears (no app restart). KB: both symptoms indocs/kb/playback.md.
Fix: wedged swarm connections are torn down — the self-heal the tune watchdog couldn't reach
On-device verification of the tune self-heal (same S22, same day, 2026-07-16) found a
wedge class it does NOT recover: after a Wi-Fi degrade + off/on cycle, the hyperswarm/
UDX connection to the broadcaster survived at transport level while hypercore
replication over it moved zero bytes — "P2P — 1 peer" on the phone, peers=1 on
all 10 feed swarms at the broadcaster, worklet heartbeats healthy, a fresh client
played the same feed in 10 s… and a tune sat at "90 %" for 15+ minutes with no
error. Two compounding causes, both fixed in sdk/player.js:
- The watchdog stood down on a STALE playlist. "Tuned" was "the playlist exists in the replica" — but on a warm/prewarmed feed the pre-flap playlist is already there, so the watchdog quit on its first tick: no re-lookups, no retune, and the friendly error could never fire. "Tuned" now means the playlist advances (a live playlist rewrites every segment, so a healthy feed passes within seconds).
- Evict + retune reused the wedged pipe. Hyperswarm keeps ONE connection per peer
across all topics, so a fresh open rides the same dead socket — and with prewarm the
broadcaster is usually the only peer, so one wedged connection starves EVERY channel
at once. The watchdog now escalates: retune at
tune.timeoutMs, then destroy the connections serving the feed at the 2nd expiry (feed:reconnectbreadcrumb — topics stay joined, the swarm dials fresh, corestore re-replicates automatically), and only then the friendly error — worst case ≤ 3×timeoutMs(90 s at defaults) instead of never. No connected peer to tear down → error at 2× as before. - Belt-and-suspenders in the app path:
AliranPlayer.reconnectActiveFeed()(new public API) exposes the same teardown + re-armed watchdog; the worklet maps it to a new{type:'reconnect'}IPC message andAliranBackend.reconnect().<AliranVideo>'s stall ladder escalates to it when a stall resync remount fails to play within anotherstallTimeoutMswindow — a mid-watch wedge now tears the transport down instead of remounting onto the same dead replica forever. - Tests:
test:sdkgains a wedged-connection scenario — the viewer's socket to a dedicated seeder is paused (probe-verified in-process stand-in for SIGSTOPping a seeder: connection stays open, 1 peer attached, live edge frozen — the exact prod signature), then a re-zap must producefeed:retune→feed:reconnect→ the wedged socket destroyed → the live edge resumes with no friendly error and no app restart. KB:docs/kb/playback.mdupdated.
Fix: a login flood can no longer freeze the broadcaster — Argon2id off the event loop
Production incident (2026-07-16 ~20:30, 1 GB VPS ~800 MB into swap): 4-5 POST
/api/login attempts against the broadcaster control API blocked the node event loop
for 25+ minutes — control API dead (HTTP timeouts), swarm replication stalled
(every viewer froze into the friendly tune-timeout error), no self-recovery; the
container had to be restarted. Argon2id verification is memory-hard (64 MiB per
verify at the default cost) and ran synchronously on the main thread; the login
throttle counts a fixed window (10/15 min) but doesn't stop attempts queueing, so
each swap-thrashed verify ground for minutes back-to-back. Fixed in
broadcaster/src/control-auth.js + control-server.js:
- Worker-thread verify (
makeAdminVerifier+ newcontrol-verify-worker.js): the grind runs off the loop — media, replication, and the rest of the control API keep answering regardless of Argon2 cost or swap pressure. Unknown usernames still cost equal work (timing doesn't confirm admin names). - Single-flight with immediate rejection: one verify in flight, ever; a
concurrent login gets
503now instead of queueing behind the grind. - Verify timeout (
loginVerifyTimeoutMs, default 30 s): a verify that outlives it fails503and its worker is terminated + respawned — a thrashing box keeps serving media even while logins are temporarily impossible. - Regression test
npm run test:login-flood(tools/e2e-login-flood-test.mjs): floods/api/loginat incident-scale cost (256 MiB × 6 passes) while a channel streams, asserting/api/statusstays fast throughout (25 ms max observed; the pre-fix sync path measured 6.3 s blocked and zero rejections), the concurrent bulk 503s immediately, the channel rides it out, and the timeout path self-heals. KB:docs/kb/operator.md. Note: the panel's admin login shared the defect — ported in the next entry.
Fix: the panel admin login gets the same hardening — Argon2id off the event loop
The panel's POST /api/login had the identical defect the broadcaster was just
fixed for: panel/src/ops.js verifyAdmin ran the synchronous memory-hard verify on
the main thread. On the panel the blast radius is worse — its event loop also
drives catalog/assets replication and the viewer login RPC, so a login flood (or a
few attempts on a swapping host) would freeze every fresh app login in the fleet,
not just the dashboard. The user-login OPRF path (panel/src/rpc.js) was audited
clean: server-side it does evaluate (ristretto255) + Ed25519 authVerify only —
the Argon2 grind in that flow runs client-side. Ported one-to-one from
broadcaster/src/control-auth.js:
- Worker-thread verify (
ops.js makeAdminVerifier+ newpanel/src/admin-verify-worker.js), single-flight with an immediate503, andloginVerifyTimeoutMs(default 30 s) with worker terminate + respawn — same semantics as the broadcaster; equal work for unknown admin names preserved. - Regression test
npm run test:panel-login-flood(tools/e2e-panel-login-flood-test.mjs): floods the panel/api/loginat incident-scale cost (256 MiB × 6) with a real Hyperswarm viewer plane connected BEFORE the flood —/api/statusstayed ≤31 ms throughout, a viewer OPRF login issued MID-flood completed in 43 ms, the concurrent bulk 503s immediately, and the timeout path self-heals. Proven both ways: re-inlining the sync verify makes the test fail (2 status samples in 6 s, max 3.7 s, zero rejections).test:admin-apiA–N green.
Fix: tuning pill lifecycle — tune-scoped completion, honest self-heal labels, slower ramp
On-device (S22, release of aa556f6, 2026-07-16 evening): channel 009 visibly PLAYING
while the pill still said "Tuning 009 — 90%", the stale state bleeding into the next
zap (pill flashes and dies while the new channel is still tuning), and a slow feed
(one shopping channel: 113 s time-to-play even from a PC) parking at 90% for a minute with no state
change. Root cause: ONE localhost URL serves every P2P channel, so the pill's old
completion signals (onBuffering(false)/onReadyForDisplay) also fire for the
PREVIOUS channel — which keeps playing under the same URL until the engine flips the
served feed — and the self-heal remounts (ff05881) / transport teardowns (2a5e396)
re-created <Video> mid-switch, orphaning whichever signal the pill was waiting on.
- <AliranVideo> now owns the switch as a TUNE (onTune lifecycle: start /
retune / reconnect / playing, monotonic tune id): playing fires on the FIRST
real playback of the CURRENT tune — the engine must have confirmed the URL serves
this stream (port reply, see below) AND the current mount produced
progress/first-frame. Completion is mount-scoped (epoch shadow of the remount
counter), so events still held by an outgoing mount are inert, and a mid-tune
remount simply re-arms the same tune on the fresh mount. A stall resync starts a
fresh tune id (onStall is now log-grade); the engine's feed:retune/
feed:reconnect breadcrumbs relay as phases and RE-ARM completion; the friendly
tune-timeout error ends the tune for the host's error UI.
- The port IPC reply now echoes streamId (worklet backend.mjs — bundle regen
required; AliranBackend.activeStreamId caches it), so the binding can tell "the
shared URL just switched channels" (remount to flush the old playlist/buffer —
zaps no longer depend on ExoPlayer stumbling into the swap via a playlist error)
from "re-confirmed the channel already playing" (keep the mount; re-entering Live
stays seamless). Pre-streamId bundles degrade to remount-on-every-port.
- LiveScreen drives the pill SOLELY from onTune (no more scattered
setSwitching sites), keyed by tune id so a tune that starts while the previous
pill is up replaces it atomically at 0%; ChannelChangeIndicator grows honest
phase labels (Tuning / Retuning / Reconnecting — the % hides mid-self-heal instead
of freezing at 90) and a two-regime ramp (quick attack to ~45%, then a slow crawl
toward ~85 budgeted ~30 s) so slow-starting feeds don't sit pinned at 90%.
- Tests: client jest gains AliranVideoTune.test.tsx (stale-mount events can't
complete or kill a tune; the port reply remounts on a channel switch and not on a
re-entry; self-heal relabels + re-arms; the friendly error ends the tune; a stall
resync restarts the tune and completes on the resync mount) and
ChannelChangeIndicator.test.tsx (ramp pacing, phase labels, snap-to-100 + hide);
jest.config.js maps module resolution for the symlinked SDK sources.
- Error-screen retry honored: the friendly tune-timeout says "switch to it again
to retry", but re-selecting the SAME channel was a play() no-op — the only way out
was a trip through the Menu (found live during a real broadcaster outage, S22
2026-07-16). Re-selecting the erroring channel now clears the error, remounting
<AliranVideo> into a fresh tune.
Zap latency: shared progressive serving core + availability wait + start-buffer tuning + optional zap prefetch
Real-world zaps against the VPS ran ~3–5 s (and slow-starting pull feeds took 60–113 s
to first play) while LAN zaps were sub-second. Profiling the serve path showed the
byte pipeline was already block-progressive (hyperdrive createReadStream resolves
64 KB blocks as they replicate — first HTTP byte lands while a 1 MB segment's tail is
still in flight); the real costs were the 404 → 2.5 s player-retry quantization
while the playlist/first segments replicate, ExoPlayer's ~2.5 s start buffer, and
demand-paged segment replication serialized against the player's sequential fetch
pattern. Four changes (sdk/serve.js is new — one serving core for the SDK engine,
the Bare worklet, and the desktop tools via tools/lib/serve-drive.js):
- Availability wait: a media path not yet in the replica is now held (polling,
bounded at 6 s — under ExoPlayer's 8 s read timeout) and served the moment its entry
lands, instead of 404ing into the player's 2.5 s retry remount. A genuinely missing
path still 404s after the bound; poster/art misses (
/assets/*) 404 immediately. - Live-edge read-ahead: serving a playlist fire-and-forgets a parallel blob download of its newest 3 segments, overlapping replication with the player's sequential requests (and sidestepping per-block round-trips on the read path). Superseded downloads are destroyed so rotated-out segments can't strand a range.
- Start-buffer tuning (
<AliranVideo>): ExoPlayer now starts at 1 s buffered (bufferForPlaybackMs, was ~2.5 s; 1.5 s after a rebuffer) — every segment starts on a keyframe, and the stall-resync → transport-teardown self-heal ladder is the net for the slightly higher rebuffer risk. Hosts override via the newbufferConfigprop (merged over the defaults, delivered assource.bufferConfig). - Adjacent-channel zap prefetch (
zapPrefetch, OFF by default): while a stream plays, keep the newest segment of the next/previous channels in curated zap order replicated locally, so CH+/CH− starts from warm bytes. Unlikeprewarm(connections only, ~free) this costs standing bandwidth ≈ each warmed neighbor's bitrate;true={ neighbors: 1, intervalMs: 3000 }. Plumbed through the worklet IPC and the RN binding (StartOptions.zapPrefetch); the app keeps it off. - Tests: new
npm run test:serve(tools/serve-progressive-test.mjs) — a deterministic byte-budget replication gate proves first bytes serve while the blob tail is provably unreplicated, the availability wait turns a pre-replication GET into a 200 (and misses still 404), Range math is exact (206/416), and a playlist GET read-aheads its newest segments with no segment request.test:sdk,test:stream,test:retentionall green. KB: zap-latency lore indocs/kb/playback.md+docs/kb/feed-buffer.md.
Scale knobs: per-channel swarm budgets + panel-published blobsKey (the repeater enabler)
Groundwork for the keyless repeater appliance (regional super-peers that mirror ciphertext only — the Open-Connect analog):
SWARM_MAX_PEERS(broadcaster): every channel owns its own Hyperswarm, so connection budgets are per channel (the hyperswarm default of 64 always was). The env makes it an operator knob, enforced at accept time on the server-only channel swarms (hyperswarm 4.x only budgets outgoing dials): connections beyond the budget are dropped before replication starts; the refused viewer's tune self-heal finds other peers. Default unchanged when unset.createPlayer({ swarm: { maxPeers } })(SDK): raises the engine's single Hyperswarm connection budget for SDK-based seed nodes / the repeater; viewers keep the default. Threaded through the worklet IPC and the RN binding (StartOptions.swarm).blobsKeycatalog enrichment (panel, "Option B"): the feed drive's blobs core is a named core whose key rides inside the encrypted drive header — not derivable from the publicfeedKey. The panel (which already stores every stream's encryption key from register) now opens each registered feed asynchronously, reads the blobs-core key from the header, and publishes it in the catalog record asblobsKey(panel/src/blobs-key.js): retried with backoff, never blocking the register RPC reply, cleared + re-enriched when a register rotates thefeedKey, swept on panel boot for pre-upgrade records. Publishing it is safe — it only enables encrypted-block replication; watching still needs a per-user sealed grant key. Zero broadcaster changes.- Tests:
test:registernow proves the enrichment round trip against a real announced drive (async blobsKey == the drive's real blobs-core key; rotation clears + re-enriches; same-key re-register preserves),test:broadcaster-apiasserts enrichment against a live channel (Test D) and across the F4 source-change rotation, and its new Test Q proves the per-channel budget (maxPeers=2 → a 3rd concurrent viewer on that one channel is refused while a second channel still accepts). SDK unit tests cover theswarmoption plumbing.
Repeater appliance — keyless regional super-peer (repeater/)
- New first-class component
repeater/— the Open-Connect analog for the swarm: a standalone hosted app (operator- or ISP-run) that mirrors chosen channels' live windows and serves them to viewers, moving fan-out off the origin broadcaster (whose per-channel egress drops to ~one stream per repeater). Hypercore request hotswapping prefers fast/low-RTT holders, so an on-net repeater wins its region with zero client changes. - Keyless by construction: config is the panel's public key + channel
selection (
all/ streamId list /category:filter) + retention + maxPeers. No account, no grants; it never opens a Hyperdrive — it mirrors both drive cores RAW at the corestore level (db core by the catalogfeedKey, blobs core by the panel-publishedblobsKey) and stores/serves ciphertext only. Its package depends on no drive/crypto library at all. - Blind block mirror, O(window) storage: live-downloads the TAIL of both
cores (
download({ start: length, end: -1 })), keeps the drive-header block a cold viewer needs, and a time-based sweep clears blocks older thanRETENTION_SECONDS(which may be deeper than the origin's HLS window — a regional blip-recovery buffer). The download range is re-armed from each new watermark so cleared blocks are never re-fetched. feedKey rotations re-target the mirror through the public catalog watch, unattended, and PURGE the old feed's blocks from disk. - Deploy pack (the established pattern):
repeater/Dockerfile,deploy/docker-compose.repeater.yml(standalone box),deploy/systemd/aliran-repeater.service,.env.example, README; new operator pagedocs/repeater.md(ISP-hosting model, sizing = pure I/O, security story). - SDK:
createPlayer({ swarm: { bootstrap } })— custom DHT bootstrap nodes for local testnets / private DHTs (used by the new test to drive real viewers). - Tests: new
npm run test:repeater— origin + panel (with real blobsKey enrichment) + repeater + SDK viewers on a local DHT testnet: viewer plays entirely off the repeater while the origin's only egress is the repeater (byte counters both sides); origin killed mid-play → warm AND cold viewers keep playing the buffered window; rotation re-points unattended + old cores purged; retention stays bounded and cleared blocks stay cleared; the box's store, config and status scanned for the encryption key + known plaintext → zero hits. - Follow-up (noted, not built): panel-assigned repeaters
(
repeaters/<repeaterPubKey>records watched from the panel bee) for dashboard-managed fleets; locality pinning (panel-published repeater addresses as preferred peers).
Fix: remote acceptance always ends with a verdict — per-channel deadline + fresh-resolve retry (verified live)
tools/acceptance-remote.mjscould hang forever on ONE wedged tune: the localhost progressive server holds playlist/segment requests until content exists, and the poll loop's clock only ticked between polls (the segment fetch had no bound at all). Observed twice against the live VPS: 5/6 channels validated in ~15 s, then the run sat 25+ minutes on the last one.- Every per-channel check now runs under a hard deadline (
--deadline, defaultmin(90, --timeout)s). On expiry it retries ONCE with a freshresolve()— which re-reads the catalog feedKey and re-arms the SDK tune self-heal — then reports a per-channel FAIL.connect()is bounded too (60 s), and a wedgedplayer.stop()can no longer hang teardown. Output format and exit codes are unchanged (0 iff every expected-live channel validates). - Each channel's ffprobe sample now lands in its own temp dir (all concurrent
checks previously wrote
probe.tsto whichever temp dir was created last). - Verified against the live VPS:
--deadline 1forces expiry → retry → per-channel FAIL, exit 1, no hang; a default run then hit the real wedge on 3/6 first tunes and the fresh-resolve retry recovered ALL three in ~7 s → 6/6 PASS, exit 0. A wedge that a fresh resolve clears immediately is more evidence for the wedged-connection-reuse theory from the repeater rollout notes.
Fix: the wedge behind that acceptance hang — a media read committed to a reclaimed blob never ends (fixed in the SDK serving core)
The acceptance deadline+retry above treated the symptom; instrumenting the live VPS
found the real cause, and it is not wedged-connection reuse. The feed is an
EPHEMERAL rolling buffer: broadcaster/src/hls.js (mirrorDirToDrive) frees the
PREVIOUS /index.m3u8 blob the instant it writes the next (~every 2 s), so each
playlist version's blob is fetchable for only about one segment. A viewer replica
that lags the live edge by a rotation resolves the path to a version whose blob has
ALREADY been reclaimed everywhere — createReadStream then waits FOREVER for blocks
no peer holds. Node buffers the 200 response headers until the first body byte, so
the response never flushes headers and never ends; a client with no read timeout
(the acceptance harness's httpGet) hangs indefinitely.
- Proven on the live VPS (2026-07-17): a single held
/index.m3u8request stuck 44 s+ (never completed, no response headers) while FRESH reads issued every second returned 200 in 2–60 ms — the current live version's blob is always present; only the committed-to old version is gone. So one HTTP request dies while the feed is perfectly healthy, and the harness's unboundedhttpGetnever retries. - The tune watchdog cannot see this. Its health signal is the playlist's METADATA
signature (
drive.entry().seq), which advances normally as versions replicate — so it stands down ("playlist advanced — tuned") and, even if it stayed armed, its remedies (evict / retune / teardown) would not restore a blob the broadcaster reclaimed. This is a read-path stall, not a discovery or connection fault: the watchdog is the wrong layer, which is why the earlier wedged-connection-reuse theory didn't fit (a fresh resolve clears it because a fresh READ re-resolves to the live version, not because a new connection is dialed). - Fix in
sdk/serve.js(the shared progressive core behind the SDK request handler, the Bare worklet, and the desktop tools): a media read that yields NO bytes forreadIdleMs(default 6 s, under ExoPlayer's 8 s read timeout) is aborted, so the client re-requests and re-resolves to the current live version whose blob still exists. The idle clock arms before the first byte (a read that never yields one must abort too) and RESETS on every block, so a slow-but-advancing progressive read is untouched. In the acceptance harness the abort turns the infinitehttpGethang into an error its existingwaitForloop retries in ~6 s (the external deadline+retry is now a rarely-hit backstop); on-device it drives react-native-video's retry sooner and cleaner than its own read timeout, so a stuck tuning pill clears instead of parking. - Tests:
npm run test:servegains case E — put a segment, replicate its ENTRY, then CLEAR its blocks at the writer (the broadcaster's per-rotation reclaim), and a GET committed to that blob must ABORT within the idle bound instead of hanging; cases A–D (progressive body, availability wait, ranges, read-ahead) are unchanged.npm run test:sdk(tune self-heal + wedged-connection teardown + hybrid + prewarm + zap-prefetch) stays green. - Verified live: the same held request that stuck forever now aborts at ~7 s
(
readIdleMs+ startup) while fresh reads keep serving in ms; two fulltools/acceptance-remote.mjsruns against the VPS passed 6/6 with every channel 10–22 s to play — none near the 90 s deadline and no fresh-resolve retry needed (before the fix, a wedged channel sat ~97 s = deadline + external retry).
Fix: the tune watchdog no longer stands down on metadata alone — "tuned" now requires servable bytes
Companion to the stalled-read fix above, closing the other half of the 2026-07-17
wedge: the tune watchdog's "tuned" verdict was "the playlist signature advances",
but that signature is the playlist entry's bee seq — metadata-core state — while
the bytes a player needs ride the blobs core. The two are separate replication
channels with separate failure modes, so a feed whose metadata replicates while zero
blob bytes are fetchable (broadcaster reclaim outpacing a lagging viewer, or a
starved/wedged blob channel) advanced the signature and stood the watchdog down on
its first tick — after which nothing at the SDK level ever escalated or surfaced an
error for a tune that never became playable: no re-lookups, no retune, no
connection teardown, no friendly error, exactly the ladder built for this situation.
The live instrumentation showed all three wedged channels with watchdog=off within
seconds while zero media bytes were served for 90 s.
sdk/player.js: the stand-down check now also demand-reads the CURRENT playlist content (new_playlistServable— boundeddrive.get, must yield bytes that reference at least one media URI). It re-resolves to the newest version on every call, so it is never pinned to a reclaimed blob; on a healthy feed the blob is already local (the serving layer pulled it for the host player) and the probe is a cache hit. While content stays unfetchable the watchdog stays armed and walks its existing ladder — retune attimeoutMs, connection teardown at 2× (the right medicine for a wedged blob channel), friendly error by ≤3×.- Tests:
npm run test:sdkgains the metadata-advancing-but-unservable scenario — a pathological seeder rewrites the playlist every 500 ms and clears its blob blocks immediately (the broadcaster's per-rotation reclaim, made permanent); the viewer must keep the watchdog armed despite the advancing signature, producefeed:retune→feed:reconnect, and surface the friendly error instead of spinning silently. Proven both ways: the scenario fails on the previous advance-only code (no error ever fires) and passes with the content check. - Known limitation (noted, not fixed): hybrid mode's stall watchdog / recovery probe still judge P2P health by signature advance alone — same conflation, out of scope here (hybrid is not in production use). Closed by the next entry.
Fix: hybrid stall/recovery probes require servable bytes — no CDN-fallback miss, no flip-back to an unplayable feed
Closes the known limitation above: hybrid mode's two background probes judged P2P health by the playlist signature alone (the entry's bee seq — metadata-core state), the same metadata/blobs conflation just fixed in the tune watchdog. Against a feed whose metadata replicates while its blob bytes are unfetchable (broadcaster reclaim outpacing the viewer, or a starved/wedged blob channel) that meant two failure modes: the stall watchdog saw an "advancing" playlist and never fired the CDN fallback (the viewer rebuffered on P2P forever), and the recovery probe could flip a CDN viewer back to the unplayable P2P source and strand it there with the fallback already spent.
sdk/player.js: both probes now gate their healthy verdict on_playlistServable(the bounded content read introduced for the tune watchdog) in addition to signature advance. The stall watchdog only resets its stall clock when the advanced playlist's content is fetchable; the recovery probe'shealthyStreakonly counts probes whose advance is servable (unservable resets the streak). The ≥2-streak anti-flap semantics are unchanged — the stricter verdict only makes flips back to P2P less eager, so no new CDN↔P2P flapping surface. Both probe loops are busy-guarded (the bounded content read can outlast the tick interval) and re-check the active play after their awaits.- Tests:
npm run test:sdkgains the hybrid half of the pathological-seeder scenario — the same rewrite-every-500 ms/clear-blobs-immediately feed, played in hybrid mode from a prewarmed replica (so the play deterministically starts on P2P): the stall watchdog must emitfallback(reasonstall) despite the advancing signature, and over an 8 s observation window on CDN the recovery probe must never emitsource-changedback to p2p. Proven both ways: on the advance-only code the fallback never fires (the run times out at the new scenario); with the servable gate the full suite passes. The existing hybrid scenario keeps proving the positive path (a genuinely seeded feed still auto-returns to P2P through the new servable gate).
Smooth zapping — user toggle over adaptive zapPrefetch + viewer-bandwidth docs (verified)
- The zapPrefetch that shipped OFF in the zap-latency pass is now a product feature: a "Smooth zapping — uses more data" switch in the app's Settings (default OFF, persisted beside the other device prefs, applied live mid-play and at every boot), backed by an engine that spends bandwidth only when it is safe to.
- SDK (
sdk/player.js):setZapPrefetch()runtime switch (re-arms/clears the warm loop mid-play) + an adaptive gate that suspends prefetch — dropping every standing download while a cheap tick watches for recovery — when (a) the host reports a metered/expensive network (setNetworkProfile, lifts immediately when cheap), (b) the active playlist stops advancing forstallMs(12 s default; resumes afterresumeMs= 60 s of clean advance — prefetch never competes with playback), or (c) neighbor segments download slower thanminHeadroom× realtime (3× default, two thin samples — the pipe can't carry a second stream). Directional prefetch (default on): once the viewer's surf direction is known, only that side is warmed — half the standing cost for the common CH+ CH+ CH+ pattern; menu jumps reset to both sides. Lifecycle is observable via new'zap-prefetch'events. uploadPolicy: 'reseed' | 'client-only'(boot option):client-onlyjoins feed/assets topics unannounced (server: false) — not discoverable, so practically zero viewer-to-viewer upload by construction; documented trade-off is one fewer re-seeder in the swarm.- Worklet/IPC + RN binding:
zap-prefetch-set(persists the choice + applies live) andnet-infomessages;AliranBackend.setZapPrefetch()/.setNetworkProfile(); prefs reply carriessmoothZapping; the boot handler applies the persisted choice over the compiled default. App wires NetInfo'sisConnectionExpensivedown automatically (new@react-native-community/netinfodep, defensively optional so stale builds don't crash). - Docs: new
docs/kb/viewer-bandwidth.mdwith the measured numbers (idle prewarm ≈ 5–6 KB/s per 10-channel lineup; watching ≈ bitrate ~2–3 Mbps; smooth zapping ≈ +1 neighbor bitrate, halved by directional; upload = opportunistic re-seeding; battery notes), SDK/RN README sections. - Tests:
test:sdkextends the live harness with the runtime OFF↔ON switch mid-play, metered suspend/lift, directional warm-set assertions, the stall suspend + clean-run resume (mirror frozen and re-fed), andclient-onlyplaying while joinedserver:false(default player assertedserver:true) — full suite PASS; client jest grows a SmoothZappingToggle suite (10/10 with the existing suites),tscclean; worklet bundle re-packs.
Redirect channels — a CDN-link channel class in the catalog (verified)
- New channel class: a catalog record can carry
{ redirect: true, url: 'https://…' }— viewers play the operator's URL directly instead of a P2P feed. P2P channels are untouched (no CDN backup, no hybrid config needed anywhere). - Panel:
url(https-only, ≤2048 chars, tokenized query strings verbatim, no extension requirement) drives the pair atomically — non-empty ⇒redirect: true+ live-by-default (explicitisLive/statusin the same request win), empty ⇒ clears; a redirect entry can never also hold afeedKey; a broadcaster re-register never erases the class (same admin-owned rule as curation/art). Admin API/UI: "Redirect URL" field on Add stream + Edit metadata, ⇢ REDIRECT badge + url line on the stream card. - SDK:
resolve()gains the redirect branch — returns{ url, source: 'cdn', port: undefined }, opens no feed, joins no swarm, arms no watchdogs; the bounded live catalog read now carriesredirect/url, so an admin url edit reaches viewers on their next tune (no re-login); zap-prefetch / feed-rotation paths skip feedless tunes; the display list stays metadata-only (no url). The client app needs zero code changes — the URL flows through the existingportIPC reply and remote https playback was already proven by the hybrid path; worklet bundle re-packed. - Tools:
acceptance-remoteprobes redirect channels with a direct https fetch (PASS = HTTP 200 +#EXTM3U; segment/ffprobe skipped — tokenized CDNs may sign per-URI);test:sdkgrows a redirect scenario (verbatim URL passthrough incl. query string, next-tune url edit, feedless non-redirect still throws, p2p↔redirect zap arms/clears the tune watchdog, hybrid machinery untouched);test:admin-apiTest O covers validation, defaulting, class exclusivity, register-preserve and purge. Docs:docs/content-management.md+ SDK README sections.
Remote channel sources — provider JSON feeds as categories (verified)
- The idea: a provider publishes a prepared channel list (id/name/logo/https
HLS url per entry — e.g. a GitHub-raw
anime-es.jsonrefreshed daily); the operator registers it once as a source with a rail label ("Anime") and the panel keeps a category of redirect channels in sync with it. P2P channels tagged with the same category share the rail — category was already ordinary catalog metadata end-to-end, so the whole feature is panel-side and needs zero SDK/app changes. - Engine (
panel/src/sources.js): registry inDATA_DIR/sources.json(nothing secret); per-source scheduled pull (daily default, hourly due-check tick, boot catch-up, single-flight, manual "Sync now") with ETag revalidation, a streaming-enforced byte cap and an entry cap; entries validated as pure data with the exact admin-input validators (https-only playback url — entry skipped otherwise; art rules — bad logo degrades to no art; id charset), then diffed against the catalog: create / update-only-if-changed / delete when the entry left the feed (operator decision — full purge, grants included). Unchanged feed or 304 ⇒ zero Hyperbee appends. Ownership is explicit: imported records are stampedsource:<name>and a sync can only touch records carrying its name — a malicious feed cannot collide into manual channels or another source (conflicts are skipped + reported). Fetch failures keep the last good state and surfacelastErrorin the dashboard. - Grants:
autoGrant(default on) seals every imported channel to every user, reconciled on every sync (304s included) so accounts created between pulls converge, plus an immediate grant pass at user creation (admin API + CLI). New channels reach devices at their next login, the known wrapped-key behavior. - EPG: the feed carries a full-day schedule per channel; it deliberately stays
out of the append-only replicated bee (57 KB/day/category, forever, on every
client). Imported records instead carry
epgUrl/epgIdpointers so a client can fetch the schedule over https on demand — planned client follow-up, same public-https stance as remote art and redirect urls. - Surface:
add-source/list-sources/set-source/sync-source/remove-source [--keep-channels](registry verbs are file-only and safe beside a running panel);/api/sourcesCRUD +/sync; dashboard Sources tab (add auto-syncs, per-row sync now / edit / pause / remove with a keep-channels detach option, last-report and error inline) and a ⇣ source chip on imported stream cards. Removing a source purges its channels; detaching strips the stamp and leaves them as manual redirect channels. - Verification: new
test:sourcese2e (in-process panel + loopback feed server — deterministic, added to the REQUIRED core CI lane) covers validation, first import (mapping/skips/conflict/grants), 304-and-unchanged frugality (bee version pinned), mutation (update/remove/add + curation surviving), the create-user hook, autoGrant toggle + reconcile, caps, oversized/unreachable feeds failing safe, the scheduler self-syncing a never-synced source, and both removal modes.test:core+ fulltest:admin-api(A–P) stay green. Live browser check against the real provider feed: 10 anime channels imported through the dashboard, real GitHub ETag answering the second sync with "not modified", stream cards showing LIVE / ⇢ REDIRECT / ⇣ anime / Anime chips.
Sources: per-channel deselect — the exclude list (verified)
- Ask: the import shows what the feed added — the operator wants to UNCHECK some
of them. New per-source
excludelist of FEED ids ({id, title} — the label is captured at exclusion time so the dialog can name entries that no longer exist in the catalog): excluded entries are skipped bymapFeed(counted, not an error), which routes them through the normal missing→delete path — removed immediately, grants included — and keeps them out on every future sync. An exclusion change resets the stored ETag, otherwise a 304 would skip the apply pass and silently mask a fresh deselection. - Surface: Sources tab channels button → checkbox dialog (imported entries in
feed order + excluded ones labeled
(excluded); Save + sync applies immediately; dialog body scrolls for big lists), "N excluded" chip on the row, newGET /api/sources/:name/channels,PATCH {exclude}andset-source --exclude "id1,id2"(""re-includes all). - Verification:
test:sourcesgrows group I — exclude removes+purges (catalog, secret, grants), the dialog endpoint carries the captured label, the standing exclusion holds through 304s with the bee version pinned, re-include re-imports and re-seals grants; A–J PASS. Live dashboard round-trip against the REAL feed: 10 → uncheck one title → 9 + "1 excluded" chip +−1report → re-check → 10 back with+1. (Fun catch during the demo: the deselected entry sits LAST in the feed — it was appended after the alphabetical bulk, which is exactly the kind of feed drift the ownership/diff machinery shrugs at.)
Program guide (EPG) — fetched on demand from the same provider JSON (verified)
- The answer to "how does EPG work remotely with the same JSON?": the schedule
never enters the catalog (append-only replicated bee — a day of programs per
category would grow every client's store forever). Since remote sources landed, every imported channel
carries
epgUrl(the same feed URL) +epgId(its id inside the feed); now the APP fetches that JSON over https on demand and renders the guide client-side. - SDK (
sdk/player.js_display,sdk/login.js,sdk/react-nativeStream):epgUrl/epgIdpass through the display list on both the login and live-push paths — public https, exposed like the art URLs (unlikeurl/redirect, which stay engine-internal). Worklet bundle regenerated (gitignored/local);test:sdkasserts the pointers reach the display list and that a channel without them doesn't grow them. - Client
src/epg.ts(EpgService): one fetch per feed URL, ETag-revalidated, size/timeout-guarded, indexed bychannels[].id;getNowNext(url,id)selects the current program (start≤now<stop) + the next few from the cached, start-sorted list. Refetch only when the cached schedule no longer coversnow(or it ages out), min- interval throttled, concurrent callers coalesced, failures degrade to an empty guide (never throws). A whole category costs ONE fetch (all its channels share the URL). - Info panel (
ChannelInfoPanel): the old honest placeholder slot now shows a live Now row (title + local HH:MM–HH:MM + elapsed bar) and an Up next list, on a 30 s refresh so it rolls over while open; channels with no/failed EPG keep "No program information" — never fabricated.useEpghook cleans up its interval on unmount. - Any channel, not just imports (
panel/src/ops.js):setMeta/addStreamacceptepgUrl(https, empty clears) +epgId, exposed onset-meta --epg-url/--epg-id,PATCH /api/streams, and the dashboard Edit dialog — point a P2P channel at a compatible JSON and the same guide lights up. - Cost/trust: a handful of ~tens-of-KB fetches per active viewer per day (mostly 304s), zero panel storage / replication / VPS bandwidth; same public-https stance as art and redirect URLs (the device fetches the provider host directly).
- Verification: client jest +2 suites (7 EpgService cases: parse/now-next, per-URL
cache, ETag 304 keeps data, coverage-expiry refetch, empty/failure degrade, coalesce;
3 ChannelInfoPanel cases: now+next render, no-EPG placeholder + no fetch, empty→
placeholder) → 20/20 client tests,
tscclean;test:sdkFULL PASS with the new passthrough assertions;test:sourcesgrows an A0 case (EPG set/clear on a manual channel via setMeta + http rejection) → A0–J. On-device install deferred (phone off wireless adb).
Bounded disk metadata — orphaned-generation GC + periodic feed rotation (verified)
The disk buffer keeps segment data O(window) via blob reclaim, but a hypercore's
append-only merkle tree / metadata is never freed by clear(). It grows for a feed's
whole lifetime — measured ~1–2 MB/h per channel, and on the production VPS the channel
store climbed 180 MB → 1177 MB over three days (of which tree_alloc alone was 823 MB). A
slow disk creep, not a crash, but unbounded. Two contributors, each now bounded:
- Retired generations. A source change (or a rotation) bumps
feedGen→ a new Corestore namespace → brand-new cores, orphaning the previous generation's cores — tree and all — on disk. NewpurgeStaleCores()(broadcaster/src/hls.js) deletes every retired generation's whole core directory, keeping only the current generation's metadata+blobs discovery keys. It runs at every start (a reopened disk store sheds prior generations), after each rotation's grace teardown, and periodically. It refuses to run without both live discovery keys and only ever touches 64-hex core dirs undercores/, so a half-open drive can never delete the running feed. Always on, nothing to configure. - A single long-lived feed's own tree. Bounded by opt-in hot feed rotation
(
_rotateFeed): mint the next generation's drive over a fresh namespace, mirror the same live window into it (ffmpeg untouched), announce its topic, swap it in as the served feed, then retire the previous generation through a grace window (it keeps replicating + announced so in-flight viewers finish following) before tearing it down and purging its cores. The encryption key is unchanged (grants survive); only the feedKey moves, which watching viewers follow live over the catalog (_maybeReresolveActiveFeed→feed-changed, the same path as a RAM restart or source change). Triggered byFEED_ROTATE_HOURS/FEED_ROTATE_TREE_MB(both off by default — orphan GC is always on) orPOST /api/channels/:id/rotate. Keep rotation infrequent to preserve disk mode's warm-topic benefit;docs/kb/feed-buffer.mdcarries a cadence/disk-ceiling tuning table.
The swarm connection handler now replicates the whole store (store.replicate — equivalent
to drive.replicate), so a retired-but-draining generation and the current one are both
served to peers requesting either discovery key.
Verified. retention-test scenario D lays down two generations on disk and asserts
purgeStaleCores keeps only the current generation's tree (not the accumulated history)
while the live generation survives intact, plus the empty-keep-set safety no-op.
broadcaster-api Test F5 hot-rotates a running channel and proves ffmpeg stays live, the
panel catalog and a fresh viewer follow the new feedKey, and the retired generation is
purged after grace. Live on the VPS: with FEED_ROTATE_TREE_MB=250, the watchdog
auto-rotated the two channels over the cap — store 1.3 GB → 889 MB, tree_alloc −347 MB in
the duration-test cron — while all six channels kept streaming through it.
Broadcaster self-heals a corrupt feed store on start; a 4 vCPU / 8 GiB scale bench (verified)
An unclean broadcaster exit — SIGKILL, OOM, power loss, or a docker stop that outruns its
grace period while many channels are closing — can truncate a disk feed's core files
mid-write, so the store never reopens (EPARTIALREAD: Could not satisfy length, or
OPLOG_CORRUPT). This bit a resized production box after a docker stop: all six
channels' stores were truncated and every one failed to start — and because the boot
reconcile caught start()'s error and swallowed it silently, the only symptom was "0
ffmpeg" with no reason in the logs.
The fix, three parts plus prevention:
start()now opens the feed drive via_openFeedDriveSelfHealing(). On a corruption error it rotates once to a fresh generation (bumpfeedGen→ a new namespace derives brand-new, uncorrupted cores), logs it, and continues; the start-time GC then purges the corrupt old generation. The encryption key is untouched (grants survive) and viewers follow the new feedKey via the catalog. Disk mode only — aramstore is fresh every start anyway.- The boot reconcile now logs any auto-resume failure (corrupt store, capability gate, port clash) instead of failing silently.
isStoreCorruption()recognizesEPARTIALREAD/ "Could not satisfy length" — the truncation error a killedstore.close()leaves behind, whichsdk/recover.js'sisCorruptionErrorwas missing (added there too, for the client's own recovery).docker-compose.ymlsetsstop_grace_period: 60sso a clean shutdown of a many-channel box has time to finish — defence-in-depth on the prevention side.
Verified. retention-test scenario E truncates a real metadata tree file and asserts
the reopen throws EPARTIALREAD and that isStoreCorruption catches it; broadcaster-api
Test F6 starts a disk channel, stops it, truncates its store on disk, and asserts start()
self-heals to a fresh generation and comes back live. Deployed to the VPS (StopTimeout=60
confirmed, clean reopen).
While benchmarking the resized box (4 vCPU / 8 GiB / 100 GiB NVMe @ 1300 IOPS) with
tools/scale-bench.mjs, a 4→48-channel copy sweep showed CPU is the binding wall (~80
channels) — copy is not free at density (48 ffmpegs + the mirror ≈ 2.4 cores, ~5 %/ch);
RAM is ~19 MB/ch (the tool's summed ffmpeg RSS triple-counts shared libraries — trust
free -m, not the summary); and disk IOPS is a non-issue (48 channels drove ~62 write
IOPS / 13 MB/s — the page cache absorbs the write-then-delete churn, so more RAM indirectly
buys IOPS headroom). So ~50 copy channels is comfortable on this box. See docs/kb/scaling.md.
Swarm UDP socket buffers — the silent stall under fan-out (verified)
Hyperswarm's transport is UDX, which multiplexes every peer stream of a swarm over one UDP socket pair. Under viewer fan-out the traffic concentrates on that single socket rather than spreading across per-viewer connections, so the kernel socket buffer runs out first — and when it does the kernel drops datagrams silently. UDX's congestion control only sees a gap, backs off, and the symptom is stalling throughput with nothing logged anywhere. Measured on the live box: UDX raises its own receive buffer to 1 MiB but leaves the send buffer at the OS default (212992) — and sending is exactly what a broadcaster or repeater does under fan-out, so the untuned direction was the one that mattered.
core/net-tune.js requests both directions at swarm startup, reads the achieved size back, and
reports clamps — best-effort throughout, so a refused setsockopt is logged, never thrown
(tuning can never be why a service fails to boot). Wired into the broadcaster's per-channel
feed swarms + panel link, the panel, and the repeater; SWARM_RCVBUF_MB / SWARM_SNDBUF_MB,
default 2/2 (repeater 4/4). Detecting a clamp needs /proc, not a readback: Linux caps the
request at net.core.{r,w}mem_max and then stores double the capped value, so a request
between the ceiling and twice the ceiling reads back larger than asked even though it was
capped — the ceiling is read from /proc and treated as authoritative. A clamped request logs
a warning naming the exact sysctl, deduped process-wide (43 channels must not print 43
warnings).
Raising the ceiling is a host action Docker cannot perform (the services run
network_mode: host, where Docker refuses net.* sysctls), so it ships as an optional
standalone deploy/sysctl/install.sh that nothing in the normal deploy calls — idempotent, and
it verifies the value actually took. New test:nettune (required CI lane) covers the doubling
trap, /proc parsing and its non-Linux fallback, a real bound UDX socket, and the dedupe; it
asserts whichever outcome is correct for the host, so stock-kernel CI exercises clamp detection
against a real undersized ceiling. See docs/kb/network-tuning.md.
Panel control-plane disk: idempotent register + the probe-core leak (verified)
Three fixes to unbounded growth in the panel's own on-disk state, found chasing
aliran_panel-data size on the live box.
Idempotent register. The register responder rebuilt a catalog record and put it
unconditionally, but the broadcaster's PanelLink re-asserts every running stream on a 5-minute
heartbeat, and the signed Hyperbee is append-only with no compaction — so every heartbeat cost
a block forever (43 channels ≈ 12,384 redundant appends/day at ~487 B). It now compares the
rebuilt record against the stored one and skips the put when identical (an exact byte
comparison — valueEncoding:'json' stores JSON.stringify(value), and the record is a pure
function of the payload with no timestamps or nonces). Carve-outs still write: feedKey rotation,
isLive/status flips, an origin change (attribution the audit trail must keep); the private
secrets file still lands on the skipped path; and the blobsKey enricher is still nudged, since
the heartbeat is its retry timer.
The probe cores were the real leak. To publish a feed's blobsKey the panel opens that feed's
drive on its own corestore. Those cores are keyed (metadata by feedKey, blobs by the key
inside the encrypted bee header), so corestore files them under cores/<discovery-key>/
regardless of the blobs-probe: namespace — and drive.close() only ended the session; the
directories stayed on disk forever. Harmless while feedKeys were stable, but the periodic
rotation mints a fresh feedKey per rotation and re-enqueues the enricher, so the control plane
grew with rotations × channels, without bound (this was the 2.1 GB). Each probe now purge()s
the cores it opened (close every session, unlink storage; corestore's rmdir takes the
directory with the last file); teardown is shared with close() and idempotent, and probes
register before ready() so a failed open is purged too. Not a RAM store, deliberately — a
second corestore replicating on the panel's existing connections would clobber the
hypercore/alpha protomux pairing and silently break serving the signed DB by discovery key.
Reclaiming what older builds stranded. Purging new probes bounds new growth, but nothing
collects the cores a pre-purge build already wrote. openStore() now sweeps them, reusing the
GC the broadcaster already trusted — which moved to @aliran/core/store-gc.js (panel/
cannot import from broadcaster/; both images vendor core/). Two fail-safe guards: all three
cores the panel owns must resolve (the signed bee + the assets drive's metadata and blobs
cores) or it deletes nothing, and anything the Corestore currently holds open is kept
regardless. On deploy this logged reclaimed 191 stray core dir(s), 2188 MB freed; the panel
volume went 2.1 GB → 18 MB, core dirs 194 → 3 (the documented steady state — never
hand-delete those; the bee is the single-writer origin of truth with no peer to re-replicate
from). test:register plants strays (including a core too corrupt to open) across four
rotations and a permanently-unreachable feed, and asserts they are gone while accounts, catalog
and assets survive — all negative-checked.
Ops dashboard, shared theme, and backup sources (verified)
The broadcaster dashboard rendered channels as a vertical card stack — fine at 6, unusable at
the 69 the scale test runs. Rebuilt on an operations-dashboard shape: a dense
sortable/filterable table that sorts by urgency by default (retrying > waiting > starting >
on air > stopped) so problems surface at the top, plus five KPI tiles all derived from real
control-API values — deliberately no CPU/RAM/disk bars, because the control API exposes no
host metrics and inventing them would be a lie. "Peer links", not "peers": the number sums
per-channel swarm connections and one zap-prefetch viewer holds several, so it is not a
viewer count. The uptime column shows how long the current ffmpeg has been alive
(watchdog.lastRestartAt), not when the operator pressed Start — at ~2.5 respawns/channel/hour
those differ by hours, and only the ffmpeg clock says whether media is actually flowing.
Both dashboards now use the palette from client/src/theme.ts — they administer the same
product but looked unrelated (broadcaster warm brown, panel cool cyan); test:theme asserts
the shared block stays byte-identical across both stylesheets.
Backup sources. A pull input takes fallbacks (max 4, same validation as the primary). A
run of consecutive failures fails forward to the next url; return-to-primary is opportunistic —
re-probing on a later respawn rather than interrupting a working backup — so failover is not
sticky. The rotation decision is a pure exported pickSource(), unit-testable (args groups
J/K). status() reports sourceIndex/sourceCount/activeSource and a BACKUP badge
surfaces the case that otherwise hides: channel looks healthy, primary is down. The Edit dialog
dropped title/description/category — those are panel-authoritative and editing them here only
changed a local copy viewers never saw.
Category presentation registry — bulk rename and merge (verified)
Categories could only be edited one channel at a time as a free-text array. With 300+ channels
on two-level rails like News/Local there was no list of which categories exist, no
ordering, no hierarchy, and renaming a rail meant editing every channel by hand. A new
catmeta/<slug> keyspace owns presentation only — label, parent, order, hidden — while
catalog records keep category: ['News/Local'] exactly as before, so the RN app, the SDK
and every existing client are untouched (this is why it was chosen over first-class category
IDs).
Membership and presentation write different keys and therefore cannot fight, which closes the
long-standing "manual edits to mapped fields don't stick" wart: a source feed decides which
channels carry its category and reasserts that on every sync; the operator owns how it looks.
Documented honestly — renaming a source-owned rail via catmeta is undone by the next real
re-sync (a 304 sync leaves it alone); rename the source's category instead. Rename cascades to
children and de-duplicates on collision; every put is gated by the register idempotency rule so a
no-op costs zero appends. Key ordering was verified before choosing the prefix: catalog scans
are bounded gt:'catalog/' lt:'catalog0', and catmeta/ sorts above catalog0, so the new
keyspace is invisible to them. Surfaced as a Categories tab (tree view, inline edit, rename,
merge, forget) with admin-cli parity. Tests: admin-api group Q and sources group K, both
negative-checked.
Runtime upload policy + a bounded feed cache (verified)
Network-adaptive upload. uploadPolicy was fixed at construction and the RN client never
set it, so every viewer re-seeded on every network; the metered gate only suspended prefetch.
setUploadPolicy() now flips re-seeding mid-session, wired to the client's NetInfo listener:
cellular or expensive → client-only, restoring the configured policy when the network is
cheap again (a deployment shipping client-only is never silently upgraded to reseed by a
Wi-Fi event). The mechanism is subtle and the test negative-checks it: swarm.join() on an
already-joined topic returns a new, additive session, so re-joining with server:false
changes nothing — the fix is session.refresh({server, client}), which mutates the session and
its _serverSessions count. Stated honestly in the code: this stops us announcing, so no
new peer can discover us, but existing bidirectional connections are not force-closed (the
player cannot tell "a peer I serve" from "a peer serving me", and closing the wrong one stalls
playback) — so upload drops to zero for new peers immediately, overall shortly after.
Bounded feed cache. Zapping away kept a neighbour's drive, corestore sessions and swarm
topic open for the whole session — browsing 50 channels left 50 open, each occupying a slot in
that channel's SWARM_MAX_PEERS budget, so browsing could crowd out actual viewers. _feeds
is now LRU-bound to 12, never evicting the feed being served. Viewer-path socket tuning was
deliberately not added (net-tune reads /proc, which cannot work on Android, and a phone's
uplink caps throughput long before a 2 MiB buffer does) — recorded in a comment so nobody
repeats the measurement. Tests: test:sdk runtime-switch coverage + the LRU bound; worklet
bundle regenerated and verified free of net-tune.
Correlated incident log for fleet-wide events (verified)
watchdog.restarts counted every respawn, but only as a per-channel cumulative total. On flaky
IPTV that counter climbs constantly (~2.5/channel/hour across 69 channels), so a single restart
carries no information — when all 69 channels respawned together at 05:51Z on 2026-07-21,
nothing recorded it: each incremented by one, exactly like ordinary churn. It was noticed
only because a 10-minute sampler happened to land in the trough.
The signal is correlation, not count, so respawns are deliberately not logged individually
(that would be the same noise with timestamps). incidents.js opens one fleet-restart
incident when enough distinct channels respawn inside a window and extends it while the
burst continues — distinct channels is the test, not event count, so one channel flapping
twenty times is a flaky source, not an outage. Source failover and return-to-primary are
recorded as discrete incidents (rare, and they change which source viewers are served).
Surfaced at GET /api/incidents and as a dashboard timeline that explains what a burst means
("upstream or host event, not one flaky source"); ephemeral by design, like the activity ring.
The threshold shipped flat ("5 distinct channels in 2 min") and was corrected within minutes:
the measured baseline is ~2.5 respawns/channel/hour, so a healthy 69-channel box already
produces ~5 distinct-channel restarts every 2 minutes — a flat floor of 5 would have cried wolf
constantly, which is worse than nothing because the one real event then looks like the noise.
It is now max(minChannels, ceil(running × 0.25)) — ~17 of 69, comfortably above the churn
floor and below the 51-of-68 actually observed — with minChannels still the floor for small
deployments where 5 of 6 really is fleet-wide. Tests: args group L, both regimes,
negative-checked.
Ingest restart rate: drop -re from live pulls, reconnect internally, per-channel demuxer tuning (verified)
Two passes at the 2085-respawns-in-12.1-h restart rate (~2.5/channel/hour), previously treated as an unavoidable property of flaky IPTV. Part of it was self-inflicted.
-re was pacing every live stream. The old rule was ".m3u8 = live, anything else over http =
a VOD file needing realtime pacing" — false for raw mpegts over http, which is what most IPTV
serves (http://host:81/CHANNEL/mpegts?token=…, no extension); 69 of 69 running channels took
the -re branch. ffmpeg's own docs say -re "should not be used with live input streams": it
throttles the reader to 1×, so after any jitter ffmpeg cannot catch up, the server-side buffer
backs up, and many IPTV servers drop a slow client. -re is now opt-in by VOD file
extension, LIVE the default for an unknown http(s) URL — also the safer way to be wrong, since a
live source read without -re is correct while one read with it degrades continuously. And
http pulls now reconnect internally (-reconnect_streamed the load-bearing option) instead
of exiting: an exit-and-respawn restarts ffmpeg's seg%d counter from 0, stranding the
previous run's high-numbered segments (the orphan that pinned blob reclaim in the 2026-07-15
disk leak) and leaving a visible gap — an internal reconnect keeps the same process, sequence
and feed. Verified the options exist in the container's ffmpeg 5.1.9 before shipping, since
an unknown input option makes ffmpeg exit immediately (this dev box runs 8.1.2, and test:args
is pure-args, so neither would have caught the skew).
Per-channel demuxer tuning for difficult push encoders — the cheap HDMI→RTMP/SRT boxes most
small operations use, not the IPTV pulls above. probesize/analyzeduration (a sparse or late
PMT makes ffmpeg give up before it has seen every elementary stream — "could not find codec
parameters", or audio silently missing; 10–50 MB / 10–20 s are normal against 5 MB / 5 s
defaults), thread_queue_size (a bursty listener overflows the default input queue and drops
packets), and discardcorrupt (keep going through the corrupt TS a marginal capture chain
produces). Every field is optional and null means ffmpeg's default, so an untouched channel
emits byte-identical arguments; they are input options asserted to land before -i (after
it, ffmpeg would apply them to the output). Editable per channel in the dashboard. Tests: args
group M (unit conversion, placement, bounds, partial-update inheritance).
Offline slate — a dead source loops "SOURCE OFFLINE" instead of going blank (verified in production)
When a channel's source died it sat in watchdog backoff and viewers saw nothing. Now the channel loops a pre-rendered slate (SMPTE bars + "SOURCE OFFLINE / PLEASE STAND BY") so it stays live with a clear message, and it returns to the real source on its own when the source recovers.
Why a file, not live-generated bars. The slate is remuxed with -c copy (input kind
file, forced copy), so a slated channel costs ~0 CPU and copy channels — which have no
encoder configured at all — can slate too. Live drawtext would force a re-encode (~0.5–1
core per slated channel; across a 69-channel fleet that is the whole box) plus a runtime font
dependency. Why a library, not one file. Slate segments append to the same index.m3u8
as the channel's pre-failure segments, and a codec or resolution change mid-playlist with no
EXT-X-DISCONTINUITY is what breaks players — so the slate matches the dead channel's detected
output on codec first, resolution second. Four variants (720p/1080p × h264/hevc) cover 100% of
the fleet; the fleet's odd rasters (854×480, 852×720, 720×480, 1024×576) are all anamorphic
16:9, so the square-pixel 720p slate is aspect-correct for them (upscaled, never stretched).
How it rides the watchdog. _pickSlate runs once per respawn, right after _pickSource,
so a channel works through every configured fallback URL before it gives up. When slated,
_spawnFfmpeg overrides the input with the slate file and forces copy; meta is never
mutated, so status() and a PATCH still show what the operator configured (same contract as
the backup-URL swap). Three subtleties: a looped file never exits or stalls, so every
exit-driven watchdog path is dead while slated — the watchdog kills the slate every
SLATE_RETRY_MS (default 30 s) purely to re-probe, which is the return mechanism; failures is
not reset when leaving to probe, so a still-dead source re-slates on its single failed attempt
rather than blanking for another SLATE_AFTER window; and profile detection is skipped while
slated, or the channel would learn the slate's profile and pin itself to the fallback.
discont_start is now on every spawn, not just slate ones. A respawn restarts ffmpeg,
which resets its output clock, so with append_list the new segments carry a backward
timestamp jump — measured ~6034 s on a channel up 1.7 h, scaling with uptime. That is the
unmarked "visible gap" every respawn (~1.45/channel/hour here) always produced; the tag also
makes the slate's codec/resolution change legal mid-playlist.
Verified end-to-end. Timing/monotonicity checked on the container's ffmpeg 5.1.9 (not
just the 8.1.2 that renders the files): -stream_loop -1 -c copy gives zero DTS regressions
and +genpts is not needed. The loop wrap is pixel-identical (consecutive-frame luma delta
0.0000). On the S22 Ultra (ExoPlayer → hardware MediaCodec) an 854×480 → 1280×720 slate
switch is absorbed cleanly (~2–4 s gap, no stall). In production: a test channel's source
was blackholed → it slated after 3 failed respawns with the profile-matched variant (state
went backoff → up) → the source was repaired with no restart → it dropped the slate and
reconnected by itself within one poll. A real lineup channel slated and self-recovered
during the same window. Media is rendered into the image at build time by
tools/render-slates.sh, so it is produced by the exact ffmpeg that later loops it.
Pure functions (pickSlate / pickSlateFile / parseVideoProfile) covered by test:args.
Full detail: docs/kb/offline-slate.md.
SDK productization: npm-ready packages, TypeScript defs, example, docs (verified)
- All three consumer packages made registry-publishable at 0.1.0 —
@aliran/core,@aliran/player-sdk,@aliran/react-native:fileswhitelists (tarballs carry exactly the runtime + README: 10 / 8 / 7 files, proven bynpm pack --dry-run),repository/homepage/bugs/keywords/engines, andpublishConfig.access: public(scoped packages default to private). The@alirannpm scope was verified unclaimed; actually publishing is a maintainer action (create the org,npm publishin dependency order: core → player-sdk → react-native). - The one real blocker was
@aliran/player-sdk's"@aliran/core": "file:../core"— npm does not rewritefile:specs at publish, so the tarball would have been uninstallable. Moved to^0.1.0; inside the repo nothing changes: the root workspace linkscore/(satisfies the range), andclient/backend's ownfile:../../coredep dedupes the same way for the Android worklet graph — proven by regenerating all three lockfiles (root,client/,client/backend/) and a fulltest:sdke2e pass on the new resolution. Theclient/lock regen needs--legacy-peer-deps(react-native-tvos prerelease alias), as documented. - TypeScript definitions (
sdk/index.d.ts, hand-maintained): the entire engine surface — options (hybrid/tune/zapPrefetch/swarm/uploadPolicy), a typed event map (all 11 events with exact payloads read fromplayer.js, not the README), methods, and the login/recover helpers — kept deliberately in sync with the JSON-safe mirror types insdk/react-native/src/backend.ts. Wired viatypes+ anexportstypes condition. @aliran/react-nativepeers tightened where honest (react >=18,react-native-video ^6,react-native-bare-kit >=0.13.3) whilereact-nativestays*by design: TV apps alias it to react-native-tvos prereleases, which fail any strict semver range (ERESOLVE).- New:
@aliran/coreREADME (the registry page was blank), absolute links in published READMEs (relative repo links break on npmjs.com), a runnableexamples/headless-player.mjs(the quickstart as a program; resolves the SDK through the workspace), and a Player SDK docs-site page (docs/sdk.md) —mkdocs build --strictgreen.
Publishing the dashboards — the exposure pattern, folded back into the repo (verified)
- The original deploy pack shipped
deploy/Caddyfile.examplewith no auth guidance. The pattern that survived contact with a real deployment (the broadcaster dashboard went public over TLS on 2026-07-21) now lives in the repo:basic_authscoped to the UI only (@ui not path /api/*), a bcrypt placeholder (caddy hash-password, cost 14), the broadcaster (:3310) as the worked example with the panel (:3210) flagged as the bigger decision (its API creates users and grants channels — own credential, if exposed at all), and a commentedremote_ipallowlist variant. - Why the scope is load-bearing (this shipped broken on first delivery): HTTP has ONE
Authorizationheader. The dashboard sendsBearer <token>to its own API, which replaces the browser's automaticBasic …, so an unscoped gate 401s every API call and the browser re-prompts on every click. Stated honestly everywhere: the UI gets two gates, the API keeps its one rate-limited Bearer gate — no header-based mechanism can add a second; an IP allowlist or mTLS can. - Two lessons that generalise, now in the docs: verify layered auth with the
composed request (each layer in isolation passes while the combination is broken —
POST /api/loginthrough the proxy, then BearerGET /api/channels, must return 200 with nowww-authenticate), and a P2P host is not "outbound only" behind a firewall — a broadcaster holds ~2 UDP sockets per channel on random ephemeral ports, and a public IP is addressed directly by peers (no NAT conntrack to mask the gap), so a default-deny ufw silently degrades seeding unless the ephemeral UDP range is allowed; verified by inbound/proc/net/snmpdatagram counters climbing withRcvbufErrors0. - Full walkthrough:
docs/kb/public-dashboards.md(DNS before Caddy, host install, credential hygiene, the ACME race — a firstcurlreturning 000 aftersystemctl start caddyis not a failure).mkdocs build --strictgreen; nocaddybinary on the dev box, so the example is checked by eye and mirrors the production config verbatim.
Viewer-path swarm socket tuning (verified on the S22 over the live VPS)
The deliberately-deferred half of the socket-buffer work (the servers were tuned
earlier): the viewer engine's single Hyperswarm now sizes its UDP buffers too. An earlier
session had tried this and reverted it as "no value on a phone" — that assessment was
half right, and the half matters: a phone's uplink does cap first, but that only
disqualifies tuning the send side. A viewer is download-dominant — every watched
feed's traffic funnels into the receive side of one UDP socket pair while the worklet's
single JS thread is busy with crypto — so the receive buffer is exactly the kernel-side
shock absorber worth sizing. Defaults: recv 2 MiB, send untouched; overridable via
the SDK swarm: { rcvbufMb, sndbufMb } option (MiB, 0 disables — the server envs'
semantics).
- The Bare constraint shaped the design. core/net-tune.js imports node:fs for
the /proc ceiling read, and an fs edge in the worklet bundle graph becomes a
builtin: ref the 0.13.x runtime cannot load. Split: core/net-tune-core.js now
holds ALL the logic with zero imports (a test:nettune group asserts that
property on the file text) and takes readFile by injection; net-tune.js stays the
Node entry binding fs, so every server callsite is untouched (suite passes unchanged).
The engine passes its already-injected fs — on Android the /proc read is denied
and the code degrades exactly as designed: the setsockopt still applies (needs no
privileges), only clamp detection falls back to the readback comparison.
- Proof on hardware (release APK of this tree, Galaxy S22, live VPS): logcat shows
{"type":"status","state":"net:tuned","message":"swarm sockets tuned: recv 2 MiB,
send untouched"} ~5 s after cold launch — the healthy summary, i.e. the S22's kernel
granted the 2 MiB receive request. Worklet boots clean (no builtin: refs at all in
the new bundle — the previous shipped bundle carried a stray inert builtin:fs, gone
now), auto-login lands, a lineup channel plays P2P (localhost HLS, peers: 1 ticker),
and zapping CDN→P2P→P2P→P2P held up with the engine healthy throughout. One lineup
channel sat in "Tuning" with an advancing progress bar while its
upstream IPTV source limped — the engine kept serving (steady peers, feed:ready
fired) and zapping away was instant, which is the flaky-source profile the scale test
already documented, not a client regression.
- Reporting: the outcome emits as a status/net:tuned event with the same text
the servers log (identical clamp warnings deduped — one socket pair, one host). The
app's debug relay (adb logcat -s ReactNativeJS, [backend] lines) is the proven
on-device sink; the worklet also mirrors a [net] console line for Bare hosts whose
console reaches a log.
- Versions: @aliran/core 0.1.1 (new entry point — published 0.1.0 tarballs are
immutable and lack the file), @aliran/player-sdk 0.1.1 (requires core ^0.1.1),
@aliran/react-native 0.1.1 (SwarmConfig mirror). All three lockfiles regenerated;
publish remains a maintainer action (core → player-sdk → react-native, one at a
time). Suites: test:nettune (new group F), sdk unit (new socket-tuning group), full
test:sdk e2e, client tsc + jest 10/10.
The VOD library: a separate service for on-demand titles (verified)
VOD landed as a new top-level deployable, library/ — an explicit architecture
decision, not an accident of code placement: the broadcaster is a live pipeline
(watchdogs, rolling buffers, feed rotation, boot-resume pacing) and none of that
lifecycle applies to a static seed; ingest is a one-shot transcode burst (0.5–1
core) that a production live box running at ~72% CPU must never absorb; and the
failure domains stay separate. Operators run the library wherever the disk and
spare cores are — beside the stack via the compose vod profile, or on entirely
different hardware.
- A title = a catalog record + one encrypted Hyperdrive. Ingest: ffprobe the
input (must have a FINITE duration — a live stream would fill the disk, and is
refused at probe time),
-c copyremux when the codecs are already HLS-compatible (h264/hevc + aac/mp3/ac3) else transcode h264/aac with keyframes forced onto segment boundaries,#EXT-X-PLAYLIST-TYPE:VOD+#EXT-X-ENDLIST, segments imported in order with the playlist LAST, ALL segments kept — disk = title size, reclaimed only by delete. Storage is the repeater's model (ONE Corestore + ONE Hyperswarm for every title — a seeder needs one socket pair, not one per title), per-generation namespaces (title:<id>:g<n>) so a re-ingest mints a fresh feedKey and purges the old cores, and per-title encryption keys minted once and reused across re-ingests so grants survive (the broadcaster's feed.key contract). PanelLink + register.js ride as self-contained copies (the control-auth convention: separate deployables each ship their own). - The panel gained a record CLASS, not a new pipeline:
type:'vod'carriesdurationSec(payload-owned, like feedKey) and omitsisLiveentirely — liveness is not a property a title has. The conditional sits exactly in isLive's slot in the record builder, so a live record's key order — and therefore its idempotency byte-compare against pre-VOD records — is unchanged (test:register stayed green untouched). Grants/sealing, panel-authoritative descriptive metadata, curation preservation and blobsKey enrichment (a future keyless title mirror needs it) apply to titles verbatim. - The SDK branch is mostly about what must NOT run. Every live self-heal
mechanism defines health as "the playlist ADVANCES" — and a finished VOD playlist
never advances, so each would false-fire on a perfectly healthy title: the tune
watchdog would walk retune → teardown → a false friendly error within 3×
timeoutMs; the zap-prefetch gate would suspend on a fake 'stall' within 12 s;
hybrid's stall probe would dump the viewer to CDN on play. resolve() now returns
{type, durationSec}and for vod serves-and-stops; the guards live INSIDE_startTuneWatchdog/_startZapPrefetchtoo, becausereconnectActiveFeed()andsetZapPrefetch(true)re-arm mid-play. vod titles keep their curated slot (and prewarm still warms their connections) but are never segment-warmed as zap neighbors, and a catalog feedKey change is deliberately NOT hot-followed (a re-ingest mid-film would yank the playhead; it applies on the next resolve). The localhost serving layer needed exactly one change: the playlist read-ahead now prefetches the HEAD of an ENDLIST playlist (a viewer starts at the top) instead of the tail (the end credits). test:vodruns the whole chain on a LOCAL DHT testnet (deterministic → the REQUIRED CI lane, which now installs ffmpeg): generate a real file → control-API ingest (copy remux) → vod record class asserted field-by-field (including register idempotence for the class) → blobsKey enrichment → grant → SDK login/resolve → ffprobe validates the SERVED url (12.0 s over P2P) → Range read on the LAST segment (seek-style random access, no sequential fetch led there) → a 10 s quiet-wait past 3× a shortened tune timeout proves the watchdog did NOT arm (events + internals) while a synthetic live channel beside it proves it still DOES arm and stand down, cleanly across vod↔live zaps → DELETE purges the cores from disk and the catalog flipsstatus:'unavailable'. One harness gotcha worth remembering: neverspawnSynca probe against a server hosted by the same Node process — spawnSync blocks the event loop, the server can't answer, and ffprobe "times out" against a perfectly healthy stack (async spawn + await).- Versions:
@aliran/player-sdk0.1.2,@aliran/react-native0.1.2 (Stream gainstype/durationSec/status; ResolveResult gainstype/durationSec; the RNportmessage declaresrecordType/durationSecfor the stage-2 app work). Core untouched. Publish remains a maintainer action. - Suites: new
test:vod(required lane) PASS; fulltest:sdkPASS untouched;test:register/test:broadcaster-apiPASS (zero broadcaster changes);test:core/corrupt/args/retention/nettune/sourcesPASS. Stage 2 (the app: Library rail, seek UI, S22 on-device) is a follow-up card.
VOD stage 2 — VOD in the app: Library rail, transport UI, live machinery interplay (verified on the S22 over the live VPS)
The app half of the on-demand library. The worklet (client/backend/backend.mjs)
now forwards the engine's ResolveResult type/durationSec as
recordType/durationSec on the port IPC reply (named recordType because
type is the IPC envelope's own discriminant — the RN union already declared the
fields). <AliranVideo> keys on it: the live-edge stall-resync ladder disarms
while the served record is vod — a paused, seeking, or finished playhead sits
still by design, and a resync remount would yank the title back to 0:00 — and
re-arms the moment a live port reply lands (vod→live zap). The component also
gains a seek() imperative handle (AliranVideoHandle; the internal ref
always points at the CURRENT mount, surviving self-heal remounts) and the
AliranBackend singleton mirrors recordType/durationSec so a re-entered
screen starts disarmed without waiting for a fresh reply.
App-side, titles are deliberately channel-shaped where it helps and not where
it lies: the Library rail falls out of the category machinery for free
(category:['Library'] on the record — zero rail code), rows/detail reuse
ChannelRow/ChannelInfoPanel with a runtime badge instead of LIVE (vod records
carry no isLive; availability is status, and 'unavailable' grays out), the
EPG slot is omitted (a title has no schedule), and titles take no channel
number and no place in the CH+/CH- ring (zapOrder/channelNumbers are
live-only — adding movies must never renumber the lineup; zapping from a title
lands on channel 001 and re-arms live behavior). The NowPlayingBar grows a
phone-only transport row: play/pause, elapsed/runtime (formatDuration
h:mm:ss), and a tap-or-drag seek bar built in pure JS (PanResponder — no
native slider dependency to autolink); the bar stays up while paused (the play
control must not fade away), and end-of-title parks on ▶ which replays from the
top. TV renders the row display-only — nothing focusable enters the D-pad zap
path (the TV-shell lesson).
Verified end-to-end against production infrastructure (the live VPS panel +
its ~84-channel lineup), first on the emulator and then on the S22 itself:
the panel image was updated to the stage-1 code (the vod record class predates
deployment — a pre-update register would have landed in the old responder), a
library instance on the dev desktop (the separate-hardware operator model)
enrolled as publisher library1 scoped vod-*, ingested a 3-minute h264/aac
clip with a burnt-in running clock, registered vod-clock-demo (type:'vod',
durationSec:180, blobsKey enriched) and seeded it over the public DHT. On the
emulator (x86_64 release build): the Library rail + 3:00 badge rendered, the
title played P2P from the desktop seed, and the burnt-in clock made the
transport provable frame-exactly — elapsed 0:58 over frame 00:00:57.000,
seek forward to 00:02:07.840, seek back to 00:00:32.120, pause
held 36 s on the identical frame with zero stall/resync/error lines (the old
ladder would have fired at 12 s), resume from position, and a zap out to a live
channel flipping the reply to recordType:'live' with the numbered tuning pill
and no transport row. On the physical S22 (arm64 release APK, chunked-push
install over a USB link that flapped authorizing→device→gone all night — the
detached-pm install and one-command-per-window lore carried it): the title
played P2P from the desktop seed, transport elapsed 0:54 over frame
00:00:54.480, seek 0:54→2:27 frame-exact, a missed tap let the title PLAY
OUT — end-of-title parked on ▶ at 3:00/3:00 on the LAST frame with the bar
held sticky and no resync restart after 30+ s of a still playhead (the
strongest possible disarm proof), replay-from-end via the seek handle,
pause held 31 s frozen + resume, and the logcat chain
a live channel (recordType:"live") → vod-clock-demo recordType:"vod" → a live channel
playing again — the full live→vod→live interplay on real hardware. Client
suites: jest 11/11 (43 tests, incl. the new AliranVideoVod group pinning
disarm/re-arm/seek and the catalog/row/bar vod cases), tsc clean, bundle
regenerated and validated (main + 15 linked addons, zero builtin: refs). The
test title, its grants, and the session's temp admins were purged from the
production panel afterwards (stop the library BEFORE the purge — its register
heartbeat resurrects a purged record); publisher library1 stays enrolled for
future library deployments.
Windows desktop player: full TV-app parity on Electron (verified against the live VPS)
The roadmap's DRM-hardening and geo items were dropped (2026-07-22) and the effort
moved into a Windows desktop player — Electron shell, full parity with the
Android app's experience. New top-level desktop/ workspace (private, never
published), built in three pushed stages.
Architecture. The engine (@aliran/player-sdk) runs in the Electron main
process — the N-API prebuilds (sodium-native, udx-native, …) that the Node e2e
suites exercise load in Electron unchanged, so the desktop app consumes the
published SDK with zero engine forks. The renderer is a sandboxed React app
(contextIsolation on, no nodeIntegration) behind a three-call preload bridge
speaking the same line-JSON message protocol as the Android worklet shell — which
made the RN screens port mechanically. hls.js/MSE plays both the engine's
localhost P2P playlists and redirect-channel URLs on one <video>. Saved
credentials are safeStorage (DPAPI)-wrapped, and the desktop goes one step
further than the phone: the password never returns to the renderer — splash
auto-login is fulfilled inside main, and the prefs reply carries the username
only. The transient-login retry loop lives main-side, once.
The <AliranVideo> contracts, reimplemented for hls.js (HlsVideo):
engine-confirmed tune lifecycle (one localhost URL serves every channel — the pill
completes only on the port reply for this channel + an advancing playhead on
the current mount), feed-changed remounts behind the same URL, the
frozen-live-edge ladder (12 s still playhead → live-edge reload → second failure
tears the wedged transport via reconnectActiveFeed()), fatal-network retry
remounts, one recoverMediaError() before a media-error remount, and a clean
per-channel codec error instead of a retry loop when the host GPU can't decode a
stream. VOD rides the same surface — recordType disarms the ladder and the
bar grows a seek/pause transport (implemented to the same contracts; a live-VPS
vod pass awaits an operator library deployment with granted titles).
Parity surface. Menu hub (icon bar over the featured wallpaper, sections
data-driven from the descriptor), category rail with the two-level drill,
numbered channel list with per-row EPG now-lines, detail panel with the live
now/next guide — the plain-TS EPG data layer is imported from
@aliran/react-native source unchanged (one cached fetch covers every row
sharing a feed URL), favorites (device-local), search, settings with the
"Smooth zapping" toggle (persisted + applied live), subtitle/audio TrackMenu
(flat hls.js indexes — the ExoPlayer group-index pitfall doesn't exist here),
tuning pill with honest self-heal labels, NowPlayingBar with clock + EPG-now,
resume-last-channel, and keyboard-first navigation mirroring the D-pad model
(rail/list panes, Esc unwind, f/i/c shortcuts).
Packaging (electron-builder 26): NSIS one-click installer + portable exe,
asarUnpack for the native modules, the operator service.json baked as a
resource (per-deployment builds, like the phone APK), a brand-palette icon.
electron-builder's workspace support collected the symlinked @aliran/{core,
player-sdk} into the asar correctly. Builds are unsigned — the SmartScreen
"unknown publisher" reality is documented rather than hidden. Per-brand desktop
packaging is noted as a follow-up.
Verification — against the production VPS (~84 live P2P channels + ~238
redirects), on the PACKAGED portable/unpacked build, with a throwaway user
(s35test, deleted after): sign-out → login → the 244-channel lineup; P2P
playback with the peers badge (P2P · 1 peer); a zap chain
P2P→P2P→P2P→redirect wrapping the numbered ring (241→…→001) with the tuning
pill; the EPG guide rendering now/next with the elapsed bar (provider feeds); track
selection flipping an English subtitle track to showing; the Smooth-zapping
toggle persisting + echoing; and the HEVC verdict: two HEVC 1080p lineup
channels play at full 1920×1080 on this box (platform
hardware decode; MediaSource.isTypeSupported('hvc1') true) — on hosts without
it the player surfaces the clean codec error. Screenshots:
aliran-ops/s35-screenshots/01…12. The engine logged the swarm socket tuning
(recv 2 MiB) on desktop too. tsc clean, node --check clean,
mkdocs build --strict green. Docs: docs/desktop-player.md + nav/README/
CHANGELOG. Gotchas kept for the next session: electron postinstall's zip
extraction silently produced only locales/ on this box (manual
Expand-Archive + path.txt fixed it); electron-builder needs electronVersion
pinned when electron is hoisted to the workspace root; the packaged and dev apps
share %APPDATA%\aliran-desktop (userData follows package.json name), so the
single-instance lock spans them — close one before launching the other.
Desktop player follow-up — the PUBLIC flavor: runtime panel-key entry (verified)
Same-day user decision: the baked-descriptor build is the operator ("aliran
ops") flavor; the public distribution must let anyone running their own
panel+broadcaster enter their panel public key + account in the app itself.
Implemented as the desktop version of the phone app's documented
runtime-descriptor path, one codebase: descriptor resolution is now baked
(resource/dev config, always wins) → runtime (aliran-service.json in userData,
persisted by the new Connect screen) → none (the Connect screen: panel key +
username + password — the exact three artifacts §2 of the SDK guide says an
operator hands out). set-service validates the key (64 hex), persists, boots
the engine, and the normal main-side retried login takes over; Settings →
Change service… (runtime flavor only) forgets the key + credentials and
relaunches clean. Packaging tolerates the missing service.json via the
directory+filter extraResources form — npm run dist with the file = operator
build, without = public build. Verified in dev and on the packaged build:
first-run Connect → key+creds → live playback; relaunch → auto-authorize
straight to live; Change service → clean Connect with both files gone; the
operator build untouched (boots to sign-in, never shows Connect). Screenshot 13
(public Connect screen) added to aliran-ops/s35-screenshots/; both flavors'
installers + portables staged in aliran-ops/s35-build/. One diagnosis from the
earlier session upgraded: the portable exe's "instant exit" was the
single-instance lock against the still-running dev app (shared userData), not a
packaging fault.
Public Android APK: runtime service descriptor, phone + TV (verified)
The Android analogue of the desktop player's public flavor: one keyless APK
(phone + Android TV — the manifest has declared both from the start) that connects to
any operator's service at runtime. config/service.json still decides the
flavor at build time; the new committed keyless config/service.public.json
(panelPubKey: "" as the deliberate marker — REPLACE_ still throws) routes
first run to a Connect screen: panel public key (64 hex) + username +
password, the exact three artifacts an operator hands out, no URLs. Both persist
in the worklet prefs (beside credentials/favorites, surviving store purges)
only after a successful sign-in — a typo'd key or password never sticks, and
retry happens in place. Later launches auto-authorize through Splash; Settings
→ Change service… (keyless flavor only) forgets service + sign-in and returns
to Connect. A baked key always wins, ignores any persisted service, and is
never changeable at runtime.
Engine/worklet: prefs grew a validated service field
(service-save/service-clear), and the {panelPubKey} connect dispatch now
swaps the engine wholesale (player.stop() → fresh player) when a different
key arrives — a wrong-key retry or a service switch never reuses the old
panel's swarm/bee/feeds. The RN binding got optional StartOptions.panelPubKey
(boot idle, read prefs first), connect(), saveService()/clearService()
and a service mirror; the ConnectScreen waits for the fresh {type:'ready'}
before logging in so nothing races a teardown. Bundle regenerated and
validated: main + 15 addons, zero builtin: refs.
Verification — the public x86_64 release APK against a LOCAL rights-clean
demo stack (offline admin-cli init/user/grants/set-meta, control-API channels
on the built-in colour-bars test input, the committed docs/demo/epg.json
re-centered and fetched from its raw.githubusercontent URL). On the Pixel 8 Pro
AVD (phone) and an Android TV AVD, each from a fresh install: Connect (the
64-hex validation caught a genuinely truncated key mid-test), connect → ready →
login, Menu, category rails with live EPG now-lines, P2P colour-bars playback
(P2P · 1-2 peers in diagnostics; the second peer was a host-side headless-SDK
viewer), D-pad zap + channel list + program guide on TV (leanback IME entry,
focus traversal to every control), force-stop → relaunch auto-authorize, and
Change service… → clean Connect on both (with the cleared state surviving
another force-stop). Client jest 12 suites / 55 tests green, tsc clean,
mkdocs build --strict green. Docs: docs/android-viewer-guide.md (install/
"unknown sources", Connect, separate phone and TV/D-pad sections, honest
privacy/bandwidth incl. the cellular no-reseed behavior, troubleshooting)
with rights-clean captures in docs/img/android/.
Two emulator-lab gotchas worth keeping: QEMU's user-mode NAT + router hairpin makes the FIRST DHT holepunch to host-local peers slow (~1–3 min) — the Connect screen's in-place retry absorbs it (the guide says "press Connect again") — and bulk P2P segment flow through that relayed path only sustains low bitrates, so the demo channels were patched to 300 kbps for the video verification (real devices on a LAN or against a remote host don't hit either; the S22 + VPS combination never has).
Desktop player on macOS — configuration-only packaging, built on hosted CI
An Apple-platform feasibility audit of the installed dependency tree settled
three questions at once. macOS: yes, with zero engine work — every native
module the engine loads in the Electron main process (both sodium-native
majors, udx-native, fs-native-extensions, crc/quickbit/rabin/simdle) already
ships darwin arm64 + x64 N-API prebuilds, and the app code was already
platform-clean (safeStorage is Keychain on macOS, DPAPI on Windows; the
build script is plain esbuild). Apple TV: no — the Bare runtime and its
addon toolchain have no tvOS targets at all (no tvos-* prebuilds anywhere,
no tvOS slice in the bare-kit xcframework, and the linker rejects tvOS hosts),
so a tvOS app could only be a CDN-only shell and is not pursued. iOS: the
engine is fully portable (bare-kit ships an iOS xcframework and all twenty
runtime addons carry complete ios-* prebuilds) — but iPhone distribution is
App-Store-only with its own review and account costs, and the project decided
not to pursue it for now.
The macOS half shipped immediately because it is configuration only:
electron-builder.yml gained a mac block (dmg + zip targets, the video app
category, the .icns derived from the existing 512-px icon) with
identity: null — there is no Apple Developer certificate, so builds carry
only the ad-hoc signature arm64 requires and Gatekeeper blocks the first
launch (right-click → Open), the macOS analogue of the SmartScreen note.
A dist:mac script covers Macs locally, and a new manual-dispatch
desktop-mac workflow builds on a hosted macOS runner — the repo carries
no config/service.json, so CI artifacts are always the keyless public
flavor by construction. First dispatch ran green end to end: root npm ci
resolved the workspace SDK and every darwin prebuild with nothing compiling,
and the runner produced all four artifacts (Aliran-0.1.0-arm64.dmg,
Aliran-0.1.0.dmg, and the matching -mac.zip pair) with checksums in the
run log. Honest scope note: the artifacts are CI-built and the packaging path
is proven, but a first-launch pass on physical Apple hardware (Gatekeeper
flow, login, P2P playback) is still pending access to a Mac.
Legacy Android builds — the SDK goes silent below the engine floor (verified on an Android 7 emulator)
Operators still run Android 6–9 set-top boxes, and the app hard-capped at
minSdk 29 — the engine's libbare-kit.so needs ELF TLS, a libc feature
Android added in 10, so the floor is physical. The decided contract: the SDK
must work in every build and simply be silent where the engine can't
exist; the host app detects that and mounts its own legacy/CDN mode.
Two build-system discoveries shaped the implementation. First, bare-kit is a
C++ TurboModule statically linked into libappmodules.so — on an old
device the dlopen failure hits at React init, before any JS, so no JS gate
can save an APK that still links it. The legacy flavor (ALIRAN_LEGACY=1)
therefore excludes react-native-bare-kit from autolinking
(client/react-native.config.js) and drops minSdk; settings.gradle
dirties the autolinking cache when the mode flips, because that cache keys on
lock-file hashes and would otherwise silently reuse the other mode's module
set. Second, the obvious JS gate — "does require('react-native-bare-kit')
throw?" — is defeated by Metro's release-mode inline requires, which
defer the package's native-spec require (and its "TurboModule missing" throw)
from package load into the Worklet constructor; the first on-emulator run
crashed exactly there. AliranBackend.isSupported() now checks
TurboModuleRegistry.get('BareKit') directly (authoritative on-device) with
a construct-a-Worklet probe as the fallback for mocked test environments —
on an engine-less device the deferred throw lands inside the caught probe,
before NativeBareKit.init, so it has no native side effects. When the
verdict is "absent", start() flips the backend inactive: every method is a
safe no-op, nothing queues, no listener ever fires.
The plan's Android 6 hope died honestly: RN 0.76+ prebuilds are built for
API 24, and gradle rejects minSdk 23 at configure time (prefab:
"library was built for 24") — so the legacy floor is Android 7, and
Android 6 boxes cannot run any current-RN app at all. Verified: 54 client
jest tests green (incl. new engineless-contract tests), the legacy APK
carries zero bare/sodium/udx libraries and sdkVersion 24
(libappmodules.so readelf shows no bare-kit DT_NEEDED), it boots on an
Android 7 emulator to the branded "this device can't run the P2P engine"
notice with zero exceptions over a sustained window, and the rebuilt modern
flavor still boots the engine to {"type":"ready"} against the production
panel (the lazy Worklet construction is the only shared-path change). A
physical pass on the old Fire OS 7 stick is pending it being reachable over
adb.
One APK for all of it — bare-kit behind a runtime dlopen (verified: same APK silent on Android 7, full engine on 16)
The two-flavor answer didn't survive contact with the requirement: operators
want one APK that installs on everything from Android 7 and simply runs
legacy mode below 10. The blocker was a single link edge — bare-kit is a C++
TurboModule statically linked into libappmodules.so, so libbare-kit.so
was a DT_NEEDED resolved at React init on every device. The fix is a
patch-package patch on react-native-bare-kit
(client/patches/react-native-bare-kit+0.13.3.patch): the module's 22
bare_* calls now go through a dlopen("libbare-kit.so",
RTLD_NOW|RTLD_GLOBAL)/dlsym table resolved at first worklet init and only
when android_get_device_api_level() >= 29; the package CMake drops the
imported link (killing the DT_NEEDED); the package minSdk falls to 24.
Packaging is untouched — the engine still ships via jniLibs; RTLD_GLOBAL
keeps bare symbols visible to the runtime's addon dlopens exactly as before.
Two belts back it up: BareKitModule::init throws a clean JSError where the
table is unavailable, and the SDK now refuses by Platform.Version < 29
before ever consulting — or constructing — the native module. The client
builds one minSdk 24 APK by default; ALIRAN_LEGACY=1 remains as an
optional engine-less lean flavor.
Verified with the SAME 300 MB universal APK: structure (sdkVersion 24,
libbare-kit.so aboard in all four ABIs, readelf shows no bare-kit NEEDED
in libappmodules.so), an Android 7 emulator (installs — previously
impossible with the engine aboard — runs silent to the branded notice, zero
linker errors), and an Android 16 emulator (engine boots through the dlopen
path to {"type":"ready"} against the production panel — the full dlsym
table exercised live: worklet alloc/init/start plus IPC both directions).
Tooling note for the KB: patch-package could not GENERATE the patch on
Windows (silent crash on the package's .cxx/build junk; --include
never matches, its internal paths are backslashed) — the patch was hand-rolled
from an npm pack pristine tree with git diff --no-index, and the apply
path verified by restoring pristine files and re-applying.
aliran-kit — the native Kotlin SDK: one APK from Android 5.0, P2P self-activating on 10+ (verified on 5.1 + a modern emulator)
The question that started it: with pure Java/Kotlin, how far down does Android
go — and does the P2P floor move? Verified from the binaries: the floor does
NOT move (the 32-bit libbare-kit.so carries the same __tls_get_addr@LIBC_Q
import — Android 10, framework-independent), but the APP floor drops to
Android 5.0, because Holepunch ships BareKit as a plain Java API
(to.holepunch.bare.kit.Worklet/IPC — the RN package merely wraps it) and
nothing else in a Kotlin stack imposes React Native's API-24 prebuild floor.
Best discovery: System.loadLibrary("bare-kit") sits in the Worklet class's
STATIC INITIALIZER, and Java classes initialize on first active use — so in
Kotlin the Android-10 gate is simply "don't touch the class below API 29".
No native patch at all, where the RN edition needed the dlopen rewrite.
sdk/android/ is a standalone Gradle project: :aliran-kit (library, minSdk
21) + :demo (the reference host). The library vendors the engine from the RN
package's checkout (bare-kit prebuilds + the linked addon set + classes.jar),
adds libc++_shared.so from the NDK (the one packaging item RN did implicitly
— its absence was the only crash of the bring-up), and converts
client/backend/app.bundle.js into a binary asset at build time — the SAME
engine bundle the RN app runs. AliranBackend.kt ports backend.ts (line-JSON
IPC over BareKit's poll-and-drain reads and direct-buffer writes; caches;
main-thread dispatch; the silent-inert contract below 29, JVM-tested).
AliranPlayerView ports the <AliranVideo> contracts onto Media3.
Verified with ONE demo APK (81 MB, sdkVersion 21, engine aboard ×4 ABIs):
an Android 5.1 emulator installs and runs it — EngineNotice + the
"watch demo stream" fallback playing plain HLS via ExoPlayer, zero linker
errors, an OS no Aliran build has ever touched — and a modern emulator
runs the FULL P2P path: worklet boot, OPRF login over the public DHT against
the production panel (after teaching the demo the boot-order lesson: ready precedes
the panel link, so login retries on "not connected"), the real catalog, and a
live channel rendering through the ported player. Lore for the KB: the old
Android 5.1 default emulator image balloons its AVD to ~4.3 GB regardless of
disk.dataPartition.size, and the fallback-CDN TLS caveat stands (below
Android 7.1.1 there is no Let's Encrypt root — the Mux test stream's classic
chain worked on 5.1).
<EngineNotice> — the unsupported-device screen becomes an SDK export (verified)
With single APKs installing on Android 7–9, every embedding app faces the
same moment: isSupported() is false and the viewer needs to be told
something — ideally with a way out. The SDK now exports EngineNotice, a
purely presentational, brandable screen (title/message/colors/children) whose
optional action button is the host's fallback seam: wire onAction to your
own alternative method (your CDN/HLS playback, a help flow) and the button
renders D-pad-focusable for TV; omit it and the screen is informational. The
component is deliberately content-agnostic — per the project's standing rule
the SDK ships the notice and the switch, never the delivery. The shipped app
dogfoods it (its EngineUnavailable now just brands EngineNotice from the
service theme), the SDK guide's single-APK recipe shows the pattern, and four
new jest tests pin the contract (default copy, branding, seam-only-with-
handler, default action label). Verified on the Android 7 emulator: the
single APK renders the SDK-exported notice identically to the previous
inline screen.
Reseller panel — role hierarchy + credit ledger fronting the panel admin API
Operators reselling access needed a way to hand out account-management power
without handing out real admin power. The panel makes that awkward for two
deliberate reasons: every panel admin is all-powerful (there is no scoped
"reseller" role), and panel accounts have no expiry (nothing ends a lapsed
subscription). Rather than complicate the panel, this is a new standalone
reseller/ service that supplies both on top — a pure HTTP service (no P2P, no
ffmpeg) that fronts the panel admin API and can run on the panel host or a
different box, authenticating as one dedicated panel admin.
The hierarchy lives here. Four roles — admin → co-admin → super reseller →
reseller — as a central capability map (roles.js), never scattered role-string
checks. The seeded root admin is the only one that can create or delete
co-admins and is itself undeletable; a co-admin is otherwise a full admin clone
(including minting) so a second all-powers login gets its own audit trail; supers
and resellers act only within their own subtree, walking parent pointers with a
cycle guard. The role is never trusted from the session token — the live
principal record is re-read on every request, so a suspension or role change
bites on the target's very next call.
Credits are months, tracked as a ledger. 1 credit = 1 month, flat (device cap
is a per-account setting, not priced). The ledger is an append-only JSONL file —
the durable audit trail, since the panel's own activity feed is in-memory — with
one global monotonic sequence (appends serialized by the service's single mutex,
so no per-user id races) and balances always derived from a boot scan, never
persisted, so they cannot drift; /healthz asserts the sum invariant, and a torn
final line from a crash mid-append is truncated on boot while corruption anywhere
else refuses to start. Only admins and co-admins mint (from nothing); even an
admin's transfer debits their own balance, so the ledger always shows where a
credit originated. Supers fund their resellers from their own balance; delete
refunds floor(remaining months) to the account's owner; admin account
operations are free and write no ledger line.
The subscription clock is the account registry. Because the panel has no
expiry, accounts.json is authoritative for it. Every account operation is
fail-closed — the panel is called first and the ledger + registry commit only on
its OK, so a rejected activation (out of credits, name taken, panel down) leaves
nothing behind — and runs inside the process mutex so a balance check and its
debit can't interleave. Accounts are namespaced
<globalPrefix>.<resellerPrefix>.<name>, which makes ownership unspoofable and
keeps a reseller's view to its own namespace. Two background loops keep the two
sides aligned: an expiry sweep that disables lapsed accounts on the panel
(its work list re-derived from expiry each tick, so an unreachable panel just
means a retry next tick, backing off to 15-minute checks meanwhile), and a
reconcile sweep that pages the panel's users under the prefix and reports —
or, with RECONCILE_REPAIR=1, repairs — divergences, always letting the local
clock win (an orphaned panel account is disabled, never deleted). The panel
client caches its Bearer token to disk so a crash-loop never burns the panel's
login throttle, and transparently re-logins once on a 401.
Trials are free time-boxed accounts (default 24 h, per-reseller daily cap enforced by counting zero-value TRIAL ledger lines in the current UTC day); renewing a trial converts it to paid with the same credentials — the upsell path.
The login path reuses the worker-thread single-flight Argon2id verifier from the 2026-07-16 flood lesson. The dashboard is the usual no-build four-view page (overview / accounts / resellers / ledger / settings) on the shared theme, with nav and controls filtered by role — the theme block's byte-identical guard now covers three sheets and its test compares each to the first rather than pairwise.
Verification. Unit suite (test:reseller-unit) covers the capability map,
subtree walk with cycle guard, the ledger (seq monotonicity, torn-tail
truncation, mid-file-corruption refusal, derived-balance invariant, scoped
listing), the store mutex + atomic writes, and principal validation.
End-to-end (test:reseller, required lane) boots a real panel admin server
in-process (loopback HTTP, no DHT) and drives the whole story through it: auth
and lockout, the co-admin root-only guardrail, credits with role gates,
fail-closed activation asserted against the panel store itself (a 402 leaves no
panel user), renew/suspend/passthroughs, the trial cap and conversion, refund
rules, a suspension biting live tokens and bulk-disabling accounts, delete-block
rules, a panel outage returning 502 with nothing spent then recovering on the
same port, the tokenVersion-bump transparent re-login, and the expiry + reconcile
sweeps. The four-role dashboard was also driven live in a browser against an
in-process demo stack — a reseller login showing role-correct nav and a renew
round-tripping through to the admin's hierarchy view. Docker image builds from
the repo root behind the reseller compose profile and smokes via
reseller-cli list-principals in CI.
Follow-up (same day): account-name prefixes removed. The
<global>.<prefix>.<name> namespacing above was dropped by decision — resellers'
viewer accounts get plain panel usernames (a global first-come-first-served
space; a clash with an existing panel user surfaces as the panel's own error).
Nothing about safety depended on the name: ownership and scoping always came
from the registry's owner field, and that stays. The one job the prefix really
did — recognizing OUR orphaned panel user after a crash between the panel create
and the local commit — moved to an intent journal: the intent is written
before the panel call and cleared after the commit, and the reconcile sweep
chases stale intents (panel user exists + no registry entry = our orphan →
disabled and reported; no panel user = the create never landed, intent cleared).
Reconcile itself became registry-driven (per-account panel GETs instead of
prefix-paged listing), which also means operator-created panel users are now
completely invisible to the reseller service — the e2e asserts exactly that
(operator-joe with no intent survives a repair-mode reconcile untouched).
Principals lost their prefix field; the create form, docs, env (GLOBAL_PREFIX
gone) and reference followed. Both suites re-run green end to end.
Follow-up (same day): the accounts list scaled for density. The dashboard
fetched a single 500-row snapshot and filtered client-side — past 500 accounts,
rows were invisible and search silently lied. list() is now a server-side query
engine over the in-memory registry (a full filter → sort → slice pass costs a few
milliseconds even at ~100k accounts, so no index — just a real query surface):
case-insensitive search across account name AND owner, status filters
(active/disabled/expiring/trial — expiring shares the KPI's 7-day window),
name/expires/owner sorting with a name tiebreak, and offset paging returning
{items, total, offset, limit} so the UI can say "Showing X of Y" (the old name
cursor is gone; offset composes with every sort, and the tiny page drift a
concurrent write can cause between two Load-More clicks is fine for an ops
table). The dashboard is now fully server-driven: 100-row pages behind a
Load more button (the ledger table's idiom), a count chip, 250 ms-debounced
search, header sorting, and a click-any-owner drill-down chip for admins and
supers. Verified by a 5,000-account synthetic-registry unit section (envelope,
ci-search on both fields, every filter and sort, an offset walk covering the
whole set exactly once, junk-param rejections, scope) plus new e2e paging/
filter/owner-search assertions — and in the browser against a 394-account demo:
"Showing 100 of 334" → Load More → 300, a needle name deep past page one found
by server search, lapsed rows first under expires-sort, and the admin drilling
into one reseller's 60 accounts via the owner chip.
Follow-up (same day): numbered pagination + more sorts + phone layout. The
Load-more/auto-scroll list was replaced with real pagination on user request:
50 per page with prev/next and a jump-to-page <select> ("Page 4 of 8"),
plus a range count ("151–200 of 394"). Sorting gained created and status
keys (so: name, expiring soonest/latest, newest/oldest created, active-first/
inactive-first) — surfaced both as clickable headers and a toolbar sort dropdown
kept in sync with the header arrows. A subtle correctness fix landed here: page
loads are now latest-wins (each carries a sequence token; only the newest
response renders) instead of a busy-flag that silently dropped a rapid second
action — verified that hammering Next three times advances exactly three pages
with the combo and count agreeing. On phones (≤700 px) the table reflows into
stacked cards: headers hidden, each row a block, data-l cells self-label
("expires: 25d", "devices: 2"), and the redundant Owner-dupe and Created columns
drop out — no horizontal scroll at 375 px, the wide 7-column table returns at
desktop widths. Server default limit is now 50 (cap unchanged at 500). Unit +
e2e cover the new sorts and the 50 default; browser-verified both widths on the
394-account demo.
Repeater field validation — production worked example (verified)
A production shakedown of the repeater appliance on a deliberately awkward box: a
16-core Ubuntu 18.04 VM (kernel 4.15) already running unrelated Apache/MySQL/
memcached. Docker CE installed fresh (bionic's glibc can't run modern Node bare, so
the container path is also the only path on hosts this old), repo cloned,
repeater/.env scoped to three production channels, stock compose deploy —
contained as designed: zero listening sockets, co-tenant services untouched. The
socket-buffer story was captured live in both states for the KB: startup on stock
ceilings logged the documented clamp warnings (0.2 MiB granted vs 4 MiB asked)
while mirroring worked anyway — the silent trap — then deploy/sysctl/install.sh +
restart produced the clean swarm sockets tuned: recv 4 MiB, send 4 MiB line, and
the kernel UDP-error counter never moved through the whole test. Serving proven
with a stock SDK viewer (real login, no repeater config of any kind): over a
3-minute session its per-connection byte counters split 46 % repeater / 54 %
origin — hotswap treating the mirror as just another fast holder — with the
repeater's watched channel showing peers 2 for exactly the session and the
unwatched channels staying at 1. Retention held: block counts flat under climbing
feed lengths, store plateaued at ~161 MB for 3 channels (the bitrate × retention
formula on the nose), load average 0.13 on 16 cores. The destructive scenarios
(origin-kill, slots-full lockout, rotation purge) stayed in npm run
test:repeater, which passed beforehand. Everything captured (anonymized) in the
new KB page docs/kb/repeater-production-example.md, linked from the repeater page
and the network-tuning KB. The box was fully reverted afterwards — this deployment
was a test rig, not a standing repeater.
Observability & config hygiene (verified)
The last v1.0 ops checkbox: config validation, structured logging, health/
metrics across all five services, with zero wire-protocol changes. Config
first: every config.js now ends in a fail-fast validation pass — a typo'd env
var (bad integer, out-of-range port, malformed hex key/URL, FEED_BUFFER=rma)
throws at import with an error naming the exact variable, and every problem in
the file is reported in one shot. The NaN class this kills was real: parseInt
garbage previously flowed into timeouts and ports and surfaced as unrelated
failures. Deliberately preserved permissiveness: socket-buffer MBs still degrade
to "off" (tuning must never stop boot) and branding stays graceful. Logging:
LOG_FORMAT=json patches console once at startup and emits one
{ts,level,svc,msg} object per line (errors keep stderr); default output is
byte-identical, proven by the test. Endpoints: the panel admin API — previously
the only surface without one — gained unauthenticated /healthz (served before
the auth gate, same "answers while a login flood chews the API" contract as the
broadcaster's), and every control/admin server gained Prometheus-text
GET /metrics built strictly from the same cheap synchronous sources as its
healthz (channel + boot-resume + incident gauges on the broadcaster; title
states + panel-link on the library; principals/accounts/panel-reachability/
ledger-invariant on the reseller). The repeater keeps its no-listening-sockets
property: STATUS_PORT (default 0/off) opt-ins a loopback status server with
the same two endpoints, including per-core held_blocks/peers series labeled
by streamId. Verified by the new test:config (subprocess probes: 10 bad-env
rejections naming their variable, 5 clean-env boots, JSON-line shape + default-
untouched proof) added to the REQUIRED CI lane, plus new assertions in the
reseller e2e (reseller + in-process REAL panel healthz/metrics), the vod e2e
(library metrics), the repeater e2e (status server against a live mirror), and
the broadcaster-api e2e (metrics before login).
Backup, restore & key rotation runbooks + bounded logs (verified)
The second of the two remaining v1.0 ops items. The runbook page
(docs/kb/backup-and-rotation.md) is organized around one model: every byte is
identity (panel keys/ — signing + OPRF + shared publisher; not rotatable, only
protectable), data (panel store, reseller ledger.jsonl, library titles, admin
secrets), or cache (broadcaster feed stores, the whole repeater — never backed
up; a restored broadcaster re-mints feeds and viewers follow the catalog). Cold
backups only (a corestore copied mid-write can tear): deploy/backup.sh does
stop→tar→start per compose volume with the panel's stop window costing only new
logins. The restore section documents the sharp edge honestly — the store is an
append-only signed log, so restoring a stale snapshot forks any client that
replicated past it (recovery: clear client storage; mitigation: hourly cron +
restore-the-newest); failover is a warm standby holding the latest snapshot
under the NEVER-two-writers rule (two panels signing under one key = permanent
fork, strictly worse than downtime — the drill instructions even point the
scratch panel at a black-hole bootstrap). The rotation matrix covers every
credential with its exact endpoint/CLI and blast radius, all wire-compatible.
Verified: new test:backup (required CI lane) proves a cold DATA_DIR copy
reopens as the same deployment — same signing identity, same admin verifiers,
same accounts/grants/catalog over a real admin server, healthz answering.
Alongside: every compose file now bounds container logs (json-file 20 MB × 5 per
service) — Docker's default driver is unbounded and the eternal-log disk-fill is
exactly the failure an untended co-tenant box hits first; the operator guide's
Monitoring section documents the journald/daemon.json equivalents. Stale
threshold-OPRF promises removed from architecture.md and the operator guide
(rescoped out of the roadmap earlier the same day).
Hardening pass over the shipped crypto paths (verified)
The last open v1.0 item: a wire-compatible implementation audit of the crypto/auth
surfaces — parameters, timing, replay, revocation, resource exhaustion, key hygiene,
legacy sunset, dependencies. No protocol change, so every deployed player/SDK/app is
untouched; anything whose fix would break the wire is documented as residual risk
instead. The audit's parameter verdicts, audited surfaces and the full residual-risk
register now live in docs/security-model.md (the package for any future external
review). The headline was a remote, unauthenticated crash: the login RPC decoded
client hex fields with b4a.from(x,'hex'), which throws a TypeError when the field
survived JSON.parse as a non-string (a JSON object/number/bool). protomux-rpc hands a
thrown responder error to safety-catch, and safety-catch rethrows
TypeError/RangeError into a microtask — with no uncaughtException handler (by design),
that terminates the process. So login {"powNonce":{}} — no account, no proof-of-work —
killed the panel, and register/session had the same shape behind weaker guards. Proven
against the real attachLoginRpc over a loopback SecretStream pair (a scratch repro
exited 42 on the uncaught path). Fix: a strict hexField() guard (string, even-length
hex, optional exact byte length) on every attacker-controlled decode, failing closed
with the existing JSON errors — a real client always sends correct hex, so the wire is
unchanged. Three more items landed alongside: the fixed-window login throttle map was
unbounded (keyed on attacker-chosen username|peer, never pruned — a slow
memory-exhaustion DoS), now bounded by an expired-window sweep plus oldest-eviction past
a cap in all four copies (panel + the three control servers); the keys/ and secrets/
directories are now created 0700 (the files were already 0600); and the panel
now warns at boot when LEGACY_PUBLISHER=1 while named publishers are enrolled,
turning the shared-key sunset from a silent doc note into an operator signal. Verdicts
that needed no change were recorded, not just assumed: Argon2id costs (256 MiB/t=3
panel, 64 MiB/t=2 control) are at/above the OWASP floor and run single-flight off the
event loop; every secret comparison is already constant-time (sodium_memcmp, libsodium
verify, timingSafeEqual); a captured register cannot be replayed to roll a feedKey
back because the per-connection challenge rotates one-shot (a positive test proves the
replay bounces unauthorized while a fresh-challenge re-sign is accepted); revocation
re-checks the live record (tokenVersion + status + role) on every authenticated route;
and the crypto dependency set is advisory-free (the lone npm audit high-sev is
electron, the desktop player's build dep, not a shipped path). Verified: the new
test:rpc-hardening (required CI lane, loopback/DHT-free) drives object/number/array/
bad-hex/short values through login/session/register and asserts clean errors with zero
uncaught exceptions, plus the replay, throttle-boundedness, legacy-predicate and
POSIX file-mode checks. Four focused fix commits, all wire-compatible; @aliran/core
was not touched.
Channel packages ("bouquets") — entitlements granted as one unit (verified)
- Problem: grants were only ever per-stream (
grant alice sports-1× N), and the Users-tab grant chips stop scaling somewhere past a few dozen channels — a real lineup wants "Basic" / "Sports" / "Cine" bundles. The shaping constraint, verified in code before designing anything: a grant is cryptographic, not an ACL —user.wrapped[streamId]is the stream secret sealed to the user's public key and clients unseal at login, so a package cannot be a runtime membership check. It has to materialize into sealed per-stream grants, and the machinery for exactly that already existed in the source auto-grant reconcile. Packages generalize it. - Model: a plain registry
DATA_DIR/packages.json({ name: { label, members, default, addedAt } }); members are explicit stream ids, id globs (sports-*, the publisher-scope matcher), or selectorscategory:<slug>(a parent slug covers itsParent/Childrails) andsource:<name>, resolved against the live catalog at reconcile time — a newly tagged/imported/created channel joins the bouquet with no package edit; an explicit id may pre-date its stream and materializes when the stream is added. User records gain grant provenance (manualGrants[]+packages[]), withwrappedremaining the wire format:seal(manual ∪ resolved members). - The reconcile engine (
panel/src/packages.js) converges every user in one pass: lazily migrates pre-package records (grants adopted as manual, except channels owned by an auto-grant source — the source engine keeps re-sealing those, and adopting them would misattribute whole imported lineups), seals missing entitled keys, and removes wrapped entries no provenance covers — manual, any held package, or an auto-grant source (the carve-out that keeps source auto-grant behavior byte-identical; without it, a removal would just flap back on the next sync). Runs on package CRUD, user assignment,createUser(default packages, beside the untouched source auto-grant hook), stream add/retag/delete, category rename/merge, every source sync, and panel boot (the migration moment — idempotent, zero bee appends when converged). Revoking a single stream now removes the manual entitlement: if a held package still covers the id the reconcile re-seals it in the same request, and the CLI/API answer says so instead of pretending a revoke happened. A source with auto-grant OFF gets no carve-out, so asource:<name>member hands that source to package governance (only holders get its channels, following feed drift). - Surface:
/api/packagesCRUD (+ resolved-channel and holder counts, live resolution preview) andPOST /api/users/:u/packages; admin-cli parity (add-package/set-package/list-packages/show-package/remove-package/set-user-packages— store-needing, panel-stopped likesync-source); dashboard Packages tab (define, members chips, resolved preview dialog, holder counts, DEFAULT badge) and the Users-tab grants cell rebuilt into provenance chips — package chips (✕ unassigns the bouquet), manual chips, and dashed auto chips for source auto-grants — with whole packages offered in the "+ grant" dialog beside single streams. Zero wire/SDK/app change: clients receivewrappedat login exactly as before; nothing outside the panel reads the provenance fields. - Verified: new required-lane suite
test:packages(in-process panel + admin server + loopback feed server, deterministic, DHT-free) — 11 sections covering CRUD validation, selector resolution (incl. parent→child), sealed materialization (seal shape ≡ a manual grant's), provenance union, revoke-with-covering-package, package-removal-keeps-manual, reconcile triggers (add/retag/delete, pre-declared member), default packages beside the source auto-grant hook, package-governed auto-grant-OFF sources following feed drift with no flap-back, auto-grant coexistence (package ops never touch auto grants; flipping auto-grant off converges them away; re-enable restores), additive pre-package migration with correct attribution, and converged-reconcile bee-frugality (zero appends). The admin-api suite grew section R (endpoint surface over real HTTP beside a live-swarm viewer login), A–R green. Dashboard browser-verified against a seeded throwaway panel: Packages tab, provenance chips, live package grant via the dialog folding the covered channels into the new chip. Docs: user-management (the operator walkthrough), security-model (record fields + registry trust note), reference (CLI + API tables + dashboard tour), panel README;mkdocs build --strictgreen.
Reseller × channel packages — bouquets become the reseller's product unit (verified)
- The channel-packages follow-up, reseller-side only (
reseller/— nothing underpanel/changed): the reseller panel now consumes the panel's channel packages as the thing a reseller actually sells. Locked cost model: credits stay purely TIME-based (1 credit = 1 month, unchanged) — a package carries no credit price and there is no per-reseller restriction layer: any principal with account management lists every package and may assign any; a package defines WHAT the account gets, credits define FOR HOW LONG.extraGrants(per-stream one-offs) stay beside packages. - Surface: cached
GET /api/packagespassthrough (60 s, any authed role — the streams-picker pattern);activatetakespackages: [](validated like the grants array, recorded on the registry, assigned best-effort in the decoration pass — an explicit pick REPLACES the panel'sdefaultbouquets, while a package-less activation stays panel-driven so defaults stand);setPackagesop +POST /api/accounts/:acct/packages(mutex-wrapped, panel-first — a typo 404s and nothing commits; the registry records the panel-normalized list); the accountliveview addspackages+manualGrants; the reconcile sweep re-asserts a record's non-empty chosen bouquets when the live panel record drifts (packagesFixedin the report — empty/absent choice is deliberately exempt so the sweep can never strip defaults). Covered revokes are honest: the panel re-seals a package-covered channel in the same request, the reseller response saysstillGranted: true. - Dashboard: the Add-account form gains a package multi-select (live
resolved-channel counts,
defaultpackages pre-checked) beside the per-stream picker labeled "extra channels (one-offs)" (that picker is new too — the earlier UI never had one, only the API); the row menu gains Channels & packages… — the live entitlement with provenance chips (▣ package /one-off/ dashedauto, matching the panel's Users-tab chip language; auto rows offer no revoke since a source sync would re-seal them) — and Manage packages… (replace dialog, current list pre-checked). The covered-revoke toast reads "still granted via package …". Trials unchanged (panel defaults reach them at creation). - Verified:
test:reseller(required lane) grew sections N (passthrough shape, activate-with-packages replacing defaults, package-less keeping them, validation 400s, typo best-effort +setPackagesrepair, replace semantics both directions incl. un-materialization, live-view provenance, covered vs clean revoke, trials receiving defaults, three activations debiting exactly 3 credits — the time-only proof) and O (panel-side strip → reconcilepackagesFixedre-assert; panel-driven accounts exempt; lapse → sweep → renew with bouquets riding along untouched).test:packages,test:reseller-unit,test:themegreen. Dashboard browser-verified against an in-process panel + reseller (DOM/text reads): pickers with counts + default pre-check + filter, activation toast naming the package, provenance chips, honest covered-revoke toast, clean revoke, manage-replace re-materializing live, one-off add. Docs: reseller-panel (packages section + the time-only statement), reference (route table), CHANGELOG;mkdocs build --strictgreen.
Aliran MCP server (mcp/): AI-operable install, config, maintenance & support
- Why: Aliran is self-hostable, but self-hosting assumes server literacy the target operator often lacks. A new Model Context Protocol server lets any MCP-capable AI client (Claude Desktop, Claude Code) operate a deployment on the operator's behalf — install it on a fresh box, configure it, maintain it, answer usage questions — so a novice never needs a terminal. Feasible because every admin op already rides a clean authed HTTP API and the deploy path is a documented compose sequence: the MCP server is a thin, credentialed adapter, not new platform machinery.
- It is the server side of MCP — it exposes tools/resources to an AI client and
does not call the Claude API. New standalone workspace
mcp/(@aliran/mcp, pattern ofrepeater//reseller/); dependency@modelcontextprotocol/sdk(+zodfor the tool schemas), no@anthropic-ai/*. Transport is local stdio (node mcp/src/index.js --config <path>); added to rootworkspaces+mcp/test:mcpscripts, not wired into the docker-compose stack (it runs on the operator's machine). - Tools (
src/tools/*.js, registered on the SDK'sMcpServerwith MCP annotations —readOnlyHinton GETs,destructiveHinton purges/restarts):panel_*over the panel admin API:3210(users, grants, channel packages, streams, sources, categories, publishers, status/observability),broadcaster_*over the control API:3310(channels create/start/stop/rotate, logs ring, capability probe, incidents — the real route missing from that file's header comment, health),server_*an SSH executor (preflight,install= the operator-guide §A sequence,update= the §3B git-pull→build→up-d recipe, never--force-recreate;status/logs/disk/backup/sysctl), anddiagnose_*(a/healthzsweep + a symptom→KB router). - Reuse:
src/http-client.jsportsreseller/src/panel-client.jsin spirit — token cache persisted0600, single-flight login, re-login once on 401, 503→one jittered retry, 429→locked. One helper per Bearer API. Loopback admin APIs are reached over an explicit TLSurlor an SSH local-forward tunnel the MCP opens with the same key (src/ssh.js, default when nourlis set). - Secrets stay local. The panel/broadcaster admin passwords and the SSH key path
live only in the operator's
0600config; the model driving the server sees only tool results.server_installrunsadmin-cli initon the box and writes the mintedPUBLISHER_KEYstraight into the box'sbroadcaster/.env, returning only the panel public key — a secret never routes through the model.panel_add_publisherbehaves the same. The SSH key is handed tosshby path, never read into the process. - Resources: every
docs/+docs/kb/markdown file as anmcp://aliran/docs/…resource, adocs_searchtool, and a top-levelmcp://aliran/guide(tool catalog + install happy-path) — so usage answers come from the shipped docs, not model memory. - Verified: new required-lane suite
test:mcp(tools/e2e-mcp-test.mjs, deterministic, no DHT / no ffmpeg / no real sshd): boots an in-process panel admin server and a broadcaster control server (fake ChannelManager), launches the MCP over a stdio pipe and drives it as an MCP client —list_tools/list_resourcesshape (59 tools, 43 resources), a read tool, a write chain (add streams → create user → package → assign → assert the sealed grants in the signed DB), destructive/readOnly annotation presence, a docs resource read +docs_searchhit, the re-login-on-401 path (bump the admintokenVersionmid-test), and the SSH executor against a command-stub seam (a fakesshbinary viassh.sshBin) — asserting the publisher secret never appears in a tool result. Rantest:core,test:packages,test:admin-api,test:mcpgreen +node --check+mkdocs build --strict. - v1 scope: panel + broadcaster + install/maintain + docs. Reseller/library/repeater
deferred (reseller was mid-change at the time); local stdio only (no remote HTTP mode).
npm-publish for
npx @aliran/mcpis a follow-up. No VPS work this segment (a real install against a throwaway box is optional post-merge validation the user runs).
MCP onboarding follow-up: --doctor self-check + quickstart walkthrough
- Why: the MCP shipped with reference docs but no guided onboarding — the target operator (non-server-literate) needs "am I set up right?" answered by a tool, not by reading. Two pieces close that.
aliran-mcp --doctor(mcp/src/doctor.js, wired intoindex.js): a human-run terminal mode (prints to stdout — it never connects the MCP transport, so the stdout-is-protocol rule doesn't apply). Validates the config + its file mode, probes SSH first (tunnels depend on it), then each configured service's unauthenticated/healthz— via the expliciturlor by opening a real SSH local-forward tunnel and closing it after — so a debugging loop can never trip the 10/900 s login throttle;--loginopts into ONE real credential check per service. Reports the docs corpus, the enabled tool groups, a paste-readyclaude_desktop_config.jsonsnippet (absolute paths computed from the install location), and next-step prompts. Exit codes0/1/2(ok / probe failure / unusable config) for provisioning scripts. Never prints a password.docs/mcp-quickstart.md(+ mkdocs nav, cross-links from mcp.md and the package README): the hands-on walkthrough — prereqs, clone/install, config with a mermaid diagram of the two reachability roads, annotated doctor output + a failure-fix table, Claude Desktop wiring (per-OS config paths) + the Claude Code one-liner, five escalating first prompts with example transcripts and a tool-call sequence diagram, theserver_installconversation, a screenshot-capture checklist (six capture points + redaction rules: never capture config/.env; tool results are password-free by design; the panel public key is safe to show), and a troubleshooting table.- Verified:
test:mcpgrew section I — the doctor run as a subprocess against the live in-process servers + the fake ssh, WITH--login(exit 0, all markers, credentials accepted, no passwords in the output), a dead-port config ([FAIL] … unreachable, exit 1), and a missing config (exit 2). ⚠ The doctor subprocess must be spawned async in the test: the panel/broadcaster servers live in the test process, so aspawnSyncwould block the event loop and the doctor's healthz probes would hang until their abort timers — the exact deadlock the first draft hit.mkdocs build --strictgreen. - Client-agnostic wiring (same day): operators can't be forced onto one
vendor's app, so the doctor now prints paste-ready snippets for every major
MCP client — the
mcpServersJSON (Claude Desktop / Cursor / Windsurf / Cline / Gemini CLI), Codex CLI TOML (single-quoted literal strings so Windows backslash paths survive), VS Code agent-modemcp.json(servers+type: "stdio"), and theclaude mcp add/codex mcp addone-liners — plus an explicit warning thatdestructiveHintconfirmations are ADVISORY per the MCP spec (clients differ; the secrets guarantee is server-side and client-independent). Quickstart Step 4 became the per-client matrix with a "client differences that matter" section (hints advisory; resources support varies —docs_searchis deliberately a TOOL so docs answers work in tools-only clients; large-catalog note); mcp.md + the package README follow. test:mcp section I asserts the multi-client markers.
Desktop Electron shell 37 → 43 + dev-descriptor auto-login (verified)
- The scheduled hardening follow-up (the one
npm auditHIGH the dependency-audit pass left tracked — electron ≤39.8.4 renderer-process CVEs in the desktop build dep, never a shipped crypto path).desktop/now builds on Electron 43.2.0 (+ electron-builder 26.15.3), app version 0.2.0. Zero API changes needed: the shell's Electron surface (BrowserWindow + narrow IPC +safeStorage+ single-instance lock +shell.openExternal) carried across six majors unchanged, and the engine's N-API prebuilds load as before (ABI-stable by design).npm auditno longer reports electron at all. - Dev auto-login: a baked descriptor's
dev: { username, password }block — which already pre-filled the Login screen — now completes the sign-in by itself on first boot (EngineHost.devCreds()as theauto-loginfallback; saved safeStorage credentials still win; public builds bake no descriptor so the block cannot exist there). Documented in desktop-player.md with the never-ship-this warning. - Windows icon: a committed multi-size
assets/icon.iconow feedswin.icondirectly — electron-builder's WASM png→ico converter died withWebAssembly.Memory(): could not allocate memoryon the memory-tight build box; handing it a ready .ico removes that step from every future build (mac keeps deriving its .icns from the PNG on the roomier CI runner). - Verified: dev boot-smoke over CDP (the established pattern, pointed at the real
DHT): baked-descriptor run reached
ready+ 329 entitled streams + a mounted live screen against the production panel in ~7 s with no saved credentials on disk (proving the new dev-creds path); keyless run (descriptor moved aside) landed on the Connect screen. The packaged artifact repeated the full autologin smoke (baked resource → login → live screen in 9 s). Public NSIS + portable re-cut keyless and verified on the bytes (noconfig/resource, panel-key hex and password needles absent from both exes and the asar). tsc clean. Private keyed test builds live outside the repo.
Privacy-preserving analytics: aggregate only, server-side only (verified)
- The ROADMAP privacy-analytics item, built on the core insight that per-user watch tracking is architecturally impossible here: viewers replicate P2P and never report playback; the panel sees logins but no viewing, the broadcaster sees anonymous swarm links but no accounts, and nothing joins the two. So analytics = aggregating what the operator's own nodes already observe as a side effect of serving — zero client/SDK changes, zero new wire messages, no presence beacon (deliberately parked as a separate decision).
- The invariant (designed in, then negative-tested): identity-carrying
inputs (usernames, hex keys, IPs, device ids) exist only in ephemeral
in-memory structures inside the current hour/day; they reduce to COUNTS at
rollup and the raw value never reaches any analytics file,
/api/analyticsresponse, or/metricsline. The panel's failed-login counter takes no argument at all — an attacker-controlled username from a failed attempt never enters any structure; unique viewers/day is a day-scoped in-memorySetreduced to.sizeand discarded. - Per-service
src/analytics.js(the control-auth.js self-contained-copy convention — no @aliran/core bump): in-memory hour bucket → per-day rollup JSON underDATA_DIR/analytics/(UTC, atomic tmp+rename, boot reloads today's file; the in-progress hour is deliberately lost on ANY exit — no partial-hour merge logic to get wrong). One knobANALYTICS_RETENTION_DAYS(default 90, fail-fast validated; 0 = off entirely: no files, no timers, honest{enabled:false}endpoints). Rollups never touch the replicated bee. - Panel: ok/failed counted at the session RPC — the only honest signal,
since the OPRF stage is oblivious (a wrong password fails on the client);
apps-online gauge = swarm connections on a 5-min tick (labeled approximate —
repeaters and broadcaster links count too); catalog composition on a 30-min
scan cached for the sync-cheap
/metricspath. New Analytics dashboard tab (hand-rolled inline-SVG bars, no chart dependency, shared theme tokens untouched — test:theme green),GET /api/analytics?days=N, andaliran_panel_logins_{ok,failed}_total/sessions_issued_total/catalog_channels{class}metrics. - Broadcaster: per-channel peer min/mean/max + egress bytes + respawns +
incidents per hour. Egress uses the UDX per-connection byte counters via a
makeEgressMeter()hooked into each channel's swarm: bytes ACCUMULATE when a connection closes and live connections are added at sample time — otherwise closed connections' bytes silently vanish (the trap the brief called out). Deltas are reset-aware (a shrinking cumulative = channel restart). Control APIGET /api/analytics,/metricsper-channelchannel_peers{stream_id}+channel_egress_bytes_total{stream_id}(read from the last in-memory sample, keeping scrapes sync-cheap at any fleet size), and a 24 h peak-peers + egress column on the ops dashboard table. - Repeater:
/metrics-only —served_bytes_total{stream_id,core}from hypercoreuploadevents, besideheld_blocks/core_peers. NO rollup files (zero-state keyless cache box; the opt-inSTATUS_PORTdefault-off / zero-listening-sockets property is unchanged). Reseller: out of scope (its ledger is the business analytics). - The honesty rule on every surface +
docs/analytics.md: origin-side peer counts are a LOWER BOUND on audience (viewers serve each other) — labeled "≥ N", never presented as a viewer count (the no-invented-metrics rule). - Verified: new required-lane
test:analytics— rollup/rollover/ boot-reload/prune math on a fake clock; the retention=0 kill switch (no dir ever created); REAL viewer logins over a loopback SecretStream pair throughattachLoginRpc(ok + unknown-user + bad-signature outcomes counted, ghost usernames excluded from uniques); the broadcaster egress meter, sampler over a fake ChannelManager, reset-aware deltas and incident folding driven through a real control server; repeater metrics rendering; and the negative identity scan — 9 surfaces (every rollup file + both API responses + all three/metricsbodies) scanned for needle usernames, auth/noise keys, device ids, IPs and any 64-hex run: ZERO hits.test:configgrew the ANALYTICS_RETENTION_DAYS probes. Full required battery +mkdocs --strictgreen locally. NO VPS work — deploy rides a later user-decided image rebuild (a broadcaster rebuild disturbs the running scale test; a panel-only precedent exists).
MCP channel tools — typed input/transcode: the end of the silent source swap (verified)
- Why: repointing four production channels through
broadcaster_update_channelset no source at all.input/transcodewere declaredz.any(), which serializes to an empty JSON Schema{}— a client with no type information for a parameter passes objects through as JSON strings. So{kind:"pull",url}arrived at the broadcaster as a string, failedPULL_SCHEME_RE, and fell intonormalizeInput's catch-all, which stores any other string as{kind:'file', path}: the entire JSON blob became a file path. HTTP 200, normal-looking body,sourceCount: 0,activeSource: null— nothing surfaced it but re-reading the returnedinput. The bare-URL string shorthand was a workaround, but it covers only pull urls;fallbacksandtranscodehad no string form at all. - Typed schemas (
mcp/src/tools/broadcaster.js):inputis now a union of the shorthand string and a discriminated union onkind—test/file{path}/pull{url,fallbacks?}/rtmp{port?,streamKey?}/srt{port?,latencyMs?, passphrase?}/udp{port?,timeoutMs?}— andtranscodea strict object over theENCODERS/RESOLUTIONS/FPS_VALUES/PRESETSsets (ornullto clear). Clients now receive the real shapes instead of{}, which is the actual root-cause fix. The shapes MIRRORbroadcaster/src/channel.jsrather than importing it: the MCP package ships standalone against a possibly remote broadcaster on the SDK + zod only, and the broadcaster stays the authority. - Defense in depth: a
{-leading string is re-parsed as JSON before it is forwarded (that is exactly the shape the failing client sent), and anything that does not parse — or parses to the wrong shape — is a loud validation error naming the offending field. Independently,normalizeInputnow refuses a brace-leading string instead of taking it for a path: abad()ControlError, so the control API answers 400. A file path is never a JSON object, and a brace inside a path (/media/{a}/x.mp4) still resolves as a path. - Verified:
test:mcpgrew section J — driven as an MCP client against a fake ChannelManager that now runs the REALnormalizeInput/normalizeTranscode(so the mirrored schemas cannot drift): the publishedinputschema is non-empty and carries all sixkindbranches; object-valued input round-trips tokind:"pull"withfallbacksintact on both add and update; a stringified object is rescued to the same result; and six malformed forms (no url, truncated JSON, unknown kind, misspelled field, bad scheme, string transcode) are each rejected with the channel's stored source left untouched.test:argscovers the broadcaster-side refusal. Both new blocks were confirmed to FAIL against the pre-fix sources. Full required lane (15 suites) green.
MCP ops completeness: env tuning, restore, analytics, admins (verified)
- Why: the MCP gap analysis (a source-level inventory of all 59 tools vs every service's admin surface) found four P0 lifecycle blockers that still forced an operator out of the MCP: no way to set an env knob or restart to apply anything (three shipped flows literally end "restart to apply" with no tool able to), backup without restore, the analytics endpoints unwrapped, and admin-account management missing entirely. This lands all four — 59 → 73 tools.
server_set_env— validate before you restart. Every service config is fail-fast at boot by design, which is exactly wrong for a remote agent: a typo'd knob + restart = service down. So all four service configs (panel/broadcaster/library/reseller) gained anode src/config.js --checkdry-run mode — direct-run detection, problem list on stdout, exit 0/1, the normal import path untouched — and the tool runs it in the built image (docker compose run --rm <svc> …, which reads the freshly-written env_file) BEFORE any restart. Validation failure ⇒ the.envsnapshot is reverted and the exact problem text is the tool error; success ⇒ applied via plaindocker compose up -d <service>. The subtle compose fact that shaped the design:docker compose restartdoes not re-read env files — only anup -drecreate does.server_restart(theserver_sysctlfollow-up) states that limitation in its own description. Keys are allowlisted per service (names only — the values are validated by the real config, so the lists can't drift into wrongness, only into incompleteness, which fails safe); secrets (PUBLISHER_KEY,PANEL_PUBKEY,WEBHOOK_SECRET, …) are refused with a pointer at their dedicated server-side flows, and values are single-line-only — an embedded newline could otherwise smuggle aPUBLISHER_KEY=line past the allowlist into the env file.deploy/restore.sh+server_restore/server_list_backups. backup.sh finally has a counterpart: verify the tarball (before anything stops) → stop → replace the volume contents → start, refusing a non-empty volume or an archive whose name doesn't match the service without--force(and with--forceit clears first — an untar-over-merge of two corestore generations is itself a corruption). The done-line states files restored, files replaced and the archive name, and the tool relays it verbatim — a destructive tool's result must say exactly what it destroyed. Also fixed in passing:backup.shwas committed non-executable, soserver_backup's./deploy/backup.shfailed on a fresh clone — both scripts are755now and invoked viash.- Analytics + admins.
panel_analytics/broadcaster_analytics {days?}are plain GET passthroughs of the aggregate-only analytics endpoints (counts and "≥ N" lower bounds; no new identity surface — the aggregate-only invariant holds by construction). Admin CRUD (list/add/remove/set-password) landed twice — panel admin API and broadcaster control API — with thepanel_create_usergenerated-password pattern, and the honest caveat that rotating the account the MCP itself logs in with requires updating the operator's localmcp/config.json(the tools re-login with the configured password). - Verified:
test:mcpsections K–P. The ssh command stub grew a persisted fake-box.envstore and — the part that matters — itsdocker compose run … --checkbranch spawns the real service config.js locally, so the revert-path test asserts the genuinePOW_DIFFICULTY must be an integer (got "notanumber")text arriving through the full MCP → ssh → image-check → revert chain, and that the.envis byte-identical afterwards. Admin passwords are live-verified (the generated password logs in over/api/login; the old one 401s after rotation; a removed admin can't log in). The whole-run command log is swept for--force-recreate(zero occurrences, the standing §3B rule).test:configgained--checkprobes for all four services (clean = exit 0 "configuration OK"; bad = exit 1 naming the knob, no stack trace). Full required lane green; docs (mcp.md, reference.md catalog, configuration.md, backup-and-rotation.md, quickstart, mcp/README) updated;mkdocs --strictgreen. NO VPS work — post-merge live validation is the user's call (the established precedent).
MCP content + business coverage: categories, curation, art, reseller, library (verified)
- Why: the MCP gap analysis' P1 tier — the content-curation jobs (categories,
source deselection, channel art) still needed the dashboard, and two whole
services (the reseller panel, the VOD library) had zero MCP coverage despite
their box-level plumbing (
server_logs/server_set_env/server_backup/server_restore) already accepting their names. This closes G5–G9: 73 → 101 tools, plus two optional config blocks. - Categories (G5) — and the selector honesty that fell out. Four tools over
the catmeta routes:
panel_set_categoryowns PRESENTATION (label/order/hidden; slugs ride in the request body because two-level rails contain/),panel_rename_category/panel_merge_categoriesrewrite the tag across every catalog record (children carried on a parent rename; merge is annotated destructive — reversing needs the old membership),panel_delete_categorydrops only the registry entry and the listing keeps showing the slug as unregistered while channels carry it. The subtle bit the test pins down: a packagecategory:member is a string resolved at reconcile time — renaming the category strips the bouquet's holders (the reconcile runs inside the rename request) until the member is edited to the new slug. The tool description says exactly that, and section Q walks the full honest loop: rename → holder loses the grants →panel_set_packageto the new slug → grants re-materialize; merge → the retagged channel auto-joins the bouquet. - Source curation (G6).
panel_source_channelswraps the channels-dialog route (imported entries from the catalog + excluded entries from the registry, with captured labels), andpanel_set_sourcegained theexcludefield the PATCH always accepted ({id,title} objects or bare id strings — REPLACES the list). The exclusion-ETag fact rides along: an exclusion CHANGE nulls the source ETag so the next sync re-pulls the full body; the test seeds a fake ETag in the registry file and asserts it survives a same-ids write but dies on a real change — no gratuitous refetch, no masked exclusion. - Stream art (G7) — bytes never transit the model.
panel_set_stream_art {id, kind: logo|poster|backdrop, path}reads the image from the OPERATOR's machine (where the MCP server runs — the description says so, twice, because "path" is ambiguous in an agent context) and POSTs the raw bytes with the content-type derived from the extension; the panel derives the stored extension from that content-type, so a.jpgupload lands asposter.jpg. Client-side before any bytes move: extension whitelist, empty/missing checks, and the 10 MiB cap (mirroring the panel's body limit). The http-client grewpostRaw(same 401-relogin discipline) anddellearned to carry a JSON body (the category DELETE rides the body). Section S uploads a real 1×1 PNG, asserts the assets drive holds byte-identical content and the catalog record gained the ref, and asserts the tool RESULT is a small ref echo — no base64, ever. - Reseller oversight (G8) — the operator's jobs, not the resellers'. New
optional
reseller {url?, user, pass}block (tunnel:3330when no url; the login should be the ROOT admin principal) and 14reseller_*tools: status + system diagnostics (live panel probe), principals list/get/add (generated passwords returned, roles co-admin/super/reseller —adminis the seeded root and not creatable), set password / limits / status (suspend bumps tokenVersion;mode:"with-accounts"bulk-disables the customer base — destructive-annotated),reseller_grant_creditsechoing the ledger line it appended (seq/actor/principal/amount/new balance — the operator-honesty precedent), ledger query (real type enum: MINT/TRANSFER/ACTIVATE/RENEW/REFUND/ RECLAIM/TRIAL/ADJUST), accounts list/get (registry + LIVE panel state), the trials view, and sweeps/ops status. Activate/renew/extend are deliberately ABSENT (asserted absent, even): daily driving belongs to resellers in their own panel, under their own audit trail. - VOD library (G9). Optional
library {url?, user, pass}block (tunnel:3320) and 8library_*tools: status, titles list/get (live ingest phase/pct), add (one-shot ingest;inputis a path ON THE LIBRARY BOX or a URL its ffmpeg can read), operational patch (input/mode/hlsTime only — descriptive metadata is panel-owned after creation and the description points atpanel_set_stream_meta), re-ingest (next feed generation; annotated destructive likebroadcaster_rotate_channel— the title is briefly unavailable), the ffmpeg log ring, and delete — which purges the library box but only marks the panel recordunavailable, so the tool result appends exactly that disposition and namespanel_delete_streamas the panel-side purge. Library admins CRUD stays deferred per the gap doc. - Wiring.
clientForgained the two services; both joindiagnose_healthzand the--doctorprobe loop + tool-groups line (the quickstart's sample transcript shows the new not-configured skip lines); the serverinstructionsstring and themcp://aliran/guideresource describe both groups incl. the daily-driving boundary;config.example.jsondocuments the optional blocks without enabling them (a copied example must not fail doctor probes on services that aren't running). - Verified:
test:mcpsections Q–V over an in-process REAL reseller service pointed at the test's REAL panel (the e2e-reseller harness pattern — root principal seeded, its own dedicated panel admin so section F's tokenVersion bump can't race it) and a library control server over a fake TitleManager that mirrors the real routes' refusal semantics (mid-ingest delete/reingest refusals, the panel-owned-metadata patch gate). Highlights: the minted credit line is looked up in the ledger itself and matched against the tool's echo; principal + rotated passwords are live-verified over/api/login(old dead, new works); the category rename walks the selector-coupling loop end to end; the diagnose sweep reports all four services. Tool floor 95 (actual 101), annotation lists extended, doctor markers updated (groups line + reseller/library healthz + credentials). Full required lane +mkdocs --strictgreen locally. NO VPS work (no reseller or library instance runs on the box; the established precedent).
MCP scale + DX: multi-host SSH, repeater reach, ergonomics, prompts, publish prep (verified)
- Why: the MCP gap analysis' P2/P3 tail — the last tier between "the MCP
covers the single-box lifecycle" and "the MCP covers the deployment". One host
meant repeater appliances and future scale-out boxes were unreachable (and
panel_add_publisher's env-write could only ever land on THE box); 350-channel catalogs flooded agent context on every list/user call; a handful of documented API fields had no schema; multi-step procedures lived only in docs; and the package could not be published without losing its docs resources. Closes G10–G17: 101 → 102 tools (repeater_status) + 6 prompts, and the operator-coverage arc is complete. - Multi-host SSH (G10).
ssh.hostsoptionally names extra boxes ({name: {host, user, keyPath?, port?, repoDir?, sshBin?}}+default); the single-host shape normalizes unchanged, and the resolved default is hoisted so every existing consumer keeps readingssh.hostas before. Everyserver_*tool takeshost:"<name>"(resolved per call — an unknown name is a loud error listing the configured names BEFORE anything touches a box);panel_add_publisher {host}resolves the target before enrolling (an unknown host cannot strand a freshly-minted secret) and writesPUBLISHER_NAME/PUBLISHER_KEYinto the NAMED box'sbroadcaster/.env.server_installdeliberately stays default-box-only: it installs the full panel+broadcaster stack — a broadcaster-only scale-out install is a planned follow-up, and a repeater install is a three-command by-hand recipe the docs (and therepeater_statusdescription) point at. The doctor probes each named host with one cheap echo. repeater_status {host?}— status for a box that has no API on purpose. A stock repeater opens ZERO listening sockets (part of its co-tenancy story), so status is SSH-shaped:docker compose -f deploy/docker-compose.repeater.yml ps+ a logs tail, thenSTATUS_PORTis read from the box'srepeater/.env— set, the tool curls127.0.0.1:<port>/metricsON the box loopback and surfaces the text (served-bytes counters included); unset, the result says "not enabled … zero listening sockets by design" honestly instead of erroring; set-but-dead gets its own distinct message.- List ergonomics (G11) — one mechanism, everywhere.
panel_list_streamsgrew client-sidecategory(parent matches itsParent/Childchildren) /prefix/idsOnly/limit; with no arguments the raw catalog returns byte-identical, with any filter the shape is{total, matched, returned, …}so nothing is silently dropped. Every user-shaped result (create/password/ status/max-devices/logout/grant/packages/revoke/get/list + the reseller account view's live block) runs through ONEcompactUserhelper: grant lists longer than 12 ids collapse to{count, sample: first 8}+ a note namingfull:true; short lists pass through untouched so small deployments never see the summary shape.panel_revoke_grantderivesstillGrantedBEFORE compaction, so the covered-revoke honesty survives the summary. - Schema gaps (G12).
broadcaster_add/update_channelgainedhlsTime(1-30) /hlsListSize(2-60) — bounds mirrorchannel.js normalizeMeta, the fake manager mirrors them for the round-trip, and the test text-matches the real source's bounds strings so a bounds change breaks the build until the mirror is updated (incl. the real quirk that patching one field re-derives the other from the env default).panel_add_streamgainedfeedKey+key(64-hex validated client-side) for the pre-seeded feed flows in the operator KB; a suppliedkeyis a secret input — it flows TO the panel (secrets/streams.json, asserted) and is REDACTED from the result with a note saying why, while an omitted key keeps the generated-password pattern (returned ONCE — there is no read-back API).panel_set_stream_metagainedfeedKey(the paired blobsKey reset is asserted). - Prompts as runbooks (G13). Six
registerPromptentries sourced from the shipped docs:new-site-install,onboard-a-reseller(incl. the daily-driving boundary),migrate-a-channel-source(both worlds: remote-source add→curate→sync→verify, and broadcaster-pull update→STOP→START→verify — the stop/start honesty is asserted),monthly-maintenance(dry-run first, backup before update),incident-triage(optionalsymptomargument interpolated),expose-dashboards. The test's drift guard extracts every tool-shaped token from every prompt body and requires it to be a registered tool — a renamed tool cannot silently strand a runbook. - G15 decision — TLS stays docs-first.
expose-dashboardswalks kb/public-dashboards.md (Caddy) and ends by repointing the MCP config at the https urls; aserver_setup_tlstool was deliberately NOT built (DNS + certificates are out-of-band; a Caddy-install orchestrator is a footgun with none of set_env's validate-revert safety net). server_update {dryRun:true}(G16).git fetch+log HEAD..'@{u}'+diff --stat— reports exactly the commits + files that WOULD deploy and states that nothing was pulled, built or restarted (the test sweeps the command log: zero build/up after a dry run). The real run keepsdestructiveHint.- npm-publish prep (G14).
@aliran/mcp0.1.0 is publish-ready:fileswhitelist (src / docs-bundle / config.example.json / README),prepackrunsscripts/bundle-docs.mjs(copies the repo docs corpus intodocs-bundle/, gitignored), anddefaultDocsDirchains repo checkout → bundled copy → the old degrade-to-empty. Publishing itself stays the user's release step (the established precedent). - Verified:
test:mcpsections W–AC. W drives a SECOND fake box through the SAME ssh stub (the seam grew--state/--logargv + cwd capture instead of being forked): per-host command logs + env stores are asserted disjoint,set_env {host:"edge"}runs the real--checkagainst the edge state, and the enrolled publisher key lands in/opt/edge/broadcaster/.envwhile the result carries no secret. X covers all three repeater status-server states. Y proves filters + compaction +full:truerecovery + small-user back-compat. Z round-trips hls bounds and the feedKey/key flow (supplied secret in the panel's secrets file AND absent from the result). AA lists/gets all six prompts and runs the drift guard. AB sweeps the log for zero build/up afterdryRun. AC runs the bundler, asserts thenpm pack --dry-runfile list (docs-bundle in,config.json+scripts/out), unpacks the real tarball and runs--doctorfrom it — docs resolve fromdocs-bundle/(45 docs) with exit 0. Doctor markers updated in lockstep (named-host line,repeater_*group, prompts line). Full required lane +mkdocs --strictgreen locally. NO VPS work; multi-box LIVE validation rides the scale-out milestone.
Viewer problem reports: pseudonymous P2P ingest, alerts, ops push (verified)
- Why: viewers had no way to tell the operator "no audio on channel X" — the only feedback path was out-of-band chat. This entry builds the whole loop: a report travels the EXISTING P2P RPC socket → pseudonymous storage → correlation → one alert → optional ops push → dashboard/CLI/MCP triage. Built as four loop segments (ingest → notifier + admin → client path → MCP + docs) + the user-gated deploy.
- Ingest core. A
reportresponder besidelogin/sessionon the socket every client already holds — no new port, and no wire change for older clients (the method simply does not exist on an older panel). Reports are pseudonymous by construction: the panel verifies the session token, then immediately reduces the identity toHMAC-SHA256(salt, userId|deviceId)sliced to 16 hex — no username and no device id is ever stored, counted, returned or logged. Records live inDATA_DIR/reports/(atomic writes, 5000-record cap, retention prune) — never in the Hyperbee, which replicates to every viewer. Four layers of flood control keep a real outage cheap: a per-reporter throttle, per-channel storm collapse (once an alert is open only a bounded sample of full records is stored), a panel-wide token-bucket breaker, and correlation that opens one alert per channel per window and extends it rather than re-firing.REPORTS_RETENTION_DAYS=0is a complete kill switch. - Notifier + admin surface. An opened alert pushes once: a
generic webhook whose body suits ntfy, Slack and Discord at the same time
(
REPORTS_WEBHOOK_URL) and/or a Telegram bot; both unset is a no-op. Delivery is fail-dark — queued and never awaited, one attempt at a time, dropped after three tries over ~30 s — so a dead endpoint can never slow ingest down. Triage: a dashboard Reports tab (alert strip, filters, grouped expandable list, per-hour chart, ack/resolve — every interpolation escaped, report text is hostile),GET/POST /api/reports…+/api/alerts…, and five admin-cli verbs that work beside a running panel. - Client path.
AliranPlayer.report({category, text})attaches what the engine already knows — the active channel, peer count, app version/platform and a rolling 50-entry ring of the engine's own breadcrumbs (fed from ONEemit()override, not hand-placed calls) — and proves entitlement with the retained session token, never a username. It never throws: a local 10-minute cooldown per channel+category keeps button-mashing off the wire, and a panel without the responder maps to a friendly "this service doesn't accept reports". Both shells got a Settings modal (vertical focusable category list — a TV remote files a report in four presses) and a consent line naming exactly what is sent. Rider: both shells now mint a per-install 8-byte deviceId at first run and pass it at login (until now every install collapsed onto one derived fallback id) — one-time device churn on upgrade, and the id never reaches the UI layer; the panel folds it into the pseudonym. The category enum is duplicated per runtime that cannot import another's copy, with an e2e drift guard holding all copies deep-equal. - MCP + docs. 102 → 107 tools:
panel_list_reports(filters +sinceHours; long breadcrumb rings compact to{count, sample}withfull:truerestoring),panel_list_alerts,panel_ack_report,panel_resolve_report {note},panel_test_notify. Alert ack/resolve stays deliberately unwrapped — a running panel holds alerts in memory, an out-of-process write would be lost.server_set_envgained theREPORTS_*tunables and refusesREPORTS_WEBHOOK_URLandREPORTS_TELEGRAM_BOT_TOKEN: ntfy/Slack/Discord endpoints carry their credential in the URL path, so a notification endpoint is a secret and belongs inpanel/.envon the box. New docs/reports.md: the ten knobs, push recipes, the alert rules, and an honest pseudonymity section — pseudonymous at rest is not anonymous to the operator; do not tell your audience otherwise. - Verified:
test:reportsin the required lane, lanes A–L (crash-fuzz over the responder, throttle, dedupe, storm drill, one webhook POST per alert, dead-endpoint fail-dark, sdk path, shell IPC, and a negative identity scan over 25 surfaces) +test:mcpsection AD. Deployed 2026-07-26 (box →dbf2247): live drill filed a desktop-UI report (CDP-driven dev build against prod) plus a second distinct headless-sdk reporter → one channel alert at a temporarily lowered threshold — set throughserver_set_env's validate-then-apply path, its first live exercise — then ack/resolve through the new MCP tools, 16-hex pseudonyms everywhere, knob restored. Push wiring left OFF by user decision (the shipped default); the phone leg rode the next APK and closed with the RPC re-arm fix below.
In-player reporting: the Report button moves onto the player (verified)
- Why: the user drove the reports Settings flow on the S22 and re-scoped it: a report should be filed from the player, about the channel being watched, and free text isn't wanted. Two user decisions captured — the Settings entry is REMOVED entirely, and the flow is select-a-symptom → Send.
- What: a ⚑ Report button on the now-playing bar (phone + desktop, plus
the
rkey on desktop) and a third ActionButton on the PLAYING channel's info panel (the TV/D-pad path — offered only for the channel being watched, since the engine attaches the active channel; a browsed channel would report the wrong one). No free-text input anywhere — the wire still acceptstextfrom SDK hosts, the shipped apps sendtext: null— and thelogincategory is derived OUT client-side (a filter, so the enum drift guard stays meaningful; reaching a channel proves login worked). Consent line updated in all three copies ("anything you type" dropped). NewReportSheet(RN) +ReportModal(desktop); login problems are no longer reportable from the shipped apps, documented honestly in docs/reports.md. - Verified: client jest 61/61 incl. the new ReportSheet suite, both tsc,
full
test:reportswith the drift guards green; desktop live drill via CDP against prod (bar button,rkey, record carriestext:null+ the watched channel, resolved after); a private arm64 APK on the S22 the same session. Zero panel/broadcaster runtime diff — the box move to028f69ewas a consistency rebuild only.
Fix: the panel RPC re-arm is validated — the first real phone report found the wedge (verified)
- Why: the user's first real phone report failed "not connected to the
service" while video played fine — and could never recover. The recent
panel redeploys dropped every client's RPC socket, and the SDK re-armed the
RPC on the next swarm connection to arrive — mid-session that is often a
broadcaster feed peer, because hyperswarm keeps one socket per peer across
every topic. Once
_callpointed at the wrong peer, every login, session check and report answered 'offline' forever: the genuine panel reconnect is skipped once the slot is held. A pre-existing gap from the MCP era (the "first connection is the panel" boot heuristic applied mid-session); reports made it visible. Session-refresh and revocation checks rode the same wedge silently. - Fix (
sdk/player.js):_maybeArmRpc()— a candidate socket must answerhellowithin a bounded probe (8 s) before it may hold the RPC slot; the validated panel identity is remembered for instant re-arms; probes run CONCURRENTLY so a hung candidate can never starve the real panel connection; andreport()on a dead slot kicks the panel topic's discovery and waits a bounded moment for the re-arm instead of failing instantly. The lesson, pinned for every future client change: in a multi-topic swarm, identify a peer by what it ANSWERS (or its key) — never by arrival order. - Verified: new
test:reportslane M (an impostor never captures or starves the slot; a report lands after re-arm; a drop clears the slot), sdk unit 9/9, a live-DHT hello probe through the new arm path, and the fixed APK on the S22 — where the user's live phone report landed (distinct pseudonym, channel attached,text:null), closing the reports phone leg for real: desktop UI + headless sdk + physical phone, all end-to-end. Client-only diff: the box checkout fast-forwarded with no rebuild.
External VOD: a third-party movies/series catalog the apps call directly (verified)
- Why: an operator who already has an account with an external VOD provider should be able to light up a Movies & Series section without the panel proxying a single byte. Built as panel switch → RN client → desktop twin → the user-gated dev-build validation.
- The panel-owned switch. One replicated record (
svcmeta/vod) holds the enable bit plus the coordinates (apiBase,service, per-kind source values, extra params).apiBasemust be https with no query string and no embedded credentials, andenabled:trueis refused while the coordinates are blank — the section can never appear pointing nowhere. Manageable from the dashboard (a card on the Sources tab),GET/PATCH /api/vod-config, the CLI, and the MCP (panel_vod_config/panel_set_vod_config— 107 → 109 tools). The login payload carries avodfield only while enabled — absent means "no VOD section", no version check — and a change lands at each viewer's next login. The panel stores no viewer credential for the provider and never proxies its calls or its media. - The apps. A menu tile gated by the panel-delivered payload
(a brand can still switch it off with
sections.vod:false), a searchable poster-grid landing (Movies/Series switch; Series shows an honest empty state until a series source exists), and a dedicated player — react-native-video on the phone, hls.js/native<video>on desktop; the live P2P engine is never involved. The app calls the provider directly with the viewer's own account: username = app username, token = app password (the operator provisions matching accounts on both sides); a gitignoredvod.devblock overrides the pair for dev builds, and the example file's placeholder words are recognized and ignored. Everything is HTTPS-only: a cleartextapiBaseis refused before dialing, and the{token}substitution is refused on anything but https. Desktop fetches in the MAIN process (the file:// renderer would trip CORS, and saved creds live in main); a new required-lanetest:desktop-vodruns the same fixtures as the RN jest suite, so the two provider-client copies must change together. - Verified live. The real
getMovieInfoshape was captured against the live provider — a flat object with the stream URL inpath/path_1080/path_720, each embedding a literal{token}placeholder, and the runtime as"hh:mm:ss";extractMovieInfowas re-cut to try the path family first and a newfillToken()(https-only substitution) landed in BOTH copies, fixtures swapped to the real shape. Desktop CDP drive against a local dev panel: tile → grid (7 228 titles) → search → detail → hls.js playback with video frames verified. Phone: the dev arm64 APK on the S22 verified tile/grid/posters/playback by screencap — after a found-and-fixed stale gitignored worklet bundle (the bakedapp.bundle.jspredated the VOD switch; regenerate withnpm run bundle-backendwhenever sdk/ or client/backend/ changed, and byte-verify the blob inside the APK). The production go/no-go finding is pinned for the enablement decision: the provider's playable URLs embed the account token as?token=…— in production that is the viewer's app password — so flipping it on for real viewers is a separate, deliberate step.
Movies & Series: the mockup browse experience, series playback, device-local lists (verified)
- Why: external VOD shipped a functional but minimal landing (one searchable grid); the user supplied a five-screen reference mockup set and chose full scope. This entry rebuilds the section in BOTH apps to that pattern and lights up the series half of the provider catalog. Built as four loop segments (plumbing → RN browse → RN series/lists/player → desktop twin) + this user-gated validation pass.
- One download, three lists. The panel's provider config grew
sources.series(CLI--series-source, APIsourcesmap, dashboard field —VOD_SOURCE_KINDSis now['movies','series']). The provider answers movies, series AND the genre names from the SAMEgetMovies.phpwrapper, so the cache entry reshaped to{at, movies, series, categories}and one fetch feeds everything.pickSeriesListreads only the wrapper'sserieskey (no first-array fallback), series years fall back toaired_firstwhenaniois "0", andgetSeriesInfostrips the seasons/episodes shape with episode URLs token-filled under the same HTTPS-only rule as movies (url:''= a notice, never a dial). Verified live this pass: fetching withsource=<the series value>returns the identical full wrapper — so a series-only config works on the same single download; the finding is pinned as a comment in both provider copies. - The browse structure (both apps). Left menu Movies / Series / Search
(search is its own view — the always-visible inline input is gone), tabs
Recommended · My List · Genres · All, an always-visible "Sort by" chip over the
five-option mockup sort set (Recently added default / A-Z / Newest releases /
Oldest releases / Recently watched — no Z-A), a scroll-synced A–Z jump rail
rendered only in the alphabetical sort, genre cards from the provider's own
category strings (an item's numeric ids index them), Recommended = one-row
Recently-added / Newest-releases rails with "SEE N MORE…" jumping to All in
that ordering, and the tile restyle (framed poster + fixed 2-line label with
"(year)" inline; the overlay badge deleted). The two sort modules are
line-identical mirrors — a comment-stripped source-identity lane in
test:desktop-vodfails the build if they drift. - Series detail + resume + tracks (both apps). Series grid → detail (poster,
★ rating from
rating/2, date range, genres, expandable plot, season tiles with episode-count badges) → episode list (duration chips via the sharedparseDuration) → playback. Watch history writes from the players every 10 s (flush on back/unmount; >95 % watched stores position 0 = "watched"), resume seeks once on load, and a series' Start continues at the newest episode entry for that series. Both VOD players reuse the live players' track machinery (RN rn-video tracks + TrackMenu, desktop hls.js audio/subtitle events + TrackMenu + CC/c). - My List and watch history are device-local (D9) — the RN worklet and the
desktop main process own the two capped, shape-gated prefs arrays
(
vodList500 /vodHistory200, whole-array replace, hostile-input rule); nothing is ever sent to the panel or the provider, and the docs now say so in plain words (white-label privacy section + both viewer guides). - Verified: full unit ladder at the tip (client jest 20 suites/167
tests + tsc,
test:desktop-vod, desktop tsc + build,test:admin-api— its lane S proves a REAL login carries the series source verbatim —test:mcp,test:config,mkdocs --strict). Dev-panel drill: series source PATCHed into a running panel, then an SDK login over the public DHT readvod.sources.seriesback. Desktop CDP drive against the live provider (7 229 movies / 197 series): tabs, genre cards (38, name + representative poster), shelves + SEE-MORE sort hand-off, search view, all five sorts, A–Z rail with exact letter jumps past the 120-tile page boundary (first target tile lands at offset 0) + two-way scroll highlight, series detail → episode playback (duration chip 51:00 = player duration 3 060 s), "Resume at" hints, Start resuming mid-episode, My List add/remove from tile and detail, real audio/subtitle switching (spa/eng/ara) viac, and everything — list and history — surviving an app restart. Phone: worklet bundle regenerated and byte-verified (vod-list-setpresent in the DECODED blob — the stale-bundle lesson applied), arm64 APK built from the main checkout with the baked descriptor and the byte-identical fresh blob verified INSIDE the APK, then installed and screencap-smoked on the dev phone (browse UI, series detail, episode playback, subtitle switch, resume hint, My List — all live). - First-device-pass polish (same day): the phone pass found the
Recommended tab clipped dead (the shelf stack neither scrolled vertically
nor horizontally) — it is now a vertical scroll of true horizontal
carousels, each capped at 50 titles with the "SEE N MORE…" chip
right-aligned in the shelf head (both apps; the desktop shelf row scrolls
too). The VOD players' tracks button dropped the misleading "CC" for a
more-options glyph (⋮), and the track menus now spell languages out in full
from the track's language code ("Spanish", never "spa t2") via a mirrored
lang.tshelper —Intl.DisplayNameswhere the engine has it, a static map where Hermes doesn't; on desktop the sharedtrackList()gives the live player the full names as well. Wire values are untouched — selection still travels by the track's own code. - QA round 2 (same day, from real-device testing against production):
(1) the VOD players' transport never auto-hid — both shells now port the
live player's fade (5 s idle, tap/mouse-move reveal, paused pins the bar,
TV stays always-on); (2) a centered skip cluster (⟲30 ⟲10 ▶ ⟳10 ⟳30)
landed under the progress bar in both VOD players — the RN player gained
the desktop's
nudge()clamp; (3) Recommended is the landing tab, led by a CONTINUE WATCHING carousel joined from the device-local history (first entry per credited id, episodes crediting their parent series, finished titles — stored position 0 — excluded), so backing out of a film no longer means searching for it again; (4) the live channel list opened with a visible "scroll hunt" — the jump was alreadyanimated:false, but withoutgetItemLayoutthe virtualized list progressively rendered its way to a deep index; rows are now pinned to an exact height (CHANNEL_ROW_H) and the list mounts directly ON the playing channel (initialScrollIndex+ exactgetItemLayout). - QA round 3 (same day): in-app volume. Neither app had any audio
control. Both players in both shells gained a speaker mute toggle + level
slider (
VolumeControl, mirrored RN/desktop): desktop applies it to the<video>(re-applied on every self-heal remount), persists it in localStorage and adds anmmute key; RN rides rn-video'svolume/mutedprops (AliranVideoneeded no change — its existingvideoPropsspread carries them), session-local beside the hardware keys. TV stays control-free by design: remotes own volume. Muting keeps the level so unmuting restores it; dragging the slider unmutes.
EPG over P2P: a standalone guide service on an epoch-rotated drive (verified)
- New
epg/deployable (library-service pattern: own corestore + publisher identity, no admin credentials): pluggable ingest (provider-JSON https with ETag / local file; XMLTV-ready contract) → per-channel-per-day JSON files in a public epoch Hyperdrive, every put byte-compared first so a quiet refresh appends nothing. Provider channel ids map to streams through the catalog's existingepgIdfield; unmatched ids surface in/statusas an operator to-do. - Growth control is rotation, not compaction: a fresh drive every
EPOCH_DAYS(30), the current window copied in, the old drive grace-served 48 h then purged. The panel's only involvement is the new publisher-scopedsetEpgKeyRPC (scopeepg, named publishers only, stale-epoch guard) writing ONEmeta/epgKeyrecord — so the append-only bee costs ~12 blocks a year for a guide that changes daily. - Viewers sparse-fetch only the channel-days they display: the engine follows
meta/epgKeylive (swap + purge of the retired replica, the feed-rotation pattern), serves/epg/*on the loopback server with the drive version as a strong ETag (an unchanged poll answers 304 before any block is touched), andEpgServicetries P2P first with the httpsepgUrlpath as automatic fallback. Repeaters grewEPG=1(full raw mirror — the pointer carries the blobs key so the box stays hyperdrive-free) andANNOUNCE=1(advertise on the catalog topic). - Verified by the new
test:epg-p2plane on a local DHT testnet: ingest → frugality (unchanged re-ingest = 0 appends) → authenticated pointer → sparse viewer serve (held 1/4 blobs; 200/304/404) → rotation ≈ 1 bee record with the guide intact → a WARM viewer answers with everything upstream dead → a COLD viewer bootstraps catalog + guide from an announcing repeater alone.
Live channel thumbnails: a rolling preview frame in the feed drive (verified)
- The broadcaster's ffmpeg gains a second tiny output: one ~320px JPEG every
THUMB_INTERVAL_SECONDS(30) written beside the segments; the mirror's supersede-then-clearBlob path frees the previous frame's blocks, so the drive grows append-only in history but held blocks stay flat (proven per-refresh bytest:thumbs). Torn mid-write JPEGs are detected (SOI/EOI) and skipped. - Measured, not assumed: the
fpsfilter drops frames AFTER the decoder, so a thumbnail forces acopychannel's decoder ON — ~0.9% of a core each even with-skip_frame nokey(88% cheaper than naive). Hence the tri-state:copychannels opt in per channel, transcoding channels follow the fleet default (~free),THUMBS=0kills fleet-wide. Video-less sources never get the output (-map 0:v:0is fatal even with?): the gate is the now-persisteddetectedProfile, whose scrape was also fixed to read input 0 only — the thumbnail's own mjpeg banner (or a logo input) used to win the regex. GPU decode pathshwdownloadbefore the software scaler so nvenc channels keep their CUDA pipeline. - Viewer side:
/feedthumb/<id>serves the frame from already-warm feeds — synchronous resolve, never parks a request (rolling-blob targets get the stalled-read abort via the serve core's newidleflag), never evicts a warm feed, never opens drives on metered networks; the viewer-side reclaim sweep spares the thumbnail's blocks. Apps show thumb-first with poster/logo fallback on a 30 s tick that only runs while the row is visible (the desktop list was windowed for this). An adversarial review round preceded the merge; its three blocking findings (hang-on-cleared-blob, video-less-map, CUDA scale) were fixed with regression cases before anything shipped. - Production rollout the same day: 60 of 89 channels enabled in monitored
batches (~78% → ~93% box CPU, matching the measured per-channel cost); the
remaining 29 set to an explicit
thumb: falseso a future respawn cannot silently arm them past the CPU budget.