push sheeet
Some checks failed
Periodic Merges (6h) / master → staging-nixos (push) Failing after 12m50s
Periodic Merges (6h) / master → staging-next (push) Failing after 12m54s
Periodic Merges (24h) / merge-base(master,staging) → haskell-updates (push) Failing after 11m54s
Periodic Merges (6h) / staging-next → staging (push) Failing after 12m13s
Periodic Merges (24h) / staging-next-25.05 → staging-25.05 (push) Failing after 13m24s
Periodic Merges (24h) / release-25.05 → staging-next-25.05 (push) Failing after 14m28s

This commit is contained in:
Dark Steveneq
2025-10-09 14:15:47 +02:00
commit 646b892680
49168 changed files with 5897842 additions and 0 deletions

View File

@@ -0,0 +1,274 @@
{
lib,
pkgs,
config,
...
}:
let
inherit (lib)
mkIf
mkEnableOption
mkPackageOption
mkOption
attrNames
types
match
optional
optionals
toInt
last
splitString
allUnique
concatStringsSep
all
filter
mapAttrs
any
getExe
maintainers
;
inherit (cfg) settings;
cfg = config.services.broadcast-box;
addressToPort = address: toInt (last (splitString ":" address));
httpPort = cfg.web.port;
tcpMuxPort = addressToPort settings.TCP_MUX_ADDRESS;
httpRedirect = settings.ENABLE_HTTP_REDIRECT or (settings.HTTPS_REDIRECT_PORT != null);
udpPorts =
optional (settings.UDP_MUX_PORT != null) settings.UDP_MUX_PORT
++ optional (settings.UDP_WHEP_PORT != null) settings.UDP_WHEP_PORT
++ optional (settings.UDP_WHIP_PORT != null) settings.UDP_WHIP_PORT;
tcpPorts = optional (settings.TCP_MUX_ADDRESS != null) tcpMuxPort;
webPorts = [ httpPort ] ++ optional httpRedirect settings.HTTPS_REDIRECT_PORT;
in
{
options.services.broadcast-box = {
enable = mkEnableOption "Broadcast Box";
package = mkPackageOption pkgs "broadcast-box" { };
web = {
host = mkOption {
type = types.str;
default = "";
example = "127.0.0.1";
description = ''
Host address the HTTP server listens on. By default the server
listens on all interfaces.
'';
};
port = mkOption {
type = types.port;
default = 8080;
description = ''
Port the HTTP server listens on.
'';
};
openFirewall = mkEnableOption ''
opening the HTTP server port and, if enabled, the HTTPS redirect server
port in the firewall.
'';
};
openFirewall = mkEnableOption ''
opening WebRTC traffic ports in the firewall. Randomly selected ports
will not be opened.
'';
settings = mkOption {
visible = "shallow";
type = types.submodule {
freeformType =
with types;
attrsOf (
nullOr (oneOf [
bool
int
str
])
);
options = {
TCP_MUX_ADDRESS = mkOption {
type = with types; nullOr (strMatching ".*:[0-9]+");
default = null;
};
DISABLE_STATUS = mkOption {
type = types.bool;
default = true;
};
UDP_MUX_PORT = mkOption {
type = with types; nullOr port;
default = null;
};
UDP_WHEP_PORT = mkOption {
type = with types; nullOr port;
default = null;
};
UDP_WHIP_PORT = mkOption {
type = with types; nullOr port;
default = null;
};
ENABLE_HTTP_REDIRECT = mkOption {
type = types.bool;
default = false;
};
HTTPS_REDIRECT_PORT = mkOption {
type = with types; nullOr port;
default = if settings.ENABLE_HTTP_REDIRECT then 80 else null;
};
};
};
default = {
DISABLE_STATUS = true;
};
example = {
DISABLE_STATUS = true;
INCLUDE_PUBLIC_IP_IN_NAT_1_TO_1_IP = true;
UDP_MUX_PORT = 3000;
};
description = ''
Attribute set of environment variables.
<https://github.com/Glimesh/broadcast-box#environment-variables>
:::{.warning}
The status API exposes stream keys so {env}`DISABLE_STATUS` is enabled
by default.
:::
'';
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = !(settings ? HTTP_ADDRESS);
message = ''
The Broadcast Box `HTTP_ADDRESS` variable should not be used. Instead
use the `host` and `port` options.
'';
}
{
assertion = httpRedirect -> settings ? SSL_CERT && settings ? SSL_KEY;
message = ''
The Broadcast Box `ENABLE_HTTP_REDIRECT` variable requires `SSL_CERT`
and `SSL_KEY` to be configured.
'';
}
{
assertion = httpRedirect -> httpPort == 443;
message = ''
Broadcast Box HTTP redirect only works if the HTTP server listen port
is 443.
'';
}
{
assertion = allUnique (tcpPorts ++ webPorts);
message = ''
Broadcast Box configuration contains duplicate TCP ports.
'';
}
{
assertion = all (name: (match "[A-Z0-9_]+" name) != null) (attrNames settings);
message =
let
offenders = filter (name: (match "[A-Z0-9_]+" name) == null) (attrNames settings);
in
''
Broadcast Box `settings` attribute names must be in uppercase snake
case. Invalid attribute name(s): `${concatStringsSep ", " offenders}`
'';
}
];
systemd.services.broadcast-box = {
description = "Broadcast Box";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
startLimitBurst = 3;
startLimitIntervalSec = 180;
environment =
(mapAttrs (
_: value:
if (builtins.typeOf value == "bool") then
if !value then null else "true"
else if (builtins.typeOf value == "int") then
toString value
else
value
) cfg.settings)
// {
APP_ENV = "nixos";
HTTP_ADDRESS = cfg.web.host + ":" + toString cfg.web.port;
};
serviceConfig =
let
priviledgedPort = any (p: p > 0 && p < 1024) (udpPorts ++ tcpPorts ++ webPorts);
in
{
ExecStart = "${getExe cfg.package}";
Restart = "always";
RestartSec = "10s";
DynamicUser = true;
LockPersonality = true;
NoNewPrivileges = true;
PrivateUsers = !priviledgedPort;
PrivateDevices = true;
PrivateMounts = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ProtectControlGroups = true;
ProtectClock = true;
ProtectProc = "invisible";
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProcSubset = "pid";
RemoveIPC = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_NETLINK"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged"
];
CapabilityBoundingSet = if priviledgedPort then [ "CAP_NET_BIND_SERVICE" ] else "";
AmbientCapabilities = mkIf priviledgedPort [ "CAP_NET_BIND_SERVICE" ];
DeviceAllow = "";
MemoryDenyWriteExecute = true;
UMask = "0077";
};
};
networking.firewall = {
allowedTCPPorts = optionals cfg.openFirewall tcpPorts ++ optionals cfg.web.openFirewall webPorts;
allowedUDPPorts = optionals cfg.openFirewall udpPorts;
};
};
meta.maintainers = with maintainers; [ JManch ];
}

View File

@@ -0,0 +1,401 @@
{
config,
lib,
options,
pkgs,
...
}:
let
cfg = config.services.epgstation;
opt = options.services.epgstation;
description = "EPGStation: DVR system for Mirakurun-managed TV tuners";
username = config.users.users.epgstation.name;
groupname = config.users.users.epgstation.group;
mirakurun = {
sock = config.services.mirakurun.unixSocket;
option = options.services.mirakurun.unixSocket;
};
yaml = pkgs.formats.yaml { };
settingsTemplate = yaml.generate "config.yml" cfg.settings;
preStartScript = pkgs.writeScript "epgstation-prestart" ''
#!${pkgs.runtimeShell}
DB_PASSWORD_FILE=${lib.escapeShellArg cfg.database.passwordFile}
if [[ ! -f "$DB_PASSWORD_FILE" ]]; then
printf "[FATAL] File containing the DB password was not found in '%s'. Double check the NixOS option '%s'." \
"$DB_PASSWORD_FILE" ${lib.escapeShellArg opt.database.passwordFile} >&2
exit 1
fi
DB_PASSWORD="$(head -n1 ${lib.escapeShellArg cfg.database.passwordFile})"
# setup configuration
touch /etc/epgstation/config.yml
chmod 640 /etc/epgstation/config.yml
sed \
-e "s,@dbPassword@,$DB_PASSWORD,g" \
${settingsTemplate} > /etc/epgstation/config.yml
chown "${username}:${groupname}" /etc/epgstation/config.yml
# NOTE: Use password authentication, since mysqljs does not yet support auth_socket
if [ ! -e /var/lib/epgstation/db-created ]; then
${pkgs.mariadb}/bin/mysql -e \
"GRANT ALL ON \`${cfg.database.name}\`.* TO '${username}'@'localhost' IDENTIFIED by '$DB_PASSWORD';"
touch /var/lib/epgstation/db-created
fi
'';
streamingConfig = lib.importJSON ./streaming.json;
logConfig = yaml.generate "logConfig.yml" {
appenders.stdout.type = "stdout";
categories = {
default = {
appenders = [ "stdout" ];
level = "info";
};
system = {
appenders = [ "stdout" ];
level = "info";
};
access = {
appenders = [ "stdout" ];
level = "info";
};
stream = {
appenders = [ "stdout" ];
level = "info";
};
};
};
# Deprecate top level options that are redundant.
deprecateTopLevelOption =
config:
lib.mkRenamedOptionModule
(
[
"services"
"epgstation"
]
++ config
)
(
[
"services"
"epgstation"
"settings"
]
++ config
);
removeOption =
config: instruction:
lib.mkRemovedOptionModule (
[
"services"
"epgstation"
]
++ config
) instruction;
in
{
meta.maintainers = with lib.maintainers; [ midchildan ];
imports = [
(deprecateTopLevelOption [ "port" ])
(deprecateTopLevelOption [ "socketioPort" ])
(deprecateTopLevelOption [ "clientSocketioPort" ])
(removeOption [ "basicAuth" ] "Use a TLS-terminated reverse proxy with authentication instead.")
];
options.services.epgstation = {
enable = lib.mkEnableOption description;
package = lib.mkPackageOption pkgs "epgstation" { };
ffmpeg = lib.mkPackageOption pkgs "ffmpeg" {
default = "ffmpeg-headless";
example = "ffmpeg-full";
};
usePreconfiguredStreaming = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Use preconfigured default streaming options.
Upstream defaults:
<https://github.com/l3tnun/EPGStation/blob/master/config/config.yml.template>
'';
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Open ports in the firewall for the EPGStation web interface.
::: {.warning}
Exposing EPGStation to the open internet is generally advised
against. Only use it inside a trusted local network, or consider
putting it behind a VPN if you want remote access.
:::
'';
};
database = {
name = lib.mkOption {
type = lib.types.str;
default = "epgstation";
description = ''
Name of the MySQL database that holds EPGStation's data.
'';
};
passwordFile = lib.mkOption {
type = lib.types.path;
example = "/run/keys/epgstation-db-password";
description = ''
A file containing the password for the database named
{option}`database.name`.
'';
};
};
# The defaults for some options come from the upstream template
# configuration, which is the one that users would get if they follow the
# upstream instructions. This is, in some cases, different from the
# application defaults. Some options like encodeProcessNum and
# concurrentEncodeNum doesn't have an optimal default value that works for
# all hardware setups and/or performance requirements. For those kind of
# options, the application default wouldn't always result in the expected
# out-of-the-box behavior because it's the responsibility of the user to
# configure them according to their needs. In these cases, the value in the
# upstream template configuration should serve as a "good enough" default.
settings = lib.mkOption {
description = ''
Options to add to config.yml.
Documentation:
<https://github.com/l3tnun/EPGStation/blob/master/doc/conf-manual.md>
'';
default = { };
example = {
recPriority = 20;
conflictPriority = 10;
};
type = lib.types.submodule {
freeformType = yaml.type;
options.port = lib.mkOption {
type = lib.types.port;
default = 20772;
description = ''
HTTP port for EPGStation to listen on.
'';
};
options.socketioPort = lib.mkOption {
type = lib.types.port;
default = cfg.settings.port + 1;
defaultText = lib.literalExpression "config.${opt.settings}.port + 1";
description = ''
Socket.io port for EPGStation to listen on. It is valid to share
ports with {option}`${opt.settings}.port`.
'';
};
options.clientSocketioPort = lib.mkOption {
type = lib.types.port;
default = cfg.settings.socketioPort;
defaultText = lib.literalExpression "config.${opt.settings}.socketioPort";
description = ''
Socket.io port that the web client is going to connect to. This may
be different from {option}`${opt.settings}.socketioPort` if
EPGStation is hidden behind a reverse proxy.
'';
};
options.mirakurunPath =
with mirakurun;
lib.mkOption {
type = lib.types.str;
default = "http+unix://${lib.replaceStrings [ "/" ] [ "%2F" ] sock}";
defaultText = lib.literalExpression ''
"http+unix://''${lib.replaceStrings ["/"] ["%2F"] config.${option}}"
'';
example = "http://localhost:40772";
description = "URL to connect to Mirakurun.";
};
options.encodeProcessNum = lib.mkOption {
type = lib.types.ints.positive;
default = 4;
description = ''
The maximum number of processes that EPGStation would allow to run
at the same time for encoding or streaming videos.
'';
};
options.concurrentEncodeNum = lib.mkOption {
type = lib.types.ints.positive;
default = 1;
description = ''
The maximum number of encoding jobs that EPGStation would run at the
same time.
'';
};
options.encode = lib.mkOption {
type = with lib.types; listOf attrs;
description = "Encoding presets for recorded videos.";
default = [
{
name = "H.264";
cmd = "%NODE% ${cfg.package}/libexec/enc.js";
suffix = ".mp4";
}
];
defaultText = lib.literalExpression ''
[
{
name = "H.264";
cmd = "%NODE% config.${opt.package}/libexec/enc.js";
suffix = ".mp4";
}
]
'';
};
};
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = !(lib.hasAttr "readOnlyOnce" cfg.settings);
message = ''
The option config.${opt.settings}.readOnlyOnce can no longer be used
since it's been removed. No replacements are available.
'';
}
];
environment.etc = {
"epgstation/epgUpdaterLogConfig.yml".source = logConfig;
"epgstation/operatorLogConfig.yml".source = logConfig;
"epgstation/serviceLogConfig.yml".source = logConfig;
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = with cfg.settings; [
port
socketioPort
];
};
users.users.epgstation = {
description = "EPGStation user";
group = config.users.groups.epgstation.name;
isSystemUser = true;
# NPM insists on creating ~/.npm
home = "/var/cache/epgstation";
};
users.groups.epgstation = { };
services.mirakurun.enable = lib.mkDefault true;
services.mysql = {
enable = lib.mkDefault true;
package = lib.mkDefault pkgs.mariadb;
ensureDatabases = [ cfg.database.name ];
# FIXME: enable once mysqljs supports auth_socket
# https://github.com/mysqljs/mysql/issues/1507
#
# ensureUsers = [ {
# name = username;
# ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; };
# } ];
};
services.epgstation.settings =
let
defaultSettings = {
dbtype = lib.mkDefault "mysql";
mysql = {
socketPath = lib.mkDefault "/run/mysqld/mysqld.sock";
user = username;
password = lib.mkDefault "@dbPassword@";
database = cfg.database.name;
};
ffmpeg = lib.mkDefault "${cfg.ffmpeg}/bin/ffmpeg";
ffprobe = lib.mkDefault "${cfg.ffmpeg}/bin/ffprobe";
# for disambiguation with TypeScript files
recordedFileExtension = lib.mkDefault ".m2ts";
};
in
lib.mkMerge [
defaultSettings
(lib.mkIf cfg.usePreconfiguredStreaming streamingConfig)
];
systemd.tmpfiles.settings."10-epgstation" = lib.listToAttrs (
map
(
dir:
lib.nameValuePair dir {
d = {
user = username;
group = groupname;
};
}
)
[
"/var/lib/epgstation/key"
"/var/lib/epgstation/streamfiles"
"/var/lib/epgstation/drop"
"/var/lib/epgstation/recorded"
"/var/lib/epgstation/thumbnail"
"/var/lib/epgstation/db/subscribers"
"/var/lib/epgstation/db/migrations/mysql"
"/var/lib/epgstation/db/migrations/postgres"
"/var/lib/epgstation/db/migrations/sqlite"
]
);
systemd.services.epgstation = {
inherit description;
wantedBy = [ "multi-user.target" ];
after = [
"network.target"
]
++ lib.optional config.services.mirakurun.enable "mirakurun.service"
++ lib.optional config.services.mysql.enable "mysql.service";
environment.NODE_ENV = "production";
serviceConfig = {
ExecStart = "${cfg.package}/bin/epgstation start";
ExecStartPre = "+${preStartScript}";
User = username;
Group = groupname;
CacheDirectory = "epgstation";
StateDirectory = "epgstation";
LogsDirectory = "epgstation";
ConfigurationDirectory = "epgstation";
};
};
};
}

View File

@@ -0,0 +1,140 @@
{
"urlscheme": {
"m2ts": {
"ios": "vlc-x-callback://x-callback-url/stream?url=PROTOCOL://ADDRESS",
"android": "intent://ADDRESS#Intent;package=org.videolan.vlc;type=video;scheme=PROTOCOL;end"
},
"video": {
"ios": "infuse://x-callback-url/play?url=PROTOCOL://ADDRESS",
"android": "intent://ADDRESS#Intent;package=com.mxtech.videoplayer.ad;type=video;scheme=PROTOCOL;end"
},
"download": {
"ios": "vlc-x-callback://x-callback-url/download?url=PROTOCOL://ADDRESS&filename=FILENAME"
}
},
"stream": {
"live": {
"ts": {
"m2ts": [
{
"name": "720p",
"cmd": "%FFMPEG% -re -dual_mono_mode main -i pipe:0 -sn -threads 0 -c:a aac -ar 48000 -b:a 192k -ac 2 -c:v libx264 -vf yadif,scale=-2:720 -b:v 3000k -preset veryfast -y -f mpegts pipe:1"
},
{
"name": "480p",
"cmd": "%FFMPEG% -re -dual_mono_mode main -i pipe:0 -sn -threads 0 -c:a aac -ar 48000 -b:a 128k -ac 2 -c:v libx264 -vf yadif,scale=-2:480 -b:v 1500k -preset veryfast -y -f mpegts pipe:1"
},
{
"name": "無変換"
}
],
"m2tsll": [
{
"name": "720p",
"cmd": "%FFMPEG% -dual_mono_mode main -f mpegts -analyzeduration 500000 -i pipe:0 -map 0 -c:s copy -c:d copy -ignore_unknown -fflags nobuffer -flags low_delay -max_delay 250000 -max_interleave_delta 1 -threads 0 -c:a aac -ar 48000 -b:a 192k -ac 2 -c:v libx264 -flags +cgop -vf yadif,scale=-2:720 -b:v 3000k -preset veryfast -y -f mpegts pipe:1"
},
{
"name": "480p",
"cmd": "%FFMPEG% -dual_mono_mode main -f mpegts -analyzeduration 500000 -i pipe:0 -map 0 -c:s copy -c:d copy -ignore_unknown -fflags nobuffer -flags low_delay -max_delay 250000 -max_interleave_delta 1 -threads 0 -c:a aac -ar 48000 -b:a 128k -ac 2 -c:v libx264 -flags +cgop -vf yadif,scale=-2:480 -b:v 1500k -preset veryfast -y -f mpegts pipe:1"
}
],
"webm": [
{
"name": "720p",
"cmd": "%FFMPEG% -re -dual_mono_mode main -i pipe:0 -sn -threads 3 -c:a libvorbis -ar 48000 -b:a 192k -ac 2 -c:v libvpx-vp9 -vf yadif,scale=-2:720 -b:v 3000k -deadline realtime -speed 4 -cpu-used -8 -y -f webm pipe:1"
},
{
"name": "480p",
"cmd": "%FFMPEG% -re -dual_mono_mode main -i pipe:0 -sn -threads 2 -c:a libvorbis -ar 48000 -b:a 128k -ac 2 -c:v libvpx-vp9 -vf yadif,scale=-2:480 -b:v 1500k -deadline realtime -speed 4 -cpu-used -8 -y -f webm pipe:1"
}
],
"mp4": [
{
"name": "720p",
"cmd": "%FFMPEG% -re -dual_mono_mode main -i pipe:0 -sn -threads 0 -c:a aac -ar 48000 -b:a 192k -ac 2 -c:v libx264 -vf yadif,scale=-2:720 -b:v 3000k -profile:v baseline -preset veryfast -tune fastdecode,zerolatency -movflags frag_keyframe+empty_moov+faststart+default_base_moof -y -f mp4 pipe:1"
},
{
"name": "480p",
"cmd": "%FFMPEG% -re -dual_mono_mode main -i pipe:0 -sn -threads 0 -c:a aac -ar 48000 -b:a 128k -ac 2 -c:v libx264 -vf yadif,scale=-2:480 -b:v 1500k -profile:v baseline -preset veryfast -tune fastdecode,zerolatency -movflags frag_keyframe+empty_moov+faststart+default_base_moof -y -f mp4 pipe:1"
}
],
"hls": [
{
"name": "720p",
"cmd": "%FFMPEG% -re -dual_mono_mode main -i pipe:0 -sn -map 0 -threads 0 -ignore_unknown -max_muxing_queue_size 1024 -f hls -hls_time 3 -hls_list_size 17 -hls_allow_cache 1 -hls_segment_filename %streamFileDir%/stream%streamNum%-%09d.ts -hls_flags delete_segments -c:a aac -ar 48000 -b:a 192k -ac 2 -c:v libx264 -vf yadif,scale=-2:720 -b:v 3000k -preset veryfast -flags +loop-global_header %OUTPUT%"
},
{
"name": "480p",
"cmd": "%FFMPEG% -re -dual_mono_mode main -i pipe:0 -sn -map 0 -threads 0 -ignore_unknown -max_muxing_queue_size 1024 -f hls -hls_time 3 -hls_list_size 17 -hls_allow_cache 1 -hls_segment_filename %streamFileDir%/stream%streamNum%-%09d.ts -hls_flags delete_segments -c:a aac -ar 48000 -b:a 128k -ac 2 -c:v libx264 -vf yadif,scale=-2:480 -b:v 1500k -preset veryfast -flags +loop-global_header %OUTPUT%"
}
]
}
},
"recorded": {
"ts": {
"webm": [
{
"name": "720p",
"cmd": "%FFMPEG% -dual_mono_mode main -i pipe:0 -sn -threads 3 -c:a libvorbis -ar 48000 -b:a 192k -ac 2 -c:v libvpx-vp9 -vf yadif,scale=-2:720 -b:v 3000k -deadline realtime -speed 4 -cpu-used -8 -y -f webm pipe:1"
},
{
"name": "480p",
"cmd": "%FFMPEG% -dual_mono_mode main -i pipe:0 -sn -threads 3 -c:a libvorbis -ar 48000 -b:a 128k -ac 2 -c:v libvpx-vp9 -vf yadif,scale=-2:480 -b:v 1500k -deadline realtime -speed 4 -cpu-used -8 -y -f webm pipe:1"
}
],
"mp4": [
{
"name": "720p",
"cmd": "%FFMPEG% -dual_mono_mode main -i pipe:0 -sn -threads 0 -c:a aac -ar 48000 -b:a 192k -ac 2 -c:v libx264 -vf yadif,scale=-2:720 -b:v 3000k -profile:v baseline -preset veryfast -tune fastdecode,zerolatency -movflags frag_keyframe+empty_moov+faststart+default_base_moof -y -f mp4 pipe:1"
},
{
"name": "480p",
"cmd": "%FFMPEG% -dual_mono_mode main -i pipe:0 -sn -threads 0 -c:a aac -ar 48000 -b:a 128k -ac 2 -c:v libx264 -vf yadif,scale=-2:480 -b:v 1500k -profile:v baseline -preset veryfast -tune fastdecode,zerolatency -movflags frag_keyframe+empty_moov+faststart+default_base_moof -y -f mp4 pipe:1"
}
],
"hls": [
{
"name": "720p",
"cmd": "%FFMPEG% -dual_mono_mode main -i pipe:0 -sn -map 0 -threads 0 -ignore_unknown -max_muxing_queue_size 1024 -f hls -hls_time 3 -hls_list_size 0 -hls_allow_cache 1 -hls_segment_filename %streamFileDir%/stream%streamNum%-%09d.ts -hls_flags delete_segments -c:a aac -ar 48000 -b:a 192k -ac 2 -c:v libx264 -vf yadif,scale=-2:720 -b:v 3000k -preset veryfast -flags +loop-global_header %OUTPUT%"
},
{
"name": "480p",
"cmd": "%FFMPEG% -dual_mono_mode main -i pipe:0 -sn -map 0 -threads 0 -ignore_unknown -max_muxing_queue_size 1024 -f hls -hls_time 3 -hls_list_size 0 -hls_allow_cache 1 -hls_segment_filename %streamFileDir%/stream%streamNum%-%09d.ts -hls_flags delete_segments -c:a aac -ar 48000 -b:a 128k -ac 2 -c:v libx264 -vf yadif,scale=-2:480 -b:v 1500k -preset veryfast -flags +loop-global_header %OUTPUT%"
}
]
},
"encoded": {
"webm": [
{
"name": "720p",
"cmd": "%FFMPEG% -dual_mono_mode main -ss %SS% -i %INPUT% -sn -threads 3 -c:a libvorbis -ar 48000 -b:a 192k -ac 2 -c:v libvpx-vp9 -vf scale=-2:720 -b:v 3000k -deadline realtime -speed 4 -cpu-used -8 -y -f webm pipe:1"
},
{
"name": "480p",
"cmd": "%FFMPEG% -dual_mono_mode main -ss %SS% -i %INPUT% -sn -threads 3 -c:a libvorbis -ar 48000 -b:a 128k -ac 2 -c:v libvpx-vp9 -vf scale=-2:480 -b:v 1500k -deadline realtime -speed 4 -cpu-used -8 -y -f webm pipe:1"
}
],
"mp4": [
{
"name": "720p",
"cmd": "%FFMPEG% -dual_mono_mode main -ss %SS% -i %INPUT% -sn -threads 0 -c:a aac -ar 48000 -b:a 192k -ac 2 -c:v libx264 -vf scale=-2:720 -b:v 3000k -profile:v baseline -preset veryfast -tune fastdecode,zerolatency -movflags frag_keyframe+empty_moov+faststart+default_base_moof -y -f mp4 pipe:1"
},
{
"name": "480p",
"cmd": "%FFMPEG% -dual_mono_mode main -ss %SS% -i %INPUT% -sn -threads 0 -c:a aac -ar 48000 -b:a 128k -ac 2 -c:v libx264 -vf scale=-2:480 -b:v 1500k -profile:v baseline -preset veryfast -tune fastdecode,zerolatency -movflags frag_keyframe+empty_moov+faststart+default_base_moof -y -f mp4 pipe:1"
}
],
"hls": [
{
"name": "720p",
"cmd": "%FFMPEG% -dual_mono_mode main -ss %SS% -i %INPUT% -sn -threads 0 -ignore_unknown -max_muxing_queue_size 1024 -f hls -hls_time 3 -hls_list_size 0 -hls_allow_cache 1 -hls_segment_filename %streamFileDir%/stream%streamNum%-%09d.ts -hls_flags delete_segments -c:a aac -ar 48000 -b:a 192k -ac 2 -c:v libx264 -vf scale=-2:720 -b:v 3000k -preset veryfast -flags +loop-global_header %OUTPUT%"
},
{
"name": "480p",
"cmd": "%FFMPEG% -dual_mono_mode main -ss %SS% -i %INPUT% -sn -threads 0 -ignore_unknown -max_muxing_queue_size 1024 -f hls -hls_time 3 -hls_list_size 0 -hls_allow_cache 1 -hls_segment_filename %streamFileDir%/stream%streamNum%-%09d.ts -hls_flags delete_segments -c:a aac -ar 48000 -b:a 128k -ac 2 -c:v libx264 -vf scale=-2:480 -b:v 3000k -preset veryfast -flags +loop-global_header %OUTPUT%"
}
]
}
}
}
}

View File

@@ -0,0 +1,753 @@
{
config,
lib,
pkgs,
...
}:
let
inherit (lib)
any
attrValues
converge
elem
filterAttrsRecursive
hasPrefix
literalExpression
makeLibraryPath
mkDefault
mkEnableOption
mkPackageOption
mkIf
mkOption
optionalAttrs
optionals
types
;
cfg = config.services.frigate;
format = pkgs.formats.yaml { };
filteredConfig = converge (filterAttrsRecursive (_: v: !elem v [ null ])) cfg.settings;
configFileUnchecked = format.generate "frigate.yaml" filteredConfig;
configFileChecked =
pkgs.runCommand "frigate-config"
{
preferLocalBuilds = true;
}
''
function error() {
cat << 'HEREDOC'
Note that not all configurations can be reliably checked in the
build sandbox.
This check can be disabled using `services.frigate.checkConfig`.
HEREDOC
exit 1
}
cp ${configFileUnchecked} $out
export CONFIG_FILE=$out
export PYTHONPATH=${cfg.package.pythonPath}
${cfg.package.python.interpreter} -m frigate --validate-config || error
'';
configFile = if cfg.checkConfig then configFileChecked else configFileUnchecked;
cameraFormat =
with types;
submodule {
freeformType = format.type;
options = {
ffmpeg = {
inputs = mkOption {
description = ''
List of inputs for this camera.
'';
type = listOf (submodule {
freeformType = format.type;
options = {
path = mkOption {
type = str;
example = "rtsp://192.0.2.1:554/rtsp";
description = ''
Stream URL
'';
};
roles = mkOption {
type = listOf (enum [
"audio"
"detect"
"record"
]);
example = [
"detect"
"record"
];
description = ''
List of roles for this stream
'';
};
};
});
};
};
};
};
# auth_request.conf
nginxAuthRequest = ''
# Send a subrequest to verify if the user is authenticated and has permission to access the resource.
auth_request /auth;
# Save the upstream metadata response headers from the auth request to variables
auth_request_set $user $upstream_http_remote_user;
auth_request_set $role $upstream_http_remote_role;
auth_request_set $groups $upstream_http_remote_groups;
auth_request_set $name $upstream_http_remote_name;
auth_request_set $email $upstream_http_remote_email;
# Inject the metadata response headers from the variables into the request made to the backend.
proxy_set_header Remote-User $user;
proxy_set_header Remote-Role $role;
proxy_set_header Remote-Groups $groups;
proxy_set_header Remote-Email $email;
proxy_set_header Remote-Name $name;
# Refresh the cookie as needed
auth_request_set $auth_cookie $upstream_http_set_cookie;
add_header Set-Cookie $auth_cookie;
# Pass the location header back up if it exists
auth_request_set $redirection_url $upstream_http_location;
add_header Location $redirection_url;
'';
nginxProxySettings = ''
# Basic Proxy Configuration
client_body_buffer_size 128k;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; ## Timeout if the real server is dead.
proxy_redirect http:// $scheme://;
proxy_cache_bypass $cookie_session;
proxy_no_cache $cookie_session;
proxy_buffers 64 256k;
# Advanced Proxy Configuration
send_timeout 5m;
proxy_read_timeout 360;
proxy_send_timeout 360;
proxy_connect_timeout 360;
'';
# Discover configured detectors for acceleration support
detectors = attrValues cfg.settings.detectors or { };
withCoralUSB = any (d: d.type == "edgetpu" && hasPrefix "usb" d.device or "") detectors;
withCoralPCI = any (d: d.type == "edgetpu" && hasPrefix "pci" d.device or "") detectors;
withCoral = withCoralPCI || withCoralUSB;
in
{
meta.buildDocsInSandbox = false;
options.services.frigate = with types; {
enable = mkEnableOption "Frigate NVR";
package = mkPackageOption pkgs "frigate" { };
hostname = mkOption {
type = str;
example = "frigate.exampe.com";
description = ''
Hostname of the nginx vhost to configure.
Only nginx is supported by upstream for direct reverse proxying.
'';
};
vaapiDriver = mkOption {
type = nullOr (enum [
"i965"
"iHD"
"nouveau"
"vdpau"
"nvidia"
"radeonsi"
]);
default = null;
example = "radeonsi";
description = ''
Force usage of a particular VA-API driver for video acceleration. Use together with `settings.ffmpeg.hwaccel_args`.
Setting this *is not required* for VA-API to work, but it can help steer VA-API towards the correct card if you have multiple.
:::{.note}
For VA-API to work you must enable {option}`hardware.graphics.enable` (sufficient for AMDGPU) and pass for example
`pkgs.intel-media-driver` (required for Intel 5th Gen. and newer) into {option}`hardware.graphics.extraPackages`.
:::
See also:
- <https://docs.frigate.video/configuration/hardware_acceleration>
- <https://docs.frigate.video/configuration/ffmpeg_presets#hwaccel-presets>
'';
};
checkConfig = mkOption {
type = bool;
default =
pkgs.stdenv.buildPlatform.canExecute pkgs.stdenv.hostPlatform
&& (!pkgs.stdenv.hostPlatform.isAarch64);
defaultText = literalExpression ''
pkgs.stdenv.buildPlatform.canExecute pkgs.stdenv.hostPlatform && !(pkgs.stdenv.hostPlaform.isAarch64)
'';
description = ''
Whether to check the configuration at build time.
'';
};
settings = mkOption {
type = submodule {
freeformType = format.type;
options = {
cameras = mkOption {
type = attrsOf cameraFormat;
description = ''
Attribute set of cameras configurations.
<https://docs.frigate.video/configuration/cameras>
'';
};
database = {
path = mkOption {
type = path;
default = "/var/lib/frigate/frigate.db";
description = ''
Path to the SQLite database used
'';
};
};
ffmpeg = {
path = mkOption {
type = coercedTo package toString str;
default = pkgs.ffmpeg-headless;
example = literalExpression "pkgs.ffmpeg-full";
description = ''
Package providing the ffmpeg and ffprobe executables below the bin/ directory.
'';
};
};
mqtt = {
enabled = mkEnableOption "MQTT support";
host = mkOption {
type = nullOr str;
default = null;
example = "mqtt.example.com";
description = ''
MQTT server hostname
'';
};
};
};
};
default = { };
description = ''
Frigate configuration as a nix attribute set.
See the project documentation for how to configure frigate.
- [Creating a config file](https://docs.frigate.video/guides/getting_started)
- [Configuration reference](https://docs.frigate.video/configuration/index)
'';
};
};
config = mkIf cfg.enable {
services.nginx = {
enable = true;
additionalModules = with pkgs.nginxModules; [
develkit
rtmp
secure-token
set-misc
vod
];
recommendedGzipSettings = mkDefault true;
mapHashBucketSize = mkDefault 128;
upstreams = {
frigate-api.servers = {
"127.0.0.1:5001" = { };
};
frigate-mqtt-ws.servers = {
"127.0.0.1:5002" = { };
};
frigate-jsmpeg.servers = {
"127.0.0.1:8082" = { };
};
frigate-go2rtc.servers = {
"127.0.0.1:1984" = { };
};
};
proxyCachePath."frigate" = {
enable = true;
keysZoneSize = "10m";
keysZoneName = "frigate_api_cache";
maxSize = "10m";
inactive = "1m";
levels = "1:2";
};
# Based on https://github.com/blakeblackshear/frigate/blob/v0.13.1/docker/main/rootfs/usr/local/nginx/conf/nginx.conf
virtualHosts."${cfg.hostname}" = {
locations = {
# auth_location.conf
"/auth" = {
proxyPass = "http://frigate-api/auth";
recommendedProxySettings = true;
extraConfig = ''
internal;
# Strip all request headers
proxy_pass_request_headers off;
# Pass info about the request
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Server-Port $server_port;
proxy_set_header Content-Length "";
# Pass along auth related info
proxy_set_header Authorization $http_authorization;
proxy_set_header Cookie $http_cookie;
proxy_set_header X-CSRF-TOKEN "1";
# Header used to validate reverse proxy trust
proxy_set_header X-Proxy-Secret $http_x_proxy_secret;
# Pass headers for common auth proxies
proxy_set_header Remote-User $http_remote_user;
proxy_set_header Remote-Groups $http_remote_groups;
proxy_set_header Remote-Email $http_remote_email;
proxy_set_header Remote-Name $http_remote_name;
proxy_set_header X-Forwarded-User $http_x_forwarded_user;
proxy_set_header X-Forwarded-Groups $http_x_forwarded_groups;
proxy_set_header X-Forwarded-Email $http_x_forwarded_email;
proxy_set_header X-Forwarded-Preferred-Username $http_x_forwarded_preferred_username;
proxy_set_header X-authentik-username $http_x_authentik_username;
proxy_set_header X-authentik-groups $http_x_authentik_groups;
proxy_set_header X-authentik-email $http_x_authentik_email;
proxy_set_header X-authentik-name $http_x_authentik_name;
proxy_set_header X-authentik-uid $http_x_authentik_uid;
${nginxProxySettings}
'';
};
"/vod/" = {
extraConfig = nginxAuthRequest + ''
aio threads;
vod hls;
secure_token $args;
secure_token_types application/vnd.apple.mpegurl;
add_header Cache-Control "no-store";
expires off;
keepalive_disable safari;
# vod module returns 502 for non-existent media
# https://github.com/kaltura/nginx-vod-module/issues/468
error_page 502 =404 /vod-not-found;
'';
};
"/vod-not-found" = {
return = 404;
};
"/stream/" = {
alias = "/var/cache/frigate/stream/";
extraConfig = nginxAuthRequest + ''
add_header Cache-Control "no-store";
expires off;
types {
application/dash+xml mpd;
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
image/jpeg jpg;
}
'';
};
"/clips/" = {
root = "/var/lib/frigate";
extraConfig = nginxAuthRequest + ''
types {
video/mp4 mp4;
image/jpeg jpg;
}
expires 7d;
add_header Cache-Control "public";
autoindex on;
'';
};
"/cache/" = {
alias = "/var/cache/frigate/";
extraConfig = ''
internal;
'';
};
"/recordings/" = {
root = "/var/lib/frigate";
extraConfig = nginxAuthRequest + ''
types {
video/mp4 mp4;
}
autoindex on;
autoindex_format json;
'';
};
"/exports/" = {
root = "/var/lib/frigate";
extraConfig = nginxAuthRequest + ''
types {
video/mp4 mp4;
}
autoindex on;
autoindex_format json;
'';
};
"/ws" = {
proxyPass = "http://frigate-mqtt-ws/";
recommendedProxySettings = true;
proxyWebsockets = true;
extraConfig = nginxAuthRequest + nginxProxySettings;
};
"/live/jsmpeg" = {
proxyPass = "http://frigate-jsmpeg/";
recommendedProxySettings = true;
proxyWebsockets = true;
extraConfig = nginxAuthRequest + nginxProxySettings;
};
# frigate lovelace card uses this path
"/live/mse/api/ws" = {
proxyPass = "http://frigate-go2rtc/api/ws";
proxyWebsockets = true;
recommendedProxySettings = true;
extraConfig =
nginxAuthRequest
+ nginxProxySettings
+ ''
limit_except GET {
deny all;
}
'';
};
"/live/webrtc/api/ws" = {
proxyPass = "http://frigate-go2rtc/api/ws";
proxyWebsockets = true;
recommendedProxySettings = true;
extraConfig =
nginxAuthRequest
+ nginxProxySettings
+ ''
limit_except GET {
deny all;
}
'';
};
# pass through go2rtc player
"/live/webrtc/webrtc.html" = {
proxyPass = "http://frigate-go2rtc/webrtc.html";
recommendedProxySettings = true;
extraConfig =
nginxAuthRequest
+ nginxProxySettings
+ ''
limit_except GET {
deny all;
}
'';
};
# frontend uses this to fetch the version
"/api/go2rtc/api" = {
proxyPass = "http://frigate-go2rtc/api";
recommendedProxySettings = true;
extraConfig =
nginxAuthRequest
+ nginxProxySettings
+ ''
limit_except GET {
deny all;
}
'';
};
# integrationn uses this to add webrtc candidate
"/api/go2rtc/webrtc" = {
proxyPass = "http://frigate-go2rtc/api/webrtc";
proxyWebsockets = true;
recommendedProxySettings = true;
extraConfig =
nginxAuthRequest
+ nginxProxySettings
+ ''
limit_except GET {
deny all;
}
'';
};
"~* /api/.*\\.(jpg|jpeg|png|webp|gif)$" = {
proxyPass = "http://frigate-api";
recommendedProxySettings = true;
extraConfig =
nginxAuthRequest
+ nginxProxySettings
+ ''
rewrite ^/api/(.*)$ /$1 break;
'';
};
"/api/" = {
proxyPass = "http://frigate-api/";
recommendedProxySettings = true;
extraConfig =
nginxAuthRequest
+ nginxProxySettings
+ ''
add_header Cache-Control "no-store";
expires off;
proxy_cache frigate_api_cache;
proxy_cache_lock on;
proxy_cache_use_stale updating;
proxy_cache_valid 200 5s;
proxy_cache_bypass $http_x_cache_bypass;
proxy_no_cache $should_not_cache;
add_header X-Cache-Status $upstream_cache_status;
location /api/vod/ {
${nginxAuthRequest}
proxy_pass http://frigate-api/vod/;
proxy_cache off;
add_header Cache-Control "no-store";
${nginxProxySettings}
}
location /api/login {
auth_request off;
rewrite ^/api(/.*)$ $1 break;
proxy_pass http://frigate-api;
${nginxProxySettings}
}
location /api/stats {
${nginxAuthRequest}
access_log off;
rewrite ^/api(/.*)$ $1 break;
add_header Cache-Control "no-store";
proxy_pass http://frigate-api;
${nginxProxySettings}
}
location /api/version {
${nginxAuthRequest}
access_log off;
rewrite ^/api(/.*)$ $1 break;
add_header Cache-Control "no-store";
proxy_pass http://frigate-api;
${nginxProxySettings}
}
'';
};
"/assets/" = {
root = cfg.package.web;
extraConfig = ''
access_log off;
expires 1y;
add_header Cache-Control "public";
'';
};
"/locales/" = {
root = cfg.package.web;
extraConfig = ''
access_log off;
add_header Cache-Control "public";
'';
};
"~ ^/.*-([A-Za-z0-9]+)\.webmanifest$" = {
root = cfg.package.web;
extraConfig = ''
access_log off;
expires 1y;
add_header Cache-Control "public";
default_type application/json;
proxy_set_header Accept-Encoding "";
'';
};
"/" = {
root = cfg.package.web;
tryFiles = "$uri $uri.html $uri/ /index.html";
extraConfig = ''
add_header Cache-Control "no-store";
expires off;
'';
};
};
extraConfig = ''
# Frigate wants to connect on 127.0.0.1:5000 for unauthenticated requests
# https://github.com/NixOS/nixpkgs/issues/370349
listen 127.0.0.1:5000;
# vod settings
vod_base_url "";
vod_segments_base_url "";
vod_mode mapped;
vod_max_mapping_response_size 1m;
vod_upstream_location /api;
vod_align_segments_to_key_frames on;
vod_manifest_segment_durations_mode accurate;
vod_ignore_edit_list on;
vod_segment_duration 10000;
vod_hls_mpegts_align_frames off;
vod_hls_mpegts_interleave_frames on;
# file handle caching / aio
open_file_cache max=1000 inactive=5m;
open_file_cache_valid 2m;
open_file_cache_min_uses 1;
open_file_cache_errors on;
aio on;
# file upload size
client_max_body_size 20M;
# https://github.com/kaltura/nginx-vod-module#vod_open_file_thread_pool
vod_open_file_thread_pool default;
# vod caches
vod_metadata_cache metadata_cache 512m;
vod_mapping_cache mapping_cache 5m 10m;
# gzip manifest
gzip_types application/vnd.apple.mpegurl;
'';
};
appendConfig = ''
rtmp {
server {
listen 1935;
chunk_size 4096;
allow publish 127.0.0.1;
deny publish all;
allow play all;
application live {
live on;
record off;
meta copy;
}
}
}
'';
appendHttpConfig = ''
map $sent_http_content_type $should_not_cache {
'application/json' 0;
default 1;
}
'';
};
systemd.services.nginx.serviceConfig.SupplementaryGroups = [
"frigate"
];
hardware.coral = {
usb.enable = mkDefault withCoralUSB;
pcie.enable = mkDefault withCoralPCI;
};
users.users.frigate = {
isSystemUser = true;
group = "frigate";
};
users.groups.frigate = { };
systemd.services.frigate = {
after = [
"go2rtc.service"
"network.target"
];
wantedBy = [
"multi-user.target"
];
environment = {
CONFIG_FILE = "/run/frigate/frigate.yml";
HOME = "/var/lib/frigate";
PYTHONPATH = cfg.package.pythonPath;
}
// optionalAttrs (cfg.vaapiDriver != null) {
LIBVA_DRIVER_NAME = cfg.vaapiDriver;
}
// optionalAttrs withCoral {
LD_LIBRARY_PATH = makeLibraryPath (with pkgs; [ libedgetpu ]);
};
path =
with pkgs;
[
# unfree:
# config.boot.kernelPackages.nvidiaPackages.latest.bin
libva-utils
procps
radeontop
]
++ optionals (!stdenv.hostPlatform.isAarch64) [
# not available on aarch64-linux
intel-gpu-tools
rocmPackages.rocminfo
];
serviceConfig = {
ExecStartPre = [
(pkgs.writeShellScript "frigate-clear-cache" ''
shopt -s extglob
rm --recursive --force /var/cache/frigate/!(model_cache)
'')
(pkgs.writeShellScript "frigate-create-writable-config" ''
cp --no-preserve=mode ${configFile} /run/frigate/frigate.yml
'')
];
ExecStart = "${cfg.package.python.interpreter} -m frigate";
Restart = "on-failure";
SyslogIdentifier = "frigate";
User = "frigate";
Group = "frigate";
SupplementaryGroups = [ "render" ] ++ optionals withCoral [ "coral" ];
AmbientCapabilities = optionals (elem cfg.vaapiDriver [
"i965"
"iHD"
]) [ "CAP_PERFMON" ]; # for intel_gpu_top
UMask = "0027";
StateDirectory = "frigate";
StateDirectoryMode = "0750";
# Caches
PrivateTmp = true;
CacheDirectory = [
"frigate"
# https://github.com/blakeblackshear/frigate/discussions/18129
"frigate/model_cache"
];
CacheDirectoryMode = "0750";
# Sockets/IPC
RuntimeDirectory = "frigate";
};
};
};
}

View File

@@ -0,0 +1,116 @@
{
lib,
config,
options,
pkgs,
...
}:
let
inherit (lib)
literalExpression
mkEnableOption
mkOption
mkPackageOption
types
;
cfg = config.services.go2rtc;
opt = options.services.go2rtc;
format = pkgs.formats.yaml { };
configFile = format.generate "go2rtc.yaml" cfg.settings;
in
{
meta.buildDocsInSandbox = false;
options.services.go2rtc = with types; {
enable = mkEnableOption "go2rtc streaming server";
package = mkPackageOption pkgs "go2rtc" { };
settings = mkOption {
default = { };
description = ''
go2rtc configuration as a Nix attribute set.
See the [wiki](https://github.com/AlexxIT/go2rtc/wiki/Configuration) for possible configuration options.
'';
type = submodule {
freeformType = format.type;
options = {
# https://github.com/AlexxIT/go2rtc/blob/v1.5.0/README.md#module-api
api = {
listen = mkOption {
type = str;
default = ":1984";
example = "127.0.0.1:1984";
description = ''
API listen address, conforming to a Go address string.
'';
};
};
# https://github.com/AlexxIT/go2rtc/blob/v1.5.0/README.md#source-ffmpeg
ffmpeg = {
bin = mkOption {
type = path;
default = lib.getExe pkgs.ffmpeg-headless;
defaultText = literalExpression "lib.getExe pkgs.ffmpeg-headless";
description = ''
The ffmpeg package to use for transcoding.
'';
};
};
# TODO: https://github.com/AlexxIT/go2rtc/blob/v1.5.0/README.md#module-rtsp
rtsp = {
};
streams = mkOption {
type = attrsOf (either str (listOf str));
default = { };
example = literalExpression ''
{
cam1 = "onvif://admin:password@192.168.1.123:2020";
cam2 = "tcp://192.168.1.123:12345";
}
'';
description = ''
Stream source configuration. Multiple source types are supported.
Check the [configuration reference](https://github.com/AlexxIT/go2rtc/blob/v${cfg.package.version}/README.md#module-streams) for possible options.
'';
};
# TODO: https://github.com/AlexxIT/go2rtc/blob/v1.5.0/README.md#module-webrtc
webrtc = {
};
};
};
};
};
config = lib.mkIf cfg.enable {
systemd.services.go2rtc = {
wants = [ "network-online.target" ];
after = [
"network-online.target"
];
wantedBy = [
"multi-user.target"
];
serviceConfig = {
DynamicUser = true;
User = "go2rtc";
SupplementaryGroups = [
# for v4l2 devices
"video"
];
StateDirectory = "go2rtc";
ExecStart = "${cfg.package}/bin/go2rtc -config ${configFile}";
};
};
};
}

View File

@@ -0,0 +1,73 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.mediamtx;
format = pkgs.formats.yaml { };
in
{
meta.maintainers = with lib.maintainers; [ fpletz ];
options = {
services.mediamtx = {
enable = lib.mkEnableOption "MediaMTX";
package = lib.mkPackageOption pkgs "mediamtx" { };
settings = lib.mkOption {
description = ''
Settings for MediaMTX. Refer to the defaults at
<https://github.com/bluenviron/mediamtx/blob/main/mediamtx.yml>.
'';
type = format.type;
default = { };
example = {
paths = {
cam = {
runOnInit = "\${lib.getExe pkgs.ffmpeg} -f v4l2 -i /dev/video0 -f rtsp rtsp://localhost:$RTSP_PORT/$RTSP_PATH";
runOnInitRestart = true;
};
};
};
};
env = lib.mkOption {
type = with lib.types; attrsOf anything;
description = "Extra environment variables for MediaMTX";
default = { };
example = {
MTX_CONFKEY = "mykey";
};
};
allowVideoAccess = lib.mkEnableOption ''
access to video devices like cameras on the system
'';
};
};
config = lib.mkIf cfg.enable {
# NOTE: mediamtx watches this file and automatically reloads if it changes
environment.etc."mediamtx.yaml".source = format.generate "mediamtx.yaml" cfg.settings;
systemd.services.mediamtx = {
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = cfg.env;
serviceConfig = {
DynamicUser = true;
User = "mediamtx";
Group = "mediamtx";
SupplementaryGroups = lib.mkIf cfg.allowVideoAccess "video";
ExecStart = "${cfg.package}/bin/mediamtx /etc/mediamtx.yaml";
Restart = "on-failure";
};
};
};
}

View File

@@ -0,0 +1,216 @@
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.services.mirakurun;
mirakurun = pkgs.mirakurun;
username = config.users.users.mirakurun.name;
groupname = config.users.users.mirakurun.group;
settingsFmt = pkgs.formats.yaml { };
polkitRule = pkgs.writeTextDir "share/polkit-1/rules.d/10-mirakurun.rules" ''
polkit.addRule(function (action, subject) {
if (
(action.id == "org.debian.pcsc-lite.access_pcsc" ||
action.id == "org.debian.pcsc-lite.access_card") &&
subject.user == "${username}"
) {
return polkit.Result.YES;
}
});
'';
in
{
options = {
services.mirakurun = {
enable = mkEnableOption "the Mirakurun DVR Tuner Server";
port = mkOption {
type = with types; nullOr port;
default = 40772;
description = ''
Port to listen on. If `null`, it won't listen on
any port.
'';
};
openFirewall = mkOption {
type = types.bool;
default = false;
description = ''
Open ports in the firewall for Mirakurun.
::: {.warning}
Exposing Mirakurun to the open internet is generally advised
against. Only use it inside a trusted local network, or
consider putting it behind a VPN if you want remote access.
:::
'';
};
unixSocket = mkOption {
type = with types; nullOr path;
default = "/var/run/mirakurun/mirakurun.sock";
description = ''
Path to unix socket to listen on. If `null`, it
won't listen on any unix sockets.
'';
};
allowSmartCardAccess = mkOption {
type = types.bool;
default = true;
description = ''
Install polkit rules to allow Mirakurun to access smart card readers
which is commonly used along with tuner devices.
'';
};
serverSettings = mkOption {
type = settingsFmt.type;
default = { };
example = literalExpression ''
{
highWaterMark = 25165824;
overflowTimeLimit = 30000;
};
'';
description = ''
Options for server.yml.
Documentation:
<https://github.com/Chinachu/Mirakurun/blob/master/doc/Configuration.md>
'';
};
tunerSettings = mkOption {
type = with types; nullOr settingsFmt.type;
default = null;
example = literalExpression ''
[
{
name = "tuner-name";
types = [ "GR" "BS" "CS" "SKY" ];
dvbDevicePath = "/dev/dvb/adapterX/dvrX";
}
];
'';
description = ''
Options which are added to tuners.yml. If none is specified, it will
automatically be generated at runtime.
Documentation:
<https://github.com/Chinachu/Mirakurun/blob/master/doc/Configuration.md>
'';
};
channelSettings = mkOption {
type = with types; nullOr settingsFmt.type;
default = null;
example = literalExpression ''
[
{
name = "channel";
types = "GR";
channel = "0";
}
];
'';
description = ''
Options which are added to channels.yml. If none is specified, it
will automatically be generated at runtime.
Documentation:
<https://github.com/Chinachu/Mirakurun/blob/master/doc/Configuration.md>
'';
};
};
};
config = mkIf cfg.enable {
environment.systemPackages = [ mirakurun ] ++ optional cfg.allowSmartCardAccess polkitRule;
environment.etc = {
"mirakurun/server.yml".source = settingsFmt.generate "server.yml" cfg.serverSettings;
"mirakurun/tuners.yml" = mkIf (cfg.tunerSettings != null) {
source = settingsFmt.generate "tuners.yml" cfg.tunerSettings;
mode = "0644";
user = username;
group = groupname;
};
"mirakurun/channels.yml" = mkIf (cfg.channelSettings != null) {
source = settingsFmt.generate "channels.yml" cfg.channelSettings;
mode = "0644";
user = username;
group = groupname;
};
};
networking.firewall = mkIf cfg.openFirewall {
allowedTCPPorts = mkIf (cfg.port != null) [ cfg.port ];
};
users.users.mirakurun = {
description = "Mirakurun user";
group = "video";
isSystemUser = true;
# NPM insists on creating ~/.npm
home = "/var/cache/mirakurun";
};
services.mirakurun.serverSettings = {
logLevel = mkDefault 2;
path = mkIf (cfg.unixSocket != null) cfg.unixSocket;
port = mkIf (cfg.port != null) cfg.port;
};
systemd.tmpfiles.settings."10-mirakurun"."/etc/mirakurun".d = {
user = username;
group = groupname;
};
systemd.services.mirakurun = {
description = mirakurun.meta.description;
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
ExecStart = "${mirakurun}/bin/mirakurun start";
User = username;
Group = groupname;
CacheDirectory = "mirakurun";
RuntimeDirectory = "mirakurun";
StateDirectory = "mirakurun";
Nice = -10;
IOSchedulingClass = "realtime";
IOSchedulingPriority = 7;
};
environment = {
SERVER_CONFIG_PATH = "/etc/mirakurun/server.yml";
TUNERS_CONFIG_PATH = "/etc/mirakurun/tuners.yml";
CHANNELS_CONFIG_PATH = "/etc/mirakurun/channels.yml";
SERVICES_DB_PATH = "/var/lib/mirakurun/services.json";
PROGRAMS_DB_PATH = "/var/lib/mirakurun/programs.json";
LOGO_DATA_DIR_PATH = "/var/lib/mirakurun/logos";
NODE_ENV = "production";
};
restartTriggers =
let
getconf = target: config.environment.etc."mirakurun/${target}.yml".source;
targets = [
"server"
]
++ optional (cfg.tunerSettings != null) "tuners"
++ optional (cfg.channelSettings != null) "channels";
in
(map getconf targets);
};
};
}

View File

@@ -0,0 +1,74 @@
{
config,
pkgs,
lib,
...
}:
let
cfg = config.services.photonvision;
in
{
options = {
services.photonvision = {
enable = lib.mkEnableOption "PhotonVision";
package = lib.mkPackageOption pkgs "photonvision" { };
openFirewall = lib.mkOption {
description = ''
Whether to open the required ports in the firewall.
'';
default = false;
type = lib.types.bool;
};
};
};
config = lib.mkIf cfg.enable {
systemd.services.photonvision = {
description = "PhotonVision, the free, fast, and easy-to-use computer vision solution for the FIRST Robotics Competition";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
ExecStart = lib.getExe cfg.package;
# ephemeral root directory
RuntimeDirectory = "photonvision";
RootDirectory = "/run/photonvision";
# setup persistent state and logs directories
StateDirectory = "photonvision";
LogsDirectory = "photonvision";
BindReadOnlyPaths = [
# mount the nix store read-only
"/nix/store"
# the JRE reads the user.home property from /etc/passwd
"/etc/passwd"
];
BindPaths = [
# mount the configuration and logs directories to the host
"/var/lib/photonvision:/photonvision_config"
"/var/log/photonvision:/photonvision_config/logs"
];
# for PhotonVision's dynamic libraries, which it writes to /tmp
PrivateTmp = true;
};
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [ 5800 ];
allowedTCPPortRanges = [
{
from = 1180;
to = 1190;
}
];
};
};
}

View File

@@ -0,0 +1,109 @@
{
config,
lib,
pkgs,
utils,
...
}:
let
inherit (lib)
getExe
mkEnableOption
mkIf
mkOption
mkPackageOption
optionals
types
;
cfg = config.services.ustreamer;
in
{
options.services.ustreamer = {
enable = mkEnableOption "µStreamer, a lightweight MJPEG-HTTP streamer";
package = mkPackageOption pkgs "ustreamer" { };
autoStart = mkOption {
description = ''
Wether to start µStreamer on boot. Disabling this will use socket
activation. The service will stop gracefully after some inactivity.
Disabling this will set `--exit-on-no-clients=300`
'';
type = types.bool;
default = true;
example = false;
};
listenAddress = mkOption {
description = ''
Address to expose the HTTP server. This accepts values for
ListenStream= defined in {manpage}`systemd.socket(5)`
'';
type = types.str;
default = "0.0.0.0:8080";
example = "/run/ustreamer.sock";
};
device = mkOption {
description = ''
The v4l2 device to stream.
'';
type = types.path;
default = "/dev/video0";
example = "/dev/v4l/by-id/usb-0000_Dummy_abcdef-video-index0";
};
extraArgs = mkOption {
description = ''
Extra arguments to pass to `ustreamer`. See {manpage}`ustreamer(1)`
'';
type = with types; listOf str;
default = [ ];
example = [ "--resolution=1920x1080" ];
};
};
config = mkIf cfg.enable {
services.ustreamer.extraArgs = [
"--device=${cfg.device}"
]
++ optionals (!cfg.autoStart) [
"--exit-on-no-clients=300"
];
systemd.services."ustreamer" = {
description = "µStreamer, a lightweight MJPEG-HTTP streamer";
after = [ "network.target" ];
requires = [ "ustreamer.socket" ];
wantedBy = mkIf cfg.autoStart [ "multi-user.target" ];
serviceConfig = {
ExecStart = utils.escapeSystemdExecArgs (
[
(getExe cfg.package)
"--systemd"
]
++ cfg.extraArgs
);
Restart = if cfg.autoStart then "always" else "on-failure";
DynamicUser = true;
SupplementaryGroups = [ "video" ];
NoNewPrivileges = true;
ProcSubset = "pid";
ProtectProc = "noaccess";
ProtectClock = "yes";
DeviceAllow = [ cfg.device ];
};
};
systemd.sockets."ustreamer" = {
wantedBy = [ "sockets.target" ];
partOf = [ "ustreamer.service" ];
socketConfig = {
ListenStream = cfg.listenAddress;
};
};
};
}

View File

@@ -0,0 +1,231 @@
{
config,
lib,
pkgs,
utils,
...
}:
let
inherit (lib)
attrValues
concatStringsSep
filterAttrs
length
listToAttrs
literalExpression
makeSearchPathOutput
mkEnableOption
mkIf
mkOption
nameValuePair
optionals
types
;
inherit (utils) escapeSystemdPath;
cfg = config.services.v4l2-relayd;
kernelPackages = config.boot.kernelPackages;
gst = (
with pkgs.gst_all_1;
[
gst-plugins-bad
gst-plugins-base
gst-plugins-good
gstreamer.out
]
);
instanceOpts =
{ name, ... }:
{
options = {
enable = mkEnableOption "this v4l2-relayd instance";
name = mkOption {
type = types.str;
default = name;
description = ''
The name of the instance.
'';
};
cardLabel = mkOption {
type = types.str;
description = ''
The name the camera will show up as.
'';
};
extraPackages = mkOption {
type = with types; listOf package;
default = [ ];
description = ''
Extra packages to add to {env}`GST_PLUGIN_PATH` for the instance.
'';
};
input = {
pipeline = mkOption {
type = types.str;
description = ''
The gstreamer-pipeline to use for the input-stream.
'';
};
format = mkOption {
type = types.str;
default = "YUY2";
description = ''
The video-format to read from input-stream.
'';
};
width = mkOption {
type = types.ints.positive;
default = 1280;
description = ''
The width to read from input-stream.
'';
};
height = mkOption {
type = types.ints.positive;
default = 720;
description = ''
The height to read from input-stream.
'';
};
framerate = mkOption {
type = types.ints.positive;
default = 30;
description = ''
The framerate to read from input-stream.
'';
};
};
output = {
format = mkOption {
type = types.str;
default = "YUY2";
description = ''
The video-format to write to output-stream.
'';
};
};
};
};
in
{
options.services.v4l2-relayd = {
instances = mkOption {
type = with types; attrsOf (submodule instanceOpts);
default = { };
example = literalExpression ''
{
example = {
cardLabel = "Example card";
input.pipeline = "videotestsrc";
};
}
'';
description = ''
v4l2-relayd instances to be created.
'';
};
};
config =
let
mkInstanceService = instance: {
description = "Streaming relay for v4l2loopback using GStreamer";
after = [
"modprobe@v4l2loopback.service"
"systemd-logind.service"
];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
Restart = "always";
PrivateNetwork = true;
PrivateTmp = true;
LimitNPROC = 1;
};
environment = {
GST_PLUGIN_PATH = makeSearchPathOutput "lib" "lib/gstreamer-1.0" (gst ++ instance.extraPackages);
V4L2_DEVICE_FILE = "/run/v4l2-relayd-${instance.name}/device";
};
script =
let
appsrcOptions = concatStringsSep "," [
"caps=video/x-raw"
"format=${instance.input.format}"
"width=${toString instance.input.width}"
"height=${toString instance.input.height}"
"framerate=${toString instance.input.framerate}/1"
];
outputPipeline = [
"appsrc name=appsrc ${appsrcOptions}"
"videoconvert"
]
++ optionals (instance.input.format != instance.output.format) [
"video/x-raw,format=${instance.output.format}"
"queue"
]
++ [ "v4l2sink name=v4l2sink device=$(cat $V4L2_DEVICE_FILE)" ];
in
''
exec ${pkgs.v4l2-relayd}/bin/v4l2-relayd -i "${instance.input.pipeline}" -o "${concatStringsSep " ! " outputPipeline}"
'';
preStart = ''
mkdir -p $(dirname $V4L2_DEVICE_FILE)
${kernelPackages.v4l2loopback.bin}/bin/v4l2loopback-ctl add -x 1 -n "${instance.cardLabel}" > $V4L2_DEVICE_FILE
'';
postStop = ''
${kernelPackages.v4l2loopback.bin}/bin/v4l2loopback-ctl delete $(cat $V4L2_DEVICE_FILE)
rm -rf $(dirname $V4L2_DEVICE_FILE)
'';
};
mkInstanceServices =
instances:
listToAttrs (
map (
instance:
nameValuePair "v4l2-relayd-${escapeSystemdPath instance.name}" (mkInstanceService instance)
) instances
);
enabledInstances = attrValues (filterAttrs (n: v: v.enable) cfg.instances);
in
{
boot = mkIf ((length enabledInstances) > 0) {
extraModulePackages = [ kernelPackages.v4l2loopback ];
kernelModules = [ "v4l2loopback" ];
};
systemd.services = mkInstanceServices enabledInstances;
};
meta.maintainers = with lib.maintainers; [ betaboon ];
}

View File

@@ -0,0 +1,250 @@
{
config,
pkgs,
lib,
...
}:
let
inherit (lib)
mkIf
mkEnableOption
mkPackageOption
mkOption
literalExpression
hasAttr
toList
length
head
tail
concatStringsSep
optionalString
optionalAttrs
isDerivation
recursiveUpdate
getExe
types
maintainers
;
cfg = config.services.wivrn;
configFormat = pkgs.formats.json { };
# For the application option to work with systemd PATH, we find the store binary path of
# the package, concat all of the following strings, and then update the application attribute.
# Since the json config attribute type "configFormat.type" doesn't allow specifying types for
# individual attributes, we have to type check manually.
# The application option should be a list with package as the first element, though a single package is also valid.
# Note that this module depends on the package containing the meta.mainProgram attribute.
# Check if an application is provided
applicationAttrExists = hasAttr "application" cfg.config.json;
applicationList = toList cfg.config.json.application;
applicationListNotEmpty = length applicationList != 0;
applicationCheck = applicationAttrExists && applicationListNotEmpty;
# Manage packages and their exe paths
applicationAttr = head applicationList;
applicationPackage = mkIf applicationCheck applicationAttr;
applicationPackageExe = getExe applicationAttr;
serverPackageExe = (
if cfg.highPriority then "${config.security.wrapperDir}/wivrn-server" else getExe cfg.package
);
# Manage strings
applicationStrings = tail applicationList;
applicationConcat = concatStringsSep " " ([ applicationPackageExe ] ++ applicationStrings);
# Manage config file
applicationUpdate = recursiveUpdate cfg.config.json (
optionalAttrs applicationCheck { application = applicationConcat; }
);
configFile = configFormat.generate "config.json" applicationUpdate;
enabledConfig = optionalString cfg.config.enable "-f ${configFile}";
# Manage server executables and flags
serverExec = concatStringsSep " " (
[
serverPackageExe
"--systemd"
enabledConfig
]
++ cfg.extraServerFlags
);
in
{
options = {
services.wivrn = {
enable = mkEnableOption "WiVRn, an OpenXR streaming application";
package = mkPackageOption pkgs "wivrn" { };
openFirewall = mkEnableOption "the default ports in the firewall for the WiVRn server";
defaultRuntime = mkEnableOption ''
WiVRn as the default OpenXR runtime on the system.
The config can be found at `/etc/xdg/openxr/1/active_runtime.json`.
Note that applications can bypass this option by setting an active
runtime in a writable XDG_CONFIG_DIRS location like `~/.config`
'';
autoStart = mkEnableOption "starting the service by default";
highPriority = mkEnableOption "high priority capability for asynchronous reprojection";
monadoEnvironment = mkOption {
type = types.attrs;
description = "Environment variables to be passed to the Monado environment.";
default = { };
};
extraServerFlags = mkOption {
type = types.listOf types.str;
description = "Flags to add to the wivrn service.";
default = [ ];
example = literalExpression ''[ "--no-publish-service" ]'';
};
steam = {
importOXRRuntimes = mkEnableOption ''
Sets `PRESSURE_VESSEL_IMPORT_OPENXR_1_RUNTIMES` system-wide to allow Steam to automatically discover the WiVRn server.
Note that you may have to logout for this variable to be visible
'';
package = mkPackageOption pkgs "steam" { };
};
config = {
enable = mkEnableOption "configuration for WiVRn";
json = mkOption {
type = configFormat.type;
description = ''
Configuration for WiVRn. The attributes are serialized to JSON in config.json. The server will fallback to default values for any missing attributes.
Like upstream, the application option is a list including the application and it's flags. In the case of the NixOS module however, the first element of the list must be a package. The module will assert otherwise.
The application can be set to a single package because it gets passed to lib.toList, though this will not allow for flags to be passed.
See <https://github.com/WiVRn/WiVRn/blob/master/docs/configuration.md>
'';
default = { };
example = literalExpression ''
{
scale = 0.5;
bitrate = 100000000;
encoders = [
{
encoder = "nvenc";
codec = "h264";
width = 1.0;
height = 1.0;
offset_x = 0.0;
offset_y = 0.0;
}
];
application = [ pkgs.wlx-overlay-s ];
}
'';
};
};
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = !applicationCheck || isDerivation applicationAttr;
message = "The application in WiVRn configuration is not a package. Please ensure that the application is a package or that a package is the first element in the list.";
}
];
security.wrappers."wivrn-server" = mkIf cfg.highPriority {
setuid = false;
owner = "root";
group = "root";
capabilities = "cap_sys_nice+eip";
source = getExe cfg.package;
};
systemd.user = {
services = {
wivrn = {
description = "WiVRn XR runtime service";
environment = recursiveUpdate {
# Default options
# https://gitlab.freedesktop.org/monado/monado/-/blob/598080453545c6bf313829e5780ffb7dde9b79dc/src/xrt/targets/service/monado.in.service#L12
XRT_COMPOSITOR_LOG = "debug";
XRT_PRINT_OPTIONS = "on";
IPC_EXIT_ON_DISCONNECT = "off";
PRESSURE_VESSEL_IMPORT_OPENXR_1_RUNTIMES = mkIf cfg.steam.importOXRRuntimes "1";
} cfg.monadoEnvironment;
serviceConfig = (
if cfg.highPriority then
{
ExecStart = serverExec;
}
# Hardening options break high-priority
else
{
ExecStart = serverExec;
# Hardening options
CapabilityBoundingSet = [ "CAP_SYS_NICE" ];
AmbientCapabilities = [ "CAP_SYS_NICE" ];
LockPersonality = true;
NoNewPrivileges = true;
PrivateTmp = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RemoveIPC = true;
RestrictNamespaces = true;
RestrictSUIDSGID = true;
}
);
path = [ cfg.steam.package ];
wantedBy = mkIf cfg.autoStart [ "default.target" ];
restartTriggers = [
cfg.package
cfg.steam.package
];
};
};
};
services = {
udev.packages = with pkgs; [ android-udev-rules ];
avahi = {
enable = true;
publish = {
enable = true;
userServices = true;
};
};
};
networking.firewall = mkIf cfg.openFirewall {
allowedTCPPorts = [ 9757 ];
allowedUDPPorts = [ 9757 ];
};
environment = {
systemPackages = [
cfg.package
applicationPackage
];
sessionVariables = mkIf cfg.steam.importOXRRuntimes {
PRESSURE_VESSEL_IMPORT_OPENXR_1_RUNTIMES = "1";
};
pathsToLink = [ "/share/openxr" ];
etc."xdg/openxr/1/active_runtime.json" = mkIf cfg.defaultRuntime {
source = "${cfg.package}/share/openxr/1/openxr_wivrn.json";
};
};
};
meta.maintainers = with maintainers; [ passivelemon ];
}