Compare commits
19 commits
experiment
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
100a218aed | ||
|
|
e07257e137 | ||
|
|
be2843ca80 | ||
|
|
e26e25b566 | ||
|
|
9b93f017b6 | ||
|
|
81f34676c4 | ||
|
|
bbfe6867c8 | ||
|
|
fe627f3aab | ||
|
|
eeedb5268a | ||
|
|
ce44496a48 | ||
| c4e9485ccb | |||
| 6fe9387626 | |||
|
|
d5b5166b95 | ||
|
|
66fc9e532a | ||
|
|
03bd906aef | ||
| a1d4c244cf | |||
| 352569fd8b | |||
|
|
7b37c0e9c3 | ||
| a10e74a596 |
63 changed files with 5487 additions and 498 deletions
6
.editorconfig
Normal file
6
.editorconfig
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
charset = utf-8
|
||||||
5
.gitattributes
vendored
5
.gitattributes
vendored
|
|
@ -1,4 +1 @@
|
||||||
* text=auto
|
* text=auto eol=lf
|
||||||
core.autocrlf=false
|
|
||||||
core.eol=lf
|
|
||||||
core.filemode=false
|
|
||||||
|
|
|
||||||
|
|
@ -43,14 +43,14 @@ generate machine:
|
||||||
# Skip if we already have a value
|
# Skip if we already have a value
|
||||||
[ $(just vars get "{{ machine }}" "$key" | jq -r) ] && continue
|
[ $(just vars get "{{ machine }}" "$key" | jq -r) ] && continue
|
||||||
|
|
||||||
just _rotate "{{ machine }}" "$key"
|
just vars _rotate "{{ machine }}" "$key"
|
||||||
done
|
done
|
||||||
|
|
||||||
[doc('Regenerate var values for {machine}')]
|
[doc('Regenerate var values for {machine}')]
|
||||||
[script]
|
[script]
|
||||||
_rotate machine key:
|
_rotate machine key:
|
||||||
# Exit if there's no script
|
# Exit if there's no script
|
||||||
[ -f "{{ justfile_directory() }}/script/{{ key }}" ] || exit
|
[ -f "{{ justfile_directory() }}/script/{{ key }}" ] || exit 0
|
||||||
|
|
||||||
echo "Executing script for {{ key }}"
|
echo "Executing script for {{ key }}"
|
||||||
just vars set "{{ machine }}" "{{ key }}" "$(cd -- "$(dirname "{{ justfile_directory() }}/script/{{ key }}")" && source "./$(basename "{{ key }}")")"
|
just vars set "{{ machine }}" "{{ key }}" "$(cd -- "$(dirname "{{ justfile_directory() }}/script/{{ key }}")" && source "./$(basename "{{ key }}")")"
|
||||||
|
|
|
||||||
246
flake.lock
generated
246
flake.lock
generated
|
|
@ -83,11 +83,11 @@
|
||||||
"treefmt-nix": "treefmt-nix"
|
"treefmt-nix": "treefmt-nix"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774258552,
|
"lastModified": 1775389026,
|
||||||
"narHash": "sha256-wTJJxhLPr3OHXQ23H9+Ch1YjdlaoMf3605ezfRYLaC4=",
|
"narHash": "sha256-cHYF7eGiVqgEnIQKs105eV0P5/zOvxl443qO1f5/Bps=",
|
||||||
"rev": "28bb98f5aec0ea70b623ab4953eb8186acdb7bba",
|
"rev": "d53f3c0b42400ff608dd468ac33359881baf969e",
|
||||||
"type": "tarball",
|
"type": "tarball",
|
||||||
"url": "https://git.clan.lol/api/v1/repos/clan/clan-core/archive/28bb98f5aec0ea70b623ab4953eb8186acdb7bba.tar.gz"
|
"url": "https://git.clan.lol/api/v1/repos/clan/clan-core/archive/d53f3c0b42400ff608dd468ac33359881baf969e.tar.gz"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"type": "tarball",
|
"type": "tarball",
|
||||||
|
|
@ -125,11 +125,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774087718,
|
"lastModified": 1774796937,
|
||||||
"narHash": "sha256-UU4KzRMTFJttIoSnRm1SWheFcfAVAsNqG+4JauKib3g=",
|
"narHash": "sha256-uDcgnNHK1D2oTHOQKsqQUPdDGMuG94dp3Nv8LsnqkEM=",
|
||||||
"rev": "734047b2dd1e67c3a803999777cdf749f3199342",
|
"rev": "04e10e10c7b4bbf2930f24d139326707a43cbb54",
|
||||||
"type": "tarball",
|
"type": "tarball",
|
||||||
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/734047b2dd1e67c3a803999777cdf749f3199342.tar.gz"
|
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/04e10e10c7b4bbf2930f24d139326707a43cbb54.tar.gz"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"type": "tarball",
|
"type": "tarball",
|
||||||
|
|
@ -163,11 +163,11 @@
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773767380,
|
"lastModified": 1775241072,
|
||||||
"narHash": "sha256-fHrKh0/EQlEJe6czXPo9/bw1lki7w0RAGKRqYv/445s=",
|
"narHash": "sha256-YpXDFEkd+JjxZOgTnvt5GHvEhORxkAda9Lc1e8e8Ox8=",
|
||||||
"owner": "emmanuelrosa",
|
"owner": "emmanuelrosa",
|
||||||
"repo": "erosanix",
|
"repo": "erosanix",
|
||||||
"rev": "ada69cf31f7649f8e59fe5376c94f3b0ea38bf37",
|
"rev": "14ac50e5ddefdb1c5ed66c11d2c6fa68959d690a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -184,11 +184,11 @@
|
||||||
"rust-analyzer-src": "rust-analyzer-src"
|
"rust-analyzer-src": "rust-analyzer-src"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774250935,
|
"lastModified": 1775373929,
|
||||||
"narHash": "sha256-mWID0WFgTnd9hbEeaPNX+YYWF70JN3r7zBouEqERJOE=",
|
"narHash": "sha256-Elx3es3UvLova3YBdJTc9rju9ULl9+5XF4K5t5Ejsa8=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "fenix",
|
"repo": "fenix",
|
||||||
"rev": "64d7705e8c37d650cfb1aa99c24a8ce46597f29e",
|
"rev": "221468471f762f355db24ce728012544561650f5",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -204,11 +204,11 @@
|
||||||
"nixpkgs": "nixpkgs_2"
|
"nixpkgs": "nixpkgs_2"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774141843,
|
"lastModified": 1775388520,
|
||||||
"narHash": "sha256-gpjHyyfLvBLZQiWumOxsfsOxt6KTjNhUOXk+m9ISBHc=",
|
"narHash": "sha256-WUnKn7L/yBo7a5xH2UmPvBfYUr3d4Q8EPCz5r09C8Eo=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "flake-firefox-nightly",
|
"repo": "flake-firefox-nightly",
|
||||||
"rev": "3a1fcd6a4dbd617ad2014dd03aa68cdd885d5322",
|
"rev": "00070174d7a635f5238aee06e4feb481ccc7d9f9",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -220,11 +220,11 @@
|
||||||
"firefox-gnome-theme": {
|
"firefox-gnome-theme": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1764873433,
|
"lastModified": 1775176642,
|
||||||
"narHash": "sha256-1XPewtGMi+9wN9Ispoluxunw/RwozuTRVuuQOmxzt+A=",
|
"narHash": "sha256-2veEED0Fg7Fsh81tvVDNYR6SzjqQxa7hbi18Jv4LWpM=",
|
||||||
"owner": "rafaelmardojai",
|
"owner": "rafaelmardojai",
|
||||||
"repo": "firefox-gnome-theme",
|
"repo": "firefox-gnome-theme",
|
||||||
"rev": "f7ffd917ac0d253dbd6a3bf3da06888f57c69f92",
|
"rev": "179704030c5286c729b5b0522037d1d51341022c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -320,11 +320,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1772408722,
|
"lastModified": 1775087534,
|
||||||
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
"narHash": "sha256-91qqW8lhL7TLwgQWijoGBbiD4t7/q75KTi8NxjVmSmA=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
"rev": "3107b77cd68437b9a76194f0f7f9c55f2329ca5b",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -383,11 +383,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1767609335,
|
"lastModified": 1775087534,
|
||||||
"narHash": "sha256-feveD98mQpptwrAEggBQKJTYbvwwglSbOv53uCfH9PY=",
|
"narHash": "sha256-91qqW8lhL7TLwgQWijoGBbiD4t7/q75KTi8NxjVmSmA=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "250481aafeb741edfe23d29195671c19b36b6dca",
|
"rev": "3107b77cd68437b9a76194f0f7f9c55f2329ca5b",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -510,20 +510,18 @@
|
||||||
"gnome-shell": {
|
"gnome-shell": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"host": "gitlab.gnome.org",
|
|
||||||
"lastModified": 1767737596,
|
"lastModified": 1767737596,
|
||||||
"narHash": "sha256-eFujfIUQDgWnSJBablOuG+32hCai192yRdrNHTv0a+s=",
|
"narHash": "sha256-eFujfIUQDgWnSJBablOuG+32hCai192yRdrNHTv0a+s=",
|
||||||
"owner": "GNOME",
|
"owner": "GNOME",
|
||||||
"repo": "gnome-shell",
|
"repo": "gnome-shell",
|
||||||
"rev": "ef02db02bf0ff342734d525b5767814770d85b49",
|
"rev": "ef02db02bf0ff342734d525b5767814770d85b49",
|
||||||
"type": "gitlab"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"host": "gitlab.gnome.org",
|
|
||||||
"owner": "GNOME",
|
"owner": "GNOME",
|
||||||
"ref": "gnome-49",
|
|
||||||
"repo": "gnome-shell",
|
"repo": "gnome-shell",
|
||||||
"type": "gitlab"
|
"rev": "ef02db02bf0ff342734d525b5767814770d85b49",
|
||||||
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"grub2-themes": {
|
"grub2-themes": {
|
||||||
|
|
@ -551,11 +549,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773992301,
|
"lastModified": 1775230022,
|
||||||
"narHash": "sha256-lm1qy9P463cblBAFC2g8VaALR1Gje1oyYXCPtiEumus=",
|
"narHash": "sha256-FBhkbsqDTULYB1nS92y1CT7qSAM9rUMZR9hS8AvIw24=",
|
||||||
"owner": "himmelblau-idm",
|
"owner": "himmelblau-idm",
|
||||||
"repo": "himmelblau",
|
"repo": "himmelblau",
|
||||||
"rev": "fcb8966990c24f97fe224fa0c8977fe730d4cf50",
|
"rev": "d700f39281354c0b08cfb9640011a381bed29136",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -571,11 +569,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774210133,
|
"lastModified": 1775360939,
|
||||||
"narHash": "sha256-yeiWCY9aAUUJ3ebMVjs0UZXRnT5x90MCtpbpOWiXrvM=",
|
"narHash": "sha256-XUBlSgUFdvTh6+K5LcI5mJu5F5L8scmJDMRiZM484TM=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "home-manager",
|
"repo": "home-manager",
|
||||||
"rev": "c6fe2944ad9f2444b2d767c4a5edee7c166e8a95",
|
"rev": "2097a5c82bdc099c6135eae4b111b78124604554",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -592,11 +590,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773422513,
|
"lastModified": 1774991950,
|
||||||
"narHash": "sha256-MPjR48roW7CUMU6lu0+qQGqj92Kuh3paIulMWFZy+NQ=",
|
"narHash": "sha256-kScKj3qJDIWuN9/6PMmgy5esrTUkYinrO5VvILik/zw=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "home-manager",
|
"repo": "home-manager",
|
||||||
"rev": "ef12a9a2b0f77c8fa3dda1e7e494fca668909056",
|
"rev": "f2d3e04e278422c7379e067e323734f3e8c585a7",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -613,11 +611,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774168156,
|
"lastModified": 1775287496,
|
||||||
"narHash": "sha256-+pwZSARdlM2RQQ6V0q76+WMKW9aNIcxkSOIThcz/f0A=",
|
"narHash": "sha256-tCBlt+RP85MLrMYntro/YvG7NWktbmFiyItGBo85Tf8=",
|
||||||
"owner": "Jovian-Experiments",
|
"owner": "Jovian-Experiments",
|
||||||
"repo": "Jovian-NixOS",
|
"repo": "Jovian-NixOS",
|
||||||
"rev": "939caad56508542d0f19cab963e2bc693f5f2831",
|
"rev": "0a7a3feb77606db451aa10287ad4c4c8f85922f8",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -632,11 +630,11 @@
|
||||||
"nixpkgs-lib": "nixpkgs-lib"
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773579712,
|
"lastModified": 1774789463,
|
||||||
"narHash": "sha256-cvxFTYuOvvmpLJz5nB8iREmMGsDksY6gmZFf74UKD1Q=",
|
"narHash": "sha256-MFraiT8o6manIcEloazGYafji1ua3HJ7Re/A/uauqYA=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "lib-aggregate",
|
"repo": "lib-aggregate",
|
||||||
"rev": "c23c52797845b8e4f273ddb5ccdf8622b5d98284",
|
"rev": "dc3bd444a2ea0834374b7d759c532f232e144128",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -728,11 +726,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773000227,
|
"lastModified": 1775037210,
|
||||||
"narHash": "sha256-zm3ftUQw0MPumYi91HovoGhgyZBlM4o3Zy0LhPNwzXE=",
|
"narHash": "sha256-KM2WYj6EA7M/FVZVCl3rqWY+TFV5QzSyyGE2gQxeODU=",
|
||||||
"owner": "nix-darwin",
|
"owner": "nix-darwin",
|
||||||
"repo": "nix-darwin",
|
"repo": "nix-darwin",
|
||||||
"rev": "da529ac9e46f25ed5616fd634079a5f3c579135f",
|
"rev": "06648f4902343228ce2de79f291dd5a58ee12146",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -770,11 +768,11 @@
|
||||||
"systems": "systems_3"
|
"systems": "systems_3"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774060651,
|
"lastModified": 1775359538,
|
||||||
"narHash": "sha256-sZiam+rmNcOZGnlbnqDD9oTwfMdQUM+uQmFqqSoe194=",
|
"narHash": "sha256-PbX+bT49p9c7cmT03ufao8tDDEn0Qi7R82R1yXDyk5k=",
|
||||||
"owner": "Infinidoge",
|
"owner": "Infinidoge",
|
||||||
"repo": "nix-minecraft",
|
"repo": "nix-minecraft",
|
||||||
"rev": "46727bd27d32d63069ed26a690554373ae2b4702",
|
"rev": "bdf703935b0aa47d9de1c6a7536fc76756b044ef",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -855,11 +853,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773882647,
|
"lastModified": 1774972752,
|
||||||
"narHash": "sha256-VzcOcE0LLpEnyoxLuMuptZ9ZWCkSBn99bTgEQoz5Viw=",
|
"narHash": "sha256-DnLIpFxznohpLkIFs390uZ0gxwkVyhtknhKNu+lQJK8=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixos-wsl",
|
"repo": "nixos-wsl",
|
||||||
"rev": "fd0eae98d1ecee31024271f8d64676250a386ee7",
|
"rev": "d97e078f4788cddb8d11c3c99f72a4bb9ddec221",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -870,11 +868,11 @@
|
||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1772380631,
|
"lastModified": 1775054576,
|
||||||
"narHash": "sha256-FhW0uxeXjefINP0vUD4yRBB52Us7fXZPk9RiPAopfiY=",
|
"narHash": "sha256-iiIr1hlTMu2LLARsUYtiqlE90tqocqIMVLK2fIzB/UY=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "6d3b61b190a899042ce82a5355111976ba76d698",
|
"rev": "fc4b9b74d4b0bdbf3c97fef4bd34c05225172912",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -886,11 +884,11 @@
|
||||||
},
|
},
|
||||||
"nixpkgs-lib": {
|
"nixpkgs-lib": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773538553,
|
"lastModified": 1774748309,
|
||||||
"narHash": "sha256-hohiyWALn8cXqk3FPnE3UADy03lRMaTV5iRzKCU86zM=",
|
"narHash": "sha256-+U7gF3qxzwD5TZuANzZPeJTZRHS29OFQgkQ2kiTJBIQ=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixpkgs.lib",
|
"repo": "nixpkgs.lib",
|
||||||
"rev": "a5ed666a3c206de0019b4c9dafc3a51f352bc7e3",
|
"rev": "333c4e0545a6da976206c74db8773a1645b5870a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -901,11 +899,11 @@
|
||||||
},
|
},
|
||||||
"nixpkgs_10": {
|
"nixpkgs_10": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773840656,
|
"lastModified": 1775126147,
|
||||||
"narHash": "sha256-9tpvMGFteZnd3gRQZFlRCohVpqooygFuy9yjuyRL2C0=",
|
"narHash": "sha256-J0dZU4atgcfo4QvM9D92uQ0Oe1eLTxBVXjJzdEMQpD0=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "9cf7092bdd603554bd8b63c216e8943cf9b12512",
|
"rev": "8d8c1fa5b412c223ffa47410867813290cdedfef",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -917,11 +915,11 @@
|
||||||
},
|
},
|
||||||
"nixpkgs_11": {
|
"nixpkgs_11": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1767767207,
|
"lastModified": 1775036866,
|
||||||
"narHash": "sha256-Mj3d3PfwltLmukFal5i3fFt27L6NiKXdBezC1EBuZs4=",
|
"narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "5912c1772a44e31bf1c63c0390b90501e5026886",
|
"rev": "6201e203d09599479a3b3450ed24fa81537ebc4e",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -933,11 +931,11 @@
|
||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774106199,
|
"lastModified": 1775371993,
|
||||||
"narHash": "sha256-US5Tda2sKmjrg2lNHQL3jRQ6p96cgfWh3J1QBliQ8Ws=",
|
"narHash": "sha256-shlcgEOzW6rl7zmZeYBMP9EpF3O/cTL7/HpWlyqearw=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "6c9a78c09ff4d6c21d0319114873508a6ec01655",
|
"rev": "ff2af6f7ebc6c123603d5689aeea6461290f46b5",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -980,11 +978,11 @@
|
||||||
},
|
},
|
||||||
"nixpkgs_5": {
|
"nixpkgs_5": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774259547,
|
"lastModified": 1775391773,
|
||||||
"narHash": "sha256-5EQ1TL+R/tcsoGas1oALp5Tj2ACfSul+pfrrxP72xC0=",
|
"narHash": "sha256-8h0YBzKR6kf+68qnZtZnC6GhTf2XAilTQ9F/tm5JDWs=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "b3f8d82c4c685fb6f3080745dab8f07606ae50d3",
|
"rev": "728629d3d4797ab52406df91b319c07a7d2ce479",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -1028,11 +1026,11 @@
|
||||||
},
|
},
|
||||||
"nixpkgs_8": {
|
"nixpkgs_8": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774106199,
|
"lastModified": 1775036866,
|
||||||
"narHash": "sha256-US5Tda2sKmjrg2lNHQL3jRQ6p96cgfWh3J1QBliQ8Ws=",
|
"narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "6c9a78c09ff4d6c21d0319114873508a6ec01655",
|
"rev": "6201e203d09599479a3b3450ed24fa81537ebc4e",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -1044,11 +1042,11 @@
|
||||||
},
|
},
|
||||||
"nixpkgs_9": {
|
"nixpkgs_9": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1771008912,
|
"lastModified": 1774386573,
|
||||||
"narHash": "sha256-gf2AmWVTs8lEq7z/3ZAsgnZDhWIckkb+ZnAo5RzSxJg=",
|
"narHash": "sha256-4hAV26quOxdC6iyG7kYaZcM3VOskcPUrdCQd/nx8obc=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "a82ccc39b39b621151d6732718e3e250109076fa",
|
"rev": "46db2e09e1d3f113a13c0d7b81e2f221c63b8ce9",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -1070,11 +1068,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1767810917,
|
"lastModified": 1775228139,
|
||||||
"narHash": "sha256-ZKqhk772+v/bujjhla9VABwcvz+hB2IaRyeLT6CFnT0=",
|
"narHash": "sha256-ebbeHmg+V7w8050bwQOuhmQHoLOEOfqKzM1KgCTexK4=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "NUR",
|
"repo": "NUR",
|
||||||
"rev": "dead29c804adc928d3a69dfe7f9f12d0eec1f1a4",
|
"rev": "601971b9c89e0304561977f2c28fa25e73aa7132",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -1093,11 +1091,11 @@
|
||||||
"systems": "systems_4"
|
"systems": "systems_4"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774224548,
|
"lastModified": 1775122065,
|
||||||
"narHash": "sha256-g45WZAZHNc7wJBkK4IdB5dq0Bh0JE7G0gcY2H5DFi44=",
|
"narHash": "sha256-ZlowJNkQOhpsXDuWbHgB1xY6W8kyzYn9coK9nJsqqNg=",
|
||||||
"owner": "notashelf",
|
"owner": "notashelf",
|
||||||
"repo": "nvf",
|
"repo": "nvf",
|
||||||
"rev": "edfb73fa4ced576f587d259a70a513b4152f8cea",
|
"rev": "d3304af3d5771e8d5bac6ee9bbdbce56086d54f7",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -1116,11 +1114,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1772361940,
|
"lastModified": 1774915545,
|
||||||
"narHash": "sha256-B1Cz+ydL1iaOnGlwOFld/C8lBECPtzhiy/pP93/CuyY=",
|
"narHash": "sha256-COT4l/+ZddGBvrDVfPf7MEOJxV8EDKame6/aRnNIKcY=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "plasma-manager",
|
"repo": "plasma-manager",
|
||||||
"rev": "a4b33606111c9c5dcd10009042bb710307174f51",
|
"rev": "f3177b3c69fb3f03201098d7fe8ab6422cce7fc1",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -1158,11 +1156,11 @@
|
||||||
"rust-analyzer-src": {
|
"rust-analyzer-src": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774221325,
|
"lastModified": 1775228522,
|
||||||
"narHash": "sha256-aEIdkqB8gtQZtEbogdUb5iyfcZpKIlD3FkG8ANu73/I=",
|
"narHash": "sha256-+6eTD6EAabjow5gdjWRP6aI2UUwOZJEjzzsvvbVu8f8=",
|
||||||
"owner": "rust-lang",
|
"owner": "rust-lang",
|
||||||
"repo": "rust-analyzer",
|
"repo": "rust-analyzer",
|
||||||
"rev": "b42b63f390a4dab14e6efa34a70e67f5b087cc62",
|
"rev": "f4b77dc99d9925667246e2887783b79bdc46a50d",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -1202,11 +1200,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774154798,
|
"lastModified": 1775365543,
|
||||||
"narHash": "sha256-zsTuloDSdKf+PrI1MsWx5z/cyGEJ8P3eERtAfdP8Bmg=",
|
"narHash": "sha256-f50qrK0WwZ9z5EdaMGWOTtALgSF7yb7XwuE7LjCuDmw=",
|
||||||
"owner": "Mic92",
|
"owner": "Mic92",
|
||||||
"repo": "sops-nix",
|
"repo": "sops-nix",
|
||||||
"rev": "3e0d543e6ba6c0c48117a81614e90c6d8c425170",
|
"rev": "a4ee2de76efb759fe8d4868c33dec9937897916f",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -1220,11 +1218,11 @@
|
||||||
"nixpkgs": "nixpkgs_10"
|
"nixpkgs": "nixpkgs_10"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774154798,
|
"lastModified": 1775365543,
|
||||||
"narHash": "sha256-zsTuloDSdKf+PrI1MsWx5z/cyGEJ8P3eERtAfdP8Bmg=",
|
"narHash": "sha256-f50qrK0WwZ9z5EdaMGWOTtALgSF7yb7XwuE7LjCuDmw=",
|
||||||
"owner": "Mic92",
|
"owner": "Mic92",
|
||||||
"repo": "sops-nix",
|
"repo": "sops-nix",
|
||||||
"rev": "3e0d543e6ba6c0c48117a81614e90c6d8c425170",
|
"rev": "a4ee2de76efb759fe8d4868c33dec9937897916f",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -1245,18 +1243,17 @@
|
||||||
"nixpkgs": "nixpkgs_11",
|
"nixpkgs": "nixpkgs_11",
|
||||||
"nur": "nur",
|
"nur": "nur",
|
||||||
"systems": "systems_6",
|
"systems": "systems_6",
|
||||||
"tinted-foot": "tinted-foot",
|
|
||||||
"tinted-kitty": "tinted-kitty",
|
"tinted-kitty": "tinted-kitty",
|
||||||
"tinted-schemes": "tinted-schemes",
|
"tinted-schemes": "tinted-schemes",
|
||||||
"tinted-tmux": "tinted-tmux",
|
"tinted-tmux": "tinted-tmux",
|
||||||
"tinted-zed": "tinted-zed"
|
"tinted-zed": "tinted-zed"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774124764,
|
"lastModified": 1775247334,
|
||||||
"narHash": "sha256-Poz9WTjiRlqZIf197CrMMJfTifZhrZpbHFv0eU1Nhtg=",
|
"narHash": "sha256-eVKt8wpQqg6Hq/UdHQkV1izXGloGQxdlE4SSk9/X27s=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "stylix",
|
"repo": "stylix",
|
||||||
"rev": "e31c79f571c5595a155f84b9d77ce53a84745494",
|
"rev": "6d0502ef7447090abf8b00362b5cda8ac64595b4",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -1392,23 +1389,6 @@
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tinted-foot": {
|
|
||||||
"flake": false,
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1726913040,
|
|
||||||
"narHash": "sha256-+eDZPkw7efMNUf3/Pv0EmsidqdwNJ1TaOum6k7lngDQ=",
|
|
||||||
"owner": "tinted-theming",
|
|
||||||
"repo": "tinted-foot",
|
|
||||||
"rev": "fd1b924b6c45c3e4465e8a849e67ea82933fcbe4",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "tinted-theming",
|
|
||||||
"repo": "tinted-foot",
|
|
||||||
"rev": "fd1b924b6c45c3e4465e8a849e67ea82933fcbe4",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tinted-kitty": {
|
"tinted-kitty": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
|
|
@ -1428,11 +1408,11 @@
|
||||||
"tinted-schemes": {
|
"tinted-schemes": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1767710407,
|
"lastModified": 1772661346,
|
||||||
"narHash": "sha256-+W1EB79Jl0/gm4JqmO0Nuc5C7hRdp4vfsV/VdzI+des=",
|
"narHash": "sha256-4eu3LqB9tPqe0Vaqxd4wkZiBbthLbpb7llcoE/p5HT0=",
|
||||||
"owner": "tinted-theming",
|
"owner": "tinted-theming",
|
||||||
"repo": "schemes",
|
"repo": "schemes",
|
||||||
"rev": "2800e2b8ac90f678d7e4acebe4fa253f602e05b2",
|
"rev": "13b5b0c299982bb361039601e2d72587d6846294",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -1444,11 +1424,11 @@
|
||||||
"tinted-tmux": {
|
"tinted-tmux": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1767489635,
|
"lastModified": 1772934010,
|
||||||
"narHash": "sha256-e6nnFnWXKBCJjCv4QG4bbcouJ6y3yeT70V9MofL32lU=",
|
"narHash": "sha256-x+6+4UvaG+RBRQ6UaX+o6DjEg28u4eqhVRM9kpgJGjQ=",
|
||||||
"owner": "tinted-theming",
|
"owner": "tinted-theming",
|
||||||
"repo": "tinted-tmux",
|
"repo": "tinted-tmux",
|
||||||
"rev": "3c32729ccae99be44fe8a125d20be06f8d7d8184",
|
"rev": "c3529673a5ab6e1b6830f618c45d9ce1bcdd829d",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -1460,11 +1440,11 @@
|
||||||
"tinted-zed": {
|
"tinted-zed": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1767488740,
|
"lastModified": 1772909925,
|
||||||
"narHash": "sha256-wVOj0qyil8m+ouSsVZcNjl5ZR+1GdOOAooAatQXHbuU=",
|
"narHash": "sha256-jx/5+pgYR0noHa3hk2esin18VMbnPSvWPL5bBjfTIAU=",
|
||||||
"owner": "tinted-theming",
|
"owner": "tinted-theming",
|
||||||
"repo": "base16-zed",
|
"repo": "base16-zed",
|
||||||
"rev": "11abb0b282ad3786a2aae088d3a01c60916f2e40",
|
"rev": "b4d3a1b3bcbd090937ef609a0a3b37237af974df",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -1481,11 +1461,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773297127,
|
"lastModified": 1775125835,
|
||||||
"narHash": "sha256-6E/yhXP7Oy/NbXtf1ktzmU8SdVqJQ09HC/48ebEGBpk=",
|
"narHash": "sha256-2qYcPgzFhnQWchHo0SlqLHrXpux5i6ay6UHA+v2iH4U=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "treefmt-nix",
|
"repo": "treefmt-nix",
|
||||||
"rev": "71b125cd05fbfd78cab3e070b73544abe24c5016",
|
"rev": "75925962939880974e3ab417879daffcba36c4a3",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -1502,11 +1482,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774242250,
|
"lastModified": 1775367672,
|
||||||
"narHash": "sha256-pchbnY7KVnH26g4O3LZO8vpshInqNj937gAqlPob1Mk=",
|
"narHash": "sha256-nGC6qrRsWysfR7/8wsSooq0X71rfJjhq1b+dFI6oQtY=",
|
||||||
"owner": "0xc000022070",
|
"owner": "0xc000022070",
|
||||||
"repo": "zen-browser-flake",
|
"repo": "zen-browser-flake",
|
||||||
"rev": "f19c3e6683c2d2f3fcfcb88fb691931a104bc47c",
|
"rev": "33cd729244914f1e121477c5de148639c5e73c4a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
{
|
{
|
||||||
description = "Nixos config flake";
|
description = "Nixos config flake";
|
||||||
|
|
||||||
|
nixConfig = {
|
||||||
|
warn-dirty = false;
|
||||||
|
};
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||||
|
|
||||||
|
|
|
||||||
2
logs/bridge-2026-04-15T09-11-43.612.log
Normal file
2
logs/bridge-2026-04-15T09-11-43.612.log
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
{"level":"fatal","error":"homeserver.address not configured","time":"2026-04-15T09:10:06.949460064Z","message":"Configuration error"}
|
||||||
|
{"level":"info","time":"2026-04-15T09:10:06.949840013Z","message":"See https://docs.mau.fi/faq/field-unconfigured for more info"}
|
||||||
2
logs/bridge.log
Normal file
2
logs/bridge.log
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
{"level":"fatal","error":"appservice.as_token not configured. Did you forget to generate the registration? ","time":"2026-04-15T09:11:43.617908298Z","message":"Configuration error"}
|
||||||
|
{"level":"info","time":"2026-04-15T09:11:43.618232253Z","message":"See https://docs.mau.fi/faq/field-unconfigured for more info"}
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
{ config, lib, pkgs, namespace, system, inputs, ... }:
|
{ config, lib, pkgs, namespace, system, inputs, ... }:
|
||||||
let
|
let
|
||||||
inherit (lib) mkIf mkEnableOption mkOption types toUpper toSentenceCase nameValuePair mapAttrs mapAttrs' concatMapAttrs concatMapStringsSep filterAttrsRecursive listToAttrs imap0 head drop length literalExpression attrNames;
|
inherit (lib) mkIf mkEnableOption mkOption toString types toUpper toSentenceCase nameValuePair mapAttrs mapAttrs' concatMapAttrs concatMapStringsSep filterAttrsRecursive listToAttrs imap0 head drop length literalExpression attrNames;
|
||||||
inherit (lib.${namespace}.strings) toSnakeCase;
|
inherit (lib.${namespace}.strings) toSnakeCase;
|
||||||
|
|
||||||
cfg = config.${namespace}.services.authentication.zitadel;
|
cfg = config.${namespace}.services.authentication.zitadel;
|
||||||
|
port = 3010;
|
||||||
|
|
||||||
database = "zitadel";
|
database = "zitadel";
|
||||||
in
|
in
|
||||||
|
|
@ -543,12 +544,12 @@ in
|
||||||
networking.caddy = {
|
networking.caddy = {
|
||||||
hosts = {
|
hosts = {
|
||||||
"auth.kruining.eu" = ''
|
"auth.kruining.eu" = ''
|
||||||
reverse_proxy h2c://[::1]:9092
|
reverse_proxy h2c://[::1]:${toString port}
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
extraConfig = ''
|
extraConfig = ''
|
||||||
(auth) {
|
(auth) {
|
||||||
forward_auth h2c://[::1]:9092 {
|
forward_auth h2c://[::1]:${toString port} {
|
||||||
uri /api/authz/forward-auth
|
uri /api/authz/forward-auth
|
||||||
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
|
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
|
||||||
}
|
}
|
||||||
|
|
@ -612,7 +613,7 @@ in
|
||||||
masterKeyFile = config.sops.secrets."zitadel/masterKey".path;
|
masterKeyFile = config.sops.secrets."zitadel/masterKey".path;
|
||||||
tlsMode = "external";
|
tlsMode = "external";
|
||||||
settings = {
|
settings = {
|
||||||
Port = 9092;
|
Port = port;
|
||||||
|
|
||||||
ExternalDomain = "auth.kruining.eu";
|
ExternalDomain = "auth.kruining.eu";
|
||||||
ExternalPort = 443;
|
ExternalPort = 443;
|
||||||
|
|
@ -698,8 +699,6 @@ in
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
networking.firewall.allowedTCPPorts = [ 80 443 ];
|
|
||||||
|
|
||||||
# Secrets
|
# Secrets
|
||||||
sops = {
|
sops = {
|
||||||
secrets = {
|
secrets = {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
...
|
...
|
||||||
}: let
|
}: let
|
||||||
inherit (builtins) toString toJSON;
|
inherit (builtins) toString toJSON;
|
||||||
inherit (lib) mkIf mkEnableOption;
|
inherit (lib) mkIf mkEnableOption mkMerge;
|
||||||
|
|
||||||
cfg = config.${namespace}.services.communication.matrix;
|
cfg = config.${namespace}.services.communication.matrix;
|
||||||
|
|
||||||
|
|
@ -16,6 +16,36 @@
|
||||||
|
|
||||||
database = "synapse";
|
database = "synapse";
|
||||||
keyFile = "/var/lib/element-call/key";
|
keyFile = "/var/lib/element-call/key";
|
||||||
|
|
||||||
|
mkMautrix = bridge: i: conf: {
|
||||||
|
${bridge} = mkMerge [
|
||||||
|
{
|
||||||
|
enable = true;
|
||||||
|
registerToSynapse = true;
|
||||||
|
|
||||||
|
settings = {
|
||||||
|
appservice = {
|
||||||
|
# hostname = "[::]";
|
||||||
|
# port = 40010 + i;
|
||||||
|
# address = "http://${config.services.${bridge}.settings.appservice.hostname}:${toString config.services.${bridge}.settings.appservice.port}";
|
||||||
|
provisioning.enabled = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
homeserver = {
|
||||||
|
inherit domain;
|
||||||
|
address = "http://[::1]:${toString port}";
|
||||||
|
};
|
||||||
|
|
||||||
|
bridge = {
|
||||||
|
permissions = {
|
||||||
|
"@chris:${domain}" = "admin";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
conf
|
||||||
|
];
|
||||||
|
};
|
||||||
in {
|
in {
|
||||||
options.${namespace}.services.communication.matrix = {
|
options.${namespace}.services.communication.matrix = {
|
||||||
enable = mkEnableOption "Matrix server (Synapse)";
|
enable = mkEnableOption "Matrix server (Synapse)";
|
||||||
|
|
@ -24,27 +54,8 @@ in {
|
||||||
config = mkIf cfg.enable {
|
config = mkIf cfg.enable {
|
||||||
${namespace}.services = {
|
${namespace}.services = {
|
||||||
persistance.postgresql.enable = true;
|
persistance.postgresql.enable = true;
|
||||||
# virtualisation.podman.enable = true;
|
|
||||||
|
|
||||||
networking.caddy = {
|
networking.caddy = {
|
||||||
# globalConfig = ''
|
|
||||||
# layer4 {
|
|
||||||
# 127.0.0.1:4004
|
|
||||||
# route {
|
|
||||||
# proxy {
|
|
||||||
# upstream synapse:4004
|
|
||||||
# }
|
|
||||||
# }
|
|
||||||
# }
|
|
||||||
# 127.0.0.1:4005
|
|
||||||
# route {
|
|
||||||
# proxy {
|
|
||||||
# upstream synapse:4005
|
|
||||||
# }
|
|
||||||
# }
|
|
||||||
# }
|
|
||||||
# }
|
|
||||||
# '';
|
|
||||||
hosts = let
|
hosts = let
|
||||||
server = {
|
server = {
|
||||||
"m.server" = "${fqn}:443";
|
"m.server" = "${fqn}:443";
|
||||||
|
|
@ -96,238 +107,191 @@ in {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
services = {
|
services = mkMerge [
|
||||||
matrix-synapse = {
|
(mkMautrix "mautrix-signal" 1 {})
|
||||||
enable = true;
|
(mkMautrix "mautrix-telegram" 2 {})
|
||||||
|
(mkMautrix "mautrix-whatsapp" 3 {})
|
||||||
extras = ["oidc"];
|
(mkMautrix "arrtrix" 4 {
|
||||||
|
environmentFile = config.sops.templates."arrtrix/secrets".path;
|
||||||
extraConfigFiles = [
|
|
||||||
config.sops.templates."synapse-oidc.yaml".path
|
|
||||||
];
|
|
||||||
|
|
||||||
settings = {
|
settings = {
|
||||||
server_name = domain;
|
observability = {
|
||||||
public_baseurl = "https://${fqn}";
|
otlp_grpc_endpoint = "http://[::1]:9071";
|
||||||
|
service_name = "arrtrix";
|
||||||
enable_metrics = true;
|
|
||||||
|
|
||||||
registration_shared_secret = "tZtBnlhEmLbMwF0lQ112VH1Rl5MkZzYH9suI4pEoPXzk6nWUB8FJF4eEnwLkbstz";
|
|
||||||
|
|
||||||
url_preview_enabled = true;
|
|
||||||
precence.enabled = true;
|
|
||||||
|
|
||||||
# Since we'll be using OIDC for auth disable all local options
|
|
||||||
enable_registration = false;
|
|
||||||
enable_registration_without_verification = false;
|
|
||||||
password_config.enabled = true;
|
|
||||||
backchannel_logout_enabled = true;
|
|
||||||
|
|
||||||
# Element Call options
|
|
||||||
max_event_delay_duration = "24h";
|
|
||||||
rc_message = {
|
|
||||||
per_second = 0.5;
|
|
||||||
burst_count = 30;
|
|
||||||
};
|
|
||||||
rc_delayed_event_mgmt = {
|
|
||||||
per_second = 1;
|
|
||||||
burst_count = 20;
|
|
||||||
};
|
|
||||||
turn_uris = ["turn:turn.${domain}:4004?transport=udp" "turn:turn.${domain}:4004?transport=tcp"];
|
|
||||||
|
|
||||||
experimental_features = {
|
|
||||||
# MSC2965: OAuth 2.0 Authorization Server Metadata discovery
|
|
||||||
msc2965_enabled = true;
|
|
||||||
|
|
||||||
# MSC3266: Room summary API. Used for knocking over federation
|
|
||||||
msc3266_enabled = true;
|
|
||||||
# MSC4222 needed for syncv2 state_after. This allow clients to
|
|
||||||
# correctly track the state of the room.
|
|
||||||
msc4222_enabled = true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
sso = {
|
network.content = {
|
||||||
client_whitelist = ["http://[::1]:9092/" "https://auth.kruining.eu/"];
|
movies = {
|
||||||
update_profile_information = true;
|
url = "http://[::1]:${toString config.services.radarr.settings.server.port}";
|
||||||
};
|
api_key = "$RADARR_APIKEY";
|
||||||
|
root_folder_path = "/var/media/movies";
|
||||||
database = {
|
quality_profile_id = 5;
|
||||||
# this is postgresql (also the default, but I prefer to be explicit)
|
};
|
||||||
name = "psycopg2";
|
series = {
|
||||||
args = {
|
url = "http://[::1]:${toString config.services.sonarr.settings.server.port}";
|
||||||
database = database;
|
api_key = "$SONARR_APIKEY";
|
||||||
user = database;
|
root_folder_path = "/var/media/series";
|
||||||
|
quality_profile_id = 5;
|
||||||
|
language_profile_id = 1;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
})
|
||||||
|
{
|
||||||
|
matrix-synapse = {
|
||||||
|
enable = true;
|
||||||
|
|
||||||
listeners = [
|
extras = ["oidc"];
|
||||||
|
|
||||||
|
extraConfigFiles = [
|
||||||
|
config.sops.templates."synapse.yaml".path
|
||||||
|
config.sops.templates."synapse-oidc.yaml".path
|
||||||
|
];
|
||||||
|
|
||||||
|
settings = {
|
||||||
|
server_name = domain;
|
||||||
|
public_baseurl = "https://${fqn}";
|
||||||
|
|
||||||
|
enable_metrics = true;
|
||||||
|
|
||||||
|
url_preview_enabled = true;
|
||||||
|
precence.enabled = true;
|
||||||
|
|
||||||
|
# Since we'll be using OIDC for auth disable all local options
|
||||||
|
enable_registration = false;
|
||||||
|
enable_registration_without_verification = false;
|
||||||
|
password_config.enabled = true;
|
||||||
|
backchannel_logout_enabled = true;
|
||||||
|
|
||||||
|
# Element Call options
|
||||||
|
max_event_delay_duration = "24h";
|
||||||
|
rc_message = {
|
||||||
|
per_second = 0.5;
|
||||||
|
burst_count = 30;
|
||||||
|
};
|
||||||
|
rc_delayed_event_mgmt = {
|
||||||
|
per_second = 1;
|
||||||
|
burst_count = 20;
|
||||||
|
};
|
||||||
|
turn_uris = ["turn:turn.${domain}:4004?transport=udp" "turn:turn.${domain}:4004?transport=tcp"];
|
||||||
|
|
||||||
|
experimental_features = {
|
||||||
|
# MSC2965: OAuth 2.0 Authorization Server Metadata discovery
|
||||||
|
msc2965_enabled = true;
|
||||||
|
|
||||||
|
# MSC3266: Room summary API. Used for knocking over federation
|
||||||
|
msc3266_enabled = true;
|
||||||
|
# MSC4222 needed for syncv2 state_after. This allow clients to
|
||||||
|
# correctly track the state of the room.
|
||||||
|
msc4222_enabled = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
sso = {
|
||||||
|
client_whitelist = ["http://[::1]:${toString config.services.zitadel.settings.Port}/" "https://auth.kruining.eu/"];
|
||||||
|
update_profile_information = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
database = {
|
||||||
|
# this is postgresql (also the default, but I prefer to be explicit)
|
||||||
|
name = "psycopg2";
|
||||||
|
args = {
|
||||||
|
database = database;
|
||||||
|
user = database;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
listeners = [
|
||||||
|
{
|
||||||
|
bind_addresses = ["::"];
|
||||||
|
port = port;
|
||||||
|
type = "http";
|
||||||
|
tls = false;
|
||||||
|
x_forwarded = true;
|
||||||
|
|
||||||
|
resources = [
|
||||||
|
{
|
||||||
|
names = ["client" "federation" "openid" "metrics" "media" "health"];
|
||||||
|
compress = true;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
postgresql = {
|
||||||
|
ensureDatabases = [database];
|
||||||
|
ensureUsers = [
|
||||||
{
|
{
|
||||||
bind_addresses = ["::"];
|
name = database;
|
||||||
port = port;
|
ensureDBOwnership = true;
|
||||||
type = "http";
|
|
||||||
tls = false;
|
|
||||||
x_forwarded = true;
|
|
||||||
|
|
||||||
resources = [
|
|
||||||
{
|
|
||||||
names = ["client" "federation" "openid" "metrics" "media" "health"];
|
|
||||||
compress = true;
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
};
|
|
||||||
|
|
||||||
mautrix-signal = {
|
livekit = {
|
||||||
enable = true;
|
enable = true;
|
||||||
registerToSynapse = true;
|
openFirewall = true;
|
||||||
|
inherit keyFile;
|
||||||
|
|
||||||
settings = {
|
settings = {
|
||||||
appservice = {
|
port = 4002;
|
||||||
provisioning.enabled = false;
|
room.auto_create = false;
|
||||||
};
|
|
||||||
|
|
||||||
homeserver = {
|
|
||||||
address = "http://[::1]:${toString port}";
|
|
||||||
domain = domain;
|
|
||||||
};
|
|
||||||
|
|
||||||
bridge = {
|
|
||||||
permissions = {
|
|
||||||
"@chris:${domain}" = "admin";
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
|
||||||
|
|
||||||
mautrix-telegram = {
|
lk-jwt-service = {
|
||||||
enable = true;
|
enable = true;
|
||||||
registerToSynapse = true;
|
port = 4003;
|
||||||
|
# can be on the same virtualHost as synapse
|
||||||
settings = {
|
livekitUrl = "wss://${domain}/livekit/sfu";
|
||||||
telegram = {
|
inherit keyFile;
|
||||||
api_id = 32770816;
|
|
||||||
api_hash = "7b63778a976619c9d4ab62adc51cde79";
|
|
||||||
bot_token = "disabled";
|
|
||||||
|
|
||||||
catch_up = true;
|
|
||||||
sequential_updates = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
appservice = {
|
|
||||||
port = 40011;
|
|
||||||
provisioning.enabled = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
homeserver = {
|
|
||||||
address = "http://[::1]:${toString port}";
|
|
||||||
domain = domain;
|
|
||||||
};
|
|
||||||
|
|
||||||
bridge = {
|
|
||||||
permissions = {
|
|
||||||
"@chris:${domain}" = "admin";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
|
||||||
|
|
||||||
mautrix-whatsapp = {
|
coturn = rec {
|
||||||
enable = true;
|
enable = true;
|
||||||
registerToSynapse = true;
|
listening-port = 4004;
|
||||||
|
tls-listening-port = 40004;
|
||||||
settings = {
|
no-cli = true;
|
||||||
appservice = {
|
no-tcp-relay = true;
|
||||||
provisioning.enabled = false;
|
min-port = 50000;
|
||||||
};
|
max-port = 50100;
|
||||||
|
use-auth-secret = true;
|
||||||
homeserver = {
|
static-auth-secret-file = config.sops.secrets."coturn/secret".path;
|
||||||
address = "http://[::1]:${toString port}";
|
realm = "turn.${domain}";
|
||||||
domain = domain;
|
# cert = "${config.security.acme.certs.${realm}.directory}/full.pem";
|
||||||
};
|
# pkey = "${config.security.acme.certs.${realm}.directory}/key.pem";
|
||||||
|
extraConfig = ''
|
||||||
bridge = {
|
# for debugging
|
||||||
permissions = {
|
verbose
|
||||||
"@chris:${domain}" = "admin";
|
# ban private IP ranges
|
||||||
};
|
no-multicast-peers
|
||||||
};
|
denied-peer-ip=0.0.0.0-0.255.255.255
|
||||||
|
denied-peer-ip=10.0.0.0-10.255.255.255
|
||||||
|
denied-peer-ip=100.64.0.0-100.127.255.255
|
||||||
|
denied-peer-ip=127.0.0.0-127.255.255.255
|
||||||
|
denied-peer-ip=169.254.0.0-169.254.255.255
|
||||||
|
denied-peer-ip=172.16.0.0-172.31.255.255
|
||||||
|
denied-peer-ip=192.0.0.0-192.0.0.255
|
||||||
|
denied-peer-ip=192.0.2.0-192.0.2.255
|
||||||
|
denied-peer-ip=192.88.99.0-192.88.99.255
|
||||||
|
denied-peer-ip=192.168.0.0-192.168.255.255
|
||||||
|
denied-peer-ip=198.18.0.0-198.19.255.255
|
||||||
|
denied-peer-ip=198.51.100.0-198.51.100.255
|
||||||
|
denied-peer-ip=203.0.113.0-203.0.113.255
|
||||||
|
denied-peer-ip=240.0.0.0-255.255.255.255
|
||||||
|
denied-peer-ip=::1
|
||||||
|
denied-peer-ip=64:ff9b::-64:ff9b::ffff:ffff
|
||||||
|
denied-peer-ip=::ffff:0.0.0.0-::ffff:255.255.255.255
|
||||||
|
denied-peer-ip=100::-100::ffff:ffff:ffff:ffff
|
||||||
|
denied-peer-ip=2001::-2001:1ff:ffff:ffff:ffff:ffff:ffff:ffff
|
||||||
|
denied-peer-ip=2002::-2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff
|
||||||
|
denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff
|
||||||
|
denied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff
|
||||||
|
'';
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
];
|
||||||
postgresql = {
|
|
||||||
enable = true;
|
|
||||||
ensureDatabases = [database];
|
|
||||||
ensureUsers = [
|
|
||||||
{
|
|
||||||
name = database;
|
|
||||||
ensureDBOwnership = true;
|
|
||||||
}
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
livekit = {
|
|
||||||
enable = true;
|
|
||||||
openFirewall = true;
|
|
||||||
inherit keyFile;
|
|
||||||
|
|
||||||
settings = {
|
|
||||||
port = 4002;
|
|
||||||
room.auto_create = false;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
lk-jwt-service = {
|
|
||||||
enable = true;
|
|
||||||
port = 4003;
|
|
||||||
# can be on the same virtualHost as synapse
|
|
||||||
livekitUrl = "wss://${domain}/livekit/sfu";
|
|
||||||
inherit keyFile;
|
|
||||||
};
|
|
||||||
|
|
||||||
coturn = rec {
|
|
||||||
enable = true;
|
|
||||||
listening-port = 4004;
|
|
||||||
tls-listening-port = 40004;
|
|
||||||
no-cli = true;
|
|
||||||
no-tcp-relay = true;
|
|
||||||
min-port = 50000;
|
|
||||||
max-port = 50100;
|
|
||||||
use-auth-secret = true;
|
|
||||||
static-auth-secret-file = config.sops.secrets."coturn/secret".path;
|
|
||||||
realm = "turn.${domain}";
|
|
||||||
# cert = "${config.security.acme.certs.${realm}.directory}/full.pem";
|
|
||||||
# pkey = "${config.security.acme.certs.${realm}.directory}/key.pem";
|
|
||||||
extraConfig = ''
|
|
||||||
# for debugging
|
|
||||||
verbose
|
|
||||||
# ban private IP ranges
|
|
||||||
no-multicast-peers
|
|
||||||
denied-peer-ip=0.0.0.0-0.255.255.255
|
|
||||||
denied-peer-ip=10.0.0.0-10.255.255.255
|
|
||||||
denied-peer-ip=100.64.0.0-100.127.255.255
|
|
||||||
denied-peer-ip=127.0.0.0-127.255.255.255
|
|
||||||
denied-peer-ip=169.254.0.0-169.254.255.255
|
|
||||||
denied-peer-ip=172.16.0.0-172.31.255.255
|
|
||||||
denied-peer-ip=192.0.0.0-192.0.0.255
|
|
||||||
denied-peer-ip=192.0.2.0-192.0.2.255
|
|
||||||
denied-peer-ip=192.88.99.0-192.88.99.255
|
|
||||||
denied-peer-ip=192.168.0.0-192.168.255.255
|
|
||||||
denied-peer-ip=198.18.0.0-198.19.255.255
|
|
||||||
denied-peer-ip=198.51.100.0-198.51.100.255
|
|
||||||
denied-peer-ip=203.0.113.0-203.0.113.255
|
|
||||||
denied-peer-ip=240.0.0.0-255.255.255.255
|
|
||||||
denied-peer-ip=::1
|
|
||||||
denied-peer-ip=64:ff9b::-64:ff9b::ffff:ffff
|
|
||||||
denied-peer-ip=::ffff:0.0.0.0-::ffff:255.255.255.255
|
|
||||||
denied-peer-ip=100::-100::ffff:ffff:ffff:ffff
|
|
||||||
denied-peer-ip=2001::-2001:1ff:ffff:ffff:ffff:ffff:ffff:ffff
|
|
||||||
denied-peer-ip=2002::-2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff
|
|
||||||
denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff
|
|
||||||
denied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
networking.firewall = {
|
networking.firewall = {
|
||||||
allowedTCPPortRanges = [];
|
allowedTCPPortRanges = [];
|
||||||
|
|
@ -376,6 +340,9 @@ in {
|
||||||
"synapse/oidc_secret" = {
|
"synapse/oidc_secret" = {
|
||||||
restartUnits = ["synapse-matrix.service"];
|
restartUnits = ["synapse-matrix.service"];
|
||||||
};
|
};
|
||||||
|
"synapse/shared_secret" = {
|
||||||
|
restartUnits = ["synapse-matrix.service"];
|
||||||
|
};
|
||||||
"coturn/secret" = {
|
"coturn/secret" = {
|
||||||
owner = config.systemd.services.coturn.serviceConfig.User;
|
owner = config.systemd.services.coturn.serviceConfig.User;
|
||||||
group = config.systemd.services.coturn.serviceConfig.Group;
|
group = config.systemd.services.coturn.serviceConfig.Group;
|
||||||
|
|
@ -384,6 +351,13 @@ in {
|
||||||
};
|
};
|
||||||
|
|
||||||
templates = {
|
templates = {
|
||||||
|
"synapse.yaml" = {
|
||||||
|
owner = "matrix-synapse";
|
||||||
|
content = ''
|
||||||
|
registration_shared_secret: ${config.sops.placeholder."synapse/shared_secret"}
|
||||||
|
'';
|
||||||
|
restartUnits = ["matrix-synapse.service"];
|
||||||
|
};
|
||||||
"synapse-oidc.yaml" = {
|
"synapse-oidc.yaml" = {
|
||||||
owner = "matrix-synapse";
|
owner = "matrix-synapse";
|
||||||
content = ''
|
content = ''
|
||||||
|
|
@ -411,6 +385,14 @@ in {
|
||||||
'';
|
'';
|
||||||
restartUnits = ["matrix-synapse.service"];
|
restartUnits = ["matrix-synapse.service"];
|
||||||
};
|
};
|
||||||
|
"arrtrix/secrets" = {
|
||||||
|
owner = "arrtrix";
|
||||||
|
content = ''
|
||||||
|
RADARR_APIKEY=${config.sops.placeholder."radarr/apikey"}
|
||||||
|
SONARR_APIKEY=${config.sops.placeholder."sonarr/apikey"}
|
||||||
|
'';
|
||||||
|
restartUnits = ["arrtrix.service"];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ in {
|
||||||
openFirewall = true;
|
openFirewall = true;
|
||||||
user = cfg.user;
|
user = cfg.user;
|
||||||
group = cfg.group;
|
group = cfg.group;
|
||||||
listenPort = 2005;
|
listenPort = 2050;
|
||||||
};
|
};
|
||||||
|
|
||||||
postgresql = {
|
postgresql = {
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,11 @@ in {
|
||||||
};
|
};
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
config = mkIf cfg.enable {
|
||||||
${namespace}.services.networking.caddy.hosts = {
|
# ${namespace}.services.networking.caddy.hosts = {
|
||||||
"https://${config.networking.hostName}:443" = ''
|
# "https://${config.networking.hostName}.arda:443" = ''
|
||||||
reverse_proxy http://[::1]:2000
|
# reverse_proxy http://[::1]:2000
|
||||||
'';
|
# '';
|
||||||
};
|
# };
|
||||||
|
|
||||||
services.glance = {
|
services.glance = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ in {
|
||||||
services.mydia = {
|
services.mydia = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
|
||||||
port = 2010;
|
port = 2100;
|
||||||
listenAddress = "0.0.0.0";
|
listenAddress = "0.0.0.0";
|
||||||
openFirewall = true;
|
openFirewall = true;
|
||||||
|
|
||||||
|
|
@ -54,7 +54,7 @@ in {
|
||||||
qbittorrent = {
|
qbittorrent = {
|
||||||
type = "qbittorrent";
|
type = "qbittorrent";
|
||||||
host = "localhost";
|
host = "localhost";
|
||||||
port = 2008;
|
port = 2080;
|
||||||
username = "admin";
|
username = "admin";
|
||||||
passwordFile = config.sops.secrets."mydia/qbittorrent_password".path;
|
passwordFile = config.sops.secrets."mydia/qbittorrent_password".path;
|
||||||
useSsl = false;
|
useSsl = false;
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,8 @@ in {
|
||||||
auth.authenticationMethod = "External";
|
auth.authenticationMethod = "External";
|
||||||
|
|
||||||
server = {
|
server = {
|
||||||
bindaddress = "0.0.0.0";
|
# bindaddress = "0.0.0.0";
|
||||||
|
bindaddress = "[::]";
|
||||||
port = port;
|
port = port;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -79,7 +80,7 @@ in {
|
||||||
qbittorrent = {
|
qbittorrent = {
|
||||||
enable = true;
|
enable = true;
|
||||||
openFirewall = true;
|
openFirewall = true;
|
||||||
webuiPort = 2008;
|
webuiPort = 2080;
|
||||||
serverConfig = lib.mkForce {};
|
serverConfig = lib.mkForce {};
|
||||||
|
|
||||||
user = "qbittorrent";
|
user = "qbittorrent";
|
||||||
|
|
@ -100,9 +101,10 @@ in {
|
||||||
settings = {
|
settings = {
|
||||||
misc = {
|
misc = {
|
||||||
host = "0.0.0.0";
|
host = "0.0.0.0";
|
||||||
port = 2009;
|
port = 2090;
|
||||||
host_whitelist = "${config.networking.hostName}";
|
host_whitelist = "${config.networking.hostName}";
|
||||||
|
|
||||||
|
permissions = "770";
|
||||||
download_dir = "/var/media/downloads/incomplete";
|
download_dir = "/var/media/downloads/incomplete";
|
||||||
complete_dir = "/var/media/downloads/done";
|
complete_dir = "/var/media/downloads/done";
|
||||||
};
|
};
|
||||||
|
|
@ -125,14 +127,15 @@ in {
|
||||||
flaresolverr = {
|
flaresolverr = {
|
||||||
enable = true;
|
enable = true;
|
||||||
openFirewall = true;
|
openFirewall = true;
|
||||||
port = 2007;
|
port = 2070;
|
||||||
};
|
};
|
||||||
|
|
||||||
postgresql = {
|
postgresql = let
|
||||||
ensureDatabases = cfg |> lib.attrNames;
|
databases = [] ++ (cfg |> lib.attrNames);
|
||||||
|
in {
|
||||||
|
ensureDatabases = databases;
|
||||||
ensureUsers =
|
ensureUsers =
|
||||||
cfg
|
databases
|
||||||
|> lib.attrNames
|
|
||||||
|> lib.map (service: {
|
|> lib.map (service: {
|
||||||
name = service;
|
name = service;
|
||||||
ensureDBOwnership = true;
|
ensureDBOwnership = true;
|
||||||
|
|
@ -192,7 +195,7 @@ in {
|
||||||
source = "devopsarr/${service}";
|
source = "devopsarr/${service}";
|
||||||
version =
|
version =
|
||||||
{
|
{
|
||||||
radarr = "2.3.3";
|
radarr = "2.3.5";
|
||||||
sonarr = "3.4.0";
|
sonarr = "3.4.0";
|
||||||
prowlarr = "3.1.0";
|
prowlarr = "3.1.0";
|
||||||
lidarr = "1.13.0";
|
lidarr = "1.13.0";
|
||||||
|
|
@ -210,6 +213,23 @@ in {
|
||||||
|
|
||||||
resource =
|
resource =
|
||||||
{
|
{
|
||||||
|
"${service}_notification_webhook" = mkIf (lib.elem service ["radarr" "sonarr" "whisparr" "lidarr" "readarr"]) {
|
||||||
|
"arrtrix" =
|
||||||
|
{
|
||||||
|
method = 1; # HTTP METHOD 1=POST, 2=PUT
|
||||||
|
name = "Arrtrix";
|
||||||
|
url = "http://localhost:${toString config'.services.arrtrix.settings.appservice.port}/_arrtrix/webhook";
|
||||||
|
|
||||||
|
on_grab = true;
|
||||||
|
on_download = true;
|
||||||
|
on_rename = true;
|
||||||
|
on_upgrade = true;
|
||||||
|
}
|
||||||
|
// (lib.optionalAttrs (lib.elem service ["radarr" "whisparr"]) {
|
||||||
|
on_movie_delete = true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
"${service}_root_folder" = mkIf (lib.elem service ["radarr" "sonarr" "whisparr"]) (
|
"${service}_root_folder" = mkIf (lib.elem service ["radarr" "sonarr" "whisparr"]) (
|
||||||
rootFolders
|
rootFolders
|
||||||
|> lib.imap (i: f: lib.nameValuePair "local${toString i}" {path = f;})
|
|> lib.imap (i: f: lib.nameValuePair "local${toString i}" {path = f;})
|
||||||
|
|
@ -225,20 +245,30 @@ in {
|
||||||
username = "admin";
|
username = "admin";
|
||||||
password = lib.tfRef "var.qbittorrent_api_key";
|
password = lib.tfRef "var.qbittorrent_api_key";
|
||||||
url_base = "/";
|
url_base = "/";
|
||||||
port = 2008;
|
port = 2080;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
"${service}_download_client_sabnzbd" = mkIf (lib.elem service ["radarr" "sonarr" "lidarr" "whisparr"]) {
|
"${service}_download_client_sabnzbd" = mkIf (lib.elem service ["radarr" "sonarr" "lidarr" "whisparr"]) {
|
||||||
"main" = {
|
"main" =
|
||||||
name = "SABnzbd";
|
{
|
||||||
enable = true;
|
name = "SABnzbd";
|
||||||
priority = 1;
|
enable = true;
|
||||||
host = "localhost";
|
priority = 1;
|
||||||
api_key = lib.tfRef "var.sabnzbd_api_key";
|
host = "localhost";
|
||||||
url_base = "/";
|
api_key = lib.tfRef "var.sabnzbd_api_key";
|
||||||
port = 2009;
|
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
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// (lib.optionalAttrs (service == "prowlarr") (
|
// (lib.optionalAttrs (service == "prowlarr") (
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,6 @@
|
||||||
|
|
||||||
cfg = config.${namespace}.services.networking.caddy;
|
cfg = config.${namespace}.services.networking.caddy;
|
||||||
hasHosts = (cfg.hosts |> attrNames |> length) > 0;
|
hasHosts = (cfg.hosts |> attrNames |> length) > 0;
|
||||||
caddyBase = pkgs.callPackage "${pkgs.path}/pkgs/by-name/ca/caddy/package.nix" {
|
|
||||||
buildGo125Module = pkgs.buildGo126Module;
|
|
||||||
caddy = caddyBase;
|
|
||||||
};
|
|
||||||
caddyPackage =
|
|
||||||
caddyBase.withPlugins {
|
|
||||||
plugins = ["github.com/corazawaf/coraza-caddy/v2@v2.1.0"];
|
|
||||||
hash = "sha256-pSXjLaZoRtKV3eFl2ySRSjl3yxi514G1Cb7pfrpxxtE=";
|
|
||||||
};
|
|
||||||
in {
|
in {
|
||||||
options.${namespace}.services.networking.caddy = {
|
options.${namespace}.services.networking.caddy = {
|
||||||
enable = mkEnableOption "enable caddy" // {default = true;};
|
enable = mkEnableOption "enable caddy" // {default = true;};
|
||||||
|
|
@ -33,10 +24,15 @@ in {
|
||||||
};
|
};
|
||||||
|
|
||||||
config = mkIf hasHosts {
|
config = mkIf hasHosts {
|
||||||
|
networking.firewall.allowedTCPPorts = [80 443];
|
||||||
|
|
||||||
services.caddy = {
|
services.caddy = {
|
||||||
enable = cfg.enable;
|
enable = cfg.enable;
|
||||||
|
|
||||||
package = caddyPackage;
|
package = pkgs.caddy.withPlugins {
|
||||||
|
plugins = ["github.com/corazawaf/coraza-caddy/v2@v2.1.0"];
|
||||||
|
hash = "sha256-pSXjLaZoRtKV3eFl2ySRSjl3yxi514G1Cb7pfrpxxtE=";
|
||||||
|
};
|
||||||
|
|
||||||
virtualHosts =
|
virtualHosts =
|
||||||
cfg.hosts
|
cfg.hosts
|
||||||
|
|
|
||||||
83
modules/nixos/services/observability/alloy/default.nix
Normal file
83
modules/nixos/services/observability/alloy/default.nix
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
namespace,
|
||||||
|
...
|
||||||
|
}: let
|
||||||
|
inherit (builtins) toString;
|
||||||
|
inherit (lib) mkEnableOption mkIf;
|
||||||
|
|
||||||
|
cfg = config.${namespace}.services.observability.alloy;
|
||||||
|
|
||||||
|
httpPort = 9070;
|
||||||
|
otlpGrpcPort = 9071;
|
||||||
|
otlpHttpPort = 9072;
|
||||||
|
tempoOtlpGrpcPort = 9062;
|
||||||
|
in {
|
||||||
|
options.${namespace}.services.observability.alloy = {
|
||||||
|
enable = mkEnableOption "enable Grafana Alloy";
|
||||||
|
};
|
||||||
|
|
||||||
|
config = mkIf cfg.enable {
|
||||||
|
services.alloy = {
|
||||||
|
enable = true;
|
||||||
|
configPath = "/etc/alloy";
|
||||||
|
extraFlags = [
|
||||||
|
"--disable-reporting"
|
||||||
|
"--server.http.listen-addr=[::]:${toString httpPort}"
|
||||||
|
"--storage.path=/var/lib/alloy"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
environment.etc."alloy/config.alloy".text = ''
|
||||||
|
otelcol.receiver.otlp "default" {
|
||||||
|
grpc {
|
||||||
|
endpoint = "[::1]:${toString otlpGrpcPort}"
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
endpoint = "[::1]:${toString otlpHttpPort}"
|
||||||
|
}
|
||||||
|
|
||||||
|
output {
|
||||||
|
metrics = [otelcol.processor.batch.metrics.input]
|
||||||
|
traces = [otelcol.processor.batch.traces.input]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
otelcol.processor.batch "metrics" {
|
||||||
|
output {
|
||||||
|
metrics = [otelcol.exporter.prometheus.default.input]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
otelcol.processor.batch "traces" {
|
||||||
|
output {
|
||||||
|
traces = [otelcol.exporter.otlp.tempo.input]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
otelcol.exporter.prometheus "default" {
|
||||||
|
forward_to = [prometheus.remote_write.local.receiver]
|
||||||
|
}
|
||||||
|
|
||||||
|
prometheus.remote_write "local" {
|
||||||
|
endpoint {
|
||||||
|
url = "http://[::1]:${toString config.services.prometheus.port}/api/v1/write"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
otelcol.exporter.otlp "tempo" {
|
||||||
|
client {
|
||||||
|
endpoint = "[::1]:${toString tempoOtlpGrpcPort}"
|
||||||
|
|
||||||
|
tls {
|
||||||
|
insecure = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
|
||||||
|
networking.firewall.allowedTCPPorts = [httpPort];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -25,8 +25,8 @@ in {
|
||||||
|
|
||||||
settings = {
|
settings = {
|
||||||
server = {
|
server = {
|
||||||
http_port = 9001;
|
http_port = 9010;
|
||||||
http_addr = "0.0.0.0";
|
http_addr = "::";
|
||||||
domain = "ulmo";
|
domain = "ulmo";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -104,18 +104,38 @@ in {
|
||||||
datasources.settings.datasources = [
|
datasources.settings.datasources = [
|
||||||
{
|
{
|
||||||
name = "Prometheus";
|
name = "Prometheus";
|
||||||
|
uid = "prometheus";
|
||||||
type = "prometheus";
|
type = "prometheus";
|
||||||
url = "http://localhost:9005";
|
url = "http://[::1]:9020";
|
||||||
isDefault = true;
|
isDefault = true;
|
||||||
editable = false;
|
editable = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
name = "Loki";
|
name = "Loki";
|
||||||
|
uid = "loki";
|
||||||
type = "loki";
|
type = "loki";
|
||||||
url = "http://localhost:9003";
|
url = "http://[::1]:9030";
|
||||||
editable = false;
|
editable = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
name = "Tempo";
|
||||||
|
uid = "tempo";
|
||||||
|
type = "tempo";
|
||||||
|
url = "http://localhost:9060";
|
||||||
|
editable = false;
|
||||||
|
jsonData = {
|
||||||
|
nodeGraph.enabled = true;
|
||||||
|
serviceMap.datasourceUid = "prometheus";
|
||||||
|
tracesToLogsV2 = {
|
||||||
|
datasourceUid = "loki";
|
||||||
|
filterByTraceID = true;
|
||||||
|
spanStartTimeShift = "-1h";
|
||||||
|
spanEndTimeShift = "1h";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ in
|
||||||
auth_enabled = false;
|
auth_enabled = false;
|
||||||
|
|
||||||
server = {
|
server = {
|
||||||
http_listen_port = 9003;
|
http_listen_port = 9030;
|
||||||
};
|
};
|
||||||
|
|
||||||
common = {
|
common = {
|
||||||
|
|
@ -44,6 +44,6 @@ in
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
networking.firewall.allowedTCPPorts = [ 9003 ];
|
networking.firewall.allowedTCPPorts = [ 9030 ];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{ pkgs, config, lib, namespace, ... }:
|
{ pkgs, config, lib, namespace, ... }:
|
||||||
let
|
let
|
||||||
inherit (builtins) toString;
|
inherit (builtins) toString;
|
||||||
inherit (lib) mkIf mkEnableOption;
|
inherit (lib) mkEnableOption mkIf optionals;
|
||||||
|
|
||||||
cfg = config.${namespace}.services.observability.prometheus;
|
cfg = config.${namespace}.services.observability.prometheus;
|
||||||
in
|
in
|
||||||
|
|
@ -13,7 +13,10 @@ in
|
||||||
config = mkIf cfg.enable {
|
config = mkIf cfg.enable {
|
||||||
services.prometheus = {
|
services.prometheus = {
|
||||||
enable = true;
|
enable = true;
|
||||||
port = 9002;
|
port = 9020;
|
||||||
|
extraFlags = optionals config.${namespace}.services.observability.alloy.enable [
|
||||||
|
"--web.enable-remote-write-receiver"
|
||||||
|
];
|
||||||
|
|
||||||
globalConfig.scrape_interval = "15s";
|
globalConfig.scrape_interval = "15s";
|
||||||
|
|
||||||
|
|
@ -21,7 +24,7 @@ in
|
||||||
{
|
{
|
||||||
job_name = "prometheus";
|
job_name = "prometheus";
|
||||||
static_configs = [
|
static_configs = [
|
||||||
{ targets = [ "localhost:9002" ]; }
|
{ targets = [ "localhost:9020" ]; }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,18 +34,34 @@ in
|
||||||
{ targets = [ "localhost:${toString config.services.prometheus.exporters.node.port}" ]; }
|
{ targets = [ "localhost:${toString config.services.prometheus.exporters.node.port}" ]; }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
++ optionals config.${namespace}.services.observability.alloy.enable [
|
||||||
|
{
|
||||||
|
job_name = "alloy";
|
||||||
|
static_configs = [
|
||||||
|
{ targets = [ "localhost:9070" ]; }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
]
|
||||||
|
++ optionals config.${namespace}.services.observability.tempo.enable [
|
||||||
|
{
|
||||||
|
job_name = "tempo";
|
||||||
|
static_configs = [
|
||||||
|
{ targets = [ "localhost:9060" ]; }
|
||||||
|
];
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
exporters = {
|
exporters = {
|
||||||
node = {
|
node = {
|
||||||
enable = true;
|
enable = true;
|
||||||
port = 9005;
|
port = 9021;
|
||||||
enabledCollectors = [ "systemd" ];
|
enabledCollectors = [ "systemd" ];
|
||||||
openFirewall = true;
|
openFirewall = true;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
networking.firewall.allowedTCPPorts = [ 9002 ];
|
networking.firewall.allowedTCPPorts = [ 9020 ];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ in {
|
||||||
|
|
||||||
configuration = {
|
configuration = {
|
||||||
server = {
|
server = {
|
||||||
http_listen_port = 9004;
|
http_listen_port = 9040;
|
||||||
grpc_listen_port = 0;
|
grpc_listen_port = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -35,7 +35,7 @@ in {
|
||||||
|
|
||||||
clients = [
|
clients = [
|
||||||
{
|
{
|
||||||
url = "http://[::1]:9003/loki/api/v1/push";
|
url = "http://[::1]:9030/loki/api/v1/push";
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -60,6 +60,6 @@ in {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
networking.firewall.allowedTCPPorts = [9004];
|
networking.firewall.allowedTCPPorts = [9040];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
51
modules/nixos/services/observability/tempo/default.nix
Normal file
51
modules/nixos/services/observability/tempo/default.nix
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
namespace,
|
||||||
|
...
|
||||||
|
}: let
|
||||||
|
inherit (lib) mkEnableOption mkIf;
|
||||||
|
|
||||||
|
cfg = config.${namespace}.services.observability.tempo;
|
||||||
|
|
||||||
|
httpPort = 9060;
|
||||||
|
grpcPort = 9061;
|
||||||
|
otlpGrpcPort = 9062;
|
||||||
|
otlpHttpPort = 9063;
|
||||||
|
in {
|
||||||
|
options.${namespace}.services.observability.tempo = {
|
||||||
|
enable = mkEnableOption "enable Grafana Tempo";
|
||||||
|
};
|
||||||
|
|
||||||
|
config = mkIf cfg.enable {
|
||||||
|
services.tempo = {
|
||||||
|
enable = true;
|
||||||
|
settings = {
|
||||||
|
auth_enabled = false;
|
||||||
|
search_enabled = true;
|
||||||
|
|
||||||
|
server = {
|
||||||
|
http_listen_address = "[::]";
|
||||||
|
http_listen_port = httpPort;
|
||||||
|
grpc_listen_address = "[::1]";
|
||||||
|
grpc_listen_port = grpcPort;
|
||||||
|
};
|
||||||
|
|
||||||
|
distributor.receivers.otlp.protocols = {
|
||||||
|
grpc.endpoint = "[::1]:${builtins.toString otlpGrpcPort}";
|
||||||
|
http.endpoint = "[::1]:${builtins.toString otlpHttpPort}";
|
||||||
|
};
|
||||||
|
|
||||||
|
storage.trace = {
|
||||||
|
backend = "local";
|
||||||
|
wal.path = "/var/lib/tempo/wal";
|
||||||
|
local.path = "/var/lib/tempo/traces";
|
||||||
|
};
|
||||||
|
|
||||||
|
compactor.compaction.block_retention = "168h";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
networking.firewall.allowedTCPPorts = [httpPort];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -15,11 +15,11 @@ in
|
||||||
enable = true;
|
enable = true;
|
||||||
|
|
||||||
settings = {
|
settings = {
|
||||||
PORT = toString 9006;
|
PORT = toString 9050;
|
||||||
HOST = "0.0.0.0";
|
HOST = "0.0.0.0";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
networking.firewall.allowedTCPPorts = [ 9006 ];
|
networking.firewall.allowedTCPPorts = [ 9050 ];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ in {
|
||||||
enable = true;
|
enable = true;
|
||||||
dbBackend = "postgresql";
|
dbBackend = "postgresql";
|
||||||
|
|
||||||
package = pkgs.${namespace}.vaultwarden;
|
package = pkgs.vaultwarden-postgresql;
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
SIGNUPS_ALLOWED = false;
|
SIGNUPS_ALLOWED = false;
|
||||||
|
|
@ -135,7 +135,7 @@ in {
|
||||||
SSO_ROLES_ENABLED = true;
|
SSO_ROLES_ENABLED = true;
|
||||||
SSO_ORGANIZATIONS_ENABLED = true;
|
SSO_ORGANIZATIONS_ENABLED = true;
|
||||||
SSO_ORGANIZATIONS_REVOCATION = true;
|
SSO_ORGANIZATIONS_REVOCATION = true;
|
||||||
SSO_AUTHORITY = "https://auth.kruining.eu/";
|
SSO_AUTHORITY = "https://auth.kruining.eu";
|
||||||
SSO_SCOPES = "email profile offline_access";
|
SSO_SCOPES = "email profile offline_access";
|
||||||
|
|
||||||
ROCKET_ADDRESS = "::1";
|
ROCKET_ADDRESS = "::1";
|
||||||
|
|
|
||||||
214
modules/nixos/temp/services/arrtrix/default.nix
Normal file
214
modules/nixos/temp/services/arrtrix/default.nix
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
namespace,
|
||||||
|
...
|
||||||
|
}: let
|
||||||
|
inherit (lib) mkEnableOption mkPackageOption mkIf mkOption optionalAttrs recursiveUpdate types baseNameOf;
|
||||||
|
|
||||||
|
cfg = config.services.arrtrix;
|
||||||
|
dataDir = "/var/lib/arrtrix";
|
||||||
|
registrationFile = "${dataDir}/arrtrix-registration.yaml";
|
||||||
|
settingsFile = "${dataDir}/config.yaml";
|
||||||
|
settingsFileUnsubstituted = settingsFormat.generate "arrtrix-config-unsubstituted.json" cfg.settings;
|
||||||
|
settingsFormat = pkgs.formats.json {};
|
||||||
|
|
||||||
|
defaultConfig = {
|
||||||
|
bridge = {
|
||||||
|
command_prefix = "!arr";
|
||||||
|
relay.enabled = true;
|
||||||
|
permissions."*" = "relay";
|
||||||
|
};
|
||||||
|
database = {
|
||||||
|
type = "sqlite3-fk-wal";
|
||||||
|
uri = "file:${dataDir}/arrtrix.db?_txlock=immediate";
|
||||||
|
};
|
||||||
|
homeserver = {
|
||||||
|
address = "http://localhost:8448";
|
||||||
|
domain = config.services.matrix-synapse.settings.server_name or "example.com";
|
||||||
|
};
|
||||||
|
appservice = {
|
||||||
|
hostname = "[::]";
|
||||||
|
port = 29329;
|
||||||
|
id = "arrtrix";
|
||||||
|
bot = {
|
||||||
|
username = "arrtrixbot";
|
||||||
|
displayname = "arrtrix Bot";
|
||||||
|
};
|
||||||
|
as_token = "";
|
||||||
|
hs_token = "";
|
||||||
|
username_template = "arrtrix_{{.}}";
|
||||||
|
};
|
||||||
|
logging = {
|
||||||
|
min_level = "info";
|
||||||
|
writers = lib.singleton {
|
||||||
|
type = "stdout";
|
||||||
|
format = "pretty-colored";
|
||||||
|
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 = {
|
||||||
|
enable = mkEnableOption "Arr-focused Matrix appservice foundation";
|
||||||
|
|
||||||
|
package = mkPackageOption pkgs.${namespace} "arrtrix" {};
|
||||||
|
|
||||||
|
registerToSynapse = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = config.services.matrix-synapse.enable;
|
||||||
|
defaultText = lib.literalExpression ''
|
||||||
|
config.services.matrix-synapse.enable
|
||||||
|
'';
|
||||||
|
description = ''
|
||||||
|
Whether to add the bridge's app service registration file to
|
||||||
|
`services.matrix-synapse.settings.app_service_config_files`.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
settings = mkOption {
|
||||||
|
apply = lib.recursiveUpdate defaultConfig;
|
||||||
|
type = settingsFormat.type;
|
||||||
|
default = defaultConfig;
|
||||||
|
description = ''
|
||||||
|
{file}`config.yaml` configuration as a Nix attribute set.
|
||||||
|
Configuration options should match those described in the example configuration.
|
||||||
|
Get an example configuration by executing `arrtrix -c example.yaml --generate-example-config`
|
||||||
|
Secret tokens should be specified using {option}`environmentFile`
|
||||||
|
instead of this world-readable attribute set.
|
||||||
|
'';
|
||||||
|
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 =
|
||||||
|
(lib.optional config.services.matrix-synapse.enable config.services.matrix-synapse.serviceUnit)
|
||||||
|
++ (lib.optional config.services.matrix-conduit.enable "conduit.service");
|
||||||
|
defaultText = lib.literalExpression ''
|
||||||
|
(optional config.services.matrix-synapse.enable config.services.matrix-synapse.serviceUnit)
|
||||||
|
++ (optional config.services.matrix-conduit.enable "conduit.service")
|
||||||
|
'';
|
||||||
|
description = ''
|
||||||
|
List of systemd units to require and wait for when starting the application service.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = mkIf cfg.enable {
|
||||||
|
users = {
|
||||||
|
users."arrtrix" = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = "arrtrix";
|
||||||
|
};
|
||||||
|
groups."arrtrix" = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
services.matrix-synapse = lib.mkIf cfg.registerToSynapse {
|
||||||
|
settings.app_service_config_files = [registrationFile];
|
||||||
|
};
|
||||||
|
systemd.services.matrix-synapse = lib.mkIf cfg.registerToSynapse {
|
||||||
|
serviceConfig.SupplementaryGroups = ["arrtrix"];
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.services.arrtrix = {
|
||||||
|
description = "arrtrix, A *arr stack to matrix bridge for *arr-notifications";
|
||||||
|
|
||||||
|
wantedBy = ["multi-user.target"];
|
||||||
|
after = ["network-online.target"];
|
||||||
|
wants = ["network-online.target"];
|
||||||
|
restartTriggers = [settingsFileUnsubstituted];
|
||||||
|
|
||||||
|
preStart = ''
|
||||||
|
# substitute the settings file by environment variables
|
||||||
|
# in this case read from EnvironmentFile
|
||||||
|
test -f '${settingsFile}' && rm -f '${settingsFile}'
|
||||||
|
|
||||||
|
old_umask=$(umask)
|
||||||
|
umask 0177
|
||||||
|
${lib.getExe pkgs.envsubst} -o '${settingsFile}' -i '${settingsFileUnsubstituted}'
|
||||||
|
umask $old_umask
|
||||||
|
|
||||||
|
if [ ! -f '${registrationFile}' ]; then
|
||||||
|
${lib.getExe cfg.package} --generate-registration --config='${settingsFile}' --registration='${registrationFile}'
|
||||||
|
fi
|
||||||
|
chmod 640 ${registrationFile}
|
||||||
|
'';
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
User = "arrtrix";
|
||||||
|
Group = "arrtrix";
|
||||||
|
|
||||||
|
StateDirectory = baseNameOf dataDir;
|
||||||
|
WorkingDirectory = dataDir;
|
||||||
|
EnvironmentFile = cfg.environmentFile;
|
||||||
|
|
||||||
|
ExecStart = ''
|
||||||
|
${lib.getExe cfg.package} --config='${settingsFile}' --registration='${registrationFile}'
|
||||||
|
'';
|
||||||
|
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = "30s";
|
||||||
|
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
PrivateTmp = true;
|
||||||
|
ProtectHome = true;
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ProtectClock = true;
|
||||||
|
ProtectControlGroups = true;
|
||||||
|
ProtectKernelLogs = true;
|
||||||
|
ProtectKernelModules = true;
|
||||||
|
ProtectKernelTunables = true;
|
||||||
|
RestrictNamespaces = true;
|
||||||
|
RestrictRealtime = true;
|
||||||
|
RestrictSUIDSGID = true;
|
||||||
|
LockPersonality = true;
|
||||||
|
MemoryDenyWriteExecute = true;
|
||||||
|
|
||||||
|
SystemCallArchitectures = "native";
|
||||||
|
SystemCallErrorNumber = "EPERM";
|
||||||
|
SystemCallFilter = ["@system-service"];
|
||||||
|
UMask = "0027";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
25
packages/arrtrix/cmd/arrtrix/main.go
Normal file
25
packages/arrtrix/cmd/arrtrix/main.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sneeuwvlok/packages/arrtrix/pkg/connector"
|
||||||
|
"sneeuwvlok/packages/arrtrix/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
Tag = "unknown"
|
||||||
|
Commit = "unknown"
|
||||||
|
BuildTime = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
var m = runtime.Main{
|
||||||
|
Name: "arrtrix",
|
||||||
|
URL: "https://github.com/chris-kruining/sneeuwvlok",
|
||||||
|
Description: "An Arr-focused Matrix appservice bridge.",
|
||||||
|
Version: "0.1.0",
|
||||||
|
Connector: &connector.ArrtrixConnector{},
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
m.InitVersion(Tag, Commit, BuildTime)
|
||||||
|
m.Run()
|
||||||
|
}
|
||||||
33
packages/arrtrix/default.nix
Normal file
33
packages/arrtrix/default.nix
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
buildGoModule,
|
||||||
|
lib,
|
||||||
|
olm,
|
||||||
|
versionCheckHook,
|
||||||
|
}:
|
||||||
|
buildGoModule rec {
|
||||||
|
pname = "arrtrix";
|
||||||
|
version = "0.1.0";
|
||||||
|
tag = "v0.1.0";
|
||||||
|
|
||||||
|
src = lib.cleanSource ./.;
|
||||||
|
|
||||||
|
vendorHash = "sha256-UYRit+v41djnCx+GFdEl/8WQsp2DzF4ywT9iv3m1pSc=";
|
||||||
|
subPackages = ["cmd/arrtrix"];
|
||||||
|
|
||||||
|
buildInputs = [olm];
|
||||||
|
|
||||||
|
ldflags = [
|
||||||
|
"-X main.Tag=${tag}"
|
||||||
|
];
|
||||||
|
|
||||||
|
doInstallCheck = true;
|
||||||
|
nativeInstallCheckInputs = [versionCheckHook];
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "*arr-stack Matrix bridge";
|
||||||
|
homepage = "https://github.com/chris-kruining/sneeuwvlok";
|
||||||
|
license = lib.licenses.mit;
|
||||||
|
maintainers = [];
|
||||||
|
mainProgram = "arrtrix";
|
||||||
|
};
|
||||||
|
}
|
||||||
60
packages/arrtrix/go.mod
Normal file
60
packages/arrtrix/go.mod
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
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 (
|
||||||
|
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/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
|
||||||
|
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
|
||||||
|
)
|
||||||
142
packages/arrtrix/go.sum
Normal file
142
packages/arrtrix/go.sum
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
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/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.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
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=
|
||||||
|
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||||
|
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/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/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=
|
||||||
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||||
|
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
|
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
|
||||||
|
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
|
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
|
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||||
|
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
|
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=
|
||||||
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
||||||
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
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=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
|
||||||
|
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
|
||||||
|
maunium.net/go/mautrix v0.26.4 h1:enHSnkf0L2V9+VnfJfNhKSReSW6pBKS/x3Su+v+Vovs=
|
||||||
|
maunium.net/go/mautrix v0.26.4/go.mod h1:YWw8NWTszsbyFAznboicBObwHPgTSLcuTbVX2kY7U2M=
|
||||||
76
packages/arrtrix/pkg/arr/catalog.go
Normal file
76
packages/arrtrix/pkg/arr/catalog.go
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
package arr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ContentType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ContentTypeMovies ContentType = "movies"
|
||||||
|
ContentTypeSeries ContentType = "series"
|
||||||
|
)
|
||||||
|
|
||||||
|
var supportedContentTypes = []ContentType{
|
||||||
|
ContentTypeMovies,
|
||||||
|
ContentTypeSeries,
|
||||||
|
}
|
||||||
|
|
||||||
|
var supportedEvents = map[ContentType][]string{
|
||||||
|
ContentTypeMovies: {"Test", "Grab", "Download", "Rename", "MovieFileDelete", "MovieDelete"},
|
||||||
|
ContentTypeSeries: {"Test", "Grab", "Download", "Rename", "EpisodeFileDelete", "SeriesDelete"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func SupportedContentTypes() []ContentType {
|
||||||
|
return append([]ContentType(nil), supportedContentTypes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SupportedEventTypes(contentType ContentType) []string {
|
||||||
|
return append([]string(nil), supportedEvents[contentType]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseContentType(value string) (ContentType, error) {
|
||||||
|
contentType := ContentType(strings.ToLower(strings.TrimSpace(value)))
|
||||||
|
if slices.Contains(supportedContentTypes, contentType) {
|
||||||
|
return contentType, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("unsupported content type %q (expected one of: %s)", value, Strings())
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseEventType(contentType ContentType, value string) (string, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if strings.EqualFold(value, "all") {
|
||||||
|
return "all", nil
|
||||||
|
}
|
||||||
|
for _, eventType := range supportedEvents[contentType] {
|
||||||
|
if strings.EqualFold(eventType, value) {
|
||||||
|
return eventType, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("unsupported event type %q for %s", value, contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SupportsEventType(contentType ContentType, eventType string) bool {
|
||||||
|
return slices.Contains(supportedEvents[contentType], strings.TrimSpace(eventType))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c ContentType) Label() string {
|
||||||
|
switch c {
|
||||||
|
case ContentTypeMovies:
|
||||||
|
return "movies"
|
||||||
|
case ContentTypeSeries:
|
||||||
|
return "series"
|
||||||
|
default:
|
||||||
|
return string(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Strings() string {
|
||||||
|
values := make([]string, 0, len(supportedContentTypes))
|
||||||
|
for _, contentType := range supportedContentTypes {
|
||||||
|
values = append(values, string(contentType))
|
||||||
|
}
|
||||||
|
return strings.Join(values, ", ")
|
||||||
|
}
|
||||||
23
packages/arrtrix/pkg/arr/catalog_test.go
Normal file
23
packages/arrtrix/pkg/arr/catalog_test.go
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
package arr
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseContentType(t *testing.T) {
|
||||||
|
contentType, err := ParseContentType("Movies")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseContentType returned error: %v", err)
|
||||||
|
}
|
||||||
|
if contentType != ContentTypeMovies {
|
||||||
|
t.Fatalf("expected movies content type, got %q", contentType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseEventType(t *testing.T) {
|
||||||
|
eventType, err := ParseEventType(ContentTypeSeries, "download")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseEventType returned error: %v", err)
|
||||||
|
}
|
||||||
|
if eventType != "Download" {
|
||||||
|
t.Fatalf("expected Download event type, got %q", eventType)
|
||||||
|
}
|
||||||
|
}
|
||||||
363
packages/arrtrix/pkg/arrclient/client.go
Normal file
363
packages/arrtrix/pkg/arrclient/client.go
Normal file
|
|
@ -0,0 +1,363 @@
|
||||||
|
package arrclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"sneeuwvlok/packages/arrtrix/pkg/arr"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client interface {
|
||||||
|
ContentType() arr.ContentType
|
||||||
|
Search(context.Context, string) ([]SearchResult, error)
|
||||||
|
List(context.Context, string) ([]ManagedItem, error)
|
||||||
|
Add(context.Context, SearchResult) (*ManagedItem, error)
|
||||||
|
SetMonitored(context.Context, int64, bool) (*ManagedItem, error)
|
||||||
|
Delete(context.Context, int64) error
|
||||||
|
FetchImage(context.Context, ManagedItem) (*MediaAsset, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchResult struct {
|
||||||
|
LookupID int64
|
||||||
|
Title string
|
||||||
|
Year int
|
||||||
|
Overview string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManagedItem struct {
|
||||||
|
ID int64
|
||||||
|
LookupID int64
|
||||||
|
Title string
|
||||||
|
Year int
|
||||||
|
Monitored bool
|
||||||
|
Path string
|
||||||
|
ImageURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaAsset struct {
|
||||||
|
Data []byte
|
||||||
|
FileName string
|
||||||
|
MimeType string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RadarrConfig struct {
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
APIKey string `yaml:"api_key"`
|
||||||
|
RootFolderPath string `yaml:"root_folder_path"`
|
||||||
|
QualityProfileID int64 `yaml:"quality_profile_id"`
|
||||||
|
MinimumAvailability string `yaml:"minimum_availability"`
|
||||||
|
SearchOnAdd *bool `yaml:"search_on_add"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SonarrConfig struct {
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
APIKey string `yaml:"api_key"`
|
||||||
|
RootFolderPath string `yaml:"root_folder_path"`
|
||||||
|
QualityProfileID int64 `yaml:"quality_profile_id"`
|
||||||
|
LanguageProfileID int64 `yaml:"language_profile_id"`
|
||||||
|
SeasonFolder *bool `yaml:"season_folder"`
|
||||||
|
SeriesType string `yaml:"series_type"`
|
||||||
|
SearchOnAdd *bool `yaml:"search_on_add"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type httpClient struct {
|
||||||
|
baseURL *url.URL
|
||||||
|
apiKey string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type mediaImage struct {
|
||||||
|
CoverType string `json:"coverType"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
RemoteURL string `json:"remoteUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RadarrConfig) ApplyDefaults() {
|
||||||
|
if c.MinimumAvailability == "" {
|
||||||
|
c.MinimumAvailability = "released"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c RadarrConfig) Enabled() bool {
|
||||||
|
return strings.TrimSpace(c.URL) != "" || strings.TrimSpace(c.APIKey) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c RadarrConfig) Validate() error {
|
||||||
|
if !c.Enabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(c.URL) == "":
|
||||||
|
return fmt.Errorf("network.content.movies.url must be set when movies content is configured")
|
||||||
|
case strings.TrimSpace(c.APIKey) == "":
|
||||||
|
return fmt.Errorf("network.content.movies.api_key must be set when movies content is configured")
|
||||||
|
case strings.TrimSpace(c.RootFolderPath) == "":
|
||||||
|
return fmt.Errorf("network.content.movies.root_folder_path must be set when movies content is configured")
|
||||||
|
case c.QualityProfileID <= 0:
|
||||||
|
return fmt.Errorf("network.content.movies.quality_profile_id must be set when movies content is configured")
|
||||||
|
case strings.TrimSpace(c.MinimumAvailability) == "":
|
||||||
|
return fmt.Errorf("network.content.movies.minimum_availability must not be empty")
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c RadarrConfig) SearchOnAddValue() bool {
|
||||||
|
return boolValue(c.SearchOnAdd, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SonarrConfig) ApplyDefaults() {
|
||||||
|
if c.SeriesType == "" {
|
||||||
|
c.SeriesType = "standard"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c SonarrConfig) Enabled() bool {
|
||||||
|
return strings.TrimSpace(c.URL) != "" || strings.TrimSpace(c.APIKey) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c SonarrConfig) Validate() error {
|
||||||
|
if !c.Enabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(c.URL) == "":
|
||||||
|
return fmt.Errorf("network.content.series.url must be set when series content is configured")
|
||||||
|
case strings.TrimSpace(c.APIKey) == "":
|
||||||
|
return fmt.Errorf("network.content.series.api_key must be set when series content is configured")
|
||||||
|
case strings.TrimSpace(c.RootFolderPath) == "":
|
||||||
|
return fmt.Errorf("network.content.series.root_folder_path must be set when series content is configured")
|
||||||
|
case c.QualityProfileID <= 0:
|
||||||
|
return fmt.Errorf("network.content.series.quality_profile_id must be set when series content is configured")
|
||||||
|
case c.LanguageProfileID <= 0:
|
||||||
|
return fmt.Errorf("network.content.series.language_profile_id must be set when series content is configured")
|
||||||
|
case strings.TrimSpace(c.SeriesType) == "":
|
||||||
|
return fmt.Errorf("network.content.series.series_type must not be empty")
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c SonarrConfig) SeasonFolderValue() bool {
|
||||||
|
return boolValue(c.SeasonFolder, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c SonarrConfig) SearchOnAddValue() bool {
|
||||||
|
return boolValue(c.SearchOnAdd, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHTTPClient(rawURL, apiKey string) (*httpClient, error) {
|
||||||
|
parsedURL, err := url.Parse(strings.TrimRight(strings.TrimSpace(rawURL), "/"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &httpClient{
|
||||||
|
baseURL: parsedURL,
|
||||||
|
apiKey: apiKey,
|
||||||
|
httpClient: http.DefaultClient,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *httpClient) do(ctx context.Context, method, requestPath string, query url.Values, body any, dest any) error {
|
||||||
|
endpoint := *c.baseURL
|
||||||
|
endpoint.Path = path.Join(endpoint.Path, requestPath)
|
||||||
|
if len(query) > 0 {
|
||||||
|
endpoint.RawQuery = query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload io.Reader
|
||||||
|
if body != nil {
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Api-Key", c.apiKey)
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
return fmt.Errorf("%s %s returned %d: %s", method, endpoint.String(), resp.StatusCode, strings.TrimSpace(string(data)))
|
||||||
|
}
|
||||||
|
if dest == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.NewDecoder(resp.Body).Decode(dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolValue(value *bool, fallback bool) bool {
|
||||||
|
if value == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsFold(haystack, needle string) bool {
|
||||||
|
return strings.Contains(strings.ToLower(haystack), strings.ToLower(strings.TrimSpace(needle)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func FormatSearchResult(result SearchResult) string {
|
||||||
|
if result.Year != 0 {
|
||||||
|
return fmt.Sprintf("%s (%d)", result.Title, result.Year)
|
||||||
|
}
|
||||||
|
return result.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
func FormatManagedItem(item ManagedItem) string {
|
||||||
|
if item.Year != 0 {
|
||||||
|
return fmt.Sprintf("%s (%d)", item.Title, item.Year)
|
||||||
|
}
|
||||||
|
return item.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
func EscapeText(text string) string {
|
||||||
|
return html.EscapeString(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *httpClient) FetchImage(ctx context.Context, item ManagedItem) (*MediaAsset, error) {
|
||||||
|
imageURL := strings.TrimSpace(item.ImageURL)
|
||||||
|
if imageURL == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint, err := url.Parse(imageURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse image URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL.ResolveReference(endpoint).String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if sameHost(req.URL, c.baseURL) {
|
||||||
|
req.Header.Set("X-Api-Key", c.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
return nil, fmt.Errorf("GET %s returned %d: %s", req.URL.String(), resp.StatusCode, strings.TrimSpace(string(data)))
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mimeType := strings.TrimSpace(resp.Header.Get("Content-Type"))
|
||||||
|
if idx := strings.Index(mimeType, ";"); idx >= 0 {
|
||||||
|
mimeType = strings.TrimSpace(mimeType[:idx])
|
||||||
|
}
|
||||||
|
if mimeType == "" {
|
||||||
|
mimeType = http.DetectContentType(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &MediaAsset{
|
||||||
|
Data: data,
|
||||||
|
FileName: imageFileName(item, endpoint, mimeType),
|
||||||
|
MimeType: mimeType,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *httpClient) imageURL(images []mediaImage) string {
|
||||||
|
for _, coverType := range []string{"poster", "cover", "fanart"} {
|
||||||
|
for _, image := range images {
|
||||||
|
if !strings.EqualFold(image.CoverType, coverType) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resolved := c.resolveMediaURL(image); resolved != "" {
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *httpClient) resolveMediaURL(image mediaImage) string {
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(image.URL) != "":
|
||||||
|
ref, err := url.Parse(strings.TrimSpace(image.URL))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return c.baseURL.ResolveReference(ref).String()
|
||||||
|
case strings.TrimSpace(image.RemoteURL) != "":
|
||||||
|
return strings.TrimSpace(image.RemoteURL)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageFileName(item ManagedItem, endpoint *url.URL, mimeType string) string {
|
||||||
|
baseName := sanitizeFileName(strings.TrimSpace(item.Title))
|
||||||
|
if baseName == "" {
|
||||||
|
baseName = fmt.Sprintf("arrtrix-%d", item.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.TrimSpace(filepath.Ext(endpoint.Path))
|
||||||
|
if ext == "" && mimeType != "" {
|
||||||
|
if extensions, err := mime.ExtensionsByType(mimeType); err == nil && len(extensions) > 0 {
|
||||||
|
ext = extensions[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ext == "" {
|
||||||
|
ext = ".jpg"
|
||||||
|
}
|
||||||
|
if item.ID != 0 {
|
||||||
|
return fmt.Sprintf("%s-%d%s", baseName, item.ID, ext)
|
||||||
|
}
|
||||||
|
return baseName + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeFileName(value string) string {
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
"<", "",
|
||||||
|
">", "",
|
||||||
|
":", "",
|
||||||
|
"\"", "",
|
||||||
|
"/", "-",
|
||||||
|
"\\", "-",
|
||||||
|
"|", "-",
|
||||||
|
"?", "",
|
||||||
|
"*", "",
|
||||||
|
)
|
||||||
|
value = replacer.Replace(value)
|
||||||
|
value = strings.Join(strings.Fields(value), "-")
|
||||||
|
return strings.Trim(value, ".- ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameHost(left, right *url.URL) bool {
|
||||||
|
if left == nil || right == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(left.Scheme, right.Scheme) && strings.EqualFold(left.Host, right.Host)
|
||||||
|
}
|
||||||
80
packages/arrtrix/pkg/arrclient/client_test.go
Normal file
80
packages/arrtrix/pkg/arrclient/client_test.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package arrclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestImageURLPrefersPosterAndResolvesRelativePath(t *testing.T) {
|
||||||
|
baseURL, err := url.Parse("https://radarr.example")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse base URL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &httpClient{baseURL: baseURL}
|
||||||
|
imageURL := client.imageURL([]mediaImage{
|
||||||
|
{CoverType: "fanart", URL: "/MediaCover/1/fanart.jpg"},
|
||||||
|
{CoverType: "poster", URL: "/MediaCover/1/poster.jpg"},
|
||||||
|
})
|
||||||
|
if imageURL != "https://radarr.example/MediaCover/1/poster.jpg" {
|
||||||
|
t.Fatalf("unexpected image URL %q", imageURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageURLFallsBackToRemoteURL(t *testing.T) {
|
||||||
|
baseURL, err := url.Parse("https://sonarr.example")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse base URL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &httpClient{baseURL: baseURL}
|
||||||
|
imageURL := client.imageURL([]mediaImage{
|
||||||
|
{CoverType: "poster", RemoteURL: "https://images.example/poster.jpg"},
|
||||||
|
})
|
||||||
|
if imageURL != "https://images.example/poster.jpg" {
|
||||||
|
t.Fatalf("unexpected remote image URL %q", imageURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchImageUsesAPIKeyForSameHost(t *testing.T) {
|
||||||
|
headers := make(chan string, 1)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
headers <- r.Header.Get("X-Api-Key")
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
_, _ = w.Write([]byte("jpeg-bytes"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client, err := newHTTPClient(server.URL, "secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
asset, err := client.FetchImage(context.Background(), ManagedItem{
|
||||||
|
ID: 42,
|
||||||
|
Title: "Dune Part Two",
|
||||||
|
ImageURL: server.URL + "/MediaCover/42/poster.jpg",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to fetch image: %v", err)
|
||||||
|
}
|
||||||
|
if asset == nil {
|
||||||
|
t.Fatal("expected media asset")
|
||||||
|
}
|
||||||
|
if got := <-headers; got != "secret" {
|
||||||
|
t.Fatalf("expected API key header, got %q", got)
|
||||||
|
}
|
||||||
|
if got := string(asset.Data); got != "jpeg-bytes" {
|
||||||
|
t.Fatalf("unexpected media bytes %q", got)
|
||||||
|
}
|
||||||
|
if asset.MimeType != "image/jpeg" {
|
||||||
|
t.Fatalf("unexpected mime type %q", asset.MimeType)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(asset.FileName, "Dune-Part-Two-42") || !strings.HasSuffix(asset.FileName, ".jpg") {
|
||||||
|
t.Fatalf("unexpected filename %q", asset.FileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
172
packages/arrtrix/pkg/arrclient/radarr.go
Normal file
172
packages/arrtrix/pkg/arrclient/radarr.go
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
package arrclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"sneeuwvlok/packages/arrtrix/pkg/arr"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RadarrClient struct {
|
||||||
|
http *httpClient
|
||||||
|
config RadarrConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type radarrMovie struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Year int `json:"year"`
|
||||||
|
TMDBID int64 `json:"tmdbId"`
|
||||||
|
Overview string `json:"overview"`
|
||||||
|
Monitored bool `json:"monitored"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Images []mediaImage `json:"images"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRadarrClient(config RadarrConfig) (*RadarrClient, error) {
|
||||||
|
config.ApplyDefaults()
|
||||||
|
if err := config.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
httpClient, err := newHTTPClient(config.URL, config.APIKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &RadarrClient{http: httpClient, config: config}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RadarrClient) ContentType() arr.ContentType {
|
||||||
|
return arr.ContentTypeMovies
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RadarrClient) Search(ctx context.Context, query string) ([]SearchResult, error) {
|
||||||
|
var response []radarrMovie
|
||||||
|
if err := c.http.do(ctx, http.MethodGet, "/api/v3/movie/lookup", url.Values{"term": {strings.TrimSpace(query)}}, nil, &response); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]SearchResult, 0, len(response))
|
||||||
|
for _, movie := range response {
|
||||||
|
if movie.TMDBID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, SearchResult{
|
||||||
|
LookupID: movie.TMDBID,
|
||||||
|
Title: movie.Title,
|
||||||
|
Year: movie.Year,
|
||||||
|
Overview: movie.Overview,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RadarrClient) List(ctx context.Context, query string) ([]ManagedItem, error) {
|
||||||
|
var response []radarrMovie
|
||||||
|
if err := c.http.do(ctx, http.MethodGet, "/api/v3/movie", nil, nil, &response); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]ManagedItem, 0, len(response))
|
||||||
|
for _, movie := range response {
|
||||||
|
if query != "" && !containsFold(movie.Title, query) && !containsFold(strconv.Itoa(movie.Year), query) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, ManagedItem{
|
||||||
|
ID: movie.ID,
|
||||||
|
LookupID: movie.TMDBID,
|
||||||
|
Title: movie.Title,
|
||||||
|
Year: movie.Year,
|
||||||
|
Monitored: movie.Monitored,
|
||||||
|
Path: movie.Path,
|
||||||
|
ImageURL: c.http.imageURL(movie.Images),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RadarrClient) Add(ctx context.Context, result SearchResult) (*ManagedItem, error) {
|
||||||
|
payload := map[string]any{
|
||||||
|
"title": result.Title,
|
||||||
|
"tmdbId": result.LookupID,
|
||||||
|
"year": result.Year,
|
||||||
|
"qualityProfileId": c.config.QualityProfileID,
|
||||||
|
"rootFolderPath": c.config.RootFolderPath,
|
||||||
|
"minimumAvailability": c.config.MinimumAvailability,
|
||||||
|
"monitored": true,
|
||||||
|
"addOptions": map[string]any{
|
||||||
|
"searchForMovie": c.config.SearchOnAddValue(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var response radarrMovie
|
||||||
|
if err := c.http.do(ctx, http.MethodPost, "/api/v3/movie", nil, payload, &response); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item := ManagedItem{
|
||||||
|
ID: response.ID,
|
||||||
|
LookupID: response.TMDBID,
|
||||||
|
Title: response.Title,
|
||||||
|
Year: response.Year,
|
||||||
|
Monitored: response.Monitored,
|
||||||
|
Path: response.Path,
|
||||||
|
ImageURL: c.http.imageURL(response.Images),
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RadarrClient) SetMonitored(ctx context.Context, id int64, monitored bool) (*ManagedItem, error) {
|
||||||
|
var movie map[string]any
|
||||||
|
endpoint := "/api/v3/movie/" + strconv.FormatInt(id, 10)
|
||||||
|
if err := c.http.do(ctx, http.MethodGet, endpoint, nil, nil, &movie); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
movie["monitored"] = monitored
|
||||||
|
|
||||||
|
var response radarrMovie
|
||||||
|
if err := c.http.do(ctx, http.MethodPut, endpoint, nil, movie, &response); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item := ManagedItem{
|
||||||
|
ID: response.ID,
|
||||||
|
LookupID: response.TMDBID,
|
||||||
|
Title: response.Title,
|
||||||
|
Year: response.Year,
|
||||||
|
Monitored: response.Monitored,
|
||||||
|
Path: response.Path,
|
||||||
|
ImageURL: c.http.imageURL(response.Images),
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RadarrClient) Delete(ctx context.Context, id int64) error {
|
||||||
|
return c.http.do(ctx, http.MethodDelete, "/api/v3/movie/"+strconv.FormatInt(id, 10), url.Values{
|
||||||
|
"deleteFiles": {"false"},
|
||||||
|
"addImportExclusion": {"false"},
|
||||||
|
}, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RadarrClient) FetchImage(ctx context.Context, item ManagedItem) (*MediaAsset, error) {
|
||||||
|
return c.http.FetchImage(ctx, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PickSingleResult(results []SearchResult, query string) (SearchResult, error) {
|
||||||
|
switch len(results) {
|
||||||
|
case 0:
|
||||||
|
return SearchResult{}, fmt.Errorf("no matching result found for %q", query)
|
||||||
|
case 1:
|
||||||
|
return results[0], nil
|
||||||
|
default:
|
||||||
|
normalized := strings.TrimSpace(strings.ToLower(query))
|
||||||
|
for _, result := range results {
|
||||||
|
title := strings.ToLower(FormatSearchResult(result))
|
||||||
|
if title == normalized {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SearchResult{}, fmt.Errorf("multiple results matched %q", query)
|
||||||
|
}
|
||||||
|
}
|
||||||
157
packages/arrtrix/pkg/arrclient/sonarr.go
Normal file
157
packages/arrtrix/pkg/arrclient/sonarr.go
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
package arrclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"sneeuwvlok/packages/arrtrix/pkg/arr"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SonarrClient struct {
|
||||||
|
http *httpClient
|
||||||
|
config SonarrConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type sonarrSeries struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Year int `json:"year"`
|
||||||
|
TVDBID int64 `json:"tvdbId"`
|
||||||
|
Overview string `json:"overview"`
|
||||||
|
Monitored bool `json:"monitored"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Images []mediaImage `json:"images"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSonarrClient(config SonarrConfig) (*SonarrClient, error) {
|
||||||
|
config.ApplyDefaults()
|
||||||
|
if err := config.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
httpClient, err := newHTTPClient(config.URL, config.APIKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &SonarrClient{http: httpClient, config: config}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SonarrClient) ContentType() arr.ContentType {
|
||||||
|
return arr.ContentTypeSeries
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SonarrClient) Search(ctx context.Context, query string) ([]SearchResult, error) {
|
||||||
|
var response []sonarrSeries
|
||||||
|
if err := c.http.do(ctx, http.MethodGet, "/api/v3/series/lookup", url.Values{"term": {strings.TrimSpace(query)}}, nil, &response); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]SearchResult, 0, len(response))
|
||||||
|
for _, series := range response {
|
||||||
|
if series.TVDBID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, SearchResult{
|
||||||
|
LookupID: series.TVDBID,
|
||||||
|
Title: series.Title,
|
||||||
|
Year: series.Year,
|
||||||
|
Overview: series.Overview,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SonarrClient) List(ctx context.Context, query string) ([]ManagedItem, error) {
|
||||||
|
var response []sonarrSeries
|
||||||
|
if err := c.http.do(ctx, http.MethodGet, "/api/v3/series", nil, nil, &response); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]ManagedItem, 0, len(response))
|
||||||
|
for _, series := range response {
|
||||||
|
if query != "" && !containsFold(series.Title, query) && !containsFold(strconv.Itoa(series.Year), query) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, ManagedItem{
|
||||||
|
ID: series.ID,
|
||||||
|
LookupID: series.TVDBID,
|
||||||
|
Title: series.Title,
|
||||||
|
Year: series.Year,
|
||||||
|
Monitored: series.Monitored,
|
||||||
|
Path: series.Path,
|
||||||
|
ImageURL: c.http.imageURL(series.Images),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SonarrClient) Add(ctx context.Context, result SearchResult) (*ManagedItem, error) {
|
||||||
|
payload := map[string]any{
|
||||||
|
"title": result.Title,
|
||||||
|
"tvdbId": result.LookupID,
|
||||||
|
"qualityProfileId": c.config.QualityProfileID,
|
||||||
|
"languageProfileId": c.config.LanguageProfileID,
|
||||||
|
"rootFolderPath": c.config.RootFolderPath,
|
||||||
|
"seasonFolder": c.config.SeasonFolderValue(),
|
||||||
|
"monitored": true,
|
||||||
|
"seriesType": c.config.SeriesType,
|
||||||
|
"addOptions": map[string]any{
|
||||||
|
"searchForMissingEpisodes": c.config.SearchOnAddValue(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if result.Year != 0 {
|
||||||
|
payload["year"] = result.Year
|
||||||
|
}
|
||||||
|
|
||||||
|
var response sonarrSeries
|
||||||
|
if err := c.http.do(ctx, http.MethodPost, "/api/v3/series", nil, payload, &response); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item := ManagedItem{
|
||||||
|
ID: response.ID,
|
||||||
|
LookupID: response.TVDBID,
|
||||||
|
Title: response.Title,
|
||||||
|
Year: response.Year,
|
||||||
|
Monitored: response.Monitored,
|
||||||
|
Path: response.Path,
|
||||||
|
ImageURL: c.http.imageURL(response.Images),
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SonarrClient) SetMonitored(ctx context.Context, id int64, monitored bool) (*ManagedItem, error) {
|
||||||
|
var series map[string]any
|
||||||
|
endpoint := "/api/v3/series/" + strconv.FormatInt(id, 10)
|
||||||
|
if err := c.http.do(ctx, http.MethodGet, endpoint, nil, nil, &series); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
series["monitored"] = monitored
|
||||||
|
|
||||||
|
var response sonarrSeries
|
||||||
|
if err := c.http.do(ctx, http.MethodPut, endpoint, nil, series, &response); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item := ManagedItem{
|
||||||
|
ID: response.ID,
|
||||||
|
LookupID: response.TVDBID,
|
||||||
|
Title: response.Title,
|
||||||
|
Year: response.Year,
|
||||||
|
Monitored: response.Monitored,
|
||||||
|
Path: response.Path,
|
||||||
|
ImageURL: c.http.imageURL(response.Images),
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SonarrClient) Delete(ctx context.Context, id int64) error {
|
||||||
|
return c.http.do(ctx, http.MethodDelete, "/api/v3/series/"+strconv.FormatInt(id, 10), url.Values{
|
||||||
|
"deleteFiles": {"false"},
|
||||||
|
"addImportListExclusion": {"false"},
|
||||||
|
}, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SonarrClient) FetchImage(ctx context.Context, item ManagedItem) (*MediaAsset, error) {
|
||||||
|
return c.http.FetchImage(ctx, item)
|
||||||
|
}
|
||||||
65
packages/arrtrix/pkg/config/config.go
Normal file
65
packages/arrtrix/pkg/config/config.go
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go.mau.fi/util/dbutil"
|
||||||
|
"go.mau.fi/zeroconfig"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
|
||||||
|
|
||||||
|
"sneeuwvlok/packages/arrtrix/pkg/observability"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Network yaml.Node `yaml:"network"`
|
||||||
|
|
||||||
|
Bridge bridgeconfig.BridgeConfig `yaml:"bridge"`
|
||||||
|
Database dbutil.Config `yaml:"database"`
|
||||||
|
Homeserver bridgeconfig.HomeserverConfig `yaml:"homeserver"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load(data []byte) (*Config, error) {
|
||||||
|
var cfg Config
|
||||||
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.applyDefaults()
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) applyDefaults() {
|
||||||
|
if c.Homeserver.Software == "" {
|
||||||
|
c.Homeserver.Software = bridgeconfig.SoftwareStandard
|
||||||
|
}
|
||||||
|
c.Observability.ApplyDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) Compile() bridgeconfig.Config {
|
||||||
|
return bridgeconfig.Config{
|
||||||
|
Network: c.Network,
|
||||||
|
Bridge: c.Bridge,
|
||||||
|
Database: c.Database,
|
||||||
|
Homeserver: c.Homeserver,
|
||||||
|
AppService: c.AppService,
|
||||||
|
Logging: c.Logging,
|
||||||
|
EnvConfigPrefix: c.EnvConfigPrefix,
|
||||||
|
ManagementRoomTexts: c.ManagementTexts,
|
||||||
|
Matrix: bridgeconfig.MatrixConfig{
|
||||||
|
MessageStatusEvents: false,
|
||||||
|
DeliveryReceipts: false,
|
||||||
|
MessageErrorNotices: true,
|
||||||
|
SyncDirectChatList: false,
|
||||||
|
FederateRooms: true,
|
||||||
|
},
|
||||||
|
DoublePuppet: bridgeconfig.DoublePuppetConfig{
|
||||||
|
Servers: map[string]string{},
|
||||||
|
Secrets: map[string]string{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
159
packages/arrtrix/pkg/config/config_test.go
Normal file
159
packages/arrtrix/pkg/config/config_test.go
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadDefaultsHomeserverSoftware(t *testing.T) {
|
||||||
|
cfg, err := Load([]byte(`
|
||||||
|
bridge:
|
||||||
|
command_prefix: "!arr"
|
||||||
|
homeserver:
|
||||||
|
address: http://127.0.0.1:8008
|
||||||
|
domain: test.local
|
||||||
|
appservice:
|
||||||
|
id: arrtrix
|
||||||
|
bot:
|
||||||
|
username: arrtrixbot
|
||||||
|
displayname: Arrtrix Bot
|
||||||
|
username_template: arrtrix_{{.}}
|
||||||
|
database:
|
||||||
|
type: sqlite3-fk-wal
|
||||||
|
uri: file:arrtrix.db?_txlock=immediate
|
||||||
|
logging:
|
||||||
|
min_level: info
|
||||||
|
writers:
|
||||||
|
- type: stdout
|
||||||
|
format: pretty-colored
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Homeserver.Software != bridgeconfig.SoftwareStandard {
|
||||||
|
t.Fatalf("expected homeserver software default %q, got %q", bridgeconfig.SoftwareStandard, cfg.Homeserver.Software)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompileSetsInternalDefaultsForHiddenSections(t *testing.T) {
|
||||||
|
cfg, err := Load([]byte(`
|
||||||
|
bridge:
|
||||||
|
command_prefix: "!arr"
|
||||||
|
permissions:
|
||||||
|
"*": relay
|
||||||
|
homeserver:
|
||||||
|
address: http://127.0.0.1:8008
|
||||||
|
domain: test.local
|
||||||
|
appservice:
|
||||||
|
id: arrtrix
|
||||||
|
bot:
|
||||||
|
username: arrtrixbot
|
||||||
|
displayname: Arrtrix Bot
|
||||||
|
username_template: arrtrix_{{.}}
|
||||||
|
database:
|
||||||
|
type: sqlite3-fk-wal
|
||||||
|
uri: file:arrtrix.db?_txlock=immediate
|
||||||
|
logging:
|
||||||
|
min_level: info
|
||||||
|
writers:
|
||||||
|
- type: stdout
|
||||||
|
format: pretty-colored
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeCfg := cfg.Compile()
|
||||||
|
if !runtimeCfg.Matrix.MessageErrorNotices {
|
||||||
|
t.Fatalf("expected message error notices to stay enabled")
|
||||||
|
}
|
||||||
|
if !runtimeCfg.Matrix.FederateRooms {
|
||||||
|
t.Fatalf("expected federated rooms to stay enabled")
|
||||||
|
}
|
||||||
|
if runtimeCfg.DoublePuppet.Servers == nil || runtimeCfg.DoublePuppet.Secrets == nil {
|
||||||
|
t.Fatalf("expected hidden double puppet maps to be initialized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadIgnoresLegacyHiddenSections(t *testing.T) {
|
||||||
|
cfg, err := Load([]byte(`
|
||||||
|
bridge:
|
||||||
|
command_prefix: "!arr"
|
||||||
|
homeserver:
|
||||||
|
address: http://127.0.0.1:8008
|
||||||
|
domain: test.local
|
||||||
|
appservice:
|
||||||
|
id: arrtrix
|
||||||
|
bot:
|
||||||
|
username: arrtrixbot
|
||||||
|
displayname: Arrtrix Bot
|
||||||
|
username_template: arrtrix_{{.}}
|
||||||
|
database:
|
||||||
|
type: sqlite3-fk-wal
|
||||||
|
uri: file:arrtrix.db?_txlock=immediate
|
||||||
|
logging:
|
||||||
|
min_level: info
|
||||||
|
writers:
|
||||||
|
- type: stdout
|
||||||
|
format: pretty-colored
|
||||||
|
matrix:
|
||||||
|
federate_rooms: false
|
||||||
|
provisioning:
|
||||||
|
shared_secret: ignored
|
||||||
|
double_puppet:
|
||||||
|
secrets:
|
||||||
|
test.local: secret
|
||||||
|
encryption:
|
||||||
|
allow: true
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeCfg := cfg.Compile()
|
||||||
|
if !runtimeCfg.Matrix.FederateRooms {
|
||||||
|
t.Fatalf("expected runtime defaults to win for hidden legacy sections")
|
||||||
|
}
|
||||||
|
if len(runtimeCfg.DoublePuppet.Secrets) != 0 {
|
||||||
|
t.Fatalf("expected hidden double puppet secrets to stay internal-only")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadIgnoresLegacyWebhookSettings(t *testing.T) {
|
||||||
|
cfg, err := Load([]byte(`
|
||||||
|
network:
|
||||||
|
webhooks:
|
||||||
|
radarr:
|
||||||
|
enabled: true
|
||||||
|
path: /_arrtrix/webhooks/radarr
|
||||||
|
secret: legacy-secret
|
||||||
|
bridge:
|
||||||
|
command_prefix: "!arr"
|
||||||
|
homeserver:
|
||||||
|
address: http://127.0.0.1:8008
|
||||||
|
domain: test.local
|
||||||
|
appservice:
|
||||||
|
id: arrtrix
|
||||||
|
bot:
|
||||||
|
username: arrtrixbot
|
||||||
|
displayname: Arrtrix Bot
|
||||||
|
username_template: arrtrix_{{.}}
|
||||||
|
database:
|
||||||
|
type: sqlite3-fk-wal
|
||||||
|
uri: file:arrtrix.db?_txlock=immediate
|
||||||
|
logging:
|
||||||
|
min_level: info
|
||||||
|
writers:
|
||||||
|
- type: stdout
|
||||||
|
format: pretty-colored
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg == nil {
|
||||||
|
t.Fatal("expected config to load")
|
||||||
|
}
|
||||||
|
}
|
||||||
74
packages/arrtrix/pkg/connector/config.go
Normal file
74
packages/arrtrix/pkg/connector/config.go
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
package connector
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func upgradeConfig(helper up.Helper) {}
|
||||||
|
|
||||||
|
func (s *ArrtrixConnector) GetConfig() (string, any, up.Upgrader) {
|
||||||
|
return ExampleConfig, &s.Config, up.SimpleUpgrader(upgradeConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
23
packages/arrtrix/pkg/connector/config_test.go
Normal file
23
packages/arrtrix/pkg/connector/config_test.go
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
package connector
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestValidateConfigRejectsPartialMoviesConfig(t *testing.T) {
|
||||||
|
conn := &ArrtrixConnector{
|
||||||
|
Config: Config{
|
||||||
|
Content: ContentConfig{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
conn.Config.Content.Movies.URL = "http://radarr.test"
|
||||||
|
|
||||||
|
if err := conn.ValidateConfig(); err == nil {
|
||||||
|
t.Fatal("expected partial movies config to fail validation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfigAllowsEmptyContentConfig(t *testing.T) {
|
||||||
|
conn := &ArrtrixConnector{}
|
||||||
|
if err := conn.ValidateConfig(); err != nil {
|
||||||
|
t.Fatalf("ValidateConfig returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
135
packages/arrtrix/pkg/connector/connector.go
Normal file
135
packages/arrtrix/pkg/connector/connector.go
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
package connector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/bridgev2"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/database"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ bridgev2.NetworkConnector = (*ArrtrixConnector)(nil)
|
||||||
|
var _ interface{ MountRoutes(*http.ServeMux) error } = (*ArrtrixConnector)(nil)
|
||||||
|
|
||||||
|
func (s *ArrtrixConnector) GetName() bridgev2.BridgeName {
|
||||||
|
return bridgev2.BridgeName{
|
||||||
|
DisplayName: "Arrtrix",
|
||||||
|
NetworkURL: "https://wiki.servarr.com/",
|
||||||
|
NetworkID: "arrtrix",
|
||||||
|
BeeperBridgeType: "arrtrix",
|
||||||
|
DefaultPort: 29329,
|
||||||
|
DefaultCommandPrefix: "!arr",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArrtrixConnector) GetDBMetaTypes() database.MetaTypes {
|
||||||
|
return database.MetaTypes{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArrtrixConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities {
|
||||||
|
return &bridgev2.NetworkGeneralCapabilities{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArrtrixConnector) LoadUserLogin(_ context.Context, login *bridgev2.UserLogin) error {
|
||||||
|
login.Client = &ArrtrixClient{
|
||||||
|
Main: s,
|
||||||
|
UserLogin: login,
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArrtrixConnector) GetLoginFlows() []bridgev2.LoginFlow {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArrtrixConnector) CreateLogin(_ context.Context, _ *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) {
|
||||||
|
return nil, fmt.Errorf("login flow %q is not implemented", flowID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArrtrixConnector) GetBridgeInfoVersion() (info, capabilities int) {
|
||||||
|
return 1, 1
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArrtrixClient struct {
|
||||||
|
Main *ArrtrixConnector
|
||||||
|
UserLogin *bridgev2.UserLogin
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ bridgev2.NetworkAPI = (*ArrtrixClient)(nil)
|
||||||
|
|
||||||
|
func (c *ArrtrixClient) Connect(context.Context) {}
|
||||||
|
|
||||||
|
func (c *ArrtrixClient) Disconnect() {}
|
||||||
|
|
||||||
|
func (c *ArrtrixClient) IsLoggedIn() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ArrtrixClient) LogoutRemote(context.Context) {}
|
||||||
|
|
||||||
|
func (c *ArrtrixClient) IsThisUser(context.Context, networkid.UserID) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ArrtrixClient) GetChatInfo(context.Context, *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
|
||||||
|
return &bridgev2.ChatInfo{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ArrtrixClient) GetUserInfo(context.Context, *bridgev2.Ghost) (*bridgev2.UserInfo, error) {
|
||||||
|
return &bridgev2.UserInfo{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ArrtrixClient) GetCapabilities(context.Context, *bridgev2.Portal) *event.RoomFeatures {
|
||||||
|
return &event.RoomFeatures{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ArrtrixClient) HandleMatrixMessage(context.Context, *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error) {
|
||||||
|
return nil, fmt.Errorf("bridging Matrix messages is not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
23
packages/arrtrix/pkg/connector/example-config.yaml
Normal file
23
packages/arrtrix/pkg/connector/example-config.yaml
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
content:
|
||||||
|
movies:
|
||||||
|
# Radarr connection for movie management commands.
|
||||||
|
url: ""
|
||||||
|
api_key: ""
|
||||||
|
root_folder_path: ""
|
||||||
|
quality_profile_id: 0
|
||||||
|
minimum_availability: released
|
||||||
|
search_on_add: true
|
||||||
|
|
||||||
|
series:
|
||||||
|
# Sonarr connection for series management commands.
|
||||||
|
url: ""
|
||||||
|
api_key: ""
|
||||||
|
root_folder_path: ""
|
||||||
|
quality_profile_id: 0
|
||||||
|
language_profile_id: 0
|
||||||
|
season_folder: true
|
||||||
|
series_type: standard
|
||||||
|
search_on_add: true
|
||||||
|
|
||||||
|
# Arr-stack webhooks are exposed automatically on the fixed built-in path:
|
||||||
|
# POST /_arrtrix/webhook
|
||||||
260
packages/arrtrix/pkg/matrixcmd/download.go
Normal file
260
packages/arrtrix/pkg/matrixcmd/download.go
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
package matrixcmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
|
"sneeuwvlok/packages/arrtrix/pkg/arr"
|
||||||
|
"sneeuwvlok/packages/arrtrix/pkg/arrclient"
|
||||||
|
"sneeuwvlok/packages/arrtrix/pkg/subscriptions"
|
||||||
|
)
|
||||||
|
|
||||||
|
type commandServiceProvider interface {
|
||||||
|
ContentClient(arr.ContentType) (arrclient.Client, bool)
|
||||||
|
Subscriptions() *subscriptions.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDownloadHandler() Handler {
|
||||||
|
return NewHandler(Meta{
|
||||||
|
Name: "download",
|
||||||
|
Description: "Manage monitored movies and series in Arr.",
|
||||||
|
Usage: "<list|search|add|monitor|remove> <movies|series> [...]",
|
||||||
|
}, func(ctx *Context) {
|
||||||
|
if len(ctx.Args) < 2 {
|
||||||
|
ctx.Reply("Usage: `download <list|search|add|monitor|remove> <movies|series> [...]`")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType, err := arr.ParseContentType(ctx.Args[1])
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client, ok := contentClient(ctx, contentType)
|
||||||
|
if !ok {
|
||||||
|
ctx.Reply("No %s client is configured yet.", contentType.Label())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(ctx.Args[0]) {
|
||||||
|
case "list":
|
||||||
|
handleDownloadList(ctx, client, contentType)
|
||||||
|
case "search":
|
||||||
|
handleDownloadSearch(ctx, client, contentType)
|
||||||
|
case "add":
|
||||||
|
handleDownloadAdd(ctx, client, contentType)
|
||||||
|
case "monitor":
|
||||||
|
handleDownloadMonitor(ctx, client, contentType)
|
||||||
|
case "remove":
|
||||||
|
handleDownloadRemove(ctx, client, contentType)
|
||||||
|
default:
|
||||||
|
ctx.Reply("Unknown download subcommand `%s`.", ctx.Args[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDownloadList(ctx *Context, client arrclient.Client, contentType arr.ContentType) {
|
||||||
|
query := strings.TrimSpace(strings.Join(ctx.Args[2:], " "))
|
||||||
|
items, err := client.List(ctx.Ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply("Failed to list %s: %v", contentType.Label(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
if query == "" {
|
||||||
|
ctx.Reply("No monitored %s are currently tracked.", contentType.Label())
|
||||||
|
} else {
|
||||||
|
ctx.Reply("No %s matched `%s`.", contentType.Label(), query)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
count := len(items)
|
||||||
|
if count > 12 {
|
||||||
|
count = 12
|
||||||
|
}
|
||||||
|
ctx.Reply("Tracked %s (showing %d of %d):", contentType.Label(), count, len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
if i == 12 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := replyWithManagedItem(ctx, client, item); err != nil {
|
||||||
|
ctx.Log.Err(err).Int64("item_id", item.ID).Str("content_type", contentType.Label()).Msg("Failed to send Matrix-native image for download listing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(items) > 12 {
|
||||||
|
ctx.Reply("…and %d more.", len(items)-12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDownloadSearch(ctx *Context, client arrclient.Client, contentType arr.ContentType) {
|
||||||
|
query := strings.TrimSpace(strings.Join(ctx.Args[2:], " "))
|
||||||
|
if query == "" {
|
||||||
|
ctx.Reply("Usage: `download search %s <query>`", contentType.Label())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results, err := client.Search(ctx.Ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply("Failed to search %s: %v", contentType.Label(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
replyWithSearchResults(ctx, contentType, query, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDownloadAdd(ctx *Context, client arrclient.Client, contentType arr.ContentType) {
|
||||||
|
query := strings.TrimSpace(strings.Join(ctx.Args[2:], " "))
|
||||||
|
if query == "" {
|
||||||
|
ctx.Reply("Usage: `download add %s <query>`", contentType.Label())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results, err := client.Search(ctx.Ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply("Failed to search %s: %v", contentType.Label(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := arrclient.PickSingleResult(results, query)
|
||||||
|
if err != nil {
|
||||||
|
replyWithSearchResults(ctx, contentType, query, results)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := client.Add(ctx.Ctx, result)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply("Failed to add %s: %v", contentType.Label(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Reply("Added %s to %s with id `%d`.", formatManagedItem(*item), contentType.Label(), item.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDownloadMonitor(ctx *Context, client arrclient.Client, contentType arr.ContentType) {
|
||||||
|
if len(ctx.Args) < 4 {
|
||||||
|
ctx.Reply("Usage: `download monitor %s <id> <on|off>`", contentType.Label())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
itemID, err := strconv.ParseInt(ctx.Args[2], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply("Invalid %s id `%s`.", contentType.Label(), ctx.Args[2])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := parseEnabled(ctx.Args[3])
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := client.SetMonitored(ctx.Ctx, itemID, state)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply("Failed to update %s monitoring: %v", contentType.Label(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Reply("%s is now monitored=%t.", formatManagedItem(*item), item.Monitored)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDownloadRemove(ctx *Context, client arrclient.Client, contentType arr.ContentType) {
|
||||||
|
if len(ctx.Args) < 3 {
|
||||||
|
ctx.Reply("Usage: `download remove %s <id>`", contentType.Label())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
itemID, err := strconv.ParseInt(ctx.Args[2], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply("Invalid %s id `%s`.", contentType.Label(), ctx.Args[2])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = client.Delete(ctx.Ctx, itemID); err != nil {
|
||||||
|
ctx.Reply("Failed to remove %s: %v", contentType.Label(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Reply("Removed `%d` from %s.", itemID, contentType.Label())
|
||||||
|
}
|
||||||
|
|
||||||
|
func contentClient(ctx *Context, contentType arr.ContentType) (arrclient.Client, bool) {
|
||||||
|
provider, ok := ctx.Bridge.Network.(commandServiceProvider)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return provider.ContentClient(contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func contentSubscriptions(ctx *Context) *subscriptions.Repository {
|
||||||
|
provider, ok := ctx.Bridge.Network.(commandServiceProvider)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return provider.Subscriptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
func replyWithSearchResults(ctx *Context, contentType arr.ContentType, query string, results []arrclient.SearchResult) {
|
||||||
|
if len(results) == 0 {
|
||||||
|
ctx.Reply("No %s matched `%s`.", contentType.Label(), query)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder strings.Builder
|
||||||
|
builder.WriteString(fmt.Sprintf("Search results for `%s` in %s:\n", query, contentType.Label()))
|
||||||
|
for i, result := range results {
|
||||||
|
if i == 8 {
|
||||||
|
builder.WriteString("…\n")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
builder.WriteString(fmt.Sprintf("- `%d` %s\n", result.LookupID, arrclient.FormatSearchResult(result)))
|
||||||
|
}
|
||||||
|
builder.WriteString(fmt.Sprintf("\nRefine the query and rerun `download add %s <query>` until only one match remains.", contentType.Label()))
|
||||||
|
ctx.Reply(builder.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatManagedItem(item arrclient.ManagedItem) string {
|
||||||
|
return arrclient.FormatManagedItem(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEnabled(value string) (bool, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "on", "true", "yes", "enabled":
|
||||||
|
return true, nil
|
||||||
|
case "off", "false", "no", "disabled":
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("expected `on` or `off`, got `%s`", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func userIDString(userID id.UserID) string {
|
||||||
|
return userID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func replyWithManagedItem(ctx *Context, client arrclient.Client, item arrclient.ManagedItem) error {
|
||||||
|
details := formatDownloadListCaption(item)
|
||||||
|
if item.ImageURL != "" {
|
||||||
|
asset, err := client.FetchImage(ctx.Ctx, item)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Log.Err(err).Int64("item_id", item.ID).Msg("Failed to fetch poster for Matrix listing")
|
||||||
|
} else if asset != nil {
|
||||||
|
if err := ctx.SendImage(asset, details); err != nil {
|
||||||
|
ctx.Log.Err(err).Int64("item_id", item.ID).Msg("Failed to upload poster for Matrix listing")
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ctx.Log.Debug().Int64("item_id", item.ID).Msg("Poster was empty for Matrix listing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.Reply(details)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDownloadListCaption(item arrclient.ManagedItem) string {
|
||||||
|
return fmt.Sprintf("%s %s", monitoredIcon(item.Monitored), arrclient.FormatManagedItem(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDownloadListFallbackCard(item arrclient.ManagedItem) string {
|
||||||
|
return formatDownloadListCaption(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func monitoredIcon(monitored bool) string {
|
||||||
|
if monitored {
|
||||||
|
return "👁"
|
||||||
|
}
|
||||||
|
return "🚫"
|
||||||
|
}
|
||||||
44
packages/arrtrix/pkg/matrixcmd/download_test.go
Normal file
44
packages/arrtrix/pkg/matrixcmd/download_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package matrixcmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneeuwvlok/packages/arrtrix/pkg/arrclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatDownloadListFallbackCardUsesMonitoredIcon(t *testing.T) {
|
||||||
|
item := arrclient.ManagedItem{
|
||||||
|
ID: 1,
|
||||||
|
Title: "Severance",
|
||||||
|
Year: 2022,
|
||||||
|
Monitored: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
fallback := formatDownloadListFallbackCard(item)
|
||||||
|
if fallback != "👁 Severance (2022)" {
|
||||||
|
t.Fatalf("unexpected monitored fallback %q", fallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatDownloadListFallbackCardUsesUnmonitoredIcon(t *testing.T) {
|
||||||
|
item := arrclient.ManagedItem{
|
||||||
|
ID: 7,
|
||||||
|
Title: "Andor",
|
||||||
|
Year: 2022,
|
||||||
|
Monitored: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
fallback := formatDownloadListFallbackCard(item)
|
||||||
|
if fallback != "🚫 Andor (2022)" {
|
||||||
|
t.Fatalf("unexpected unmonitored fallback %q", fallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonitoredIcon(t *testing.T) {
|
||||||
|
if monitoredIcon(true) != "👁" {
|
||||||
|
t.Fatalf("expected monitored icon, got %q", monitoredIcon(true))
|
||||||
|
}
|
||||||
|
if monitoredIcon(false) != "🚫" {
|
||||||
|
t.Fatalf("expected unmonitored icon, got %q", monitoredIcon(false))
|
||||||
|
}
|
||||||
|
}
|
||||||
60
packages/arrtrix/pkg/matrixcmd/help.go
Normal file
60
packages/arrtrix/pkg/matrixcmd/help.go
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
package matrixcmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewHelpHandler(proc *Processor) Handler {
|
||||||
|
return NewHandler(Meta{
|
||||||
|
Name: "help",
|
||||||
|
Description: "Show this help message.",
|
||||||
|
}, func(ctx *Context) {
|
||||||
|
ctx.Reply(formatHelp(proc, ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatHelp(proc *Processor, ctx *Context) string {
|
||||||
|
var builder strings.Builder
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case ctx.RoomID == ctx.User.ManagementRoom:
|
||||||
|
builder.WriteString(fmt.Sprintf("This is your management room: prefixing commands with `%s` is not required.\n", ctx.Bridge.Config.CommandPrefix))
|
||||||
|
case ctx.Portal != nil:
|
||||||
|
builder.WriteString(fmt.Sprintf("**This is a portal room**: you must always prefix commands with `%s`. Management commands will not be bridged.\n", ctx.Bridge.Config.CommandPrefix))
|
||||||
|
default:
|
||||||
|
builder.WriteString(fmt.Sprintf("This is not your management room: prefixing commands with `%s` is required.\n", ctx.Bridge.Config.CommandPrefix))
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.WriteString("Parameters in [square brackets] are optional, while parameters in <angle brackets> are required.\n\n")
|
||||||
|
builder.WriteString("#### General\n")
|
||||||
|
|
||||||
|
handlers := proc.Handlers()
|
||||||
|
sort.SliceStable(handlers, func(i, j int) bool {
|
||||||
|
return handlers[i].Meta().Name < handlers[j].Meta().Name
|
||||||
|
})
|
||||||
|
for _, handler := range handlers {
|
||||||
|
meta := handler.Meta()
|
||||||
|
builder.WriteString("**")
|
||||||
|
builder.WriteString(meta.Name)
|
||||||
|
builder.WriteString("**")
|
||||||
|
if meta.Usage != "" {
|
||||||
|
builder.WriteByte(' ')
|
||||||
|
builder.WriteString(meta.Usage)
|
||||||
|
}
|
||||||
|
if meta.Description != "" {
|
||||||
|
builder.WriteString(" - ")
|
||||||
|
builder.WriteString(meta.Description)
|
||||||
|
}
|
||||||
|
builder.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
if extra := strings.TrimSpace(ctx.Processor.texts.AdditionalHelp); extra != "" {
|
||||||
|
builder.WriteByte('\n')
|
||||||
|
builder.WriteString(extra)
|
||||||
|
builder.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
46
packages/arrtrix/pkg/matrixcmd/help_test.go
Normal file
46
packages/arrtrix/pkg/matrixcmd/help_test.go
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
package matrixcmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/bridgev2"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/database"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatHelpManagementRoom(t *testing.T) {
|
||||||
|
roomID := id.RoomID("!arrtrix:test")
|
||||||
|
proc := &Processor{
|
||||||
|
texts: bridgeconfig.ManagementRoomTexts{AdditionalHelp: "Extra help text."},
|
||||||
|
command: make(map[string]Handler),
|
||||||
|
alias: make(map[string]string),
|
||||||
|
}
|
||||||
|
proc.Add(NewHelpHandler(proc))
|
||||||
|
proc.Add(NewDownloadHandler())
|
||||||
|
proc.Add(NewSubscriptionsHandler())
|
||||||
|
|
||||||
|
out := formatHelp(proc, &Context{
|
||||||
|
Bridge: &bridgev2.Bridge{
|
||||||
|
Config: &bridgeconfig.BridgeConfig{
|
||||||
|
CommandPrefix: "!arr",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RoomID: roomID,
|
||||||
|
User: &bridgev2.User{User: &database.User{ManagementRoom: roomID}},
|
||||||
|
Processor: proc,
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, fragment := range []string{
|
||||||
|
"prefixing commands with `!arr` is not required",
|
||||||
|
"**download** <list|search|add|monitor|remove> <movies|series> [...] - Manage monitored movies and series in Arr.",
|
||||||
|
"**help** - Show this help message.",
|
||||||
|
"**subscriptions** <list|enable|disable> [movies|series] [event-type|all] - Manage notification subscriptions by content type and event type.",
|
||||||
|
"Extra help text.",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, fragment) {
|
||||||
|
t.Fatalf("expected help output to contain %q, got:\n%s", fragment, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
270
packages/arrtrix/pkg/matrixcmd/processor.go
Normal file
270
packages/arrtrix/pkg/matrixcmd/processor.go
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
package matrixcmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"runtime/debug"
|
||||||
|
"sort"
|
||||||
|
"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/bridgev2/status"
|
||||||
|
"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 {
|
||||||
|
Meta() Meta
|
||||||
|
Run(*Context)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Meta struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Usage string
|
||||||
|
Aliases []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type HandlerFunc struct {
|
||||||
|
meta Meta
|
||||||
|
run func(*Context)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(meta Meta, run func(*Context)) Handler {
|
||||||
|
return HandlerFunc{meta: meta, run: run}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h HandlerFunc) Meta() Meta {
|
||||||
|
return h.meta
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h HandlerFunc) Run(ctx *Context) {
|
||||||
|
h.run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Processor struct {
|
||||||
|
bridge *bridgev2.Bridge
|
||||||
|
bot bridgev2.MatrixAPI
|
||||||
|
texts bridgeconfig.ManagementRoomTexts
|
||||||
|
command map[string]Handler
|
||||||
|
alias map[string]string
|
||||||
|
order []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Context struct {
|
||||||
|
Bridge *bridgev2.Bridge
|
||||||
|
Bot bridgev2.MatrixAPI
|
||||||
|
RoomID id.RoomID
|
||||||
|
OrigRoomID id.RoomID
|
||||||
|
EventID id.EventID
|
||||||
|
ReplyTo id.EventID
|
||||||
|
User *bridgev2.User
|
||||||
|
Portal *bridgev2.Portal
|
||||||
|
Command string
|
||||||
|
Args []string
|
||||||
|
RawArgs string
|
||||||
|
Ctx context.Context
|
||||||
|
Log *zerolog.Logger
|
||||||
|
Processor *Processor
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ bridgev2.CommandProcessor = (*Processor)(nil)
|
||||||
|
|
||||||
|
func NewProcessor(bridge *bridgev2.Bridge, texts bridgeconfig.ManagementRoomTexts) *Processor {
|
||||||
|
proc := &Processor{
|
||||||
|
bridge: bridge,
|
||||||
|
bot: bridge.Bot,
|
||||||
|
texts: texts,
|
||||||
|
command: make(map[string]Handler),
|
||||||
|
alias: make(map[string]string),
|
||||||
|
}
|
||||||
|
proc.Add(NewHelpHandler(proc))
|
||||||
|
proc.Add(NewDownloadHandler())
|
||||||
|
proc.Add(NewSubscriptionsHandler())
|
||||||
|
return proc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Processor) Add(handler Handler) {
|
||||||
|
meta := handler.Meta()
|
||||||
|
p.command[meta.Name] = handler
|
||||||
|
p.order = append(p.order, meta.Name)
|
||||||
|
for _, alias := range meta.Aliases {
|
||||||
|
p.alias[alias] = meta.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Processor) Handlers() []Handler {
|
||||||
|
names := append([]string(nil), p.order...)
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
handlers := make([]Handler, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
handler, ok := p.command[name]
|
||||||
|
if ok {
|
||||||
|
handlers = append(handlers, handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return handlers
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
logCopy := zerolog.Ctx(ctx).With().Logger()
|
||||||
|
log := &logCopy
|
||||||
|
outcome := "success"
|
||||||
|
commandName := "unknown-command"
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
statusInfo := &bridgev2.MessageStatusEventInfo{
|
||||||
|
RoomID: roomID,
|
||||||
|
SourceEventID: eventID,
|
||||||
|
EventType: event.EventMessage,
|
||||||
|
Sender: user.MXID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
logEvt := log.Error().Bytes(zerolog.ErrorStackFieldName, debug.Stack())
|
||||||
|
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)
|
||||||
|
}()
|
||||||
|
|
||||||
|
args := strings.Fields(message)
|
||||||
|
if len(args) == 0 {
|
||||||
|
args = []string{"unknown-command"}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
log.Err(err).Msg("Failed to get portal")
|
||||||
|
}
|
||||||
|
|
||||||
|
commandCtx := &Context{
|
||||||
|
Bridge: p.bridge,
|
||||||
|
Bot: p.bot,
|
||||||
|
RoomID: roomID,
|
||||||
|
OrigRoomID: roomID,
|
||||||
|
EventID: eventID,
|
||||||
|
ReplyTo: replyTo,
|
||||||
|
User: user,
|
||||||
|
Portal: portal,
|
||||||
|
Command: commandName,
|
||||||
|
Args: args[1:],
|
||||||
|
RawArgs: strings.TrimSpace(strings.TrimPrefix(message, args[0])),
|
||||||
|
Ctx: ctx,
|
||||||
|
Log: log,
|
||||||
|
Processor: p,
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
log.UpdateContext(func(c zerolog.Context) zerolog.Context {
|
||||||
|
return c.Str("mx_command", commandName)
|
||||||
|
})
|
||||||
|
log.Debug().Msg("Received Matrix room command")
|
||||||
|
handler.Run(commandCtx)
|
||||||
|
span.SetStatus(codes.Ok, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Context) Reply(message string, args ...any) {
|
||||||
|
message = strings.ReplaceAll(message, "$cmdprefix ", c.Bridge.Config.CommandPrefix+" ")
|
||||||
|
if len(args) > 0 {
|
||||||
|
message = fmt.Sprintf(message, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := format.RenderMarkdown(message, true, false)
|
||||||
|
content.MsgType = event.MsgNotice
|
||||||
|
if err := c.sendNotice(&content); 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
|
||||||
|
}
|
||||||
107
packages/arrtrix/pkg/matrixcmd/subscriptions.go
Normal file
107
packages/arrtrix/pkg/matrixcmd/subscriptions.go
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
package matrixcmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
|
"sneeuwvlok/packages/arrtrix/pkg/arr"
|
||||||
|
"sneeuwvlok/packages/arrtrix/pkg/subscriptions"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewSubscriptionsHandler() Handler {
|
||||||
|
return NewHandler(Meta{
|
||||||
|
Name: "subscriptions",
|
||||||
|
Aliases: []string{"subscription", "notify"},
|
||||||
|
Description: "Manage notification subscriptions by content type and event type.",
|
||||||
|
Usage: "<list|enable|disable> [movies|series] [event-type|all]",
|
||||||
|
}, func(ctx *Context) {
|
||||||
|
repo := contentSubscriptions(ctx)
|
||||||
|
if repo == nil {
|
||||||
|
ctx.Reply("Subscription storage is not available.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(ctx.Args) == 0 || strings.EqualFold(ctx.Args[0], "list") {
|
||||||
|
handleSubscriptionList(ctx, repo)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(ctx.Args) < 3 {
|
||||||
|
ctx.Reply("Usage: `subscriptions <enable|disable> <movies|series> <event-type|all>`")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType, err := arr.ParseContentType(ctx.Args[1])
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
eventType, err := arr.ParseEventType(contentType, ctx.Args[2])
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(ctx.Args[0]) {
|
||||||
|
case "enable":
|
||||||
|
handleSubscriptionSet(ctx, repo, contentType, eventType, true)
|
||||||
|
case "disable":
|
||||||
|
handleSubscriptionSet(ctx, repo, contentType, eventType, false)
|
||||||
|
default:
|
||||||
|
ctx.Reply("Unknown subscriptions subcommand `%s`.", ctx.Args[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSubscriptionList(ctx *Context, repo subscriptionRepo) {
|
||||||
|
preferences, err := repo.List(ctx.Ctx, ctx.User.MXID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply("Failed to load subscriptions: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder strings.Builder
|
||||||
|
builder.WriteString("Current notification subscriptions:\n")
|
||||||
|
for _, contentType := range arr.SupportedContentTypes() {
|
||||||
|
builder.WriteString(fmt.Sprintf("\n**%s**\n", strings.Title(contentType.Label())))
|
||||||
|
for _, eventType := range arr.SupportedEventTypes(contentType) {
|
||||||
|
enabled := findPreference(preferences, contentType, eventType)
|
||||||
|
builder.WriteString(fmt.Sprintf("- `%s`: %t\n", eventType, enabled))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.Reply(builder.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSubscriptionSet(ctx *Context, repo subscriptionRepo, contentType arr.ContentType, eventType string, enabled bool) {
|
||||||
|
var err error
|
||||||
|
if eventType == "all" {
|
||||||
|
err = repo.SetAll(ctx.Ctx, ctx.User.MXID, contentType, enabled)
|
||||||
|
} else {
|
||||||
|
err = repo.Set(ctx.Ctx, ctx.User.MXID, contentType, eventType, enabled)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply("Failed to update subscriptions: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if eventType == "all" {
|
||||||
|
ctx.Reply("Set all `%s` notifications for %s to %t.", contentType.Label(), userIDString(ctx.User.MXID), enabled)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Reply("Set `%s/%s` notifications to %t.", contentType.Label(), eventType, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
type subscriptionRepo interface {
|
||||||
|
List(ctx context.Context, userID id.UserID) ([]subscriptions.Preference, error)
|
||||||
|
Set(ctx context.Context, userID id.UserID, contentType arr.ContentType, eventType string, enabled bool) error
|
||||||
|
SetAll(ctx context.Context, userID id.UserID, contentType arr.ContentType, enabled bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func findPreference(preferences []subscriptions.Preference, contentType arr.ContentType, eventType string) bool {
|
||||||
|
for _, preference := range preferences {
|
||||||
|
if preference.ContentType == contentType && preference.EventType == eventType {
|
||||||
|
return preference.Enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
22
packages/arrtrix/pkg/observability/config.go
Normal file
22
packages/arrtrix/pkg/observability/config.go
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
package observability
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
OTLPGRPCEndpoint string `yaml:"otlp_grpc_endpoint"`
|
||||||
|
ServiceName string `yaml:"service_name"`
|
||||||
|
ResourceAttributes map[string]string `yaml:"resource_attributes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) ApplyDefaults() {
|
||||||
|
if c.ServiceName == "" {
|
||||||
|
c.ServiceName = "arrtrix"
|
||||||
|
}
|
||||||
|
if c.ResourceAttributes == nil {
|
||||||
|
c.ResourceAttributes = map[string]string{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) Enabled() bool {
|
||||||
|
return strings.TrimSpace(c.OTLPGRPCEndpoint) != ""
|
||||||
|
}
|
||||||
397
packages/arrtrix/pkg/observability/otel.go
Normal file
397
packages/arrtrix/pkg/observability/otel.go
Normal file
|
|
@ -0,0 +1,397 @@
|
||||||
|
package observability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
|
||||||
|
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
|
||||||
|
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||||
|
otellog "go.opentelemetry.io/otel/log"
|
||||||
|
logglobal "go.opentelemetry.io/otel/log/global"
|
||||||
|
otelmetric "go.opentelemetry.io/otel/metric"
|
||||||
|
sdklog "go.opentelemetry.io/otel/sdk/log"
|
||||||
|
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||||
|
"go.opentelemetry.io/otel/sdk/resource"
|
||||||
|
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
instrumentationScope = "sneeuwvlok/packages/arrtrix"
|
||||||
|
logScope = instrumentationScope + "/logs"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Runtime struct {
|
||||||
|
traceProvider *sdktrace.TracerProvider
|
||||||
|
meterProvider *sdkmetric.MeterProvider
|
||||||
|
logProvider *sdklog.LoggerProvider
|
||||||
|
logHook zerolog.Hook
|
||||||
|
}
|
||||||
|
|
||||||
|
type exporterEndpoint struct {
|
||||||
|
raw string
|
||||||
|
insecure bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type instruments struct {
|
||||||
|
webhookRequests otelCounter
|
||||||
|
webhookLatency otelHistogram
|
||||||
|
commandInvocations otelCounter
|
||||||
|
inviteEvents otelCounter
|
||||||
|
startupDuration otelHistogram
|
||||||
|
}
|
||||||
|
|
||||||
|
type otelCounter interface {
|
||||||
|
Add(context.Context, int64, ...otelmetric.AddOption)
|
||||||
|
}
|
||||||
|
|
||||||
|
type otelHistogram interface {
|
||||||
|
Record(context.Context, float64, ...otelmetric.RecordOption)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.RWMutex
|
||||||
|
current instruments
|
||||||
|
tracer = otel.Tracer(instrumentationScope)
|
||||||
|
currentReady bool
|
||||||
|
)
|
||||||
|
|
||||||
|
func Setup(ctx context.Context, cfg Config, version string) (*Runtime, error) {
|
||||||
|
cfg.ApplyDefaults()
|
||||||
|
if !cfg.Enabled() {
|
||||||
|
resetInstruments()
|
||||||
|
return &Runtime{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := buildResource(cfg, version)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
endpoint, err := parseEndpoint(cfg.OTLPGRPCEndpoint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
traceExporter, err := otlptracegrpc.New(ctx, traceOptions(endpoint)...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create trace exporter: %w", err)
|
||||||
|
}
|
||||||
|
metricExporter, err := otlpmetricgrpc.New(ctx, metricOptions(endpoint)...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create metric exporter: %w", err)
|
||||||
|
}
|
||||||
|
logExporter, err := otlploggrpc.New(ctx, logOptions(endpoint)...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create log exporter: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
traceProvider := sdktrace.NewTracerProvider(
|
||||||
|
sdktrace.WithResource(res),
|
||||||
|
sdktrace.WithBatcher(traceExporter),
|
||||||
|
)
|
||||||
|
meterProvider := sdkmetric.NewMeterProvider(
|
||||||
|
sdkmetric.WithResource(res),
|
||||||
|
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExporter, sdkmetric.WithInterval(30*time.Second))),
|
||||||
|
)
|
||||||
|
logProvider := sdklog.NewLoggerProvider(
|
||||||
|
sdklog.WithResource(res),
|
||||||
|
sdklog.WithProcessor(sdklog.NewBatchProcessor(logExporter)),
|
||||||
|
)
|
||||||
|
|
||||||
|
otel.SetTracerProvider(traceProvider)
|
||||||
|
otel.SetMeterProvider(meterProvider)
|
||||||
|
logglobal.SetLoggerProvider(logProvider)
|
||||||
|
|
||||||
|
if err = setInstruments(meterProvider); err != nil {
|
||||||
|
_ = traceProvider.Shutdown(ctx)
|
||||||
|
_ = meterProvider.Shutdown(ctx)
|
||||||
|
_ = logProvider.Shutdown(ctx)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tracer = otel.Tracer(instrumentationScope)
|
||||||
|
return &Runtime{
|
||||||
|
traceProvider: traceProvider,
|
||||||
|
meterProvider: meterProvider,
|
||||||
|
logProvider: logProvider,
|
||||||
|
logHook: newLogHook(logglobal.Logger(logScope)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runtime) Enabled() bool {
|
||||||
|
return r != nil && r.traceProvider != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runtime) LoggerHook() zerolog.Hook {
|
||||||
|
if r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r.logHook
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runtime) Shutdown(ctx context.Context) error {
|
||||||
|
if r == nil || !r.Enabled() {
|
||||||
|
resetInstruments()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
if err := r.logProvider.Shutdown(ctx); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("shutdown log provider: %w", err))
|
||||||
|
}
|
||||||
|
if err := r.meterProvider.Shutdown(ctx); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("shutdown meter provider: %w", err))
|
||||||
|
}
|
||||||
|
if err := r.traceProvider.Shutdown(ctx); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("shutdown trace provider: %w", err))
|
||||||
|
}
|
||||||
|
resetInstruments()
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartSpan(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
|
||||||
|
return tracer.Start(ctx, name, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RecordWebhook(ctx context.Context, eventType, outcome string, statusCode int, duration time.Duration) {
|
||||||
|
mu.RLock()
|
||||||
|
inst := current
|
||||||
|
ready := currentReady
|
||||||
|
mu.RUnlock()
|
||||||
|
if !ready {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
attrs := otelmetric.WithAttributes(
|
||||||
|
attribute.String("event_type", eventType),
|
||||||
|
attribute.String("outcome", outcome),
|
||||||
|
attribute.Int("http.status_code", statusCode),
|
||||||
|
)
|
||||||
|
inst.webhookRequests.Add(ctx, 1, attrs)
|
||||||
|
inst.webhookLatency.Record(ctx, duration.Seconds(), attrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RecordCommand(ctx context.Context, name, outcome string) {
|
||||||
|
mu.RLock()
|
||||||
|
inst := current
|
||||||
|
ready := currentReady
|
||||||
|
mu.RUnlock()
|
||||||
|
if !ready {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inst.commandInvocations.Add(ctx, 1, otelmetric.WithAttributes(
|
||||||
|
attribute.String("command", name),
|
||||||
|
attribute.String("outcome", outcome),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func RecordInvite(ctx context.Context, outcome string) {
|
||||||
|
mu.RLock()
|
||||||
|
inst := current
|
||||||
|
ready := currentReady
|
||||||
|
mu.RUnlock()
|
||||||
|
if !ready {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inst.inviteEvents.Add(ctx, 1, otelmetric.WithAttributes(attribute.String("outcome", outcome)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func RecordStartupPhase(ctx context.Context, phase, outcome string, duration time.Duration) {
|
||||||
|
mu.RLock()
|
||||||
|
inst := current
|
||||||
|
ready := currentReady
|
||||||
|
mu.RUnlock()
|
||||||
|
if !ready {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inst.startupDuration.Record(ctx, duration.Seconds(), otelmetric.WithAttributes(
|
||||||
|
attribute.String("phase", phase),
|
||||||
|
attribute.String("outcome", outcome),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEndpoint(raw string) (exporterEndpoint, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return exporterEndpoint{}, errors.New("observability.otlp_grpc_endpoint must not be empty when observability is enabled")
|
||||||
|
}
|
||||||
|
if strings.Contains(raw, "://") {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return exporterEndpoint{}, fmt.Errorf("parse observability.otlp_grpc_endpoint: %w", err)
|
||||||
|
}
|
||||||
|
if u.Scheme == "" || u.Host == "" {
|
||||||
|
return exporterEndpoint{}, fmt.Errorf("invalid observability.otlp_grpc_endpoint %q", raw)
|
||||||
|
}
|
||||||
|
return exporterEndpoint{raw: raw, insecure: u.Scheme == "http"}, nil
|
||||||
|
}
|
||||||
|
return exporterEndpoint{raw: "http://" + raw, insecure: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildResource(cfg Config, version string) (*resource.Resource, error) {
|
||||||
|
attrs := []attribute.KeyValue{
|
||||||
|
attribute.String("service.name", cfg.ServiceName),
|
||||||
|
}
|
||||||
|
if version != "" {
|
||||||
|
attrs = append(attrs, attribute.String("service.version", version))
|
||||||
|
}
|
||||||
|
for key, value := range cfg.ResourceAttributes {
|
||||||
|
attrs = append(attrs, attribute.String(key, value))
|
||||||
|
}
|
||||||
|
return resource.Merge(resource.Default(), resource.NewWithAttributes("", attrs...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func setInstruments(provider *sdkmetric.MeterProvider) error {
|
||||||
|
meter := provider.Meter(instrumentationScope)
|
||||||
|
|
||||||
|
webhookRequests, err := meter.Int64Counter(
|
||||||
|
"arrtrix.webhook.requests",
|
||||||
|
otelmetric.WithDescription("Number of Arr webhook requests handled by arrtrix."),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create webhook request counter: %w", err)
|
||||||
|
}
|
||||||
|
webhookLatency, err := meter.Float64Histogram(
|
||||||
|
"arrtrix.webhook.duration.seconds",
|
||||||
|
otelmetric.WithDescription("Duration of Arr webhook request handling."),
|
||||||
|
otelmetric.WithUnit("s"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create webhook duration histogram: %w", err)
|
||||||
|
}
|
||||||
|
commandInvocations, err := meter.Int64Counter(
|
||||||
|
"arrtrix.matrix.commands",
|
||||||
|
otelmetric.WithDescription("Number of Matrix management-room commands handled by arrtrix."),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create command counter: %w", err)
|
||||||
|
}
|
||||||
|
inviteEvents, err := meter.Int64Counter(
|
||||||
|
"arrtrix.matrix.invites",
|
||||||
|
otelmetric.WithDescription("Number of management-room invite flows observed by arrtrix."),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create invite counter: %w", err)
|
||||||
|
}
|
||||||
|
startupDuration, err := meter.Float64Histogram(
|
||||||
|
"arrtrix.runtime.phase.duration.seconds",
|
||||||
|
otelmetric.WithDescription("Duration of arrtrix runtime startup and shutdown phases."),
|
||||||
|
otelmetric.WithUnit("s"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create runtime duration histogram: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
current = instruments{
|
||||||
|
webhookRequests: webhookRequests,
|
||||||
|
webhookLatency: webhookLatency,
|
||||||
|
commandInvocations: commandInvocations,
|
||||||
|
inviteEvents: inviteEvents,
|
||||||
|
startupDuration: startupDuration,
|
||||||
|
}
|
||||||
|
currentReady = true
|
||||||
|
mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetInstruments() {
|
||||||
|
mu.Lock()
|
||||||
|
current = instruments{}
|
||||||
|
currentReady = false
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func traceOptions(endpoint exporterEndpoint) []otlptracegrpc.Option {
|
||||||
|
opts := []otlptracegrpc.Option{otlptracegrpc.WithEndpointURL(endpoint.raw)}
|
||||||
|
if endpoint.insecure {
|
||||||
|
opts = append(opts, otlptracegrpc.WithInsecure())
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
func metricOptions(endpoint exporterEndpoint) []otlpmetricgrpc.Option {
|
||||||
|
opts := []otlpmetricgrpc.Option{otlpmetricgrpc.WithEndpointURL(endpoint.raw)}
|
||||||
|
if endpoint.insecure {
|
||||||
|
opts = append(opts, otlpmetricgrpc.WithInsecure())
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
func logOptions(endpoint exporterEndpoint) []otlploggrpc.Option {
|
||||||
|
opts := []otlploggrpc.Option{otlploggrpc.WithEndpointURL(endpoint.raw)}
|
||||||
|
if endpoint.insecure {
|
||||||
|
opts = append(opts, otlploggrpc.WithInsecure())
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
type otelLogHook struct {
|
||||||
|
logger otellog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLogHook(logger otellog.Logger) zerolog.Hook {
|
||||||
|
return otelLogHook{logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h otelLogHook) Run(e *zerolog.Event, level zerolog.Level, message string) {
|
||||||
|
if h.logger == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := e.GetCtx()
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
severity := mapSeverity(level)
|
||||||
|
if !h.logger.Enabled(ctx, otellog.EnabledParameters{Severity: severity}) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
record := otellog.Record{}
|
||||||
|
record.SetTimestamp(now)
|
||||||
|
record.SetObservedTimestamp(now)
|
||||||
|
record.SetSeverity(severity)
|
||||||
|
record.SetSeverityText(strings.ToUpper(level.String()))
|
||||||
|
record.SetBody(otellog.StringValue(message))
|
||||||
|
record.AddAttributes(otellog.String("log.scope", logScope))
|
||||||
|
|
||||||
|
if spanCtx := trace.SpanContextFromContext(ctx); spanCtx.IsValid() {
|
||||||
|
record.AddAttributes(
|
||||||
|
otellog.String("trace_id", spanCtx.TraceID().String()),
|
||||||
|
otellog.String("span_id", spanCtx.SpanID().String()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Emit(ctx, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapSeverity(level zerolog.Level) otellog.Severity {
|
||||||
|
switch level {
|
||||||
|
case zerolog.TraceLevel:
|
||||||
|
return otellog.SeverityTrace
|
||||||
|
case zerolog.DebugLevel:
|
||||||
|
return otellog.SeverityDebug
|
||||||
|
case zerolog.InfoLevel:
|
||||||
|
return otellog.SeverityInfo
|
||||||
|
case zerolog.WarnLevel:
|
||||||
|
return otellog.SeverityWarn
|
||||||
|
case zerolog.ErrorLevel:
|
||||||
|
return otellog.SeverityError
|
||||||
|
case zerolog.FatalLevel:
|
||||||
|
return otellog.SeverityFatal
|
||||||
|
case zerolog.PanicLevel:
|
||||||
|
return otellog.SeverityFatal4
|
||||||
|
default:
|
||||||
|
return otellog.SeverityUndefined
|
||||||
|
}
|
||||||
|
}
|
||||||
54
packages/arrtrix/pkg/observability/otel_test.go
Normal file
54
packages/arrtrix/pkg/observability/otel_test.go
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
package observability
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestConfigDefaults(t *testing.T) {
|
||||||
|
var cfg Config
|
||||||
|
cfg.ApplyDefaults()
|
||||||
|
|
||||||
|
if cfg.ServiceName != "arrtrix" {
|
||||||
|
t.Fatalf("expected default service name arrtrix, got %q", cfg.ServiceName)
|
||||||
|
}
|
||||||
|
if cfg.ResourceAttributes == nil {
|
||||||
|
t.Fatal("expected resource attributes map to be initialized")
|
||||||
|
}
|
||||||
|
if cfg.Enabled() {
|
||||||
|
t.Fatal("expected observability to be disabled by default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseEndpointSupportsURLAndBareHost(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantRaw string
|
||||||
|
insecure bool
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{name: "https url", input: "https://otel.example:4317", wantRaw: "https://otel.example:4317"},
|
||||||
|
{name: "http url", input: "http://127.0.0.1:4317", wantRaw: "http://127.0.0.1:4317", insecure: true},
|
||||||
|
{name: "bare host", input: "collector:4317", wantRaw: "http://collector:4317", insecure: true},
|
||||||
|
{name: "invalid", input: "://bad", wantError: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := parseEndpoint(tt.input)
|
||||||
|
if tt.wantError {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseEndpoint returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got.raw != tt.wantRaw {
|
||||||
|
t.Fatalf("expected raw endpoint %q, got %q", tt.wantRaw, got.raw)
|
||||||
|
}
|
||||||
|
if got.insecure != tt.insecure {
|
||||||
|
t.Fatalf("expected insecure=%t, got %t", tt.insecure, got.insecure)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
171
packages/arrtrix/pkg/onboarding/welcome.go
Normal file
171
packages/arrtrix/pkg/onboarding/welcome.go
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
package onboarding
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"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"
|
||||||
|
|
||||||
|
func HandleBotInvite(ctx context.Context, bridge *bridgev2.Bridge, texts bridgeconfig.ManagementRoomTexts, evt *event.Event) {
|
||||||
|
if evt.Type != event.StateMember ||
|
||||||
|
evt.GetStateKey() != bridge.Bot.GetMXID().String() ||
|
||||||
|
evt.Content.AsMember().Membership != event.MembershipInvite {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
assignedManagementRoom := sender.ManagementRoom == ""
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWelcomeMessage(bridge *bridgev2.Bridge, texts bridgeconfig.ManagementRoomTexts, sender *bridgev2.User, assignedManagementRoom bool) string {
|
||||||
|
return composeWelcomeMessage(
|
||||||
|
bridge.Network.GetName().DisplayName,
|
||||||
|
bridge.Config.CommandPrefix,
|
||||||
|
bridge.Bot.GetMXID(),
|
||||||
|
texts,
|
||||||
|
sender.GetDefaultLogin() != nil,
|
||||||
|
assignedManagementRoom,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func composeWelcomeMessage(
|
||||||
|
bridgeName string,
|
||||||
|
commandPrefix string,
|
||||||
|
botMXID id.UserID,
|
||||||
|
texts bridgeconfig.ManagementRoomTexts,
|
||||||
|
connected bool,
|
||||||
|
assignedManagementRoom bool,
|
||||||
|
) string {
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
"$cmdprefix", commandPrefix,
|
||||||
|
"$bridge", bridgeName,
|
||||||
|
"$bot", string(botMXID),
|
||||||
|
)
|
||||||
|
|
||||||
|
var parts []string
|
||||||
|
|
||||||
|
base := strings.TrimSpace(texts.Welcome)
|
||||||
|
if base == "" {
|
||||||
|
base = fmt.Sprintf("Hello, I'm the %s bot.", bridgeName)
|
||||||
|
}
|
||||||
|
parts = append(parts, replacer.Replace(base))
|
||||||
|
|
||||||
|
if assignedManagementRoom {
|
||||||
|
parts = append(parts, "This room has been marked as your management room.")
|
||||||
|
} else {
|
||||||
|
parts = append(parts, fmt.Sprintf("Use `%s help` to see available commands in this room.", commandPrefix))
|
||||||
|
}
|
||||||
|
|
||||||
|
if connected {
|
||||||
|
connected := strings.TrimSpace(texts.WelcomeConnected)
|
||||||
|
if connected == "" {
|
||||||
|
connected = "You're connected. Use `help` to see the commands available right now."
|
||||||
|
}
|
||||||
|
parts = append(parts, replacer.Replace(connected))
|
||||||
|
} else {
|
||||||
|
unconnected := strings.TrimSpace(texts.WelcomeUnconnected)
|
||||||
|
if unconnected == "" {
|
||||||
|
unconnected = "Use `help` to see the commands available right now."
|
||||||
|
}
|
||||||
|
parts = append(parts, replacer.Replace(unconnected))
|
||||||
|
}
|
||||||
|
|
||||||
|
if extra := strings.TrimSpace(texts.AdditionalHelp); extra != "" {
|
||||||
|
parts = append(parts, replacer.Replace(extra))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(parts, "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsHandledInviteEvent(evt *event.Event) bool {
|
||||||
|
return evt.Type.Type == handledInviteEventType
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsBotInviteFor(roomBot id.UserID, evt *event.Event) bool {
|
||||||
|
return evt.Type == event.StateMember &&
|
||||||
|
evt.GetStateKey() == roomBot.String() &&
|
||||||
|
evt.Content.AsMember().Membership == event.MembershipInvite
|
||||||
|
}
|
||||||
56
packages/arrtrix/pkg/onboarding/welcome_test.go
Normal file
56
packages/arrtrix/pkg/onboarding/welcome_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package onboarding
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestComposeWelcomeMessageDefaults(t *testing.T) {
|
||||||
|
out := composeWelcomeMessage(
|
||||||
|
"Arrtrix",
|
||||||
|
"!arr",
|
||||||
|
id.UserID("@arrtrixbot:test"),
|
||||||
|
bridgeconfig.ManagementRoomTexts{},
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, fragment := range []string{
|
||||||
|
"Hello, I'm the Arrtrix bot.",
|
||||||
|
"This room has been marked as your management room.",
|
||||||
|
"Use `help` to see the commands available right now.",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, fragment) {
|
||||||
|
t.Fatalf("expected welcome output to contain %q, got:\n%s", fragment, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeWelcomeMessageTemplateValues(t *testing.T) {
|
||||||
|
out := composeWelcomeMessage(
|
||||||
|
"Arrtrix",
|
||||||
|
"!arr",
|
||||||
|
id.UserID("@arrtrixbot:test"),
|
||||||
|
bridgeconfig.ManagementRoomTexts{
|
||||||
|
Welcome: "Welcome to $bridge.",
|
||||||
|
WelcomeConnected: "Talk to $bot with $cmdprefix help.",
|
||||||
|
AdditionalHelp: "Custom footer for $bridge.",
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, fragment := range []string{
|
||||||
|
"Welcome to Arrtrix.",
|
||||||
|
"Use `!arr help` to see available commands in this room.",
|
||||||
|
"Talk to @arrtrixbot:test with !arr help.",
|
||||||
|
"Custom footer for Arrtrix.",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, fragment) {
|
||||||
|
t.Fatalf("expected templated welcome output to contain %q, got:\n%s", fragment, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
256
packages/arrtrix/pkg/runtime/envconfig.go
Normal file
256
packages/arrtrix/pkg/runtime/envconfig.go
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const fileEnvPrefix = "READFILE:"
|
||||||
|
|
||||||
|
func updateConfigFromEnv(cfg, networkData any, prefix string) error {
|
||||||
|
if prefix == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgVal := reflect.ValueOf(cfg)
|
||||||
|
networkVal := reflect.ValueOf(networkData)
|
||||||
|
|
||||||
|
for _, env := range os.Environ() {
|
||||||
|
if !strings.HasPrefix(env, prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
keyValue := strings.SplitN(env, "=", 2)
|
||||||
|
if len(keyValue) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key := strings.TrimPrefix(keyValue[0], prefix)
|
||||||
|
value := keyValue[1]
|
||||||
|
if strings.HasSuffix(key, "_FILE") {
|
||||||
|
key = strings.TrimSuffix(key, "_FILE")
|
||||||
|
value = fileEnvPrefix + value
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(value, fileEnvPrefix) {
|
||||||
|
filePath := strings.TrimPrefix(value, fileEnvPrefix)
|
||||||
|
fileData, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read file %s for %s: %w", filePath, formatKey(path), err)
|
||||||
|
}
|
||||||
|
value = strings.TrimSpace(string(fileData))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := setReflectedValue(field, path, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type reflectedField struct {
|
||||||
|
value reflect.Value
|
||||||
|
valueKind reflect.Kind
|
||||||
|
remainingPath []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatKey(path []string) string {
|
||||||
|
return strings.Join(path, "->")
|
||||||
|
}
|
||||||
|
|
||||||
|
func reflectGetFromMainOrNetwork(main, network reflect.Value, path []string) (*reflectedField, bool) {
|
||||||
|
if len(path) > 0 && path[0] == "network" {
|
||||||
|
return reflectGetYAML(network, path[1:])
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if value.Kind() == reflect.Ptr {
|
||||||
|
value = value.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Map:
|
||||||
|
return &reflectedField{
|
||||||
|
value: value,
|
||||||
|
valueKind: value.Type().Elem().Kind(),
|
||||||
|
remainingPath: path,
|
||||||
|
}, true
|
||||||
|
case reflect.Struct:
|
||||||
|
fields := reflect.VisibleFields(value.Type())
|
||||||
|
for _, field := range fields {
|
||||||
|
if yamlFieldName(field) != path[0] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return reflectGetYAML(value.FieldByIndex(field.Index), path[1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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]; {
|
||||||
|
case name == "-" && len(parts) == 1:
|
||||||
|
return ""
|
||||||
|
case name == "":
|
||||||
|
return strings.ToLower(field.Name)
|
||||||
|
default:
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
value := field.value
|
||||||
|
if value.Kind() == reflect.Ptr {
|
||||||
|
if value.IsNil() {
|
||||||
|
value.Set(reflect.New(value.Type().Elem()))
|
||||||
|
}
|
||||||
|
value = value.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
if value.Kind() == reflect.Map {
|
||||||
|
if value.Type().Key().Kind() != reflect.String {
|
||||||
|
return fmt.Errorf("unsupported map key type %s in %s", value.Type().Key().Kind(), formatKey(path))
|
||||||
|
}
|
||||||
|
key := strings.Join(field.remainingPath, ".")
|
||||||
|
value.SetMapIndex(reflect.ValueOf(key), reflect.ValueOf(parsed))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
value.Set(reflect.ValueOf(parsed))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseValue(kind reflect.Kind, raw string, path []string) (any, error) {
|
||||||
|
switch kind {
|
||||||
|
case reflect.String:
|
||||||
|
return raw, nil
|
||||||
|
case reflect.Bool:
|
||||||
|
parsed, err := strconv.ParseBool(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid value for %s: %w", formatKey(path), err)
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid value for %s: %w", formatKey(path), err)
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case reflect.Int8:
|
||||||
|
return int8(parsed), nil
|
||||||
|
case reflect.Int16:
|
||||||
|
return int16(parsed), nil
|
||||||
|
case reflect.Int32:
|
||||||
|
return int32(parsed), nil
|
||||||
|
case reflect.Int64:
|
||||||
|
return parsed, nil
|
||||||
|
default:
|
||||||
|
return int(parsed), nil
|
||||||
|
}
|
||||||
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||||
|
parsed, err := strconv.ParseUint(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid value for %s: %w", formatKey(path), err)
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case reflect.Uint8:
|
||||||
|
return uint8(parsed), nil
|
||||||
|
case reflect.Uint16:
|
||||||
|
return uint16(parsed), nil
|
||||||
|
case reflect.Uint32:
|
||||||
|
return uint32(parsed), nil
|
||||||
|
case reflect.Uint64:
|
||||||
|
return parsed, nil
|
||||||
|
default:
|
||||||
|
return uint(parsed), nil
|
||||||
|
}
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
parsed, err := strconv.ParseFloat(raw, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid value for %s: %w", formatKey(path), err)
|
||||||
|
}
|
||||||
|
if kind == reflect.Float32 {
|
||||||
|
return float32(parsed), nil
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported type %s in %s", kind, formatKey(path))
|
||||||
|
}
|
||||||
|
}
|
||||||
57
packages/arrtrix/pkg/runtime/envconfig_test.go
Normal file
57
packages/arrtrix/pkg/runtime/envconfig_test.go
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
|
||||||
|
|
||||||
|
"sneeuwvlok/packages/arrtrix/pkg/connector"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdateConfigFromEnvSupportsFlatUnderscorePaths(t *testing.T) {
|
||||||
|
t.Setenv("ARRTRIX_NETWORK_CONTENT_MOVIES_APIKEY", "radarr-secret")
|
||||||
|
|
||||||
|
cfg := &bridgeconfig.Config{}
|
||||||
|
network := &connector.Config{}
|
||||||
|
if err := updateConfigFromEnv(cfg, network, "ARRTRIX_"); err != nil {
|
||||||
|
t.Fatalf("updateConfigFromEnv returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if network.Content.Movies.APIKey != "radarr-secret" {
|
||||||
|
t.Fatalf("expected movies api key to be overridden, got %q", network.Content.Movies.APIKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateConfigFromEnvSupportsExplicitUnderscoredFieldNames(t *testing.T) {
|
||||||
|
t.Setenv("ARRTRIX_NETWORK_CONTENT_MOVIES_ROOT_FOLDER_PATH", "/data/movies")
|
||||||
|
|
||||||
|
cfg := &bridgeconfig.Config{}
|
||||||
|
network := &connector.Config{}
|
||||||
|
if err := updateConfigFromEnv(cfg, network, "ARRTRIX_"); err != nil {
|
||||||
|
t.Fatalf("updateConfigFromEnv returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if network.Content.Movies.RootFolderPath != "/data/movies" {
|
||||||
|
t.Fatalf("expected root folder path to be overridden, got %q", network.Content.Movies.RootFolderPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateConfigFromEnvSupportsDoubleUnderscorePaths(t *testing.T) {
|
||||||
|
t.Setenv("ARRTRIX_NETWORK__CONTENT__SERIES__API_KEY", "sonarr-secret")
|
||||||
|
|
||||||
|
cfg := &bridgeconfig.Config{}
|
||||||
|
network := &connector.Config{}
|
||||||
|
if err := updateConfigFromEnv(cfg, network, "ARRTRIX_"); err != nil {
|
||||||
|
t.Fatalf("updateConfigFromEnv returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if network.Content.Series.APIKey != "sonarr-secret" {
|
||||||
|
t.Fatalf("expected series api key to be overridden, got %q", network.Content.Series.APIKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
code := m.Run()
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
76
packages/arrtrix/pkg/runtime/example.go
Normal file
76
packages/arrtrix/pkg/runtime/example.go
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/bridgev2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func makeExampleConfig(networkName bridgev2.BridgeName, networkExample string) string {
|
||||||
|
var builder strings.Builder
|
||||||
|
|
||||||
|
builder.WriteString("# Network-specific config options\n")
|
||||||
|
builder.WriteString("network:\n")
|
||||||
|
for _, line := range strings.Split(strings.TrimRight(networkExample, "\n"), "\n") {
|
||||||
|
if line == "" {
|
||||||
|
builder.WriteString(" \n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
builder.WriteString(" ")
|
||||||
|
builder.WriteString(line)
|
||||||
|
builder.WriteByte('\n')
|
||||||
|
}
|
||||||
|
builder.WriteByte('\n')
|
||||||
|
|
||||||
|
builder.WriteString(fmt.Sprintf(`bridge:
|
||||||
|
command_prefix: "%s"
|
||||||
|
permissions:
|
||||||
|
"*": relay
|
||||||
|
"@admin:example.com": admin
|
||||||
|
|
||||||
|
database:
|
||||||
|
type: sqlite3-fk-wal
|
||||||
|
uri: file:arrtrix.db?_txlock=immediate
|
||||||
|
|
||||||
|
homeserver:
|
||||||
|
address: http://example.localhost:8008
|
||||||
|
domain: example.com
|
||||||
|
software: standard
|
||||||
|
|
||||||
|
appservice:
|
||||||
|
address: http://localhost:%d
|
||||||
|
hostname: 127.0.0.1
|
||||||
|
port: %d
|
||||||
|
id: %s
|
||||||
|
bot:
|
||||||
|
username: %s
|
||||||
|
displayname: %s
|
||||||
|
as_token: This value is generated when generating the registration
|
||||||
|
hs_token: This value is generated when generating the registration
|
||||||
|
username_template: %s_{{.}}
|
||||||
|
|
||||||
|
logging:
|
||||||
|
min_level: info
|
||||||
|
writers:
|
||||||
|
- 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: ""
|
||||||
|
welcome_unconnected: ""
|
||||||
|
additional_help: ""
|
||||||
|
|
||||||
|
env_config_prefix: ""
|
||||||
|
`, networkName.DefaultCommandPrefix, networkName.DefaultPort, networkName.DefaultPort, networkName.NetworkID, "arrtrixbot", "Arrtrix Bot", networkName.NetworkID))
|
||||||
|
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
486
packages/arrtrix/pkg/runtime/main.go
Normal file
486
packages/arrtrix/pkg/runtime/main.go
Normal file
|
|
@ -0,0 +1,486 @@
|
||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"go.mau.fi/util/dbutil"
|
||||||
|
"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"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/bridgev2"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/commands"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/matrix"
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
|
|
||||||
|
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()
|
||||||
|
var writeExampleConfig = flag.MakeFull("e", "generate-example-config", "Save the example config to the config path and quit.", "false").Bool()
|
||||||
|
var dontSaveConfig = flag.MakeFull("n", "no-update", "Don't save updated config to disk.", "false").Bool()
|
||||||
|
var registrationPath = flag.MakeFull("r", "registration", "The path where to save the appservice registration.", "registration.yaml").String()
|
||||||
|
var generateRegistration = flag.MakeFull("g", "generate-registration", "Generate registration and quit.", "false").Bool()
|
||||||
|
var version = flag.MakeFull("v", "version", "View bridge version and quit.", "false").Bool()
|
||||||
|
var versionJSON = flag.Make().LongKey("version-json").Usage("Print a JSON object representing the bridge version and quit.").Default("false").Bool()
|
||||||
|
var ignoreUnsupportedDatabase = flag.Make().LongKey("ignore-unsupported-database").Usage("Run even if the database schema is too new").Default("false").Bool()
|
||||||
|
var ignoreForeignTables = flag.Make().LongKey("ignore-foreign-tables").Usage("Run even if the database contains tables from other programs (like Synapse)").Default("false").Bool()
|
||||||
|
var ignoreUnsupportedServer = flag.Make().LongKey("ignore-unsupported-server").Usage("Run even if the Matrix homeserver is outdated").Default("false").Bool()
|
||||||
|
var wantHelp, _ = flag.MakeHelpFlag()
|
||||||
|
|
||||||
|
type Main struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
URL string
|
||||||
|
Version string
|
||||||
|
|
||||||
|
Connector bridgev2.NetworkConnector
|
||||||
|
PostInit func()
|
||||||
|
PostStart func()
|
||||||
|
|
||||||
|
Log *zerolog.Logger
|
||||||
|
DB *dbutil.Database
|
||||||
|
PublicConfig *arrconfig.Config
|
||||||
|
Config *bridgeconfig.Config
|
||||||
|
Matrix *matrix.Connector
|
||||||
|
Bridge *bridgev2.Bridge
|
||||||
|
OTEL *observability.Runtime
|
||||||
|
|
||||||
|
ConfigPath string
|
||||||
|
RegistrationPath string
|
||||||
|
SaveConfig bool
|
||||||
|
|
||||||
|
ver progver.ProgramVersion
|
||||||
|
manualStop chan int
|
||||||
|
}
|
||||||
|
|
||||||
|
type versionJSONOutput struct {
|
||||||
|
progver.ProgramVersion
|
||||||
|
|
||||||
|
OS string
|
||||||
|
Arch string
|
||||||
|
|
||||||
|
Mautrix struct {
|
||||||
|
Version string
|
||||||
|
Commit string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type routeMounter interface {
|
||||||
|
MountRoutes(*http.ServeMux) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Main) Run() {
|
||||||
|
m.PreInit()
|
||||||
|
m.Init()
|
||||||
|
m.Start()
|
||||||
|
exitCode := m.WaitForInterrupt()
|
||||||
|
m.Stop()
|
||||||
|
os.Exit(exitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Main) PreInit() {
|
||||||
|
m.manualStop = make(chan int, 1)
|
||||||
|
flag.SetHelpTitles(
|
||||||
|
fmt.Sprintf("%s - %s", m.Name, m.Description),
|
||||||
|
fmt.Sprintf("%s [-hgvn] [-c <path>] [-r <path>]", m.Name),
|
||||||
|
)
|
||||||
|
|
||||||
|
err := flag.Parse()
|
||||||
|
m.ConfigPath = *configPath
|
||||||
|
m.RegistrationPath = *registrationPath
|
||||||
|
m.SaveConfig = !*dontSaveConfig
|
||||||
|
if err != nil {
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, err)
|
||||||
|
flag.PrintHelp()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case *wantHelp:
|
||||||
|
flag.PrintHelp()
|
||||||
|
os.Exit(0)
|
||||||
|
case *version:
|
||||||
|
fmt.Println(m.ver.VersionDescription)
|
||||||
|
os.Exit(0)
|
||||||
|
case *versionJSON:
|
||||||
|
output := versionJSONOutput{
|
||||||
|
ProgramVersion: m.ver,
|
||||||
|
OS: runtime.GOOS,
|
||||||
|
Arch: runtime.GOARCH,
|
||||||
|
}
|
||||||
|
output.Mautrix.Version = mautrix.Version
|
||||||
|
output.Mautrix.Commit = mautrix.Commit
|
||||||
|
_ = json.NewEncoder(os.Stdout).Encode(output)
|
||||||
|
os.Exit(0)
|
||||||
|
case *writeExampleConfig:
|
||||||
|
m.writeExampleConfig()
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.LoadConfig()
|
||||||
|
if *generateRegistration {
|
||||||
|
m.GenerateRegistration()
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Main) writeExampleConfig() {
|
||||||
|
if *configPath != "-" {
|
||||||
|
if _, err := os.Stat(*configPath); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, *configPath, "already exists, please remove it if you want to generate a new example")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
networkExample, _, _ := m.Connector.GetConfig()
|
||||||
|
example := makeExampleConfig(m.Connector.GetName(), networkExample)
|
||||||
|
if *configPath == "-" {
|
||||||
|
fmt.Print(example)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
exerrors.PanicIfNotNil(os.WriteFile(*configPath, []byte(example), 0o600))
|
||||||
|
fmt.Println("Wrote example config to", *configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Main) GenerateRegistration() {
|
||||||
|
if !m.SaveConfig {
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, "--no-update is not compatible with --generate-registration")
|
||||||
|
os.Exit(5)
|
||||||
|
}
|
||||||
|
if m.Config.Homeserver.Domain == "example.com" {
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, "Homeserver domain is not set")
|
||||||
|
os.Exit(20)
|
||||||
|
}
|
||||||
|
|
||||||
|
registration := m.Config.GenerateRegistration()
|
||||||
|
if err := registration.Save(m.RegistrationPath); err != nil {
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, "Failed to save registration:", err)
|
||||||
|
os.Exit(21)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.saveConfig(); err != nil {
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, "Failed to save config:", err)
|
||||||
|
os.Exit(22)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Registration generated. See https://docs.mau.fi/bridges/general/registering-appservices.html for instructions on installing the registration.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Main) LoadConfig() {
|
||||||
|
configData, err := os.ReadFile(m.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, "Failed to read config:", err)
|
||||||
|
os.Exit(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
publicConfig, err := arrconfig.Load(configData)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, "Failed to parse config:", err)
|
||||||
|
os.Exit(10)
|
||||||
|
}
|
||||||
|
cfg := publicConfig.Compile()
|
||||||
|
if err = m.loadRegistrationTokens(&cfg); err != nil {
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, "Failed to parse registration:", err)
|
||||||
|
os.Exit(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, networkData, _ := m.Connector.GetConfig()
|
||||||
|
if networkData != nil {
|
||||||
|
if err = cfg.Network.Decode(networkData); err != nil {
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, "Failed to parse network config:", err)
|
||||||
|
os.Exit(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Bridge.Backfill = cfg.Backfill
|
||||||
|
if err = updateConfigFromEnv(&cfg, networkData, cfg.EnvConfigPrefix); err != nil {
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, "Failed to parse environment variables:", err)
|
||||||
|
os.Exit(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.PublicConfig = publicConfig
|
||||||
|
m.Config = &cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Main) loadRegistrationTokens(cfg *bridgeconfig.Config) error {
|
||||||
|
if m.RegistrationPath == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(m.RegistrationPath)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokens struct {
|
||||||
|
AppToken string `yaml:"as_token"`
|
||||||
|
ServerToken string `yaml:"hs_token"`
|
||||||
|
}
|
||||||
|
if err = yaml.Unmarshal(data, &tokens); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokens.AppToken != "" {
|
||||||
|
cfg.AppService.ASToken = tokens.AppToken
|
||||||
|
}
|
||||||
|
if tokens.ServerToken != "" {
|
||||||
|
cfg.AppService.HSToken = tokens.ServerToken
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Main) Init() {
|
||||||
|
start := time.Now()
|
||||||
|
ctx := context.Background()
|
||||||
|
var err error
|
||||||
|
m.Log, err = m.Config.Logging.Compile()
|
||||||
|
if err != nil {
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, "Failed to initialize logger:", err)
|
||||||
|
os.Exit(12)
|
||||||
|
}
|
||||||
|
exzerolog.SetupDefaults(m.Log)
|
||||||
|
|
||||||
|
if err = m.validateConfig(); err != nil {
|
||||||
|
m.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Configuration error")
|
||||||
|
m.Log.Info().Msg("See https://docs.mau.fi/faq/field-unconfigured for more info")
|
||||||
|
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).
|
||||||
|
Time("built_at", m.ver.BuildTime).
|
||||||
|
Str("go_version", runtime.Version()).
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
m.Matrix.IgnoreUnsupportedServer = *ignoreUnsupportedServer
|
||||||
|
m.Bridge = bridgev2.NewBridge("", m.DB, *m.Log, &m.Config.Bridge, m.Matrix, m.Connector, commands.NewProcessor)
|
||||||
|
m.Bridge.Commands = matrixcmd.NewProcessor(m.Bridge, m.Config.ManagementRoomTexts)
|
||||||
|
|
||||||
|
if m.Matrix.EventProcessor != nil {
|
||||||
|
if m.Config.AppService.AsyncTransactions {
|
||||||
|
m.Matrix.EventProcessor.ExecMode = appservice.AsyncLoop
|
||||||
|
} else {
|
||||||
|
m.Matrix.EventProcessor.ExecMode = appservice.Sync
|
||||||
|
}
|
||||||
|
m.Matrix.EventProcessor.PrependHandler(event.StateMember, func(ctx context.Context, evt *event.Event) {
|
||||||
|
onboarding.HandleBotInvite(ctx, m.Bridge, m.Config.ManagementRoomTexts, evt)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
m.Matrix.AS.DoublePuppetValue = m.Name
|
||||||
|
if mounter, ok := m.Connector.(routeMounter); ok {
|
||||||
|
if err = mounter.MountRoutes(m.Matrix.AS.Router); err != nil {
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, "Failed to mount HTTP routes:", err)
|
||||||
|
os.Exit(13)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.PostInit != nil {
|
||||||
|
m.PostInit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
interrupts := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(interrupts, os.Interrupt, syscall.SIGTERM)
|
||||||
|
select {
|
||||||
|
case <-interrupts:
|
||||||
|
m.Log.Info().Msg("Interrupt signal received from OS")
|
||||||
|
return 0
|
||||||
|
case exitCode := <-m.manualStop:
|
||||||
|
m.Log.Info().Msg("Internal stop signal received")
|
||||||
|
return exitCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Main) TriggerStop(exitCode int) {
|
||||||
|
select {
|
||||||
|
case m.manualStop <- exitCode:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Main) InitVersion(tag, commit, rawBuildTime string) {
|
||||||
|
m.ver = progver.ProgramVersion{
|
||||||
|
Name: m.Name,
|
||||||
|
URL: m.URL,
|
||||||
|
BaseVersion: m.Version,
|
||||||
|
}.Init(tag, commit, rawBuildTime)
|
||||||
|
mautrix.DefaultUserAgent = fmt.Sprintf("%s/%s %s", m.Name, m.ver.FormattedVersion, mautrix.DefaultUserAgent)
|
||||||
|
m.Version = m.ver.FormattedVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Main) validateConfig() error {
|
||||||
|
switch {
|
||||||
|
case m.Config.Homeserver.Address == "http://example.localhost:8008":
|
||||||
|
return errors.New("homeserver.address not configured")
|
||||||
|
case m.Config.Homeserver.Domain == "example.com":
|
||||||
|
return errors.New("homeserver.domain not configured")
|
||||||
|
case !bridgeconfig.AllowedHomeserverSoftware[m.Config.Homeserver.Software]:
|
||||||
|
return errors.New("invalid value for homeserver.software (use `standard` if you don't know what the field is for)")
|
||||||
|
case m.Config.AppService.ASToken == "This value is generated when generating the registration":
|
||||||
|
return errors.New("appservice.as_token not configured. Did you forget to generate the registration?")
|
||||||
|
case m.Config.AppService.HSToken == "This value is generated when generating the registration":
|
||||||
|
return errors.New("appservice.hs_token not configured. Did you forget to generate the registration?")
|
||||||
|
case m.Config.Database.URI == "postgres://user:password@host/database?sslmode=disable":
|
||||||
|
return errors.New("database.uri not configured")
|
||||||
|
case !m.Config.Bridge.Permissions.IsConfigured():
|
||||||
|
return errors.New("bridge.permissions not configured")
|
||||||
|
case !strings.Contains(m.Config.AppService.FormatUsername("1234567890"), "1234567890"):
|
||||||
|
return errors.New("username template is missing user ID placeholder")
|
||||||
|
default:
|
||||||
|
if validator, ok := m.Connector.(bridgev2.ConfigValidatingNetwork); ok {
|
||||||
|
return validator.ValidateConfig()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Main) initDB() {
|
||||||
|
if m.Config.Database.Type == "sqlite3" {
|
||||||
|
m.Log.WithLevel(zerolog.FatalLevel).Msg("Invalid database type sqlite3. Use sqlite3-fk-wal instead.")
|
||||||
|
os.Exit(14)
|
||||||
|
}
|
||||||
|
if (m.Config.Database.Type == "sqlite3-fk-wal" || m.Config.Database.Type == "litestream") &&
|
||||||
|
m.Config.Database.MaxOpenConns != 1 &&
|
||||||
|
!strings.Contains(m.Config.Database.URI, "_txlock=immediate") {
|
||||||
|
var fixedURI string
|
||||||
|
switch {
|
||||||
|
case !strings.HasPrefix(m.Config.Database.URI, "file:"):
|
||||||
|
fixedURI = fmt.Sprintf("file:%s?_txlock=immediate", m.Config.Database.URI)
|
||||||
|
case !strings.ContainsRune(m.Config.Database.URI, '?'):
|
||||||
|
fixedURI = fmt.Sprintf("%s?_txlock=immediate", m.Config.Database.URI)
|
||||||
|
default:
|
||||||
|
fixedURI = fmt.Sprintf("%s&_txlock=immediate", m.Config.Database.URI)
|
||||||
|
}
|
||||||
|
m.Log.Warn().Str("fixed_uri_example", fixedURI).Msg("Using SQLite without _txlock=immediate is not recommended")
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
m.DB, err = dbutil.NewFromConfig("megabridge/"+m.Name, m.Config.Database, dbutil.ZeroLogger(m.Log.With().Str("db_section", "main").Logger()))
|
||||||
|
if err != nil {
|
||||||
|
m.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to initialize database connection")
|
||||||
|
os.Exit(14)
|
||||||
|
}
|
||||||
|
m.DB.IgnoreUnsupportedDatabase = *ignoreUnsupportedDatabase
|
||||||
|
m.DB.IgnoreForeignTables = *ignoreForeignTables
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Main) saveConfig() error {
|
||||||
|
publicConfig := *m.PublicConfig
|
||||||
|
publicConfig.AppService.ASToken = m.Config.AppService.ASToken
|
||||||
|
publicConfig.AppService.HSToken = m.Config.AppService.HSToken
|
||||||
|
|
||||||
|
configData, err := yaml.Marshal(&publicConfig)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(m.ConfigPath, configData, 0o600)
|
||||||
|
}
|
||||||
30
packages/arrtrix/pkg/runtime/main_test.go
Normal file
30
packages/arrtrix/pkg/runtime/main_test.go
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadRegistrationTokens(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
registrationPath := filepath.Join(tempDir, "registration.yaml")
|
||||||
|
if err := os.WriteFile(registrationPath, []byte("as_token: app-token\nhs_token: hs-token\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("failed to write registration file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &bridgeconfig.Config{}
|
||||||
|
main := &Main{RegistrationPath: registrationPath}
|
||||||
|
if err := main.loadRegistrationTokens(cfg); err != nil {
|
||||||
|
t.Fatalf("loadRegistrationTokens returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.AppService.ASToken != "app-token" {
|
||||||
|
t.Fatalf("expected as token to be loaded, got %q", cfg.AppService.ASToken)
|
||||||
|
}
|
||||||
|
if cfg.AppService.HSToken != "hs-token" {
|
||||||
|
t.Fatalf("expected hs token to be loaded, got %q", cfg.AppService.HSToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
141
packages/arrtrix/pkg/subscriptions/repo.go
Normal file
141
packages/arrtrix/pkg/subscriptions/repo.go
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
package subscriptions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"go.mau.fi/util/dbutil"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
|
"sneeuwvlok/packages/arrtrix/pkg/arr"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Preference struct {
|
||||||
|
ContentType arr.ContentType
|
||||||
|
EventType string
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Repository struct {
|
||||||
|
db *dbutil.Database
|
||||||
|
bridgeID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnsureSchema(ctx context.Context, db *dbutil.Database) error {
|
||||||
|
_, err := db.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS arrtrix_subscription (
|
||||||
|
bridge_id TEXT NOT NULL,
|
||||||
|
user_mxid TEXT NOT NULL,
|
||||||
|
content_type TEXT NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
enabled BOOLEAN NOT NULL,
|
||||||
|
PRIMARY KEY (bridge_id, user_mxid, content_type, event_type)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepository(db *dbutil.Database, bridgeID string) *Repository {
|
||||||
|
return &Repository{db: db, bridgeID: bridgeID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) EnsureDefaults(ctx context.Context, userID id.UserID) error {
|
||||||
|
var existing int
|
||||||
|
if err := r.db.QueryRow(ctx, `SELECT COUNT(*) FROM arrtrix_subscription WHERE bridge_id=$1 AND user_mxid=$2`, r.bridgeID, userID.String()).Scan(&existing); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existing > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, contentType := range arr.SupportedContentTypes() {
|
||||||
|
for _, eventType := range arr.SupportedEventTypes(contentType) {
|
||||||
|
if _, err := r.db.Exec(ctx, `
|
||||||
|
INSERT INTO arrtrix_subscription (bridge_id, user_mxid, content_type, event_type, enabled)
|
||||||
|
VALUES ($1, $2, $3, $4, TRUE)
|
||||||
|
`, r.bridgeID, userID.String(), string(contentType), eventType); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) List(ctx context.Context, userID id.UserID) ([]Preference, error) {
|
||||||
|
if err := r.EnsureDefaults(ctx, userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := r.db.Query(ctx, `
|
||||||
|
SELECT content_type, event_type, enabled
|
||||||
|
FROM arrtrix_subscription
|
||||||
|
WHERE bridge_id=$1 AND user_mxid=$2
|
||||||
|
ORDER BY content_type, event_type
|
||||||
|
`, r.bridgeID, userID.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var preferences []Preference
|
||||||
|
for rows.Next() {
|
||||||
|
var contentType string
|
||||||
|
var preference Preference
|
||||||
|
if err = rows.Scan(&contentType, &preference.EventType, &preference.Enabled); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
preference.ContentType = arr.ContentType(contentType)
|
||||||
|
preferences = append(preferences, preference)
|
||||||
|
}
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return preferences, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) Set(ctx context.Context, userID id.UserID, contentType arr.ContentType, eventType string, enabled bool) error {
|
||||||
|
if err := r.EnsureDefaults(ctx, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := r.db.Exec(ctx, `
|
||||||
|
INSERT INTO arrtrix_subscription (bridge_id, user_mxid, content_type, event_type, enabled)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (bridge_id, user_mxid, content_type, event_type)
|
||||||
|
DO UPDATE SET enabled=excluded.enabled
|
||||||
|
`, r.bridgeID, userID.String(), string(contentType), eventType, enabled); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) SetAll(ctx context.Context, userID id.UserID, contentType arr.ContentType, enabled bool) error {
|
||||||
|
if err := r.EnsureDefaults(ctx, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, eventType := range arr.SupportedEventTypes(contentType) {
|
||||||
|
if err := r.Set(ctx, userID, contentType, eventType, enabled); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) Allows(ctx context.Context, userID id.UserID, contentType arr.ContentType, eventType string) (bool, error) {
|
||||||
|
if !arr.SupportsEventType(contentType, eventType) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if err := r.EnsureDefaults(ctx, userID); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var enabled bool
|
||||||
|
err := r.db.QueryRow(ctx, `
|
||||||
|
SELECT enabled
|
||||||
|
FROM arrtrix_subscription
|
||||||
|
WHERE bridge_id=$1 AND user_mxid=$2 AND content_type=$3 AND event_type=$4
|
||||||
|
`, r.bridgeID, userID.String(), string(contentType), eventType).Scan(&enabled)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("query subscription: %w", err)
|
||||||
|
}
|
||||||
|
return enabled, nil
|
||||||
|
}
|
||||||
349
packages/arrtrix/pkg/webhook/arr.go
Normal file
349
packages/arrtrix/pkg/webhook/arr.go
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
package webhook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"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"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNoManagementRoom = errors.New("no management room configured")
|
||||||
|
ErrAmbiguousManagementRoom = errors.New("multiple management rooms configured")
|
||||||
|
)
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type movie struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Year int `json:"year"`
|
||||||
|
ImdbID string `json:"imdbId"`
|
||||||
|
TmdbID int `json:"tmdbId"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type movieFile struct {
|
||||||
|
Quality string `json:"quality"`
|
||||||
|
RelativePath string `json:"relativePath"`
|
||||||
|
SceneName string `json:"sceneName"`
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func MountArr(router *http.ServeMux, bridge *bridgev2.Bridge, subscriptions SubscriptionFilter) error {
|
||||||
|
if bridge == nil {
|
||||||
|
return fmt.Errorf("bridge is not initialized")
|
||||||
|
}
|
||||||
|
handler := &ArrHandler{
|
||||||
|
resolver: bridgeRoomResolver{bridge: bridge},
|
||||||
|
sender: bridgeNoticeSender{bridge: bridge},
|
||||||
|
subscriptions: subscriptions,
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
statusCode = http.StatusInternalServerError
|
||||||
|
outcome = "resolve_failed"
|
||||||
|
if errors.Is(err, ErrNoManagementRoom) || errors.Is(err, ErrAmbiguousManagementRoom) {
|
||||||
|
statusCode = http.StatusConflict
|
||||||
|
outcome = "routing_conflict"
|
||||||
|
}
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
http.Error(w, err.Error(), statusCode)
|
||||||
|
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())
|
||||||
|
http.Error(w, "failed to deliver webhook", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
span.SetStatus(codes.Ok, "")
|
||||||
|
w.WriteHeader(statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var target managementTarget
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
owners = append(owners, id.UserID(mxid))
|
||||||
|
if target.RoomID == "" {
|
||||||
|
target = managementTarget{
|
||||||
|
UserID: id.UserID(mxid),
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch len(owners) {
|
||||||
|
case 0:
|
||||||
|
span.SetStatus(codes.Error, ErrNoManagementRoom.Error())
|
||||||
|
return managementTarget{}, ErrNoManagementRoom
|
||||||
|
case 1:
|
||||||
|
span.SetAttributes(attribute.Int("arrtrix.management_room.count", 1))
|
||||||
|
span.SetStatus(codes.Ok, "")
|
||||||
|
return target, 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), ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type bridgeNoticeSender struct {
|
||||||
|
bridge *bridgev2.Bridge
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertUserIDs(users []id.UserID) []string {
|
||||||
|
out := make([]string, len(users))
|
||||||
|
for i, user := range users {
|
||||||
|
out[i] = string(user)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
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, ", ")
|
||||||
|
}
|
||||||
145
packages/arrtrix/pkg/webhook/arr_test.go
Normal file
145
packages/arrtrix/pkg/webhook/arr_test.go
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
package webhook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
|
"sneeuwvlok/packages/arrtrix/pkg/arr"
|
||||||
|
)
|
||||||
|
|
||||||
|
type stubRoomResolver struct {
|
||||||
|
target managementTarget
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stubRoomResolver) ResolveManagementRoom(context.Context) (managementTarget, error) {
|
||||||
|
return s.target, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubNoticeSender struct {
|
||||||
|
roomID id.RoomID
|
||||||
|
message string
|
||||||
|
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 {
|
||||||
|
t.Fatal("expected nil bridge to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArrHandlerDeliversNotice(t *testing.T) {
|
||||||
|
sender := &stubNoticeSender{}
|
||||||
|
handler := &ArrHandler{
|
||||||
|
resolver: stubRoomResolver{target: managementTarget{UserID: "@user:test", RoomID: "!room:test"}},
|
||||||
|
sender: sender,
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, ArrWebhookPath, strings.NewReader(`{"eventType":"Download","movie":{"title":"Dune","year":2021,"imdbId":"tt1160419"},"movieFile":{"quality":"1080p","relativePath":"Dune (2021)/Dune.mkv"},"isUpgrade":false}`))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("expected accepted status, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if sender.roomID != "!room:test" {
|
||||||
|
t.Fatalf("expected notice sent to management room, got %q", sender.roomID)
|
||||||
|
}
|
||||||
|
if !strings.Contains(sender.message, "**Arr Download**") || !strings.Contains(sender.message, "Dune (2021)") {
|
||||||
|
t.Fatalf("unexpected message: %s", sender.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArrHandlerReportsAmbiguousManagementRoom(t *testing.T) {
|
||||||
|
handler := &ArrHandler{
|
||||||
|
resolver: stubRoomResolver{err: ErrAmbiguousManagementRoom},
|
||||||
|
sender: &stubNoticeSender{},
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, ArrWebhookPath, strings.NewReader(`{"eventType":"Test"}`))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("expected conflict status, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderNoticeForTestEvent(t *testing.T) {
|
||||||
|
msg := renderNotice(payload{EventType: "Test"})
|
||||||
|
if strings.TrimSpace(msg) != "**Arr Test**" {
|
||||||
|
t.Fatalf("unexpected test-event message: %q", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArrHandlerReturnsBadGatewayOnSendFailure(t *testing.T) {
|
||||||
|
handler := &ArrHandler{
|
||||||
|
resolver: stubRoomResolver{target: managementTarget{UserID: "@user:test", RoomID: "!room:test"}},
|
||||||
|
sender: &stubNoticeSender{err: errors.New("send failed")},
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, ArrWebhookPath, strings.NewReader(`{"eventType":"Test"}`))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("expected bad gateway status, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArrHandlerRejectsMissingEventType(t *testing.T) {
|
||||||
|
handler := &ArrHandler{
|
||||||
|
resolver: stubRoomResolver{target: managementTarget{UserID: "@user:test", RoomID: "!room:test"}},
|
||||||
|
sender: &stubNoticeSender{},
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, ArrWebhookPath, strings.NewReader(`{"movie":{"title":"Dune"}}`))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
{ lib, stdenv, rustPlatform, fetchFromGitHub, openssl, pkg-config, postgresql, dbBackend ? "postgresql", ... }:
|
|
||||||
rustPlatform.buildRustPackage rec {
|
|
||||||
pname = "vaultwarden";
|
|
||||||
version = "1.34.3";
|
|
||||||
|
|
||||||
src = fetchFromGitHub {
|
|
||||||
owner = "Timshel";
|
|
||||||
repo = "vaultwarden";
|
|
||||||
rev = "1.34.3";
|
|
||||||
hash = "sha256-Dj0ySVRvBZ/57+UHas3VI8bi/0JBRqn0IW1Dq+405J0=";
|
|
||||||
};
|
|
||||||
|
|
||||||
cargoHash = "sha256-4sDagd2XGamBz1XvDj4ycRVJ0F+4iwHOPlj/RglNDqE=";
|
|
||||||
|
|
||||||
# used for "Server Installed" version in admin panel
|
|
||||||
env.VW_VERSION = version;
|
|
||||||
|
|
||||||
nativeBuildInputs = [ pkg-config ];
|
|
||||||
buildInputs =
|
|
||||||
[ openssl ]
|
|
||||||
++ lib.optional (dbBackend == "postgresql") postgresql;
|
|
||||||
|
|
||||||
buildFeatures = dbBackend;
|
|
||||||
|
|
||||||
meta = with lib; {
|
|
||||||
license = licenses.agpl3Only;
|
|
||||||
mainProgram = "vaultwarden";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
3
script/synapse/shared_secret
Normal file
3
script/synapse/shared_secret
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
pwgen -s 128 1
|
||||||
|
|
@ -18,5 +18,6 @@ mkShell {
|
||||||
openssl
|
openssl
|
||||||
inputs.clan-core.packages.${stdenv.hostPlatform.system}.clan-cli
|
inputs.clan-core.packages.${stdenv.hostPlatform.system}.clan-cli
|
||||||
nix-output-monitor
|
nix-output-monitor
|
||||||
|
dos2unix
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,21 @@
|
||||||
{...}: {
|
{
|
||||||
|
pkgs,
|
||||||
|
config,
|
||||||
|
...
|
||||||
|
}: {
|
||||||
imports = [
|
imports = [
|
||||||
./disks.nix
|
./disks.nix
|
||||||
./hardware.nix
|
./hardware.nix
|
||||||
];
|
];
|
||||||
|
|
||||||
|
environment.systemPackages = with pkgs; [bup];
|
||||||
|
services.postgresqlBackup = {
|
||||||
|
enable = true;
|
||||||
|
backupAll = true;
|
||||||
|
startAt = "*-*-* 01:00:00";
|
||||||
|
location = "/var/backup/postgresql";
|
||||||
|
};
|
||||||
|
|
||||||
networking = {
|
networking = {
|
||||||
interfaces.enp2s0 = {
|
interfaces.enp2s0 = {
|
||||||
ipv6.addresses = [
|
ipv6.addresses = [
|
||||||
|
|
@ -27,31 +39,6 @@
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# virtualisation = {
|
|
||||||
# containers.enable = true;
|
|
||||||
# podman = {
|
|
||||||
# enable = true;
|
|
||||||
# dockerCompat = true;
|
|
||||||
# };
|
|
||||||
|
|
||||||
# oci-containers = {
|
|
||||||
# backend = "podman";
|
|
||||||
# containers = {
|
|
||||||
# homey = {
|
|
||||||
# image = "ghcr.io/athombv/homey-shs:latest";
|
|
||||||
# autoStart = true;
|
|
||||||
# privileged = true;
|
|
||||||
# volumes = [
|
|
||||||
# "/home/chris/.homey-shs:/homey/user"
|
|
||||||
# ];
|
|
||||||
# ports = [
|
|
||||||
# "4859:4859"
|
|
||||||
# ];
|
|
||||||
# };
|
|
||||||
# };
|
|
||||||
# };
|
|
||||||
# };
|
|
||||||
|
|
||||||
sneeuwvlok = {
|
sneeuwvlok = {
|
||||||
services = {
|
services = {
|
||||||
backup.borg.enable = true;
|
backup.borg.enable = true;
|
||||||
|
|
@ -128,13 +115,13 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
mydia = {
|
mydia = {
|
||||||
redirectUris = ["http://localhost:2010/auth/oidc/callback"];
|
redirectUris = ["http://localhost:2100/auth/oidc/callback"];
|
||||||
grantTypes = ["authorizationCode"];
|
grantTypes = ["authorizationCode"];
|
||||||
responseTypes = ["code"];
|
responseTypes = ["code"];
|
||||||
};
|
};
|
||||||
|
|
||||||
grafana = {
|
grafana = {
|
||||||
redirectUris = ["http://localhost:9001/login/generic_oauth"];
|
redirectUris = ["http://localhost:9010/login/generic_oauth"];
|
||||||
grantTypes = ["authorizationCode"];
|
grantTypes = ["authorizationCode"];
|
||||||
responseTypes = ["code"];
|
responseTypes = ["code"];
|
||||||
};
|
};
|
||||||
|
|
@ -212,7 +199,7 @@
|
||||||
media.servarr = {
|
media.servarr = {
|
||||||
radarr = {
|
radarr = {
|
||||||
enable = true;
|
enable = true;
|
||||||
port = 2001;
|
port = 2010;
|
||||||
rootFolders = [
|
rootFolders = [
|
||||||
"/var/media/movies"
|
"/var/media/movies"
|
||||||
];
|
];
|
||||||
|
|
@ -221,7 +208,7 @@
|
||||||
sonarr = {
|
sonarr = {
|
||||||
enable = true;
|
enable = true;
|
||||||
# debug = true;
|
# debug = true;
|
||||||
port = 2002;
|
port = 2020;
|
||||||
rootFolders = [
|
rootFolders = [
|
||||||
"/var/media/series"
|
"/var/media/series"
|
||||||
];
|
];
|
||||||
|
|
@ -230,7 +217,7 @@
|
||||||
lidarr = {
|
lidarr = {
|
||||||
enable = true;
|
enable = true;
|
||||||
debug = true;
|
debug = true;
|
||||||
port = 2003;
|
port = 2030;
|
||||||
rootFolders = [
|
rootFolders = [
|
||||||
"/var/media/music"
|
"/var/media/music"
|
||||||
];
|
];
|
||||||
|
|
@ -239,15 +226,17 @@
|
||||||
prowlarr = {
|
prowlarr = {
|
||||||
enable = true;
|
enable = true;
|
||||||
# debug = true;
|
# debug = true;
|
||||||
port = 2004;
|
port = 2040;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
observability = {
|
observability = {
|
||||||
|
alloy.enable = true;
|
||||||
grafana.enable = true;
|
grafana.enable = true;
|
||||||
prometheus.enable = true;
|
|
||||||
loki.enable = true;
|
loki.enable = true;
|
||||||
|
prometheus.enable = true;
|
||||||
promtail.enable = true;
|
promtail.enable = true;
|
||||||
|
tempo.enable = true;
|
||||||
# uptime-kuma.enable = true;
|
# uptime-kuma.enable = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,13 @@ email:
|
||||||
zitadel:
|
zitadel:
|
||||||
masterKey: ENC[AES256_GCM,data:4MPvBo407qrS7NF4oUTf84tZoPkSRmiHdD7qpkYeHME=,iv:H2NIAN0xBUDqnyco9gA3zYAsKtSeA/JpqYrPhc1eqc0=,tag:6OFGDfsucG5gDerImgpuXA==,type:str]
|
masterKey: ENC[AES256_GCM,data:4MPvBo407qrS7NF4oUTf84tZoPkSRmiHdD7qpkYeHME=,iv:H2NIAN0xBUDqnyco9gA3zYAsKtSeA/JpqYrPhc1eqc0=,tag:6OFGDfsucG5gDerImgpuXA==,type:str]
|
||||||
nix: {}
|
nix: {}
|
||||||
users: ENC[AES256_GCM,data:w/2Vdq0EHXaJ5u/aA/reSCtwRHreWm1U1WoJT927xV81zoN0ytoYOwush610caZu8vVXkL4b0hysK77dyWJkdkYpwLY8xG9pLkYlU3lN5E/2tgEjB7Dd7oY7TFTCNuypmIzYh6V74KiHMeA0vlyWUp9lLNt40Ro3MZLT42DyTYjF6YBoUHUp0fS0rKypILJGobJBrwz2YWagXj80IqaaUmmsIcYAaM2u3dQviLlRkIyUxPd1wjFoMc/OMp5Y8A4ZHroCN0wJitGeEEP33GD+MUy58u05pA430AD5Mo4H2V7b3t0qIkOQ8a0BgSVA8UqmrcY/TfikuIZ1kTyCxvD7kmjPq5tG+bhtHt85wgk1XffVO3NDTK7UrltO8R6KolQ5bBgcKgl7YnFTN5qSAT+xrYg8oZaPrGQBTx6eEVETKHKe4oSDkGlAle86lenhF+jm3k2ALmH9X3P/TpAtfRhuU+sUKqhrqQ2Nf4M7LfBtd7lyt2ESqilKokcl51gWCY+1B75dCEIdb/BPmpwzJBGFOI2nZqhxFnVa8TyMpT7C2TxK7rCBPDt5NnNvWYc4+8sRXHBz7s2R5NTk4gaJODlo3HvyL0MV,iv:XlO48HKJWRgwsozmgXstfirwb5CUY+ywelbgLlcx/n4=,tag:GuQMkL2mpNkTJIep79x0zw==,type:str]
|
users: ENC[AES256_GCM,data:ikpAuiQT32i4+aaVPz/nRqlf5ESID3khat2MrOySOfF9duJaQLWBonaKau6JVRljnGb+RGTiEH/EpxzXHnNydfHrir/jS4cDFMUMNV9aee0CyEbfqHAFqbC3B4ReZZE+XCkiq1j5jLnRg7EiGRK5+g+ul2iGIAwJ5SoHiOSSBcJ2E4B+AdkhGVO6Qsf+DW3hUZ/MsoaDsOB3IX15iC6/9z+NT+/Jefz5In6jn/vdYpD2i/zWvNHHPVXIkK1Co8FUidRdOjyWiiCb4+A0DI5v9E69xKe4zl26GHv3+1aK7cTxq2meDI4AXKhaTpak0A/neO/E6Xrc78752rTNRUDre9jJNrip/UPu8KvaCzpUi8Y4aN2Qg6ICF6JudzgouFyOGJ/JyxjVcJhUBOof/vCOcihdmHlo8sgyAi5mn/70VqnEF6Ei4KkRMAMlz9mfEVHDmjWMP1wHLw8eJD+Vhn/AJ76VecSCr51OHYtwgEcQXC6ikyPwBn8XQ5CNae/XGhcs0c8UbAcUXCH40zxvn4DFYHzJCkwurqv2iiV5zRN+rre6SoEWIToByq5KAwzkgLrLIVIbYWcLXlBYLvuMjnHbRknqWndQS72fRds0EWg+/OfjO+0SrPkJIoHkMNiUUmoq17ouwz0mcKVEh3o1Wptrp54ArDLkUjdtbOhaGTEzpGH+y0b+LITiN0erGPFITjf8sgGtvg+fRnoqCxPpex99,iv:+MjTW26sd8csWm4RXscFMgUm3wNY5Yj+qP8Xfg/WvsQ=,tag:mXjrEJqpbuqaVLa8EJpjoQ==,type:str]
|
||||||
forgejo:
|
forgejo:
|
||||||
action_runner_token: ENC[AES256_GCM,data:yJ6OnRq5kinbuhvH06K5o3l86EafuBoojMwg/qhP+cgeH+BwPeE+Ng==,iv:IeXJahPxgLNIUFmkgp495tLVh8UyQBmJ2SnVEUhlhHs=,tag:XYQi613CxSp8AQeilJMrsg==,type:str]
|
action_runner_token: ENC[AES256_GCM,data:yJ6OnRq5kinbuhvH06K5o3l86EafuBoojMwg/qhP+cgeH+BwPeE+Ng==,iv:IeXJahPxgLNIUFmkgp495tLVh8UyQBmJ2SnVEUhlhHs=,tag:XYQi613CxSp8AQeilJMrsg==,type:str]
|
||||||
synapse:
|
synapse:
|
||||||
oidc_id: ENC[AES256_GCM,data:XbCpyGq0LeRJWq8dv/5Dipvp,iv:YDhgl26z1NBbIQLoLdGVz0+ze6o1ZcmgVHPfwoRj57I=,tag:y2vUuqnDmtTvVQmZCAlnLg==,type:str]
|
oidc_id: ENC[AES256_GCM,data:XbCpyGq0LeRJWq8dv/5Dipvp,iv:YDhgl26z1NBbIQLoLdGVz0+ze6o1ZcmgVHPfwoRj57I=,tag:y2vUuqnDmtTvVQmZCAlnLg==,type:str]
|
||||||
oidc_secret: ENC[AES256_GCM,data:nVFi5EFbNMZ0mvrDHVYC0NiwJlo2eEw44D+Fcv9SKSb2oO00lGEDkP/oXDj5YgDq6RLQSe3f/SUOn77ntwnZYg==,iv:awe7VNUYOn9ofl1QlQTrEN5d0i5WkVM35qndruL4VXo=,tag:8Yoc9lFF9aWbtAa5fzQGEA==,type:str]
|
oidc_secret: ENC[AES256_GCM,data:nVFi5EFbNMZ0mvrDHVYC0NiwJlo2eEw44D+Fcv9SKSb2oO00lGEDkP/oXDj5YgDq6RLQSe3f/SUOn77ntwnZYg==,iv:awe7VNUYOn9ofl1QlQTrEN5d0i5WkVM35qndruL4VXo=,tag:8Yoc9lFF9aWbtAa5fzQGEA==,type:str]
|
||||||
|
shared_secret: ENC[AES256_GCM,data:IkzZ6QV1gLzChAFSsYsK3HM5dKFD4AoDJ53xgoxNpgt5tb45mMw/LRxu4NArGVLUtVGBy6jk6arU+Nxvi8bxPOC8c2UFCRUF+FM1phICEbb4Chgy5g803VKNFOu6BLaEmwDmuZSQP7CwX1hy8TX8yChboHGp7hH+n5SAZpejrLg=,iv:d+Ab91yCltYwudDWhrWPw0Xod/TKriCsoGD8i6PD4H4=,tag:xOXnzNuajcOz+imjMJr3Dg==,type:str]
|
||||||
radarr:
|
radarr:
|
||||||
apikey: ENC[AES256_GCM,data:G141GW4PyS5pbAV39HcVscMw3s30txOgTZzWaL7o+ccZfnfDLv796O6xKXdqGZ8saLsveghLw9Z6a5luusHyQ3Q5ESL6W7SVeZVTuSqSC3i/4jl75FJxhnsgVsfrnYxzLGpKiw==,iv:sZl/XLh6y3WgSAn6nH3sFB6atBifZdghm+QsCNDbcjY=,tag:Tw+R80nrF0T0yDti0Uf+ig==,type:str]
|
apikey: ENC[AES256_GCM,data:G141GW4PyS5pbAV39HcVscMw3s30txOgTZzWaL7o+ccZfnfDLv796O6xKXdqGZ8saLsveghLw9Z6a5luusHyQ3Q5ESL6W7SVeZVTuSqSC3i/4jl75FJxhnsgVsfrnYxzLGpKiw==,iv:sZl/XLh6y3WgSAn6nH3sFB6atBifZdghm+QsCNDbcjY=,tag:Tw+R80nrF0T0yDti0Uf+ig==,type:str]
|
||||||
sonarr:
|
sonarr:
|
||||||
|
|
@ -40,6 +41,8 @@ coturn:
|
||||||
qbittorrent:
|
qbittorrent:
|
||||||
password: ENC[AES256_GCM,data:LIDxh0Ni0JgQGWFix/Ihw7IlUPgzMhrMlWNP5LKkAnEM6EoqA9kFwiPeizB0CZ20+vSqRiL9fikBf8qGLA17L7AKh8I4OTFDlpKpMRtRlMq9S5UBEyOqtOMcvkCSf6/qGoORd1KJSlaitZk47SYRuccOpy/2vAvbMRdLm0SYEqc=,iv:tQdN1N9kXoq7OZbR2eYyy50FltsMAAUI4Lr7U4/SpJE=,tag:3ZOLvjHXD7i7WFy1/Ggqtg==,type:str]
|
password: ENC[AES256_GCM,data:LIDxh0Ni0JgQGWFix/Ihw7IlUPgzMhrMlWNP5LKkAnEM6EoqA9kFwiPeizB0CZ20+vSqRiL9fikBf8qGLA17L7AKh8I4OTFDlpKpMRtRlMq9S5UBEyOqtOMcvkCSf6/qGoORd1KJSlaitZk47SYRuccOpy/2vAvbMRdLm0SYEqc=,iv:tQdN1N9kXoq7OZbR2eYyy50FltsMAAUI4Lr7U4/SpJE=,tag:3ZOLvjHXD7i7WFy1/Ggqtg==,type:str]
|
||||||
password_hash: ENC[AES256_GCM,data:urufJbSErLqPdU6jLLZk+27fe4k+cKLXcGRGSqroUDdGMzDnhSF+ZWuPxwDlJQR3ws2GnuiEASncwNO/SALKXFDk2V2gsKJ4hsjyiIbsqCwSEFB/XMY0nY/x0xrcIfMVE0HdrNYeQ3zT01Z5jQpSd7wo2M63LaULL/Av498=,iv:tnUVhOgrImKa6iii2hJZn5LKrySM5v47B2zDZMgmUow=,tag:g3xa/4Z+t1Q9Wnd4XzefLg==,type:str]
|
password_hash: ENC[AES256_GCM,data:urufJbSErLqPdU6jLLZk+27fe4k+cKLXcGRGSqroUDdGMzDnhSF+ZWuPxwDlJQR3ws2GnuiEASncwNO/SALKXFDk2V2gsKJ4hsjyiIbsqCwSEFB/XMY0nY/x0xrcIfMVE0HdrNYeQ3zT01Z5jQpSd7wo2M63LaULL/Av498=,iv:tnUVhOgrImKa6iii2hJZn5LKrySM5v47B2zDZMgmUow=,tag:g3xa/4Z+t1Q9Wnd4XzefLg==,type:str]
|
||||||
|
backup:
|
||||||
|
ssh-key: ENC[AES256_GCM,data:aRY+9mYssEXPmfJQ2KOYU4wxkgzgYbv3GJ4KUkECSZ6IdQVv4CpKMg75dEhO5/t7MYjiNXze5WibZ0UHSTnUv4OB6NP6Mp1HZjIZb6paCJxjkoul0BVwtF5AKViJe0LIKoh+,iv:kZgZTqgYdqJSD6rO3lj/IFqhO9mYgZ7YYOCS2b+xpXQ=,tag:xPh0yL2uMyqgrioC36PPpA==,type:str]
|
||||||
sops:
|
sops:
|
||||||
age:
|
age:
|
||||||
- recipient: age19qfpf980tadguqq44zf6xwvjvl428dyrj46ha3n6aeqddwhtnuqqml7etq
|
- recipient: age19qfpf980tadguqq44zf6xwvjvl428dyrj46ha3n6aeqddwhtnuqqml7etq
|
||||||
|
|
@ -60,7 +63,7 @@ sops:
|
||||||
TTRWaHhpNWlkVDFmMFN4ZTNHMUxyNVkKV693pzTKRkZboQCMPr9IyMGSgxfuHXcb
|
TTRWaHhpNWlkVDFmMFN4ZTNHMUxyNVkKV693pzTKRkZboQCMPr9IyMGSgxfuHXcb
|
||||||
Y6BNcp6Qg6PWtX5QI7wRkPNINAK1TEbRBba+b8h6gMmVU4DliQyFiQ==
|
Y6BNcp6Qg6PWtX5QI7wRkPNINAK1TEbRBba+b8h6gMmVU4DliQyFiQ==
|
||||||
-----END AGE ENCRYPTED FILE-----
|
-----END AGE ENCRYPTED FILE-----
|
||||||
lastmodified: "2026-03-05T10:38:19Z"
|
lastmodified: "2026-04-16T05:20:18Z"
|
||||||
mac: ENC[AES256_GCM,data:gS6YTRTl6UdOC7Afrj1LrkgA7MWRLF0HNWytfzhkvThLW+JJrHPEhvWiYrsPW1Bm6o2JkKqVP5HfzcuGNIHJySkEQ4HV02BbibtMNiUKqk+voATsWOpo6957bwRJaTbvDvxmzIQ38TSUoj/pt8Z8WTl0hSPAlqNlWYffXX0y8K4=,iv:53R2bKYKiHJi9DTecg7hiuGNb3Kj9rA2U/oPJ+AFO5I=,tag:5uqvmEJCaCS/yNqyt/FPZg==,type:str]
|
mac: ENC[AES256_GCM,data:YqkxwV30uqSHhsn4niFEODxxl9R2ZuiyyX4g8zONVjMvdA52C08zPpxdxjtXnUT9m3sT7iSmWcJJZwhMhRIb8LJ2sdIJ4v+wpG9I4pPokhEXI2ozqbzw3k68GnZOzYu3kePQBJjQx1fmlM63dgILIwx7ytPnpm9arQ1rszZynNs=,iv:hxdhU5oH9h9mRH3m76oFkYVNA68PnivVJpJRjxSRtTw=,tag:Fyyg6cWPb96c/Vap+PifUQ==,type:str]
|
||||||
unencrypted_suffix: _unencrypted
|
unencrypted_suffix: _unencrypted
|
||||||
version: 3.11.0
|
version: 3.11.0
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue