Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b5c9f82
feat(chat): find a comment again, and act on it in two clicks
rainmanjam Aug 6, 2026
61f4d1f
feat(i18n): translate the pages, and explain the settings on them
rainmanjam Aug 6, 2026
2981a2d
feat(i18n): the four smallest pages
rainmanjam Aug 6, 2026
4477578
feat(i18n): monitoring and clips
rainmanjam Aug 6, 2026
a2abcf2
feat(i18n): extract the last eight pages
rainmanjam Aug 6, 2026
14728a2
feat(i18n): the ternaries, and the cost estimator's prose
rainmanjam Aug 6, 2026
bf3c425
feat(i18n): the prose the regex could not see
rainmanjam Aug 6, 2026
87e0025
feat(i18n): translate the eight extracted pages into all fourteen loc…
rainmanjam Aug 6, 2026
d5dc845
fix(i18n): one word for "rendition" per locale
rainmanjam Aug 6, 2026
53237c2
docs: the changelog understated the i18n work
rainmanjam Aug 6, 2026
671bc86
feat(i18n): the last 153 keys, in all fourteen locales
rainmanjam Aug 6, 2026
777ee1f
feat: multi-source RTMP, a 32-track ceiling, and a marketing site
rainmanjam Aug 7, 2026
cecf1c7
fix(engine): a fresh install logged an ERROR about an RTMP port nobod…
rainmanjam Aug 7, 2026
f64d6de
feat(ui): make the cost of a destination's video treatment visible
rainmanjam Aug 7, 2026
7c15d09
feat(ui): an advanced section for customising a destination's encode
rainmanjam Aug 7, 2026
0e2e145
feat(db): per-platform encoder guidance, with its sources attached
rainmanjam Aug 7, 2026
531615e
fix(db): cross-check the platform guidance against OBS's services.json
rainmanjam Aug 7, 2026
e8882a9
feat(ui): surface each platform's published encoder settings on its d…
rainmanjam Aug 7, 2026
540b9dc
fix: the three things CI found, and why two of them could not fail lo…
rainmanjam Aug 7, 2026
cac87b1
i18n: the destination dialog, and a ratchet so this cannot recur
rainmanjam Aug 7, 2026
c8063c1
fix: every healthy SRT install said its ingest was offline
rainmanjam Aug 7, 2026
1c0dc97
fix: E-RTMP multitrack was unusable for any subscriber that joined late
rainmanjam Aug 7, 2026
31023d5
chore: port the E-RTMP multitrack harness from Python to Go
rainmanjam Aug 7, 2026
b170910
test: run real OBS against the ingest, and find that it does not send…
rainmanjam Aug 7, 2026
d8b719b
fix(install): the one network fetch that could be downgraded to http
rainmanjam Aug 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,22 @@ jobs:
- name: Install FFmpeg
timeout-minutes: 6
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends ffmpeg
# Ubuntu's own package is 6.1.1, and the Docker image ships 8.1.2.
# Testing against 6.1.1 meant CI never exercised the FFmpeg users
# actually get, and the two differ in ways that matter: 6.1.1 accepts
# an 80-channel amerge where 8.1 stops at 64, and 6.1.1 cannot demux
# multitrack FLV at all. Pin the same 8.1 line the image uses.
set -euo pipefail
cd /tmp
# -c rather than a heredoc: inside a YAML block the heredoc body and
# its terminator are indented, which Python reads as a bad indent and
# bash does not accept as a delimiter.
python3 -c 'import urllib.request; urllib.request.urlretrieve("https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n8.1-latest-linux64-gpl-8.1.tar.xz", "/tmp/ff.tar.xz")'
mkdir -p ffbuild && tar xf ff.tar.xz --strip-components=1 -C ffbuild
sudo install -m 0755 ffbuild/bin/ffmpeg /usr/local/bin/ffmpeg
sudo install -m 0755 ffbuild/bin/ffprobe /usr/local/bin/ffprobe
# Fail loudly rather than silently testing a build without SRT.
ffmpeg -hide_banner -protocols | tr ' ' '\n' | grep -qx srt
ffmpeg -hide_banner -version | head -1

# The gate is "gofmt named no files", and it has to rest on gofmt having
Expand Down Expand Up @@ -196,7 +210,24 @@ jobs:
- name: Install FFmpeg (Linux)
timeout-minutes: 6
if: runner.os == 'Linux'
run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends ffmpeg
run: |
# Ubuntu's own package is 6.1.1, and the Docker image ships 8.1.2.
# Testing against 6.1.1 meant CI never exercised the FFmpeg users
# actually get, and the two differ in ways that matter: 6.1.1 accepts
# an 80-channel amerge where 8.1 stops at 64, and 6.1.1 cannot demux
# multitrack FLV at all. Pin the same 8.1 line the image uses.
set -euo pipefail
cd /tmp
# -c rather than a heredoc: inside a YAML block the heredoc body and
# its terminator are indented, which Python reads as a bad indent and
# bash does not accept as a delimiter.
python3 -c 'import urllib.request; urllib.request.urlretrieve("https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n8.1-latest-linux64-gpl-8.1.tar.xz", "/tmp/ff.tar.xz")'
mkdir -p ffbuild && tar xf ff.tar.xz --strip-components=1 -C ffbuild
sudo install -m 0755 ffbuild/bin/ffmpeg /usr/local/bin/ffmpeg
sudo install -m 0755 ffbuild/bin/ffprobe /usr/local/bin/ffprobe
# Fail loudly rather than silently testing a build without SRT.
ffmpeg -hide_banner -protocols | tr ' ' '\n' | grep -qx srt
ffmpeg -hide_banner -version | head -1
- name: Install FFmpeg (macOS)
if: runner.os == 'macOS'
run: brew install ffmpeg
Expand Down Expand Up @@ -328,6 +359,11 @@ jobs:
- run: npx --no-install tsc -b --noEmit
- run: npm run lint
- run: npm run build
# Unit tests for the pure logic the browser suite cannot enumerate --
# platform link construction has five platforms times several missing-field
# cases, and driving each through a real browser would cost minutes to
# assert what a millisecond of vitest does.
- run: npm test
# A vulnerable direct dependency should fail the build, not sit in a
# report nobody opens. --audit-level=high so a low-severity transitive
# advisory does not block a hotfix.
Expand Down
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,14 @@ server.key
# not part of them. Regenerated freely and often half-finished, and a deck is
# not something a reader of this repository needs in order to run polyemesis.
presentation/

# Playwright's own trace/video output from the capture harness. 18 MB of
# .webm per run, regenerated every time, and it lands INSIDE docs/media
# because that is where the harness writes — so it is one `git add -A` away
# from history unless it is named here.
docs/media/.playwright/

# The built site. `/dist/` above is root-anchored and does not cover it, so
# without this line web/dist ships a second copy of every screenshot and the
# 1.4 MB tour video that web/public already holds.
web/dist/
63 changes: 63 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,69 @@ its first tagged release.

## [Unreleased]

### Added

- **Chat search.** `GET /api/v1/chat/search?q=` matches a message's text or its
author's name across the retained scrollback, newest first, optionally scoped
to one platform. The pane gains a search box that replaces the live timeline
while a query is active — results come from the database and are frozen at the
moment of the query, so letting live messages append underneath them would
present two different things as one list. Search is where an operator can
wrongly conclude something did not happen, so the retention caveat renders on
an empty result, not only on a full one.
- **Right-click and double-click moderation on a chat message.** Timeouts, delete
and the user card, two seconds from reading a bad line to acting on it.
Double-click is the same menu for pointers with no secondary button. It adds no
capability the user card lacked; a permanent ban deliberately does *not* fire
from the menu but opens the card, which confirms — the one irreversible action
reachable from a right-click should not be a single click on a menu that
appeared under the cursor.
- **Links out to the platform.** Twitch gets its real moderator viewer card in a
separate window. YouTube, Kick and Facebook get a profile or channel link and
say so, because none of them publishes a per-viewer chat history at any URL —
a uniform "Open on <platform>" label would promise a moderator the same thing
everywhere and deliver it only on Twitch.
- **Per-setting help.** An `(i)` popover beside a setting's label explains what it
actually changes — 2.5× your round-trip time for SRT latency, why the free-space
floor is the only limit that accounts for files polyemesis did not write. Click
rather than hover, so it works on touch and under a screen reader, and the body
is a catalogue key, so the explanation is translated too.

### Changed

- **The application is translated.** Every page under `src/pages` now reads from
the catalogue; a sweep for hard-coded prose returns nothing. The catalogue grew
from 135 keys to 1,098 and all fifteen languages are complete.

Previously only the nav shell and three widgets were extracted, so an operator
who chose Deutsch got a German sidebar and an English application. The 135 keys
that existed were complete in every language, which is exactly why the gap was
easy to miss: the coverage looked like 100%.

Extraction found prose in four shapes, each invisible to the check written for
the one before it — JSX text, string props and toasts; ternaries such as
`{busy ? "Pushing…" : "Push to platforms"}`; object-literal properties built
from template literals; and eight module-scope tables. The last of those could
never have called `useT()` at all, since hooks do not run at module scope, so
each now holds a `TranslationKey` and is translated where it is rendered — a
`Record<K, TranslationKey>` cannot hold a sentence, which the compiler enforces.
- **One word per locale for "rendition".** Ten of the fifteen catalogues disagreed
with themselves across `nav.renditions`, `rend.title`, `sources.renditions` and
`dash.renditions`. In Polish the sidebar read "Opcje jakości" while the page it
opened was titled "Warianty", so following the link appeared to land elsewhere.
Japanese, Korean and Dutch additionally used a term meaning "rendering
settings"; a rendition is one video variant at a given size and bitrate, not a
settings screen, and they now say so.
- `vitest` runs in CI alongside `tsc` and `oxlint`, covering the pure logic the
browser suite cannot practically enumerate — platform link construction across
five platforms, and the translation catalogues themselves.

### Fixed

- Chat's `(i)` help buttons no longer all announce as "More information"; each
names the setting it explains, so a screen-reader user can tell a dozen of them
apart.

## [0.3.0] — 2026-08-05

A minor bump rather than a patch, and deliberately: `facebook.backupIngest`
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@ hot. One upload, one video encode, different audio per platform.
encode feeds every destination that selects it, ref-counted so an unused tier
costs nothing — and it re-encodes **video only**, so audio still routes per
destination. [→ RENDITIONS.md](docs/RENDITIONS.md)
- **SRT multitrack ingest** (up to 6 AAC tracks), addressed by token on a single
port, with RTMP as a single-track fallback. [→ OBS.md](docs/OBS.md)
- **SRT multitrack ingest** (up to 32 AAC tracks; six is what OBS sends), addressed by token on a single
port, with RTMP as a single-track fallback — also one port, addressed by
stream key, and equally unlimited in how many sources it carries.
[→ OBS.md](docs/OBS.md)
- **Live audio meters** for every channel of every track — how you verify the
clean track really is clean, *before* going live.
- **Unlimited destinations**: RTMP(S), SRT, or local file, each independently
Expand Down Expand Up @@ -86,9 +88,8 @@ roadmap.

| | |
|---|---|
| **No multitrack over RTMP.** | RTMP carries one stereo pair. Enhanced RTMP multitrack from OBS 30.2+ is **not** supported, and the `enhancedRtmp` config key is an inert placeholder. Multitrack means SRT. |
| **One RTMP source, maximum.** | polyemesis has no RTMP server of its own — it uses `ffmpeg -listen 1`, which cannot demultiplex by path. Any number of sources can share the SRT port; only one can use RTMP. [Why](docs/DESIGN-ONE-PORT-ONLY.md#rtmp) |
| **No per-source ports.** | Every push source is addressed by its token on one SRT port. Giving one programme its own port for firewall, NIC or QoS purposes is not something polyemesis does. [Why](docs/DESIGN-ONE-PORT-ONLY.md#what-it-costs) |
| **RTMP multitrack is not the operated path.** | Classic RTMP carries one stereo pair. Enhanced RTMP (OBS 30.2+) carries more and works on FFmpeg 7.1+ — verified end to end — but not on FFmpeg 6.1.1, and not yet confirmed with OBS publishing. SRT is what is operated. See `docs/notes/enhanced-rtmp-multitrack.md`. |
| **No per-source ports.** | Every push source is addressed by its token on one SRT port, or its stream key on one RTMP port. Giving one programme its own port for firewall, NIC or QoS purposes is not something polyemesis does. [Why](docs/DESIGN-ONE-PORT-ONLY.md#what-it-costs) |
| **One user, no roles.** | No multi-user model, no per-destination permissions. Access to the UI is full control of the server's streaming — and, through file destinations and expert mode, meaningful control of the machine. |
| **Instagram Live cannot work.** | There is no Live broadcast API, and Live Producer's RTMP path was removed for most accounts. It is listed and marked unsupported rather than shipped as a preset that quietly never connects. |
| **Your source video is your problem.** | Renditions can re-encode down, but polyemesis will not rescue a 4K60 ingest on a box that cannot software-encode it in realtime. |
Expand Down
11 changes: 7 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,13 @@ These are design decisions, not oversights. Read them as operating instructions.
need it, it is guarded by a confirmation showing exactly what will be spliced
in, and it is still a way to make FFmpeg do things. Treat the ability to reach
it as equivalent to shell access.
- **RTMP ingest is only as protected as its stream key.** SRT is authenticated
by construction — the publish token *is* the address, so a publisher that
cannot present a valid one is refused. RTMP has no such routing: it is gated
by the stream key alone, and it serves at most one source.
- **RTMP ingest is only as protected as its stream key.** Both protocols are now
authenticated by construction — the publish token *is* the address, over SRT as
`streamid` and over RTMP as the stream key in the URL path, so a publisher that
cannot present a valid one is refused. The difference that remains is the wire:
SRT can additionally encrypt with a passphrase, RTMP cannot unless you put it
behind RTMPS. Subscribing to the RTMP listener is restricted to loopback, so a
publish credential never doubles as a viewing one.
- **Nothing is encrypted on the wire unless you say so.** Plain HTTP means the
password and session cookie cross the network in clear text. The server warns
about this at startup; the warning is not decorative.
Expand Down
9 changes: 8 additions & 1 deletion cmd/polyemesis/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -569,7 +569,14 @@ func reportStartup(log *slog.Logger, cfg config.Config, provider *tlsx.Provider,

fmt.Printf("\n polyemesis %s\n", version)
fmt.Printf(" web ui %s://%s\n", scheme, shown)
fmt.Printf(" ingest %s (port %d)\n", settings.Ingest.Mode, ingestPort(settings))
// An install that has not chosen yet prints that, rather than an empty mode
// beside a port number — which reads as "srt on 6000" to anyone skimming and
// is the one impression this must not give.
if settings.Ingest.Mode == db.IngestUnset {
fmt.Printf(" ingest not chosen yet — pick SRT, RTMP or pull in the web UI\n")
} else {
fmt.Printf(" ingest %s (port %d)\n", settings.Ingest.Mode, ingestPort(settings))
}
fmt.Printf(" data dir %s\n", cfg.DataDir)
fmt.Printf(" ffmpeg %s\n", tools.Version)
reportTLS(cfg, provider, shown)
Expand Down
6 changes: 4 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ services:
- "6000:6000/udp" # SRT ingest — UDP, not TCP. ONE port for every
# source; they are told apart by publish token,
# so adding a source never changes this file.
- "1935:1935" # RTMP ingest (fallback, single stereo pair,
# and serves at most one source)
- "1935:1935" # RTMP ingest. One port, any number of sources,
# told apart by stream key — same as SRT above.
# Classic RTMP carries one stereo pair; Enhanced
# RTMP carries more on FFmpeg 7.1+.

# ACME ONLY. Uncomment together with the config mount below if you want
# polyemesis to obtain its own Let's Encrypt certificate: HTTP-01
Expand Down
17 changes: 16 additions & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ told so. The schedule still saves and still runs.
| `GET` | `/oauth/{platform}/start`, `/callback` |
| `GET` | `/metadata`, `/metadata/broadcast-window` |
| `POST` | `/metadata/push`, `GET` `/metadata/push/{id}` |
| `GET` | `/chat`, `/chat/messages`, `/chat/users` |
| `GET` | `/chat`, `/chat/messages`, `/chat/search`, `/chat/users` |
| `POST` | `/chat/send` |
| `DELETE` | `/chat/messages` |
| `POST` | `/chat/messages/hide` |
Expand All @@ -391,6 +391,21 @@ Its mod card is a web-app feature backed by internal endpoints. The trade is
depth for breadth: shallower than Twitch's card, and it works across all four
platforms at once.

`/chat/search?q=` finds a message again, matching on its text **or its author's
name**, newest first — the one read here that is not chronological, because a
result list answers "where did that comment go" and burying the likeliest answer
at the bottom would be perverse. `platform=` narrows it to one tab and `limit=`
bounds the page.

It searches the database and never the Hub's in-memory ring, which holds only
what the current process has seen; "find the comment from earlier" is precisely
the question a process-lifetime buffer cannot answer. The same caveat as
`/chat/users` applies and applies harder: the response carries `retentionNote`
and `truncated` because search is the one place an operator can conclude
something did *not* happen. **An empty result means "not in the scrollback we
kept", never "never said"** — so render the note alongside no-results, not only
alongside a full page.

`DELETE /chat/messages` removes one message on the platform. `POST
/chat/messages/hide` is Facebook's reversible hide where the platform offers it,
and a local-only hide everywhere else — the pane stops showing it, the platform
Expand Down
Loading
Loading