DVR

The DVR writes a stream to disk while it is live, keeps an index of what it wrote, deletes old material according to the rules you set, and serves the result back as a playlist a normal HLS player can open. Recording is configured per stream, and each stream can write to more than one place at once.

Two properties shape everything on this page. Recording never blocks the live stream: a destination that is slow, full or gone loses recordings and reports itself unhealthy, but viewers watching live are not affected. And the recording on disk does not depend on the index to be valid — the media files are ordinary MPEG-TS, laid out in a predictable directory tree, and the index can be rebuilt from them.

The single most common DVR failure on a systemd installation has nothing to do with the DVR itself: the destination directory is not listed in the service unit's ReadWritePaths, so the whole filesystem is read-only to the server and the first write fails. If you have just added a destination and nothing is being recorded, read Every DVR root must be in ReadWritePaths first.

Turning recording on for a stream

Recording is off until you configure it. The starter configuration that ettix-stream config example writes contains no dvr block at all, so a fresh installation records nothing.

Add a dvr block to the stream in /etc/ettix-stream/config.json. The minimum is enabled and one destination:

{
  "id": "channel1",
  "name": "Channel 1",
  "source": {
    "type": "rtmp_push",
    "rtmp": { "app": "live", "stream_key": "a-real-key" }
  },
  "hls": { "segment_duration": "4s" },
  "dvr": {
    "enabled": true,
    "mode": "always",
    "destinations": [
      { "id": "d1", "path": "/mnt/dvr1", "max_age": "168h", "min_free_percent": 10 }
    ]
  }
}

Check the file before you apply it. The check is a separate command because a configuration that fails validation is refused whole, and finding that out from a failed reload is worse than finding it out from a command you ran on purpose.

ettix-stream config check --config /etc/ettix-stream/config.json
systemctl reload ettix-stream

DVR changes are applied by a reload; the service does not need to be restarted for them. Adding, removing or re-tuning a destination takes effect on the next reload, and a destination that is removed from the configuration simply stops being written to — its directory tree is left exactly as it is.

enabled: true with no destinations is refused by validation, and so is a destination without an absolute path. Recording also requires the dvr_record entitlement in the installation's licence; if the licence lapses, recording stops within moments and resumes when the licence is valid again.

When to record: mode

modeRecords
always (default)whenever the stream is live.
scheduleonly inside the windows in schedule[]. At least one window is required.
apionly between an explicit start and stop (the Start/Stop button on the administration interface's DVR page, or POST /api/v1/streams/{id}/dvr/recording with {"action":"start"}).
offnever, while leaving the destinations configured.

Schedule windows are {"days": ["mon","tue"], "start": "09:00", "end": "17:00"} in the server's own local time zone, with minute resolution. An empty days list means every day, and an end earlier than start wraps past midnight. Windows are re-evaluated every ten seconds, which is the worst case for how late a scheduled recording starts.

mode: "off" stops writing. It does not stop retention: the rules below keep running against what is already on disk, so an archive with max_age set continues to age out while recording is off. If you want to preserve material, place a hold on it (POST /api/v1/streams/{id}/dvr/holds) or remove the retention rule; holds outrank every deletion rule.

Destinations, and why more than one

A destination is one storage root for one stream. Each has an id that names it in the API, the logs and the administration interface, and a path that must be an absolute directory. A stream may have up to eight, and two destinations of one stream may not share a path.

Every destination is completely self-contained. It has its own writer, its own write queue, its own sequence numbers, its own index and its own retention rules, and it produces its own independent directory tree. Nothing is shared between two destinations of the same stream, which is what makes the following true: one destination filling up, failing, being unmounted or being physically removed does not disturb the other, and does not stop the live stream.

That independence is the reason to configure more than one:

WhyShape
Survive losing a diskTwo roots on two different filesystems, the same retention on each. Losing one leaves a complete recording on the other.
Two retention horizonsA fast disk with a short max_age for scrubbing and instant playback, and a large slow disk with a long max_bytes for the archive.
Migrate storage without a gapAdd the new root, let both record, and remove the old one once the new one covers the window you need.

Give each destination its own filesystem. The free-space rules measure the medium, so two roots on one filesystem make those rules fight each other over the same free space, and the figures on the status page describe the filesystem rather than the destination.

Every DVR root must be in ReadWritePaths

The shipped systemd unit sets ProtectSystem=strict, which makes the entire filesystem read-only to the service. Only the paths named in ReadWritePaths= can be written. A DVR root that is not listed there fails on its first write, and the destination is marked failed with the reason destination root is read-only — which is accurate but looks nothing like the configuration mistake it actually is.

The unit ships with this line:

ReadWritePaths=/var/lib/ettix-stream /etc/ettix-stream

install.sh asks the binary which DVR roots the configuration names and appends them to that line, and creates each root owned by the service account. It can only do that for the roots that exist in the configuration at the moment it runs. A destination you add later is yours to add to the unit. The server cannot do it for you: it is not permitted to edit its own service unit, and it would not help if it were, because the sandbox is applied when the process starts.

Adding a destination that was not there at install time

1. Ask the binary which roots the configuration now names. This prints one absolute path per line and nothing else, so there is no guessing and no reading JSON by eye.

ettix-stream config check --config /etc/ettix-stream/config.json --dvr-roots

2. Create the directory, owned by the service account. The server runs as ettix-stream and cannot create a directory on a read-only filesystem or under a root it does not own. Mode 0750 matches what the installer creates: recordings may contain private material, so nothing here is world-readable.

install -d -m 0750 -o ettix-stream -g ettix-stream /mnt/dvr2

3. Add the root to the unit with a drop-in. ReadWritePaths accumulates across drop-in files, so this adds /mnt/dvr2 to the paths the shipped unit already lists rather than replacing them. A drop-in is used rather than editing the unit itself because re-running install.sh for an upgrade replaces /etc/systemd/system/ettix-stream.service outright; a drop-in survives that.

mkdir -p /etc/systemd/system/ettix-stream.service.d
cat > /etc/systemd/system/ettix-stream.service.d/dvr-paths.conf <<'EOF'
[Service]
ReadWritePaths=/mnt/dvr2
EOF

4. Reload systemd and restart the service. daemon-reload alone is not enough. The sandbox — including the read-only mount — is built when the process starts, so the running process keeps the old set of writable paths until it is restarted.

systemctl daemon-reload
systemctl restart ettix-stream

5. Confirm the process really has the path. The first command shows the effective value after drop-ins; the second shows the destination reporting itself as active once the stream is live.

systemctl show ettix-stream --property=ReadWritePaths
curl -H "Authorization: …" http://127.0.0.1:8081/api/v1/streams/channel1/dvr

If you would rather not maintain the unit by hand, re-running ./install.sh from the unpacked release directory regenerates the ReadWritePaths line from the current configuration, creates any missing roots and restarts the service. That is a heavier operation than a drop-in — it also reinstalls the binary — but it is the same mechanism, and it cannot forget a root, because it reads the list from the configuration.

The same rule is why the unit lists /etc/ettix-stream: the administration API writes the configuration file back, and without that entry every save from the interface fails with read-only file system.

How a segment is written: queue_segments, sync and sync_interval

Each destination has a bounded queue between the media path and its writer. When an HLS segment is complete it is handed to the queue and the media path carries straight on; the writer picks it up, writes the file and appends the index record. The queue is the reason a slow disk cannot slow a broadcast.

FieldDefaultWhat it does
queue_segments32How many completed segments may wait to be written. When the queue is full the segment is dropped, counted in segments_dropped, and the destination is marked degraded. It is never allowed to block the media path or grow without limit. At four-second segments, 32 is roughly two minutes of slack for a disk that stalls.
syncintervalsegment, interval or none — see below.
sync_interval1sThe syncer period when sync is interval. Between 100ms and 1m.

A segment file is written to a temporary name, optionally flushed, then renamed into place. The rename is atomic, so a reader never sees half a file and a crash leaves either the whole segment or nothing. Segment files are never appended to or rewritten after that rename.

The sync setting decides how much a sudden power loss can cost:

syncBehaviourCost of losing power
segmentfdatasync on every segment file, and on its directory after the rename.Nothing already written is lost. Costs one flush per segment per destination.
interval (default)A background syncer marks a flush due every sync_interval; the next segment written after that is flushed, and the index journal is flushed on the same tick.At most about one sync_interval of recording.
noneNothing is flushed explicitly; the kernel writes back when it chooses.Whatever the page cache was still holding.

interval is the default because it is the honest trade for most hosts. A process crash — kill -9, an out-of-memory kill, a panic — loses nothing at any of the three settings, because the data is already in the kernel. Only losing the machine itself costs anything, and that is what segment protects against. Use segment when the host can lose power without a battery-backed write cache and the recording is the product; the price is a flush per segment.

Retention: max_age and the rest

Retention is per destination, so two destinations of the same stream can keep different amounts of it. All four rules are optional and they combine.

FieldDefaultMeaning
max_age0 (off)Delete segments whose start time is older than this, oldest first.
max_bytes0 (off)Cap what this stream occupies on this destination. Accepts "4TB", "500GiB" or a plain number of bytes.
min_free_percent0 (off)Keep at least this percentage of the medium free, deleting oldest first. 0–99.
min_free_bytes0 (off)The absolute form of the same rule. When both are set the larger requirement wins.
retention_interval60sHow often retention evaluates the rules. Between 1s and 1h.

Durations are written the way Go writes them: "168h", "90m", "30s". There is no day unit"7d" is not a valid duration and the configuration will be rejected. Seven days is "168h", thirty days is "720h".

Each tick, retention works out which segments any configured rule selects, skips the ones that are protected, and deletes at most 200 of them. Anything left over is picked up on the next tick, so a large backlog — a max_age that was just shortened, for instance — drains over several minutes instead of freezing the process in one sweep. It works entirely from the index and never scans directories, so its cost does not grow with the size of the archive.

Two things always outrank every deletion rule: a hold, which is a journalled, persistent protection over a time range that survives restarts (and is permanent if you give it no expiry), and an active read lease, which a playback session or an MP4 export takes over the range it is reading so that retention cannot delete material out from under it. A leased segment is not skipped forever; its deletion is simply deferred to a later tick.

Where several streams record to the same medium, the free-space rule deletes in global oldest-first order across those streams rather than each stream trimming only itself — the disk is shared, so the fair thing to free is the oldest material on it, whoever recorded it.

With no retention rules configured, retention does nothing at all and the destination fills until the filesystem is full. That is deliberate — the server will not invent a policy for deleting your recordings — but it means an unconfigured destination is a disk that will one day stop accepting writes. Set at least min_free_percent on every destination you are not managing by hand.

What the recording looks like on disk

One destination root holds a marker file and one directory per stream:

/mnt/dvr1/
  ettix-dvr.json                              destination marker (format version, id, server, created)
  channel1/
    _index/
      000000000001.journal                    append-only index journals
      000000000001.ckpt                       checkpoint covering journals up to 1
    2026/09/06/21/                            UTC year/month/day/hour
      000000001234-1757195400123.ts           one recorded segment
      000000001235-1757195404123.ts
      .tmp-000000001236-1757195408123.ts      an interrupted write; deleted at startup

Hour directories are YYYY/MM/DD/HH in UTC, and they are created on demand and removed by retention when they empty. A segment file name is a twelve-digit recording sequence number, a hyphen, the segment's start time as thirteen digits of Unix milliseconds, and .ts — or .tse when the destination encrypts at rest. The sequence number is the destination's own counter, persisted in the index; it is not the live HLS media sequence, which restarts with every process run.

The layout is fixed and mechanical on purpose, and that is worth more than it first appears:

The directory tree belongs to the server while it is running. Do not delete, move or rewrite files under a live destination: the recorder owns the index, and files that vanish underneath it are reported as missing and tombstoned. To remove material, use the retention rules or DELETE /api/v1/streams/{id}/dvr?from=&to=, which updates the index as it goes. Files whose names this format did not produce are treated as foreign and are never touched, so anything of your own that you leave in the tree is safe.

The index, dvr verify and dvr repair

Each stream on each destination keeps an index under _index/: append-only journals recording every segment written, every deletion, every hold and every session boundary, plus checkpoints that summarise the journals so startup does not have to replay them all. It exists so that "what do you have between 06:00 and 07:00 yesterday?" is a binary search rather than a directory walk, and so the answer stays fast at millions of segments.

The index is a cache of what the media already says, not the record of truth. At startup the server loads the newest checkpoint that verifies, replays the journals after it, truncates a corrupt journal tail (keeping the discarded bytes as <journal>.corrupt-<time> for diagnosis), then reconciles against the files actually present: media on disk that the index does not know about is adopted, index entries whose file has gone are tombstoned, and leftover .tmp- files are removed. Ordinary recovery after a crash or a power cut is automatic and needs nothing from you.

dvr verify

Verify is read-only. It never writes, deletes or repairs anything, so it is safe to run against a destination the server is using.

ettix-stream dvr verify --path /mnt/dvr1 --stream channel1
ettix-stream dvr verify --path /mnt/dvr1 --stream channel1 --deep
ettix-stream dvr verify --path /mnt/dvr1 --stream channel1 --json

The fast pass checks the checkpoint and journal checksums, and every indexed segment's existence and size. It reports missing media, size mismatches, orphan files (media with no index entry), foreign files, invalid paths and corrupt journals. --deep additionally reads every segment: it checks transport-stream framing and timestamps, and compares each file against the SHA-256 recorded with it when it was written.

Verify exits non-zero when it finds problems, so it can gate a cron job or a deployment step.

The digest detects corruption, not tampering. It lives in the journal on the same volume as the media, so anyone able to rewrite one can rewrite the other. It answers "are these the bytes that were recorded?", which is the question a failing disk or a bad sector raises. Encrypted (.tse) segments already have the stronger property from their own authentication tag.

Segments recorded by a build that predates content digests are counted and reported as "no digest", never as failures, and are re-digested naturally as retention replaces them. Nothing needs to be done about them.

dvr repair

Repair rebuilds the index from the media on disk: it truncates corrupt journal tails, adopts orphaned media files, tombstones index entries whose media has gone, and writes a fresh checkpoint.

systemctl stop ettix-stream
sudo -u ettix-stream ettix-stream dvr repair --path /mnt/dvr1 --stream channel1
systemctl start ettix-stream

Two constraints on that command. Run it with the service stopped, or against a destination the server is not writing to: while it runs, the recorder owns the index, and two writers on one journal is not a supported state. And run it as the service account, so the index objects it writes stay owned by the user the server runs as.

Repair never deletes or modifies a media file. It reads the media and writes index data. If verify reports a truncated or corrupt segment, repair will not make it go away — the media is what is wrong, and it keeps being reported so that you decide what to do with it. A payload that no longer matches its digest is restored from a backup or accepted as lost; there is nothing else that can be done with it, and the tool says so rather than quietly rewriting the record to match the damage.

Verify and repair are the whole maintenance toolset in this build. The dvr inspect, dvr rebuild and dvr migrate commands described in the on-disk format document are not implemented here; repair performs a full rescan of the media, which is what rebuild would be used for.

When a destination fills, goes read-only or disappears

Every destination reports a state, visible on the DVR page of the administration interface, in GET /api/v1/streams/{id}/dvr, and in the log on every transition (journalctl -u ettix-stream | grep -i "dvr destination").

StateMeaning
idleConfigured and not currently recording — the stream is offline, outside a schedule window, or in api mode without a start.
activeAccepting writes.
degradedThe write queue overflowed, or a write failed for a reason that is not one of the hard cases below. Segments are being lost.
fullThe filesystem returned "no space left on device" and retention could not free more.
failedThe root is unusable. The reason field distinguishes them: destination root is missing (unmounted or deleted), destination root is read-only (an unlisted ReadWritePaths entry, or a filesystem remounted read-only after an error), destination root is not writable (ownership or mode), unsupported on-disk format.

Whichever of those happens, four things are true, and they are the reason the DVR is built the way it is:

What you do lose is the material that arrived while the destination was unavailable. That is counted, not guessed: segments_dropped and write_errors on the destination status say how much, and last_error and last_error_at say what happened and when.

curl -H "Authorization: …" http://127.0.0.1:8081/api/v1/streams/channel1/dvr
curl -H "Authorization: …" http://127.0.0.1:8081/api/v1/disks

The first reports every destination of one stream: state, reason, queue depth and capacity, segments and bytes written, segments dropped, write errors, write latency, the filesystem's total, free and used bytes, the oldest and newest recording it holds, and the last error. The second reports capacity for every configured destination on the server, which is the one to alert on: a destination near full is a problem you want to hear about before it becomes full.

Playing a recording back

Recorded material is served from the playback listener under /dvr/, with the same viewer authorisation as live playback:

GET /dvr/{stream}/index.m3u8?from=<t>&to=<t>   a closed historical range
GET /dvr/{stream}/index.m3u8?from=<t>            from that point into the live edge
GET /dvr/{stream}/bounds                          the range a viewer may scrub to
GET /dvr/{stream}/{sequence}.ts                   one recorded segment
GET /dvr/{stream}/export.mp4?from=<t>&to=<t>   MP4 download of a range

from and to accept RFC 3339 (2026-09-07T06:00:00Z), Unix seconds, Unix milliseconds, or a negative duration relative to now (-30m). Omitting to selects timeshift, which joins the history onto the live window so a player can run from the past into the present without changing playlist.

When a stream has several destinations, a playlist is answered from the one holding most of the requested range, preferring healthy destinations over degraded or full ones. The playlist and segment routes take no destination parameter, so "what exactly is on this disk?" — the question you want answered before decommissioning one — is answered with ettix-stream dvr verify --path /mnt/dvr1 --stream channel1 rather than with a playlist request. One route does name a destination: export.mp4 takes ?destination=<id>, which restricts the read to exactly that destination and returns nothing when the name matches none, rather than falling back to another disk.

export.mp4 is off by default and is enabled per stream with dvr.export.enabled. It remuxes a recorded range into one progressive MP4 without transcoding, decrypting an encrypted destination as it reads, and it never writes a temporary file. It is off by default because one request can read an arbitrarily long span from the same disks the recorder is writing to; export.max_range (4h by default, 24h maximum), export.max_segments and export.max_concurrent bound that. Exports hold a read lease over their range, so retention cannot delete material an export has not reached yet.

What uninstalling does not delete

./install.sh --uninstall stops and disables the service, removes the unit, removes the binary, and removes /etc/ettix-stream. It deliberately leaves two things behind:

Left in placeWhy
Every DVR root in the configurationYour recordings. The installer has never deleted a recording and does not.
/var/lib/ettix-streamThe installation identity, the licence lease, and the encryption master key. Delete this and every encrypted recording becomes permanently unreadable — the key is not held anywhere else, by anyone, including Ettix.

Removing them is a separate, explicit act, and it should be, because both are irreversible at exactly the moment an operator is least expecting it. Note that uninstalling removes /etc/ettix-stream and with it config.json — which is the only record of which paths your DVR roots were. Copy that file before you uninstall if you intend to keep the recordings.

The same applies at a smaller scale: removing a destination from the configuration stops writing to it and leaves the tree exactly as it stands. Deleting recordings is always something you do on purpose.