Compare commits

...

6 commits

Author SHA1 Message Date
Chris Kruining
100a218aed
Add poster image support to Matrix download listings
Some checks failed
Test action / kaas (push) Failing after 2s
- Fetch and display poster images for tracked items in Matrix
- Show monitored/unmonitored icons in listings
- Limit displayed items to 12, with count and overflow message
- Add tests for image fetching and formatting
- Enable Grafana datasources
- Fix Sonarr/Radarr URL config bug
2026-04-16 16:55:52 +02:00
Chris Kruining
e07257e137
checkpoint 2026-04-16 15:36:33 +02:00
Chris Kruining
be2843ca80
. 2026-04-16 11:00:38 +02:00
Chris Kruining
e26e25b566
Change observability service ports and add Arrtrix content management
- Update ports for Alloy, Grafana, Loki, Prometheus, Promtail, Tempo,
  and
  Uptime Kuma to new ranges
- Add Arrtrix content management commands and subscriptions
- Implement Radarr and Sonarr client logic for movie and series
  management
- Add matrix commands for download and subscription management
- Add subscription repository with database schema and logic
- Update Arrtrix config and example config for content section
- Update help text and command processor to include new commands
- Update vendor hash for Arrtrix package
2026-04-16 10:41:16 +02:00
Chris Kruining
9b93f017b6
Add observability stack: Alloy, Tempo, and OTEL support
- Add NixOS modules for Alloy and Tempo with default configs
- Update Grafana datasource config for Prometheus, Loki, Tempo
- Add Prometheus remote_write for Alloy
- Implement OTEL metrics/tracing/logging in arrtrix (Go)
- Enable Alloy and Tempo in ulmo system config
2026-04-16 10:29:04 +02:00
Chris Kruining
81f34676c4
Add OpenTelemetry observability to Arrtrix
- Add OTLP/gRPC observability config and resource attributes
- Instrument webhook and onboarding handlers with tracing and metrics
- Add OpenTelemetry dependencies to go.mod/go.sum
- Update NixOS modules to configure observability settings
2026-04-16 10:13:51 +02:00
48 changed files with 2963 additions and 162 deletions

View file

@ -1,6 +1,10 @@
{ {
description = "Nixos config flake"; description = "Nixos config flake";
nixConfig = {
warn-dirty = false;
};
inputs = { inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";

View file

@ -1,9 +1,10 @@
{ config, lib, pkgs, namespace, system, inputs, ... }: { config, lib, pkgs, namespace, system, inputs, ... }:
let let
inherit (lib) mkIf mkEnableOption mkOption types toUpper toSentenceCase nameValuePair mapAttrs mapAttrs' concatMapAttrs concatMapStringsSep filterAttrsRecursive listToAttrs imap0 head drop length literalExpression attrNames; inherit (lib) mkIf mkEnableOption mkOption toString types toUpper toSentenceCase nameValuePair mapAttrs mapAttrs' concatMapAttrs concatMapStringsSep filterAttrsRecursive listToAttrs imap0 head drop length literalExpression attrNames;
inherit (lib.${namespace}.strings) toSnakeCase; inherit (lib.${namespace}.strings) toSnakeCase;
cfg = config.${namespace}.services.authentication.zitadel; cfg = config.${namespace}.services.authentication.zitadel;
port = 3010;
database = "zitadel"; database = "zitadel";
in in
@ -543,12 +544,12 @@ in
networking.caddy = { networking.caddy = {
hosts = { hosts = {
"auth.kruining.eu" = '' "auth.kruining.eu" = ''
reverse_proxy h2c://[::1]:9092 reverse_proxy h2c://[::1]:${toString port}
''; '';
}; };
extraConfig = '' extraConfig = ''
(auth) { (auth) {
forward_auth h2c://[::1]:9092 { forward_auth h2c://[::1]:${toString port} {
uri /api/authz/forward-auth uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
} }
@ -612,7 +613,7 @@ in
masterKeyFile = config.sops.secrets."zitadel/masterKey".path; masterKeyFile = config.sops.secrets."zitadel/masterKey".path;
tlsMode = "external"; tlsMode = "external";
settings = { settings = {
Port = 9092; Port = port;
ExternalDomain = "auth.kruining.eu"; ExternalDomain = "auth.kruining.eu";
ExternalPort = 443; ExternalPort = 443;
@ -698,8 +699,6 @@ in
}; };
}; };
networking.firewall.allowedTCPPorts = [ 80 443 ];
# Secrets # Secrets
sops = { sops = {
secrets = { secrets = {

View file

@ -112,10 +112,29 @@ in {
(mkMautrix "mautrix-telegram" 2 {}) (mkMautrix "mautrix-telegram" 2 {})
(mkMautrix "mautrix-whatsapp" 3 {}) (mkMautrix "mautrix-whatsapp" 3 {})
(mkMautrix "arrtrix" 4 { (mkMautrix "arrtrix" 4 {
settings.network.webhooks.radarr = { environmentFile = config.sops.templates."arrtrix/secrets".path;
enabled = true;
path = "/_arrtrix/webhooks/radarr"; settings = {
secret = ""; observability = {
otlp_grpc_endpoint = "http://[::1]:9071";
service_name = "arrtrix";
};
network.content = {
movies = {
url = "http://[::1]:${toString config.services.radarr.settings.server.port}";
api_key = "$RADARR_APIKEY";
root_folder_path = "/var/media/movies";
quality_profile_id = 5;
};
series = {
url = "http://[::1]:${toString config.services.sonarr.settings.server.port}";
api_key = "$SONARR_APIKEY";
root_folder_path = "/var/media/series";
quality_profile_id = 5;
language_profile_id = 1;
};
};
}; };
}) })
{ {
@ -168,7 +187,7 @@ in {
}; };
sso = { sso = {
client_whitelist = ["http://[::1]:9092/" "https://auth.kruining.eu/"]; client_whitelist = ["http://[::1]:${toString config.services.zitadel.settings.Port}/" "https://auth.kruining.eu/"];
update_profile_information = true; update_profile_information = true;
}; };
@ -366,6 +385,14 @@ in {
''; '';
restartUnits = ["matrix-synapse.service"]; restartUnits = ["matrix-synapse.service"];
}; };
"arrtrix/secrets" = {
owner = "arrtrix";
content = ''
RADARR_APIKEY=${config.sops.placeholder."radarr/apikey"}
SONARR_APIKEY=${config.sops.placeholder."sonarr/apikey"}
'';
restartUnits = ["arrtrix.service"];
};
}; };
}; };
}; };

View file

@ -64,7 +64,7 @@ in {
openFirewall = true; openFirewall = true;
user = cfg.user; user = cfg.user;
group = cfg.group; group = cfg.group;
listenPort = 2005; listenPort = 2050;
}; };
postgresql = { postgresql = {

View file

@ -13,11 +13,11 @@ in {
}; };
config = mkIf cfg.enable { config = mkIf cfg.enable {
${namespace}.services.networking.caddy.hosts = { # ${namespace}.services.networking.caddy.hosts = {
"https://${config.networking.hostName}:443" = '' # "https://${config.networking.hostName}.arda:443" = ''
reverse_proxy http://[::1]:2000 # reverse_proxy http://[::1]:2000
''; # '';
}; # };
services.glance = { services.glance = {
enable = true; enable = true;

View file

@ -22,7 +22,7 @@ in {
services.mydia = { services.mydia = {
enable = true; enable = true;
port = 2010; port = 2100;
listenAddress = "0.0.0.0"; listenAddress = "0.0.0.0";
openFirewall = true; openFirewall = true;
@ -54,7 +54,7 @@ in {
qbittorrent = { qbittorrent = {
type = "qbittorrent"; type = "qbittorrent";
host = "localhost"; host = "localhost";
port = 2008; port = 2080;
username = "admin"; username = "admin";
passwordFile = config.sops.secrets."mydia/qbittorrent_password".path; passwordFile = config.sops.secrets."mydia/qbittorrent_password".path;
useSsl = false; useSsl = false;

View file

@ -56,7 +56,8 @@ in {
auth.authenticationMethod = "External"; auth.authenticationMethod = "External";
server = { server = {
bindaddress = "0.0.0.0"; # bindaddress = "0.0.0.0";
bindaddress = "[::]";
port = port; port = port;
}; };
@ -79,7 +80,7 @@ in {
qbittorrent = { qbittorrent = {
enable = true; enable = true;
openFirewall = true; openFirewall = true;
webuiPort = 2008; webuiPort = 2080;
serverConfig = lib.mkForce {}; serverConfig = lib.mkForce {};
user = "qbittorrent"; user = "qbittorrent";
@ -100,7 +101,7 @@ in {
settings = { settings = {
misc = { misc = {
host = "0.0.0.0"; host = "0.0.0.0";
port = 2009; port = 2090;
host_whitelist = "${config.networking.hostName}"; host_whitelist = "${config.networking.hostName}";
permissions = "770"; permissions = "770";
@ -126,7 +127,7 @@ in {
flaresolverr = { flaresolverr = {
enable = true; enable = true;
openFirewall = true; openFirewall = true;
port = 2007; port = 2070;
}; };
postgresql = let postgresql = let
@ -194,7 +195,7 @@ in {
source = "devopsarr/${service}"; source = "devopsarr/${service}";
version = version =
{ {
radarr = "2.3.3"; radarr = "2.3.5";
sonarr = "3.4.0"; sonarr = "3.4.0";
prowlarr = "3.1.0"; prowlarr = "3.1.0";
lidarr = "1.13.0"; lidarr = "1.13.0";
@ -212,6 +213,23 @@ in {
resource = resource =
{ {
"${service}_notification_webhook" = mkIf (lib.elem service ["radarr" "sonarr" "whisparr" "lidarr" "readarr"]) {
"arrtrix" =
{
method = 1; # HTTP METHOD 1=POST, 2=PUT
name = "Arrtrix";
url = "http://localhost:${toString config'.services.arrtrix.settings.appservice.port}/_arrtrix/webhook";
on_grab = true;
on_download = true;
on_rename = true;
on_upgrade = true;
}
// (lib.optionalAttrs (lib.elem service ["radarr" "whisparr"]) {
on_movie_delete = true;
});
};
"${service}_root_folder" = mkIf (lib.elem service ["radarr" "sonarr" "whisparr"]) ( "${service}_root_folder" = mkIf (lib.elem service ["radarr" "sonarr" "whisparr"]) (
rootFolders rootFolders
|> lib.imap (i: f: lib.nameValuePair "local${toString i}" {path = f;}) |> lib.imap (i: f: lib.nameValuePair "local${toString i}" {path = f;})
@ -227,20 +245,30 @@ in {
username = "admin"; username = "admin";
password = lib.tfRef "var.qbittorrent_api_key"; password = lib.tfRef "var.qbittorrent_api_key";
url_base = "/"; url_base = "/";
port = 2008; port = 2080;
}; };
}; };
"${service}_download_client_sabnzbd" = mkIf (lib.elem service ["radarr" "sonarr" "lidarr" "whisparr"]) { "${service}_download_client_sabnzbd" = mkIf (lib.elem service ["radarr" "sonarr" "lidarr" "whisparr"]) {
"main" = { "main" =
{
name = "SABnzbd"; name = "SABnzbd";
enable = true; enable = true;
priority = 1; priority = 1;
host = "localhost"; host = "localhost";
api_key = lib.tfRef "var.sabnzbd_api_key"; api_key = lib.tfRef "var.sabnzbd_api_key";
url_base = "/"; url_base = "/";
port = 2009; port = 2090;
}; }
// ({
radarr = {movie_category = "movies";};
sonarr = {tv_category = "tv";};
lidarr = {music_category = "audio";};
whisparr = {movie_category = "movies";};
readarr = {book_category = "Default";};
}.${
service
});
}; };
} }
// (lib.optionalAttrs (service == "prowlarr") ( // (lib.optionalAttrs (service == "prowlarr") (

View file

@ -24,6 +24,8 @@ in {
}; };
config = mkIf hasHosts { config = mkIf hasHosts {
networking.firewall.allowedTCPPorts = [80 443];
services.caddy = { services.caddy = {
enable = cfg.enable; enable = cfg.enable;

View file

@ -0,0 +1,83 @@
{
config,
lib,
namespace,
...
}: let
inherit (builtins) toString;
inherit (lib) mkEnableOption mkIf;
cfg = config.${namespace}.services.observability.alloy;
httpPort = 9070;
otlpGrpcPort = 9071;
otlpHttpPort = 9072;
tempoOtlpGrpcPort = 9062;
in {
options.${namespace}.services.observability.alloy = {
enable = mkEnableOption "enable Grafana Alloy";
};
config = mkIf cfg.enable {
services.alloy = {
enable = true;
configPath = "/etc/alloy";
extraFlags = [
"--disable-reporting"
"--server.http.listen-addr=[::]:${toString httpPort}"
"--storage.path=/var/lib/alloy"
];
};
environment.etc."alloy/config.alloy".text = ''
otelcol.receiver.otlp "default" {
grpc {
endpoint = "[::1]:${toString otlpGrpcPort}"
}
http {
endpoint = "[::1]:${toString otlpHttpPort}"
}
output {
metrics = [otelcol.processor.batch.metrics.input]
traces = [otelcol.processor.batch.traces.input]
}
}
otelcol.processor.batch "metrics" {
output {
metrics = [otelcol.exporter.prometheus.default.input]
}
}
otelcol.processor.batch "traces" {
output {
traces = [otelcol.exporter.otlp.tempo.input]
}
}
otelcol.exporter.prometheus "default" {
forward_to = [prometheus.remote_write.local.receiver]
}
prometheus.remote_write "local" {
endpoint {
url = "http://[::1]:${toString config.services.prometheus.port}/api/v1/write"
}
}
otelcol.exporter.otlp "tempo" {
client {
endpoint = "[::1]:${toString tempoOtlpGrpcPort}"
tls {
insecure = true
}
}
}
'';
networking.firewall.allowedTCPPorts = [httpPort];
};
}

View file

@ -25,8 +25,8 @@ in {
settings = { settings = {
server = { server = {
http_port = 9001; http_port = 9010;
http_addr = "0.0.0.0"; http_addr = "::";
domain = "ulmo"; domain = "ulmo";
}; };
@ -104,18 +104,38 @@ in {
datasources.settings.datasources = [ datasources.settings.datasources = [
{ {
name = "Prometheus"; name = "Prometheus";
uid = "prometheus";
type = "prometheus"; type = "prometheus";
url = "http://localhost:9005"; url = "http://[::1]:9020";
isDefault = true; isDefault = true;
editable = false; editable = false;
} }
{ {
name = "Loki"; name = "Loki";
uid = "loki";
type = "loki"; type = "loki";
url = "http://localhost:9003"; url = "http://[::1]:9030";
editable = false; editable = false;
} }
{
name = "Tempo";
uid = "tempo";
type = "tempo";
url = "http://localhost:9060";
editable = false;
jsonData = {
nodeGraph.enabled = true;
serviceMap.datasourceUid = "prometheus";
tracesToLogsV2 = {
datasourceUid = "loki";
filterByTraceID = true;
spanStartTimeShift = "-1h";
spanEndTimeShift = "1h";
};
};
}
]; ];
}; };
}; };

View file

@ -17,7 +17,7 @@ in
auth_enabled = false; auth_enabled = false;
server = { server = {
http_listen_port = 9003; http_listen_port = 9030;
}; };
common = { common = {
@ -44,6 +44,6 @@ in
}; };
}; };
networking.firewall.allowedTCPPorts = [ 9003 ]; networking.firewall.allowedTCPPorts = [ 9030 ];
}; };
} }

View file

@ -1,7 +1,7 @@
{ pkgs, config, lib, namespace, ... }: { pkgs, config, lib, namespace, ... }:
let let
inherit (builtins) toString; inherit (builtins) toString;
inherit (lib) mkIf mkEnableOption; inherit (lib) mkEnableOption mkIf optionals;
cfg = config.${namespace}.services.observability.prometheus; cfg = config.${namespace}.services.observability.prometheus;
in in
@ -13,7 +13,10 @@ in
config = mkIf cfg.enable { config = mkIf cfg.enable {
services.prometheus = { services.prometheus = {
enable = true; enable = true;
port = 9002; port = 9020;
extraFlags = optionals config.${namespace}.services.observability.alloy.enable [
"--web.enable-remote-write-receiver"
];
globalConfig.scrape_interval = "15s"; globalConfig.scrape_interval = "15s";
@ -21,7 +24,7 @@ in
{ {
job_name = "prometheus"; job_name = "prometheus";
static_configs = [ static_configs = [
{ targets = [ "localhost:9002" ]; } { targets = [ "localhost:9020" ]; }
]; ];
} }
@ -31,18 +34,34 @@ in
{ targets = [ "localhost:${toString config.services.prometheus.exporters.node.port}" ]; } { targets = [ "localhost:${toString config.services.prometheus.exporters.node.port}" ]; }
]; ];
} }
]
++ optionals config.${namespace}.services.observability.alloy.enable [
{
job_name = "alloy";
static_configs = [
{ targets = [ "localhost:9070" ]; }
];
}
]
++ optionals config.${namespace}.services.observability.tempo.enable [
{
job_name = "tempo";
static_configs = [
{ targets = [ "localhost:9060" ]; }
];
}
]; ];
exporters = { exporters = {
node = { node = {
enable = true; enable = true;
port = 9005; port = 9021;
enabledCollectors = [ "systemd" ]; enabledCollectors = [ "systemd" ];
openFirewall = true; openFirewall = true;
}; };
}; };
}; };
networking.firewall.allowedTCPPorts = [ 9002 ]; networking.firewall.allowedTCPPorts = [ 9020 ];
}; };
} }

View file

@ -25,7 +25,7 @@ in {
configuration = { configuration = {
server = { server = {
http_listen_port = 9004; http_listen_port = 9040;
grpc_listen_port = 0; grpc_listen_port = 0;
}; };
@ -35,7 +35,7 @@ in {
clients = [ clients = [
{ {
url = "http://[::1]:9003/loki/api/v1/push"; url = "http://[::1]:9030/loki/api/v1/push";
} }
]; ];
@ -60,6 +60,6 @@ in {
}; };
}; };
networking.firewall.allowedTCPPorts = [9004]; networking.firewall.allowedTCPPorts = [9040];
}; };
} }

View file

@ -0,0 +1,51 @@
{
config,
lib,
namespace,
...
}: let
inherit (lib) mkEnableOption mkIf;
cfg = config.${namespace}.services.observability.tempo;
httpPort = 9060;
grpcPort = 9061;
otlpGrpcPort = 9062;
otlpHttpPort = 9063;
in {
options.${namespace}.services.observability.tempo = {
enable = mkEnableOption "enable Grafana Tempo";
};
config = mkIf cfg.enable {
services.tempo = {
enable = true;
settings = {
auth_enabled = false;
search_enabled = true;
server = {
http_listen_address = "[::]";
http_listen_port = httpPort;
grpc_listen_address = "[::1]";
grpc_listen_port = grpcPort;
};
distributor.receivers.otlp.protocols = {
grpc.endpoint = "[::1]:${builtins.toString otlpGrpcPort}";
http.endpoint = "[::1]:${builtins.toString otlpHttpPort}";
};
storage.trace = {
backend = "local";
wal.path = "/var/lib/tempo/wal";
local.path = "/var/lib/tempo/traces";
};
compactor.compaction.block_retention = "168h";
};
};
networking.firewall.allowedTCPPorts = [httpPort];
};
}

View file

@ -15,11 +15,11 @@ in
enable = true; enable = true;
settings = { settings = {
PORT = toString 9006; PORT = toString 9050;
HOST = "0.0.0.0"; HOST = "0.0.0.0";
}; };
}; };
networking.firewall.allowedTCPPorts = [ 9006 ]; networking.firewall.allowedTCPPorts = [ 9050 ];
}; };
} }

View file

@ -48,6 +48,31 @@
time_format = " "; time_format = " ";
}; };
}; };
observability = {
otlp_grpc_endpoint = "";
service_name = "arrtrix";
resource_attributes = {};
};
network.content = {
movies = {
url = "";
api_key = "";
root_folder_path = "";
quality_profile_id = 0;
minimum_availability = "released";
search_on_add = true;
};
series = {
url = "";
api_key = "";
root_folder_path = "";
quality_profile_id = 0;
language_profile_id = 0;
season_folder = true;
series_type = "standard";
search_on_add = true;
};
};
}; };
in { in {
options.services.arrtrix = { options.services.arrtrix = {
@ -81,6 +106,18 @@ in {
example = {}; example = {};
}; };
environmentFile = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
File containing environment variables to be passed to the arrtrix service.
If an environment variable `ARRTRIX_BRIDGE_LOGIN_SHARED_SECRET` is set,
then its value will be used in the configuration file for the option
`double_puppet.secrets` without leaking it to the store, using the configured
`homeserver.domain` as key.
'';
};
serviceDependencies = lib.mkOption { serviceDependencies = lib.mkOption {
type = with lib.types; listOf str; type = with lib.types; listOf str;
default = default =
@ -143,6 +180,7 @@ in {
StateDirectory = baseNameOf dataDir; StateDirectory = baseNameOf dataDir;
WorkingDirectory = dataDir; WorkingDirectory = dataDir;
EnvironmentFile = cfg.environmentFile;
ExecStart = '' ExecStart = ''
${lib.getExe cfg.package} --config='${settingsFile}' --registration='${registrationFile}' ${lib.getExe cfg.package} --config='${settingsFile}' --registration='${registrationFile}'

View file

@ -11,7 +11,7 @@ buildGoModule rec {
src = lib.cleanSource ./.; src = lib.cleanSource ./.;
vendorHash = "sha256-FbatoXcxZcnqVUmoj/jeSMFO/iTmD8uga47MoTdGcRw="; vendorHash = "sha256-UYRit+v41djnCx+GFdEl/8WQsp2DzF4ywT9iv3m1pSc=";
subPackages = ["cmd/arrtrix"]; subPackages = ["cmd/arrtrix"];
buildInputs = [olm]; buildInputs = [olm];

View file

@ -3,41 +3,58 @@ module sneeuwvlok/packages/arrtrix
go 1.25.0 go 1.25.0
require ( require (
github.com/rs/zerolog v1.34.0
go.mau.fi/util v0.9.7 go.mau.fi/util v0.9.7
go.mau.fi/zeroconfig v0.2.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
go.opentelemetry.io/otel/log v0.19.0
go.opentelemetry.io/otel/metric v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/sdk/log v0.19.0
go.opentelemetry.io/otel/sdk/metric v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mauflag v1.0.0
maunium.net/go/mautrix v0.26.4 maunium.net/go/mautrix v0.26.4
) )
require (
github.com/kr/pretty v0.3.1 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
)
require ( require (
filippo.io/edwards25519 v1.2.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/coder/websocket v1.8.14 // indirect github.com/coder/websocket v1.8.14 // indirect
github.com/coreos/go-systemd/v22 v22.6.0 // indirect github.com/coreos/go-systemd/v22 v22.6.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/lib/pq v1.11.2 // indirect github.com/lib/pq v1.11.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect
github.com/rs/xid v1.6.0 // indirect github.com/rs/xid v1.6.0 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect github.com/tidwall/sjson v1.2.5 // indirect
github.com/yuin/goldmark v1.7.16 // indirect github.com/yuin/goldmark v1.7.16 // indirect
go.mau.fi/zeroconfig v0.2.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
golang.org/x/crypto v0.49.0 // indirect golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/net v0.52.0 // indirect golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
maunium.net/go/mauflag v1.0.0 // indirect
) )

View file

@ -2,20 +2,33 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo= github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU= github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
@ -31,13 +44,11 @@ github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 h1:rh2lKw/P/EqHa724vYH2+VVQ1YnW4u6EOXl0PMAovZE= github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 h1:rh2lKw/P/EqHa724vYH2+VVQ1YnW4u6EOXl0PMAovZE=
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
@ -63,6 +74,36 @@ go.mau.fi/util v0.9.7 h1:AWGNbJfz1zRcQOKeOEYhKUG2fT+/26Gy6kyqcH8tnBg=
go.mau.fi/util v0.9.7/go.mod h1:5T2f3ZWZFAGgmFwg3dGw7YK6kIsb9lryDzvynoR98pE= go.mau.fi/util v0.9.7/go.mod h1:5T2f3ZWZFAGgmFwg3dGw7YK6kIsb9lryDzvynoR98pE=
go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU=
go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 h1:Dn8rkudDzY6KV9dr/D/bTUuWgqDf9xe0rr4G2elrn0Y=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0/go.mod h1:gMk9F0xDgyN9M/3Ed5Y1wKcx/9mlU91NXY2SNq7RQuU=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4=
go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko=
go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg=
go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk=
go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
@ -78,6 +119,16 @@ golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View file

@ -0,0 +1,76 @@
package arr
import (
"fmt"
"slices"
"strings"
)
type ContentType string
const (
ContentTypeMovies ContentType = "movies"
ContentTypeSeries ContentType = "series"
)
var supportedContentTypes = []ContentType{
ContentTypeMovies,
ContentTypeSeries,
}
var supportedEvents = map[ContentType][]string{
ContentTypeMovies: {"Test", "Grab", "Download", "Rename", "MovieFileDelete", "MovieDelete"},
ContentTypeSeries: {"Test", "Grab", "Download", "Rename", "EpisodeFileDelete", "SeriesDelete"},
}
func SupportedContentTypes() []ContentType {
return append([]ContentType(nil), supportedContentTypes...)
}
func SupportedEventTypes(contentType ContentType) []string {
return append([]string(nil), supportedEvents[contentType]...)
}
func ParseContentType(value string) (ContentType, error) {
contentType := ContentType(strings.ToLower(strings.TrimSpace(value)))
if slices.Contains(supportedContentTypes, contentType) {
return contentType, nil
}
return "", fmt.Errorf("unsupported content type %q (expected one of: %s)", value, Strings())
}
func ParseEventType(contentType ContentType, value string) (string, error) {
value = strings.TrimSpace(value)
if strings.EqualFold(value, "all") {
return "all", nil
}
for _, eventType := range supportedEvents[contentType] {
if strings.EqualFold(eventType, value) {
return eventType, nil
}
}
return "", fmt.Errorf("unsupported event type %q for %s", value, contentType)
}
func SupportsEventType(contentType ContentType, eventType string) bool {
return slices.Contains(supportedEvents[contentType], strings.TrimSpace(eventType))
}
func (c ContentType) Label() string {
switch c {
case ContentTypeMovies:
return "movies"
case ContentTypeSeries:
return "series"
default:
return string(c)
}
}
func Strings() string {
values := make([]string, 0, len(supportedContentTypes))
for _, contentType := range supportedContentTypes {
values = append(values, string(contentType))
}
return strings.Join(values, ", ")
}

View file

@ -0,0 +1,23 @@
package arr
import "testing"
func TestParseContentType(t *testing.T) {
contentType, err := ParseContentType("Movies")
if err != nil {
t.Fatalf("ParseContentType returned error: %v", err)
}
if contentType != ContentTypeMovies {
t.Fatalf("expected movies content type, got %q", contentType)
}
}
func TestParseEventType(t *testing.T) {
eventType, err := ParseEventType(ContentTypeSeries, "download")
if err != nil {
t.Fatalf("ParseEventType returned error: %v", err)
}
if eventType != "Download" {
t.Fatalf("expected Download event type, got %q", eventType)
}
}

View file

@ -0,0 +1,363 @@
package arrclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html"
"io"
"mime"
"net/http"
"net/url"
"path"
"path/filepath"
"strings"
"sneeuwvlok/packages/arrtrix/pkg/arr"
)
type Client interface {
ContentType() arr.ContentType
Search(context.Context, string) ([]SearchResult, error)
List(context.Context, string) ([]ManagedItem, error)
Add(context.Context, SearchResult) (*ManagedItem, error)
SetMonitored(context.Context, int64, bool) (*ManagedItem, error)
Delete(context.Context, int64) error
FetchImage(context.Context, ManagedItem) (*MediaAsset, error)
}
type SearchResult struct {
LookupID int64
Title string
Year int
Overview string
}
type ManagedItem struct {
ID int64
LookupID int64
Title string
Year int
Monitored bool
Path string
ImageURL string
}
type MediaAsset struct {
Data []byte
FileName string
MimeType string
}
type RadarrConfig struct {
URL string `yaml:"url"`
APIKey string `yaml:"api_key"`
RootFolderPath string `yaml:"root_folder_path"`
QualityProfileID int64 `yaml:"quality_profile_id"`
MinimumAvailability string `yaml:"minimum_availability"`
SearchOnAdd *bool `yaml:"search_on_add"`
}
type SonarrConfig struct {
URL string `yaml:"url"`
APIKey string `yaml:"api_key"`
RootFolderPath string `yaml:"root_folder_path"`
QualityProfileID int64 `yaml:"quality_profile_id"`
LanguageProfileID int64 `yaml:"language_profile_id"`
SeasonFolder *bool `yaml:"season_folder"`
SeriesType string `yaml:"series_type"`
SearchOnAdd *bool `yaml:"search_on_add"`
}
type httpClient struct {
baseURL *url.URL
apiKey string
httpClient *http.Client
}
type mediaImage struct {
CoverType string `json:"coverType"`
URL string `json:"url"`
RemoteURL string `json:"remoteUrl"`
}
func (c *RadarrConfig) ApplyDefaults() {
if c.MinimumAvailability == "" {
c.MinimumAvailability = "released"
}
}
func (c RadarrConfig) Enabled() bool {
return strings.TrimSpace(c.URL) != "" || strings.TrimSpace(c.APIKey) != ""
}
func (c RadarrConfig) Validate() error {
if !c.Enabled() {
return nil
}
switch {
case strings.TrimSpace(c.URL) == "":
return fmt.Errorf("network.content.movies.url must be set when movies content is configured")
case strings.TrimSpace(c.APIKey) == "":
return fmt.Errorf("network.content.movies.api_key must be set when movies content is configured")
case strings.TrimSpace(c.RootFolderPath) == "":
return fmt.Errorf("network.content.movies.root_folder_path must be set when movies content is configured")
case c.QualityProfileID <= 0:
return fmt.Errorf("network.content.movies.quality_profile_id must be set when movies content is configured")
case strings.TrimSpace(c.MinimumAvailability) == "":
return fmt.Errorf("network.content.movies.minimum_availability must not be empty")
default:
return nil
}
}
func (c RadarrConfig) SearchOnAddValue() bool {
return boolValue(c.SearchOnAdd, true)
}
func (c *SonarrConfig) ApplyDefaults() {
if c.SeriesType == "" {
c.SeriesType = "standard"
}
}
func (c SonarrConfig) Enabled() bool {
return strings.TrimSpace(c.URL) != "" || strings.TrimSpace(c.APIKey) != ""
}
func (c SonarrConfig) Validate() error {
if !c.Enabled() {
return nil
}
switch {
case strings.TrimSpace(c.URL) == "":
return fmt.Errorf("network.content.series.url must be set when series content is configured")
case strings.TrimSpace(c.APIKey) == "":
return fmt.Errorf("network.content.series.api_key must be set when series content is configured")
case strings.TrimSpace(c.RootFolderPath) == "":
return fmt.Errorf("network.content.series.root_folder_path must be set when series content is configured")
case c.QualityProfileID <= 0:
return fmt.Errorf("network.content.series.quality_profile_id must be set when series content is configured")
case c.LanguageProfileID <= 0:
return fmt.Errorf("network.content.series.language_profile_id must be set when series content is configured")
case strings.TrimSpace(c.SeriesType) == "":
return fmt.Errorf("network.content.series.series_type must not be empty")
default:
return nil
}
}
func (c SonarrConfig) SeasonFolderValue() bool {
return boolValue(c.SeasonFolder, true)
}
func (c SonarrConfig) SearchOnAddValue() bool {
return boolValue(c.SearchOnAdd, true)
}
func newHTTPClient(rawURL, apiKey string) (*httpClient, error) {
parsedURL, err := url.Parse(strings.TrimRight(strings.TrimSpace(rawURL), "/"))
if err != nil {
return nil, err
}
return &httpClient{
baseURL: parsedURL,
apiKey: apiKey,
httpClient: http.DefaultClient,
}, nil
}
func (c *httpClient) do(ctx context.Context, method, requestPath string, query url.Values, body any, dest any) error {
endpoint := *c.baseURL
endpoint.Path = path.Join(endpoint.Path, requestPath)
if len(query) > 0 {
endpoint.RawQuery = query.Encode()
}
var payload io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return err
}
payload = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), payload)
if err != nil {
return err
}
req.Header.Set("X-Api-Key", c.apiKey)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("%s %s returned %d: %s", method, endpoint.String(), resp.StatusCode, strings.TrimSpace(string(data)))
}
if dest == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(dest)
}
func boolValue(value *bool, fallback bool) bool {
if value == nil {
return fallback
}
return *value
}
func containsFold(haystack, needle string) bool {
return strings.Contains(strings.ToLower(haystack), strings.ToLower(strings.TrimSpace(needle)))
}
func FormatSearchResult(result SearchResult) string {
if result.Year != 0 {
return fmt.Sprintf("%s (%d)", result.Title, result.Year)
}
return result.Title
}
func FormatManagedItem(item ManagedItem) string {
if item.Year != 0 {
return fmt.Sprintf("%s (%d)", item.Title, item.Year)
}
return item.Title
}
func EscapeText(text string) string {
return html.EscapeString(text)
}
func (c *httpClient) FetchImage(ctx context.Context, item ManagedItem) (*MediaAsset, error) {
imageURL := strings.TrimSpace(item.ImageURL)
if imageURL == "" {
return nil, nil
}
endpoint, err := url.Parse(imageURL)
if err != nil {
return nil, fmt.Errorf("parse image URL: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL.ResolveReference(endpoint).String(), nil)
if err != nil {
return nil, err
}
if sameHost(req.URL, c.baseURL) {
req.Header.Set("X-Api-Key", c.apiKey)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("GET %s returned %d: %s", req.URL.String(), resp.StatusCode, strings.TrimSpace(string(data)))
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return nil, err
}
mimeType := strings.TrimSpace(resp.Header.Get("Content-Type"))
if idx := strings.Index(mimeType, ";"); idx >= 0 {
mimeType = strings.TrimSpace(mimeType[:idx])
}
if mimeType == "" {
mimeType = http.DetectContentType(data)
}
return &MediaAsset{
Data: data,
FileName: imageFileName(item, endpoint, mimeType),
MimeType: mimeType,
}, nil
}
func (c *httpClient) imageURL(images []mediaImage) string {
for _, coverType := range []string{"poster", "cover", "fanart"} {
for _, image := range images {
if !strings.EqualFold(image.CoverType, coverType) {
continue
}
if resolved := c.resolveMediaURL(image); resolved != "" {
return resolved
}
}
}
return ""
}
func (c *httpClient) resolveMediaURL(image mediaImage) string {
switch {
case strings.TrimSpace(image.URL) != "":
ref, err := url.Parse(strings.TrimSpace(image.URL))
if err != nil {
return ""
}
return c.baseURL.ResolveReference(ref).String()
case strings.TrimSpace(image.RemoteURL) != "":
return strings.TrimSpace(image.RemoteURL)
default:
return ""
}
}
func imageFileName(item ManagedItem, endpoint *url.URL, mimeType string) string {
baseName := sanitizeFileName(strings.TrimSpace(item.Title))
if baseName == "" {
baseName = fmt.Sprintf("arrtrix-%d", item.ID)
}
ext := strings.TrimSpace(filepath.Ext(endpoint.Path))
if ext == "" && mimeType != "" {
if extensions, err := mime.ExtensionsByType(mimeType); err == nil && len(extensions) > 0 {
ext = extensions[0]
}
}
if ext == "" {
ext = ".jpg"
}
if item.ID != 0 {
return fmt.Sprintf("%s-%d%s", baseName, item.ID, ext)
}
return baseName + ext
}
func sanitizeFileName(value string) string {
replacer := strings.NewReplacer(
"<", "",
">", "",
":", "",
"\"", "",
"/", "-",
"\\", "-",
"|", "-",
"?", "",
"*", "",
)
value = replacer.Replace(value)
value = strings.Join(strings.Fields(value), "-")
return strings.Trim(value, ".- ")
}
func sameHost(left, right *url.URL) bool {
if left == nil || right == nil {
return false
}
return strings.EqualFold(left.Scheme, right.Scheme) && strings.EqualFold(left.Host, right.Host)
}

View file

@ -0,0 +1,80 @@
package arrclient
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestImageURLPrefersPosterAndResolvesRelativePath(t *testing.T) {
baseURL, err := url.Parse("https://radarr.example")
if err != nil {
t.Fatalf("failed to parse base URL: %v", err)
}
client := &httpClient{baseURL: baseURL}
imageURL := client.imageURL([]mediaImage{
{CoverType: "fanart", URL: "/MediaCover/1/fanart.jpg"},
{CoverType: "poster", URL: "/MediaCover/1/poster.jpg"},
})
if imageURL != "https://radarr.example/MediaCover/1/poster.jpg" {
t.Fatalf("unexpected image URL %q", imageURL)
}
}
func TestImageURLFallsBackToRemoteURL(t *testing.T) {
baseURL, err := url.Parse("https://sonarr.example")
if err != nil {
t.Fatalf("failed to parse base URL: %v", err)
}
client := &httpClient{baseURL: baseURL}
imageURL := client.imageURL([]mediaImage{
{CoverType: "poster", RemoteURL: "https://images.example/poster.jpg"},
})
if imageURL != "https://images.example/poster.jpg" {
t.Fatalf("unexpected remote image URL %q", imageURL)
}
}
func TestFetchImageUsesAPIKeyForSameHost(t *testing.T) {
headers := make(chan string, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
headers <- r.Header.Get("X-Api-Key")
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write([]byte("jpeg-bytes"))
}))
defer server.Close()
client, err := newHTTPClient(server.URL, "secret")
if err != nil {
t.Fatalf("failed to create client: %v", err)
}
asset, err := client.FetchImage(context.Background(), ManagedItem{
ID: 42,
Title: "Dune Part Two",
ImageURL: server.URL + "/MediaCover/42/poster.jpg",
})
if err != nil {
t.Fatalf("failed to fetch image: %v", err)
}
if asset == nil {
t.Fatal("expected media asset")
}
if got := <-headers; got != "secret" {
t.Fatalf("expected API key header, got %q", got)
}
if got := string(asset.Data); got != "jpeg-bytes" {
t.Fatalf("unexpected media bytes %q", got)
}
if asset.MimeType != "image/jpeg" {
t.Fatalf("unexpected mime type %q", asset.MimeType)
}
if !strings.HasPrefix(asset.FileName, "Dune-Part-Two-42") || !strings.HasSuffix(asset.FileName, ".jpg") {
t.Fatalf("unexpected filename %q", asset.FileName)
}
}

View file

@ -0,0 +1,172 @@
package arrclient
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"sneeuwvlok/packages/arrtrix/pkg/arr"
)
type RadarrClient struct {
http *httpClient
config RadarrConfig
}
type radarrMovie struct {
ID int64 `json:"id"`
Title string `json:"title"`
Year int `json:"year"`
TMDBID int64 `json:"tmdbId"`
Overview string `json:"overview"`
Monitored bool `json:"monitored"`
Path string `json:"path"`
Images []mediaImage `json:"images"`
}
func NewRadarrClient(config RadarrConfig) (*RadarrClient, error) {
config.ApplyDefaults()
if err := config.Validate(); err != nil {
return nil, err
}
httpClient, err := newHTTPClient(config.URL, config.APIKey)
if err != nil {
return nil, err
}
return &RadarrClient{http: httpClient, config: config}, nil
}
func (c *RadarrClient) ContentType() arr.ContentType {
return arr.ContentTypeMovies
}
func (c *RadarrClient) Search(ctx context.Context, query string) ([]SearchResult, error) {
var response []radarrMovie
if err := c.http.do(ctx, http.MethodGet, "/api/v3/movie/lookup", url.Values{"term": {strings.TrimSpace(query)}}, nil, &response); err != nil {
return nil, err
}
results := make([]SearchResult, 0, len(response))
for _, movie := range response {
if movie.TMDBID == 0 {
continue
}
results = append(results, SearchResult{
LookupID: movie.TMDBID,
Title: movie.Title,
Year: movie.Year,
Overview: movie.Overview,
})
}
return results, nil
}
func (c *RadarrClient) List(ctx context.Context, query string) ([]ManagedItem, error) {
var response []radarrMovie
if err := c.http.do(ctx, http.MethodGet, "/api/v3/movie", nil, nil, &response); err != nil {
return nil, err
}
items := make([]ManagedItem, 0, len(response))
for _, movie := range response {
if query != "" && !containsFold(movie.Title, query) && !containsFold(strconv.Itoa(movie.Year), query) {
continue
}
items = append(items, ManagedItem{
ID: movie.ID,
LookupID: movie.TMDBID,
Title: movie.Title,
Year: movie.Year,
Monitored: movie.Monitored,
Path: movie.Path,
ImageURL: c.http.imageURL(movie.Images),
})
}
return items, nil
}
func (c *RadarrClient) Add(ctx context.Context, result SearchResult) (*ManagedItem, error) {
payload := map[string]any{
"title": result.Title,
"tmdbId": result.LookupID,
"year": result.Year,
"qualityProfileId": c.config.QualityProfileID,
"rootFolderPath": c.config.RootFolderPath,
"minimumAvailability": c.config.MinimumAvailability,
"monitored": true,
"addOptions": map[string]any{
"searchForMovie": c.config.SearchOnAddValue(),
},
}
var response radarrMovie
if err := c.http.do(ctx, http.MethodPost, "/api/v3/movie", nil, payload, &response); err != nil {
return nil, err
}
item := ManagedItem{
ID: response.ID,
LookupID: response.TMDBID,
Title: response.Title,
Year: response.Year,
Monitored: response.Monitored,
Path: response.Path,
ImageURL: c.http.imageURL(response.Images),
}
return &item, nil
}
func (c *RadarrClient) SetMonitored(ctx context.Context, id int64, monitored bool) (*ManagedItem, error) {
var movie map[string]any
endpoint := "/api/v3/movie/" + strconv.FormatInt(id, 10)
if err := c.http.do(ctx, http.MethodGet, endpoint, nil, nil, &movie); err != nil {
return nil, err
}
movie["monitored"] = monitored
var response radarrMovie
if err := c.http.do(ctx, http.MethodPut, endpoint, nil, movie, &response); err != nil {
return nil, err
}
item := ManagedItem{
ID: response.ID,
LookupID: response.TMDBID,
Title: response.Title,
Year: response.Year,
Monitored: response.Monitored,
Path: response.Path,
ImageURL: c.http.imageURL(response.Images),
}
return &item, nil
}
func (c *RadarrClient) Delete(ctx context.Context, id int64) error {
return c.http.do(ctx, http.MethodDelete, "/api/v3/movie/"+strconv.FormatInt(id, 10), url.Values{
"deleteFiles": {"false"},
"addImportExclusion": {"false"},
}, nil, nil)
}
func (c *RadarrClient) FetchImage(ctx context.Context, item ManagedItem) (*MediaAsset, error) {
return c.http.FetchImage(ctx, item)
}
func PickSingleResult(results []SearchResult, query string) (SearchResult, error) {
switch len(results) {
case 0:
return SearchResult{}, fmt.Errorf("no matching result found for %q", query)
case 1:
return results[0], nil
default:
normalized := strings.TrimSpace(strings.ToLower(query))
for _, result := range results {
title := strings.ToLower(FormatSearchResult(result))
if title == normalized {
return result, nil
}
}
return SearchResult{}, fmt.Errorf("multiple results matched %q", query)
}
}

View file

@ -0,0 +1,157 @@
package arrclient
import (
"context"
"net/http"
"net/url"
"strconv"
"strings"
"sneeuwvlok/packages/arrtrix/pkg/arr"
)
type SonarrClient struct {
http *httpClient
config SonarrConfig
}
type sonarrSeries struct {
ID int64 `json:"id"`
Title string `json:"title"`
Year int `json:"year"`
TVDBID int64 `json:"tvdbId"`
Overview string `json:"overview"`
Monitored bool `json:"monitored"`
Path string `json:"path"`
Images []mediaImage `json:"images"`
}
func NewSonarrClient(config SonarrConfig) (*SonarrClient, error) {
config.ApplyDefaults()
if err := config.Validate(); err != nil {
return nil, err
}
httpClient, err := newHTTPClient(config.URL, config.APIKey)
if err != nil {
return nil, err
}
return &SonarrClient{http: httpClient, config: config}, nil
}
func (c *SonarrClient) ContentType() arr.ContentType {
return arr.ContentTypeSeries
}
func (c *SonarrClient) Search(ctx context.Context, query string) ([]SearchResult, error) {
var response []sonarrSeries
if err := c.http.do(ctx, http.MethodGet, "/api/v3/series/lookup", url.Values{"term": {strings.TrimSpace(query)}}, nil, &response); err != nil {
return nil, err
}
results := make([]SearchResult, 0, len(response))
for _, series := range response {
if series.TVDBID == 0 {
continue
}
results = append(results, SearchResult{
LookupID: series.TVDBID,
Title: series.Title,
Year: series.Year,
Overview: series.Overview,
})
}
return results, nil
}
func (c *SonarrClient) List(ctx context.Context, query string) ([]ManagedItem, error) {
var response []sonarrSeries
if err := c.http.do(ctx, http.MethodGet, "/api/v3/series", nil, nil, &response); err != nil {
return nil, err
}
items := make([]ManagedItem, 0, len(response))
for _, series := range response {
if query != "" && !containsFold(series.Title, query) && !containsFold(strconv.Itoa(series.Year), query) {
continue
}
items = append(items, ManagedItem{
ID: series.ID,
LookupID: series.TVDBID,
Title: series.Title,
Year: series.Year,
Monitored: series.Monitored,
Path: series.Path,
ImageURL: c.http.imageURL(series.Images),
})
}
return items, nil
}
func (c *SonarrClient) Add(ctx context.Context, result SearchResult) (*ManagedItem, error) {
payload := map[string]any{
"title": result.Title,
"tvdbId": result.LookupID,
"qualityProfileId": c.config.QualityProfileID,
"languageProfileId": c.config.LanguageProfileID,
"rootFolderPath": c.config.RootFolderPath,
"seasonFolder": c.config.SeasonFolderValue(),
"monitored": true,
"seriesType": c.config.SeriesType,
"addOptions": map[string]any{
"searchForMissingEpisodes": c.config.SearchOnAddValue(),
},
}
if result.Year != 0 {
payload["year"] = result.Year
}
var response sonarrSeries
if err := c.http.do(ctx, http.MethodPost, "/api/v3/series", nil, payload, &response); err != nil {
return nil, err
}
item := ManagedItem{
ID: response.ID,
LookupID: response.TVDBID,
Title: response.Title,
Year: response.Year,
Monitored: response.Monitored,
Path: response.Path,
ImageURL: c.http.imageURL(response.Images),
}
return &item, nil
}
func (c *SonarrClient) SetMonitored(ctx context.Context, id int64, monitored bool) (*ManagedItem, error) {
var series map[string]any
endpoint := "/api/v3/series/" + strconv.FormatInt(id, 10)
if err := c.http.do(ctx, http.MethodGet, endpoint, nil, nil, &series); err != nil {
return nil, err
}
series["monitored"] = monitored
var response sonarrSeries
if err := c.http.do(ctx, http.MethodPut, endpoint, nil, series, &response); err != nil {
return nil, err
}
item := ManagedItem{
ID: response.ID,
LookupID: response.TVDBID,
Title: response.Title,
Year: response.Year,
Monitored: response.Monitored,
Path: response.Path,
ImageURL: c.http.imageURL(response.Images),
}
return &item, nil
}
func (c *SonarrClient) Delete(ctx context.Context, id int64) error {
return c.http.do(ctx, http.MethodDelete, "/api/v3/series/"+strconv.FormatInt(id, 10), url.Values{
"deleteFiles": {"false"},
"addImportListExclusion": {"false"},
}, nil, nil)
}
func (c *SonarrClient) FetchImage(ctx context.Context, item ManagedItem) (*MediaAsset, error) {
return c.http.FetchImage(ctx, item)
}

View file

@ -6,6 +6,8 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"maunium.net/go/mautrix/bridgev2/bridgeconfig" "maunium.net/go/mautrix/bridgev2/bridgeconfig"
"sneeuwvlok/packages/arrtrix/pkg/observability"
) )
type Config struct { type Config struct {
@ -17,6 +19,7 @@ type Config struct {
AppService bridgeconfig.AppserviceConfig `yaml:"appservice"` AppService bridgeconfig.AppserviceConfig `yaml:"appservice"`
Logging zeroconfig.Config `yaml:"logging"` Logging zeroconfig.Config `yaml:"logging"`
Observability observability.Config `yaml:"observability"`
EnvConfigPrefix string `yaml:"env_config_prefix"` EnvConfigPrefix string `yaml:"env_config_prefix"`
ManagementTexts bridgeconfig.ManagementRoomTexts `yaml:"management_room_texts"` ManagementTexts bridgeconfig.ManagementRoomTexts `yaml:"management_room_texts"`
} }
@ -34,6 +37,7 @@ func (c *Config) applyDefaults() {
if c.Homeserver.Software == "" { if c.Homeserver.Software == "" {
c.Homeserver.Software = bridgeconfig.SoftwareStandard c.Homeserver.Software = bridgeconfig.SoftwareStandard
} }
c.Observability.ApplyDefaults()
} }
func (c *Config) Compile() bridgeconfig.Config { func (c *Config) Compile() bridgeconfig.Config {

View file

@ -8,13 +8,23 @@ import (
up "go.mau.fi/util/configupgrade" up "go.mau.fi/util/configupgrade"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"sneeuwvlok/packages/arrtrix/pkg/arr"
"sneeuwvlok/packages/arrtrix/pkg/arrclient"
"sneeuwvlok/packages/arrtrix/pkg/subscriptions"
"sneeuwvlok/packages/arrtrix/pkg/webhook" "sneeuwvlok/packages/arrtrix/pkg/webhook"
) )
//go:embed example-config.yaml //go:embed example-config.yaml
var ExampleConfig string var ExampleConfig string
type Config struct{} type Config struct {
Content ContentConfig `yaml:"content"`
}
type ContentConfig struct {
Movies arrclient.RadarrConfig `yaml:"movies"`
Series arrclient.SonarrConfig `yaml:"series"`
}
func upgradeConfig(helper up.Helper) {} func upgradeConfig(helper up.Helper) {}
@ -23,6 +33,14 @@ func (s *ArrtrixConnector) GetConfig() (string, any, up.Upgrader) {
} }
func (s *ArrtrixConnector) ValidateConfig() error { func (s *ArrtrixConnector) ValidateConfig() error {
s.Config.Content.Movies.ApplyDefaults()
s.Config.Content.Series.ApplyDefaults()
if err := s.Config.Content.Movies.Validate(); err != nil {
return err
}
if err := s.Config.Content.Series.Validate(); err != nil {
return err
}
return nil return nil
} }
@ -30,7 +48,27 @@ func (s *ArrtrixConnector) MountRoutes(router *http.ServeMux) error {
if s.Bridge == nil { if s.Bridge == nil {
return fmt.Errorf("bridge is not initialized") return fmt.Errorf("bridge is not initialized")
} }
return webhook.MountArr(router, s.Bridge) return webhook.MountArr(router, s.Bridge, s.Subscriptions())
} }
var _ bridgev2.ConfigValidatingNetwork = (*ArrtrixConnector)(nil) var _ bridgev2.ConfigValidatingNetwork = (*ArrtrixConnector)(nil)
var _ webhook.SubscriptionFilter = (*subscriptions.Repository)(nil)
func (c ContentConfig) Client(contentType arr.ContentType) (arrclient.Client, bool, error) {
switch contentType {
case arr.ContentTypeMovies:
if !c.Movies.Enabled() {
return nil, false, nil
}
client, err := arrclient.NewRadarrClient(c.Movies)
return client, true, err
case arr.ContentTypeSeries:
if !c.Series.Enabled() {
return nil, false, nil
}
client, err := arrclient.NewSonarrClient(c.Series)
return client, true, err
default:
return nil, false, fmt.Errorf("unsupported content type %q", contentType)
}
}

View file

@ -0,0 +1,23 @@
package connector
import "testing"
func TestValidateConfigRejectsPartialMoviesConfig(t *testing.T) {
conn := &ArrtrixConnector{
Config: Config{
Content: ContentConfig{},
},
}
conn.Config.Content.Movies.URL = "http://radarr.test"
if err := conn.ValidateConfig(); err == nil {
t.Fatal("expected partial movies config to fail validation")
}
}
func TestValidateConfigAllowsEmptyContentConfig(t *testing.T) {
conn := &ArrtrixConnector{}
if err := conn.ValidateConfig(); err != nil {
t.Fatalf("ValidateConfig returned error: %v", err)
}
}

View file

@ -10,11 +10,17 @@ import (
"maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"sneeuwvlok/packages/arrtrix/pkg/arr"
"sneeuwvlok/packages/arrtrix/pkg/arrclient"
"sneeuwvlok/packages/arrtrix/pkg/subscriptions"
) )
type ArrtrixConnector struct { type ArrtrixConnector struct {
Bridge *bridgev2.Bridge Bridge *bridgev2.Bridge
Config Config Config Config
clients map[arr.ContentType]arrclient.Client
subscriptions *subscriptions.Repository
} }
var _ bridgev2.NetworkConnector = (*ArrtrixConnector)(nil) var _ bridgev2.NetworkConnector = (*ArrtrixConnector)(nil)
@ -33,6 +39,17 @@ func (s *ArrtrixConnector) GetName() bridgev2.BridgeName {
func (s *ArrtrixConnector) Init(bridge *bridgev2.Bridge) { func (s *ArrtrixConnector) Init(bridge *bridgev2.Bridge) {
s.Bridge = bridge s.Bridge = bridge
s.subscriptions = subscriptions.NewRepository(bridge.DB.Database, string(bridge.ID))
s.clients = make(map[arr.ContentType]arrclient.Client)
for _, contentType := range arr.SupportedContentTypes() {
client, ok, err := s.Config.Content.Client(contentType)
if err != nil {
panic(err)
}
if ok {
s.clients[contentType] = client
}
}
} }
func (s *ArrtrixConnector) Start(context.Context) error { func (s *ArrtrixConnector) Start(context.Context) error {
@ -107,3 +124,12 @@ func (c *ArrtrixClient) HandleMatrixMessage(context.Context, *bridgev2.MatrixMes
func (c *ArrtrixClient) GenerateTransactionID(userID id.UserID, roomID id.RoomID, eventType event.Type) networkid.RawTransactionID { func (c *ArrtrixClient) GenerateTransactionID(userID id.UserID, roomID id.RoomID, eventType event.Type) networkid.RawTransactionID {
return networkid.RawTransactionID("") return networkid.RawTransactionID("")
} }
func (s *ArrtrixConnector) ContentClient(contentType arr.ContentType) (arrclient.Client, bool) {
client, ok := s.clients[contentType]
return client, ok
}
func (s *ArrtrixConnector) Subscriptions() *subscriptions.Repository {
return s.subscriptions
}

View file

@ -1,4 +1,23 @@
# No network-specific config is required yet. content:
# movies:
# Radarr connection for movie management commands.
url: ""
api_key: ""
root_folder_path: ""
quality_profile_id: 0
minimum_availability: released
search_on_add: true
series:
# Sonarr connection for series management commands.
url: ""
api_key: ""
root_folder_path: ""
quality_profile_id: 0
language_profile_id: 0
season_folder: true
series_type: standard
search_on_add: true
# Arr-stack webhooks are exposed automatically on the fixed built-in path: # Arr-stack webhooks are exposed automatically on the fixed built-in path:
# POST /_arrtrix/webhook # POST /_arrtrix/webhook

View file

@ -0,0 +1,260 @@
package matrixcmd
import (
"fmt"
"strconv"
"strings"
"maunium.net/go/mautrix/id"
"sneeuwvlok/packages/arrtrix/pkg/arr"
"sneeuwvlok/packages/arrtrix/pkg/arrclient"
"sneeuwvlok/packages/arrtrix/pkg/subscriptions"
)
type commandServiceProvider interface {
ContentClient(arr.ContentType) (arrclient.Client, bool)
Subscriptions() *subscriptions.Repository
}
func NewDownloadHandler() Handler {
return NewHandler(Meta{
Name: "download",
Description: "Manage monitored movies and series in Arr.",
Usage: "<list|search|add|monitor|remove> <movies|series> [...]",
}, func(ctx *Context) {
if len(ctx.Args) < 2 {
ctx.Reply("Usage: `download <list|search|add|monitor|remove> <movies|series> [...]`")
return
}
contentType, err := arr.ParseContentType(ctx.Args[1])
if err != nil {
ctx.Reply(err.Error())
return
}
client, ok := contentClient(ctx, contentType)
if !ok {
ctx.Reply("No %s client is configured yet.", contentType.Label())
return
}
switch strings.ToLower(ctx.Args[0]) {
case "list":
handleDownloadList(ctx, client, contentType)
case "search":
handleDownloadSearch(ctx, client, contentType)
case "add":
handleDownloadAdd(ctx, client, contentType)
case "monitor":
handleDownloadMonitor(ctx, client, contentType)
case "remove":
handleDownloadRemove(ctx, client, contentType)
default:
ctx.Reply("Unknown download subcommand `%s`.", ctx.Args[0])
}
})
}
func handleDownloadList(ctx *Context, client arrclient.Client, contentType arr.ContentType) {
query := strings.TrimSpace(strings.Join(ctx.Args[2:], " "))
items, err := client.List(ctx.Ctx, query)
if err != nil {
ctx.Reply("Failed to list %s: %v", contentType.Label(), err)
return
}
if len(items) == 0 {
if query == "" {
ctx.Reply("No monitored %s are currently tracked.", contentType.Label())
} else {
ctx.Reply("No %s matched `%s`.", contentType.Label(), query)
}
return
}
count := len(items)
if count > 12 {
count = 12
}
ctx.Reply("Tracked %s (showing %d of %d):", contentType.Label(), count, len(items))
for i, item := range items {
if i == 12 {
break
}
if err := replyWithManagedItem(ctx, client, item); err != nil {
ctx.Log.Err(err).Int64("item_id", item.ID).Str("content_type", contentType.Label()).Msg("Failed to send Matrix-native image for download listing")
}
}
if len(items) > 12 {
ctx.Reply("…and %d more.", len(items)-12)
}
}
func handleDownloadSearch(ctx *Context, client arrclient.Client, contentType arr.ContentType) {
query := strings.TrimSpace(strings.Join(ctx.Args[2:], " "))
if query == "" {
ctx.Reply("Usage: `download search %s <query>`", contentType.Label())
return
}
results, err := client.Search(ctx.Ctx, query)
if err != nil {
ctx.Reply("Failed to search %s: %v", contentType.Label(), err)
return
}
replyWithSearchResults(ctx, contentType, query, results)
}
func handleDownloadAdd(ctx *Context, client arrclient.Client, contentType arr.ContentType) {
query := strings.TrimSpace(strings.Join(ctx.Args[2:], " "))
if query == "" {
ctx.Reply("Usage: `download add %s <query>`", contentType.Label())
return
}
results, err := client.Search(ctx.Ctx, query)
if err != nil {
ctx.Reply("Failed to search %s: %v", contentType.Label(), err)
return
}
result, err := arrclient.PickSingleResult(results, query)
if err != nil {
replyWithSearchResults(ctx, contentType, query, results)
return
}
item, err := client.Add(ctx.Ctx, result)
if err != nil {
ctx.Reply("Failed to add %s: %v", contentType.Label(), err)
return
}
ctx.Reply("Added %s to %s with id `%d`.", formatManagedItem(*item), contentType.Label(), item.ID)
}
func handleDownloadMonitor(ctx *Context, client arrclient.Client, contentType arr.ContentType) {
if len(ctx.Args) < 4 {
ctx.Reply("Usage: `download monitor %s <id> <on|off>`", contentType.Label())
return
}
itemID, err := strconv.ParseInt(ctx.Args[2], 10, 64)
if err != nil {
ctx.Reply("Invalid %s id `%s`.", contentType.Label(), ctx.Args[2])
return
}
state, err := parseEnabled(ctx.Args[3])
if err != nil {
ctx.Reply(err.Error())
return
}
item, err := client.SetMonitored(ctx.Ctx, itemID, state)
if err != nil {
ctx.Reply("Failed to update %s monitoring: %v", contentType.Label(), err)
return
}
ctx.Reply("%s is now monitored=%t.", formatManagedItem(*item), item.Monitored)
}
func handleDownloadRemove(ctx *Context, client arrclient.Client, contentType arr.ContentType) {
if len(ctx.Args) < 3 {
ctx.Reply("Usage: `download remove %s <id>`", contentType.Label())
return
}
itemID, err := strconv.ParseInt(ctx.Args[2], 10, 64)
if err != nil {
ctx.Reply("Invalid %s id `%s`.", contentType.Label(), ctx.Args[2])
return
}
if err = client.Delete(ctx.Ctx, itemID); err != nil {
ctx.Reply("Failed to remove %s: %v", contentType.Label(), err)
return
}
ctx.Reply("Removed `%d` from %s.", itemID, contentType.Label())
}
func contentClient(ctx *Context, contentType arr.ContentType) (arrclient.Client, bool) {
provider, ok := ctx.Bridge.Network.(commandServiceProvider)
if !ok {
return nil, false
}
return provider.ContentClient(contentType)
}
func contentSubscriptions(ctx *Context) *subscriptions.Repository {
provider, ok := ctx.Bridge.Network.(commandServiceProvider)
if !ok {
return nil
}
return provider.Subscriptions()
}
func replyWithSearchResults(ctx *Context, contentType arr.ContentType, query string, results []arrclient.SearchResult) {
if len(results) == 0 {
ctx.Reply("No %s matched `%s`.", contentType.Label(), query)
return
}
var builder strings.Builder
builder.WriteString(fmt.Sprintf("Search results for `%s` in %s:\n", query, contentType.Label()))
for i, result := range results {
if i == 8 {
builder.WriteString("…\n")
break
}
builder.WriteString(fmt.Sprintf("- `%d` %s\n", result.LookupID, arrclient.FormatSearchResult(result)))
}
builder.WriteString(fmt.Sprintf("\nRefine the query and rerun `download add %s <query>` until only one match remains.", contentType.Label()))
ctx.Reply(builder.String())
}
func formatManagedItem(item arrclient.ManagedItem) string {
return arrclient.FormatManagedItem(item)
}
func parseEnabled(value string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "on", "true", "yes", "enabled":
return true, nil
case "off", "false", "no", "disabled":
return false, nil
default:
return false, fmt.Errorf("expected `on` or `off`, got `%s`", value)
}
}
func userIDString(userID id.UserID) string {
return userID.String()
}
func replyWithManagedItem(ctx *Context, client arrclient.Client, item arrclient.ManagedItem) error {
details := formatDownloadListCaption(item)
if item.ImageURL != "" {
asset, err := client.FetchImage(ctx.Ctx, item)
if err != nil {
ctx.Log.Err(err).Int64("item_id", item.ID).Msg("Failed to fetch poster for Matrix listing")
} else if asset != nil {
if err := ctx.SendImage(asset, details); err != nil {
ctx.Log.Err(err).Int64("item_id", item.ID).Msg("Failed to upload poster for Matrix listing")
} else {
return nil
}
} else {
ctx.Log.Debug().Int64("item_id", item.ID).Msg("Poster was empty for Matrix listing")
}
}
ctx.Reply(details)
return nil
}
func formatDownloadListCaption(item arrclient.ManagedItem) string {
return fmt.Sprintf("%s %s", monitoredIcon(item.Monitored), arrclient.FormatManagedItem(item))
}
func formatDownloadListFallbackCard(item arrclient.ManagedItem) string {
return formatDownloadListCaption(item)
}
func monitoredIcon(monitored bool) string {
if monitored {
return "👁"
}
return "🚫"
}

View file

@ -0,0 +1,44 @@
package matrixcmd
import (
"testing"
"sneeuwvlok/packages/arrtrix/pkg/arrclient"
)
func TestFormatDownloadListFallbackCardUsesMonitoredIcon(t *testing.T) {
item := arrclient.ManagedItem{
ID: 1,
Title: "Severance",
Year: 2022,
Monitored: true,
}
fallback := formatDownloadListFallbackCard(item)
if fallback != "👁 Severance (2022)" {
t.Fatalf("unexpected monitored fallback %q", fallback)
}
}
func TestFormatDownloadListFallbackCardUsesUnmonitoredIcon(t *testing.T) {
item := arrclient.ManagedItem{
ID: 7,
Title: "Andor",
Year: 2022,
Monitored: false,
}
fallback := formatDownloadListFallbackCard(item)
if fallback != "🚫 Andor (2022)" {
t.Fatalf("unexpected unmonitored fallback %q", fallback)
}
}
func TestMonitoredIcon(t *testing.T) {
if monitoredIcon(true) != "👁" {
t.Fatalf("expected monitored icon, got %q", monitoredIcon(true))
}
if monitoredIcon(false) != "🚫" {
t.Fatalf("expected unmonitored icon, got %q", monitoredIcon(false))
}
}

View file

@ -18,6 +18,8 @@ func TestFormatHelpManagementRoom(t *testing.T) {
alias: make(map[string]string), alias: make(map[string]string),
} }
proc.Add(NewHelpHandler(proc)) proc.Add(NewHelpHandler(proc))
proc.Add(NewDownloadHandler())
proc.Add(NewSubscriptionsHandler())
out := formatHelp(proc, &Context{ out := formatHelp(proc, &Context{
Bridge: &bridgev2.Bridge{ Bridge: &bridgev2.Bridge{
@ -32,7 +34,9 @@ func TestFormatHelpManagementRoom(t *testing.T) {
for _, fragment := range []string{ for _, fragment := range []string{
"prefixing commands with `!arr` is not required", "prefixing commands with `!arr` is not required",
"**download** <list|search|add|monitor|remove> <movies|series> [...] - Manage monitored movies and series in Arr.",
"**help** - Show this help message.", "**help** - Show this help message.",
"**subscriptions** <list|enable|disable> [movies|series] [event-type|all] - Manage notification subscriptions by content type and event type.",
"Extra help text.", "Extra help text.",
} { } {
if !strings.Contains(out, fragment) { if !strings.Contains(out, fragment) {

View file

@ -8,6 +8,8 @@ import (
"strings" "strings"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/bridgeconfig" "maunium.net/go/mautrix/bridgev2/bridgeconfig"
@ -15,6 +17,9 @@ import (
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format" "maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"sneeuwvlok/packages/arrtrix/pkg/arrclient"
"sneeuwvlok/packages/arrtrix/pkg/observability"
) )
type Handler interface { type Handler interface {
@ -83,6 +88,8 @@ func NewProcessor(bridge *bridgev2.Bridge, texts bridgeconfig.ManagementRoomText
alias: make(map[string]string), alias: make(map[string]string),
} }
proc.Add(NewHelpHandler(proc)) proc.Add(NewHelpHandler(proc))
proc.Add(NewDownloadHandler())
proc.Add(NewSubscriptionsHandler())
return proc return proc
} }
@ -110,6 +117,9 @@ func (p *Processor) Handlers() []Handler {
} }
func (p *Processor) Handle(ctx context.Context, roomID id.RoomID, eventID id.EventID, user *bridgev2.User, message string, replyTo id.EventID) { func (p *Processor) Handle(ctx context.Context, roomID id.RoomID, eventID id.EventID, user *bridgev2.User, message string, replyTo id.EventID) {
ctx, span := observability.StartSpan(ctx, "arrtrix.matrix.command")
defer span.End()
ms := &bridgev2.MessageStatus{ ms := &bridgev2.MessageStatus{
Step: status.MsgStepCommand, Step: status.MsgStepCommand,
Status: event.MessageStatusSuccess, Status: event.MessageStatusSuccess,
@ -117,6 +127,8 @@ func (p *Processor) Handle(ctx context.Context, roomID id.RoomID, eventID id.Eve
logCopy := zerolog.Ctx(ctx).With().Logger() logCopy := zerolog.Ctx(ctx).With().Logger()
log := &logCopy log := &logCopy
outcome := "success"
commandName := "unknown-command"
defer func() { defer func() {
statusInfo := &bridgev2.MessageStatusEventInfo{ statusInfo := &bridgev2.MessageStatusEventInfo{
@ -131,16 +143,21 @@ func (p *Processor) Handle(ctx context.Context, roomID id.RoomID, eventID id.Eve
if err, ok := recovered.(error); ok { if err, ok := recovered.(error); ok {
logEvt = logEvt.Err(err) logEvt = logEvt.Err(err)
ms.InternalError = err ms.InternalError = err
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
} else { } else {
logEvt = logEvt.Any(zerolog.ErrorFieldName, recovered) logEvt = logEvt.Any(zerolog.ErrorFieldName, recovered)
ms.InternalError = fmt.Errorf("%v", recovered) ms.InternalError = fmt.Errorf("%v", recovered)
span.SetStatus(codes.Error, "panic")
} }
logEvt.Msg("Panic in arrtrix Matrix command handler") logEvt.Msg("Panic in arrtrix Matrix command handler")
ms.Status = event.MessageStatusFail ms.Status = event.MessageStatusFail
ms.IsCertain = true ms.IsCertain = true
ms.ErrorAsMessage = true ms.ErrorAsMessage = true
outcome = "panic"
} }
observability.RecordCommand(ctx, commandName, outcome)
p.bridge.Matrix.SendMessageStatus(ctx, ms, statusInfo) p.bridge.Matrix.SendMessageStatus(ctx, ms, statusInfo)
}() }()
@ -149,10 +166,14 @@ func (p *Processor) Handle(ctx context.Context, roomID id.RoomID, eventID id.Eve
args = []string{"unknown-command"} args = []string{"unknown-command"}
} }
commandName := strings.ToLower(args[0]) commandName = strings.ToLower(args[0])
if actual, ok := p.alias[commandName]; ok { if actual, ok := p.alias[commandName]; ok {
commandName = actual commandName = actual
} }
span.SetAttributes(
attribute.String("arrtrix.matrix.command.name", commandName),
attribute.String("matrix.room_id", roomID.String()),
)
portal, err := p.bridge.GetPortalByMXID(ctx, roomID) portal, err := p.bridge.GetPortalByMXID(ctx, roomID)
if err != nil { if err != nil {
@ -179,6 +200,8 @@ func (p *Processor) Handle(ctx context.Context, roomID id.RoomID, eventID id.Eve
handler, ok := p.command[commandName] handler, ok := p.command[commandName]
if !ok { if !ok {
log.Debug().Str("mx_command", commandName).Msg("Received unknown Matrix room command") log.Debug().Str("mx_command", commandName).Msg("Received unknown Matrix room command")
span.SetStatus(codes.Error, "unknown command")
outcome = "unknown"
commandCtx.Reply("Unknown command, use the `help` command for help.") commandCtx.Reply("Unknown command, use the `help` command for help.")
return return
} }
@ -188,6 +211,7 @@ func (p *Processor) Handle(ctx context.Context, roomID id.RoomID, eventID id.Eve
}) })
log.Debug().Msg("Received Matrix room command") log.Debug().Msg("Received Matrix room command")
handler.Run(commandCtx) handler.Run(commandCtx)
span.SetStatus(codes.Ok, "")
} }
func (c *Context) Reply(message string, args ...any) { func (c *Context) Reply(message string, args ...any) {
@ -198,7 +222,49 @@ func (c *Context) Reply(message string, args ...any) {
content := format.RenderMarkdown(message, true, false) content := format.RenderMarkdown(message, true, false)
content.MsgType = event.MsgNotice content.MsgType = event.MsgNotice
if _, err := c.Bot.SendMessage(c.Ctx, c.OrigRoomID, event.EventMessage, &event.Content{Parsed: &content}, nil); err != nil { if err := c.sendNotice(&content); err != nil {
c.Log.Err(err).Msg("Failed to reply to Matrix room command") c.Log.Err(err).Msg("Failed to reply to Matrix room command")
} }
} }
func (c *Context) ReplyFormatted(body, formattedBody string) {
content := &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: body,
Format: event.FormatHTML,
FormattedBody: formattedBody,
}
if err := c.sendNotice(content); err != nil {
c.Log.Err(err).Msg("Failed to reply to Matrix room command")
}
}
func (c *Context) SendImage(asset *arrclient.MediaAsset, body string) error {
if asset == nil || len(asset.Data) == 0 {
return nil
}
mxcURL, file, err := c.Bot.UploadMedia(c.Ctx, c.OrigRoomID, asset.Data, asset.FileName, asset.MimeType)
if err != nil {
return err
}
content := &event.MessageEventContent{
MsgType: event.MsgImage,
Body: body,
FileName: asset.FileName,
URL: mxcURL,
File: file,
Info: &event.FileInfo{
MimeType: asset.MimeType,
Size: len(asset.Data),
},
}
_, err = c.Bot.SendMessage(c.Ctx, c.OrigRoomID, event.EventMessage, &event.Content{Parsed: content}, nil)
return err
}
func (c *Context) sendNotice(content *event.MessageEventContent) error {
_, err := c.Bot.SendMessage(c.Ctx, c.OrigRoomID, event.EventMessage, &event.Content{Parsed: content}, nil)
return err
}

View file

@ -0,0 +1,107 @@
package matrixcmd
import (
"context"
"fmt"
"strings"
"maunium.net/go/mautrix/id"
"sneeuwvlok/packages/arrtrix/pkg/arr"
"sneeuwvlok/packages/arrtrix/pkg/subscriptions"
)
func NewSubscriptionsHandler() Handler {
return NewHandler(Meta{
Name: "subscriptions",
Aliases: []string{"subscription", "notify"},
Description: "Manage notification subscriptions by content type and event type.",
Usage: "<list|enable|disable> [movies|series] [event-type|all]",
}, func(ctx *Context) {
repo := contentSubscriptions(ctx)
if repo == nil {
ctx.Reply("Subscription storage is not available.")
return
}
if len(ctx.Args) == 0 || strings.EqualFold(ctx.Args[0], "list") {
handleSubscriptionList(ctx, repo)
return
}
if len(ctx.Args) < 3 {
ctx.Reply("Usage: `subscriptions <enable|disable> <movies|series> <event-type|all>`")
return
}
contentType, err := arr.ParseContentType(ctx.Args[1])
if err != nil {
ctx.Reply(err.Error())
return
}
eventType, err := arr.ParseEventType(contentType, ctx.Args[2])
if err != nil {
ctx.Reply(err.Error())
return
}
switch strings.ToLower(ctx.Args[0]) {
case "enable":
handleSubscriptionSet(ctx, repo, contentType, eventType, true)
case "disable":
handleSubscriptionSet(ctx, repo, contentType, eventType, false)
default:
ctx.Reply("Unknown subscriptions subcommand `%s`.", ctx.Args[0])
}
})
}
func handleSubscriptionList(ctx *Context, repo subscriptionRepo) {
preferences, err := repo.List(ctx.Ctx, ctx.User.MXID)
if err != nil {
ctx.Reply("Failed to load subscriptions: %v", err)
return
}
var builder strings.Builder
builder.WriteString("Current notification subscriptions:\n")
for _, contentType := range arr.SupportedContentTypes() {
builder.WriteString(fmt.Sprintf("\n**%s**\n", strings.Title(contentType.Label())))
for _, eventType := range arr.SupportedEventTypes(contentType) {
enabled := findPreference(preferences, contentType, eventType)
builder.WriteString(fmt.Sprintf("- `%s`: %t\n", eventType, enabled))
}
}
ctx.Reply(builder.String())
}
func handleSubscriptionSet(ctx *Context, repo subscriptionRepo, contentType arr.ContentType, eventType string, enabled bool) {
var err error
if eventType == "all" {
err = repo.SetAll(ctx.Ctx, ctx.User.MXID, contentType, enabled)
} else {
err = repo.Set(ctx.Ctx, ctx.User.MXID, contentType, eventType, enabled)
}
if err != nil {
ctx.Reply("Failed to update subscriptions: %v", err)
return
}
if eventType == "all" {
ctx.Reply("Set all `%s` notifications for %s to %t.", contentType.Label(), userIDString(ctx.User.MXID), enabled)
return
}
ctx.Reply("Set `%s/%s` notifications to %t.", contentType.Label(), eventType, enabled)
}
type subscriptionRepo interface {
List(ctx context.Context, userID id.UserID) ([]subscriptions.Preference, error)
Set(ctx context.Context, userID id.UserID, contentType arr.ContentType, eventType string, enabled bool) error
SetAll(ctx context.Context, userID id.UserID, contentType arr.ContentType, enabled bool) error
}
func findPreference(preferences []subscriptions.Preference, contentType arr.ContentType, eventType string) bool {
for _, preference := range preferences {
if preference.ContentType == contentType && preference.EventType == eventType {
return preference.Enabled
}
}
return true
}

View file

@ -0,0 +1,22 @@
package observability
import "strings"
type Config struct {
OTLPGRPCEndpoint string `yaml:"otlp_grpc_endpoint"`
ServiceName string `yaml:"service_name"`
ResourceAttributes map[string]string `yaml:"resource_attributes"`
}
func (c *Config) ApplyDefaults() {
if c.ServiceName == "" {
c.ServiceName = "arrtrix"
}
if c.ResourceAttributes == nil {
c.ResourceAttributes = map[string]string{}
}
}
func (c Config) Enabled() bool {
return strings.TrimSpace(c.OTLPGRPCEndpoint) != ""
}

View file

@ -0,0 +1,397 @@
package observability
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"sync"
"time"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
otellog "go.opentelemetry.io/otel/log"
logglobal "go.opentelemetry.io/otel/log/global"
otelmetric "go.opentelemetry.io/otel/metric"
sdklog "go.opentelemetry.io/otel/sdk/log"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/trace"
)
const (
instrumentationScope = "sneeuwvlok/packages/arrtrix"
logScope = instrumentationScope + "/logs"
)
type Runtime struct {
traceProvider *sdktrace.TracerProvider
meterProvider *sdkmetric.MeterProvider
logProvider *sdklog.LoggerProvider
logHook zerolog.Hook
}
type exporterEndpoint struct {
raw string
insecure bool
}
type instruments struct {
webhookRequests otelCounter
webhookLatency otelHistogram
commandInvocations otelCounter
inviteEvents otelCounter
startupDuration otelHistogram
}
type otelCounter interface {
Add(context.Context, int64, ...otelmetric.AddOption)
}
type otelHistogram interface {
Record(context.Context, float64, ...otelmetric.RecordOption)
}
var (
mu sync.RWMutex
current instruments
tracer = otel.Tracer(instrumentationScope)
currentReady bool
)
func Setup(ctx context.Context, cfg Config, version string) (*Runtime, error) {
cfg.ApplyDefaults()
if !cfg.Enabled() {
resetInstruments()
return &Runtime{}, nil
}
res, err := buildResource(cfg, version)
if err != nil {
return nil, err
}
endpoint, err := parseEndpoint(cfg.OTLPGRPCEndpoint)
if err != nil {
return nil, err
}
traceExporter, err := otlptracegrpc.New(ctx, traceOptions(endpoint)...)
if err != nil {
return nil, fmt.Errorf("create trace exporter: %w", err)
}
metricExporter, err := otlpmetricgrpc.New(ctx, metricOptions(endpoint)...)
if err != nil {
return nil, fmt.Errorf("create metric exporter: %w", err)
}
logExporter, err := otlploggrpc.New(ctx, logOptions(endpoint)...)
if err != nil {
return nil, fmt.Errorf("create log exporter: %w", err)
}
traceProvider := sdktrace.NewTracerProvider(
sdktrace.WithResource(res),
sdktrace.WithBatcher(traceExporter),
)
meterProvider := sdkmetric.NewMeterProvider(
sdkmetric.WithResource(res),
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExporter, sdkmetric.WithInterval(30*time.Second))),
)
logProvider := sdklog.NewLoggerProvider(
sdklog.WithResource(res),
sdklog.WithProcessor(sdklog.NewBatchProcessor(logExporter)),
)
otel.SetTracerProvider(traceProvider)
otel.SetMeterProvider(meterProvider)
logglobal.SetLoggerProvider(logProvider)
if err = setInstruments(meterProvider); err != nil {
_ = traceProvider.Shutdown(ctx)
_ = meterProvider.Shutdown(ctx)
_ = logProvider.Shutdown(ctx)
return nil, err
}
tracer = otel.Tracer(instrumentationScope)
return &Runtime{
traceProvider: traceProvider,
meterProvider: meterProvider,
logProvider: logProvider,
logHook: newLogHook(logglobal.Logger(logScope)),
}, nil
}
func (r *Runtime) Enabled() bool {
return r != nil && r.traceProvider != nil
}
func (r *Runtime) LoggerHook() zerolog.Hook {
if r == nil {
return nil
}
return r.logHook
}
func (r *Runtime) Shutdown(ctx context.Context) error {
if r == nil || !r.Enabled() {
resetInstruments()
return nil
}
var errs []error
if err := r.logProvider.Shutdown(ctx); err != nil {
errs = append(errs, fmt.Errorf("shutdown log provider: %w", err))
}
if err := r.meterProvider.Shutdown(ctx); err != nil {
errs = append(errs, fmt.Errorf("shutdown meter provider: %w", err))
}
if err := r.traceProvider.Shutdown(ctx); err != nil {
errs = append(errs, fmt.Errorf("shutdown trace provider: %w", err))
}
resetInstruments()
return errors.Join(errs...)
}
func StartSpan(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
return tracer.Start(ctx, name, opts...)
}
func RecordWebhook(ctx context.Context, eventType, outcome string, statusCode int, duration time.Duration) {
mu.RLock()
inst := current
ready := currentReady
mu.RUnlock()
if !ready {
return
}
attrs := otelmetric.WithAttributes(
attribute.String("event_type", eventType),
attribute.String("outcome", outcome),
attribute.Int("http.status_code", statusCode),
)
inst.webhookRequests.Add(ctx, 1, attrs)
inst.webhookLatency.Record(ctx, duration.Seconds(), attrs)
}
func RecordCommand(ctx context.Context, name, outcome string) {
mu.RLock()
inst := current
ready := currentReady
mu.RUnlock()
if !ready {
return
}
inst.commandInvocations.Add(ctx, 1, otelmetric.WithAttributes(
attribute.String("command", name),
attribute.String("outcome", outcome),
))
}
func RecordInvite(ctx context.Context, outcome string) {
mu.RLock()
inst := current
ready := currentReady
mu.RUnlock()
if !ready {
return
}
inst.inviteEvents.Add(ctx, 1, otelmetric.WithAttributes(attribute.String("outcome", outcome)))
}
func RecordStartupPhase(ctx context.Context, phase, outcome string, duration time.Duration) {
mu.RLock()
inst := current
ready := currentReady
mu.RUnlock()
if !ready {
return
}
inst.startupDuration.Record(ctx, duration.Seconds(), otelmetric.WithAttributes(
attribute.String("phase", phase),
attribute.String("outcome", outcome),
))
}
func parseEndpoint(raw string) (exporterEndpoint, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return exporterEndpoint{}, errors.New("observability.otlp_grpc_endpoint must not be empty when observability is enabled")
}
if strings.Contains(raw, "://") {
u, err := url.Parse(raw)
if err != nil {
return exporterEndpoint{}, fmt.Errorf("parse observability.otlp_grpc_endpoint: %w", err)
}
if u.Scheme == "" || u.Host == "" {
return exporterEndpoint{}, fmt.Errorf("invalid observability.otlp_grpc_endpoint %q", raw)
}
return exporterEndpoint{raw: raw, insecure: u.Scheme == "http"}, nil
}
return exporterEndpoint{raw: "http://" + raw, insecure: true}, nil
}
func buildResource(cfg Config, version string) (*resource.Resource, error) {
attrs := []attribute.KeyValue{
attribute.String("service.name", cfg.ServiceName),
}
if version != "" {
attrs = append(attrs, attribute.String("service.version", version))
}
for key, value := range cfg.ResourceAttributes {
attrs = append(attrs, attribute.String(key, value))
}
return resource.Merge(resource.Default(), resource.NewWithAttributes("", attrs...))
}
func setInstruments(provider *sdkmetric.MeterProvider) error {
meter := provider.Meter(instrumentationScope)
webhookRequests, err := meter.Int64Counter(
"arrtrix.webhook.requests",
otelmetric.WithDescription("Number of Arr webhook requests handled by arrtrix."),
)
if err != nil {
return fmt.Errorf("create webhook request counter: %w", err)
}
webhookLatency, err := meter.Float64Histogram(
"arrtrix.webhook.duration.seconds",
otelmetric.WithDescription("Duration of Arr webhook request handling."),
otelmetric.WithUnit("s"),
)
if err != nil {
return fmt.Errorf("create webhook duration histogram: %w", err)
}
commandInvocations, err := meter.Int64Counter(
"arrtrix.matrix.commands",
otelmetric.WithDescription("Number of Matrix management-room commands handled by arrtrix."),
)
if err != nil {
return fmt.Errorf("create command counter: %w", err)
}
inviteEvents, err := meter.Int64Counter(
"arrtrix.matrix.invites",
otelmetric.WithDescription("Number of management-room invite flows observed by arrtrix."),
)
if err != nil {
return fmt.Errorf("create invite counter: %w", err)
}
startupDuration, err := meter.Float64Histogram(
"arrtrix.runtime.phase.duration.seconds",
otelmetric.WithDescription("Duration of arrtrix runtime startup and shutdown phases."),
otelmetric.WithUnit("s"),
)
if err != nil {
return fmt.Errorf("create runtime duration histogram: %w", err)
}
mu.Lock()
current = instruments{
webhookRequests: webhookRequests,
webhookLatency: webhookLatency,
commandInvocations: commandInvocations,
inviteEvents: inviteEvents,
startupDuration: startupDuration,
}
currentReady = true
mu.Unlock()
return nil
}
func resetInstruments() {
mu.Lock()
current = instruments{}
currentReady = false
mu.Unlock()
}
func traceOptions(endpoint exporterEndpoint) []otlptracegrpc.Option {
opts := []otlptracegrpc.Option{otlptracegrpc.WithEndpointURL(endpoint.raw)}
if endpoint.insecure {
opts = append(opts, otlptracegrpc.WithInsecure())
}
return opts
}
func metricOptions(endpoint exporterEndpoint) []otlpmetricgrpc.Option {
opts := []otlpmetricgrpc.Option{otlpmetricgrpc.WithEndpointURL(endpoint.raw)}
if endpoint.insecure {
opts = append(opts, otlpmetricgrpc.WithInsecure())
}
return opts
}
func logOptions(endpoint exporterEndpoint) []otlploggrpc.Option {
opts := []otlploggrpc.Option{otlploggrpc.WithEndpointURL(endpoint.raw)}
if endpoint.insecure {
opts = append(opts, otlploggrpc.WithInsecure())
}
return opts
}
type otelLogHook struct {
logger otellog.Logger
}
func newLogHook(logger otellog.Logger) zerolog.Hook {
return otelLogHook{logger: logger}
}
func (h otelLogHook) Run(e *zerolog.Event, level zerolog.Level, message string) {
if h.logger == nil {
return
}
ctx := e.GetCtx()
if ctx == nil {
ctx = context.Background()
}
severity := mapSeverity(level)
if !h.logger.Enabled(ctx, otellog.EnabledParameters{Severity: severity}) {
return
}
now := time.Now()
record := otellog.Record{}
record.SetTimestamp(now)
record.SetObservedTimestamp(now)
record.SetSeverity(severity)
record.SetSeverityText(strings.ToUpper(level.String()))
record.SetBody(otellog.StringValue(message))
record.AddAttributes(otellog.String("log.scope", logScope))
if spanCtx := trace.SpanContextFromContext(ctx); spanCtx.IsValid() {
record.AddAttributes(
otellog.String("trace_id", spanCtx.TraceID().String()),
otellog.String("span_id", spanCtx.SpanID().String()),
)
}
h.logger.Emit(ctx, record)
}
func mapSeverity(level zerolog.Level) otellog.Severity {
switch level {
case zerolog.TraceLevel:
return otellog.SeverityTrace
case zerolog.DebugLevel:
return otellog.SeverityDebug
case zerolog.InfoLevel:
return otellog.SeverityInfo
case zerolog.WarnLevel:
return otellog.SeverityWarn
case zerolog.ErrorLevel:
return otellog.SeverityError
case zerolog.FatalLevel:
return otellog.SeverityFatal
case zerolog.PanicLevel:
return otellog.SeverityFatal4
default:
return otellog.SeverityUndefined
}
}

View file

@ -0,0 +1,54 @@
package observability
import "testing"
func TestConfigDefaults(t *testing.T) {
var cfg Config
cfg.ApplyDefaults()
if cfg.ServiceName != "arrtrix" {
t.Fatalf("expected default service name arrtrix, got %q", cfg.ServiceName)
}
if cfg.ResourceAttributes == nil {
t.Fatal("expected resource attributes map to be initialized")
}
if cfg.Enabled() {
t.Fatal("expected observability to be disabled by default")
}
}
func TestParseEndpointSupportsURLAndBareHost(t *testing.T) {
tests := []struct {
name string
input string
wantRaw string
insecure bool
wantError bool
}{
{name: "https url", input: "https://otel.example:4317", wantRaw: "https://otel.example:4317"},
{name: "http url", input: "http://127.0.0.1:4317", wantRaw: "http://127.0.0.1:4317", insecure: true},
{name: "bare host", input: "collector:4317", wantRaw: "http://collector:4317", insecure: true},
{name: "invalid", input: "://bad", wantError: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseEndpoint(tt.input)
if tt.wantError {
if err == nil {
t.Fatal("expected error")
}
return
}
if err != nil {
t.Fatalf("parseEndpoint returned error: %v", err)
}
if got.raw != tt.wantRaw {
t.Fatalf("expected raw endpoint %q, got %q", tt.wantRaw, got.raw)
}
if got.insecure != tt.insecure {
t.Fatalf("expected insecure=%t, got %t", tt.insecure, got.insecure)
}
})
}
}

View file

@ -6,12 +6,16 @@ import (
"strings" "strings"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/bridgeconfig" "maunium.net/go/mautrix/bridgev2/bridgeconfig"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format" "maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"sneeuwvlok/packages/arrtrix/pkg/observability"
) )
const handledInviteEventType = "com.arrtrix.handled_invite" const handledInviteEventType = "com.arrtrix.handled_invite"
@ -23,27 +27,49 @@ func HandleBotInvite(ctx context.Context, bridge *bridgev2.Bridge, texts bridgec
return return
} }
ctx, span := observability.StartSpan(ctx, "arrtrix.matrix.invite")
defer span.End()
span.SetAttributes(
attribute.String("matrix.room_id", evt.RoomID.String()),
attribute.String("matrix.sender", evt.Sender.String()),
)
outcome := "ignored"
defer observability.RecordInvite(ctx, outcome)
log := zerolog.Ctx(ctx) log := zerolog.Ctx(ctx)
sender, err := bridge.GetUserByMXID(ctx, evt.Sender) sender, err := bridge.GetUserByMXID(ctx, evt.Sender)
if err != nil { if err != nil {
outcome = "user_lookup_failed"
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
log.Err(err).Msg("Failed to load sender for bot invite") log.Err(err).Msg("Failed to load sender for bot invite")
return return
} }
if !sender.Permissions.Commands { if !sender.Permissions.Commands {
outcome = "permission_denied"
span.SetStatus(codes.Error, "sender lacks command permission")
return return
} }
if err = bridge.Bot.EnsureJoined(ctx, evt.RoomID); err != nil { if err = bridge.Bot.EnsureJoined(ctx, evt.RoomID); err != nil {
outcome = "join_failed"
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
log.Err(err).Msg("Failed to accept invite to room") log.Err(err).Msg("Failed to accept invite to room")
return return
} }
members, err := bridge.Matrix.GetMembers(ctx, evt.RoomID) members, err := bridge.Matrix.GetMembers(ctx, evt.RoomID)
if err != nil { if err != nil {
outcome = "member_lookup_failed"
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
log.Err(err).Msg("Failed to get members of room after accepting invite") log.Err(err).Msg("Failed to get members of room after accepting invite")
return return
} }
if len(members) != 2 { if len(members) != 2 {
outcome = "non_management_room"
span.SetStatus(codes.Error, "invite room is not a direct management room")
return return
} }
@ -51,6 +77,9 @@ func HandleBotInvite(ctx context.Context, bridge *bridgev2.Bridge, texts bridgec
if assignedManagementRoom { if assignedManagementRoom {
sender.ManagementRoom = evt.RoomID sender.ManagementRoom = evt.RoomID
if err = sender.Save(ctx); err != nil { if err = sender.Save(ctx); err != nil {
outcome = "management_room_save_failed"
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
log.Err(err).Msg("Failed to update user's management room in database") log.Err(err).Msg("Failed to update user's management room in database")
return return
} }
@ -59,10 +88,15 @@ func HandleBotInvite(ctx context.Context, bridge *bridgev2.Bridge, texts bridgec
message := buildWelcomeMessage(bridge, texts, sender, assignedManagementRoom) message := buildWelcomeMessage(bridge, texts, sender, assignedManagementRoom)
content := format.RenderMarkdown(message, true, false) content := format.RenderMarkdown(message, true, false)
if _, err = bridge.Bot.SendMessage(ctx, evt.RoomID, event.EventMessage, &event.Content{Parsed: &content}, nil); err != nil { if _, err = bridge.Bot.SendMessage(ctx, evt.RoomID, event.EventMessage, &event.Content{Parsed: &content}, nil); err != nil {
outcome = "welcome_send_failed"
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
log.Err(err).Msg("Failed to send welcome message to room") log.Err(err).Msg("Failed to send welcome message to room")
return return
} }
outcome = "welcomed"
span.SetStatus(codes.Ok, "")
evt.Type = event.Type{Type: handledInviteEventType} evt.Type = event.Type{Type: handledInviteEventType}
} }

View file

@ -36,12 +36,16 @@ func updateConfigFromEnv(cfg, networkData any, prefix string) error {
} }
key = strings.ToLower(key) key = strings.ToLower(key)
lookupKey := key
if !strings.ContainsRune(key, '.') { if !strings.ContainsRune(key, '.') {
key = strings.ReplaceAll(key, "__", ".") key = strings.ReplaceAll(key, "__", ".")
} }
path := strings.Split(key, ".") path := strings.Split(key, ".")
field, ok := reflectGetFromMainOrNetwork(cfgVal, networkVal, path) field, ok := reflectGetFromMainOrNetwork(cfgVal, networkVal, path)
if !ok && !strings.ContainsRune(lookupKey, '.') {
field, ok = reflectGetFromMainOrNetworkTokens(cfgVal, networkVal, strings.Split(lookupKey, "_"))
}
if !ok { if !ok {
return fmt.Errorf("%s not found", formatKey(path)) return fmt.Errorf("%s not found", formatKey(path))
} }
@ -80,6 +84,13 @@ func reflectGetFromMainOrNetwork(main, network reflect.Value, path []string) (*r
return reflectGetYAML(main, path) return reflectGetYAML(main, path)
} }
func reflectGetFromMainOrNetworkTokens(main, network reflect.Value, tokens []string) (*reflectedField, bool) {
if len(tokens) > 0 && normalizeKey(tokens[0]) == "network" {
return reflectGetYAMLTokens(network, tokens[1:])
}
return reflectGetYAMLTokens(main, tokens)
}
func reflectGetYAML(value reflect.Value, path []string) (*reflectedField, bool) { func reflectGetYAML(value reflect.Value, path []string) (*reflectedField, bool) {
if len(path) == 0 { if len(path) == 0 {
return &reflectedField{value: value, valueKind: value.Kind()}, true return &reflectedField{value: value, valueKind: value.Kind()}, true
@ -108,6 +119,41 @@ func reflectGetYAML(value reflect.Value, path []string) (*reflectedField, bool)
return nil, false return nil, false
} }
func reflectGetYAMLTokens(value reflect.Value, tokens []string) (*reflectedField, bool) {
if len(tokens) == 0 {
return &reflectedField{value: value, valueKind: value.Kind()}, true
}
if value.Kind() == reflect.Ptr {
value = value.Elem()
}
switch value.Kind() {
case reflect.Map:
return &reflectedField{
value: value,
valueKind: value.Type().Elem().Kind(),
remainingPath: []string{strings.Join(tokens, "_")},
}, true
case reflect.Struct:
fields := reflect.VisibleFields(value.Type())
for _, field := range fields {
name := yamlFieldName(field)
if name == "" {
continue
}
normalizedFieldName := normalizeKey(name)
for i := len(tokens); i >= 1; i-- {
if normalizeKey(strings.Join(tokens[:i], "_")) != normalizedFieldName {
continue
}
return reflectGetYAMLTokens(value.FieldByIndex(field.Index), tokens[i:])
}
}
}
return nil, false
}
func yamlFieldName(field reflect.StructField) string { func yamlFieldName(field reflect.StructField) string {
parts := strings.SplitN(field.Tag.Get("yaml"), ",", 2) parts := strings.SplitN(field.Tag.Get("yaml"), ",", 2)
switch name := parts[0]; { switch name := parts[0]; {
@ -120,6 +166,10 @@ func yamlFieldName(field reflect.StructField) string {
} }
} }
func normalizeKey(value string) string {
return strings.ReplaceAll(strings.ToLower(value), "_", "")
}
func setReflectedValue(field *reflectedField, path []string, raw string) error { func setReflectedValue(field *reflectedField, path []string, raw string) error {
parsed, err := parseValue(field.valueKind, raw, path) parsed, err := parseValue(field.valueKind, raw, path)
if err != nil { if err != nil {

View file

@ -0,0 +1,57 @@
package runtime
import (
"os"
"testing"
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
"sneeuwvlok/packages/arrtrix/pkg/connector"
)
func TestUpdateConfigFromEnvSupportsFlatUnderscorePaths(t *testing.T) {
t.Setenv("ARRTRIX_NETWORK_CONTENT_MOVIES_APIKEY", "radarr-secret")
cfg := &bridgeconfig.Config{}
network := &connector.Config{}
if err := updateConfigFromEnv(cfg, network, "ARRTRIX_"); err != nil {
t.Fatalf("updateConfigFromEnv returned error: %v", err)
}
if network.Content.Movies.APIKey != "radarr-secret" {
t.Fatalf("expected movies api key to be overridden, got %q", network.Content.Movies.APIKey)
}
}
func TestUpdateConfigFromEnvSupportsExplicitUnderscoredFieldNames(t *testing.T) {
t.Setenv("ARRTRIX_NETWORK_CONTENT_MOVIES_ROOT_FOLDER_PATH", "/data/movies")
cfg := &bridgeconfig.Config{}
network := &connector.Config{}
if err := updateConfigFromEnv(cfg, network, "ARRTRIX_"); err != nil {
t.Fatalf("updateConfigFromEnv returned error: %v", err)
}
if network.Content.Movies.RootFolderPath != "/data/movies" {
t.Fatalf("expected root folder path to be overridden, got %q", network.Content.Movies.RootFolderPath)
}
}
func TestUpdateConfigFromEnvSupportsDoubleUnderscorePaths(t *testing.T) {
t.Setenv("ARRTRIX_NETWORK__CONTENT__SERIES__API_KEY", "sonarr-secret")
cfg := &bridgeconfig.Config{}
network := &connector.Config{}
if err := updateConfigFromEnv(cfg, network, "ARRTRIX_"); err != nil {
t.Fatalf("updateConfigFromEnv returned error: %v", err)
}
if network.Content.Series.APIKey != "sonarr-secret" {
t.Fatalf("expected series api key to be overridden, got %q", network.Content.Series.APIKey)
}
}
func TestMain(m *testing.M) {
code := m.Run()
os.Exit(code)
}

View file

@ -56,6 +56,13 @@ logging:
- type: stdout - type: stdout
format: pretty-colored format: pretty-colored
observability:
# OTLP/gRPC endpoint for logs, traces, and metrics.
# Set to e.g. http://127.0.0.1:4317 to enable export.
otlp_grpc_endpoint: ""
service_name: arrtrix
resource_attributes: {}
management_room_texts: management_room_texts:
welcome: "" welcome: ""
welcome_connected: "" welcome_connected: ""

View file

@ -18,6 +18,7 @@ import (
"go.mau.fi/util/exerrors" "go.mau.fi/util/exerrors"
"go.mau.fi/util/exzerolog" "go.mau.fi/util/exzerolog"
"go.mau.fi/util/progver" "go.mau.fi/util/progver"
"go.opentelemetry.io/otel/codes"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
flag "maunium.net/go/mauflag" flag "maunium.net/go/mauflag"
"maunium.net/go/mautrix/appservice" "maunium.net/go/mautrix/appservice"
@ -31,7 +32,9 @@ import (
arrconfig "sneeuwvlok/packages/arrtrix/pkg/config" arrconfig "sneeuwvlok/packages/arrtrix/pkg/config"
"sneeuwvlok/packages/arrtrix/pkg/matrixcmd" "sneeuwvlok/packages/arrtrix/pkg/matrixcmd"
"sneeuwvlok/packages/arrtrix/pkg/observability"
"sneeuwvlok/packages/arrtrix/pkg/onboarding" "sneeuwvlok/packages/arrtrix/pkg/onboarding"
"sneeuwvlok/packages/arrtrix/pkg/subscriptions"
) )
var configPath = flag.MakeFull("c", "config", "The path to your config file.", "config.yaml").String() var configPath = flag.MakeFull("c", "config", "The path to your config file.", "config.yaml").String()
@ -62,6 +65,7 @@ type Main struct {
Config *bridgeconfig.Config Config *bridgeconfig.Config
Matrix *matrix.Connector Matrix *matrix.Connector
Bridge *bridgev2.Bridge Bridge *bridgev2.Bridge
OTEL *observability.Runtime
ConfigPath string ConfigPath string
RegistrationPath string RegistrationPath string
@ -251,6 +255,8 @@ func (m *Main) loadRegistrationTokens(cfg *bridgeconfig.Config) error {
} }
func (m *Main) Init() { func (m *Main) Init() {
start := time.Now()
ctx := context.Background()
var err error var err error
m.Log, err = m.Config.Logging.Compile() m.Log, err = m.Config.Logging.Compile()
if err != nil { if err != nil {
@ -265,6 +271,33 @@ func (m *Main) Init() {
os.Exit(11) os.Exit(11)
} }
otelCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
m.OTEL, err = observability.Setup(otelCtx, m.PublicConfig.Observability, m.Version)
cancel()
if err != nil {
m.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to initialize observability")
os.Exit(15)
}
if hook := m.OTEL.LoggerHook(); hook != nil {
logger := m.Log.Hook(hook)
m.Log = &logger
exzerolog.SetupDefaults(m.Log)
}
ctx = m.Log.WithContext(context.Background())
ctx, span := observability.StartSpan(ctx, "arrtrix.runtime.init")
defer func() {
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
observability.RecordStartupPhase(ctx, "init", "error", time.Since(start))
return
}
span.SetStatus(codes.Ok, "")
observability.RecordStartupPhase(ctx, "init", "ok", time.Since(start))
}()
defer span.End()
m.Log.Info(). m.Log.Info().
Str("name", m.Name). Str("name", m.Name).
Str("version", m.ver.FormattedVersion). Str("version", m.ver.FormattedVersion).
@ -273,6 +306,10 @@ func (m *Main) Init() {
Msg("Initializing bridge") Msg("Initializing bridge")
m.initDB() m.initDB()
if err = subscriptions.EnsureSchema(ctx, m.DB); err != nil {
m.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to initialize subscription schema")
os.Exit(14)
}
m.Matrix = matrix.NewConnector(m.Config) m.Matrix = matrix.NewConnector(m.Config)
m.Matrix.OnWebsocketReplaced = func() { m.Matrix.OnWebsocketReplaced = func() {
m.TriggerStop(0) m.TriggerStop(0)
@ -306,17 +343,48 @@ func (m *Main) Init() {
} }
func (m *Main) Start() { func (m *Main) Start() {
start := time.Now()
ctx := m.Log.WithContext(context.Background()) ctx := m.Log.WithContext(context.Background())
ctx, span := observability.StartSpan(ctx, "arrtrix.runtime.start")
defer func() {
if r := recover(); r != nil {
span.SetStatus(codes.Error, "panic")
observability.RecordStartupPhase(ctx, "start", "panic", time.Since(start))
span.End()
panic(r)
}
span.End()
}()
if err := m.Bridge.Start(ctx); err != nil { if err := m.Bridge.Start(ctx); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
observability.RecordStartupPhase(ctx, "start", "error", time.Since(start))
m.Log.Fatal().Err(err).Msg("Failed to start bridge") m.Log.Fatal().Err(err).Msg("Failed to start bridge")
} }
span.SetStatus(codes.Ok, "")
observability.RecordStartupPhase(ctx, "start", "ok", time.Since(start))
if m.PostStart != nil { if m.PostStart != nil {
m.PostStart() m.PostStart()
} }
} }
func (m *Main) Stop() { func (m *Main) Stop() {
start := time.Now()
ctx := m.Log.WithContext(context.Background())
ctx, span := observability.StartSpan(ctx, "arrtrix.runtime.stop")
defer span.End()
m.Bridge.StopWithTimeout(5 * time.Second) m.Bridge.StopWithTimeout(5 * time.Second)
span.SetStatus(codes.Ok, "")
observability.RecordStartupPhase(ctx, "stop", "ok", time.Since(start))
if m.OTEL != nil {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := m.OTEL.Shutdown(shutdownCtx); err != nil && m.Log != nil {
m.Log.Error().Err(err).Msg("Failed to shut down observability")
}
}
} }
func (m *Main) WaitForInterrupt() int { func (m *Main) WaitForInterrupt() int {

View file

@ -0,0 +1,141 @@
package subscriptions
import (
"context"
"fmt"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/id"
"sneeuwvlok/packages/arrtrix/pkg/arr"
)
type Preference struct {
ContentType arr.ContentType
EventType string
Enabled bool
}
type Repository struct {
db *dbutil.Database
bridgeID string
}
func EnsureSchema(ctx context.Context, db *dbutil.Database) error {
_, err := db.Exec(ctx, `
CREATE TABLE IF NOT EXISTS arrtrix_subscription (
bridge_id TEXT NOT NULL,
user_mxid TEXT NOT NULL,
content_type TEXT NOT NULL,
event_type TEXT NOT NULL,
enabled BOOLEAN NOT NULL,
PRIMARY KEY (bridge_id, user_mxid, content_type, event_type)
)
`)
return err
}
func NewRepository(db *dbutil.Database, bridgeID string) *Repository {
return &Repository{db: db, bridgeID: bridgeID}
}
func (r *Repository) EnsureDefaults(ctx context.Context, userID id.UserID) error {
var existing int
if err := r.db.QueryRow(ctx, `SELECT COUNT(*) FROM arrtrix_subscription WHERE bridge_id=$1 AND user_mxid=$2`, r.bridgeID, userID.String()).Scan(&existing); err != nil {
return err
}
if existing > 0 {
return nil
}
for _, contentType := range arr.SupportedContentTypes() {
for _, eventType := range arr.SupportedEventTypes(contentType) {
if _, err := r.db.Exec(ctx, `
INSERT INTO arrtrix_subscription (bridge_id, user_mxid, content_type, event_type, enabled)
VALUES ($1, $2, $3, $4, TRUE)
`, r.bridgeID, userID.String(), string(contentType), eventType); err != nil {
return err
}
}
}
return nil
}
func (r *Repository) List(ctx context.Context, userID id.UserID) ([]Preference, error) {
if err := r.EnsureDefaults(ctx, userID); err != nil {
return nil, err
}
rows, err := r.db.Query(ctx, `
SELECT content_type, event_type, enabled
FROM arrtrix_subscription
WHERE bridge_id=$1 AND user_mxid=$2
ORDER BY content_type, event_type
`, r.bridgeID, userID.String())
if err != nil {
return nil, err
}
defer rows.Close()
var preferences []Preference
for rows.Next() {
var contentType string
var preference Preference
if err = rows.Scan(&contentType, &preference.EventType, &preference.Enabled); err != nil {
return nil, err
}
preference.ContentType = arr.ContentType(contentType)
preferences = append(preferences, preference)
}
if err = rows.Err(); err != nil {
return nil, err
}
return preferences, nil
}
func (r *Repository) Set(ctx context.Context, userID id.UserID, contentType arr.ContentType, eventType string, enabled bool) error {
if err := r.EnsureDefaults(ctx, userID); err != nil {
return err
}
if _, err := r.db.Exec(ctx, `
INSERT INTO arrtrix_subscription (bridge_id, user_mxid, content_type, event_type, enabled)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (bridge_id, user_mxid, content_type, event_type)
DO UPDATE SET enabled=excluded.enabled
`, r.bridgeID, userID.String(), string(contentType), eventType, enabled); err != nil {
return err
}
return nil
}
func (r *Repository) SetAll(ctx context.Context, userID id.UserID, contentType arr.ContentType, enabled bool) error {
if err := r.EnsureDefaults(ctx, userID); err != nil {
return err
}
for _, eventType := range arr.SupportedEventTypes(contentType) {
if err := r.Set(ctx, userID, contentType, eventType, enabled); err != nil {
return err
}
}
return nil
}
func (r *Repository) Allows(ctx context.Context, userID id.UserID, contentType arr.ContentType, eventType string) (bool, error) {
if !arr.SupportsEventType(contentType, eventType) {
return true, nil
}
if err := r.EnsureDefaults(ctx, userID); err != nil {
return false, err
}
var enabled bool
err := r.db.QueryRow(ctx, `
SELECT enabled
FROM arrtrix_subscription
WHERE bridge_id=$1 AND user_mxid=$2 AND content_type=$3 AND event_type=$4
`, r.bridgeID, userID.String(), string(contentType), eventType).Scan(&enabled)
if err != nil {
return false, fmt.Errorf("query subscription: %w", err)
}
return enabled, nil
}

View file

@ -7,11 +7,18 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"time"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format" "maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"sneeuwvlok/packages/arrtrix/pkg/arr"
"sneeuwvlok/packages/arrtrix/pkg/observability"
) )
const ArrWebhookPath = "/_arrtrix/webhook" const ArrWebhookPath = "/_arrtrix/webhook"
@ -25,6 +32,9 @@ type payload struct {
EventType string `json:"eventType"` EventType string `json:"eventType"`
Movie *movie `json:"movie"` Movie *movie `json:"movie"`
MovieFile *movieFile `json:"movieFile"` MovieFile *movieFile `json:"movieFile"`
Series *series `json:"series"`
Episodes []episode `json:"episodes"`
EpisodeFile *episodeFile `json:"episodeFile"`
IsUpgrade bool `json:"isUpgrade"` IsUpgrade bool `json:"isUpgrade"`
} }
@ -43,94 +53,192 @@ type movieFile struct {
ReleaseGroup string `json:"releaseGroup"` ReleaseGroup string `json:"releaseGroup"`
} }
type series struct {
Title string `json:"title"`
Year int `json:"year"`
Path string `json:"path"`
}
type episode struct {
SeasonNumber int `json:"seasonNumber"`
EpisodeNumber int `json:"episodeNumber"`
Title string `json:"title"`
}
type episodeFile struct {
Quality string `json:"quality"`
RelativePath string `json:"relativePath"`
SceneName string `json:"sceneName"`
}
type managementTarget struct {
UserID id.UserID
RoomID id.RoomID
}
type roomResolver interface { type roomResolver interface {
ResolveManagementRoom(context.Context) (id.RoomID, error) ResolveManagementRoom(context.Context) (managementTarget, error)
} }
type noticeSender interface { type noticeSender interface {
SendNotice(context.Context, id.RoomID, string) error SendNotice(context.Context, id.RoomID, string) error
} }
type SubscriptionFilter interface {
Allows(context.Context, id.UserID, arr.ContentType, string) (bool, error)
}
type ArrHandler struct { type ArrHandler struct {
resolver roomResolver resolver roomResolver
sender noticeSender sender noticeSender
subscriptions SubscriptionFilter
} }
func MountArr(router *http.ServeMux, bridge *bridgev2.Bridge) error { func MountArr(router *http.ServeMux, bridge *bridgev2.Bridge, subscriptions SubscriptionFilter) error {
if bridge == nil { if bridge == nil {
return fmt.Errorf("bridge is not initialized") return fmt.Errorf("bridge is not initialized")
} }
handler := &ArrHandler{ handler := &ArrHandler{
resolver: bridgeRoomResolver{bridge: bridge}, resolver: bridgeRoomResolver{bridge: bridge},
sender: bridgeNoticeSender{bridge: bridge}, sender: bridgeNoticeSender{bridge: bridge},
subscriptions: subscriptions,
} }
router.Handle(fmt.Sprintf("POST %s", ArrWebhookPath), handler) router.Handle(fmt.Sprintf("POST %s", ArrWebhookPath), handler)
return nil return nil
} }
func (h *ArrHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *ArrHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ctx, span := observability.StartSpan(r.Context(), "arrtrix.webhook.handle", trace.WithSpanKind(trace.SpanKindServer))
defer span.End()
statusCode := http.StatusAccepted
outcome := "ok"
eventType := ""
defer func() {
observability.RecordWebhook(ctx, eventType, outcome, statusCode, time.Since(start))
}()
var body payload var body payload
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
statusCode = http.StatusBadRequest
outcome = "invalid_payload"
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
http.Error(w, "invalid webhook payload", http.StatusBadRequest) http.Error(w, "invalid webhook payload", http.StatusBadRequest)
return return
} }
if strings.TrimSpace(body.EventType) == "" { if strings.TrimSpace(body.EventType) == "" {
statusCode = http.StatusBadRequest
outcome = "missing_event_type"
span.SetStatus(codes.Error, "missing eventType")
http.Error(w, "missing eventType", http.StatusBadRequest) http.Error(w, "missing eventType", http.StatusBadRequest)
return return
} }
eventType = body.EventType
span.SetAttributes(
attribute.String("arrtrix.webhook.event_type", body.EventType),
attribute.String("http.method", r.Method),
attribute.String("http.route", ArrWebhookPath),
)
roomID, err := h.resolver.ResolveManagementRoom(r.Context()) target, err := h.resolver.ResolveManagementRoom(ctx)
if err != nil { if err != nil {
status := http.StatusInternalServerError statusCode = http.StatusInternalServerError
outcome = "resolve_failed"
if errors.Is(err, ErrNoManagementRoom) || errors.Is(err, ErrAmbiguousManagementRoom) { if errors.Is(err, ErrNoManagementRoom) || errors.Is(err, ErrAmbiguousManagementRoom) {
status = http.StatusConflict statusCode = http.StatusConflict
outcome = "routing_conflict"
} }
http.Error(w, err.Error(), status) span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
http.Error(w, err.Error(), statusCode)
return return
} }
if err = h.sender.SendNotice(r.Context(), roomID, renderNotice(body)); err != nil { contentType, ok := body.ContentType()
if ok && h.subscriptions != nil {
allowed, filterErr := h.subscriptions.Allows(ctx, target.UserID, contentType, body.EventType)
if filterErr != nil {
statusCode = http.StatusInternalServerError
outcome = "subscription_check_failed"
span.RecordError(filterErr)
span.SetStatus(codes.Error, filterErr.Error())
http.Error(w, "failed to evaluate subscriptions", statusCode)
return
}
if !allowed {
outcome = "filtered"
span.SetStatus(codes.Ok, "filtered")
w.WriteHeader(statusCode)
return
}
}
if err = h.sender.SendNotice(ctx, target.RoomID, renderNotice(body)); err != nil {
statusCode = http.StatusBadGateway
outcome = "delivery_failed"
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
http.Error(w, "failed to deliver webhook", http.StatusBadGateway) http.Error(w, "failed to deliver webhook", http.StatusBadGateway)
return return
} }
w.WriteHeader(http.StatusAccepted) span.SetStatus(codes.Ok, "")
w.WriteHeader(statusCode)
} }
type bridgeRoomResolver struct { type bridgeRoomResolver struct {
bridge *bridgev2.Bridge bridge *bridgev2.Bridge
} }
func (r bridgeRoomResolver) ResolveManagementRoom(ctx context.Context) (id.RoomID, error) { func (r bridgeRoomResolver) ResolveManagementRoom(ctx context.Context) (managementTarget, error) {
ctx, span := observability.StartSpan(ctx, "arrtrix.webhook.resolve_management_room")
defer span.End()
rows, err := r.bridge.DB.Query(ctx, `SELECT mxid, management_room FROM "user" WHERE bridge_id=$1 AND management_room IS NOT NULL AND management_room <> ''`, r.bridge.ID) rows, err := r.bridge.DB.Query(ctx, `SELECT mxid, management_room FROM "user" WHERE bridge_id=$1 AND management_room IS NOT NULL AND management_room <> ''`, r.bridge.ID)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to query management rooms: %w", err) span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return managementTarget{}, fmt.Errorf("failed to query management rooms: %w", err)
} }
defer rows.Close() defer rows.Close()
var roomID id.RoomID var target managementTarget
var owners []id.UserID var owners []id.UserID
for rows.Next() { for rows.Next() {
var mxid, managementRoom string var mxid, managementRoom string
if err = rows.Scan(&mxid, &managementRoom); err != nil { if err = rows.Scan(&mxid, &managementRoom); err != nil {
return "", fmt.Errorf("failed to scan management room: %w", err) span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return managementTarget{}, fmt.Errorf("failed to scan management room: %w", err)
} }
owners = append(owners, id.UserID(mxid)) owners = append(owners, id.UserID(mxid))
if roomID == "" { if target.RoomID == "" {
roomID = id.RoomID(managementRoom) target = managementTarget{
UserID: id.UserID(mxid),
RoomID: id.RoomID(managementRoom),
}
} }
} }
if err = rows.Err(); err != nil { if err = rows.Err(); err != nil {
return "", fmt.Errorf("failed to iterate management rooms: %w", err) span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return managementTarget{}, fmt.Errorf("failed to iterate management rooms: %w", err)
} }
switch len(owners) { switch len(owners) {
case 0: case 0:
return "", ErrNoManagementRoom span.SetStatus(codes.Error, ErrNoManagementRoom.Error())
return managementTarget{}, ErrNoManagementRoom
case 1: case 1:
return roomID, nil span.SetAttributes(attribute.Int("arrtrix.management_room.count", 1))
span.SetStatus(codes.Ok, "")
return target, nil
default: default:
return "", fmt.Errorf("%w: %s", ErrAmbiguousManagementRoom, strings.Join(convertUserIDs(owners), ", ")) span.SetAttributes(attribute.Int("arrtrix.management_room.count", len(owners)))
span.SetStatus(codes.Error, ErrAmbiguousManagementRoom.Error())
return managementTarget{}, fmt.Errorf("%w: %s", ErrAmbiguousManagementRoom, strings.Join(convertUserIDs(owners), ", "))
} }
} }
@ -139,27 +247,36 @@ type bridgeNoticeSender struct {
} }
func (s bridgeNoticeSender) SendNotice(ctx context.Context, roomID id.RoomID, markdown string) error { func (s bridgeNoticeSender) SendNotice(ctx context.Context, roomID id.RoomID, markdown string) error {
ctx, span := observability.StartSpan(ctx, "arrtrix.webhook.send_notice")
defer span.End()
span.SetAttributes(attribute.String("matrix.room_id", roomID.String()))
if err := s.bridge.Bot.EnsureJoined(ctx, roomID); err != nil { if err := s.bridge.Bot.EnsureJoined(ctx, roomID); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err return err
} }
content := format.RenderMarkdown(markdown, true, false) content := format.RenderMarkdown(markdown, true, false)
_, err := s.bridge.Bot.SendMessage(ctx, roomID, event.EventMessage, &event.Content{Parsed: &content}, nil) _, err := s.bridge.Bot.SendMessage(ctx, roomID, event.EventMessage, &event.Content{Parsed: &content}, nil)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
span.SetStatus(codes.Ok, "")
return err return err
} }
func renderNotice(body payload) string { func renderNotice(body payload) string {
title := "Arr" lines := []string{fmt.Sprintf("**Arr %s**", body.EventType)}
if body.Movie != nil {
title = body.Movie.Title switch contentType, ok := body.ContentType(); {
case ok && contentType == arr.ContentTypeMovies:
title := body.Movie.Title
if body.Movie.Year != 0 { if body.Movie.Year != 0 {
title = fmt.Sprintf("%s (%d)", title, body.Movie.Year) title = fmt.Sprintf("%s (%d)", title, body.Movie.Year)
} }
}
lines := []string{fmt.Sprintf("**Arr %s**", body.EventType)}
if title != "Arr" {
lines = append(lines, fmt.Sprintf("Movie: %s", title)) lines = append(lines, fmt.Sprintf("Movie: %s", title))
}
if body.MovieFile != nil && body.MovieFile.Quality != "" { if body.MovieFile != nil && body.MovieFile.Quality != "" {
lines = append(lines, fmt.Sprintf("Quality: %s", body.MovieFile.Quality)) lines = append(lines, fmt.Sprintf("Quality: %s", body.MovieFile.Quality))
} }
@ -169,9 +286,30 @@ func renderNotice(body payload) string {
if body.EventType == "Download" { if body.EventType == "Download" {
lines = append(lines, fmt.Sprintf("Upgrade: %t", body.IsUpgrade)) lines = append(lines, fmt.Sprintf("Upgrade: %t", body.IsUpgrade))
} }
if body.Movie != nil && body.Movie.ImdbID != "" { if body.Movie.ImdbID != "" {
lines = append(lines, fmt.Sprintf("IMDb: `%s`", body.Movie.ImdbID)) lines = append(lines, fmt.Sprintf("IMDb: `%s`", body.Movie.ImdbID))
} }
case ok && contentType == arr.ContentTypeSeries:
title := body.Series.Title
if body.Series.Year != 0 {
title = fmt.Sprintf("%s (%d)", title, body.Series.Year)
}
lines = append(lines, fmt.Sprintf("Series: %s", title))
if len(body.Episodes) > 0 {
lines = append(lines, fmt.Sprintf("Episodes: %s", renderEpisodes(body.Episodes)))
}
if body.EpisodeFile != nil && body.EpisodeFile.Quality != "" {
lines = append(lines, fmt.Sprintf("Quality: %s", body.EpisodeFile.Quality))
}
if body.EpisodeFile != nil && body.EpisodeFile.RelativePath != "" {
lines = append(lines, fmt.Sprintf("File: `%s`", body.EpisodeFile.RelativePath))
}
default:
if body.EventType != "Test" {
lines = append(lines, "Payload received.")
}
}
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
@ -186,3 +324,26 @@ func convertUserIDs(users []id.UserID) []string {
var _ roomResolver = bridgeRoomResolver{} var _ roomResolver = bridgeRoomResolver{}
var _ noticeSender = bridgeNoticeSender{} var _ noticeSender = bridgeNoticeSender{}
var _ http.Handler = (*ArrHandler)(nil) var _ http.Handler = (*ArrHandler)(nil)
func (p payload) ContentType() (arr.ContentType, bool) {
switch {
case p.Movie != nil:
return arr.ContentTypeMovies, true
case p.Series != nil:
return arr.ContentTypeSeries, true
default:
return "", false
}
}
func renderEpisodes(episodes []episode) string {
parts := make([]string, 0, len(episodes))
for _, item := range episodes {
if item.Title != "" {
parts = append(parts, fmt.Sprintf("S%02dE%02d %s", item.SeasonNumber, item.EpisodeNumber, item.Title))
continue
}
parts = append(parts, fmt.Sprintf("S%02dE%02d", item.SeasonNumber, item.EpisodeNumber))
}
return strings.Join(parts, ", ")
}

View file

@ -9,15 +9,17 @@ import (
"testing" "testing"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"sneeuwvlok/packages/arrtrix/pkg/arr"
) )
type stubRoomResolver struct { type stubRoomResolver struct {
roomID id.RoomID target managementTarget
err error err error
} }
func (s stubRoomResolver) ResolveManagementRoom(context.Context) (id.RoomID, error) { func (s stubRoomResolver) ResolveManagementRoom(context.Context) (managementTarget, error) {
return s.roomID, s.err return s.target, s.err
} }
type stubNoticeSender struct { type stubNoticeSender struct {
@ -26,15 +28,24 @@ type stubNoticeSender struct {
err error err error
} }
type stubSubscriptionFilter struct {
allowed bool
err error
}
func (s *stubNoticeSender) SendNotice(_ context.Context, roomID id.RoomID, message string) error { func (s *stubNoticeSender) SendNotice(_ context.Context, roomID id.RoomID, message string) error {
s.roomID = roomID s.roomID = roomID
s.message = message s.message = message
return s.err return s.err
} }
func (s stubSubscriptionFilter) Allows(context.Context, id.UserID, arr.ContentType, string) (bool, error) {
return s.allowed, s.err
}
func TestMountArrRequiresBridge(t *testing.T) { func TestMountArrRequiresBridge(t *testing.T) {
router := http.NewServeMux() router := http.NewServeMux()
if err := MountArr(router, nil); err == nil { if err := MountArr(router, nil, nil); err == nil {
t.Fatal("expected nil bridge to fail") t.Fatal("expected nil bridge to fail")
} }
} }
@ -42,7 +53,7 @@ func TestMountArrRequiresBridge(t *testing.T) {
func TestArrHandlerDeliversNotice(t *testing.T) { func TestArrHandlerDeliversNotice(t *testing.T) {
sender := &stubNoticeSender{} sender := &stubNoticeSender{}
handler := &ArrHandler{ handler := &ArrHandler{
resolver: stubRoomResolver{roomID: "!room:test"}, resolver: stubRoomResolver{target: managementTarget{UserID: "@user:test", RoomID: "!room:test"}},
sender: sender, sender: sender,
} }
@ -85,7 +96,7 @@ func TestRenderNoticeForTestEvent(t *testing.T) {
func TestArrHandlerReturnsBadGatewayOnSendFailure(t *testing.T) { func TestArrHandlerReturnsBadGatewayOnSendFailure(t *testing.T) {
handler := &ArrHandler{ handler := &ArrHandler{
resolver: stubRoomResolver{roomID: "!room:test"}, resolver: stubRoomResolver{target: managementTarget{UserID: "@user:test", RoomID: "!room:test"}},
sender: &stubNoticeSender{err: errors.New("send failed")}, sender: &stubNoticeSender{err: errors.New("send failed")},
} }
@ -100,7 +111,7 @@ func TestArrHandlerReturnsBadGatewayOnSendFailure(t *testing.T) {
func TestArrHandlerRejectsMissingEventType(t *testing.T) { func TestArrHandlerRejectsMissingEventType(t *testing.T) {
handler := &ArrHandler{ handler := &ArrHandler{
resolver: stubRoomResolver{roomID: "!room:test"}, resolver: stubRoomResolver{target: managementTarget{UserID: "@user:test", RoomID: "!room:test"}},
sender: &stubNoticeSender{}, sender: &stubNoticeSender{},
} }
@ -112,3 +123,23 @@ func TestArrHandlerRejectsMissingEventType(t *testing.T) {
t.Fatalf("expected bad request status, got %d", rec.Code) t.Fatalf("expected bad request status, got %d", rec.Code)
} }
} }
func TestArrHandlerFiltersDisabledSubscriptions(t *testing.T) {
sender := &stubNoticeSender{}
handler := &ArrHandler{
resolver: stubRoomResolver{target: managementTarget{UserID: "@user:test", RoomID: "!room:test"}},
sender: sender,
subscriptions: stubSubscriptionFilter{allowed: false},
}
req := httptest.NewRequest(http.MethodPost, ArrWebhookPath, strings.NewReader(`{"eventType":"Download","movie":{"title":"Dune","year":2021}}`))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("expected accepted status, got %d", rec.Code)
}
if sender.roomID != "" {
t.Fatalf("expected no notice to be sent, got room %q", sender.roomID)
}
}

View file

@ -18,5 +18,6 @@ mkShell {
openssl openssl
inputs.clan-core.packages.${stdenv.hostPlatform.system}.clan-cli inputs.clan-core.packages.${stdenv.hostPlatform.system}.clan-cli
nix-output-monitor nix-output-monitor
dos2unix
]; ];
} }

View file

@ -39,31 +39,6 @@
}; };
}; };
# virtualisation = {
# containers.enable = true;
# podman = {
# enable = true;
# dockerCompat = true;
# };
# oci-containers = {
# backend = "podman";
# containers = {
# homey = {
# image = "ghcr.io/athombv/homey-shs:latest";
# autoStart = true;
# privileged = true;
# volumes = [
# "/home/chris/.homey-shs:/homey/user"
# ];
# ports = [
# "4859:4859"
# ];
# };
# };
# };
# };
sneeuwvlok = { sneeuwvlok = {
services = { services = {
backup.borg.enable = true; backup.borg.enable = true;
@ -140,13 +115,13 @@
}; };
mydia = { mydia = {
redirectUris = ["http://localhost:2010/auth/oidc/callback"]; redirectUris = ["http://localhost:2100/auth/oidc/callback"];
grantTypes = ["authorizationCode"]; grantTypes = ["authorizationCode"];
responseTypes = ["code"]; responseTypes = ["code"];
}; };
grafana = { grafana = {
redirectUris = ["http://localhost:9001/login/generic_oauth"]; redirectUris = ["http://localhost:9010/login/generic_oauth"];
grantTypes = ["authorizationCode"]; grantTypes = ["authorizationCode"];
responseTypes = ["code"]; responseTypes = ["code"];
}; };
@ -224,7 +199,7 @@
media.servarr = { media.servarr = {
radarr = { radarr = {
enable = true; enable = true;
port = 2001; port = 2010;
rootFolders = [ rootFolders = [
"/var/media/movies" "/var/media/movies"
]; ];
@ -233,7 +208,7 @@
sonarr = { sonarr = {
enable = true; enable = true;
# debug = true; # debug = true;
port = 2002; port = 2020;
rootFolders = [ rootFolders = [
"/var/media/series" "/var/media/series"
]; ];
@ -242,7 +217,7 @@
lidarr = { lidarr = {
enable = true; enable = true;
debug = true; debug = true;
port = 2003; port = 2030;
rootFolders = [ rootFolders = [
"/var/media/music" "/var/media/music"
]; ];
@ -251,15 +226,17 @@
prowlarr = { prowlarr = {
enable = true; enable = true;
# debug = true; # debug = true;
port = 2004; port = 2040;
}; };
}; };
observability = { observability = {
alloy.enable = true;
grafana.enable = true; grafana.enable = true;
prometheus.enable = true;
loki.enable = true; loki.enable = true;
prometheus.enable = true;
promtail.enable = true; promtail.enable = true;
tempo.enable = true;
# uptime-kuma.enable = true; # uptime-kuma.enable = true;
}; };