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.
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.
modemode | Records |
|---|---|
always (default) | whenever the stream is live. |
schedule | only inside the windows in schedule[]. At least one window is required. |
api | only 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"}). |
off | never, 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.
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:
| Why | Shape |
|---|---|
| Survive losing a disk | Two roots on two different filesystems, the same retention on each. Losing one leaves a complete recording on the other. |
| Two retention horizons | A 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 gap | Add 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.
ReadWritePathsThe 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.
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.
queue_segments, sync and sync_intervalEach 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.
| Field | Default | What it does |
|---|---|---|
queue_segments | 32 | How 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. |
sync | interval | segment, interval or none — see below. |
sync_interval | 1s | The 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:
sync | Behaviour | Cost of losing power |
|---|---|---|
segment | fdatasync 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. |
none | Nothing 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.
max_age and the restRetention is per destination, so two destinations of the same stream can keep different amounts of it. All four rules are optional and they combine.
| Field | Default | Meaning |
|---|---|---|
max_age | 0 (off) | Delete segments whose start time is older than this, oldest first. |
max_bytes | 0 (off) | Cap what this stream occupies on this destination. Accepts "4TB", "500GiB" or a plain number of bytes. |
min_free_percent | 0 (off) | Keep at least this percentage of the medium free, deleting oldest first. 0–99. |
min_free_bytes | 0 (off) | The absolute form of the same rule. When both are set the larger requirement wins. |
retention_interval | 60s | How 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.
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:
ls, find, rsync or tar,
without the server running and without any tool from Ettix.ls and shell globs give the right answer.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.
dvr verify and dvr repairEach 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 verifyVerify 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 repairRepair 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.
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").
| State | Meaning |
|---|---|
idle | Configured and not currently recording — the stream is offline, outside a schedule window, or in api mode without a start. |
active | Accepting writes. |
degraded | The write queue overflowed, or a write failed for a reason that is not one of the hard cases below. Segments are being lost. |
full | The filesystem returned "no space left on device" and retention could not free more. |
failed | The 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:
active,
logs that it is healthy, and carries on. The gap is marked in the recording as a
discontinuity so playback across it is correct rather than silently
misaligned.DELETE through the API or the administration interface, and your own
hands on the filesystem.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.
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.
./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 place | Why |
|---|---|
| Every DVR root in the configuration | Your recordings. The installer has never deleted a recording and does not. |
/var/lib/ettix-stream | The 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.