- Golang-based video-server for re-streaming RTSP to HLS/MSE
Simple WS/HTTP server for re-streaming video (RTSP) to client in MSE/HLS format.
It is highly inspired by https://github.com/deepch and his projects. So why am I trying to reinvent the wheel? Well, I'm just trying to fit my needs.
Download the archive for your platform from the latest release:
| Platform | Archive |
|---|---|
| Linux amd64 | linux-amd64-video_server.tar.gz |
| Linux arm64 | linux-arm64-video_server.tar.gz |
| macOS amd64 | darwin-amd64-video_server.tar.gz |
| macOS arm64 | darwin-arm64-video_server.tar.gz |
| Windows amd64 | windows-amd64-video_server.zip |
Each archive contains video_server or video_server.exe. Extract it into a directory in your PATH.
Quick installation on Linux amd64:
curl -fsSL https://github.com/LdDl/video-server/releases/latest/download/linux-amd64-video_server.tar.gz \
| sudo tar -xz -C /usr/local/bin video_serverFor Linux arm64, replace linux-amd64 with linux-arm64. On macOS, use darwin-amd64 for Intel or darwin-arm64 for Apple Silicon.
If you would rather run the server in a container, see Docker.
go install github.com/LdDl/video-server/cmd/video_server@latestOr clone the repository and build it yourself:
git clone https://github.com/LdDl/video-server.git
cd video-server
go build -o video_server ./cmd/video_servervideo_server -h-conf string
Path to configuration either TOML-file or JSON-file (default "conf.toml")
-cpuprofile file
write cpu profile to file
-memprofile file
write memory profile to filePrepare configuration file (example here). Then run binary:
video_server --conf=conf.tomlFor HLS-based player go to hls-subdirectory.
For MSE-based (websockets) player go to mse-subdirectory.
Then follow this set of commands:
npm install
export NODE_OPTIONS=--openssl-legacy-provider
npm run devYou will se something like this after succesfull fron-end start:
DONE Compiled successfully in 1783ms 12:09:30 PM
App running at:
- Local: http://localhost:8080/ Paste link to the browser and check if video loaded successfully.
The image is published as dimahkiin/video_server.
The server does not read environment variables, so a configuration file has to be mounted into the container. Both servers must listen on 0.0.0.0 inside it, otherwise the published ports stay unreachable:
[api]
host = "0.0.0.0"
port = 8091
[video]
host = "0.0.0.0"
port = 8090Start the latest image with that configuration file:
docker run --rm --name video_server --stop-timeout 30 \
-p 127.0.0.1:8090:8090 -p 127.0.0.1:8091:8091 \
--mount type=bind,source="$(pwd)/conf.toml",target=/app/conf.toml,readonly \
-v video_server_hls:/app/hls \
-v video_server_mp4:/app/mp4 \
docker.io/dimahkiin/video_server:latest -conf /app/conf.tomlPort 8090 serves MSE websockets and HLS files, port 8091 serves the REST API. To accept connections from other hosts, drop the 127.0.0.1: prefix from the port mappings. The named volumes keep HLS segments and archive files across container recreation; skip them when neither HLS nor recording is enabled.
From the repository root, use the published image with the example configuration:
VIDEO_SERVER_IMAGE=dimahkiin/video_server:latest \
docker compose up --no-build --pull alwaysVIDEO_SERVER_CONFIG selects the configuration file (use an absolute path for a file outside the repository), VIDEO_SERVER_PORT and VIDEO_SERVER_API_PORT change the published host ports, and VIDEO_SERVER_BIND_HOST changes the host bind address. The video_server_hls and video_server_mp4 volumes survive container recreation.
VIDEO_SERVER_IMAGE=dimahkiin/video_server:latest \
VIDEO_SERVER_CONFIG=/etc/video_server/conf.toml \
VIDEO_SERVER_BIND_HOST=0.0.0.0 \
docker compose up -d --no-build --pull alwaysThe docker-compose.yaml file is unrelated to the server itself: it starts a MinIO instance for the archive, see MinIO Configuration.
By default every configured stream is pulled from its source all the time, whether anybody is watching or not. For a fleet of cameras that nobody looks at most of the day this is a lot of wasted inbound traffic and CPU.
With on-demand mode the upstream connection (RTSP session or local file reader) is opened when the first viewer arrives and closed a little while after the last one leaves:
- MSE: the WebSocket connection is the viewer. The first connection dials the source, the last disconnect arms the idle timer.
- HLS: there is no persistent connection, so every playlist/segment request counts as activity. The stream stays alive while requests keep coming. The very first playlist request of a cold stream waits for the first segment to be cut (about two
ms_per_segment), so expect a start-up delay of that order. - Recording: a stream with
archive.recording = trueis never released, otherwise the archive would have gaps.
[on_demand]
# default for all streams
enabled = true
# keep the upstream alive this long after the last viewer
idle_ms = 30000
# probe idle sources so that `online` stays meaningful
health_check = true
health_interval_ms = 30000
health_timeout_ms = 3000
[[rtsp_streams]]
guid = "..."
url = "rtsp://..."
output_types = ["mse"]
# per-stream override: this one is always on
on_demand = falseThe first viewer of a cold stream waits for the source to answer (typically 1-3 seconds for an RTSP camera) before video starts.
An idle on-demand stream is not connected, so the server would not know whether the camera is alive. When health_check is enabled, idle sources are probed every health_interval_ms with a plain RTSP DESCRIBE (Basic and Digest auth are supported). No PLAY is sent, so the camera never starts streaming media for a probe. Local-file sources are checked for file existence. Streams with a live upstream are not probed: the session itself is the health signal.
GET /status reports, per stream:
on_demand- whether the stream is lazily connectedonline- last known reachability of the source (from the live session or from the health probe)streaming- whether the upstream loop is running right nowviewers- number of attached MSE clientslast_health_check,last_health_error- when the source was last checked and what went wrong, if anythingstatus- kept for compatibility:truewhile a live session has codec data
online = true, streaming = false, viewers = 0 is the normal state of a healthy camera that nobody is watching.
You can configure application to write MP4 chunks of custom duration (but not less than first keyframe duration) to the filesystem or S3 MinIO. The archive system supports two independent modes: recording (writing new segments) and serving (playback of existing archive files).
The archive configuration has two separate flags:
recording- Enables writing new archive segments. Whentrue, the server will continuously record video stream chunks to storage.serving- Enables playback of existing archive files via WebSocket. Whentrue, clients can request archive playback even if recording is disabled.
This allows flexible deployment scenarios:
recording = true, serving = true- Full archive functionality (record and playback)recording = true, serving = false- Record only, no playback APIrecording = false, serving = true- Playback only from existing archive files (useful for read-only archive access)recording = false, serving = false- Archive completely disabled
Global archive settings provide defaults for all streams:
[archive]
recording = true
serving = true
directory = "./mp4"
ms_per_file = 30000Each stream can override global settings. Per-stream settings take precedence over global configuration.
For filesystem storage:
[[rtsp_streams]]
# ...
# Some other single stream props
# ...
archive = { recording = true, ms_per_file = 20000, type = "filesystem", directory = "custom_folder" }For S3 MinIO storage:
[[rtsp_streams]]
# ...
# Some other single stream props
# ...
archive = { recording = true, ms_per_file = 20000, type = "minio", directory = "custom_folder", minio_bucket = "vod-bucket", minio_path = "/var/archive_data_custom" }When a client requests archive playback:
- The server first checks per-stream archive configuration
- If per-stream storage is not available, it falls back to global archive settings
- Global
servingflag must betruefor fallback to work
This allows you to:
- Record with per-stream settings but serve using global directory
- Serve pre-existing archive files without per-stream configuration
For storing archive to S3 MinIO, configure both filesystem (for temporary files) and MinIO settings:
[archive]
recording = true
serving = true
directory = "./mp4"
ms_per_file = 30000
minio_settings = { host = "localhost", port = 29199, user = "minio_secret_login", password = "minio_secret_password", default_bucket = "archive-bucket", default_path = "/var/archive_data" }To install MinIO you can use ./docker-compose.yaml or [./scripts/minio-ansible.yml](Ansible script) for example of deployment workflows.
The server provides a REST API endpoint to query available archive time ranges:
GET /archive/:stream_id/ranges
Response example:
{
"stream_id": "0742091c-19cd-4658-9b4f-5320da160f45",
"ranges": [
{
"start": "2025-12-20T10:00:00Z",
"end": "2025-12-20T12:30:00Z"
},
{
"start": "2025-12-20T14:00:00Z",
"end": "2025-12-20T18:00:00Z"
}
]
}GIN web-framework - https://github.com/gin-gonic/gin. License is MIT
Media library - http://github.com/deepch/vdk. License is MIT.
UUID generation and parsing - https://github.com/google/uuid. License is BSD 3-Clause
Websockets - https://github.com/gorilla/websocket. License is BSD 2-Clause
m3u8 library - https://github.com/grafov/m3u8. License is BSD 3-Clause
Working with mp4 files - https://github.com/Eyevinn/mp4ff. License is MIT
errors wrapping - https://github.com/pkg/errors . License is BSD 2-Clause
You can check it here
Roman - https://github.com/webver
Pavel - https://github.com/Pavel7824
Dimitrii Lopanov - https://github.com/LdDl
Morozka - https://github.com/morozka