From f02606902c7400dcda3b9d93e56aaaf21f09e158 Mon Sep 17 00:00:00 2001 From: OlivierChiasson Date: Sun, 10 May 2026 01:45:16 -0300 Subject: [PATCH] Rebase to flake parts #9 --- .sops.yaml | 2 +- modules/desktop/gui.nix | 8 + modules/desktop/niri/default.nix | 5 + modules/hosts/14900k/_private/displays.nix | 108 ++--- .../14900k/_private/jellyfin-nfs-export.nix | 3 +- modules/hosts/14900k/_private/nvidia.nix | 29 +- modules/hosts/14900k/configuration.nix | 8 +- modules/hosts/ideapad/_private/CAMERA-TODO.md | 139 +++++++ modules/hosts/ideapad/_private/platform.nix | 45 ++ .../hosts/ideapad/_private/touch-tablet.nix | 321 +++++++++++++++ modules/hosts/ideapad/configuration.nix | 201 +++++++++ modules/hosts/ideapad/default.nix | 14 + modules/hosts/ideapad/hardware.nix | 22 + .../_services/attic-cache-server.nix | 30 ++ .../_services/ddrm-media-server.nix | 12 + .../nix-server/_services/flaresolverr.nix | 11 + modules/hosts/nix-server/_services/immich.nix | 29 ++ .../_services/jellyfin-remote-storage.nix | 27 ++ .../hosts/nix-server/_services/jellyfin.nix | 60 +++ .../hosts/nix-server/_services/organizr.nix | 53 +++ .../hosts/nix-server/_services/portainer.nix | 20 + .../hosts/nix-server/_services/prowlarr.nix | 22 + .../_services/prowlarr/torrent9-custom.yml | 193 +++++++++ .../nix-server/_services/qbittorrent.nix | 36 ++ modules/hosts/nix-server/_services/radarr.nix | 16 + modules/hosts/nix-server/_services/seerr.nix | 29 ++ modules/hosts/nix-server/_services/sonarr.nix | 16 + .../hosts/nix-server/_services/swiftshare.nix | 113 ++++++ modules/hosts/nix-server/configuration.nix | 11 + modules/lib/ssh-inventory.nix | 2 +- modules/patches/yt-dlp-telequebec.patch | 383 ++++++++++++++++++ modules/system/caching/cachix.nix | 120 ++++++ modules/system/caching/default.nix | 9 + modules/system/default.nix | 2 +- modules/system/users/catalog-default.nix | 5 +- modules/system/yt-dlp-telequebec-overlay.nix | 35 ++ modules/wisdom/default.nix | 44 ++ modules/wisdom/editors/kate.nix | 17 + modules/wisdom/editors/obsidian.nix | 19 + modules/wisdom/shells/bash.nix | 37 ++ modules/wisdom/shells/fish.nix | 37 ++ modules/wisdom/shells/oh-my-posh.nix | 41 ++ modules/wisdom/shells/yazi.nix | 59 +++ modules/wisdom/terminals/kitty.nix | 31 ++ secrets/attic-secrets.yaml | 62 +-- secrets/secrets.yaml | 62 +-- 46 files changed, 2382 insertions(+), 166 deletions(-) create mode 100644 modules/hosts/ideapad/_private/CAMERA-TODO.md create mode 100644 modules/hosts/ideapad/_private/platform.nix create mode 100644 modules/hosts/ideapad/_private/touch-tablet.nix create mode 100644 modules/hosts/ideapad/configuration.nix create mode 100644 modules/hosts/ideapad/default.nix create mode 100644 modules/hosts/ideapad/hardware.nix create mode 100644 modules/hosts/nix-server/_services/attic-cache-server.nix create mode 100644 modules/hosts/nix-server/_services/ddrm-media-server.nix create mode 100644 modules/hosts/nix-server/_services/flaresolverr.nix create mode 100644 modules/hosts/nix-server/_services/immich.nix create mode 100644 modules/hosts/nix-server/_services/jellyfin-remote-storage.nix create mode 100644 modules/hosts/nix-server/_services/jellyfin.nix create mode 100644 modules/hosts/nix-server/_services/organizr.nix create mode 100644 modules/hosts/nix-server/_services/portainer.nix create mode 100644 modules/hosts/nix-server/_services/prowlarr.nix create mode 100644 modules/hosts/nix-server/_services/prowlarr/torrent9-custom.yml create mode 100644 modules/hosts/nix-server/_services/qbittorrent.nix create mode 100644 modules/hosts/nix-server/_services/radarr.nix create mode 100644 modules/hosts/nix-server/_services/seerr.nix create mode 100644 modules/hosts/nix-server/_services/sonarr.nix create mode 100644 modules/hosts/nix-server/_services/swiftshare.nix create mode 100644 modules/patches/yt-dlp-telequebec.patch create mode 100644 modules/system/caching/cachix.nix create mode 100644 modules/system/caching/default.nix create mode 100644 modules/system/yt-dlp-telequebec-overlay.nix create mode 100644 modules/wisdom/default.nix create mode 100644 modules/wisdom/editors/kate.nix create mode 100644 modules/wisdom/editors/obsidian.nix create mode 100644 modules/wisdom/shells/bash.nix create mode 100644 modules/wisdom/shells/fish.nix create mode 100644 modules/wisdom/shells/oh-my-posh.nix create mode 100644 modules/wisdom/shells/yazi.nix create mode 100644 modules/wisdom/terminals/kitty.nix 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]