dotfiles/nixos/hosts/sunken-ship.nix
Danny ef383cb2f0 sunken-ship: bump navidrome 0.61.2 -> 0.62.0 for inPlaylist visibility fix
In navidrome 0.61.x, the inPlaylist/notInPlaylist smart-playlist
criteria SQL builder did not know the smart playlist owner. It only
allowed referencing PUBLIC playlists, regardless of ownership. Per
the docs, an inaccessible reference makes the rule match no tracks,
so notInPlaylist against a private playlist silently degrades to
NOT IN () (always true) - zero filtering.

Symptom: smart playlist `Unrated (de-duped)` returned 9217 tracks
including all members of `[mulbo] dupe-losers` (private, same owner).
GRIVINA "Я хочу" showed 3 copies (1 unique + 2 dupe-losers). Verified
by DB poke: same owner_id, public=0 on both playlists.

Upstream fix: navidrome/navidrome#5411 (deluan) - "Relax playlist
visibility in inPlaylist/notInPlaylist rules". Passes the smart
playlist owner identity into the criteria SQL builder so same-owner
private references work. Shipped in v0.62.0 (2026-06-08).

nixpkgs PR for this bump: NixOS/nixpkgs#529720 (tebriel), opened
2026-06-09, not yet merged. nixos-unstable still on 0.61.2. This
adds a local nixos/pkgs/navidrome/ verbatim from nixpkgs master with
just the 3 hash lines bumped, and wires services.navidrome.package
to it. REMOVE both once nixpkgs-unstable carries 0.62.x.

After deploy: smart playlist songCount 9217 -> 7101, GRIVINA dupes
3 -> 1. Confirmed via direct API fetch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 18:21:34 +02:00

509 lines
23 KiB
Nix

# NixOS server: hostname, user, SSH, auto-rebuild from dotfiles repo.
#
# One-time on server: clone repo to /etc/dotfiles (root needs git access).
# If private repo: use SSH (ssh:// or git@) and add root's key to GitHub, or use HTTPS + token.
# Then: sudo nixos-rebuild switch --flake /etc/dotfiles#sunken-ship
# If sudo git is not found: sudo nix run nixpkgs#git -- -C /etc/dotfiles pull origin main
# Timer runs every 15 min: git fetch, pull if origin/main changed, rebuild.
{ config, lib, pkgs, ... }:
{
imports = [ ./sunken-ship-hardware.nix ];
networking.hostName = "sunken-ship";
# No networks defined => uses /etc/wpa_supplicant.conf on the server
networking.wireless.enable = true;
time.timeZone = "Europe/Copenhagen";
boot.kernelParams = [ "consoleblank=60" ]; # blank TTY after 60s to reduce burn-in
# Turn off panel backlight after boot so the screen actually dims (consoleblank only blanks framebuffer).
# At the console, run: brightnessctl set 100% (or `brightnessctl max`) to restore brightness.
systemd.services.server-backlight-off = {
description = "Turn off panel backlight after console idle (reduce burn-in)";
after = [ "multi-user.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig.Type = "oneshot";
script = ''
${pkgs.coreutils}/bin/sleep 65
for d in /sys/class/backlight/*; do
[ -f "$d/brightness" ] && echo 0 > "$d/brightness" 2>/dev/null || true
done
'';
};
nix.settings.experimental-features = [ "nix-command" "flakes" ];
programs.nix-ld.enable = true; # run dynamically linked binaries (e.g. Claude Code remote CLI)
system.stateVersion = "24.11";
users.users.danny = {
isNormalUser = true;
extraGroups = [ "wheel" "video" "audio" ]; # video: backlight; audio: sound devices
openssh.authorizedKeys.keys = [
# Mac admin (~/.ssh/id_ed25519_sunken_ship on Daniel-Macbook-Air).
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKW/akfIiVU5o63YrTAJVZhMj7kXfYHOnXDtlpVFW7pf danny@sunken-ship"
# Self-loopback (used by clan ssh-ng:// during nix-copy-closure
# back to this same host on `clan machines update`). Pubkey of the
# /home/danny/.ssh/id_ed25519 that lives on this host.
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB9t4YAaoHvVouqp+qyFOq8o3SAtXMiAmjF6J0ldyx4g danny@sunken-ship self"
];
};
# root needs the mac admin key so `clan machines update` can SSH to
# root@<host> to upload SOPS keys (sops-install-secrets bootstrap).
users.users.root.openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKW/akfIiVU5o63YrTAJVZhMj7kXfYHOnXDtlpVFW7pf danny@sunken-ship"
];
# Key-only auth; no password or keyboard-interactive.
services.openssh = {
enable = true;
settings = {
PasswordAuthentication = false;
KbdInteractiveAuthentication = false;
};
# Optionally restrict to LAN: settings.ListenAddress = "10.0.0.1"; or similar.
};
# Passwordless sudo for wheel.
security.sudo.wheelNeedsPassword = false;
# Trust `danny` for Nix remote builds (so the mac can delegate
# x86_64-linux builds here via ssh-ng://danny@sunken-ship-zt).
nix.settings.trusted-users = [ "root" "danny" ];
environment.systemPackages = with pkgs; [
git # clone/bootstrap, repo-pull timers, dm-pull-deploy push
brightnessctl # manual backlight; replaces removed `light` from nixpkgs
uxplay # AirPlay mirroring receiver
alsa-utils # aplay, amixer, arecord for audio debugging
];
# Avahi (mDNS) — required for AirPlay discovery.
services.avahi = {
enable = true;
nssmdns4 = true;
publish = { enable = true; userServices = true; };
};
# Open firewall for AirPlay (mDNS + UxPlay default ports) + Navidrome.
# bbbot's HTTP backend (port 8080) is intentionally NOT in the global
# allowedTCPPorts — it's only allowed on the ZeroTier interface
# (clan-managed name; matches anything starting with `zt`) so the
# vps-relay Caddy can reach it via the ZT mesh. Same trick could lock
# 4533 down later but Navidrome stays globally accessible for now (LAN
# convenience).
networking.firewall = {
allowedTCPPorts = [ 7000 7001 7100 4533 ];
allowedUDPPorts = [ 5353 6000 6001 7011 ];
# 8080: bbbot HTTP backend. 8081: bbbot SHIPYARD STAGING (B3Bot beta).
# 8091: mulbo-server companion service. All ZT-only — see vps-relay.nix
# for the reverse proxies that expose them publicly.
interfaces."zt+".allowedTCPPorts = [ 8080 8081 8091 ];
};
# Navidrome — self-hosted music streaming server (Subsonic API).
# Music library: /srv/music (bind-mounted from /home/danny/music).
# Web UI + Substreamer client on port 4533.
services.navidrome = {
enable = true;
# Bumped to 0.62.0 ahead of nixpkgs (#529720) for navidrome PR
# #5411 ("Relax playlist visibility in inPlaylist/notInPlaylist
# rules"). Without this the smart playlist Unrated (de-duped)
# silently keeps dupe-losers members. Drop this line + nixos/pkgs/
# navidrome/ when nixpkgs-unstable has 0.62.x.
package = pkgs.callPackage ../pkgs/navidrome { };
settings = {
Address = "0.0.0.0";
Port = 4533;
MusicFolder = "/srv/music";
# Auto-delete `missing=1` rows during scan so transient files
# (e.g. mulbo dedupe quarantine ones) don't accumulate as stale
# track IDs that Substreamer caches and then 500s on. Without
# this, Navidrome keeps missing rows forever (default behaviour
# preserves play history; we trade that for client-cache hygiene).
# Valid values: never | always | full. `always` purges on every
# scan (selective + full); risk on transient missing is fine
# here (stable local disk).
Scanner.PurgeMissing = "always";
};
};
# Navidrome's Subsonic API path field is tag-virtual; only the internal
# SQLite has real fs paths. mulbo-server reads navidrome.db ro to
# power /folders + POST /tracks resolution. UMask=0027 makes new DB
# files (and WAL rotations) group-readable; the tmpfile rule fixes the
# existing files written under the previous 0600 umask.
systemd.services.navidrome.serviceConfig.UMask = lib.mkForce "0027";
# Persist the bind mount so navidrome can read music outside ProtectHome.
fileSystems."/srv/music" = {
device = "/home/danny/music";
fsType = "none";
options = [ "bind" "ro" ];
};
# Navidrome is now reachable only over the ZeroTier mesh — see the
# sunken-ship-zt SSH alias on the mac, or hit http://[fdd5:53a2:de33:
# d269:6499:93d5:53a2:de33]:4533 directly from any ZT-joined device.
# The Cloudflare Tunnel + its clan vars generator were retired in 4d;
# delete the tunnel itself in the Cloudflare Zero Trust dashboard.
# UxPlay AirPlay receiver — audio-only, outputs directly to Scarlett Solo via ALSA.
# Runs as a system service (no PipeWire needed on a headless server).
systemd.services.uxplay = {
description = "UxPlay AirPlay receiver";
after = [ "network-online.target" "avahi-daemon.service" ];
wants = [ "network-online.target" "avahi-daemon.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = ''${pkgs.uxplay}/bin/uxplay -n sunken-ship -p -vs 0 -as "audioconvert ! audioresample ! alsasink device=plughw:USB,0 buffer-time=200000"'';
Restart = "on-failure";
RestartSec = 5;
User = "danny";
SupplementaryGroups = [ "audio" ];
};
};
# BigBiggerBiggestBot — Mini App backend (no Telegram polling).
# Code: https://github.com/DannyDannyDanny/bigbiggerbiggestbot cloned at /home/danny/tg_fitness_bot
# Bot token (used only for validating Telegram WebApp initData HMACs):
# ~danny/.secrets/bigbiggerbiggestbot
# Deployment: fitness-bot-pull timer below runs every 15 min, git pulls, restarts service on changes.
#
# Mini App URL is fronted by Caddy on the vps-relay host at
# https://bbbot.dannydannydanny.me (VPS → ZeroTier → localhost:8080).
# start.py honors WEBAPP_URL to skip starting its own cloudflared
# Quick Tunnel when the stable URL from the VPS is already set.
#
# The slash-command bot (bot.py) was removed in May 2026 — the Mini App
# is now the only interface. No python-telegram-bot dependency required.
# ExecStartPost re-publishes the bot's chat-side presence (menu button,
# description, cleared command list) every time the service starts.
# Idempotent against the Telegram API. Errors are non-fatal (`-` prefix).
systemd.services.fitness-bot = let
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
python-dotenv
aiohttp
]);
in {
description = "BigBiggerBiggestBot Mini App backend";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
path = [ pythonEnv ];
environment.WEBAPP_URL = "https://bbbot.dannydannydanny.me";
# Bind dual-stack so the VPS Caddy can reach us over ZT IPv6.
environment.API_HOST = "::";
serviceConfig = {
WorkingDirectory = "/home/danny/tg_fitness_bot";
ExecStart = "${pythonEnv}/bin/python start.py";
ExecStartPost = "-${pythonEnv}/bin/python scripts/set-bot-presence.py";
Restart = "on-failure";
RestartSec = 10;
User = "danny";
};
};
# Pull fitness bot from GitHub and restart the service if the repo has new commits.
# Code lives at /home/danny/tg_fitness_bot (git clone of DannyDannyDanny/bigbiggerbiggestbot).
# workouts.db is gitignored — preserved across pulls.
systemd.services.fitness-bot-pull = {
description = "Pull fitness bot and restart service if repo changed";
path = with pkgs; [ git systemd ];
environment.GIT_CONFIG_COUNT = "1";
environment.GIT_CONFIG_KEY_0 = "safe.directory";
environment.GIT_CONFIG_VALUE_0 = "/home/danny/tg_fitness_bot";
script = ''
set -euo pipefail
cd /home/danny/tg_fitness_bot
git fetch origin
if [ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ]; then
exit 0
fi
git pull origin main
systemctl restart fitness-bot
'';
serviceConfig.Type = "oneshot";
};
systemd.timers.fitness-bot-pull = {
wantedBy = [ "timers.target" ];
timerConfig.OnCalendar = "*-*-* *:07/15:00"; # every 15 minutes, offset from dotfiles-rebuild
timerConfig.RandomizedDelaySec = "2min";
};
# ── Shipyard staging — B3Bot beta tenant under shipyard_poc_bot ──────
# Mini-App-only HTTP server (no Telegram polling — shipyard_poc_bot on
# phantom-ship owns the polling loop; this service only validates Telegram
# WebApp initData HMACs against the shared bot token).
#
# Working dir: /home/danny/tg_fitness_bot_shipyard (separate clone of the
# same repo, gitignored workouts.db kept across pulls).
# Branch: origin/staging (push there to deploy here; push to origin/main for prod).
# Token file: /home/danny/.secrets/shipyard_poc_bot.env
# File contents: BOT_TOKEN=<shipyard_poc_bot token>
# Service won't start until this file exists (ConditionPathExists).
# Mini App URL: https://b3.dannydannydanny.me (vps-relay Caddy →
# ZT IPv6 → here:8081). Stable across restarts — listed in
# ~/python-projects/26_shipyard/apps.json.
# Workflow: git push origin <branch>:staging → wait ~15 min → tap B3Bot
# beta in shipyard_poc_bot's launcher → test → git push <branch>:main.
systemd.services.fitness-bot-shipyard = let
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
python-dotenv
aiohttp
]);
in {
description = "BigBiggerBiggestBot SHIPYARD STAGING instance";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
path = [ pythonEnv ];
environment.API_HOST = "::";
environment.API_PORT = "8081";
# Stable URL fronted by vps-relay's Caddy → ZT → here:8081.
# WEBAPP_URL set tells start.py to skip cloudflared entirely.
environment.WEBAPP_URL = "https://b3.dannydannydanny.me";
unitConfig.ConditionPathExists = "/home/danny/.secrets/shipyard_poc_bot.env";
serviceConfig = {
WorkingDirectory = "/home/danny/tg_fitness_bot_shipyard";
EnvironmentFile = "/home/danny/.secrets/shipyard_poc_bot.env";
ExecStart = "${pythonEnv}/bin/python start.py";
Restart = "on-failure";
RestartSec = 10;
User = "danny";
};
};
systemd.services.fitness-bot-shipyard-pull = {
description = "Pull shipyard fitness bot from origin/staging and restart if changed";
path = with pkgs; [ git systemd ];
environment.GIT_CONFIG_COUNT = "1";
environment.GIT_CONFIG_KEY_0 = "safe.directory";
environment.GIT_CONFIG_VALUE_0 = "/home/danny/tg_fitness_bot_shipyard";
script = ''
set -euo pipefail
if [ ! -d /home/danny/tg_fitness_bot_shipyard/.git ]; then
echo "Shipyard working dir not bootstrapped yet skipping pull."
exit 0
fi
cd /home/danny/tg_fitness_bot_shipyard
git fetch origin
if [ "$(git rev-parse HEAD)" = "$(git rev-parse origin/staging)" ]; then
exit 0
fi
git pull origin staging
systemctl restart fitness-bot-shipyard
'';
serviceConfig.Type = "oneshot";
};
systemd.timers.fitness-bot-shipyard-pull = {
wantedBy = [ "timers.target" ];
# Offset from prod (07/15), mulbo (11/15), and dotfiles-rebuild.
timerConfig.OnCalendar = "*-*-* *:13/15:00";
timerConfig.RandomizedDelaySec = "2min";
};
# Mulbo companion service (Phase 5: uploads + dedup index + folders).
# Wire spec: ~danny/python-projects/20_mulbo/SERVER_API.md.
# Bootstrap (one-time): git clone git@github.com:DannyDannyDanny/python-projects.git /home/danny/python-projects
# (uses sunken-ship's id_ed25519 as a read-only deploy key on the repo)
# ZT-only via the firewall rule above (port 8091). Runs as `danny` so
# writes go through to /home/danny/music/mulbo-uploads, which Navidrome
# reads via the existing /srv/music ro bind-mount with no mount changes.
systemd.tmpfiles.rules = [
"d /home/danny/music/mulbo-uploads 0755 danny users -"
# One-time fix for the existing navidrome.db (+ WAL/SHM) created
# under the old 0600 umask. UMask=0027 above keeps future writes
# group-readable.
"z /var/lib/navidrome/navidrome.db 0640 navidrome navidrome -"
"z /var/lib/navidrome/navidrome.db-wal 0640 navidrome navidrome -"
"z /var/lib/navidrome/navidrome.db-shm 0640 navidrome navidrome -"
];
systemd.services.mulbo-server = let
pythonEnv = pkgs.python312.withPackages (ps: with ps; [
fastapi
uvicorn
python-multipart
mutagen # tag writeback (enrich.write_tags); needed by the
# /enrich/revert endpoint which reuses enrich.py.
numpy # FFT for spectral-rolloff analysis (quality.py); used
# by chromaprint-dupe winner picker in --spectral mode.
]);
in {
description = "Mulbo companion service (uploads, dedup, folders)";
after = [ "network-online.target" "navidrome.service" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
# ffmpeg: PCM extraction for quality.py's spectral-rolloff probe
# (chromaprint-dupe winner picker in --spectral mode). Without it,
# the subprocess silently fails and rolloff returns 0Hz.
path = with pkgs; [ ffmpeg ];
environment = {
MULBO_UPLOADS_DIR = "/home/danny/music/mulbo-uploads";
MULBO_INDEX_DB = "/var/lib/mulbo-server/index.db";
MULBO_MUSIC_ROOT = "/srv/music"; # ro view via bind-mount; reads + hashing
MULBO_MUSIC_WRITE_ROOT = "/home/danny/music"; # underlying rw path; deletes + quarantines
MULBO_NAVIDROME_URL = "http://localhost:4533";
MULBO_BIND_HOST = "::";
MULBO_BIND_PORT = "8091";
PYTHONUNBUFFERED = "1"; # immediate journal output
};
serviceConfig = {
WorkingDirectory = "/home/danny/python-projects/20_mulbo";
ExecStart = "${pythonEnv}/bin/python mulbo_server/app.py";
Restart = "on-failure";
RestartSec = 5;
User = "danny";
# Read-only access to navidrome.db (+WAL/SHM) — see UMask override
# on the navidrome service above.
SupplementaryGroups = [ "navidrome" ];
StateDirectory = "mulbo-server"; # /var/lib/mulbo-server, owned by danny
# Navidrome credentials — file format: KEY=value lines.
# Required keys: MULBO_NAVIDROME_USER, MULBO_NAVIDROME_PASS.
# Created manually on sunken-ship (mode 600, owned by danny):
# echo -e "MULBO_NAVIDROME_USER=DannyDannyDanny\nMULBO_NAVIDROME_PASS=..." > ~/.secrets/mulbo-server-navidrome
# chmod 600 ~/.secrets/mulbo-server-navidrome
EnvironmentFile = "/home/danny/.secrets/mulbo-server-navidrome";
};
};
# Pull mulbo (python-projects repo) and restart service if repo changed.
# Repo lives at /home/danny/python-projects (must be cloned manually first
# — see bootstrap note above). DBs/state live in /var/lib/mulbo-server,
# not in the repo, so they survive pulls.
systemd.services.mulbo-pull = {
description = "Pull mulbo repo and restart mulbo-server if changed";
# openssh: `git fetch origin` over an SSH remote forks `ssh`; without
# it git dies with "cannot run ssh: No such file or directory" and the
# unit fails (shows up as system `degraded`).
path = with pkgs; [ git openssh systemd ];
environment = {
GIT_CONFIG_COUNT = "1";
GIT_CONFIG_KEY_0 = "safe.directory";
GIT_CONFIG_VALUE_0 = "/home/danny/python-projects";
};
script = ''
set -euo pipefail
cd /home/danny/python-projects
git fetch origin
if [ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ]; then
exit 0
fi
git pull origin main
systemctl restart mulbo-server
'';
serviceConfig.Type = "oneshot";
};
systemd.timers.mulbo-pull = {
wantedBy = [ "timers.target" ];
timerConfig.OnCalendar = "*-*-* *:11/15:00"; # every 15 min, offset from fitness-bot-pull and dotfiles-rebuild
timerConfig.RandomizedDelaySec = "2min";
};
# dm-pull-deploy push automation. sunken-ship is the push node for the
# clan dm-pull-deploy instance (wired in flake-modules/clan.nix), but
# the upstream module only ships a manual `dm-send-deploy` binary — no
# scheduler. This timer announces the latest origin/main rev over
# data-mesher gossip; the watchers (dm-pull-deploy.path on sunken +
# phantom) compare and only rebuild when the rev actually changes, so
# re-announcing the same rev is a cheap no-op. This is the replacement
# for the legacy dotfiles-rebuild pull timer (being retired).
#
# dm-send-deploy self-discovers the rev via `git ls-remote` and signs
# with /run/secrets/vars/dm-pull-deploy-signing-key — needs root.
systemd.services.dm-pull-deploy-push = {
description = "Announce latest origin/main rev via data-mesher (dm-pull-deploy push)";
serviceConfig = {
Type = "oneshot";
ExecStart = "/run/current-system/sw/bin/dm-send-deploy";
User = "root";
};
};
systemd.timers.dm-pull-deploy-push = {
wantedBy = [ "timers.target" ];
timerConfig.OnCalendar = "*-*-* *:04/15:00"; # every 15 min, offset from the other pull timers
timerConfig.RandomizedDelaySec = "2min";
timerConfig.Persistent = true;
};
# One-shot backfill: walks Navidrome's media_file, computes
# (sha256, chromaprint) per file, populates mulbo-server's tracks_index
# with the corresponding navidrome_track_id. Idempotent — existing rows
# left alone. Without this, /tracks/by-hash misses for every existing
# offshore track and `mulbo reconcile-local` duplicates content.
#
# Trigger manually: sudo systemctl start mulbo-server-backfill
# Follow progress: journalctl -fu mulbo-server-backfill
systemd.services.mulbo-server-backfill = let
pythonEnv = pkgs.python312.withPackages (ps: with ps; [ ]);
in {
description = "Backfill mulbo-server tracks_index from Navidrome catalog";
after = [ "mulbo-server.service" ];
requires = [ "mulbo-server.service" ];
path = [ pkgs.chromaprint ]; # provides fpcalc
environment = {
MULBO_INDEX_DB = "/var/lib/mulbo-server/index.db";
MULBO_NAVIDROME_DB = "/var/lib/navidrome/navidrome.db";
MULBO_MUSIC_ROOT = "/srv/music";
PYTHONUNBUFFERED = "1";
};
serviceConfig = {
Type = "oneshot";
WorkingDirectory = "/home/danny/python-projects/20_mulbo";
ExecStart = "${pythonEnv}/bin/python mulbo_server/backfill.py";
User = "danny";
SupplementaryGroups = [ "navidrome" ]; # ro access to navidrome.db
StateDirectory = "mulbo-server"; # so /var/lib/mulbo-server/index.db stays writable
TimeoutSec = "8h"; # full backfill on 274 GB ≈ 1h, leave headroom
};
};
# Phase 7.5 enrichment one-shot. For tracks where Navidrome's tags
# are empty/Unknown, runs three sources (filename heuristics, yt-dlp
# for SoundCloud `[<id>]` patterns, AcoustID+MusicBrainz), votes the
# results, and writes back via mutagen with strict-replacement
# (never touches user-set tags).
#
# Trigger: sudo systemctl start mulbo-server-enrich
# Follow progress: journalctl -fu mulbo-server-enrich
systemd.services.mulbo-server-enrich = let
pythonEnv = pkgs.python312.withPackages (ps: with ps; [
mutagen # tag writeback
]);
in {
description = "Enrich Navidrome tracks with empty/Unknown metadata";
after = [ "mulbo-server.service" ];
requires = [ "mulbo-server.service" ];
path = with pkgs; [ yt-dlp chromaprint ]; # yt-dlp for SC/YT lookups, chromaprint for AcoustID's -plain fingerprint
environment = {
MULBO_INDEX_DB = "/var/lib/mulbo-server/index.db";
MULBO_NAVIDROME_DB = "/var/lib/navidrome/navidrome.db";
MULBO_MUSIC_ROOT = "/srv/music";
MULBO_MUSIC_WRITE_ROOT = "/home/danny/music";
PYTHONUNBUFFERED = "1";
};
serviceConfig = {
Type = "oneshot";
WorkingDirectory = "/home/danny/python-projects/20_mulbo";
ExecStart = "${pythonEnv}/bin/python mulbo_server/enrich.py";
User = "danny";
SupplementaryGroups = [ "navidrome" ];
StateDirectory = "mulbo-server";
# Add MULBO_ACOUSTID_KEY to the secrets file to enable the
# AcoustID source. yt-dlp source needs no key. Filename source
# needs nothing.
EnvironmentFile = "/home/danny/.secrets/mulbo-server-navidrome";
TimeoutSec = "8h";
};
};
# Deploys now flow through clan dm-pull-deploy: the dm-pull-deploy-push
# timer above announces origin/main, and the dm-pull-deploy.path watcher
# rebuilds on change. The legacy pull-based dotfiles-rebuild module was
# retired 2026-05-19.
}