nixos/tdarr: init module

This commit is contained in:
Mistyttm
2026-01-21 09:51:15 +10:00
parent 6f4c85b56f
commit e2f3c26865
9 changed files with 931 additions and 0 deletions

View File

@@ -76,6 +76,69 @@
"module-services-tandoor-recipes-migrating-media-option-disallow-access": [
"index.html#module-services-tandoor-recipes-migrating-media-option-disallow-access"
],
"module-services-tdarr": [
"index.html#module-services-tdarr"
],
"module-services-tdarr-advanced": [
"index.html#module-services-tdarr-advanced"
],
"module-services-tdarr-advanced-datadir": [
"index.html#module-services-tdarr-advanced-datadir"
],
"module-services-tdarr-advanced-node-datadir": [
"index.html#module-services-tdarr-advanced-node-datadir"
],
"module-services-tdarr-advanced-plugins": [
"index.html#module-services-tdarr-advanced-plugins"
],
"module-services-tdarr-authentication": [
"index.html#module-services-tdarr-authentication"
],
"module-services-tdarr-basic-usage": [
"index.html#module-services-tdarr-basic-usage"
],
"module-services-tdarr-distributed": [
"index.html#module-services-tdarr-distributed"
],
"module-services-tdarr-distributed-nodes": [
"index.html#module-services-tdarr-distributed-nodes"
],
"module-services-tdarr-distributed-server": [
"index.html#module-services-tdarr-distributed-server"
],
"module-services-tdarr-networking": [
"index.html#module-services-tdarr-networking"
],
"module-services-tdarr-networking-firewall": [
"index.html#module-services-tdarr-networking-firewall"
],
"module-services-tdarr-networking-ipv6": [
"index.html#module-services-tdarr-networking-ipv6"
],
"module-services-tdarr-networking-ports": [
"index.html#module-services-tdarr-networking-ports"
],
"module-services-tdarr-nodes": [
"index.html#module-services-tdarr-nodes"
],
"module-services-tdarr-nodes-multiple": [
"index.html#module-services-tdarr-nodes-multiple"
],
"module-services-tdarr-nodes-only": [
"index.html#module-services-tdarr-nodes-only"
],
"module-services-tdarr-nodes-path-translators": [
"index.html#module-services-tdarr-nodes-path-translators"
],
"module-services-tdarr-nodes-types": [
"index.html#module-services-tdarr-nodes-types"
],
"module-services-tdarr-nodes-workers": [
"index.html#module-services-tdarr-nodes-workers"
],
"module-services-tdarr-server-only": [
"index.html#module-services-tdarr-server-only"
],
"module-virtualisation-xen": [
"index.html#module-virtualisation-xen"
],

View File

@@ -80,6 +80,8 @@
- [tabbyAPI](https://github.com/theroyallab/tabbyAPI), the official OpenAI compatible API server for Exllama. Available as [services.tabbyapi](#opt-services.tabbyapi.enable).
- [Tdarr](https://tdarr.io), Audio/Video Library Analytics & Transcode/Remux Automation. Available as [services.tdarr](#opt-services.tdarr.enable)
## Backward Incompatibilities {#sec-release-26.05-incompatibilities}
<!-- To avoid merge conflicts, consider adding your item at an arbitrary place in the list instead. -->

View File

@@ -976,6 +976,7 @@
./services/misc/taskchampion-sync-server.nix
./services/misc/taskserver
./services/misc/tautulli.nix
./services/misc/tdarr
./services/misc/tee-supplicant
./services/misc/tiddlywiki.nix
./services/misc/tp-auto-kbbl.nix

View File

@@ -0,0 +1,65 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.tdarr;
in
{
imports = [
./server.nix
./node.nix
];
options.services.tdarr = {
enable = lib.mkEnableOption "Tdarr distributed transcoding system" // {
description = ''
Whether to enable Tdarr. This is a convenience option that enables both
the server and all configured nodes. For more granular control, use
{option}`services.tdarr.server.enable` and configure nodes individually.
'';
};
package = lib.mkPackageOption pkgs "tdarr" { };
dataDir = lib.mkOption {
type = lib.types.path;
default = "/var/lib/tdarr";
description = "Base directory for Tdarr data.";
};
user = lib.mkOption {
type = lib.types.str;
default = "tdarr";
description = "User account under which Tdarr runs.";
};
group = lib.mkOption {
type = lib.types.str;
default = "tdarr";
description = "Group under which Tdarr runs.";
};
};
config = lib.mkIf (cfg.enable || cfg.server.enable || cfg.nodes != { }) {
users.users.tdarr = lib.mkIf (cfg.user == "tdarr") {
isSystemUser = true;
group = cfg.group;
home = cfg.dataDir;
createHome = true;
};
users.groups.tdarr = lib.mkIf (cfg.group == "tdarr") { };
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0750 ${cfg.user} ${cfg.group} -"
];
};
meta = {
maintainers = with lib.maintainers; [ mistyttm ];
doc = ./tdarr.md;
};
}

View File

@@ -0,0 +1,244 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.tdarr;
enabledNodes = lib.filterAttrs (_: nodeCfg: nodeCfg.enable) cfg.nodes;
nodesEnabled = cfg.enable || (enabledNodes != { });
serverEnabled = cfg.enable || cfg.server.enable;
nodeConfigFiles = lib.mapAttrs (
nodeId: nodeCfg:
pkgs.writeText "Tdarr_Node_Config_${nodeId}.json" (
builtins.toJSON { pathTranslators = nodeCfg.pathTranslators; }
)
) enabledNodes;
in
{
options.services.tdarr.nodes = lib.mkOption {
default = { };
description = "Attribute set of Tdarr processing nodes to run on this machine.";
type = lib.types.attrsOf (
lib.types.submodule (
{ name, ... }:
{
options = {
enable = lib.mkEnableOption "this Tdarr node" // {
default = true;
};
package = lib.mkOption {
type = lib.types.package;
default = cfg.package.node;
defaultText = lib.literalExpression "config.services.tdarr.package.node";
description = "Package to use for this Tdarr node.";
};
name = lib.mkOption {
type = lib.types.str;
default = "${config.networking.hostName}-${name}";
defaultText = lib.literalExpression ''"''${config.networking.hostName}-''${name}"'';
description = "Display name for this node in the Tdarr web UI.";
};
dataDir = lib.mkOption {
type = lib.types.path;
default = "${cfg.dataDir}/nodes/${name}";
defaultText = lib.literalExpression ''"''${config.services.tdarr.dataDir}/nodes/''${name}"'';
description = "Data directory for this node.";
};
serverURL = lib.mkOption {
type = lib.types.str;
default = "http://127.0.0.1:${toString cfg.server.serverPort}";
defaultText = lib.literalExpression ''"http://127.0.0.1:''${toString config.services.tdarr.server.serverPort}"'';
description = ''
Full URL of the Tdarr server this node connects to.
This is the recommended way to specify the server location.
When running a local server, the default value is correct.
'';
};
type = lib.mkOption {
type = lib.types.enum [
"mapped"
"unmapped"
];
default = "mapped";
description = ''
Node type.
- `mapped`: Node accesses files directly from the library paths.
- `unmapped`: Node receives files over the network API.
'';
};
priority = lib.mkOption {
type = lib.types.int;
default = -1;
description = ''
Node priority for job assignment.
`-1` means no priority. `0` is the highest priority, `1` is next, and so on.
'';
};
pollInterval = lib.mkOption {
type = lib.types.ints.unsigned;
default = 2000;
description = "How often the node checks the server for work, in milliseconds.";
};
startPaused = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether the node starts in a paused state.";
};
maxLogSizeMB = lib.mkOption {
type = lib.types.ints.unsigned;
default = 10;
description = "Maximum log file size in megabytes.";
};
cronPluginUpdate = lib.mkOption {
type = lib.types.str;
default = "";
description = "Cron expression for automatic plugin updates. Empty string disables.";
};
pathTranslators = lib.mkOption {
type = lib.types.listOf (
lib.types.submodule {
options = {
server = lib.mkOption {
type = lib.types.str;
description = "Server-side path for path translation.";
};
node = lib.mkOption {
type = lib.types.str;
description = "Node-side path for path translation.";
};
};
}
);
default = [ ];
description = ''
Path translations between server and node for cross-platform or
cross-mount-point file access.
'';
example = lib.literalExpression ''
[
{ server = "/media"; node = "/mnt/media"; }
{ server = "/cache"; node = "/mnt/cache"; }
]
'';
};
workers = {
transcodeGPU = lib.mkOption {
type = lib.types.ints.unsigned;
default = 0;
description = "Number of GPU transcode workers. Can be overridden in the web UI.";
};
transcodeCPU = lib.mkOption {
type = lib.types.ints.unsigned;
default = 2;
description = "Number of CPU transcode workers. Can be overridden in the web UI.";
};
healthcheckGPU = lib.mkOption {
type = lib.types.ints.unsigned;
default = 0;
description = "Number of GPU healthcheck workers. Can be overridden in the web UI.";
};
healthcheckCPU = lib.mkOption {
type = lib.types.ints.unsigned;
default = 1;
description = "Number of CPU healthcheck workers. Can be overridden in the web UI.";
};
};
environmentFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
File containing environment variable overrides for this node,
in the format accepted by systemd's `EnvironmentFile`.
Useful for passing secrets like `apiKey` without putting them
in the Nix store.
'';
example = "/run/secrets/tdarr-node-env";
};
};
}
)
);
};
config = lib.mkIf nodesEnabled {
systemd.tmpfiles.rules = lib.concatMap (nodeId: [
"d ${cfg.dataDir}/nodes/${nodeId} 0750 ${cfg.user} ${cfg.group} -"
"d ${cfg.dataDir}/nodes/${nodeId}/configs 0750 ${cfg.user} ${cfg.group} -"
"d ${cfg.dataDir}/nodes/${nodeId}/logs 0750 ${cfg.user} ${cfg.group} -"
"L+ ${cfg.dataDir}/nodes/${nodeId}/configs/Tdarr_Node_Config.json - - - - ${nodeConfigFiles.${nodeId}}"
]) (builtins.attrNames enabledNodes);
systemd.services = lib.mapAttrs' (
nodeId: nodeCfg:
lib.nameValuePair "tdarr-node-${nodeId}" {
description = "Tdarr Node - ${nodeCfg.name}";
after = [ "network.target" ] ++ lib.optionals serverEnabled [ "tdarr-server.service" ];
wants = lib.optionals serverEnabled [ "tdarr-server.service" ];
wantedBy = [ "multi-user.target" ];
environment = {
nodeName = nodeCfg.name;
serverURL = nodeCfg.serverURL;
nodeType = nodeCfg.type;
priority = toString nodeCfg.priority;
cronPluginUpdate = nodeCfg.cronPluginUpdate;
maxLogSizeMB = toString nodeCfg.maxLogSizeMB;
pollInterval = toString nodeCfg.pollInterval;
startPaused = lib.boolToString nodeCfg.startPaused;
transcodegpuWorkers = toString nodeCfg.workers.transcodeGPU;
transcodecpuWorkers = toString nodeCfg.workers.transcodeCPU;
healthcheckgpuWorkers = toString nodeCfg.workers.healthcheckGPU;
healthcheckcpuWorkers = toString nodeCfg.workers.healthcheckCPU;
rootDataPath = toString nodeCfg.dataDir;
};
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
ExecStart = lib.getExe nodeCfg.package;
Restart = "on-failure";
RestartSec = 5;
WorkingDirectory = toString nodeCfg.dataDir;
# Hardening
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
StateDirectory = lib.mkIf (lib.hasPrefix "/var/lib/" (toString nodeCfg.dataDir)) (
let
rel = lib.removePrefix "/var/lib/" (toString nodeCfg.dataDir);
in
"${rel} ${rel}/configs ${rel}/logs"
);
StateDirectoryMode = lib.mkIf (lib.hasPrefix "/var/lib/" (toString nodeCfg.dataDir)) "0750";
ReadWritePaths = lib.optionals (!lib.hasPrefix "/var/lib/" (toString nodeCfg.dataDir)) [
(toString nodeCfg.dataDir)
];
}
// lib.optionalAttrs (nodeCfg.environmentFile != null) {
EnvironmentFile = nodeCfg.environmentFile;
};
}
) enabledNodes;
};
}

View File

@@ -0,0 +1,151 @@
{
config,
lib,
...
}:
let
cfg = config.services.tdarr;
serverDataDir = "${cfg.dataDir}/server";
serverEnabled = cfg.enable || cfg.server.enable;
in
{
options.services.tdarr.server = {
enable = lib.mkEnableOption "Tdarr server";
package = lib.mkOption {
type = lib.types.package;
default = cfg.package.server;
defaultText = lib.literalExpression "config.services.tdarr.package.server";
description = "Package to use for the Tdarr server.";
};
serverPort = lib.mkOption {
type = lib.types.port;
default = 8266;
description = "Port for server API communication.";
};
webUIPort = lib.mkOption {
type = lib.types.port;
default = 8265;
description = "Port for the Tdarr web UI.";
};
serverIP = lib.mkOption {
type = lib.types.str;
default = "0.0.0.0";
description = "IP address the server binds to.";
};
serverBindIP = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to bind to the specific IP in {option}`services.tdarr.server.serverIP`.";
};
serverDualStack = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Enable dual-stack (IPv4/IPv6) networking.
When enabled, the server binds to `::` if IPv6 is available, accepting both
IPv4 and IPv6 connections. Useful in Kubernetes and other modern networking setups.
'';
};
maxLogSizeMB = lib.mkOption {
type = lib.types.ints.unsigned;
default = 10;
description = "Maximum log file size in megabytes.";
};
cronPluginUpdate = lib.mkOption {
type = lib.types.str;
default = "";
description = "Cron expression for automatic plugin updates. Empty string disables.";
example = "0 2 * * *";
};
auth.enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to enable authentication for the Tdarr web UI and API.";
};
environmentFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
File containing environment variable overrides for the server,
in the format accepted by systemd's `EnvironmentFile`.
Useful for setting secrets such as `authSecretKey` or `seededApiKey`
without exposing them in the Nix store.
Example file contents:
```
authSecretKey=your-secret-key
seededApiKey=tapi_your_api_key_here
```
'';
example = "/run/secrets/tdarr-server-env";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to open the firewall for the Tdarr server web UI and API ports.";
};
};
config = lib.mkIf serverEnabled {
systemd.tmpfiles.rules = [
"d ${serverDataDir} 0750 ${cfg.user} ${cfg.group} -"
"d ${serverDataDir}/configs 0750 ${cfg.user} ${cfg.group} -"
];
systemd.services.tdarr-server = {
description = "Tdarr Server";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
serverPort = toString cfg.server.serverPort;
webUIPort = toString cfg.server.webUIPort;
serverIP = cfg.server.serverIP;
serverBindIP = lib.boolToString cfg.server.serverBindIP;
serverDualStack = lib.boolToString cfg.server.serverDualStack;
openBrowser = "false";
auth = lib.boolToString cfg.server.auth.enable;
maxLogSizeMB = toString cfg.server.maxLogSizeMB;
cronPluginUpdate = cfg.server.cronPluginUpdate;
rootDataPath = serverDataDir;
};
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
ExecStart = lib.getExe cfg.server.package;
Restart = "on-failure";
RestartSec = 5;
WorkingDirectory = serverDataDir;
# Hardening
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ cfg.dataDir ];
}
// lib.optionalAttrs (cfg.server.environmentFile != null) {
EnvironmentFile = cfg.server.environmentFile;
};
};
networking.firewall.allowedTCPPorts = lib.mkIf cfg.server.openFirewall [
cfg.server.serverPort
cfg.server.webUIPort
];
};
}

View File

@@ -0,0 +1,280 @@
# Tdarr {#module-services-tdarr}
*Source:* {file}`modules/services/misc/tdarr`
*Upstream documentation:* <https://docs.tdarr.io/\>
[Tdarr](https://tdarr.io) is a distributed transcoding system for automating media library transcoding operations using FFmpeg and HandBrake. It provides a web interface for managing transcoding nodes and configuring media processing pipelines.
## Basic Usage {#module-services-tdarr-basic-usage}
A minimal Tdarr setup with a server and one local node:
```nix
{
services.tdarr = {
enable = true;
nodes.main = { };
};
}
```
This creates a Tdarr server accessible at `http://localhost:8265` (web UI) with one processing node. The service runs as the `tdarr` user with data stored in `/var/lib/tdarr`.
::: {.note}
The `services.tdarr.enable` option is a convenience that enables both the server and all configured nodes. For finer control, use `services.tdarr.server.enable` and configure nodes independently.
:::
### Server Only {#module-services-tdarr-server-only}
To run only the Tdarr server without local nodes:
```nix
{
services.tdarr.server.enable = true;
}
```
### Nodes Only {#module-services-tdarr-nodes-only}
To run node(s) connecting to a remote server:
```nix
{
services.tdarr.nodes.worker1 = {
serverURL = "http://192.168.1.100:8266";
environmentFile = "/run/secrets/tdarr-node-env";
# /run/secrets/tdarr-node-env contains:
# apiKey=tapi_your_api_key_here
};
}
```
## Authentication {#module-services-tdarr-authentication}
Authentication should be enabled for any installation accessible beyond localhost. Secrets are passed via environment files to avoid leaking them into the Nix store:
```nix
{
services.tdarr = {
enable = true;
server = {
auth.enable = true;
environmentFile = "/run/secrets/tdarr-server-env";
# /run/secrets/tdarr-server-env contains:
# authSecretKey=your-secret-key
# seededApiKey=tapi_your_api_key_here
};
nodes.main = {
environmentFile = "/run/secrets/tdarr-node-env";
# /run/secrets/tdarr-node-env contains:
# apiKey=tapi_your_api_key_here
};
};
}
```
::: {.warning}
When using unmapped nodes, files in Tdarr's library source and cache folders become accessible through the network API. Authentication is strongly recommended in this configuration.
:::
## Node Configuration {#module-services-tdarr-nodes}
### Multiple Nodes {#module-services-tdarr-nodes-multiple}
You can run multiple nodes on the same machine with different configurations:
```nix
{
services.tdarr = {
enable = true;
nodes = {
cpu-node = {
workers = {
transcodeCPU = 4;
healthcheckCPU = 2;
};
};
gpu-node = {
workers = {
transcodeGPU = 2;
transcodeCPU = 1;
healthcheckGPU = 1;
};
};
};
};
}
```
### Worker Configuration {#module-services-tdarr-nodes-workers}
Workers determine how many parallel transcoding and healthcheck operations a node can perform:
```nix
{
services.tdarr.nodes.main = {
workers = {
transcodeCPU = 4; # default: 2
transcodeGPU = 1; # default: 0
healthcheckCPU = 2; # default: 1
healthcheckGPU = 0; # default: 0
};
};
}
```
::: {.note}
GPU workers require appropriate hardware and drivers. Worker counts can also be adjusted at runtime through the Tdarr web UI.
:::
### Node Types {#module-services-tdarr-nodes-types}
Tdarr supports two node types:
- **Mapped nodes** (default): Access files directly from the library paths configured in the Tdarr web interface.
- **Unmapped nodes**: Receive files over the network, useful for nodes without direct storage access.
```nix
{
services.tdarr.nodes = {
local.type = "mapped";
remote = {
type = "unmapped";
};
};
}
```
### Path Translators {#module-services-tdarr-nodes-path-translators}
Path translators enable cross-mount-point file access by mapping server paths to node paths:
```nix
{
services.tdarr.nodes.remote-node = {
pathTranslators = [
{
server = "/media/videos";
node = "/mnt/nfs/videos";
}
{
server = "/media/music";
node = "/mnt/nfs/music";
}
];
};
}
```
## Networking {#module-services-tdarr-networking}
### Firewall Configuration {#module-services-tdarr-networking-firewall}
```nix
{
services.tdarr.server = {
enable = true;
openFirewall = true; # Opens ports 8265 (web UI) and 8266 (server API)
};
}
```
### Custom Ports {#module-services-tdarr-networking-ports}
```nix
{
services.tdarr.server = {
enable = true;
serverPort = 9266; # default: 8266
webUIPort = 9265; # default: 8265
};
}
```
### IPv6 Support {#module-services-tdarr-networking-ipv6}
Enable dual-stack networking for IPv4 and IPv6 support:
```nix
{
services.tdarr.server = {
enable = true;
serverDualStack = true;
};
}
```
## Advanced Configuration {#module-services-tdarr-advanced}
### Plugin Updates {#module-services-tdarr-advanced-plugins}
Configure automatic plugin updates using cron expressions:
```nix
{
services.tdarr.server = {
enable = true;
cronPluginUpdate = "0 2 * * *"; # Daily at 2 AM
};
}
```
### Custom Data Directory {#module-services-tdarr-advanced-datadir}
```nix
{
services.tdarr = {
enable = true;
dataDir = "/mnt/storage/tdarr";
};
}
```
### Per-Node Data Directories {#module-services-tdarr-advanced-node-datadir}
```nix
{
services.tdarr.nodes = {
ssd-node.dataDir = "/mnt/ssd/tdarr-node";
hdd-node.dataDir = "/mnt/hdd/tdarr-node";
};
}
```
## Distributed Setup {#module-services-tdarr-distributed}
Tdarr's distributed architecture allows running nodes on separate machines from the server.
### Server Machine {#module-services-tdarr-distributed-server}
```nix
{
services.tdarr.server = {
enable = true;
serverIP = "0.0.0.0";
openFirewall = true;
auth.enable = true;
environmentFile = "/run/secrets/tdarr-server-env";
};
}
```
### Worker Machines {#module-services-tdarr-distributed-nodes}
```nix
{
services.tdarr.nodes.remote-worker = {
serverURL = "http://192.168.1.100:8266";
environmentFile = "/run/secrets/tdarr-node-env";
workers = {
transcodeCPU = 4;
healthcheckCPU = 2;
};
};
}
```
::: {.note}
Ensure the server's firewall allows incoming connections on the configured ports. Both server and nodes must have access to the same media and transcode cache paths (for mapped nodes).
:::

View File

@@ -1638,6 +1638,7 @@ in
taskchampion-sync-server = runTest ./taskchampion-sync-server.nix;
taskserver = runTest ./taskserver.nix;
tayga = runTest ./tayga.nix;
tdarr = runTest ./tdarr.nix;
technitium-dns-server = runTest ./technitium-dns-server.nix;
teeworlds = runTest ./teeworlds.nix;
telegraf = runTest ./telegraf.nix;

124
nixos/tests/tdarr.nix Normal file
View File

@@ -0,0 +1,124 @@
{ lib, ... }:
{
name = "tdarr";
meta = with lib.maintainers; {
maintainers = [ mistyttm ];
};
nodes.machine =
{ pkgs, ... }:
{
services.tdarr = {
enable = true;
server = {
serverPort = 9266;
webUIPort = 9265;
};
nodes = {
main = {
type = "mapped";
priority = -1;
pollInterval = 2000;
startPaused = false;
maxLogSizeMB = 10;
workers = {
transcodeCPU = 1;
healthcheckCPU = 1;
};
};
secondary = {
enable = false;
};
};
};
};
testScript = ''
import json
machine.wait_for_unit("tdarr-server.service")
machine.wait_for_unit("tdarr-node-main.service")
with subtest("disabled node should not have a service"):
machine.fail("systemctl is-enabled tdarr-node-secondary.service")
with subtest("data directories created with correct ownership"):
machine.succeed("test -d /var/lib/tdarr")
machine.succeed("stat -c '%U:%G' /var/lib/tdarr | grep -q 'tdarr:tdarr'")
with subtest("disabled node directory should not exist"):
machine.fail("test -d /var/lib/tdarr/nodes/secondary")
with subtest("server environment variables are set correctly"):
env = machine.succeed(
"systemctl show tdarr-server.service --property=Environment"
)
assert "serverPort=9266" in env, f"serverPort not found in: {env}"
assert "webUIPort=9265" in env, f"webUIPort not found in: {env}"
assert "serverIP=0.0.0.0" in env, f"serverIP not found in: {env}"
assert "serverBindIP=false" in env, f"serverBindIP not found in: {env}"
assert "serverDualStack=false" in env, f"serverDualStack not found in: {env}"
assert "openBrowser=false" in env, f"openBrowser not found in: {env}"
assert "auth=false" in env, f"auth not found in: {env}"
assert "maxLogSizeMB=10" in env, f"maxLogSizeMB not found in: {env}"
assert "cronPluginUpdate=" in env, f"cronPluginUpdate not found in: {env}"
with subtest("node environment variables are set correctly"):
env = machine.succeed(
"systemctl show tdarr-node-main.service --property=Environment"
)
assert "serverURL=http://127.0.0.1:9266" in env, f"serverURL not found in: {env}"
assert "nodeType=mapped" in env, f"nodeType not found in: {env}"
assert "priority=-1" in env, f"priority not found in: {env}"
assert "pollInterval=2000" in env, f"pollInterval not found in: {env}"
assert "startPaused=false" in env, f"startPaused not found in: {env}"
assert "maxLogSizeMB=10" in env, f"maxLogSizeMB not found in: {env}"
assert "transcodecpuWorkers=1" in env, f"transcodecpuWorkers not found in: {env}"
assert "healthcheckcpuWorkers=1" in env, f"healthcheckcpuWorkers not found in: {env}"
assert "transcodegpuWorkers=0" in env, f"transcodegpuWorkers not found in: {env}"
assert "healthcheckgpuWorkers=0" in env, f"healthcheckgpuWorkers not found in: {env}"
with subtest("custom ports are listening"):
machine.wait_for_open_port(9265)
machine.wait_for_open_port(9266)
with subtest("server reports healthy status"):
status = json.loads(machine.succeed("curl -sf http://localhost:9266/api/v2/status"))
assert "version" in status, f"version missing from status: {status}"
assert status.get("uptime", -1) >= 0, f"unexpected uptime in status: {status}"
with subtest("web UI serves HTML"):
html = machine.succeed("curl --fail http://localhost:9265/")
assert "<!DOCTYPE html>" in html or "<html" in html, f"web UI did not return HTML: {html[:200]}"
with subtest("node registers with server and reports correct config"):
machine.wait_until_succeeds(
"curl -sf http://localhost:9266/api/v2/get-nodes | grep -q 'main'"
)
response = machine.succeed("curl -sf http://localhost:9266/api/v2/get-nodes")
nodes = json.loads(response)
node = next((v for v in nodes.values() if "main" in v.get("nodeName", "")), None)
assert node is not None, f"node 'main' not found in: {nodes}"
node_config = node["config"]
node_worker_limits = node["workerLimits"]
# Worker limits
assert node_worker_limits.get("transcodecpu") == 1, f"unexpected transcodecpu worker count: {node}"
assert node_worker_limits.get("healthcheckcpu") == 1, f"unexpected healthcheckcpu worker count: {node}"
assert node_worker_limits.get("transcodegpu") == 0, f"unexpected transcodegpu worker count: {node}"
assert node_worker_limits.get("healthcheckgpu") == 0, f"unexpected healthcheckgpu worker count: {node}"
# Node config as registered with the server
assert "pathTranslators" in node_config, f"pathTranslators missing from node config: {node_config}"
assert node_config.get("nodeType") == "mapped", f"unexpected nodeType: {node_config}"
assert node_config.get("priority") == -1, f"unexpected priority: {node_config}"
assert node_config.get("pollInterval") == 2000, f"unexpected pollInterval: {node_config}"
assert node_config.get("startPaused") == False, f"unexpected startPaused: {node_config}"
assert node_config.get("maxLogSizeMB") == "10", f"unexpected maxLogSizeMB: {node_config}"
assert node_config.get("cronPluginUpdate") == "", f"unexpected cronPluginUpdate: {node_config}"
# Top-level node state
assert node.get("nodePaused") == False, f"unexpected nodePaused: {node}"
assert node.get("nodeTags") == "mapped", f"unexpected nodeTags: {node}"
'';
}