diff --git a/.sops.yaml b/.sops.yaml
index 0567291..5b6b5a8 100644
--- a/.sops.yaml
+++ b/.sops.yaml
@@ -3,7 +3,7 @@ keys:
- &host_14900k age1elk6zwmcylwfk7gd4pjda7g29upftjvxys8py42s8d42jklnyv7s7dm9z2
- &host_uConsole age193gw802ytal7h5p5q37kpd9079k2vsflzmnvupcwfxh2kjdrwqtsk3g6rm
- &host_t2mbp age1yr7vurfxc3w8ewfw9djfm54atw6ayze69qglamecuft5q0n9gu2sadsa2m
- - &host_ideapad age1m30m9xzszmcawte35m0yymz42gfx3x84w7d5l67mtdtajhgpfgssuc2plm
+ - &host_ideapad age1hya7pgpe8zal52w3pjf036tpapmehedatfm4r84h30t4wuh079ssedfd37
- &host_nix-server age1p05z980kdtngk9mw67hfev72h7xhslplpxfk9yskgmf0hl4lu3ls04zht9
creation_rules:
- path_regex: secrets/[^/]+\.(yaml|json|env|ini)$
diff --git a/modules/desktop/gui.nix b/modules/desktop/gui.nix
index 0a0ccbb..f247b8c 100644
--- a/modules/desktop/gui.nix
+++ b/modules/desktop/gui.nix
@@ -58,6 +58,14 @@
xdg.portal.extraPortals =
[ pkgs.xdg-desktop-portal-gtk ]
++ lib.optionals cfg.plasma.enable [ pkgs.kdePackages.xdg-desktop-portal-kde ];
+
+ # Backlight control: `brightnessctl` + its udev rule (auto-loaded from the package's
+ # `lib/udev/rules.d`). The rule grants the `video` group write access to
+ # `/sys/class/backlight/*/brightness`; the user catalog adds desktop users to `video`.
+ # The Hyprland defaults already bind XF86MonBrightness* to brightnessctl, and the Niri
+ # base config does the same — this guarantees the binary is actually present.
+ environment.systemPackages = [ pkgs.brightnessctl ];
+ services.udev.packages = [ pkgs.brightnessctl ];
})
(lib.mkIf (guiEnabled && !useGreeter) {
services.displayManager.sddm = {
diff --git a/modules/desktop/niri/default.nix b/modules/desktop/niri/default.nix
index 5504565..e6d70f7 100644
--- a/modules/desktop/niri/default.nix
+++ b/modules/desktop/niri/default.nix
@@ -113,6 +113,11 @@ let
"toggle"
];
+ # Backlight: relies on `pkgs.brightnessctl` being on PATH (provided by `desktopGui` when
+ # any GUI session is enabled) and the user being in the `video` group (catalog default).
+ "XF86MonBrightnessUp".spawn = [ "brightnessctl" "set" "+5%" ];
+ "XF86MonBrightnessDown".spawn = [ "brightnessctl" "set" "5%-" ];
+
Print.screenshot = _: { };
"Ctrl+Print"."screenshot-screen" = _: { };
"Alt+Print"."screenshot-window" = _: { };
diff --git a/modules/hosts/14900k/_private/displays.nix b/modules/hosts/14900k/_private/displays.nix
index 8b80efa..f6b6f75 100644
--- a/modules/hosts/14900k/_private/displays.nix
+++ b/modules/hosts/14900k/_private/displays.nix
@@ -4,46 +4,27 @@
#TODO[epic=Moderate] Clean this up, move to host's configuration.nix.
{ config, lib, ... }:
-let
- gpuPassthrough = config.chiasson.system.vm.gpuPassthrough.enable;
-in
{
chiasson.desktop.niri.extraSettings = {
- extraConfig =
- if gpuPassthrough then
- ''
- output "DP-1" {
- mode "2560x1080@144"
- scale 1.0
- position x=1920 y=0
- focus-at-startup
- }
- output "HDMI-A-2" {
- mode "2560x1080@60"
- scale 1.0
- position x=0 y=0
- }
- ''
- else
- ''
- output "DP-2" {
- mode "2560x1080@144"
- scale 1.0
- position x=0 y=0
- focus-at-startup
- }
- output "HDMI-A-3" {
- mode "1920x1080@60"
- scale 1.0
- position x=-1920 y=0
- }
- output "DP-4" {
- mode "1920x1080@144"
- scale 1.0
- position x=0 y=-1080
- }
+ extraConfig = ''
+ output "DP-2" {
+ mode "2560x1080@144"
+ scale 1.0
+ position x=0 y=0
+ focus-at-startup
+ }
+ output "HDMI-A-3" {
+ mode "1920x1080@60"
+ scale 1.0
+ position x=-1920 y=0
+ }
+ output "DP-4" {
+ mode "1920x1080@144"
+ scale 1.0
+ position x=0 y=-1080
+ }
- '';
+ '';
binds."XF86Tools".spawn = [
"wpctl"
@@ -55,43 +36,22 @@ in
chiasson.desktop.hyprland.settings = lib.mkIf config.chiasson.desktop.hyprland.enable (
let
- monitorList =
- if gpuPassthrough then
- [
- "DP-1, 2560x1080@144, 0x0, 1"
- "HDMI-A-2, 1920x1080@60, auto-up, 1"
- ]
- else
- [
- "DP-2, 2560x1080@144, 0x0, 1"
- "DP-4, 1920x1080@144, 0x-1080, 1"
- "HDMI-A-3, 1920x1080@60, -1920x0, 1"
- ];
- workspaceList =
- if gpuPassthrough then
- [
- "1, monitor:DP-1, default:true"
- "2, monitor:DP-1"
- "3, monitor:DP-1"
- "4, monitor:DP-1"
- "5, monitor:HDMI-A-2, default:true"
- "6, monitor:HDMI-A-2"
- "7, monitor:HDMI-A-2"
- "8, monitor:HDMI-A-2"
- "9, monitor:DP-1"
- ]
- else
- [
- "1, monitor:DP-3, default:true"
- "2, monitor:DP-3"
- "3, monitor:DP-3"
- "4, monitor:Unknown-2, default:true"
- "5, monitor:Unknown-2"
- "6, monitor:Unknown-2"
- "7, monitor:DP-4"
- "8, monitor:DP-4"
- "9, monitor:DP-4"
- ];
+ monitorList = [
+ "DP-2, 2560x1080@144, 0x0, 1"
+ "DP-4, 1920x1080@144, 0x-1080, 1"
+ "HDMI-A-3, 1920x1080@60, -1920x0, 1"
+ ];
+ workspaceList = [
+ "1, monitor:DP-3, default:true"
+ "2, monitor:DP-3"
+ "3, monitor:DP-3"
+ "4, monitor:Unknown-2, default:true"
+ "5, monitor:Unknown-2"
+ "6, monitor:Unknown-2"
+ "7, monitor:DP-4"
+ "8, monitor:DP-4"
+ "9, monitor:DP-4"
+ ];
in
{
monitor = lib.mkBefore monitorList;
diff --git a/modules/hosts/14900k/_private/jellyfin-nfs-export.nix b/modules/hosts/14900k/_private/jellyfin-nfs-export.nix
index b4f8c33..97c9966 100644
--- a/modules/hosts/14900k/_private/jellyfin-nfs-export.nix
+++ b/modules/hosts/14900k/_private/jellyfin-nfs-export.nix
@@ -26,8 +26,9 @@
mountdPort = 4000;
lockdPort = 4001;
statdPort = 4002;
+ # fsid= stabilizes file handles across server reboots/remounts of this tree (avoids client ESTALE).
exports = ''
- /mnt/test/jellyfin 192.168.2.238(rw,sync,no_subtree_check,crossmnt,root_squash,all_squash,anonuid=990,anongid=990)
+ /mnt/test/jellyfin 192.168.2.238(rw,sync,no_subtree_check,crossmnt,root_squash,all_squash,anonuid=990,anongid=990,fsid=1)
'';
};
diff --git a/modules/hosts/14900k/_private/nvidia.nix b/modules/hosts/14900k/_private/nvidia.nix
index 123f653..ebfa9c4 100644
--- a/modules/hosts/14900k/_private/nvidia.nix
+++ b/modules/hosts/14900k/_private/nvidia.nix
@@ -1,26 +1,19 @@
-# NVIDIA for host desktop; when `chiasson.system.vm.gpuPassthrough` is enabled, drop NVIDIA for VFIO (port later).
+# NVIDIA for host desktop.
{ config, lib, pkgs, ... }:
-let
- passthrough = config.chiasson.system.vm.gpuPassthrough.enable;
-in
{
boot.kernelParams = [ "snd_hda_core.gpu_bind=0" ];
boot.kernelPackages = lib.mkDefault pkgs.linuxPackages_latest;
- services.xserver.videoDrivers = if passthrough then [ "modesetting" ] else [ "nvidia" ];
+ services.xserver.videoDrivers = [ "nvidia" ];
- hardware.nvidia =
- if passthrough then
- lib.mkForce { }
- else {
- modesetting.enable = true;
- powerManagement.enable = false;
- powerManagement.finegrained = false;
- open = true;
- nvidiaSettings = true;
- package = config.boot.kernelPackages.nvidiaPackages.stable;
- };
+ hardware.nvidia = {
+ modesetting.enable = true;
+ powerManagement.enable = false;
+ powerManagement.finegrained = false;
+ open = true;
+ nvidiaSettings = true;
+ package = config.boot.kernelPackages.nvidiaPackages.stable;
+ };
- # Needed for `docker compose` GPU passthrough (e.g. `--gpus all` / DEVICE=gpu).
- hardware.nvidia-container-toolkit.enable = !passthrough;
+ hardware.nvidia-container-toolkit.enable = true;
}
diff --git a/modules/hosts/14900k/configuration.nix b/modules/hosts/14900k/configuration.nix
index 9857ba9..0e35199 100644
--- a/modules/hosts/14900k/configuration.nix
+++ b/modules/hosts/14900k/configuration.nix
@@ -84,13 +84,7 @@ services.cloudflare-warp.enable = true;
};
chiasson.system = {
- # libvirt/QEMU + VFIO; host uses Intel iGPU for Niri while NVIDIA is passed through (see
- # `_private/nvidia.nix`, `_private/displays.nix`). If your GPU is not RTX 2070-class IDs, set
- # `chiasson.system.vm.gpuPassthrough.vfioIds` from `lspci -nn` (GPU + HDA functions in the same group).
- vm = {
- enable = true;
- gpuPassthrough.enable = false;
- };
+ ytDlpTelequebecPatch.enable = true;
audio.enable = true;
docker.enable = true;
diff --git a/modules/hosts/ideapad/_private/CAMERA-TODO.md b/modules/hosts/ideapad/_private/CAMERA-TODO.md
new file mode 100644
index 0000000..6fccbf3
--- /dev/null
+++ b/modules/hosts/ideapad/_private/CAMERA-TODO.md
@@ -0,0 +1,139 @@
+# Cameras on the Lenovo Duet 3 (`lenovo-wormdingler`) — TODO
+
+The front and rear cameras do **not** work on the current Mobile NixOS image.
+This file is a starting point for picking the work back up later, so we don't
+have to re-diagnose from scratch.
+
+## Current state (May 2026)
+
+Empirical, taken on the running device:
+
+```
+$ uname -a
+Linux ideapad 6.5.0 #1-mobile-nixos SMP Tue Jan 1 00:00:00 UTC 1980 aarch64 GNU/Linux
+
+$ ls /dev/video*
+/dev/video0 ← Qualcomm Venus video decoder (h.264/h.265 dec)
+/dev/video1 ← Qualcomm Venus video encoder (h.264/h.265 enc)
+
+$ ls /dev/media* # nothing — no media controller graph at all
+$ lsmod | grep -i camss # nothing
+$ wpctl status | grep -A1 Sources # nothing — PipeWire has no camera sources
+```
+
+Relevant kernel config bits in `/proc/config.gz` on the running image:
+
+```
+CONFIG_MEDIA_CAMERA_SUPPORT=y ← media framework is on
+# CONFIG_VIDEO_QCOM_CAMSS is not set ← Qualcomm Camera Subsystem driver is OFF
+# CONFIG_VIDEO_OV5675 is not set ← every relevant sensor driver is OFF
+# CONFIG_VIDEO_OV13858 is not set
+# CONFIG_VIDEO_HI556 is not set
+# (full grep of `VIDEO_OV*` / `VIDEO_HI*` is all `not set`)
+```
+
+So the *media framework* is enabled, but the *ISP driver* (CAMSS) and every
+plausible sensor driver are off, and no out-of-tree modules are shipped. The
+two `/dev/video*` nodes are the Venus codecs, not cameras — that's why
+`snapshot` reports "no camera found."
+
+## Why this is non-trivial
+
+1. **Kernel rebuild required.** `mobile-nixos` builds its own kernel for this
+ device (see `mobile.kernel.structuredConfig` in `hardware.nix`, where we
+ already enable `CIFS` and `EXFAT_FS`). Adding camera support means adding:
+ - `VIDEO_QCOM_CAMSS = module;`
+ - The right sensor driver(s) — and we don't currently know which ones the
+ Duet 3 actually uses. The `wormdingler` Chromium-OS DT references a pair
+ of OV-series sensors but the exact part numbers can vary by SKU and
+ production batch.
+ - Their dependencies (`I2C`, `V4L2`, `MEDIA_CONTROLLER`, etc. — most are
+ already pulled in by `MEDIA_CAMERA_SUPPORT`).
+
+2. **Device-tree wiring.** Even with the drivers compiled in, the device tree
+ has to describe the CCI bus, the sensor I²C addresses, the regulators, the
+ reset/enable GPIOs, and the CSI port mapping. Mobile NixOS' DT for
+ wormdingler may or may not include these nodes — needs verification by
+ reading the upstream device file at
+ `${inputs.mobile-nixos}/devices/lenovo-wormdingler/`.
+
+3. **libcamera is the user-space side.** CAMSS is not a "single v4l2 device"
+ driver — it exposes a media-controller graph that has to be configured by
+ libcamera (per-sensor IPA, format negotiation, etc.). Apps then talk to
+ libcamera via the `libcamerasrc` PipeWire module or directly. So the
+ user-space stack is:
+ - `pkgs.libcamera` (system-wide)
+ - `pkgs.pipewire` already running, but needs the libcamera module enabled
+ (`services.pipewire.libcamera = …` or equivalent — check current option
+ name)
+ - GUI: `snapshot`, `gnome-camera`, or anything that talks to PipeWire
+ video sources.
+
+## Investigation checklist
+
+When picking this up again, do these in order:
+
+1. **Identify the actual sensors.** The cleanest way:
+ - Read the upstream Mobile NixOS device file for wormdingler
+ (`devices/lenovo-wormdingler/default.nix` and any `.dts` overlays).
+ - Cross-check with the Chromium OS overlay at
+
+ and the upstream Linux DTS at
+ `arch/arm64/boot/dts/qcom/sc7180-trogdor-wormdingler-*.dts`.
+ - As a runtime cross-check, when CAMSS is eventually loaded, `dmesg | grep -i
+ -E 'cci|sensor|isp'` will print the I²C probe attempts.
+
+2. **Enable kernel options** in `modules/hosts/ideapad/hardware.nix` under
+ `mobile.kernel.structuredConfig`:
+
+ ```nix
+ (helpers: with helpers; {
+ VIDEO_QCOM_CAMSS = module;
+ # plus whatever sensors step 1 identified, e.g.:
+ # VIDEO_OV5675 = module;
+ # VIDEO_OV13858 = module;
+ })
+ ```
+
+ Cross-build on the 14900k via the existing flow (binfmt aarch64 + push back).
+ Reboot, then check:
+
+ ```
+ ls /dev/media* # expect at least /dev/media0
+ sudo dmesg | grep -i -E 'camss|sensor|isp|cci' # probe history
+ sudo modprobe -v qcom-camss # if not auto-loaded
+ ```
+
+3. **Install diagnostic tools** for this round of work (do **not** keep these
+ in the long-term config unless cameras actually work):
+
+ ```nix
+ environment.systemPackages = with pkgs; [
+ v4l-utils # provides v4l2-ctl, media-ctl
+ libcamera # provides `cam`, `qcam`
+ ];
+ ```
+
+ Then:
+
+ ```
+ v4l2-ctl --list-devices
+ media-ctl -p # dumps the full media-controller graph
+ cam -l # libcamera's view of available cameras
+ ```
+
+4. **Wire libcamera into PipeWire.** Once `cam -l` shows at least one camera,
+ enable PipeWire's libcamera module (option name may have shifted; current
+ nixpkgs typically has `services.pipewire.wireplumber.extraConfig` or
+ similar). Then `wpctl status` should show new Sources under "Video", and
+ `snapshot` will see them.
+
+5. **Re-add a camera GUI** to `configuration.nix`. `snapshot` is the simplest
+ touch-first option; `gnome-camera` and `cheese` are alternatives.
+
+## Why nothing else is being touched right now
+
+Steps 1–4 above are speculative — there's no guarantee the Duet 3 cameras have
+working mainline-Linux sensor drivers at all. The conservative move is to
+leave the config tablet-usable without them, document the dead end, and revisit
+when there's time for a real spike.
diff --git a/modules/hosts/ideapad/_private/platform.nix b/modules/hosts/ideapad/_private/platform.nix
new file mode 100644
index 0000000..5053588
--- /dev/null
+++ b/modules/hosts/ideapad/_private/platform.nix
@@ -0,0 +1,45 @@
+{ pkgs, ... }: {
+ # ─────────────────────── Power & thermal ───────────────────────
+ # Snapdragon 7c (sc7180) on a tablet form factor: aim for battery life. `schedutil` is the
+ # right modern cpufreq governor on ARM (responsive + power-aware); use it instead of
+ # `powersave` to avoid pinning the CPU at minimum frequency under interactive load.
+ powerManagement.cpuFreqGovernor = "schedutil";
+ powerManagement.enable = true;
+
+ # ─────────────────────── logind: lid & power button ───────────────────────
+ # Closing the lid suspends, even on AC — Duet 3 is a tablet, treat it like one.
+ # Short press on power: suspend (matches ChromeOS/iOS); long press: poweroff.
+ # The DMS bar power menu is the way to reboot / shut down explicitly.
+ services.logind.settings.Login = {
+ HandleLidSwitch = "suspend";
+ HandleLidSwitchExternalPower = "suspend";
+ HandleLidSwitchDocked = "ignore";
+ HandlePowerKey = "suspend";
+ HandlePowerKeyLongPress = "poweroff";
+ };
+
+ # ─────────────────────── Idle / suspend tuning ───────────────────────
+ # Allow suspend-to-RAM but disable hibernate (ARM swap-resume is unreliable, and we don't
+ # have a swap device by default anyway).
+ systemd.sleep.settings.Sleep = {
+ AllowHibernation = "no";
+ AllowHybridSleep = "no";
+ AllowSuspendThenHibernate = "no";
+ };
+
+ # upower picks the right battery percentages for low/critical out of the box; just make
+ # sure the action on critical is hibernate-then-poweroff fallback (we disabled hibernate
+ # so it'll go straight to poweroff). DMS reads upower for the bar widget.
+ services.upower = {
+ enable = true;
+ criticalPowerAction = "PowerOff";
+ percentageLow = 15;
+ percentageCritical = 7;
+ percentageAction = 3;
+ };
+
+ # ─────────────────────── Bluetooth audio quality of life ───────────────────────
+ # Duet has limited mic/speaker hw — keep wpa_supplicant power-save off so audio doesn't crackle
+ # over Bluetooth when CPU is idle. (Wi-Fi + BT share the chip; aggressive power-save = stutter.)
+ networking.networkmanager.wifi.powersave = false;
+}
diff --git a/modules/hosts/ideapad/_private/touch-tablet.nix b/modules/hosts/ideapad/_private/touch-tablet.nix
new file mode 100644
index 0000000..912e72c
--- /dev/null
+++ b/modules/hosts/ideapad/_private/touch-tablet.nix
@@ -0,0 +1,321 @@
+# Host-only: ideapad tablet ergonomics — touchscreen calibration, IIO sensors, virtual keyboard,
+# and per-session helper daemons (tablet-mode toggle + auto-rotation via iio-sensor-proxy) for both
+# Niri and Hyprland.
+#
+# Why all of this lives at the *NixOS* layer (not the home-manager catalog under wisdom/):
+# - The hardware bits (`hardware.sensor.iio.enable`, the udev calibration matrix) are system-wide
+# and tied to this exact device, so they belong with the host module.
+# - The compositor helpers run via session-specific autostart hooks (Niri `spawn-at-startup`,
+# Hyprland `exec-once`); the wiring is gated on the matching `chiasson.desktop..enable`,
+# so picking a different session at the greeter just leaves them dormant.
+#
+# Two compositor flavours of each daemon:
+# - Hyprland (CW transforms via `hyprctl`) — original; matches the old NixOS-New setup.
+# - Niri (CCW transforms via `niri msg output`) — needed because Niri is the V2 default.
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+let
+ # ─────────────────────── Hyprland helpers ───────────────────────
+ ideapadTabletModeDaemon = pkgs.writeShellScriptBin "ideapad-tablet-mode-daemon" ''
+ #!/usr/bin/env bash
+ set -euo pipefail
+
+ HYPRCTL="${pkgs.hyprland}/bin/hyprctl"
+ JQ="${pkgs.jq}/bin/jq"
+ PKILL="${pkgs.procps}/bin/pkill"
+ PGREP="${pkgs.procps}/bin/pgrep"
+ WVKBD="${pkgs.wvkbd}/bin/wvkbd-mobintl"
+
+ monitor_name="DSI-1"
+ keyboard_scale="1.25"
+ tablet_scale="1.6"
+ state_file="''${XDG_RUNTIME_DIR:-/run/user/$(${pkgs.coreutils}/bin/id -u)}/ideapad-input-mode.state"
+
+ current_transform() {
+ "$HYPRCTL" -j monitors 2>/dev/null | "$JQ" -r --arg mon "$monitor_name" '
+ ([.[] | select(.name == $mon)][0].transform // 1 | tostring)
+ ' 2>/dev/null || echo "1"
+ }
+
+ has_attached_pogo_dock() {
+ "$HYPRCTL" -j devices 2>/dev/null | "$JQ" -e '
+ any((([.keyboards[]?.name] + [.mice[]?.name])[]?); (ascii_downcase | test("google-inc\\.-hammer")))
+ ' >/dev/null
+ }
+
+ # wvkbd is no longer auto-spawned at session start — this daemon owns its lifecycle so we
+ # only have a virtual keyboard surface in memory when the pogo cover is detached.
+ start_wvkbd() {
+ "$PGREP" -x wvkbd-mobintl >/dev/null 2>&1 && return 0
+ "$WVKBD" --non-exclusive -H 520 -L 360 --fn 'DejaVu Sans 18' >/dev/null 2>&1 &
+ }
+ stop_wvkbd() {
+ "$PKILL" -x wvkbd-mobintl >/dev/null 2>&1 || true
+ }
+
+ apply_mode() {
+ mode="$1"
+ transform="$(current_transform)"
+ if [ "$mode" = "tablet" ]; then
+ "$HYPRCTL" keyword monitor "$monitor_name,1200x2000@60.0,0x0,$tablet_scale, transform, $transform" >/dev/null 2>&1 || true
+ start_wvkbd
+ else
+ "$HYPRCTL" keyword monitor "$monitor_name,1200x2000@60.0,0x0,$keyboard_scale, transform, $transform" >/dev/null 2>&1 || true
+ stop_wvkbd
+ fi
+ "$HYPRCTL" keyword input:touchdevice:output "$monitor_name" >/dev/null 2>&1 || true
+ "$HYPRCTL" keyword input:touchdevice:transform "$transform" >/dev/null 2>&1 || true
+ printf "%s\n" "$mode" > "$state_file"
+ }
+
+ # Always reapply on startup — the cached state file may lie (e.g., session restart while
+ # pogo state changed) and wvkbd needs to be (re)spawned since nothing else launches it now.
+ previous_mode=""
+
+ while true; do
+ if has_attached_pogo_dock; then
+ mode="keyboard"
+ else
+ mode="tablet"
+ fi
+
+ if [ "$mode" != "$previous_mode" ]; then
+ apply_mode "$mode"
+ previous_mode="$mode"
+ fi
+ sleep 2
+ done
+ '';
+
+ ideapadAutoRotateDaemon = pkgs.writeShellScriptBin "ideapad-autorotate-daemon" ''
+ #!/usr/bin/env bash
+ set -euo pipefail
+
+ HYPRCTL="${pkgs.hyprland}/bin/hyprctl"
+ MONITOR_SENSOR="${pkgs.iio-sensor-proxy}/bin/monitor-sensor"
+
+ monitor_name="DSI-1"
+ keyboard_scale="1.25"
+ tablet_scale="1.6"
+ state_file="''${XDG_RUNTIME_DIR:-/run/user/$(${pkgs.coreutils}/bin/id -u)}/ideapad-input-mode.state"
+
+ scale_for_mode() {
+ mode="$(cat "$state_file" 2>/dev/null || echo keyboard)"
+ if [ "$mode" = "tablet" ]; then
+ printf "%s\n" "$tablet_scale"
+ else
+ printf "%s\n" "$keyboard_scale"
+ fi
+ }
+
+ transform_for_orientation() {
+ orientation="$1"
+ case "$orientation" in
+ normal) printf "0\n" ;;
+ bottom-up) printf "2\n" ;;
+ left-up) printf "1\n" ;;
+ right-up) printf "3\n" ;;
+ *) return 1 ;;
+ esac
+ }
+
+ apply_orientation() {
+ orientation="$1"
+ transform="$(transform_for_orientation "$orientation")" || return 0
+ scale="$(scale_for_mode)"
+ "$HYPRCTL" keyword monitor "$monitor_name,1200x2000@60.0,0x0,$scale, transform, $transform" >/dev/null 2>&1 || true
+ "$HYPRCTL" keyword input:touchdevice:output "$monitor_name" >/dev/null 2>&1 || true
+ "$HYPRCTL" keyword input:touchdevice:transform "$transform" >/dev/null 2>&1 || true
+ }
+
+ while true; do
+ "$MONITOR_SENSOR" --accel 2>/dev/null | while IFS= read -r line; do
+ case "$line" in
+ *normal*) apply_orientation "normal" ;;
+ *bottom-up*) apply_orientation "bottom-up" ;;
+ *left-up*) apply_orientation "left-up" ;;
+ *right-up*) apply_orientation "right-up" ;;
+ esac
+ done
+ sleep 2
+ done
+ '';
+
+ # ─────────────────────── Niri helpers ───────────────────────
+ # `niri msg output DSI-1 transform ` accepts: normal | 90 | 180 | 270.
+ #
+ # Empirical mapping for this exact device + ACCEL_MOUNT_MATRIX (lenovo-wormdingler family):
+ # physical position iio orientation correct niri transform
+ # ────────────────── ──────────────── ──────────────────────
+ # keyboard down (LS) left-up normal
+ # keyboard up (LS) right-up 180
+ # keyboard right (P) bottom-up 90
+ # keyboard left (P) normal 270
+ # iio's "normal" is *not* the natural landscape pose here — the panel-mount matrix in the
+ # mobile-nixos device file biases it toward portrait-with-keyboard-left. Don't trust the
+ # textbook iio→Wayland convention; trust the table above.
+ ideapadNiriTabletModeDaemon = pkgs.writeShellScriptBin "ideapad-niri-tablet-mode-daemon" ''
+ #!/usr/bin/env bash
+ set -euo pipefail
+
+ NIRI="${pkgs.niri}/bin/niri"
+ JQ="${pkgs.jq}/bin/jq"
+ PKILL="${pkgs.procps}/bin/pkill"
+ PGREP="${pkgs.procps}/bin/pgrep"
+ WVKBD="${pkgs.wvkbd}/bin/wvkbd-mobintl"
+
+ output_name="DSI-1"
+ keyboard_scale="1.25"
+ tablet_scale="1.6"
+ state_file="''${XDG_RUNTIME_DIR:-/run/user/$(${pkgs.coreutils}/bin/id -u)}/ideapad-input-mode.state"
+
+ has_attached_pogo_dock() {
+ # Niri doesn't expose input devices via IPC, so we look in `/dev/input/by-id` for the
+ # USB-class enumeration of the Google Hammer / Whiskers pogo keyboard. The directory
+ # contains symlinks; presence of either string means the cover is attached.
+ # NOTE: `find` ships in findutils, not coreutils — using the wrong package here makes
+ # the pipeline fail silently (stderr is dropped) and pogo always reads as detached.
+ ${pkgs.findutils}/bin/find /dev/input/by-id -maxdepth 1 -type l 2>/dev/null \
+ | ${pkgs.gnugrep}/bin/grep -i -E 'google.*hammer|google.*whiskers' >/dev/null
+ }
+
+ # wvkbd is owned by this daemon: spawn on pogo detach, kill on pogo attach. The DMS bar
+ # `pkill -SIGRTMIN -x wvkbd-mobintl` toggle still works while wvkbd is running (i.e., in
+ # tablet mode). When the pogo cover is on, the pill is a no-op — that's intentional, the
+ # physical keyboard is the input.
+ start_wvkbd() {
+ "$PGREP" -x wvkbd-mobintl >/dev/null 2>&1 && return 0
+ "$WVKBD" --non-exclusive -H 520 -L 360 --fn 'DejaVu Sans 18' >/dev/null 2>&1 &
+ }
+ stop_wvkbd() {
+ "$PKILL" -x wvkbd-mobintl >/dev/null 2>&1 || true
+ }
+
+ apply_mode() {
+ mode="$1"
+ if [ "$mode" = "tablet" ]; then
+ "$NIRI" msg output "$output_name" scale "$tablet_scale" >/dev/null 2>&1 || true
+ start_wvkbd
+ else
+ "$NIRI" msg output "$output_name" scale "$keyboard_scale" >/dev/null 2>&1 || true
+ stop_wvkbd
+ fi
+ printf "%s\n" "$mode" > "$state_file"
+ }
+
+ # Always reapply on startup — see Hyprland daemon's identical comment.
+ previous_mode=""
+
+ while true; do
+ if has_attached_pogo_dock; then
+ mode="keyboard"
+ else
+ mode="tablet"
+ fi
+
+ if [ "$mode" != "$previous_mode" ]; then
+ apply_mode "$mode"
+ previous_mode="$mode"
+ fi
+ sleep 2
+ done
+ '';
+
+ ideapadNiriAutoRotateDaemon = pkgs.writeShellScriptBin "ideapad-niri-autorotate-daemon" ''
+ #!/usr/bin/env bash
+ set -euo pipefail
+
+ NIRI="${pkgs.niri}/bin/niri"
+ MONITOR_SENSOR="${pkgs.iio-sensor-proxy}/bin/monitor-sensor"
+
+ output_name="DSI-1"
+
+ transform_for_orientation() {
+ orientation="$1"
+ case "$orientation" in
+ normal) printf "270\n" ;;
+ bottom-up) printf "90\n" ;;
+ left-up) printf "normal\n" ;;
+ right-up) printf "180\n" ;;
+ *) return 1 ;;
+ esac
+ }
+
+ apply_orientation() {
+ orientation="$1"
+ transform="$(transform_for_orientation "$orientation")" || return 0
+ "$NIRI" msg output "$output_name" transform "$transform" >/dev/null 2>&1 || true
+ }
+
+ while true; do
+ "$MONITOR_SENSOR" --accel 2>/dev/null | while IFS= read -r line; do
+ case "$line" in
+ *normal*) apply_orientation "normal" ;;
+ *bottom-up*) apply_orientation "bottom-up" ;;
+ *left-up*) apply_orientation "left-up" ;;
+ *right-up*) apply_orientation "right-up" ;;
+ esac
+ done
+ sleep 2
+ done
+ '';
+in
+{
+ # ─────────────────────── Hardware ───────────────────────
+ hardware.sensor.iio.enable = true;
+
+ # Touchscreen calibration — solved empirically with `niri msg output DSI-1 transform normal`
+ # in the natural kb-down pose. The panel's touch hardware reports raw coordinates already
+ # aligned with the panel-native frame (HW(visual_top_left) = (0,0), etc.), so identity is
+ # correct. `niri input.touch.map-to-output = "DSI-1"` then handles per-orientation rotation
+ # on top — never re-tune this matrix per orientation; rotate the *output* instead.
+ services.udev.extraRules = ''
+ SUBSYSTEM=="input", ENV{ID_INPUT_TOUCHSCREEN}=="1", ENV{LIBINPUT_CALIBRATION_MATRIX}="1 0 0 0 1 0"
+ '';
+
+ # ─────────────────────── User-facing tools ───────────────────────
+ # System-wide so any user session (Niri or Hyprland) can launch wvkbd / hyprctl / niri-msg helpers.
+ environment.systemPackages = [
+ pkgs.wvkbd
+ pkgs.iio-sensor-proxy
+ ideapadTabletModeDaemon
+ ideapadAutoRotateDaemon
+ ideapadNiriTabletModeDaemon
+ ideapadNiriAutoRotateDaemon
+ ];
+
+ # ─────────────────────── Niri session autostart ───────────────────────
+ # Set on the NixOS layer; the `desktopNiri` HM module merges this into per-user `niri/config.kdl`.
+ # Touch input is glued to DSI-1 so it follows whatever transform the autorotate daemon sets.
+ # wvkbd is intentionally NOT spawned here — `ideapad-niri-tablet-mode-daemon` starts/kills it
+ # in response to pogo-cover attach/detach so we don't keep a virtual-keyboard surface alive
+ # while a physical keyboard is plugged in.
+ chiasson.desktop.niri.extraSettings = lib.mkIf config.chiasson.desktop.niri.enable {
+ input.touch.map-to-output = "DSI-1";
+
+ # wrapper-modules schema: each entry is a `command argv` list of strings (or a single string).
+ spawn-at-startup = [
+ [ "ideapad-niri-autorotate-daemon" ]
+ [ "ideapad-niri-tablet-mode-daemon" ]
+ ];
+ };
+
+ # ─────────────────────── Hyprland session autostart ───────────────────────
+ # Same lifecycle policy as Niri: the tablet-mode daemon owns wvkbd, no exec-once for it.
+ chiasson.desktop.hyprland.settings = lib.mkIf config.chiasson.desktop.hyprland.enable {
+ exec-once = lib.mkAfter [
+ "${pkgs.procps}/bin/pkill -x ideapad-tablet-mode-daemon >/dev/null 2>&1 || true; ideapad-tablet-mode-daemon &"
+ "${pkgs.procps}/bin/pkill -x ideapad-autorotate-daemon >/dev/null 2>&1 || true; ideapad-autorotate-daemon &"
+ ];
+ bind = lib.mkAfter [
+ "Super, K, exec, pkill -SIGRTMIN -x wvkbd-mobintl"
+ ];
+ monitor = lib.mkAfter [
+ "DSI-1,1200x2000@60.0,0x0,1.25, transform, 1"
+ ];
+ };
+}
diff --git a/modules/hosts/ideapad/configuration.nix b/modules/hosts/ideapad/configuration.nix
new file mode 100644
index 0000000..56f4f5e
--- /dev/null
+++ b/modules/hosts/ideapad/configuration.nix
@@ -0,0 +1,201 @@
+{ self, inputs, ... }: {
+
+ # Lenovo Chromebook Duet 3 (`lenovo-wormdingler`) on Mobile NixOS.
+ #
+ # Phase 1 (minimal bootstrap) lived here previously; we now run the full V2 stack:
+ # mobile-nixos device + Niri/Hyprland/DMS, DankGreeter, Waydroid (tablet-class), wvkbd,
+ # IIO sensors, touchscreen calibration, attic cache, sops, and the standard user catalog.
+ flake.nixosModules.ideapadConfiguration =
+ {
+ self,
+ config,
+ lib,
+ pkgs,
+ ...
+ }:
+ {
+ imports = [
+ # Mobile NixOS device + family + depthcharge system-type.
+ (import "${inputs.mobile-nixos}/lib/configuration.nix" {
+ device = "lenovo-wormdingler";
+ })
+
+ self.nixosModules.ideapadHardware
+
+ inputs.home-manager.nixosModules.home-manager
+ inputs.sops-nix.nixosModules.sops
+
+ self.nixosModules.system
+ self.nixosModules.desktop
+ self.nixosModules.users
+
+ self.nixosModules."client-services"
+
+ # Host-only: IIO + touchscreen calibration + per-compositor tablet/autorotate helpers.
+ ./_private/touch-tablet.nix
+
+ # Host-only: cpufreq, lid/power-button policy, upower thresholds.
+ ./_private/platform.nix
+ ];
+
+ # ─────────────────────── Sops ───────────────────────
+ # `host_ideapad` recipient in `.sops.yaml` derives from the new ed25519 host key (post-reflash).
+ sops = {
+ defaultSopsFile = ../../../secrets/secrets.yaml;
+ defaultSopsFormat = "yaml";
+ age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
+ };
+
+ sops.secrets."users/olivier/hashedPassword".neededForUsers = true;
+ sops.secrets."caching/attic/token" = {
+ owner = "olivier";
+ group = "users";
+ mode = "0400";
+ };
+ sops.secrets."swiftshare/API_KEY" = {
+ owner = "olivier";
+ group = "users";
+ mode = "0400";
+ };
+
+ # ─────────────────────── Mobile NixOS / firmware ───────────────────────
+ # mruby's test-suite breaks on aarch64 in the Nix sandbox; the overlay strips checks and
+ # rebuilds Mobile NixOS' script-loader against the patched mruby.
+ chiasson.system.ideapadMrubyOverlay.enable = true;
+
+ # Wi-Fi modem (qcom-wcn3990) + Bluetooth (QCA crnv32) need binary blobs.
+ nixpkgs.config.allowUnfreePredicate =
+ pkg: builtins.elem (lib.getName pkg) [
+ "chromeos-sc7180-unredistributable-firmware"
+ "chromeos-sc7180-unredistributable-firmware-zstd"
+ ];
+ hardware.firmware = [ pkgs.chromeos-sc7180-unredistributable-firmware ];
+ hardware.enableRedistributableFirmware = true;
+
+ # ─────────────────────── Attic (substitution + push + CLI token) ───────
+ chiasson.system.caching.attic = {
+ enable = true;
+ cacheName = "nixos-new";
+ endpoint = "http://192.168.2.238:8080/";
+ publicKey = "nixos-new:8NySIcT0HP7KvGQKgBRWoWESxxRA8BVYo8S85UNpNX0=";
+ tokenFile = config.sops.secrets."caching/attic/token".path;
+ push.enable = true;
+ userCli.enable = true;
+ };
+
+ # ─────────────────────── System bits ───────────────────────
+ chiasson.system = {
+ audio.enable = true;
+ networking = {
+ hostName = "ideapad";
+ networkManager = {
+ enable = true;
+ unmanaged = [ ];
+ };
+ wifi.tools.enabled = true;
+ };
+ extraPackages = with pkgs; [
+ gitMinimal
+ sops
+ ssh-to-age
+ ];
+ };
+
+ # ─────────────────────── Desktop ───────────────────────
+ # Both compositors enabled; DankGreeter lets you pick at login. Default = Niri (V2 convention),
+ # Hyprland session is where the tablet-mode + autorotate daemons in `_private/touch-tablet.nix`
+ # actually run (they hook `exec-once`).
+ chiasson.desktop = {
+ niri.enable = true;
+ hyprland.enable = true;
+
+ defaultSession = "niri";
+ shell = "dms";
+ shells.dms = {
+ enableWvkbdToggle = true;
+ # Cross-build on the 14900k via binfmt and push back over LAN — much faster than
+ # rebuilding aarch64 closure on the Snapdragon. Mirrors the old NixOS-New flow:
+ # ssh out to nixdesk, run nixos-rebuild --target-host pointing back at us.
+ rebuildCommand = [
+ "bash"
+ "-lc"
+ ''
+ ssh -t olivier@nixdesk \
+ "nixos-rebuild switch --flake path:/home/olivier/NixOS-V2#ideapad --target-host olivier@ideapad --sudo --ask-sudo-password 2>&1"
+ ''
+ ];
+ };
+
+ # Tablet-class screen → constrain Waydroid to a sane portrait-ish frame and use gesture nav
+ # instead of 3-button so it feels like the ChromeOS tablet UI.
+ #waydroid = {
+ # enable = true;
+ # multiWindows = false;
+ # width = 1600;
+ # height = 960;
+ # navigationMode = "gestures";
+ #};
+ };
+
+ # ─────────────────────── Users / HM ───────────────────────
+ chiasson.users.enabled = [ "olivier" ];
+
+ # Touch-friendly application set, mirroring uConsole's selection (no heavy IDEs / gaming).
+ chiasson.users.extraModules.olivier = [
+ self.homeManagerModules.wisdomFilebrowsersDolphin
+ self.homeManagerModules.wisdomTerminalsKitty
+ self.homeManagerModules.wisdomBrowsersZen
+ self.homeManagerModules.wisdomEditorsKate
+ self.homeManagerModules.wisdomShellFish
+ self.homeManagerModules.wisdomShellOhMyPosh
+ self.homeManagerModules.wisdomAppsSpotify
+ self.homeManagerModules.wisdomAppsLocalsend
+ self.homeManagerModules.wisdomDesktopScreenshot
+ {
+ chiasson.home = {
+ shell = {
+ fish.enable = true;
+ ohMyPosh.enable = true;
+ };
+ terminals.kitty.enable = true;
+ filebrowsers.dolphin.enable = true;
+ browsers.zen.enable = true;
+ editors.kate.enable = true;
+ apps.spotify.enable = true;
+ apps.localsend.enable = true;
+ desktop = {
+ screenshot = {
+ enable = true;
+ swiftshareApiKeyFile = "/run/secrets/swiftshare/API_KEY"; #TODO[epic=sops] redo this by passing sops file output directly
+ };
+ };
+ };
+ }
+ # Tablet-class apps: kept inline rather than promoting to wisdom modules — these aren't
+ # part of the broader catalog (no use on uConsole / 14900k / servers) and adding a wisdom
+ # module per single-host package would just be ceremony. If a second tablet host ever
+ # appears, factor them out then.
+ #
+ # NOTE on cameras: no v4l2/libcamera GUI is installed. The Mobile NixOS kernel for
+ # `lenovo-wormdingler` ships with `CONFIG_VIDEO_QCOM_CAMSS` disabled and no
+ # `VIDEO_OV*`/`VIDEO_HI*` sensor drivers, so `/dev/video0`-`/dev/video1` only expose
+ # the Qualcomm Venus codecs (h.264/h.265 enc/dec) and there is no camera source for
+ # PipeWire / libcamera to pick up. See `_private/CAMERA-TODO.md` for the steps that
+ # would (potentially) bring the front/rear cameras online — it's a kernel-rebuild +
+ # device-tree + libcamera project, not a config tweak.
+ (
+ { pkgs, ... }:
+ {
+ home.packages = with pkgs; [
+ # PDF viewer — fits the existing KDE app set (Dolphin + Kate).
+ kdePackages.okular
+ # ePub reader, GTK4, large touch targets.
+ foliate
+ ];
+ }
+ )
+ ];
+
+ system.stateVersion = "26.05";
+ };
+}
diff --git a/modules/hosts/ideapad/default.nix b/modules/hosts/ideapad/default.nix
new file mode 100644
index 0000000..f974b04
--- /dev/null
+++ b/modules/hosts/ideapad/default.nix
@@ -0,0 +1,14 @@
+{ self, inputs, ... }: {
+
+ flake.nixosConfigurations.ideapad = inputs.nixpkgs.lib.nixosSystem {
+ system = "aarch64-linux";
+ specialArgs = {
+ inherit self inputs;
+ host = "ideapad";
+ system = "aarch64-linux";
+ };
+ modules = [
+ self.nixosModules.ideapadConfiguration
+ ];
+ };
+}
diff --git a/modules/hosts/ideapad/hardware.nix b/modules/hosts/ideapad/hardware.nix
new file mode 100644
index 0000000..f9ee833
--- /dev/null
+++ b/modules/hosts/ideapad/hardware.nix
@@ -0,0 +1,22 @@
+{ ... }: {
+ flake.nixosModules.ideapadHardware =
+ # Mobile NixOS' depthcharge system-type wires up the disk image, kernel partitions and
+ # rootfs entirely; we don't need a generated `hardware-configuration.nix`. This module is a
+ # placeholder so the host follows the standard `Configuration` / `Hardware` shape
+ # and gives us a place to drop kernel-config knobs that aren't covered by the device family.
+ { ... }:
+ {
+ # Useful on a portable: mounting USB sticks (often exFAT) and SMB shares.
+ boot.supportedFilesystems = [ "exfat" "cifs" ];
+
+ # Mobile NixOS builds its own kernel — the regular `boot.kernelModules` won't help if the
+ # module isn't compiled in. `mobile.kernel.structuredConfig` lives upstream in
+ # `modules/system-types/depthcharge/kernel/` and is the right layer to add features.
+ mobile.kernel.structuredConfig = [
+ (helpers: with helpers; {
+ CIFS = module;
+ EXFAT_FS = module;
+ })
+ ];
+ };
+}
diff --git a/modules/hosts/nix-server/_services/attic-cache-server.nix b/modules/hosts/nix-server/_services/attic-cache-server.nix
new file mode 100644
index 0000000..15a8999
--- /dev/null
+++ b/modules/hosts/nix-server/_services/attic-cache-server.nix
@@ -0,0 +1,30 @@
+{ config, ... }: {
+ sops = {
+ templates."atticd.env" = {
+ owner = "root";
+ group = "root";
+ mode = "0400";
+ content = ''
+ ATTIC_SERVER_TOKEN_RS256_SECRET_BASE64=${config.sops.placeholder."attic/server-token-rs256-secret-base64"}
+ '';
+ };
+ };
+
+ sops.secrets."attic/server-token-rs256-secret-base64" = {
+ sopsFile = ../../../../secrets/attic-secrets.yaml;
+ owner = "root";
+ group = "root";
+ mode = "0400";
+ };
+
+ services.atticd = {
+ enable = true;
+ environmentFile = config.sops.templates."atticd.env".path;
+ settings = {
+ listen = "[::]:8080";
+ jwt = { };
+ };
+ };
+
+ chiasson.system.networking.firewall.allowedTCPPorts = [ 8080 ];
+}
\ No newline at end of file
diff --git a/modules/hosts/nix-server/_services/ddrm-media-server.nix b/modules/hosts/nix-server/_services/ddrm-media-server.nix
new file mode 100644
index 0000000..7a20f7c
--- /dev/null
+++ b/modules/hosts/nix-server/_services/ddrm-media-server.nix
@@ -0,0 +1,12 @@
+# DDRM Flask backend (Widevine / PlayReady decrypt). Extension URL: http://:58239
+{ pkgs, inputs, ... }:
+{
+ services.ddrm-media-server = {
+ enable = true;
+ port = 58239;
+ listenAddress = "0.0.0.0";
+ openFirewall = true;
+ package = inputs.ddrm.packages.${pkgs.stdenv.hostPlatform.system}.default;
+ # State: /var/lib/ddrm-media (venv + configs + CDMs on first run — needs network for pip).
+ };
+}
diff --git a/modules/hosts/nix-server/_services/flaresolverr.nix b/modules/hosts/nix-server/_services/flaresolverr.nix
new file mode 100644
index 0000000..07f1464
--- /dev/null
+++ b/modules/hosts/nix-server/_services/flaresolverr.nix
@@ -0,0 +1,11 @@
+{ ... }:
+{
+ # FlareSolverr (Cloudflare / JS challenge solver for some indexers).
+ # Typically used by Prowlarr as an HTTP proxy.
+ #
+ # UI/endpoint: http://:8191
+ services.flaresolverr.enable = true;
+
+ networking.firewall.allowedTCPPorts = [ 8191 ];
+}
+
diff --git a/modules/hosts/nix-server/_services/immich.nix b/modules/hosts/nix-server/_services/immich.nix
new file mode 100644
index 0000000..b145dd2
--- /dev/null
+++ b/modules/hosts/nix-server/_services/immich.nix
@@ -0,0 +1,29 @@
+{ config, ... }:
+let
+ secretFilePath = ../secrets.yaml;
+in
+{
+ sops.secrets."immich/database-password".sopsFile = secretFilePath;
+
+ # Placeholders are expanded only inside template `content` (not in arbitrary Nix strings).
+ sops.templates."immich-db.env" = {
+ content = ''
+ POSTGRES_PASSWORD=${config.sops.placeholder."immich/database-password"}
+ DB_PASSWORD=${config.sops.placeholder."immich/database-password"}
+ '';
+ };
+
+ chiasson.system.services.immich = {
+ enable = true;
+ host = "0.0.0.0";
+ port = 2283;
+ timezone = "America/Moncton";
+ uploadLocation = "/var/lib/immich/library";
+ environmentFiles = [ config.sops.templates."immich-db.env".path ];
+ postgres = {
+ user = "postgres";
+ #password = ""; # Defined in sops.templates."immich-db.env"
+ database = "immich";
+ };
+ };
+}
diff --git a/modules/hosts/nix-server/_services/jellyfin-remote-storage.nix b/modules/hosts/nix-server/_services/jellyfin-remote-storage.nix
new file mode 100644
index 0000000..622c543
--- /dev/null
+++ b/modules/hosts/nix-server/_services/jellyfin-remote-storage.nix
@@ -0,0 +1,27 @@
+# NFS read-only mount of nixdesk (14900k) bulk storage for extra Jellyfin libraries.
+# Source: ssh inventory hostName for 14900k. Export is defined in
+# modules/hosts/14900k/_private/jellyfin-nfs-export.nix
+#
+# In Jellyfin (in addition to local /var/lib/media/...), add e.g.:
+# Movies → /mnt/nixdesk-jellyfin/movies
+# Shows → /mnt/nixdesk-jellyfin/tv
+{ ... }:
+let
+ # Must match LAN IP of the NFS server (flake `sshInventory` → hosts."14900k".hostName).
+ nfsExportHost = "192.168.2.25";
+in
+{
+ fileSystems."/mnt/nixdesk-jellyfin" = {
+ device = "${nfsExportHost}:/mnt/test/jellyfin";
+ fsType = "nfs";
+ options = [
+ "rw"
+ "noatime"
+ "nofail"
+ "_netdev"
+ "x-systemd.automount"
+ "x-systemd.idle-timeout=600"
+ ];
+ };
+
+}
diff --git a/modules/hosts/nix-server/_services/jellyfin.nix b/modules/hosts/nix-server/_services/jellyfin.nix
new file mode 100644
index 0000000..94310bf
--- /dev/null
+++ b/modules/hosts/nix-server/_services/jellyfin.nix
@@ -0,0 +1,60 @@
+# Jellyfin (native NixOS service). Local media: /var/lib/media (group `media`; jellyfin + server).
+# Dashboard: Movies → /var/lib/media/movies, Shows → /var/lib/media/tv (see jellyfin-remote-storage.nix
+# for bulk libraries on nixdesk at /mnt/nixdesk-jellyfin/{movies,tv}).
+# Do not use "Mixed Movies and Shows" (deprecated): https://jellyfin.org/docs/general/server/media/mixed-movies-and-shows
+# Dedicated disk: fileSystems."/var/lib/media" in hardware.nix, then fix ownership.
+{ lib, ... }:
+{
+ nixpkgs.overlays = [
+ (final: prev: {
+ jellyfin-web = prev.jellyfin-web.overrideAttrs (oldAttrs: {
+ postInstall =
+ (oldAttrs.postInstall or "")
+ + ''
+ # Blank default Jellyfin banner assets (read-only store otherwise). Wildcards
+ # track hashed filenames across jellyfin-web releases; bump if layout changes.
+ find "$out" -type f \( -name 'banner-light.*.png' -o -name 'banner-dark.*.png' \) \
+ -exec truncate -s 0 {} \;
+ '';
+ });
+ })
+ ];
+
+ users.groups.media = { };
+
+ users.users.jellyfin.extraGroups = [ "media" ];
+ users.users.server.extraGroups = [ "media" ];
+
+ systemd.tmpfiles.settings."nix-server-var-lib-media" = {
+ "/var/lib/media"."d" = {
+ mode = "0775";
+ user = "root";
+ group = "media";
+ };
+ "/var/lib/media/movies"."d" = {
+ mode = "0775";
+ user = "root";
+ group = "media";
+ };
+ "/var/lib/media/tv"."d" = {
+ mode = "0775";
+ user = "root";
+ group = "media";
+ };
+ };
+
+ services.jellyfin = {
+ enable = true;
+ openFirewall = true;
+ };
+
+ # `users.users.jellyfin.extraGroups` does not affect systemd; the service must list
+ # supplementary groups explicitly. Without `media`, directories mode 775 root:media are
+ # not writable by uid jellyfin (it only had group `jellyfin`), so deletes fail.
+ systemd.services.jellyfin.serviceConfig = {
+ SupplementaryGroups = [ "media" ];
+ # Jellyfin libraries may live on NFS (e.g. /mnt/nixdesk-jellyfin). PrivateUsers breaks
+ # uid mapping for NFS auth in practice; disable so deletes use the real host jellyfin uid.
+ PrivateUsers = lib.mkForce false;
+ };
+}
diff --git a/modules/hosts/nix-server/_services/organizr.nix b/modules/hosts/nix-server/_services/organizr.nix
new file mode 100644
index 0000000..80a0685
--- /dev/null
+++ b/modules/hosts/nix-server/_services/organizr.nix
@@ -0,0 +1,53 @@
+# Organizr — homelab dashboard (Docker). UI: http://:8888
+# Official image: https://github.com/organizr/docker-organizr
+#
+# Wizard errors like "API … /default/ not writable" are almost always host permissions on
+# `/var/lib/organizr`: the first container run may leave root-owned files under `/config`.
+{ lib, pkgs, ... }:
+{
+ users.groups.organizr = { gid = 950; };
+ users.users.organizr = {
+ isSystemUser = true;
+ uid = 950;
+ group = "organizr";
+ };
+
+ systemd.tmpfiles.settings."nix-server-organizr-config" = {
+ "/var/lib/organizr"."d" = {
+ mode = "0755";
+ user = "organizr";
+ group = "organizr";
+ };
+ };
+
+ # Recursively reset ownership (handles root-owned files from an earlier container run).
+ systemd.tmpfiles.settings."nix-server-organizr-config-perms" = {
+ "/var/lib/organizr"."Z" = {
+ mode = "0755";
+ user = "organizr";
+ group = "organizr";
+ };
+ };
+
+ systemd.services.docker-organizr.preStart = lib.mkBefore ''
+ ${pkgs.coreutils}/bin/mkdir -p /var/lib/organizr
+ ${pkgs.coreutils}/bin/chown -R organizr:organizr /var/lib/organizr
+ '';
+
+ virtualisation.oci-containers.containers.organizr = {
+ image = "ghcr.io/organizr/organizr:latest";
+ ports = [ "8888:80" ];
+ volumes = [
+ "/var/lib/organizr:/config"
+ ];
+ environment = {
+ PUID = "950";
+ PGID = "950";
+ TZ = "America/Moncton";
+ # v2-master / master are stable v2; optional override:
+ # branch = "v2-master";
+ };
+ };
+
+ networking.firewall.allowedTCPPorts = [ 8888 ];
+}
diff --git a/modules/hosts/nix-server/_services/portainer.nix b/modules/hosts/nix-server/_services/portainer.nix
new file mode 100644
index 0000000..63e7cdd
--- /dev/null
+++ b/modules/hosts/nix-server/_services/portainer.nix
@@ -0,0 +1,20 @@
+{config, ...}: {
+ virtualisation = {
+ docker.enable = true;
+ oci-containers = {
+ backend = "docker";
+ containers = {
+ portainer = {
+ image = "portainer/portainer-ce:latest";
+ ports = [ "9443:9443" ];
+ volumes = [
+ "/var/run/docker.sock:/var/run/docker.sock"
+ "/var/lib/portainer:/data"
+ ];
+
+ };
+ };
+ };
+ };
+ networking.firewall.allowedTCPPorts = [ 9443 ];
+}
\ No newline at end of file
diff --git a/modules/hosts/nix-server/_services/prowlarr.nix b/modules/hosts/nix-server/_services/prowlarr.nix
new file mode 100644
index 0000000..314b6dd
--- /dev/null
+++ b/modules/hosts/nix-server/_services/prowlarr.nix
@@ -0,0 +1,22 @@
+{ lib, ... }:
+{
+ # Prowlarr (indexer manager). UI: http://:9696
+ # Data dir is /var/lib/prowlarr (see systemd unit ExecStart -data=…), not ~/.config/Prowlarr.
+ services.prowlarr.enable = true;
+
+ # Useful when Prowlarr/Sonarr/Radarr need to write into shared areas (downloads, etc.).
+ users.groups.prowlarr = { };
+ users.users.prowlarr = {
+ isSystemUser = true;
+ group = "prowlarr";
+ extraGroups = [ "media" ];
+ };
+
+ systemd.services.prowlarr.preStart = lib.mkBefore ''
+ mkdir -p /var/lib/prowlarr/Definitions/Custom
+ ln -sf ${./prowlarr/torrent9-custom.yml} /var/lib/prowlarr/Definitions/Custom/torrent9-custom.yml
+ '';
+
+ networking.firewall.allowedTCPPorts = [ 9696 ];
+}
+
diff --git a/modules/hosts/nix-server/_services/prowlarr/torrent9-custom.yml b/modules/hosts/nix-server/_services/prowlarr/torrent9-custom.yml
new file mode 100644
index 0000000..d985cbb
--- /dev/null
+++ b/modules/hosts/nix-server/_services/prowlarr/torrent9-custom.yml
@@ -0,0 +1,193 @@
+---
+id: torrent9-custom
+name: Torrent9 (Custom URL)
+description: "Torrent9 is a FRENCH Public site for MOVIES / TV / GENERAL"
+language: fr-FR
+type: public
+encoding: UTF-8
+followredirect: true
+testlinktorrent: false
+links:
+ - https://www.torrent9.club/
+ - https://www6.torrent9.to/
+legacylinks:
+ - https://www.torrent9.pl/ # this is a proxy for torrent9clone
+ - https://torrent9.black-mirror.xyz/ # this is a proxy for torrent9clone
+ - https://torrent9.unblocked.casa/ # this is a proxy for torrent9clone
+ - https://torrent9.proxyportal.fun/ # this is a proxy for torrent9clone
+ - https://torrent9.uk-unblock.xyz/ # this is a proxy for torrent9clone
+ - https://torrent9.ind-unblock.xyz/ # this is a proxy for torrent9clone
+ - https://ww1.torrent9.to/
+ - https://www.torrent9.is/
+ - https://torrent9.li/ # not a proxy for torrent9 or torrent9clone
+ - https://www.oxtorrent.me/
+ - https://www.torrent9.gg/
+ - https://www.torrent9.fi/ # this is the torrent9clone domain
+ - https://www.torrent9.fm/
+ - https://torrent9.se/ # redirect to www.
+ - https://torrent9.ninjaproxy1.com/ # no response data
+ - https://torrent9.proxyninja.org/ # Error 1007
+ - https://www.torrent9.se/
+ - https://torrent9.unblockninja.com/ # 403 forbidden
+ - https://ww1.torrent9.fm/
+ - https://www.torrent9.zone/ # clone? details links are broken
+ - https://torrent9.to/
+ - https://ww2.torrent9.to/
+ - https://www5.torrent9.to/
+
+caps:
+ # dont forget to update the search fields category case block
+ categorymappings:
+ - {id: films, cat: Movies, desc: "Movies"}
+ - {id: series, cat: TV, desc: "TV"}
+ - {id: musique, cat: Audio, desc: "Music"}
+ - {id: ebook, cat: Books, desc: "Books"}
+ - {id: logiciels, cat: PC, desc: "Software"}
+ - {id: jeux-pc, cat: PC/Games, desc: "PC Games"}
+ - {id: other, cat: Other, desc: "Other"} # dummy cat for results missing icon
+
+ modes:
+ search: [q]
+ tv-search: [q, season, ep]
+ movie-search: [q]
+ music-search: [q]
+ book-search: [q]
+ allowrawsearch: true
+
+settings:
+ - name: info_flaresolverr
+ type: info_flaresolverr
+ - name: multilang
+ type: checkbox
+ label: Replace MULTi by another language in release name
+ default: false
+ - name: multilanguage
+ type: select
+ label: Replace MULTi by this language
+ default: FRENCH
+ options:
+ FRENCH: FRENCH
+ MULTi FRENCH: MULTi FRENCH
+ ENGLISH: ENGLISH
+ MULTi ENGLISH: MULTi ENGLISH
+ VOSTFR: VOSTFR
+ MULTi VOSTFR: MULTi VOSTFR
+ - name: vostfr
+ type: checkbox
+ label: Replace VOSTFR and SUBFRENCH with ENGLISH
+ default: false
+ - name: sort
+ type: select
+ label: Sort requested from site (Only works for searches with Keywords)
+ default: ".html"
+ options:
+ ".html": best
+ ".html,trie-date-d": created desc
+ ".html,trie-date-a": created asc
+ ".html,trie-seeds-d": seeders desc
+ ".html,trie-seeds-a": seeders asc
+ ".html,trie-poid-d": size desc
+ ".html,trie-poid-a": size asc
+ ".html,trie-nom-d": title desc
+ ".html,trie-nom-a": title asc
+
+download:
+ selectors:
+ - selector: a[href^="magnet:?"]
+ attribute: href
+ - selector: script:contains("magnet:?")
+ filters:
+ - name: regexp
+ args: "\\s'(magnet:\\?.+?)';"
+
+search:
+ paths:
+ - path: "{{ if .Keywords }}search_torrent/{{ .Keywords }}{{ .Config.sort }}{{ else }}home/{{ end }}"
+ keywordsfilters:
+ # if searching for season packs with S01 to saison 1 #9712
+ - name: re_replace
+ args: ["(?i)(S0)(\\d{1,2})$", "saison $2"]
+ - name: re_replace
+ args: ["(?i)(S)(\\d{1,3})$", "saison $2"]
+ - name: replace
+ args: [" ", "-"]
+
+ headers:
+ # site blocks all Linux User-Agents, so use a slightly altered Windows Jackett UA here (e.g. Safari/537.36 > Safari/537.35)
+ User-Agent: ["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.35"]
+
+ rows:
+ selector: table.table-striped > tbody > tr
+ filters:
+ - name: andmatch
+
+ fields:
+ category_optional:
+ selector: td:nth-child(1) i
+ optional: true
+ case:
+ i[class="fa fa-video-camera"]: films
+ i[class="fa fa-tv"]: series # search by name
+ i[class="fa fa-desktop"]: series # keywordless search
+ i[class="fa fa-music"]: musique
+ i[class="fa fa-gamepad"]: jeux-pc
+ i[class="fa fa-laptop"]: logiciels
+ i[class="fa fa-book"]: ebook
+ category:
+ text: "{{ if .Result.category_optional }}{{ .Result.category_optional }}{{ else }}other{{ end }}"
+ title_default:
+ selector: td:nth-child(1) a
+ title_optional:
+ selector: td:nth-child(1) a[title]
+ attribute: title
+ optional: true
+ title_phase1:
+ text: "{{ if .Result.title_optional }}{{ .Result.title_optional }}{{ else }}{{ .Result.title_default }}{{ end }}"
+ filters:
+ - name: re_replace
+ args: ["(?i)\\b(FRENCH|MULTI|TRUEFRENCH|VOSTFR|SUBFRENCH)\\b(.+?)(\\b((19|20)\\d{2})\\b)$", "$3 $1$2"]
+ title_vostfr:
+ text: "{{ .Result.title_phase1 }}"
+ filters:
+ - name: re_replace
+ args: ["(?i)\\b(vostfr|subfrench)\\b", "ENGLISH"]
+ title_phase2:
+ text: "{{ if .Config.vostfr }}{{ .Result.title_vostfr }}{{ else }}{{ .Result.title_phase1 }}{{ end }}"
+ title_multilang:
+ text: "{{ .Result.title_phase2 }}"
+ filters:
+ - name: re_replace
+ args: ["(?i)\\b(MULTI(?!.*(?:FRENCH|ENGLISH|VOSTFR)))\\b", "{{ .Config.multilanguage }}"]
+ title:
+ text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase2 }}{{ end }}"
+ details:
+ selector: td:nth-child(1) a
+ attribute: href
+ download:
+ selector: td:nth-child(1) a
+ attribute: href
+ date:
+ selector: td:nth-child(2):contains("/")
+ optional: true
+ default: now
+ filters:
+ - name: dateparse
+ args: "dd/MM/yyyy"
+ size:
+ selector: "{{ if .Keywords }}td:nth-child(3){{ else }}td:nth-child(2){{ end }}"
+ filters:
+ - name: re_replace
+ args: ["(\\w)o", "$1B"]
+ seeders:
+ selector: "{{ if .Keywords }}td:nth-child(4){{ else }}td:nth-child(3){{ end }}"
+ optional: true
+ default: 0
+ leechers:
+ selector: "{{ if .Keywords }}td:nth-child(5){{ else }}td:nth-child(4){{ end }}"
+ optional: true
+ default: 0
+ downloadvolumefactor:
+ text: 0
+ uploadvolumefactor:
+ text: 1
+# engine n/a
diff --git a/modules/hosts/nix-server/_services/qbittorrent.nix b/modules/hosts/nix-server/_services/qbittorrent.nix
new file mode 100644
index 0000000..6eec8cd
--- /dev/null
+++ b/modules/hosts/nix-server/_services/qbittorrent.nix
@@ -0,0 +1,36 @@
+{ config, lib, ... }:
+let
+ webPort = 8081;
+ btPort = 51413;
+ downloadsDir = "/var/lib/downloads";
+in
+{
+ # qBittorrent (headless). Web UI: http://:8081
+ services.qbittorrent = {
+ enable = true;
+ openFirewall = true;
+ webuiPort = webPort;
+ # Prefer a stable port for NAT/firewall and for easier debugging.
+ torrentingPort = btPort;
+ };
+
+ users.groups.qbittorrent = { };
+ users.users.qbittorrent = {
+ isSystemUser = true;
+ group = "qbittorrent";
+ extraGroups = [ "media" ];
+ };
+
+ systemd.tmpfiles.settings."nix-server-downloads-dir" = {
+ "${downloadsDir}"."d" = {
+ mode = "2775";
+ user = "root";
+ group = "media";
+ };
+ };
+
+ # Some NixOS versions don't open UDP for torrenting even when openFirewall=true.
+ networking.firewall.allowedTCPPorts = [ webPort btPort ];
+ networking.firewall.allowedUDPPorts = [ btPort ];
+}
+
diff --git a/modules/hosts/nix-server/_services/radarr.nix b/modules/hosts/nix-server/_services/radarr.nix
new file mode 100644
index 0000000..62107bb
--- /dev/null
+++ b/modules/hosts/nix-server/_services/radarr.nix
@@ -0,0 +1,16 @@
+{ ... }:
+{
+ # Radarr (movie automation). UI: http://:7878
+ services.radarr.enable = true;
+
+ # Keep permissions aligned with Jellyfin (/var/lib/media via group `media`).
+ users.groups.radarr = { };
+ users.users.radarr = {
+ isSystemUser = true;
+ group = "radarr";
+ extraGroups = [ "media" ];
+ };
+
+ networking.firewall.allowedTCPPorts = [ 7878 ];
+}
+
diff --git a/modules/hosts/nix-server/_services/seerr.nix b/modules/hosts/nix-server/_services/seerr.nix
new file mode 100644
index 0000000..f01f619
--- /dev/null
+++ b/modules/hosts/nix-server/_services/seerr.nix
@@ -0,0 +1,29 @@
+{ ... }:
+{
+ # Blank default Seerr branding assets (read-only store otherwise).
+ nixpkgs.overlays = [
+ (final: prev: {
+ seerr = prev.seerr.overrideAttrs (oldAttrs: {
+ postInstall =
+ (oldAttrs.postInstall or "")
+ + ''
+ find "$out/share/public" -maxdepth 1 -type f \( -name 'logo_full.svg' -o -name 'logo_stacked.svg' \) \
+ -exec truncate -s 0 {} \;
+ '';
+ });
+ })
+ ];
+
+ # "Seerr" request management. For Jellyfin, Jellyseerr is the right choice.
+ # UI: http://:5055
+ services.seerr.enable = true;
+
+ users.groups.jellyseerr = { };
+ users.users.jellyseerr = {
+ isSystemUser = true;
+ group = "jellyseerr";
+ };
+
+ networking.firewall.allowedTCPPorts = [ 5055 ];
+}
+
diff --git a/modules/hosts/nix-server/_services/sonarr.nix b/modules/hosts/nix-server/_services/sonarr.nix
new file mode 100644
index 0000000..d11a6e0
--- /dev/null
+++ b/modules/hosts/nix-server/_services/sonarr.nix
@@ -0,0 +1,16 @@
+{ ... }:
+{
+ # Sonarr (TV automation). UI: http://:8989
+ services.sonarr.enable = true;
+
+ # Ensure Sonarr can manage the same libraries as Jellyfin.
+ users.groups.sonarr = { };
+ users.users.sonarr = {
+ isSystemUser = true;
+ group = "sonarr";
+ extraGroups = [ "media" ];
+ };
+
+ networking.firewall.allowedTCPPorts = [ 8989 ];
+}
+
diff --git a/modules/hosts/nix-server/_services/swiftshare.nix b/modules/hosts/nix-server/_services/swiftshare.nix
new file mode 100644
index 0000000..812ade3
--- /dev/null
+++ b/modules/hosts/nix-server/_services/swiftshare.nix
@@ -0,0 +1,113 @@
+{ config, ... }:
+let
+ secretFilePath = ../secrets.yaml;
+in
+{
+ sops.secrets."swiftshare/ghcr-token".sopsFile = secretFilePath;
+ sops.secrets."swiftshare/database-password".sopsFile = secretFilePath;
+ sops.secrets."swiftshare/oauth-discord-client-secret".sopsFile = secretFilePath;
+ sops.secrets."swiftshare/oauth-github-client-secret".sopsFile = secretFilePath;
+ sops.secrets."swiftshare/auth-secret".sopsFile = secretFilePath;
+ sops.secrets."swiftshare/oauth-google-client-id".sopsFile = secretFilePath;
+ sops.secrets."swiftshare/oauth-google-client-secret".sopsFile = secretFilePath;
+ sops.secrets."swiftshare/smtp-pass".sopsFile = secretFilePath;
+ sops.secrets."swiftshare/minio-access-key".sopsFile = secretFilePath;
+ sops.secrets."swiftshare/minio-secret-key".sopsFile = secretFilePath;
+
+ # Docker `--env-file` expects `KEY=value`. Separate snippets for DB/MinIO so only `swiftshare.env` hits the app container.
+ sops.templates."swiftshare-postgres.env" = {
+ content = ''
+ POSTGRES_PASSWORD=${config.sops.placeholder."swiftshare/database-password"}
+ '';
+ };
+
+ sops.templates."swiftshare-minio.env" = {
+ content = ''
+ MINIO_ROOT_USER=${config.sops.placeholder."swiftshare/minio-access-key"}
+ MINIO_ROOT_PASSWORD=${config.sops.placeholder."swiftshare/minio-secret-key"}
+ '';
+ };
+
+ sops.templates."swiftshare.env" = {
+ content = ''
+ DATABASE_URL=postgresql://swiftshare:${config.sops.placeholder."swiftshare/database-password"}@swiftshare-db:5432/swiftshare
+ AUTH_SECRET=${config.sops.placeholder."swiftshare/auth-secret"}
+ AUTH_DISCORD_SECRET=${config.sops.placeholder."swiftshare/oauth-discord-client-secret"}
+ AUTH_GITHUB_SECRET=${config.sops.placeholder."swiftshare/oauth-github-client-secret"}
+ AUTH_GOOGLE_SECRET=${config.sops.placeholder."swiftshare/oauth-google-client-secret"}
+ AUTH_GOOGLE_ID=${config.sops.placeholder."swiftshare/oauth-google-client-id"}
+ SMTP_PASS=${config.sops.placeholder."swiftshare/smtp-pass"}
+ STORAGE_ACCESS_KEY=${config.sops.placeholder."swiftshare/minio-access-key"}
+ STORAGE_SECRET_KEY=${config.sops.placeholder."swiftshare/minio-secret-key"}
+ '';
+ };
+
+ services.swiftshare = {
+ enable = true;
+
+ app = {
+ image = "ghcr.io/olivierchiasson/swiftshare:main";
+ ghcr = {
+ username = "olivierchiasson";
+ passwordFile = config.sops.secrets."swiftshare/ghcr-token".path;
+ };
+
+ origin = "https://swiftshare.cloud";
+ port = 3000;
+ uploadBodySizeLimit = "100mb";
+ disableTelemetry = true;
+ environmentFiles = [ config.sops.templates."swiftshare.env".path ];
+ };
+
+ database = {
+ user = "swiftshare";
+ #password = ""; # Defined in sops.templates."swiftshare-postgres.env"
+ name = "swiftshare";
+ environmentFiles = [ config.sops.templates."swiftshare-postgres.env".path ];
+ #exposePort.enable = true;
+ };
+
+ auth = {
+ #secret = "";
+
+ discord = {
+ clientId = "1400660345068191855";
+ #clientSecret = ""; # Defined in sops.templates."swiftshare.env"
+ };
+
+ # GitHub OAuth App (https://github.com/settings/developers) — replace placeholders.
+ github = {
+ clientId = "Ov23lifcVKR6B1iYDicU";
+ #clientSecret = ""; # Defined in sops.templates."swiftshare.env"
+ };
+
+ # Google Cloud OAuth 2.0 client — replace placeholders.
+ #google = {
+ # clientId = ""; # Defined in sops.templates."swiftshare.env"
+ # clientSecret = ""; # Defined in sops.templates."swiftshare.env"
+ #};
+
+ # SMTP for Better Auth email verification / password reset.
+ smtp = {
+ host = "smtp.purelymail.com";
+ port = 465;
+ secure = true;
+ user = "noreply@swiftshare.cloud";
+ #pass = ""; # Defined in sops.templates."swiftshare.env"
+ from = "noreply@swiftshare.cloud";
+ };
+ };
+
+ minio = {
+ #accessKey = ""; # Defined in sops.templates."swiftshare-minio.env"
+ #secretKey = ""; # Defined in sops.templates."swiftshare-minio.env"
+ bucketName = "swiftshare-assets";
+ environmentFiles = [ config.sops.templates."swiftshare-minio.env".path ];
+ };
+
+ umami = {
+ websiteId = "b4e1240d-a9d8-4075-b64d-0d3e0329cac8";
+ scriptUrl = "https://analytics.chiasson.cloud/script.js";
+ };
+ };
+}
diff --git a/modules/hosts/nix-server/configuration.nix b/modules/hosts/nix-server/configuration.nix
index 029ec66..49f3c7b 100644
--- a/modules/hosts/nix-server/configuration.nix
+++ b/modules/hosts/nix-server/configuration.nix
@@ -9,14 +9,25 @@
}:
{
imports = [
+ inputs.ddrm.nixosModules.default
self.nixosModules.nix-serverHardware
inputs.sops-nix.nixosModules.sops
self.nixosModules.system
self.nixosModules.users
./_services/attic-cache-server.nix
./_services/portainer.nix
+ ./_services/organizr.nix
./_services/swiftshare.nix
./_services/immich.nix
+ ./_services/jellyfin.nix
+ ./_services/jellyfin-remote-storage.nix
+ ./_services/ddrm-media-server.nix
+ ./_services/sonarr.nix
+ ./_services/prowlarr.nix
+ ./_services/flaresolverr.nix
+ ./_services/radarr.nix
+ ./_services/qbittorrent.nix
+ ./_services/seerr.nix
];
boot.loader.grub = {
diff --git a/modules/lib/ssh-inventory.nix b/modules/lib/ssh-inventory.nix
index 4e9c256..06e3b80 100644
--- a/modules/lib/ssh-inventory.nix
+++ b/modules/lib/ssh-inventory.nix
@@ -9,7 +9,7 @@
};
ideapad = {
- hostName = "192.168.2.113";
+ hostName = "192.168.2.229";
aliases = [ "ideapad" ];
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIQwaaI90xIMjZ46EcMyO8kBwGCxf7qVL75IYhw8Ssze ideapad";
};
diff --git a/modules/patches/yt-dlp-telequebec.patch b/modules/patches/yt-dlp-telequebec.patch
new file mode 100644
index 0000000..7fb7343
--- /dev/null
+++ b/modules/patches/yt-dlp-telequebec.patch
@@ -0,0 +1,383 @@
+diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py
+index 1a29a93ed..94434d5e7 100644
+--- a/yt_dlp/extractor/_extractors.py
++++ b/yt_dlp/extractor/_extractors.py
+@@ -2034,6 +2034,7 @@
+ TeleQuebecEmissionIE,
+ TeleQuebecIE,
+ TeleQuebecLiveIE,
++ TeleQuebecSeasonIE,
+ TeleQuebecSquatIE,
+ TeleQuebecVideoIE,
+ )
+diff --git a/yt_dlp/extractor/telequebec.py b/yt_dlp/extractor/telequebec.py
+index 7f5d5d29b..be927d6c5 100644
+--- a/yt_dlp/extractor/telequebec.py
++++ b/yt_dlp/extractor/telequebec.py
+@@ -1,7 +1,12 @@
++import json
++import re
++
+ from .common import InfoExtractor
+ from ..utils import (
++ ExtractorError,
+ int_or_none,
+ smuggle_url,
++ traverse_obj,
+ try_get,
+ unified_timestamp,
+ )
+@@ -28,81 +33,174 @@ class TeleQuebecIE(TeleQuebecBaseIE):
+ )/(?P\d+)
+ '''
+ _TESTS = [{
+- # available till 01.01.2023
+- 'url': 'http://zonevideo.telequebec.tv/media/37578/un-petit-choc-et-puis-repart/un-chef-a-la-cabane',
+- 'info_dict': {
+- 'id': '6155972771001',
+- 'ext': 'mp4',
+- 'title': 'Un petit choc et puis repart!',
+- 'description': 'md5:b04a7e6b3f74e32d7b294cffe8658374',
+- 'timestamp': 1589262469,
+- 'uploader_id': '6150020952001',
+- 'upload_date': '20200512',
+- },
+- 'add_ie': ['BrightcoveNew'],
++ 'url': 'https://zonevideo.telequebec.tv/media/1/exemple',
++ 'only_matching': True,
+ }, {
+- 'url': 'https://zonevideo.telequebec.tv/media/55267/le-soleil/passe-partout',
++ 'url': 'https://coucou.telequebec.tv/videos/1004958/top-cornichon/l-anniversaire-de-top-cornichon',
+ 'info_dict': {
+- 'id': '6167180337001',
++ 'id': '6370144678112',
+ 'ext': 'mp4',
+- 'title': 'Le soleil',
+- 'description': 'md5:64289c922a8de2abbe99c354daffde02',
++ 'title': 'L\'anniversaire de Top Cornichon',
++ 'description': 'md5:fc4fb2967dcea0baa8b6d39a11da917b',
+ 'uploader_id': '6150020952001',
+- 'upload_date': '20200625',
+- 'timestamp': 1593090307,
++ 'duration': 360.107,
++ 'thumbnail': 'md5:027b0d8b371bc86d5ac9c024acfeb6f2',
++ 'timestamp': 1742217124,
++ 'upload_date': '20250317',
++ 'series': 'Top Cornichon',
++ 'episode': 'L\'anniversaire de Top Cornichon',
++ },
++ 'params': {
++ 'skip_download': True,
+ },
+ 'add_ie': ['BrightcoveNew'],
+- }, {
+- # no description
+- 'url': 'http://zonevideo.telequebec.tv/media/30261',
+- 'only_matching': True,
+- }, {
+- 'url': 'https://coucou.telequebec.tv/videos/41788/idee-de-genie/l-heure-du-bain',
+- 'only_matching': True,
+ }]
+
+ def _real_extract(self, url):
+ media_id = self._match_id(url)
+- media = self._download_json(
++ meta = self._download_json(
+ 'https://mnmedias.api.telequebec.tv/api/v3/media/' + media_id,
+- media_id)['media']
+- source_id = next(source_info['sourceId'] for source_info in media['streamInfos'] if source_info.get('source') == 'Brightcove')
+- info = self._brightcove_result(source_id, '22gPKdt7f')
+- product = media.get('product') or {}
+- season = product.get('season') or {}
++ media_id, fatal=False)
++ media = meta.get('media') if meta else None
++ stream_infos = try_get(media, lambda m: m['streamInfos']) or []
++
++ if media and any(si and si.get('source') == 'Brightcove' for si in stream_infos):
++ source_id = next(
++ si['sourceId'] for si in stream_infos
++ if si and si.get('source') == 'Brightcove')
++ info = self._brightcove_result(source_id, '22gPKdt7f')
++ product = media.get('product') or {}
++ season = product.get('season') or {}
++ info.update({
++ 'description': try_get(media, lambda x: x['descriptions'][-1]['text'], str),
++ 'series': try_get(season, lambda x: x['serie']['titre']),
++ 'season': season.get('name'),
++ 'season_number': int_or_none(season.get('seasonNo')),
++ 'episode': product.get('titre'),
++ 'episode_number': int_or_none(product.get('episodeNo')),
++ })
++ return info
++
++ # Coucou Next.js: Brightcove id is in __NEXT_DATA__ when mnmedias has no catalogue row.
++ webpage = self._download_webpage(url, media_id)
++ next_json = self._search_regex(
++ r'',
++ webpage, '__NEXT_DATA__')
++ next_data = self._parse_json(next_json, media_id)
++ media_obj = traverse_obj(next_data, ('props', 'pageProps', 'media')) or {}
++ bc_video_id = media_obj.get('mediaId')
++ if not bc_video_id:
++ raise ExtractorError('Unable to extract Brightcove video id')
++
++ info = self._brightcove_result(str(bc_video_id), '22gPKdt7f')
++ hero = traverse_obj(next_data, ('props', 'pageProps', 'hero')) or {}
+ info.update({
+- 'description': try_get(media, lambda x: x['descriptions'][-1]['text'], str),
+- 'series': try_get(season, lambda x: x['serie']['titre']),
+- 'season': season.get('name'),
+- 'season_number': int_or_none(season.get('seasonNo')),
+- 'episode': product.get('titre'),
+- 'episode_number': int_or_none(product.get('episodeNo')),
++ 'description': media_obj.get('description'),
++ 'episode': media_obj.get('titre'),
++ 'series': hero.get('nom'),
+ })
+ return info
+
+
+-class TeleQuebecSquatIE(InfoExtractor):
+- _VALID_URL = r'https?://squat\.telequebec\.tv/videos/(?P\d+)'
++class TeleQuebecSeasonIE(InfoExtractor):
++ """telequebec.tv/contenu/{slug}/saison/{n} — expands to episode /regarder/ URLs via GraphQL."""
++
++ _VALID_URL = r'https?://(?:www\.)?telequebec\.tv/contenu/(?P[^/?#]+)/saison/(?P\d+)'
+ _TESTS = [{
+- 'url': 'https://squat.telequebec.tv/videos/9314',
++ 'url': 'https://telequebec.tv/contenu/macaroni-tout-garni/saison/1',
++ 'playlist_mincount': 15,
+ 'info_dict': {
+- 'id': 'd59ae78112d542e793d83cc9d3a5b530',
+- 'ext': 'mp4',
+- 'title': 'Poupeflekta',
+- 'description': 'md5:2f0718f8d2f8fece1646ee25fb7bce75',
+- 'duration': 1351,
+- 'timestamp': 1569057600,
+- 'upload_date': '20190921',
+- 'series': 'Miraculous : Les Aventures de Ladybug et Chat Noir',
+- 'season': 'Saison 3',
+- 'season_number': 3,
+- 'episode_number': 57,
++ 'id': 'macaroni-tout-garni-s1',
++ 'title': 'Macaroni tout garni',
+ },
+ 'params': {
+ 'skip_download': True,
+ },
++ }, {
++ 'url': 'https://www.telequebec.tv/contenu/macaroni-tout-garni/saison/1/',
++ 'only_matching': True,
+ }]
+
++ def _real_extract(self, url):
++ mobj = self._match_valid_url(url)
++ slug, season = mobj.group('slug'), int(mobj.group('season'))
++ playlist_id = f'{slug}-s{season}'
++
++ query = '''query ($slug: String!, $season: Int!) {
++ productPage(rootProductSlug: $slug, seasonNumber: $season) {
++ ... on ArtisanPage {
++ blocks {
++ ... on ArtisanBlocksProductPlayableProductsStrip {
++ blockConfiguration {
++ rootProduct { title }
++ season { title }
++ products { title videoCanonicalUrl }
++ }
++ }
++ }
++ }
++ }
++ }'''
++
++ resp = self._download_json(
++ 'https://api.pc-cms.tele.quebec/graphql', playlist_id,
++ data=json.dumps({
++ 'query': query,
++ 'variables': {'slug': slug, 'season': season},
++ }, separators=(',', ':')).encode(),
++ headers={
++ 'Content-Type': 'application/json',
++ 'Accept': 'application/json',
++ 'Origin': 'https://telequebec.tv',
++ })
++
++ errs = traverse_obj(resp, ('errors', ..., 'message', {str}))
++ if errs:
++ raise ExtractorError(', '.join(errs), expected=True)
++
++ playlist_title = None
++ products = []
++ for block in traverse_obj(resp, ('data', 'productPage', 'blocks')) or []:
++ cfg = traverse_obj(block, 'blockConfiguration')
++ if not cfg:
++ continue
++ prods = cfg.get('products')
++ if not prods:
++ continue
++ products = prods
++ playlist_title = traverse_obj(cfg, ('season', 'title')) or traverse_obj(cfg, ('rootProduct', 'title'))
++ break
++
++ if not products:
++ raise ExtractorError('No playable episodes in this season', expected=True)
++
++ def _episode_sort_key(p):
++ vu = p.get('videoCanonicalUrl') or ''
++ m = re.search(r'/(\d+)/?(?:[?#]|$)', vu)
++ return int(m.group(1)) if m else 0
++
++ products = sorted(products, key=_episode_sort_key)
++
++ entries = [
++ self.url_result(
++ p['videoCanonicalUrl'],
++ ie=TeleQuebecEmissionIE.ie_key(),
++ video_title=p.get('title'))
++ for p in products
++ if p.get('videoCanonicalUrl')
++ ]
++
++ return self.playlist_result(
++ entries,
++ playlist_id=playlist_id,
++ playlist_title=playlist_title or slug)
++
++
++class TeleQuebecSquatIE(InfoExtractor):
++ _WORKING = False
++ _VALID_URL = r'https?://squat\.telequebec\.tv/videos/(?P\d+)'
++ _TESTS = []
++
+ def _real_extract(self, url):
+ video_id = self._match_id(url)
+
+@@ -136,25 +234,47 @@ class TeleQuebecEmissionIE(InfoExtractor):
+ )
+ (?P[^?#&]+)
+ '''
++
++ @classmethod
++ def suitable(cls, url):
++ if re.match(r'https?://(?:www\.)?telequebec\.tv/contenu/[^/?#]+/saison/\d+', url):
++ return False
++ return super().suitable(url)
++
+ _TESTS = [{
+- 'url': 'http://lindicemcsween.telequebec.tv/emissions/100430013/des-soins-esthetiques-a-377-d-interets-annuels-ca-vous-tente',
++ 'url': 'https://telequebec.tv/regarder/donner-lgout/2/6',
+ 'info_dict': {
+- 'id': '6154476028001',
++ 'id': 'ref:100832979',
+ 'ext': 'mp4',
+- 'title': 'Des soins esthétiques à 377 % d’intérêts annuels, ça vous tente?',
+- 'description': 'md5:cb4d378e073fae6cce1f87c00f84ae9f',
+- 'upload_date': '20200505',
+- 'timestamp': 1588713424,
++ 'title': 'La Grèce à Québec',
++ 'description': 'md5:c506e07b90426ad391e18a753c021516',
+ 'uploader_id': '6150020952001',
++ 'duration': 2695.083,
++ 'thumbnail': 'md5:17aead23a395fb3f56a376524eb9f23c',
++ 'timestamp': 1777782475,
++ 'upload_date': '20260503',
+ },
++ 'params': {
++ 'skip_download': True,
++ },
++ 'add_ie': ['BrightcoveNew'],
++ }, {
++ 'url': 'https://telequebec.tv/regarder/donner-lgout/2/1',
++ 'only_matching': True,
++ }, {
++ 'url': 'https://telequebec.tv/regarder/kamikazes/2/7',
++ 'only_matching': True,
+ }, {
+- 'url': 'http://bancpublic.telequebec.tv/emissions/emission-49/31986/jeunes-meres-sous-pression',
++ 'url': 'https://telequebec.tv/regarder/5050',
+ 'only_matching': True,
+ }, {
+- 'url': 'http://www.telequebec.tv/masha-et-michka/epi059masha-et-michka-3-053-078',
++ 'url': 'https://telequebec.tv/regarder/les-magnetiques',
+ 'only_matching': True,
+ }, {
+- 'url': 'http://www.telequebec.tv/documentaire/bebes-sur-mesure/',
++ 'url': 'https://telequebec.tv/regarder/les-dalton/2/107',
++ 'only_matching': True,
++ }, {
++ 'url': 'https://telequebec.tv/regarder/macaroni-tout-garni/1/1',
+ 'only_matching': True,
+ }]
+
+@@ -164,31 +284,33 @@ def _real_extract(self, url):
+ webpage = self._download_webpage(url, display_id)
+
+ media_id = self._search_regex(
+- r'mediaId\s*:\s*(?P\d+)', webpage, 'media id')
++ (
++ r'mediaId\s*:\s*(?P\d+)',
++ r'"mediaId"\s*,\s*"(?P\d+)"',
++ r'"mediaId"\s*,\s*(?P\d+)',
++ r'mediaId\\"\s*,\s*\\"(?P\d+)',
++ ),
++ webpage, 'media id')
++
++ meta = self._download_json(
++ 'https://mnmedias.api.telequebec.tv/api/v3/media/' + media_id,
++ media_id, fatal=False)
++ media = meta.get('media') if meta else None
++ stream_infos = try_get(media, lambda m: m['streamInfos']) or []
++ if media and any(si and si.get('source') == 'Brightcove' for si in stream_infos):
++ return self.url_result(
++ 'http://zonevideo.telequebec.tv/media/' + media_id,
++ TeleQuebecIE.ie_key())
+
+- return self.url_result(
+- 'http://zonevideo.telequebec.tv/media/' + media_id,
+- TeleQuebecIE.ie_key())
++ # New telequebec.tv stack no longer mirrors catalogue ids into mnmedias; Brightcove loads
++ # videoId=ref:{mediaId} (see web player bundle: videoId:`ref:${mediaId}`).
++ return TeleQuebecBaseIE._brightcove_result(f'ref:{media_id}', 'ja7RtbSne')
+
+
+ class TeleQuebecLiveIE(TeleQuebecBaseIE):
++ _WORKING = False
+ _VALID_URL = r'https?://zonevideo\.telequebec\.tv/(?Pendirect)'
+- _TEST = {
+- 'url': 'http://zonevideo.telequebec.tv/endirect/',
+- 'info_dict': {
+- 'id': '6159095684001',
+- 'ext': 'mp4',
+- 'title': 're:^Télé-Québec [0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}$',
+- 'is_live': True,
+- 'description': 'Canal principal de Télé-Québec',
+- 'uploader_id': '6150020952001',
+- 'timestamp': 1590439901,
+- 'upload_date': '20200525',
+- },
+- 'params': {
+- 'skip_download': True,
+- },
+- }
++ _TESTS = []
+
+ def _real_extract(self, url):
+ return self._brightcove_result('6159095684001', 'skCsmi2Uw')
+@@ -198,15 +320,7 @@ class TeleQuebecVideoIE(TeleQuebecBaseIE):
+ _VALID_URL = r'https?://video\.telequebec\.tv/player(?:-live)?/(?P\d+)'
+ _TESTS = [{
+ 'url': 'https://video.telequebec.tv/player/31110/stream',
+- 'info_dict': {
+- 'id': '6202570652001',
+- 'ext': 'mp4',
+- 'title': 'Le coût du véhicule le plus vendu au Canada / Tous les frais liés à la procréation assistée',
+- 'description': 'md5:685a7e4c450ba777c60adb6e71e41526',
+- 'upload_date': '20201019',
+- 'timestamp': 1603115930,
+- 'uploader_id': '6101674910001',
+- },
++ 'only_matching': True,
+ }, {
+ 'url': 'https://video.telequebec.tv/player-live/28527',
+ 'only_matching': True,
diff --git a/modules/system/caching/cachix.nix b/modules/system/caching/cachix.nix
new file mode 100644
index 0000000..74425e7
--- /dev/null
+++ b/modules/system/caching/cachix.nix
@@ -0,0 +1,120 @@
+# Pull everywhere; enable push only on the host that runs `nixos-rebuild --target-host` (needs sops `cachix/auth-token`).
+{ inputs, ... }: {
+ flake.nixosModules.systemCachingCachix =
+ { config, lib, pkgs, ... }:
+ let
+ cfg = config.chiasson.system.caching.cachix;
+ in
+ {
+ imports = [ inputs.sops-nix.nixosModules.sops ];
+
+ options.chiasson.system.caching.cachix = with lib; {
+ enable = mkOption {
+ type = types.bool;
+ default = false;
+ description = "Enable Cachix cache integration for this host.";
+ };
+
+ cacheName = mkOption {
+ type = types.str;
+ default = "";
+ example = "chiasson-nixosnew";
+ description = "Your Cachix cache name (from app.cachix.org). Leave empty to disable.";
+ };
+ publicKey = mkOption {
+ type = types.str;
+ default = "";
+ example = "chiasson-nixosnew.cachix.org-1:xxxx=";
+ description = "Public key for the cache (shown on the cache page).";
+ };
+ enablePush = mkOption {
+ type = types.bool;
+ default = false;
+ description = "Push every build to Cachix (enable only on the machine that runs nixos-rebuild --target-host).";
+ };
+ pushExcludePatterns = mkOption {
+ type = types.listOf types.str;
+ default = [ ];
+ example = [ "wallpaper" "hm_wallpapers" ".iso" "-source" "large-assets" ];
+ description = "Substring patterns: store paths containing any of these are not pushed to Cachix (saves cache space for heavy outputs).";
+ };
+ };
+
+ config = let
+ enabled = cfg.enable && cfg.cacheName != "" && cfg.publicKey != "";
+ cacheUrl = "https://${cfg.cacheName}.cachix.org";
+ in
+ lib.mkMerge [
+ (lib.mkIf enabled {
+ nix.settings = {
+ substituters = lib.mkAfter [ cacheUrl ];
+ trusted-public-keys = lib.mkAfter [ cfg.publicKey ];
+ };
+ })
+ (lib.mkIf (enabled && cfg.enablePush) {
+ nix.settings.post-build-hook = pkgs.writeShellScript "upload-to-cachix" ''
+ set -eu
+ set -f
+ token_path="${config.sops.secrets."cachix/auth-token".path}"
+ if [ ! -r "$token_path" ]; then
+ echo "cachix: skipping push (token not readable at $token_path)" >&2
+ exit 0
+ fi
+ export PATH="${lib.makeBinPath [ pkgs.cachix ]}:$PATH"
+ export CACHIX_AUTH_TOKEN=$(cat "$token_path")
+
+ push_paths=""
+ skipped_roots=0
+ pushed_roots=0
+ for path in $OUT_PATHS; do
+ skip=0
+ skip_reason=""
+
+ closure_paths="$(nix-store -qR "$path" 2>/dev/null || true)"
+ for candidate in "$path" $closure_paths; do
+ while IFS= read -r pat; do
+ pat="$(printf '%s' "$pat" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
+ [ -z "$pat" ] && continue
+ case "$candidate" in
+ *"$pat"*)
+ skip=1
+ skip_reason="$pat ($candidate)"
+ break
+ ;;
+ esac
+ done << 'EXCLUDE_PATTERNS'
+ ${lib.concatStringsSep "\n" cfg.pushExcludePatterns}
+ EXCLUDE_PATTERNS
+ if [ "$skip" -eq 1 ]; then
+ break
+ fi
+ done
+
+ if [ "$skip" -eq 0 ]; then
+ push_paths="$push_paths $path"
+ pushed_roots=$((pushed_roots + 1))
+ else
+ skipped_roots=$((skipped_roots + 1))
+ echo "cachix: skipping root $path (matches exclude pattern via $skip_reason)" >&2
+ fi
+ done
+
+ echo "cachix: root paths selected for push: $pushed_roots, skipped: $skipped_roots" >&2
+
+ if [ -n "$push_paths" ]; then
+ if ! cachix push ${lib.escapeShellArg cfg.cacheName} $push_paths; then
+ echo "cachix: push failed (build succeeded; check token/network)" >&2
+ fi
+ fi
+ exit 0
+ '';
+
+ sops.secrets."cachix/auth-token" = {
+ owner = "root";
+ group = "root";
+ mode = "0400";
+ };
+ })
+ ];
+ };
+}
diff --git a/modules/system/caching/default.nix b/modules/system/caching/default.nix
new file mode 100644
index 0000000..4d2dbdb
--- /dev/null
+++ b/modules/system/caching/default.nix
@@ -0,0 +1,9 @@
+# `system.caching.*` — Attic + Cachix; exported again as `nixosModules.systemCaching`.
+{ self, ... }: {
+ flake.nixosModules.systemCaching = {
+ imports = [
+ self.nixosModules.systemCachingAttic
+ self.nixosModules.systemCachingCachix
+ ];
+ };
+}
diff --git a/modules/system/default.nix b/modules/system/default.nix
index e090bfc..1d05fcc 100644
--- a/modules/system/default.nix
+++ b/modules/system/default.nix
@@ -13,6 +13,7 @@
self.nixosModules.systemFlatpak
self.nixosModules.systemAudio
self.nixosModules.systemIdeapadMrubyOverlay
+ self.nixosModules.systemYtDlpTelequebecPatch
self.nixosModules.systemGaming
self.nixosModules.systemUconsoleKernelBuilder
self.nixosModules.systemLibrepods
@@ -20,7 +21,6 @@
self.nixosModules.systemCaching
inputs.swiftshare.nixosModules.systemServiceSwiftshare
self.nixosModules.systemServiceImmich
- self.nixosModules.systemVM
];
};
}
diff --git a/modules/system/users/catalog-default.nix b/modules/system/users/catalog-default.nix
index 590aa19..02bd5fd 100644
--- a/modules/system/users/catalog-default.nix
+++ b/modules/system/users/catalog-default.nix
@@ -11,11 +11,14 @@
extraGroups = [
"networkmanager"
"wheel"
- "libvirtd"
"docker"
"fuse"
"uinput"
"kvm"
+ # `video` is required for the brightnessctl/light udev rules to grant write access
+ # to /sys/class/backlight/*/brightness without sudo. Harmless on hosts without a
+ # backlight (servers, desktop towers): the group simply has no devices to own.
+ "video"
];
# Host must set `sops.secrets."users/olivier/hashedPassword".neededForUsers = true`.
diff --git a/modules/system/yt-dlp-telequebec-overlay.nix b/modules/system/yt-dlp-telequebec-overlay.nix
new file mode 100644
index 0000000..b1b5b75
--- /dev/null
+++ b/modules/system/yt-dlp-telequebec-overlay.nix
@@ -0,0 +1,35 @@
+# Tele-Québec extractor fixes until upstream yt-dlp carries them.
+#
+# Regenerate `modules/patches/yt-dlp-telequebec.patch` from yt-dlp checkout
+# (branch with fixes vs master), redirecting into this flake's modules/patches/:
+#
+# git diff master..ie/telequebec-update -- \
+# yt_dlp/extractor/telequebec.py yt_dlp/extractor/_extractors.py \
+# > path/to/NixOS-V2/modules/patches/yt-dlp-telequebec.patch
+#
+{ ... }: {
+ flake.nixosModules.systemYtDlpTelequebecPatch =
+ { config, lib, ... }:
+ let
+ cfg = config.chiasson.system.ytDlpTelequebecPatch;
+ patchFile = ../patches/yt-dlp-telequebec.patch;
+ in
+ {
+ options.chiasson.system.ytDlpTelequebecPatch = {
+ enable = lib.mkEnableOption ''
+ Patch yt-dlp with Tele-Québec extractor updates (telequebec.tv, Coucou, season playlists).
+ Regenerate `modules/patches/yt-dlp-telequebec.patch` when nixpkgs bumps yt-dlp if the build fails.
+ '';
+ };
+
+ config = lib.mkIf cfg.enable {
+ nixpkgs.overlays = lib.mkOrder 1000 [
+ (final: prev: {
+ yt-dlp = prev.yt-dlp.overrideAttrs (old: {
+ patches = (old.patches or [ ]) ++ [ patchFile ];
+ });
+ })
+ ];
+ };
+ };
+}
diff --git a/modules/wisdom/default.nix b/modules/wisdom/default.nix
new file mode 100644
index 0000000..4e337f6
--- /dev/null
+++ b/modules/wisdom/default.nix
@@ -0,0 +1,44 @@
+# HM side of the flake; option tree is `chiasson.home.*` (docs/module-conventions.md).
+{ self, inputs, ... }: {
+ imports = [
+ ./apps/discord.nix
+ ./apps/localsend.nix
+ ./apps/pokeclicker
+ ./apps/spotify.nix
+ ./browsers/orion.nix
+ ./desktop/screenshot.nix
+ ./hardware/uconsole-gamepad.nix
+ ];
+
+ # Root module: chiasson.home.enable + bash. Everything else is separate `wisdom*` exports —
+ # pull those into `home.users..extraModules` on each host as needed.
+ flake.homeManagerModules.wisdom =
+ { config, lib, ... }:
+ let
+ cfg = config.chiasson.home;
+ in
+ {
+ imports = [
+ self.homeManagerModules.wisdomShellBash
+ ];
+
+ options.chiasson.home = {
+ enable = lib.mkEnableOption ''
+ HM profile root for this flake (bash on by default). Wire other `wisdom*` modules in
+ `home.users..extraModules` and flip their `chiasson.home.*.enable` options on the host.
+ '' // {
+ default = true;
+ };
+
+ extraPackages = lib.mkOption {
+ type = lib.types.listOf lib.types.package;
+ default = [ ];
+ description = ''
+ Extra `home.packages` (e.g. `pkgs.parsec-bin`) when you do not want a separate wisdom module.
+ '';
+ };
+ };
+
+ config = lib.mkIf cfg.enable { home.packages = cfg.extraPackages; };
+ };
+}
diff --git a/modules/wisdom/editors/kate.nix b/modules/wisdom/editors/kate.nix
new file mode 100644
index 0000000..734ef1a
--- /dev/null
+++ b/modules/wisdom/editors/kate.nix
@@ -0,0 +1,17 @@
+{ ... }: {
+ flake.homeManagerModules.wisdomEditorsKate =
+ { config, lib, pkgs, ... }:
+ let
+ root = config.chiasson.home;
+ cfg = config.chiasson.home.editors.kate;
+ in
+ {
+ options.chiasson.home.editors.kate.enable = lib.mkEnableOption "Kate.";
+
+ config = lib.mkIf (root.enable && cfg.enable) {
+ home.packages = lib.optional (
+ lib.meta.availableOn pkgs.stdenv.hostPlatform pkgs.kdePackages.kate
+ ) pkgs.kdePackages.kate;
+ };
+ };
+}
diff --git a/modules/wisdom/editors/obsidian.nix b/modules/wisdom/editors/obsidian.nix
new file mode 100644
index 0000000..59d77a8
--- /dev/null
+++ b/modules/wisdom/editors/obsidian.nix
@@ -0,0 +1,19 @@
+{ ... }: {
+ flake.homeManagerModules.wisdomEditorsObsidian =
+ { config, lib, pkgs, ... }:
+ let
+ root = config.chiasson.home;
+ cfg = config.chiasson.home.editors.obsidian;
+ in
+ {
+ options.chiasson.home.editors.obsidian.enable = lib.mkEnableOption ''
+ Obsidian (unfree); skipped if unavailable here.
+ '';
+
+ config = lib.mkIf (root.enable && cfg.enable) {
+ home.packages = lib.optional (
+ lib.meta.availableOn pkgs.stdenv.hostPlatform pkgs.obsidian
+ ) pkgs.obsidian;
+ };
+ };
+}
diff --git a/modules/wisdom/shells/bash.nix b/modules/wisdom/shells/bash.nix
new file mode 100644
index 0000000..85405af
--- /dev/null
+++ b/modules/wisdom/shells/bash.nix
@@ -0,0 +1,37 @@
+{ ... }: {
+ flake.homeManagerModules.wisdomShellBash =
+ { config, lib, ... }:
+ let
+ root = config.chiasson.home;
+ cfg = config.chiasson.home.shell.bash;
+ in
+ {
+ options.chiasson.home.shell.bash.enable = lib.mkEnableOption "Bash + rbw SSH socket env in profile/init." // {
+ default = true;
+ };
+
+ config = lib.mkIf (root.enable && cfg.enable) {
+ programs.bash = {
+ enable = true;
+ profileExtra = ''
+ if [ -z "''${SSH_AUTH_SOCK:-}" ]; then
+ if [ -n "''${XDG_RUNTIME_DIR:-}" ]; then
+ export SSH_AUTH_SOCK="$XDG_RUNTIME_DIR/rbw/ssh-agent-socket"
+ else
+ export SSH_AUTH_SOCK="/run/user/$(id -u)/rbw/ssh-agent-socket"
+ fi
+ fi
+ '';
+ initExtra = ''
+ if [ -z "''${SSH_AUTH_SOCK:-}" ]; then
+ if [ -n "''${XDG_RUNTIME_DIR:-}" ]; then
+ export SSH_AUTH_SOCK="$XDG_RUNTIME_DIR/rbw/ssh-agent-socket"
+ else
+ export SSH_AUTH_SOCK="/run/user/$(id -u)/rbw/ssh-agent-socket"
+ fi
+ fi
+ '';
+ };
+ };
+ };
+}
diff --git a/modules/wisdom/shells/fish.nix b/modules/wisdom/shells/fish.nix
new file mode 100644
index 0000000..ec16f77
--- /dev/null
+++ b/modules/wisdom/shells/fish.nix
@@ -0,0 +1,37 @@
+{ ... }: {
+ flake.homeManagerModules.wisdomShellFish =
+ { config, lib, pkgs, ... }:
+ let
+ root = config.chiasson.home;
+ cfg = config.chiasson.home.shell.fish;
+ in
+ {
+ options.chiasson.home.shell.fish.enable = lib.mkEnableOption "Fish + grc, quiet greeting, rbw SSH socket defaults.";
+
+ config = lib.mkIf (root.enable && cfg.enable) {
+ # `fishPlugins.grc` only installs the plugin; the wrapper invokes the `grc` binary.
+ home.packages = [ pkgs.grc ];
+
+ programs.fish = {
+ enable = true;
+ interactiveShellInit = ''
+ set fish_greeting ""
+ if test -z "$SSH_AUTH_SOCK"
+ if test -n "$XDG_RUNTIME_DIR"
+ set -gx SSH_AUTH_SOCK "$XDG_RUNTIME_DIR/rbw/ssh-agent-socket"
+ else
+ set -l _hm_uid (${pkgs.coreutils}/bin/id -u)
+ set -gx SSH_AUTH_SOCK "/run/user/$_hm_uid/rbw/ssh-agent-socket"
+ end
+ end
+ '';
+ plugins = [
+ {
+ name = "grc";
+ src = pkgs.fishPlugins.grc.src;
+ }
+ ];
+ };
+ };
+ };
+}
diff --git a/modules/wisdom/shells/oh-my-posh.nix b/modules/wisdom/shells/oh-my-posh.nix
new file mode 100644
index 0000000..63cfacd
--- /dev/null
+++ b/modules/wisdom/shells/oh-my-posh.nix
@@ -0,0 +1,41 @@
+{ ... }: {
+ flake.homeManagerModules.wisdomShellOhMyPosh =
+ { config, lib, ... }:
+ let
+ root = config.chiasson.home;
+ cfg = config.chiasson.home.shell.ohMyPosh;
+ in
+ {
+ options.chiasson.home.shell.ohMyPosh = {
+ enable = lib.mkEnableOption "[Oh My Posh](https://ohmyposh.dev/) prompt.";
+
+ builtinTheme = lib.mkOption {
+ type = lib.types.str;
+ default = "jandedobbeleer";
+ description = ''
+ Stock theme when DMS is not overriding `configFile` (DMS replaces this with matugen output).
+ '';
+ };
+
+ enableFishIntegration = lib.mkOption {
+ type = lib.types.bool;
+ default = true;
+ description = "Run `oh-my-posh init fish` in Fish `shellInit` (Home Manager).";
+ };
+
+ enableBashIntegration = lib.mkOption {
+ type = lib.types.bool;
+ default = false;
+ description = "Run `oh-my-posh init bash` in bash `initExtra`.";
+ };
+ };
+
+ config = lib.mkIf (root.enable && cfg.enable) {
+ programs.oh-my-posh = {
+ enable = true;
+ inherit (cfg) enableFishIntegration enableBashIntegration;
+ useTheme = lib.mkDefault cfg.builtinTheme;
+ };
+ };
+ };
+}
diff --git a/modules/wisdom/shells/yazi.nix b/modules/wisdom/shells/yazi.nix
new file mode 100644
index 0000000..7cefa76
--- /dev/null
+++ b/modules/wisdom/shells/yazi.nix
@@ -0,0 +1,59 @@
+{ ... }: {
+ # Optional fragment: import from `home.users..extraModules` only on hosts that need Yazi so this
+ # module (and its `pkgs.yazi` wiring) is not evaluated when omitted.
+ flake.homeManagerModules.wisdomShellYazi =
+ { config, lib, pkgs, ... }:
+ let
+ root = config.chiasson.home;
+ cfg = config.chiasson.home.shell.yazi;
+ in
+ {
+ options.chiasson.home.shell.yazi.enable = lib.mkEnableOption "Yazi as `y` + 7zz with rar.";
+
+ config = lib.mkIf (root.enable && cfg.enable) {
+ programs.yazi = {
+ enable = true;
+ package = pkgs.yazi.override {
+ _7zz = pkgs._7zz-rar;
+ };
+ shellWrapperName = "y";
+ settings = {
+ manager = {
+ ratio = [
+ 1
+ 4
+ 3
+ ];
+ sort_by = "natural";
+ sort_sensitive = true;
+ sort_reverse = false;
+ sort_dir_first = true;
+ linemode = "none";
+ show_hidden = true;
+ show_symlink = true;
+ };
+ preview = {
+ image_filter = "lanczos3";
+ image_quality = 90;
+ tab_size = 1;
+ max_width = 600;
+ max_height = 900;
+ cache_dir = "";
+ ueberzug_scale = 1;
+ ueberzug_offset = [
+ 0
+ 0
+ 0
+ 0
+ ];
+ };
+ tasks = {
+ micro_workers = 5;
+ macro_workers = 10;
+ bizarre_retry = 5;
+ };
+ };
+ };
+ };
+ };
+}
diff --git a/modules/wisdom/terminals/kitty.nix b/modules/wisdom/terminals/kitty.nix
new file mode 100644
index 0000000..f32ea65
--- /dev/null
+++ b/modules/wisdom/terminals/kitty.nix
@@ -0,0 +1,31 @@
+{ ... }: {
+ flake.homeManagerModules.wisdomTerminalsKitty =
+ { config, lib, ... }:
+ let
+ root = config.chiasson.home;
+ cfg = config.chiasson.home.terminals.kitty;
+ in
+ {
+ options.chiasson.home.terminals.kitty.enable = lib.mkEnableOption ''
+ Kitty + DMS includes; activation strips `*-theme.auto.conf` so matugen colors stick.
+ '';
+
+ config = lib.mkIf (root.enable && cfg.enable) {
+ programs.kitty = {
+ enable = true;
+ extraConfig = ''
+ include dank-tabs.conf
+ include dank-theme.conf
+ allow_remote_control yes
+ listen_on unix:@kitty
+ '';
+ };
+
+ home.activation.kittyRemoveAutoThemes = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
+ rm -f "$HOME/.config/kitty/dark-theme.auto.conf" \
+ "$HOME/.config/kitty/light-theme.auto.conf" \
+ "$HOME/.config/kitty/no-preference-theme.auto.conf"
+ '';
+ };
+ };
+}
diff --git a/secrets/attic-secrets.yaml b/secrets/attic-secrets.yaml
index cc06296..f6ec6fe 100644
--- a/secrets/attic-secrets.yaml
+++ b/secrets/attic-secrets.yaml
@@ -5,56 +5,56 @@ sops:
- recipient: age1yyzgmazjxkvwtfcv9re3lqmt2ru5dcrfu3sauysm0wzfwzvyap8qkjkq32
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBuWEtXVW8ydkNhQTZSdkdE
- MUswOHZNcGE5YTFrZkY5YmpCRFM0QkYxUGpFCml6MWJyakxjRzhlR2VHRGVlU3lB
- U2lNMjZsWW1hZ1o1WFZqekl4a3crVU0KLS0tIEtFMkFrWVBvY2hZVGNwYnY4R2Jn
- dWlSOWhySWZydFQ0eWFseUlyOGRhYjQKRDM5k5q4AmyuPQ0EPzt5yrTmcNhW0umW
- dxYNO68NoHtFfK9efokxPKUnv9XqdbujTt/AWrtbxiyH4iOwt5N6fg==
+ YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZbmNOZm45NFYvdCtsRlpr
+ Sk05ZENGUjQ2UHhkZnFxUFZyd2VNR0twZ3c4CmEwSFRMOUZiNVFRTEhSUEFFK1BO
+ VDdINkZRSmdFWmVQRzVSRVQ3cENFZ2MKLS0tIDZTSCtmQzZFdzNwbytJNmVsWmVn
+ RmFQakVYaHd0TDVSd0lsOGJqOVYremMKHUINhxbHI0lxG5kHoCH1bZSm5Gkr4qRp
+ uPazF1+7kxrfMEBiZixq3K94HKzo+KYJQrcmD2EpS/p2cGN9oNvKHQ==
-----END AGE ENCRYPTED FILE-----
- recipient: age1elk6zwmcylwfk7gd4pjda7g29upftjvxys8py42s8d42jklnyv7s7dm9z2
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBha3BFaFJWTytOTlpTNXVV
- UXE1eENkZFRkQytzTVNLVkYreG5KNktrMVJRCk1iYnRQYW5IZFE1blB0T0JkUEhS
- YkRISDJBR1REY1cwVkNhSGtPbmMzcjAKLS0tIEJEamFvQjRvck1pMXJFNkxSVEVT
- WUd0b0Z5RXNCcVdKL09Ic2liMmtvMkkKtWcKmBSCOTqfaa52OxoHM3jA039l5o42
- Quvwzq0mc78/lyJbRAGCEBgvUvUnHVGlRf1NJOYyTb/zRkv/HGplOw==
+ YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlVWRvMm5BQWFYRlYzbjU0
+ dHBlaHZCWnR5UXJSZDFiOWRjS2w0L0V4TVFNCkpYVk5CQ0RwNjlmckV6amNia1pl
+ MFp5blAybncxazNneTYxRTdqNEdBaEEKLS0tIGtpVjQxSWMzaDVPS1dkZnVJMFFi
+ aWNRZTBSQllPSkQ2ZjYyOHpNcTEzWjgKK6XZaHV8GhLAr10VfvdP3+wc+ngBvLnv
+ PpdC4mPcHeueqv6fgLyUe0eUTulQ+oTXP51HG1/yClM/VGurA+Oxkg==
-----END AGE ENCRYPTED FILE-----
- recipient: age193gw802ytal7h5p5q37kpd9079k2vsflzmnvupcwfxh2kjdrwqtsk3g6rm
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB5U3NscUtHRzJmV2crQ3da
- UmpDOGMybER3M2p4Y291SjBPT0pRZnpUMHlFClFhRkgzbEFJaXVaRnFJUHhXdWVw
- QS9lMHVKeHFuZlRGNnZwUDI0WDkxdzgKLS0tIDYxMzBWcjNlbUZURGQ4VjlJek1Z
- Zk5zUlA2cEpSb1dwbDdlYVFFcXpvK2sKbOgfT2ocpG/WcBIAfoJ1w6OhRQh43bw6
- QaQZ3H+WykP07HNKjXw+ZH38LzdfPidDUaxa85Pcomu53E4v1cbF6w==
+ YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwMTBsYjB3K2taN3BSL3Zx
+ VDhnVlhnKzJXeUZhT1ArZG8xZGFSQmlyczNjCkhQck92Q3R6UVR2WDlhREFRcVJB
+ UXRDTGkyV3VNenp5Z2c1ZGh2dmdwZDAKLS0tIGlUZ3M2VFptSm1qTGlhc3NUTFEv
+ ckdsQTkxbzk2WDNRTGhWUS9YeTM1encK0+SFVSrpC3YmeAAlnmD1c3eYUXVOt5/I
+ VjYtU9IBcoUzQXM9o+wl3FyowOR/Q4Ws+I0sWtYuXigHHnDubL3Ylw==
-----END AGE ENCRYPTED FILE-----
- recipient: age1yr7vurfxc3w8ewfw9djfm54atw6ayze69qglamecuft5q0n9gu2sadsa2m
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZZHpENjduTTJxTWNIQ1NH
- Z3h5RjFrVHVZbnZqcVc1QjBpSEh3bkNpdW1zCi91NkpkR1RnSjJucWxZM3Zpc1Jo
- Q0cyV0pVU0M3UjBqNWFFRDdMaDRUWjQKLS0tIDN4MjVmeFNXV3FjdVdiZXNGNWxM
- TzdPUlE4R3RDcnJzTmFTSG9BVFZBL00Kr1PcmgTjo7Du66eocglrOtwpPIQ/itp4
- EWMCz7J7rRUf0690bb39VU72L7sj15wBhYbyWK80IJjtUl+4w+EyQw==
+ YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBiNk0zK0ZVVTNBR0p2aFNn
+ WkgyVkNzV1pPOFlSUll5cXduVVJ4eFR3cmwwCkxOZUNxQjU2OEtlYUlGR0tXZ2lv
+ UkxXcnJPNkNxMVd6TFpvRmFFWFJ2a3MKLS0tIGt5TkgxeFQwTlZEYmN5aUk0Zk5C
+ ZTlkOVEwd3NSVDNtY2owQWRjRU5KdkkKC9AxSP/e5/St79taNHVTmgWx8fGpsntm
+ 4mdRILdGGnkwTpPyipSxwX1X71O5kBs45cyoQ0brysP0O7b2aXOvdA==
-----END AGE ENCRYPTED FILE-----
- - recipient: age1m30m9xzszmcawte35m0yymz42gfx3x84w7d5l67mtdtajhgpfgssuc2plm
+ - recipient: age1hya7pgpe8zal52w3pjf036tpapmehedatfm4r84h30t4wuh079ssedfd37
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBzY250TU53Y2hjYTk3eExX
- Vk5EZ2lJSDI2NzZaTVNWU1hIZVdieGNwc1FJCk5PaVlDMytNbm0zdlhyaWdoVkJ5
- T0Y4dVIyeFRvNktqMDcvZ0h3bDN4bzQKLS0tIEtMR3p5azBlTEV6LytvU3JqLzd4
- em14dlFUUmY4aXRoK3A3Y2kyd3V4ZkUKu2XT/sL/syXYXC1B0Yah07QniAVvCik7
- j1Pnn3buQ8ZwP9fSRuE9uYQzqW89CmZanq2X9P32Pdw3JC9NfvaILA==
+ YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA5disxY3F2U1dmUENIbjhI
+ bnRkTElIbjE2ZlRZZTBPWWpjQXEyVUEvTlhBCklXMDNVT0tYUysybndqaHlqbDAz
+ akpLdE9CVGhOZ2cxQmsyNklWY3grSDgKLS0tIEE4TWMyZHdqR2w1ZWpnTUpuS25Z
+ RjVRM051U2pvSU1id2x5VjFzQ1dkd0UKhGU97Sqf1PG2/ZLz7jqFTsLTq5cx0h/7
+ jdWXSRUmmv2Cz3G3CxmF/lOi+NYRPTkx7DRAmoX3S7flc68onlSgcQ==
-----END AGE ENCRYPTED FILE-----
- recipient: age1p05z980kdtngk9mw67hfev72h7xhslplpxfk9yskgmf0hl4lu3ls04zht9
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBKaDRHNVdQM0Q5SFIwVTMr
- SzJmQkVsLzZ1dkNMYnU0WjlOU1dsem5zdVZFClg4QllIZGl0a3ZmMVBKc2pobFFm
- SVFaRU52T2FUWXQ4UFdDWDNUZTdGMTAKLS0tIFY0OXlDaTJOOEsrVm1TUkFQMENW
- MThLYTVYWER6Wm8yRlh0c0cwU2lDQjgKP8TdNo46f6rnYXjx+kBHwahv6UIlHgNq
- +aMTovb79YrY937wSk2Agtt0KlHnlsgvxsmWOpeAoboN+4FBalTySg==
+ YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA3cjE2dVpSOUZYRk5WTFFi
+ TDliOEJsNEdTUzFGck40K1NtdlQ0YjJ1Vng4CjVTeFU0ZDN2ZmVIT3FGZU1ia1Aw
+ RmNTOTN6WDVMR25uSzRRbnFzeDdTYmMKLS0tIGtla2owUW9wTTFaTERTaUMxb0k0
+ bjlyQ3BDOVRkMFROdk1veEgyYVZuK3cKAbhQPk1T0o6bGYBT9EggACViunccjSgG
+ G1vZMTJbLCOU9G3JNFGWVEnpIkY3fqLJGDpVTinRLh1fN3VNpNRowQ==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-03-24T00:25:39Z"
mac: ENC[AES256_GCM,data:fl3MByuB+MrsRdsmpvLbH8ebnJ+4RKfKLu26aO50tRpdvMqi8lqpcYb9FKwTksGM1qb84rU/Q/NK4/mkwqGr3hAftLJ1J2pcR+GcnBbnihJs5uA3jfb59fine855QLaWbfk61LbQk3GWJs45jRGMAGSCqZMqXZNM5N55KSWSmjw=,iv:j3ev3sVc41TsyPVP1570uGxOOmmogYPQHPPDklt9qtM=,tag:JwXmI7/FMdyZg4w6v6Rq4g==,type:str]
diff --git a/secrets/secrets.yaml b/secrets/secrets.yaml
index dd6627b..6995e4c 100644
--- a/secrets/secrets.yaml
+++ b/secrets/secrets.yaml
@@ -15,56 +15,56 @@ sops:
- recipient: age1yyzgmazjxkvwtfcv9re3lqmt2ru5dcrfu3sauysm0wzfwzvyap8qkjkq32
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBDT3hZams2RW9tYnpuclNU
- V1BSN2E3L1VicldXb2E4U2cwMEMvekw4MDJRCklyTlhwa3FhVTNibVNkcTFoNThC
- MzJKdFdhMlRqVlpvQjVmbmo0Z1JuZ3MKLS0tIHcwTk10WklKU3ZEYWg4dlVuRTFy
- T1NKaW1CdEppOU44c2t4Tm45MzhCRTAKeUTQB64sXl2NT3VlQqfDZVH1iqGI0HMN
- egV/S6pqKV0mC4aKrn2AKQUws4E6XF60z7ICM2ouBh+uth5xQ4U42g==
+ YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBtc25Wd0dlZldBVHZKTDRW
+ ZlVHamluT0hPbVArdWx0Qlk5czR6ZldIaEhRCnVkUVp6cHRwdnNsRDBhV285Q0tV
+ RitNWHNrUy9pN0dpanFSVHJjK0VuM1kKLS0tIHVLRWFRNmZyR2JOR3FwYjZsME9v
+ L2ZKbFIydkJ5L25GSWFBamhtbHZXV0UKAPIGMBbIdR7sCxsCYJa/9kajqmceOAJa
+ /jjxxcaWDkv5cmq0u8qFZEDkTjY3PKoJofBcx5q0npuJmrIA/oLeJA==
-----END AGE ENCRYPTED FILE-----
- recipient: age1elk6zwmcylwfk7gd4pjda7g29upftjvxys8py42s8d42jklnyv7s7dm9z2
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBIN1Zkd0FFS2FkbEZVQWhM
- aWhka2lWY0tIbEtGK25maGtVcnRmL09Yc0NrCnF1RnYyakozM1RxTC91UUtlVHRN
- TTRxdTBFaHJ4RXRvQ3RFN3VXV1FDTzgKLS0tIEdYNkhEc21rdFF3eldOaFFTQWNR
- NUtkbTdVd0JNeEd4ZVBDb3YybGpUVUUKKhyOptpf1xD4z2nEYH2QKfEC0RPI9TSy
- XiQ66Wzh1fkdqTlOkNdRzBniSObKg03oRaiZVXBtPx3iIrZw0XwywA==
+ YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBvcm5rZmtlUy9yQSs3Vnhh
+ eDVYaUxvcCtMYXhEa0ViTmFYK3JONnFsTWw0Cmdad2ZKdGcxeGQzeGVnTXRUeGJZ
+ R1kxQ1ZDK0FsQzc4S1EwMFN4VVZaTzAKLS0tIDJ5S3QycG5ZdThIVlZEVjBnMG9K
+ V1RSem1LdWpKT2o2aHh5dC9wVGJRakUK+5/eE+9WtSXmwoJ2Nqk4ni01GS4c3gLQ
+ p+wcpiOsxwnnisZTxag2yCn4hlv6FcOUWOcISq5H/sxwKgjBaeeuRQ==
-----END AGE ENCRYPTED FILE-----
- recipient: age193gw802ytal7h5p5q37kpd9079k2vsflzmnvupcwfxh2kjdrwqtsk3g6rm
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxUlFTVmQwbGVCWjQxbjF4
- bUcvSTRZY3hMTFdiaUlwN3hCb2QrWEhETlJZCjlVN3JDR0xUbFI4ZjFwVXowUmVE
- WklhVEI1Vjh6Ukg3VzlSSXM0WWtHVkEKLS0tIDloaDB5VGxVYmhSU3hjbE4vZ3Zi
- bTFaVVcyRVJjQ1dFVm9ITnhYRzBKbGcKAIw03NgRWOsmd/Kc8r8j+8fRRA3syHCz
- fmMgs8w9lpuJj24XDmcpIkN48PnCTV5XzTuEg5nvIJdb3TG2t0KWvA==
+ YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB6TjZVN3dFbmg5MGhNVFdD
+ YTZ4WVdOUWRGTzkrUmRGVUpobmg0Y0ozaUFNClh6cXhKWVkxcTlRYmZ5NmNqeEJJ
+ YzdYR1FyRExabXBDSnVSbXNxZy91am8KLS0tIHZLa0dXWHZhMDU5Q3BNR2U3NG00
+ eFp4Vmxha0xOVTVwd09PVzgrdzFNL2sKDEofAS4W4i8+VBU0wl1yTWmOogNbGHhY
+ azvb0QmxrYpurxjep3BYsc/5Co6U4mwowidoyzQLsiBJWDWy3wPdLQ==
-----END AGE ENCRYPTED FILE-----
- recipient: age1yr7vurfxc3w8ewfw9djfm54atw6ayze69qglamecuft5q0n9gu2sadsa2m
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxSkdOakRYSm5FOFEwRzdW
- UmZsREltNlhYdDhKWkE0blV0NVVwRWdvczBVCmVhSjB5Ymx4SEJ5S1NaU2RNSzV1
- RjRjQzRpaHlMeE9ZbVozQTBiVmVncUEKLS0tIDNTTE9JMG9jczE4cGI3TW1aVk9o
- anN5Wmgzd1NnWEFIY1lMU205REZjOHcKI3Jjobw0KmN6gK85QUsSW3m6IDtC960K
- eO6fk2WHT1jSPjWH87JuqNzbrkR7XJRB2CW+MXIDDb8h7euLWp7Png==
+ YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2d3dLVitDTVpyNllCTEJN
+ R1RLc2NkRnBrandDakhxeTJWeEdMVkZyS0JRCnl5Uk9FSk1iZmxCZUI1ZWdmYlJa
+ dEFlbjkxYjI4Y2FDZmswOGZqMUxGUXcKLS0tIGdqVWNaU1NWcGs4QXZyTkc5Z2VK
+ MGpaWmtHdzdpRDJxQXNveTd5WFkzTTgKxKuoC36uLqy+QoSGVcQekv5wn69yF0qH
+ 2qZPAm3wnf0KzZ8Wo/B8nXkjkq4llfKHbwfePiMRL4RObKXAejYhLA==
-----END AGE ENCRYPTED FILE-----
- - recipient: age1m30m9xzszmcawte35m0yymz42gfx3x84w7d5l67mtdtajhgpfgssuc2plm
+ - recipient: age1hya7pgpe8zal52w3pjf036tpapmehedatfm4r84h30t4wuh079ssedfd37
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBvRmZ5ZFAweWFuWFJrSDJY
- SFJUR0FPYnZXdGVucVpBRUZRVEFhSktlMFZzCmg4T1VTY3l4cm1BK3ZGRC80ckRI
- RjFHWllwamdEanAwU3BjNjgyZGR2bmcKLS0tIEUxK2NDV0IvRW0ycjA5cFdWaVhj
- L0ZJODdNTjJiNDNqd3k2eGs4SktBN1kKhPba1fZ/fIMr6ys+sUc4bi71O/oE9Cns
- 7tSKVUXUnP5aNSW217gMwPBoc/5vTl92cYJaGKFlQ2IpECCYJKCi7g==
+ YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4am5wWWRkL3J0dlZVaWJR
+ eVdMNnhTdGJqeFlNdnppTHNLdjdjcVNYbUQ4CjcwdGM5Nmdvck1wb0RSRjMwL01D
+ STlFdkdDallLLzNnbGY4WndiLzdRMzAKLS0tIHRENzRFTVA2MWE5djVHUy9peVdG
+ dkZDTTJvWU1vZHN2ZUR1R1Z3UWpkSlEKjo621j7xjyyHcc/ij8/X0H5uNLD6SnZa
+ o+apPj44+fTJFL5+VjP/XOsx0rCKTQhnV0gGfZU2SPtfH2BUiDjV0g==
-----END AGE ENCRYPTED FILE-----
- recipient: age1p05z980kdtngk9mw67hfev72h7xhslplpxfk9yskgmf0hl4lu3ls04zht9
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBiMDh5MmVyOHlJaUZiRVc4
- RENYRDBORExRb1NETld4TnZkZ21DR0NIRm13ClVQeWVaNWYxc3FTcjFRcnRYZm1R
- a2loWlpoYlc4bzc4Z2V0dWd5V0d2c2cKLS0tIG0wUGxTQUxqb0Z1cGZYSHpjQWRW
- am1lYUp4VFpmKzJESjMrdUZwVFN0MWcK7wHDAEqHcMWcBcZyw+wL1dWH7R9xFq5H
- grGFxgoPv2sn+4eQNKagC7jACm+l2vUFX5UuH0qJRVTpatXHnYb6SA==
+ YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA5YmF1K1ZtQmhoMGRVM0Fk
+ bW1PL0RPQ1ZUZEg4VU5VUi9BdlNhM2hPZkRNCnVIaUJJR1dFeXA1SWk2UFBkQW9a
+ cjc4bFRDb2p0eXJodG5IWk01ekdCdG8KLS0tIHFzU3l3WGcyTmZ0QjRyTmQwcDRZ
+ Mnp5K1VjcncrWWt4SEUrUVlCTTc3OVkKtRqPoatEp8NvZW4Z73nfCUshdz90SCad
+ VFgYF/2DYc7lSDP7otbsjBzGlauQQTWF1wfgEVOkw2EzOt2LCoflbg==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-03-24T00:15:02Z"
mac: ENC[AES256_GCM,data:dYTwO5DtkKinTKfBXGuvXRFxl8yavxXMKTw27M5/GcK/kkstHBG119IRk9B9KC6s6IHTY81U3MeUxE9XwdBiE7q4m15+ZO2vmdBVhN8wAh+82P9BP0HSaxLkjWLeKWBfULyLX/YXmQVsr09/NUEVSZcugJ6m40Ta+X9AQgO+cyA=,iv:FmsznsKTuIr61s3Zn0QZKSKvb/e2AljEB1ijKE52RKk=,tag:rHF2Xi4iP9VF33rxpBr5pg==,type:str]