diff --git a/flake.nix b/flake.nix index 692afe1..7ccab59 100644 --- a/flake.nix +++ b/flake.nix @@ -1,10 +1,6 @@ { description = "Nixos config flake"; - nixConfig = { - warn-dirty = false; - }; - inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; diff --git a/modules/nixos/services/authentication/zitadel/default.nix b/modules/nixos/services/authentication/zitadel/default.nix index 6e42eeb..7674835 100644 --- a/modules/nixos/services/authentication/zitadel/default.nix +++ b/modules/nixos/services/authentication/zitadel/default.nix @@ -1,10 +1,9 @@ { config, lib, pkgs, namespace, system, inputs, ... }: let - inherit (lib) mkIf mkEnableOption mkOption toString types toUpper toSentenceCase nameValuePair mapAttrs mapAttrs' concatMapAttrs concatMapStringsSep filterAttrsRecursive listToAttrs imap0 head drop length literalExpression attrNames; + inherit (lib) mkIf mkEnableOption mkOption types toUpper toSentenceCase nameValuePair mapAttrs mapAttrs' concatMapAttrs concatMapStringsSep filterAttrsRecursive listToAttrs imap0 head drop length literalExpression attrNames; inherit (lib.${namespace}.strings) toSnakeCase; cfg = config.${namespace}.services.authentication.zitadel; - port = 3010; database = "zitadel"; in @@ -544,12 +543,12 @@ in networking.caddy = { hosts = { "auth.kruining.eu" = '' - reverse_proxy h2c://[::1]:${toString port} + reverse_proxy h2c://[::1]:9092 ''; }; extraConfig = '' (auth) { - forward_auth h2c://[::1]:${toString port} { + forward_auth h2c://[::1]:9092 { uri /api/authz/forward-auth copy_headers Remote-User Remote-Groups Remote-Email Remote-Name } @@ -613,7 +612,7 @@ in masterKeyFile = config.sops.secrets."zitadel/masterKey".path; tlsMode = "external"; settings = { - Port = port; + Port = 9092; ExternalDomain = "auth.kruining.eu"; ExternalPort = 443; @@ -699,6 +698,8 @@ in }; }; + networking.firewall.allowedTCPPorts = [ 80 443 ]; + # Secrets sops = { secrets = { diff --git a/modules/nixos/services/communication/matrix/default.nix b/modules/nixos/services/communication/matrix/default.nix index 9a7d53c..607fa72 100644 --- a/modules/nixos/services/communication/matrix/default.nix +++ b/modules/nixos/services/communication/matrix/default.nix @@ -112,29 +112,10 @@ in { (mkMautrix "mautrix-telegram" 2 {}) (mkMautrix "mautrix-whatsapp" 3 {}) (mkMautrix "arrtrix" 4 { - environmentFile = config.sops.templates."arrtrix/secrets".path; - - settings = { - 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; - }; - }; + settings.network.webhooks.radarr = { + enabled = true; + path = "/_arrtrix/webhooks/radarr"; + secret = ""; }; }) { @@ -187,7 +168,7 @@ in { }; sso = { - client_whitelist = ["http://[::1]:${toString config.services.zitadel.settings.Port}/" "https://auth.kruining.eu/"]; + client_whitelist = ["http://[::1]:9092/" "https://auth.kruining.eu/"]; update_profile_information = true; }; @@ -385,14 +366,6 @@ in { ''; 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"]; - }; }; }; }; diff --git a/modules/nixos/services/media/default.nix b/modules/nixos/services/media/default.nix index 900eee4..c10a08e 100644 --- a/modules/nixos/services/media/default.nix +++ b/modules/nixos/services/media/default.nix @@ -64,7 +64,7 @@ in { openFirewall = true; user = cfg.user; group = cfg.group; - listenPort = 2050; + listenPort = 2005; }; postgresql = { diff --git a/modules/nixos/services/media/glance/default.nix b/modules/nixos/services/media/glance/default.nix index bdd4c87..b042297 100644 --- a/modules/nixos/services/media/glance/default.nix +++ b/modules/nixos/services/media/glance/default.nix @@ -13,11 +13,11 @@ in { }; config = mkIf cfg.enable { - # ${namespace}.services.networking.caddy.hosts = { - # "https://${config.networking.hostName}.arda:443" = '' - # reverse_proxy http://[::1]:2000 - # ''; - # }; + ${namespace}.services.networking.caddy.hosts = { + "https://${config.networking.hostName}:443" = '' + reverse_proxy http://[::1]:2000 + ''; + }; services.glance = { enable = true; diff --git a/modules/nixos/services/media/mydia/default.nix b/modules/nixos/services/media/mydia/default.nix index 9044c2e..7e082a3 100644 --- a/modules/nixos/services/media/mydia/default.nix +++ b/modules/nixos/services/media/mydia/default.nix @@ -22,7 +22,7 @@ in { services.mydia = { enable = true; - port = 2100; + port = 2010; listenAddress = "0.0.0.0"; openFirewall = true; @@ -54,7 +54,7 @@ in { qbittorrent = { type = "qbittorrent"; host = "localhost"; - port = 2080; + port = 2008; username = "admin"; passwordFile = config.sops.secrets."mydia/qbittorrent_password".path; useSsl = false; diff --git a/modules/nixos/services/media/servarr/default.nix b/modules/nixos/services/media/servarr/default.nix index ed9b94a..ae0e3b0 100644 --- a/modules/nixos/services/media/servarr/default.nix +++ b/modules/nixos/services/media/servarr/default.nix @@ -56,8 +56,7 @@ in { auth.authenticationMethod = "External"; server = { - # bindaddress = "0.0.0.0"; - bindaddress = "[::]"; + bindaddress = "0.0.0.0"; port = port; }; @@ -80,7 +79,7 @@ in { qbittorrent = { enable = true; openFirewall = true; - webuiPort = 2080; + webuiPort = 2008; serverConfig = lib.mkForce {}; user = "qbittorrent"; @@ -101,7 +100,7 @@ in { settings = { misc = { host = "0.0.0.0"; - port = 2090; + port = 2009; host_whitelist = "${config.networking.hostName}"; permissions = "770"; @@ -127,7 +126,7 @@ in { flaresolverr = { enable = true; openFirewall = true; - port = 2070; + port = 2007; }; postgresql = let @@ -195,7 +194,7 @@ in { source = "devopsarr/${service}"; version = { - radarr = "2.3.5"; + radarr = "2.3.3"; sonarr = "3.4.0"; prowlarr = "3.1.0"; lidarr = "1.13.0"; @@ -213,23 +212,6 @@ in { 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"]) ( rootFolders |> lib.imap (i: f: lib.nameValuePair "local${toString i}" {path = f;}) @@ -245,30 +227,20 @@ in { username = "admin"; password = lib.tfRef "var.qbittorrent_api_key"; url_base = "/"; - port = 2080; + port = 2008; }; }; "${service}_download_client_sabnzbd" = mkIf (lib.elem service ["radarr" "sonarr" "lidarr" "whisparr"]) { - "main" = - { - name = "SABnzbd"; - enable = true; - priority = 1; - host = "localhost"; - api_key = lib.tfRef "var.sabnzbd_api_key"; - url_base = "/"; - port = 2090; - } - // ({ - radarr = {movie_category = "movies";}; - sonarr = {tv_category = "tv";}; - lidarr = {music_category = "audio";}; - whisparr = {movie_category = "movies";}; - readarr = {book_category = "Default";}; - }.${ - service - }); + "main" = { + name = "SABnzbd"; + enable = true; + priority = 1; + host = "localhost"; + api_key = lib.tfRef "var.sabnzbd_api_key"; + url_base = "/"; + port = 2009; + }; }; } // (lib.optionalAttrs (service == "prowlarr") ( diff --git a/modules/nixos/services/networking/caddy/default.nix b/modules/nixos/services/networking/caddy/default.nix index 21ab908..e18a707 100644 --- a/modules/nixos/services/networking/caddy/default.nix +++ b/modules/nixos/services/networking/caddy/default.nix @@ -24,8 +24,6 @@ in { }; config = mkIf hasHosts { - networking.firewall.allowedTCPPorts = [80 443]; - services.caddy = { enable = cfg.enable; diff --git a/modules/nixos/services/observability/alloy/default.nix b/modules/nixos/services/observability/alloy/default.nix deleted file mode 100644 index 3b64f2e..0000000 --- a/modules/nixos/services/observability/alloy/default.nix +++ /dev/null @@ -1,83 +0,0 @@ -{ - 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]; - }; -} diff --git a/modules/nixos/services/observability/grafana/default.nix b/modules/nixos/services/observability/grafana/default.nix index 879ecdc..a867351 100644 --- a/modules/nixos/services/observability/grafana/default.nix +++ b/modules/nixos/services/observability/grafana/default.nix @@ -25,8 +25,8 @@ in { settings = { server = { - http_port = 9010; - http_addr = "::"; + http_port = 9001; + http_addr = "0.0.0.0"; domain = "ulmo"; }; @@ -104,38 +104,18 @@ in { datasources.settings.datasources = [ { name = "Prometheus"; - uid = "prometheus"; type = "prometheus"; - url = "http://[::1]:9020"; + url = "http://localhost:9005"; isDefault = true; editable = false; } { name = "Loki"; - uid = "loki"; type = "loki"; - url = "http://[::1]:9030"; + url = "http://localhost:9003"; 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"; - }; - }; - } ]; }; }; diff --git a/modules/nixos/services/observability/loki/default.nix b/modules/nixos/services/observability/loki/default.nix index bab5b3f..d4774ac 100644 --- a/modules/nixos/services/observability/loki/default.nix +++ b/modules/nixos/services/observability/loki/default.nix @@ -17,7 +17,7 @@ in auth_enabled = false; server = { - http_listen_port = 9030; + http_listen_port = 9003; }; common = { @@ -44,6 +44,6 @@ in }; }; - networking.firewall.allowedTCPPorts = [ 9030 ]; + networking.firewall.allowedTCPPorts = [ 9003 ]; }; } diff --git a/modules/nixos/services/observability/prometheus/default.nix b/modules/nixos/services/observability/prometheus/default.nix index c092286..af5ee9d 100644 --- a/modules/nixos/services/observability/prometheus/default.nix +++ b/modules/nixos/services/observability/prometheus/default.nix @@ -1,7 +1,7 @@ { pkgs, config, lib, namespace, ... }: let inherit (builtins) toString; - inherit (lib) mkEnableOption mkIf optionals; + inherit (lib) mkIf mkEnableOption; cfg = config.${namespace}.services.observability.prometheus; in @@ -13,10 +13,7 @@ in config = mkIf cfg.enable { services.prometheus = { enable = true; - port = 9020; - extraFlags = optionals config.${namespace}.services.observability.alloy.enable [ - "--web.enable-remote-write-receiver" - ]; + port = 9002; globalConfig.scrape_interval = "15s"; @@ -24,7 +21,7 @@ in { job_name = "prometheus"; static_configs = [ - { targets = [ "localhost:9020" ]; } + { targets = [ "localhost:9002" ]; } ]; } @@ -34,34 +31,18 @@ in { 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 = { node = { enable = true; - port = 9021; + port = 9005; enabledCollectors = [ "systemd" ]; openFirewall = true; }; }; }; - networking.firewall.allowedTCPPorts = [ 9020 ]; + networking.firewall.allowedTCPPorts = [ 9002 ]; }; } diff --git a/modules/nixos/services/observability/promtail/default.nix b/modules/nixos/services/observability/promtail/default.nix index b852f1f..38dbbab 100644 --- a/modules/nixos/services/observability/promtail/default.nix +++ b/modules/nixos/services/observability/promtail/default.nix @@ -25,7 +25,7 @@ in { configuration = { server = { - http_listen_port = 9040; + http_listen_port = 9004; grpc_listen_port = 0; }; @@ -35,7 +35,7 @@ in { clients = [ { - url = "http://[::1]:9030/loki/api/v1/push"; + url = "http://[::1]:9003/loki/api/v1/push"; } ]; @@ -60,6 +60,6 @@ in { }; }; - networking.firewall.allowedTCPPorts = [9040]; + networking.firewall.allowedTCPPorts = [9004]; }; } diff --git a/modules/nixos/services/observability/tempo/default.nix b/modules/nixos/services/observability/tempo/default.nix deleted file mode 100644 index 46339bc..0000000 --- a/modules/nixos/services/observability/tempo/default.nix +++ /dev/null @@ -1,51 +0,0 @@ -{ - 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]; - }; -} diff --git a/modules/nixos/services/observability/uptime-kuma/default.nix b/modules/nixos/services/observability/uptime-kuma/default.nix index af0cfa8..c23977b 100644 --- a/modules/nixos/services/observability/uptime-kuma/default.nix +++ b/modules/nixos/services/observability/uptime-kuma/default.nix @@ -15,11 +15,11 @@ in enable = true; settings = { - PORT = toString 9050; + PORT = toString 9006; HOST = "0.0.0.0"; }; }; - networking.firewall.allowedTCPPorts = [ 9050 ]; + networking.firewall.allowedTCPPorts = [ 9006 ]; }; } diff --git a/modules/nixos/temp/services/arrtrix/default.nix b/modules/nixos/temp/services/arrtrix/default.nix index 6bb1d9f..618de39 100644 --- a/modules/nixos/temp/services/arrtrix/default.nix +++ b/modules/nixos/temp/services/arrtrix/default.nix @@ -48,31 +48,6 @@ 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 { options.services.arrtrix = { @@ -106,18 +81,6 @@ in { 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 { type = with lib.types; listOf str; default = @@ -180,7 +143,6 @@ in { StateDirectory = baseNameOf dataDir; WorkingDirectory = dataDir; - EnvironmentFile = cfg.environmentFile; ExecStart = '' ${lib.getExe cfg.package} --config='${settingsFile}' --registration='${registrationFile}' diff --git a/packages/arrtrix/default.nix b/packages/arrtrix/default.nix index 0113edb..81950f9 100644 --- a/packages/arrtrix/default.nix +++ b/packages/arrtrix/default.nix @@ -11,7 +11,7 @@ buildGoModule rec { src = lib.cleanSource ./.; - vendorHash = "sha256-UYRit+v41djnCx+GFdEl/8WQsp2DzF4ywT9iv3m1pSc="; + vendorHash = "sha256-FbatoXcxZcnqVUmoj/jeSMFO/iTmD8uga47MoTdGcRw="; subPackages = ["cmd/arrtrix"]; buildInputs = [olm]; diff --git a/packages/arrtrix/go.mod b/packages/arrtrix/go.mod index 81a6c93..eed27b5 100644 --- a/packages/arrtrix/go.mod +++ b/packages/arrtrix/go.mod @@ -3,58 +3,41 @@ module sneeuwvlok/packages/arrtrix go 1.25.0 require ( - github.com/rs/zerolog v1.34.0 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 ) +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 ( 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/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/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.34 // indirect github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // 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/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/yuin/goldmark v1.7.16 // 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 + go.mau.fi/zeroconfig v0.2.0 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.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/yaml.v3 v3.0.1 // indirect + maunium.net/go/mauflag v1.0.0 // indirect ) diff --git a/packages/arrtrix/go.sum b/packages/arrtrix/go.sum index 8d8f5ab..d8e9404 100644 --- a/packages/arrtrix/go.sum +++ b/packages/arrtrix/go.sum @@ -2,33 +2,20 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= 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/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/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.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo= 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/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/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.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 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/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= @@ -44,11 +31,13 @@ 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/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/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +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/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= @@ -74,36 +63,6 @@ go.mau.fi/util v0.9.7 h1:AWGNbJfz1zRcQOKeOEYhKUG2fT+/26Gy6kyqcH8tnBg= 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/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/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= @@ -119,16 +78,6 @@ 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/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= 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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/packages/arrtrix/pkg/arr/catalog.go b/packages/arrtrix/pkg/arr/catalog.go deleted file mode 100644 index eb2f833..0000000 --- a/packages/arrtrix/pkg/arr/catalog.go +++ /dev/null @@ -1,76 +0,0 @@ -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, ", ") -} diff --git a/packages/arrtrix/pkg/arr/catalog_test.go b/packages/arrtrix/pkg/arr/catalog_test.go deleted file mode 100644 index e3c2784..0000000 --- a/packages/arrtrix/pkg/arr/catalog_test.go +++ /dev/null @@ -1,23 +0,0 @@ -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) - } -} diff --git a/packages/arrtrix/pkg/arrclient/client.go b/packages/arrtrix/pkg/arrclient/client.go deleted file mode 100644 index fc7fb53..0000000 --- a/packages/arrtrix/pkg/arrclient/client.go +++ /dev/null @@ -1,363 +0,0 @@ -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) -} diff --git a/packages/arrtrix/pkg/arrclient/client_test.go b/packages/arrtrix/pkg/arrclient/client_test.go deleted file mode 100644 index ecce6c3..0000000 --- a/packages/arrtrix/pkg/arrclient/client_test.go +++ /dev/null @@ -1,80 +0,0 @@ -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) - } -} diff --git a/packages/arrtrix/pkg/arrclient/radarr.go b/packages/arrtrix/pkg/arrclient/radarr.go deleted file mode 100644 index e214ce3..0000000 --- a/packages/arrtrix/pkg/arrclient/radarr.go +++ /dev/null @@ -1,172 +0,0 @@ -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) - } -} diff --git a/packages/arrtrix/pkg/arrclient/sonarr.go b/packages/arrtrix/pkg/arrclient/sonarr.go deleted file mode 100644 index caa6cec..0000000 --- a/packages/arrtrix/pkg/arrclient/sonarr.go +++ /dev/null @@ -1,157 +0,0 @@ -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) -} diff --git a/packages/arrtrix/pkg/config/config.go b/packages/arrtrix/pkg/config/config.go index ff97e98..c3b11b8 100644 --- a/packages/arrtrix/pkg/config/config.go +++ b/packages/arrtrix/pkg/config/config.go @@ -6,8 +6,6 @@ import ( "gopkg.in/yaml.v3" "maunium.net/go/mautrix/bridgev2/bridgeconfig" - - "sneeuwvlok/packages/arrtrix/pkg/observability" ) type Config struct { @@ -19,7 +17,6 @@ type Config struct { AppService bridgeconfig.AppserviceConfig `yaml:"appservice"` Logging zeroconfig.Config `yaml:"logging"` - Observability observability.Config `yaml:"observability"` EnvConfigPrefix string `yaml:"env_config_prefix"` ManagementTexts bridgeconfig.ManagementRoomTexts `yaml:"management_room_texts"` } @@ -37,7 +34,6 @@ func (c *Config) applyDefaults() { if c.Homeserver.Software == "" { c.Homeserver.Software = bridgeconfig.SoftwareStandard } - c.Observability.ApplyDefaults() } func (c *Config) Compile() bridgeconfig.Config { diff --git a/packages/arrtrix/pkg/connector/config.go b/packages/arrtrix/pkg/connector/config.go index 149fd32..2cdec34 100644 --- a/packages/arrtrix/pkg/connector/config.go +++ b/packages/arrtrix/pkg/connector/config.go @@ -8,23 +8,13 @@ import ( up "go.mau.fi/util/configupgrade" "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" ) //go:embed example-config.yaml var ExampleConfig string -type Config struct { - Content ContentConfig `yaml:"content"` -} - -type ContentConfig struct { - Movies arrclient.RadarrConfig `yaml:"movies"` - Series arrclient.SonarrConfig `yaml:"series"` -} +type Config struct{} func upgradeConfig(helper up.Helper) {} @@ -33,14 +23,6 @@ func (s *ArrtrixConnector) GetConfig() (string, any, up.Upgrader) { } 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 } @@ -48,27 +30,7 @@ func (s *ArrtrixConnector) MountRoutes(router *http.ServeMux) error { if s.Bridge == nil { return fmt.Errorf("bridge is not initialized") } - return webhook.MountArr(router, s.Bridge, s.Subscriptions()) + return webhook.MountArr(router, s.Bridge) } 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) - } -} diff --git a/packages/arrtrix/pkg/connector/config_test.go b/packages/arrtrix/pkg/connector/config_test.go deleted file mode 100644 index 9516e37..0000000 --- a/packages/arrtrix/pkg/connector/config_test.go +++ /dev/null @@ -1,23 +0,0 @@ -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) - } -} diff --git a/packages/arrtrix/pkg/connector/connector.go b/packages/arrtrix/pkg/connector/connector.go index 4be007a..121e94c 100644 --- a/packages/arrtrix/pkg/connector/connector.go +++ b/packages/arrtrix/pkg/connector/connector.go @@ -10,17 +10,11 @@ import ( "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" - - "sneeuwvlok/packages/arrtrix/pkg/arr" - "sneeuwvlok/packages/arrtrix/pkg/arrclient" - "sneeuwvlok/packages/arrtrix/pkg/subscriptions" ) type ArrtrixConnector struct { - Bridge *bridgev2.Bridge - Config Config - clients map[arr.ContentType]arrclient.Client - subscriptions *subscriptions.Repository + Bridge *bridgev2.Bridge + Config Config } var _ bridgev2.NetworkConnector = (*ArrtrixConnector)(nil) @@ -39,17 +33,6 @@ func (s *ArrtrixConnector) GetName() bridgev2.BridgeName { func (s *ArrtrixConnector) Init(bridge *bridgev2.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 { @@ -124,12 +107,3 @@ func (c *ArrtrixClient) HandleMatrixMessage(context.Context, *bridgev2.MatrixMes func (c *ArrtrixClient) GenerateTransactionID(userID id.UserID, roomID id.RoomID, eventType event.Type) 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 -} diff --git a/packages/arrtrix/pkg/connector/example-config.yaml b/packages/arrtrix/pkg/connector/example-config.yaml index a917e23..9c11ddf 100644 --- a/packages/arrtrix/pkg/connector/example-config.yaml +++ b/packages/arrtrix/pkg/connector/example-config.yaml @@ -1,23 +1,4 @@ -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 - +# No network-specific config is required yet. +# # Arr-stack webhooks are exposed automatically on the fixed built-in path: # POST /_arrtrix/webhook diff --git a/packages/arrtrix/pkg/matrixcmd/download.go b/packages/arrtrix/pkg/matrixcmd/download.go deleted file mode 100644 index 23414b1..0000000 --- a/packages/arrtrix/pkg/matrixcmd/download.go +++ /dev/null @@ -1,260 +0,0 @@ -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: " [...]", - }, func(ctx *Context) { - if len(ctx.Args) < 2 { - ctx.Reply("Usage: `download [...]`") - 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 `", 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 `", 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 `", 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 `", 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 ` 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 "🚫" -} diff --git a/packages/arrtrix/pkg/matrixcmd/download_test.go b/packages/arrtrix/pkg/matrixcmd/download_test.go deleted file mode 100644 index 19b93b9..0000000 --- a/packages/arrtrix/pkg/matrixcmd/download_test.go +++ /dev/null @@ -1,44 +0,0 @@ -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)) - } -} diff --git a/packages/arrtrix/pkg/matrixcmd/help_test.go b/packages/arrtrix/pkg/matrixcmd/help_test.go index 817f7ed..b5b325b 100644 --- a/packages/arrtrix/pkg/matrixcmd/help_test.go +++ b/packages/arrtrix/pkg/matrixcmd/help_test.go @@ -18,8 +18,6 @@ func TestFormatHelpManagementRoom(t *testing.T) { alias: make(map[string]string), } proc.Add(NewHelpHandler(proc)) - proc.Add(NewDownloadHandler()) - proc.Add(NewSubscriptionsHandler()) out := formatHelp(proc, &Context{ Bridge: &bridgev2.Bridge{ @@ -34,9 +32,7 @@ func TestFormatHelpManagementRoom(t *testing.T) { for _, fragment := range []string{ "prefixing commands with `!arr` is not required", - "**download** [...] - Manage monitored movies and series in Arr.", "**help** - Show this help message.", - "**subscriptions** [movies|series] [event-type|all] - Manage notification subscriptions by content type and event type.", "Extra help text.", } { if !strings.Contains(out, fragment) { diff --git a/packages/arrtrix/pkg/matrixcmd/processor.go b/packages/arrtrix/pkg/matrixcmd/processor.go index e9d3980..1dabfd6 100644 --- a/packages/arrtrix/pkg/matrixcmd/processor.go +++ b/packages/arrtrix/pkg/matrixcmd/processor.go @@ -8,8 +8,6 @@ import ( "strings" "github.com/rs/zerolog" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/codes" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/bridgeconfig" @@ -17,9 +15,6 @@ import ( "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/id" - - "sneeuwvlok/packages/arrtrix/pkg/arrclient" - "sneeuwvlok/packages/arrtrix/pkg/observability" ) type Handler interface { @@ -88,8 +83,6 @@ func NewProcessor(bridge *bridgev2.Bridge, texts bridgeconfig.ManagementRoomText alias: make(map[string]string), } proc.Add(NewHelpHandler(proc)) - proc.Add(NewDownloadHandler()) - proc.Add(NewSubscriptionsHandler()) return proc } @@ -117,9 +110,6 @@ 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) { - ctx, span := observability.StartSpan(ctx, "arrtrix.matrix.command") - defer span.End() - ms := &bridgev2.MessageStatus{ Step: status.MsgStepCommand, Status: event.MessageStatusSuccess, @@ -127,8 +117,6 @@ func (p *Processor) Handle(ctx context.Context, roomID id.RoomID, eventID id.Eve logCopy := zerolog.Ctx(ctx).With().Logger() log := &logCopy - outcome := "success" - commandName := "unknown-command" defer func() { statusInfo := &bridgev2.MessageStatusEventInfo{ @@ -143,21 +131,16 @@ func (p *Processor) Handle(ctx context.Context, roomID id.RoomID, eventID id.Eve if err, ok := recovered.(error); ok { logEvt = logEvt.Err(err) ms.InternalError = err - span.RecordError(err) - span.SetStatus(codes.Error, err.Error()) } else { logEvt = logEvt.Any(zerolog.ErrorFieldName, recovered) ms.InternalError = fmt.Errorf("%v", recovered) - span.SetStatus(codes.Error, "panic") } logEvt.Msg("Panic in arrtrix Matrix command handler") ms.Status = event.MessageStatusFail ms.IsCertain = true ms.ErrorAsMessage = true - outcome = "panic" } - observability.RecordCommand(ctx, commandName, outcome) p.bridge.Matrix.SendMessageStatus(ctx, ms, statusInfo) }() @@ -166,14 +149,10 @@ func (p *Processor) Handle(ctx context.Context, roomID id.RoomID, eventID id.Eve args = []string{"unknown-command"} } - commandName = strings.ToLower(args[0]) + commandName := strings.ToLower(args[0]) if actual, ok := p.alias[commandName]; ok { 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) if err != nil { @@ -200,8 +179,6 @@ func (p *Processor) Handle(ctx context.Context, roomID id.RoomID, eventID id.Eve handler, ok := p.command[commandName] if !ok { 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.") return } @@ -211,7 +188,6 @@ func (p *Processor) Handle(ctx context.Context, roomID id.RoomID, eventID id.Eve }) log.Debug().Msg("Received Matrix room command") handler.Run(commandCtx) - span.SetStatus(codes.Ok, "") } func (c *Context) Reply(message string, args ...any) { @@ -222,49 +198,7 @@ func (c *Context) Reply(message string, args ...any) { content := format.RenderMarkdown(message, true, false) content.MsgType = event.MsgNotice - if err := c.sendNotice(&content); err != nil { + if _, err := c.Bot.SendMessage(c.Ctx, c.OrigRoomID, event.EventMessage, &event.Content{Parsed: &content}, nil); err != nil { 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 -} diff --git a/packages/arrtrix/pkg/matrixcmd/subscriptions.go b/packages/arrtrix/pkg/matrixcmd/subscriptions.go deleted file mode 100644 index ed1a11f..0000000 --- a/packages/arrtrix/pkg/matrixcmd/subscriptions.go +++ /dev/null @@ -1,107 +0,0 @@ -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: " [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 `") - 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 -} diff --git a/packages/arrtrix/pkg/observability/config.go b/packages/arrtrix/pkg/observability/config.go deleted file mode 100644 index 187c5b5..0000000 --- a/packages/arrtrix/pkg/observability/config.go +++ /dev/null @@ -1,22 +0,0 @@ -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) != "" -} diff --git a/packages/arrtrix/pkg/observability/otel.go b/packages/arrtrix/pkg/observability/otel.go deleted file mode 100644 index 2fe46ef..0000000 --- a/packages/arrtrix/pkg/observability/otel.go +++ /dev/null @@ -1,397 +0,0 @@ -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 - } -} diff --git a/packages/arrtrix/pkg/observability/otel_test.go b/packages/arrtrix/pkg/observability/otel_test.go deleted file mode 100644 index 4dd8e3e..0000000 --- a/packages/arrtrix/pkg/observability/otel_test.go +++ /dev/null @@ -1,54 +0,0 @@ -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) - } - }) - } -} diff --git a/packages/arrtrix/pkg/onboarding/welcome.go b/packages/arrtrix/pkg/onboarding/welcome.go index e96ea7a..14860c1 100644 --- a/packages/arrtrix/pkg/onboarding/welcome.go +++ b/packages/arrtrix/pkg/onboarding/welcome.go @@ -6,16 +6,12 @@ import ( "strings" "github.com/rs/zerolog" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/codes" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/bridgeconfig" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/id" - - "sneeuwvlok/packages/arrtrix/pkg/observability" ) const handledInviteEventType = "com.arrtrix.handled_invite" @@ -27,49 +23,27 @@ func HandleBotInvite(ctx context.Context, bridge *bridgev2.Bridge, texts bridgec 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) sender, err := bridge.GetUserByMXID(ctx, evt.Sender) 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") return } if !sender.Permissions.Commands { - outcome = "permission_denied" - span.SetStatus(codes.Error, "sender lacks command permission") return } 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") return } members, err := bridge.Matrix.GetMembers(ctx, evt.RoomID) 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") return } if len(members) != 2 { - outcome = "non_management_room" - span.SetStatus(codes.Error, "invite room is not a direct management room") return } @@ -77,9 +51,6 @@ func HandleBotInvite(ctx context.Context, bridge *bridgev2.Bridge, texts bridgec if assignedManagementRoom { sender.ManagementRoom = evt.RoomID 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") return } @@ -88,15 +59,10 @@ func HandleBotInvite(ctx context.Context, bridge *bridgev2.Bridge, texts bridgec message := buildWelcomeMessage(bridge, texts, sender, assignedManagementRoom) content := format.RenderMarkdown(message, true, false) 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") return } - outcome = "welcomed" - span.SetStatus(codes.Ok, "") evt.Type = event.Type{Type: handledInviteEventType} } diff --git a/packages/arrtrix/pkg/runtime/envconfig.go b/packages/arrtrix/pkg/runtime/envconfig.go index 173de78..f8ffd13 100644 --- a/packages/arrtrix/pkg/runtime/envconfig.go +++ b/packages/arrtrix/pkg/runtime/envconfig.go @@ -36,16 +36,12 @@ func updateConfigFromEnv(cfg, networkData any, prefix string) error { } key = strings.ToLower(key) - lookupKey := key if !strings.ContainsRune(key, '.') { key = strings.ReplaceAll(key, "__", ".") } path := strings.Split(key, ".") field, ok := reflectGetFromMainOrNetwork(cfgVal, networkVal, path) - if !ok && !strings.ContainsRune(lookupKey, '.') { - field, ok = reflectGetFromMainOrNetworkTokens(cfgVal, networkVal, strings.Split(lookupKey, "_")) - } if !ok { return fmt.Errorf("%s not found", formatKey(path)) } @@ -84,13 +80,6 @@ func reflectGetFromMainOrNetwork(main, network reflect.Value, path []string) (*r 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) { if len(path) == 0 { return &reflectedField{value: value, valueKind: value.Kind()}, true @@ -119,41 +108,6 @@ func reflectGetYAML(value reflect.Value, path []string) (*reflectedField, bool) 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 { parts := strings.SplitN(field.Tag.Get("yaml"), ",", 2) switch name := parts[0]; { @@ -166,10 +120,6 @@ 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 { parsed, err := parseValue(field.valueKind, raw, path) if err != nil { diff --git a/packages/arrtrix/pkg/runtime/envconfig_test.go b/packages/arrtrix/pkg/runtime/envconfig_test.go deleted file mode 100644 index 6381a47..0000000 --- a/packages/arrtrix/pkg/runtime/envconfig_test.go +++ /dev/null @@ -1,57 +0,0 @@ -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) -} diff --git a/packages/arrtrix/pkg/runtime/example.go b/packages/arrtrix/pkg/runtime/example.go index c8d7ca4..1cba7b6 100644 --- a/packages/arrtrix/pkg/runtime/example.go +++ b/packages/arrtrix/pkg/runtime/example.go @@ -56,13 +56,6 @@ logging: - type: stdout 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: welcome: "" welcome_connected: "" diff --git a/packages/arrtrix/pkg/runtime/main.go b/packages/arrtrix/pkg/runtime/main.go index c685706..42e1495 100644 --- a/packages/arrtrix/pkg/runtime/main.go +++ b/packages/arrtrix/pkg/runtime/main.go @@ -18,7 +18,6 @@ import ( "go.mau.fi/util/exerrors" "go.mau.fi/util/exzerolog" "go.mau.fi/util/progver" - "go.opentelemetry.io/otel/codes" "gopkg.in/yaml.v3" flag "maunium.net/go/mauflag" "maunium.net/go/mautrix/appservice" @@ -32,9 +31,7 @@ import ( arrconfig "sneeuwvlok/packages/arrtrix/pkg/config" "sneeuwvlok/packages/arrtrix/pkg/matrixcmd" - "sneeuwvlok/packages/arrtrix/pkg/observability" "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() @@ -65,7 +62,6 @@ type Main struct { Config *bridgeconfig.Config Matrix *matrix.Connector Bridge *bridgev2.Bridge - OTEL *observability.Runtime ConfigPath string RegistrationPath string @@ -255,8 +251,6 @@ func (m *Main) loadRegistrationTokens(cfg *bridgeconfig.Config) error { } func (m *Main) Init() { - start := time.Now() - ctx := context.Background() var err error m.Log, err = m.Config.Logging.Compile() if err != nil { @@ -271,33 +265,6 @@ func (m *Main) Init() { 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(). Str("name", m.Name). Str("version", m.ver.FormattedVersion). @@ -306,10 +273,6 @@ func (m *Main) Init() { Msg("Initializing bridge") 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.OnWebsocketReplaced = func() { m.TriggerStop(0) @@ -343,48 +306,17 @@ func (m *Main) Init() { } func (m *Main) Start() { - start := time.Now() 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 { - 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") } - span.SetStatus(codes.Ok, "") - observability.RecordStartupPhase(ctx, "start", "ok", time.Since(start)) if m.PostStart != nil { m.PostStart() } } 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) - 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 { diff --git a/packages/arrtrix/pkg/subscriptions/repo.go b/packages/arrtrix/pkg/subscriptions/repo.go deleted file mode 100644 index 85c6b57..0000000 --- a/packages/arrtrix/pkg/subscriptions/repo.go +++ /dev/null @@ -1,141 +0,0 @@ -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 -} diff --git a/packages/arrtrix/pkg/webhook/arr.go b/packages/arrtrix/pkg/webhook/arr.go index 5446825..42e350c 100644 --- a/packages/arrtrix/pkg/webhook/arr.go +++ b/packages/arrtrix/pkg/webhook/arr.go @@ -7,18 +7,11 @@ import ( "fmt" "net/http" "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/event" "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/id" - - "sneeuwvlok/packages/arrtrix/pkg/arr" - "sneeuwvlok/packages/arrtrix/pkg/observability" ) const ArrWebhookPath = "/_arrtrix/webhook" @@ -29,13 +22,10 @@ var ( ) type payload struct { - EventType string `json:"eventType"` - Movie *movie `json:"movie"` - MovieFile *movieFile `json:"movieFile"` - Series *series `json:"series"` - Episodes []episode `json:"episodes"` - EpisodeFile *episodeFile `json:"episodeFile"` - IsUpgrade bool `json:"isUpgrade"` + EventType string `json:"eventType"` + Movie *movie `json:"movie"` + MovieFile *movieFile `json:"movieFile"` + IsUpgrade bool `json:"isUpgrade"` } type movie struct { @@ -53,192 +43,94 @@ type movieFile struct { 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 { - ResolveManagementRoom(context.Context) (managementTarget, error) + ResolveManagementRoom(context.Context) (id.RoomID, error) } type noticeSender interface { SendNotice(context.Context, id.RoomID, string) error } -type SubscriptionFilter interface { - Allows(context.Context, id.UserID, arr.ContentType, string) (bool, error) -} - type ArrHandler struct { - resolver roomResolver - sender noticeSender - subscriptions SubscriptionFilter + resolver roomResolver + sender noticeSender } -func MountArr(router *http.ServeMux, bridge *bridgev2.Bridge, subscriptions SubscriptionFilter) error { +func MountArr(router *http.ServeMux, bridge *bridgev2.Bridge) error { if bridge == nil { return fmt.Errorf("bridge is not initialized") } handler := &ArrHandler{ - resolver: bridgeRoomResolver{bridge: bridge}, - sender: bridgeNoticeSender{bridge: bridge}, - subscriptions: subscriptions, + resolver: bridgeRoomResolver{bridge: bridge}, + sender: bridgeNoticeSender{bridge: bridge}, } router.Handle(fmt.Sprintf("POST %s", ArrWebhookPath), handler) return nil } 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 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) return } 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) 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), - ) - target, err := h.resolver.ResolveManagementRoom(ctx) + roomID, err := h.resolver.ResolveManagementRoom(r.Context()) if err != nil { - statusCode = http.StatusInternalServerError - outcome = "resolve_failed" + status := http.StatusInternalServerError if errors.Is(err, ErrNoManagementRoom) || errors.Is(err, ErrAmbiguousManagementRoom) { - statusCode = http.StatusConflict - outcome = "routing_conflict" + status = http.StatusConflict } - span.RecordError(err) - span.SetStatus(codes.Error, err.Error()) - http.Error(w, err.Error(), statusCode) + http.Error(w, err.Error(), status) return } - 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()) + if err = h.sender.SendNotice(r.Context(), roomID, renderNotice(body)); err != nil { http.Error(w, "failed to deliver webhook", http.StatusBadGateway) return } - span.SetStatus(codes.Ok, "") - w.WriteHeader(statusCode) + w.WriteHeader(http.StatusAccepted) } type bridgeRoomResolver struct { bridge *bridgev2.Bridge } -func (r bridgeRoomResolver) ResolveManagementRoom(ctx context.Context) (managementTarget, error) { - ctx, span := observability.StartSpan(ctx, "arrtrix.webhook.resolve_management_room") - defer span.End() - +func (r bridgeRoomResolver) ResolveManagementRoom(ctx context.Context) (id.RoomID, error) { 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 { - span.RecordError(err) - span.SetStatus(codes.Error, err.Error()) - return managementTarget{}, fmt.Errorf("failed to query management rooms: %w", err) + return "", fmt.Errorf("failed to query management rooms: %w", err) } defer rows.Close() - var target managementTarget + var roomID id.RoomID var owners []id.UserID for rows.Next() { var mxid, managementRoom string if err = rows.Scan(&mxid, &managementRoom); err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, err.Error()) - return managementTarget{}, fmt.Errorf("failed to scan management room: %w", err) + return "", fmt.Errorf("failed to scan management room: %w", err) } owners = append(owners, id.UserID(mxid)) - if target.RoomID == "" { - target = managementTarget{ - UserID: id.UserID(mxid), - RoomID: id.RoomID(managementRoom), - } + if roomID == "" { + roomID = id.RoomID(managementRoom) } } if err = rows.Err(); err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, err.Error()) - return managementTarget{}, fmt.Errorf("failed to iterate management rooms: %w", err) + return "", fmt.Errorf("failed to iterate management rooms: %w", err) } switch len(owners) { case 0: - span.SetStatus(codes.Error, ErrNoManagementRoom.Error()) - return managementTarget{}, ErrNoManagementRoom + return "", ErrNoManagementRoom case 1: - span.SetAttributes(attribute.Int("arrtrix.management_room.count", 1)) - span.SetStatus(codes.Ok, "") - return target, nil + return roomID, nil default: - 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), ", ")) + return "", fmt.Errorf("%w: %s", ErrAmbiguousManagementRoom, strings.Join(convertUserIDs(owners), ", ")) } } @@ -247,69 +139,39 @@ type bridgeNoticeSender struct { } 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 { - span.RecordError(err) - span.SetStatus(codes.Error, err.Error()) return err } content := format.RenderMarkdown(markdown, true, false) _, 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 } func renderNotice(body payload) string { - lines := []string{fmt.Sprintf("**Arr %s**", body.EventType)} - - switch contentType, ok := body.ContentType(); { - case ok && contentType == arr.ContentTypeMovies: - title := body.Movie.Title + title := "Arr" + if body.Movie != nil { + title = body.Movie.Title if body.Movie.Year != 0 { title = fmt.Sprintf("%s (%d)", title, body.Movie.Year) } - lines = append(lines, fmt.Sprintf("Movie: %s", title)) - if body.MovieFile != nil && body.MovieFile.Quality != "" { - lines = append(lines, fmt.Sprintf("Quality: %s", body.MovieFile.Quality)) - } - if body.MovieFile != nil && body.MovieFile.RelativePath != "" { - lines = append(lines, fmt.Sprintf("File: `%s`", body.MovieFile.RelativePath)) - } - if body.EventType == "Download" { - lines = append(lines, fmt.Sprintf("Upgrade: %t", body.IsUpgrade)) - } - if 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.") - } } + lines := []string{fmt.Sprintf("**Arr %s**", body.EventType)} + if title != "Arr" { + lines = append(lines, fmt.Sprintf("Movie: %s", title)) + } + if body.MovieFile != nil && body.MovieFile.Quality != "" { + lines = append(lines, fmt.Sprintf("Quality: %s", body.MovieFile.Quality)) + } + if body.MovieFile != nil && body.MovieFile.RelativePath != "" { + lines = append(lines, fmt.Sprintf("File: `%s`", body.MovieFile.RelativePath)) + } + if body.EventType == "Download" { + lines = append(lines, fmt.Sprintf("Upgrade: %t", body.IsUpgrade)) + } + if body.Movie != nil && body.Movie.ImdbID != "" { + lines = append(lines, fmt.Sprintf("IMDb: `%s`", body.Movie.ImdbID)) + } return strings.Join(lines, "\n") } @@ -324,26 +186,3 @@ func convertUserIDs(users []id.UserID) []string { var _ roomResolver = bridgeRoomResolver{} var _ noticeSender = bridgeNoticeSender{} 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, ", ") -} diff --git a/packages/arrtrix/pkg/webhook/arr_test.go b/packages/arrtrix/pkg/webhook/arr_test.go index e7e89f6..b7ac511 100644 --- a/packages/arrtrix/pkg/webhook/arr_test.go +++ b/packages/arrtrix/pkg/webhook/arr_test.go @@ -9,17 +9,15 @@ import ( "testing" "maunium.net/go/mautrix/id" - - "sneeuwvlok/packages/arrtrix/pkg/arr" ) type stubRoomResolver struct { - target managementTarget + roomID id.RoomID err error } -func (s stubRoomResolver) ResolveManagementRoom(context.Context) (managementTarget, error) { - return s.target, s.err +func (s stubRoomResolver) ResolveManagementRoom(context.Context) (id.RoomID, error) { + return s.roomID, s.err } type stubNoticeSender struct { @@ -28,24 +26,15 @@ type stubNoticeSender struct { err error } -type stubSubscriptionFilter struct { - allowed bool - err error -} - func (s *stubNoticeSender) SendNotice(_ context.Context, roomID id.RoomID, message string) error { s.roomID = roomID s.message = message 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) { router := http.NewServeMux() - if err := MountArr(router, nil, nil); err == nil { + if err := MountArr(router, nil); err == nil { t.Fatal("expected nil bridge to fail") } } @@ -53,7 +42,7 @@ func TestMountArrRequiresBridge(t *testing.T) { func TestArrHandlerDeliversNotice(t *testing.T) { sender := &stubNoticeSender{} handler := &ArrHandler{ - resolver: stubRoomResolver{target: managementTarget{UserID: "@user:test", RoomID: "!room:test"}}, + resolver: stubRoomResolver{roomID: "!room:test"}, sender: sender, } @@ -96,7 +85,7 @@ func TestRenderNoticeForTestEvent(t *testing.T) { func TestArrHandlerReturnsBadGatewayOnSendFailure(t *testing.T) { handler := &ArrHandler{ - resolver: stubRoomResolver{target: managementTarget{UserID: "@user:test", RoomID: "!room:test"}}, + resolver: stubRoomResolver{roomID: "!room:test"}, sender: &stubNoticeSender{err: errors.New("send failed")}, } @@ -111,7 +100,7 @@ func TestArrHandlerReturnsBadGatewayOnSendFailure(t *testing.T) { func TestArrHandlerRejectsMissingEventType(t *testing.T) { handler := &ArrHandler{ - resolver: stubRoomResolver{target: managementTarget{UserID: "@user:test", RoomID: "!room:test"}}, + resolver: stubRoomResolver{roomID: "!room:test"}, sender: &stubNoticeSender{}, } @@ -123,23 +112,3 @@ func TestArrHandlerRejectsMissingEventType(t *testing.T) { 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) - } -} diff --git a/shells/default/default.nix b/shells/default/default.nix index 76d15b7..ed12b5c 100644 --- a/shells/default/default.nix +++ b/shells/default/default.nix @@ -18,6 +18,5 @@ mkShell { openssl inputs.clan-core.packages.${stdenv.hostPlatform.system}.clan-cli nix-output-monitor - dos2unix ]; } diff --git a/systems/x86_64-linux/ulmo/default.nix b/systems/x86_64-linux/ulmo/default.nix index fd25824..7c20a11 100644 --- a/systems/x86_64-linux/ulmo/default.nix +++ b/systems/x86_64-linux/ulmo/default.nix @@ -39,6 +39,31 @@ }; }; + # 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 = { services = { backup.borg.enable = true; @@ -115,13 +140,13 @@ }; mydia = { - redirectUris = ["http://localhost:2100/auth/oidc/callback"]; + redirectUris = ["http://localhost:2010/auth/oidc/callback"]; grantTypes = ["authorizationCode"]; responseTypes = ["code"]; }; grafana = { - redirectUris = ["http://localhost:9010/login/generic_oauth"]; + redirectUris = ["http://localhost:9001/login/generic_oauth"]; grantTypes = ["authorizationCode"]; responseTypes = ["code"]; }; @@ -199,7 +224,7 @@ media.servarr = { radarr = { enable = true; - port = 2010; + port = 2001; rootFolders = [ "/var/media/movies" ]; @@ -208,7 +233,7 @@ sonarr = { enable = true; # debug = true; - port = 2020; + port = 2002; rootFolders = [ "/var/media/series" ]; @@ -217,7 +242,7 @@ lidarr = { enable = true; debug = true; - port = 2030; + port = 2003; rootFolders = [ "/var/media/music" ]; @@ -226,17 +251,15 @@ prowlarr = { enable = true; # debug = true; - port = 2040; + port = 2004; }; }; observability = { - alloy.enable = true; grafana.enable = true; - loki.enable = true; prometheus.enable = true; + loki.enable = true; promtail.enable = true; - tempo.enable = true; # uptime-kuma.enable = true; };