Streaming

This page is about getting video into ETTIX STREAM and getting it out again. A stream is one channel. It has one source, one HLS timeline and one identifier, and that identifier appears in every URL and every API route that concerns it.

There are two ways in, chosen per stream by streams[].source.type:

TypeWho connects to whomTypical use
rtmp_push The encoder connects to ETTIX, on the RTMP port. ETTIX authenticates the publisher. OBS, ffmpeg, Wirecast, vMix, hardware encoders.
srt_pull ETTIX connects out to a remote SRT listener and pulls MPEG-TS from it. The far end authenticates ETTIX. Contribution feeds from a remote site, and any source you want ETTIX to chase rather than wait for.

There is one way out: HLS over HTTP, optionally encrypted. The output is the same whichever ingest produced it, so nothing downstream of the server needs to know which one a stream uses.

Every configuration change described here goes into the JSON configuration file. Validate it with ettix-stream config check before you apply it, and apply it with SIGHUP rather than a restart: an invalid file is rejected and the running configuration stays active.

ettix-stream config check --config /etc/ettix-stream/config.json
kill -HUP $(pidof ettix-stream)

Ingest — RTMP push

ETTIX STREAM is an RTMP publish server only. It accepts H.264 video and AAC audio and does not serve RTMP playback. The listener is rtmp.listen, default :1935.

The URL

rtmp://host:1935/<app>/<stream-id>?key=<stream key>

Three parts of that URL matter, and they are not interchangeable.

The stream id is the last path element. It is streams[].id in the configuration, and it is what selects the stream. It must match ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ — one to sixty-four characters of letters, digits, underscore and hyphen, starting with a letter or a digit. The same id names the stream in the playback URL, so an id is a public identifier and should not be secret.

The app is the path element before it, and it is a second thing that has to agree rather than a second way to select a stream. ETTIX looks the stream up by id, then requires source.rtmp.app to equal the app the publisher connected with; if it does not, the publish is refused exactly as though the stream did not exist. The default app is live. Use the app to group channels for your own clarity, or to make a copied encoder profile fail loudly when it is pointed at the wrong service.

The stream key is a credential and is not part of the path. It travels as the key query parameter.

Most encoders split the URL into a "Server" field and a "Stream Key" field. The split is at the app boundary, and the query string goes in the second field. In OBS: Server rtmp://origin.example/live, Stream Key channel1?key=SECRET. ETTIX merges query parameters from both halves before it looks at them, so it does not matter to the server which field carried them.

A complete RTMP example

Configure the stream

This is a whole, valid configuration file. The stream key here is an example; generate your own, and make it long — the minimum accepted length is 8 characters, which is a floor, not a recommendation.

{
  "version": 1,
  "server": { "name": "origin-1", "data_dir": "/var/lib/ettix-stream" },
  "log": { "level": "info", "format": "text" },
  "http": {
    "listen": ":8080",
    "admin_listen": "127.0.0.1:8081",
    "cors_origins": ["*"]
  },
  "rtmp": { "listen": ":1935" },
  "hls": { "cache_max_bytes": "512MB" },
  "streams": [
    {
      "id": "channel1",
      "name": "Channel 1",
      "source": {
        "type": "rtmp_push",
        "rtmp": {
          "app": "live",
          "stream_key": "Xg7pQ2vL9taR4ncE",
          "replace_policy": "reject"
        }
      },
      "hls": {
        "segment_duration": "4s",
        "playlist_segments": 6,
        "cache_segments": 12,
        "program_date_time": true
      }
    }
  ]
}

Publish to it

From an encoder, or from ffmpeg for a test. ffmpeg is not required by ETTIX and is not shipped with it; this is only a convenient way to prove the path works.

ffmpeg -re -stream_loop -1 -i testdata/sample-320x240-h264-aac.flv \
  -c copy -f flv "rtmp://127.0.0.1:1935/live/channel1?key=Xg7pQ2vL9taR4ncE"

The equivalent OBS settings are Server rtmp://127.0.0.1/live and Stream Key channel1?key=Xg7pQ2vL9taR4ncE.

Play it back

The playlist appears a few seconds after the first keyframe arrives, because the first segment has to be cut before there is anything to list.

curl -i http://127.0.0.1:8080/hls/channel1/index.m3u8
ffplay http://127.0.0.1:8080/hls/channel1/index.m3u8

A 503 with PLAYLIST_NOT_READY and Retry-After: 2 means the stream exists but is not live yet. A 404 with STREAM_NOT_FOUND means the id is wrong or the stream is disabled.

Publisher authentication

The stream key alone is the whole check for a stream that configures nothing else, and it is compared in constant time. Anything more is configured in the stream's publisher block, which holds up to four local requirements plus an optional external callback:

FieldWhat it requires
publisher.users[] A username and password. username is plain text; password_hash is a hash, never a password. Any one configured account satisfies the requirement.
publisher.tokens[] A bearer token, presented as ?token=. Each entry has a name (which appears in logs and is not a secret) and a hash. Any one token satisfies the requirement.
publisher.ip An address allow-list and deny-list (allow, deny, as CIDRs or bare addresses). Deny is applied first. The address is the TCP peer's own, which for RTMP is authoritative: there is no forwarded-for header for a client to forge.
publisher.require_stream_key The stream key from source.rtmp.stream_key, in addition to whatever else is set. Defaults to true whenever a stream key is configured.
publisher.external An HTTPS callback to your own service, asked only after every local requirement above has passed. See the Security page.

The composition is AND, not OR. A block with both users and tokens requires a valid password and a valid token, not either one. If you want alternatives, configure one mechanism and let whatever issues credentials decide which to hand out.

Passwords and tokens are configured as hashes because a configuration file gets copied, backed up and read by more people than you expect. Both an Argon2id hash ($argon2id$v=19$…) and a bcrypt hash ($2b$…) are accepted; bcrypt exists so an existing user database can be migrated without resetting every password.

There is no dedicated publisher-credential helper in this release. ettix-stream admin hash --user NAME produces a hash in the accepted format, so it can be used to generate one — but copy only the password_hash value out of what it prints. It emits an object shaped for http.admin_auth.users, which carries a read_only field that a publisher entry does not have, and unknown fields are rejected by the configuration loader.

ettix-stream admin hash --user encoder-a

An RTMP URL can carry four credentials, in the places RTMP leaves for them:

CredentialWhere it is read from
Stream key?key=, or the RTMP password field — see below
Usernameuser:…@ in the URL, otherwise ?user=
Password…:password@ in the URL, otherwise ?pass= or ?password=
Token?token= — RTMP has no header to carry a bearer token in

One field cannot be two credentials. OBS, ffmpeg and every librtmp-based tool put the stream key in the RTMP password field, and ETTIX reads it there — but only for a stream that configures no publisher accounts. As soon as publisher.users exists, the password field means the account password, and the stream key must be given as ?key=. Adding a publisher account to a stream whose encoder relies on the old behaviour will take that encoder off the air until its URL is changed.

What a refused publisher sees

Nothing useful, deliberately. A wrong key, a wrong password, a disallowed address, an unknown stream, a disabled stream and a stream that already has a publisher all produce one generic NetStream.Publish.BadName, and every refusal is held for roughly 250 ms plus jitter so that a byte comparison and a password hash cannot be told apart by timing. The real reason goes to your log as a stable PUB_* code, and that log line is the only place it exists. When an encoder cannot connect, read the server's log; there is no more detail to be had at the encoder.

When a second publisher arrives

A stream has one source and one HLS timeline, so source.rtmp.max_publishers is 1 and may only be set to 1 or 0 (which selects 1). replace_policy does not raise that limit; it decides which of two connections holds the single slot.

SettingEffect
"replace_policy": "reject" (default) The publisher already on air keeps the stream. The new connection is refused. A flapping backup encoder cannot interrupt a good broadcast.
"replace_policy": "replace" The new publisher takes the stream and the previous connection is closed. Use it when the newest connection is by definition the right one — an encoder that reconnects after a network drop while the server still believes the old connection is alive.

A takeover is not free. The previous source's partial segment is flushed immediately and the new source starts on a discontinuity, which every player handles but few handle invisibly. That is why source.rtmp.min_replace_interval exists: it is the shortest time between two replacements, default 2s, maximum 1m. Inside that window replace behaves as reject. Without it, two encoders configured with the same credentials and both set to reconnect would take the stream from each other as fast as TCP allows and insert a discontinuity every time. The first publisher on an idle stream is never a replacement and is never delayed, which is the case that actually happens day to day.

"allow_anonymous": true accepts a publisher with no credential at all. Anyone who can reach port 1935 can then broadcast on that channel. It exists for isolated test networks and nothing else. It cannot be combined with a stream key or with publisher credentials — the configuration is refused rather than guessed at — and its use is recorded in the log as INGEST_UNAUTHENTICATED.

Ingest — SRT pull

An srt_pull stream works in the opposite direction to RTMP. ETTIX dials out as an SRT caller, so the far end must be configured as an SRT listener waiting on a UDP port. Listener-mode ingest, where a remote encoder pushes SRT into ETTIX, is not part of this release; if your source can only push, put an SRT-to-RTMP bridge in front of it, or configure it to listen instead.

Two consequences follow from ETTIX being the caller, and both surprise people:

Sources and priority

source.srt.sources[] lists between one and eight remote listeners. Each entry is one place the same programme can be obtained from, and ETTIX uses exactly one of them at a time.

FieldDefaultMeaning
idrequired Names the source in the API and in logs. Same grammar as a stream id, unique within the stream. Choose something you would want to read in an alert: primary, backup-fibre.
hostrequired DNS name or IP address of the remote listener.
portrequired Its UDP port, 1–65535.
latency120ms SRT latency, between 20ms and 8s. This is the buffer SRT uses to retransmit lost packets, so it must be comfortably larger than the round-trip time to the source. Too small and loss becomes visible corruption; too large and it is added to every viewer's delay.
stream_idempty The SRT streamid string sent to the listener, up to 512 bytes. It is what most SRT servers use to select a channel and to authorise the caller. ETTIX treats it as a secret: it is never logged and never returned by the API, because it commonly carries a token.
passphraseempty Enables SRT's own encryption, 10 to 79 bytes. Secret. Without it the feed crosses the network in the clear.
pbkeylen16 when a passphrase is set 16, 24 or 32 — AES-128, AES-192 or AES-256. It must be 0, or absent, when there is no passphrase; there is no key length for encryption you did not ask for.
connect_timeout5s Bounds one connection attempt, between 1s and 60s.
prioritylist position Lower is preferred, 1 to 1000, and unique within the stream. This is what makes one source the primary and the rest backups.

Two sources with the same host, port and stream_id are refused: they are the same endpoint written twice, and as a redundancy plan they protect against nothing.

Reconnection of a single source is exponential backoff, configured once for the whole stream in source.srt.reconnect: delay (default 1s), multiplied by multiplier (default 2.0) after each failure, capped at max_delay (default 30s).

Health, failover and warm standby

The source.srt.failover block decides when ETTIX gives up on the source it is using and which one it moves to.

FieldDefaultMeaning
modereturn_to_primary return_to_primary: once a higher-priority source is healthy again, and has stayed healthy, take it back. stay: having failed over, remain on the current source until it too fails.
unhealthy_after5s How long a still-connected source may be unhealthy before ETTIX abandons it, between 1s and 5m. A source whose connection is lost outright is not given this grace: reconnecting already costs at least the reconnect delay, so waiting achieves nothing.
stability_period30s How long a source must be continuously healthy before it is considered trustworthy again, between 1s and 1h. This is the anti-flap clock, and any bad instant restarts it from zero.
warm_standbyfalse Keep every source connected at all times, discarding the packets of the ones that are not active.
min_bitrate_bps0 A connected source delivering less than this counts as unhealthy. 0 disables the check. Use it where a source can fail by going quiet rather than by disconnecting.
max_keyframe_interval10s A video source that has produced no keyframe for this long counts as unhealthy, between 1s and 5m. Set it above the encoder's real keyframe interval with room to spare, or a healthy source will be declared dead.

A source counts as healthy only while it is connected and all of the following hold: bytes are arriving, the MPEG-TS program tables have been seen, media and its decoder configuration are arriving, timestamps are moving forward, a keyframe has arrived within max_keyframe_interval (for a source that carries video), the bitrate is at or above min_bitrate_bps, and demux errors are not pouring in. When a source is unhealthy the API reports which of those signals is missing, so failover is something you can explain afterwards.

The choice of a new source is deliberately conservative. ETTIX prefers the highest-priority source that has been healthy for the whole stability_period. If there is no such source it will take any currently healthy one — something beats nothing — and if there is none at all it stays on the degraded source it has rather than dropping the stream. Every switch marks a discontinuity in the HLS output.

Warm standby is the difference between a backup that is already connected and one that has to be dialled when it is needed. With warm_standby: false (the default) a cold backup must complete a UDP handshake, fill its SRT latency buffer and wait for program tables and a keyframe before it can carry the programme — several seconds of dead air on top of the failover decision itself. With warm_standby: true every source is connected and demuxed continuously and its packets are thrown away until it is needed, so a switch is close to instant. The price is real: each standby source uses the bandwidth of a full feed and roughly the same memory as the active one, because it holds its own receive buffer and demuxer state. Turn it on for sources whose failover must be invisible; leave it off for a distant backup you are willing to wait a few seconds for.

Even with warm standby off, ETTIX is not entirely idle about backups. In return_to_primary mode it keeps every source of higher priority than the active one connected, because it cannot know a primary has recovered without watching it. And while the active source is degraded it starts bringing up backups in priority order rather than waiting for the failover decision.

Return to primary is the behaviour most operators want and is the default: a stream that failed over to a backup at 03:00 should be back on its primary path when someone looks at it in the morning, without anyone touching it. The cost is a second discontinuity, at a moment nobody chose, when the primary comes back. Choose stay for a channel where every glitch has to be accounted for and you would rather move back deliberately — by editing the configuration and reloading — than automatically.

A complete SRT example

Two sites carrying the same programme. The primary is a fibre path with an SRT passphrase; the backup is over the public internet, so it is given more latency to absorb worse loss. Failover is warm, because this channel must not go dark, and it returns to the primary once that has been solid for a minute.

{
  "version": 1,
  "server": { "name": "origin-1", "data_dir": "/var/lib/ettix-stream" },
  "http": { "listen": ":8080", "admin_listen": "127.0.0.1:8081" },
  "hls": { "cache_max_bytes": "512MB" },
  "streams": [
    {
      "id": "channel2",
      "name": "Channel 2",
      "source": {
        "type": "srt_pull",
        "srt": {
          "sources": [
            {
              "id": "primary-fibre",
              "host": "198.51.100.10",
              "port": 9000,
              "priority": 1,
              "latency": "120ms",
              "stream_id": "live/ch2",
              "passphrase": "a-long-shared-passphrase",
              "pbkeylen": 16
            },
            {
              "id": "backup-internet",
              "host": "203.0.113.7",
              "port": 9000,
              "priority": 2,
              "latency": "800ms",
              "stream_id": "live/ch2-backup"
            }
          ],
          "reconnect": { "delay": "1s", "max_delay": "30s", "multiplier": 2.0 },
          "failover": {
            "mode": "return_to_primary",
            "unhealthy_after": "5s",
            "stability_period": "60s",
            "warm_standby": true,
            "max_keyframe_interval": "10s"
          }
        }
      },
      "hls": { "segment_duration": "4s", "playlist_segments": 6 }
    }
  ]
}

The far end must be listening on those ports with the matching passphrase and stream id. There is nothing to publish and nothing to start: as soon as the configuration is loaded, ETTIX begins dialling, and it keeps dialling on the reconnect backoff for as long as the stream is enabled.

Which source is live, why the last switch happened, and what each source is doing are all on the administration listener:

curl -s http://127.0.0.1:8081/api/v1/streams/channel2/sources

SRT ingest is a licensed capability (srt_ingest). On an installation whose licence does not cover it, the sources are not dialled at all and the reason is logged. RTMP ingest, HLS playback and the encryption modes are gated the same way. See the licence status on the administration interface if a correctly configured source never connects.

Output — HLS

Playback is served from the HTTP listener, http.listen, default :8080. There are two routes per stream, and their shape never changes:

GET /hls/<stream-id>/index.m3u8      the live media playlist
GET /hls/<stream-id>/<sequence>.ts   one segment

Segment names are the media sequence number zero-padded to at least ten digits, for example 1757195400123.ts, and only that exact form is accepted. The sequence does not start at zero: it starts from a wall-clock-derived value, so a segment name is never reused after a restart. That is what makes it safe for a CDN or a browser to cache segments for a long time.

Adding viewer authorisation later adds query parameters and headers to these URLs but never changes the paths, so a CDN configured for a public stream keeps working when the stream is made private.

Segment duration and the playlist window

SettingDefaultMeaning
streams[].hls.segment_duration4s The target segment length, 1s to 30s.
streams[].hls.playlist_segments6 How many segments the live playlist lists, 3 to 60. Three is the minimum HLS itself permits.
streams[].hls.cache_segments12 How many completed segments stay in memory, at least playlist_segments and at most 600.
streams[].hls.program_date_timetrue Write absolute wall-clock times into the playlist.

segment_duration is a target rather than a promise, because a segment has to start with a keyframe for a player to be able to join at it. ETTIX cuts at the first video keyframe at or after the target, so the real length is decided by your encoder's keyframe interval. If your encoder emits a keyframe every 2 seconds and you ask for 4-second segments you will get 4-second segments; if it emits one every 5 seconds you will get 5-second segments regardless of what you configured. A segment that reaches three times the target, or 64 MiB, is cut without a keyframe as a last resort — that is a symptom, and the server logs it, because a segment with no keyframe at the front is one a player cannot start at.

These two numbers together set the two things viewers feel. Latency is roughly the playlist window: a player typically starts about three segments back from the live edge, so 4-second segments put a viewer around 12 seconds behind reality. Tolerance of a bad network is the same window from the other side: with playlist_segments: 6 a player has 24 seconds of listed media to survive a stall before the segment it wanted has left the playlist. Shortening segments reduces latency and increases request rate and overhead; lengthening them does the reverse. Four seconds and six segments is a balance that works for most live channels, and it is the default for that reason.

cache_segments is separate because a segment can usefully outlive the playlist that listed it: a viewer who fell behind, or a CDN filling a cache miss, may ask for a segment after it has scrolled out of the window. The default of 12 keeps roughly twice the playlist window available.

Program date time

With program_date_time enabled — the default — ETTIX writes an EXT-X-PROGRAM-DATE-TIME tag giving the segment's wall-clock time in UTC. The tag appears at the head of the playlist window and again after every discontinuity, which is all a player needs to map the whole timeline to real time.

This is what lets a player display an absolute clock rather than a position, what lets a viewer be sent to "20:00:00" rather than "142 seconds in", and what lines live playback up with the DVR timeline of the same stream. It costs one short line per playlist. Turn it off only if you have a downstream system that mishandles the tag.

The segment cache

Completed segments live in memory, not on disk. Two limits apply: streams[].hls.cache_segments per stream, and hls.cache_max_bytes as one global budget shared by every stream — default 512MB, minimum 16MB.

When the global budget is exceeded, the oldest segments outside a live playlist window are evicted first. Segments inside a live window are never evicted for the budget, because dropping one would break playback for every viewer of that stream in order to fix a number the operator can see and change. Instead the cache reports itself as over budget, in the hls_cache section of the status API:

curl -s http://127.0.0.1:8081/api/v1/status

If hls_cache.over_budget is true, either raise hls.cache_max_bytes or lower cache_segments on the streams that are using it. hls_cache.evictions counts how often the budget has had to take a segment out of the cache.

Sizing is arithmetic: bitrate multiplied by segment duration multiplied by cache_segments, summed over every stream. A 5 Mbit/s stream with 4-second segments and 12 cached segments needs about 30 MB.

Caching headers

ResponseCache-ControlWhy
Playlistno-cache, max-age=0 It changes every segment. A cached playlist is a stalled player.
Segment public, max-age=<cache_segments × segment_duration>, immutable A segment's bytes never change and its name is never reused, so shared caches may keep it for as long as the origin does.
Content keyno-store Authorisation is decided per request; a cached key would outlive the decision that released it.

Browsers reaching the playback listener from another origin need http.cors_origins to list their origin, or "*". With no match, no CORS headers are sent at all and the browser refuses the response.

Output — encryption

HLS delivery encryption is configured per stream in streams[].hls.encryption. It protects the segments in transit and at rest in every cache between the server and the player: someone who obtains the segment files still has nothing to watch without the content key.

FieldDefaultMeaning
modenone none, aes-128 or sample-aes.
key_rotation_interval24h Rotate the content key on a clock. 24h is also the hard ceiling; the minimum is 1m.
key_rotation_segments0 Also rotate after this many segments. 0 disables the segment rule; the interval still applies.
key_uri_prefixempty Overrides the origin of the key URI written into playlists. Empty means this server's own key endpoint, which is what almost every deployment wants.

Choosing a mode

aes-128 encrypts each segment whole. It is the oldest and most widely implemented form of HLS encryption and it plays everywhere HLS plays. Choose it unless you have a specific reason not to.

sample-aes encrypts only the media samples and leaves the transport stream itself readable, as Apple's HLS Sample Encryption specifies. Support is uneven and the unevenness is not ETTIX's to fix: Apple platforms support it broadly, browsers largely do not, and FFmpeg-based players show a decode error on the rare slice whose payload needs emulation prevention — a defect in their decryptor rather than in this output, which is verified against an independent implementation on every build. Choose sample-aes only when you know what will be playing the stream, and choose aes-128 when a stream must play flawlessly everywhere.

The key URL

With encryption enabled the playlist carries an EXT-X-KEY tag whose URI attribute points at the content key. The key is not in the playlist; the URI is a request the player has to make, and that request is authorised. By default the URI is a path on this server:

GET /key/<stream-id>/<key-id>
GET /hls/<stream-id>/key/<key-id>    the same key, under the HLS prefix

The key id is 32 hexadecimal characters. The response is 16 raw bytes with Cache-Control: no-store. Every case that cannot produce a key — unknown key id, a key belonging to a different stream, a stream that does not exist, a stream with no encryption — answers identically, so key ids cannot be probed from outside.

The content key is served only to a request that passes the same authorisation as the playlist and the segments. That is the whole point of the design: the key, the playlist and the segments are gated with one decision, because gating the key alone would leave the media readable to anyone on an unencrypted stream, and gating the playlist alone would leave segment URLs guessable from one leaked playlist.

"Authorised viewers" means whatever your auth block says, and a stream with no auth block is public. Encryption on a public stream still protects the segments in caches and on the wire, but the key endpoint will hand a key to anyone who can reach the playback listener. Encryption and viewer authorisation are two separate settings and each is doing only its own job; configure both. The server logs a warning at startup when key delivery is running with no authorisation policy behind it.

key_uri_prefix exists for deployments that front key delivery with a CDN or an edge: set it to an absolute path or an http(s) origin and that is what appears in the playlist. It changes where players ask for the key and nothing else. Whatever is at the other end still has to enforce authorisation, and if it does not, you have moved the key out from behind the check.

Keys on disk

Content keys are stored wrapped by an installation master key, under encryption.key_dir (<data_dir>/keys by default), with the master key itself at encryption.master_key_file (<key_dir>/master.key). The directory must be on the server's own storage and is created readable only by the service user. The installer, or ettix-stream init, generates the master key; the server will not silently create one, because a fresh master key beside existing wrapped keys would orphan them.

A stream configured to encrypt is never served in the clear because the key provider failed to start: the server treats that as a startup failure rather than falling back.

Back up the master key, and keep the backup somewhere other than the server it protects. Losing it makes every encrypted recording unrecoverable, and no support path can undo that.

ettix-stream keys export --out /secure/backup/ettix-master.key

Delivery encryption is a licensed capability (hls_encryption for AES-128, sample_aes for SAMPLE-AES). If the licence does not cover the configured mode, the stream is served in the clear and HLS_ENCRYPTION_UNLICENSED is logged. The playlist then carries no EXT-X-KEY tag, so players are not misled into thinking they are receiving protected content — but nor is the content protected. Check the log after enabling encryption for the first time.

Delivery encryption and DVR at-rest encryption are independent of each other. Recording stores the cleartext segment under its own at-rest key, so rotating a delivery key never affects the archive. See the DVR page.