navidrome: a music server on its own box, with beets in front of it
Navidrome 0.64.0 on music-box (nodito VM 8200, 1 core / 1 GB / 50 GB), public at music.contrapeso.xyz. A single Go binary with a SQLite database; the only external dependency is ffmpeg for transcoding. Plus beets, the CLI that fixes tags before Navidrome ever indexes them. ── Exposed without basic auth, on purpose ───────────────────────────────── Navidrome speaks two protocols on one port: its web UI, and the Subsonic API (/rest/*) that every mobile client actually uses. A Subsonic client authenticates with ?u=&t=md5(password+salt)&s= in the query string and has nowhere to carry a second credential, so Basic Auth in front of the site is invisible to most of them. The choice is a proxy credential or a working phone. Same call this estate already made for monerod's restricted RPC, for the same reason: proxied bare so Feather can reach it, with the service's own auth doing the authenticating. The cost is stated rather than glossed: the login page and the whole /rest/* surface are reachable by anyone who finds the hostname, and Navidrome has no rate limiting and no 2FA. A fail2ban jail on vipy reads a per-vhost Caddy access log and catches WEB UI brute force, where a bad login returns 401. It very likely does NOT catch Subsonic brute force, because that protocol answers a bad password with HTTP 200 and puts the failure in the body - which Caddy never sees. Nor can it be fixed on the music box: the request arrives from vipy over the tailnet, so the only address fail2ban could ban there is vipy's, which would take down every service behind the edge. Banning has to happen at the edge; the edge cannot see the failure. So /rest/* is protected by password strength alone, and the README carries a one-line curl to re-check that after every upgrade. ── Passwords are stored reversibly, and cannot not be ───────────────────── The Subsonic API requires the server to compute md5(password+salt) per request, so Navidrome cannot hash passwords one-way. It encrypts them at rest with PasswordEncryptionKey instead. Two real consequences, both in the README: root on that box means every Navidrome password in plaintext, so the password must be unique to this service; and changing the key invalidates every stored password, so it is set before the first account and left alone. ── beets is not a service, deliberately ─────────────────────────────────── `beet import` is interactive and it MOVES AND RENAMES FILES. A timer that reorganises a music library unattended is a way to lose one. Installed and configured, run by hand. /srv/music is counterweight:navidrome mode 2750 and the setgid bit is load-bearing: without it beets' files land in the curator's own group, Navidrome cannot read them, and the symptom is an EMPTY LIBRARY rather than a permission error. ReplayGain and lyrics are auto: no - on one core they turn a two-minute import into a twenty-minute one. ── Monitoring ───────────────────────────────────────────────────────────── Host liveness and disk (400), the systemd unit (401), a probe that fetches /app/ and greps it (403), and public reachability plus cert expiry (402). /app/ was chosen by testing all three candidates against 0.64.0: /ping returns a bare "." and proves only that something listens; /rest/ping.view identifies the server but logs two warnings per call, ~576 a day; /app/ returns <title>Navidrome</title> and logs nothing. 402 asserts 200 on /app/ and not on "/", because "/" answers 302 - checked live rather than assumed. ── Also ─────────────────────────────────────────────────────────────────── The headscale ACL grants tag:prd-vipy exactly tag:music-box:4533. Without a rule the node is invisible rather than unreachable, which surfaces as a Caddy 502 "no such host". inventory lan_ip was read off nodito's ARP table for the VM's own MAC, not guessed - and .149 had belonged to another machine, whose stale host key made SSH refuse the connection. That is the designed behaviour, not a fault. 50 GB is the LIBRARY ceiling, not just the OS: /srv/music is on the root disk, so roughly 300-450 albums in FLAC. 1 GB is fine for serving and tight for the initial scan, which is the memory peak. No backups, deliberately. The music files would swamp the age-encrypted nightly path, and navidrome.db is a rebuildable index. What is NOT rebuildable is playlists, play counts, ratings and accounts - there are none today, which is what makes the trade fair, and the README names this as the decision to revisit if that changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7e4a7c850d
commit
d208783fbf
25 changed files with 1457 additions and 191 deletions
189
scripts/add-music.sh
Executable file
189
scripts/add-music.sh
Executable file
|
|
@ -0,0 +1,189 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Add music to music-box: push it, tag it with beets, let Navidrome see it.
|
||||
#
|
||||
# WHY THIS SCRIPT EXISTS
|
||||
# ----------------------
|
||||
# The three steps are simple individually and easy to get subtly wrong together:
|
||||
#
|
||||
# 1. rsync to /srv/incoming, NOT to /srv/music. Navidrome reads /srv/music and
|
||||
# beets is the only thing that should ever write there. Dropping files
|
||||
# straight into the library skips tagging entirely, which is the whole
|
||||
# point of having beets.
|
||||
# 2. `beet import` is INTERACTIVE, so the ssh needs a TTY (-t). Without it the
|
||||
# prompt never renders and the import appears to hang.
|
||||
# 3. beets must run as counterweight. Under sudo it finds no config (the
|
||||
# config lives in ~counterweight), silently falls back to its defaults and
|
||||
# writes to a SECOND library database in root's home - so you end up with
|
||||
# files in the wrong place and two competing indexes.
|
||||
#
|
||||
# WHAT IT DOES NOT DO
|
||||
# It does not touch your local files. rsync copies; the `move: yes` in the
|
||||
# beets config only moves things around ON THE BOX, from /srv/incoming into
|
||||
# /srv/music. Your originals on this machine are never modified or deleted.
|
||||
#
|
||||
# Usage: scripts/add-music.sh [path] (prompts for the path if omitted)
|
||||
# Needs: rsync and ssh here, tailnet up (music-box resolves via MagicDNS)
|
||||
set -euo pipefail
|
||||
|
||||
MUSIC_HOST="${MUSIC_HOST:-music-box}"
|
||||
REMOTE_USER="${MUSIC_USER:-counterweight}"
|
||||
INCOMING="/srv/incoming"
|
||||
# Pre-filled into the prompt, editable in place. $HOME rather than a literal
|
||||
# /home/counterweight so the script still behaves if it is ever run as anyone
|
||||
# else; for the usual user it resolves to exactly /home/counterweight/music.
|
||||
DEFAULT_SRC="${MUSIC_LOCAL_DIR:-$HOME/music}"
|
||||
SSH_KEY="${MUSIC_SSH_KEY:-$HOME/.ssh/counterganzua}"
|
||||
TARGET="${REMOTE_USER}@${MUSIC_HOST}"
|
||||
|
||||
# Colours, but only when we are actually on a terminal.
|
||||
if [ -t 1 ]; then
|
||||
B=$'\033[1m'; DIM=$'\033[2m'; R=$'\033[31m'; G=$'\033[32m'; Y=$'\033[33m'; N=$'\033[0m'
|
||||
else
|
||||
B=""; DIM=""; R=""; G=""; Y=""; N=""
|
||||
fi
|
||||
|
||||
say() { printf '%s==>%s %s\n' "$B" "$N" "$*"; }
|
||||
warn() { printf '%s==>%s %s\n' "$Y" "$N" "$*" >&2; }
|
||||
die() { printf '%sERROR:%s %s\n' "$R" "$N" "$*" >&2; exit 1; }
|
||||
|
||||
# -n is load-bearing: without it ssh reads OUR stdin and swallows the answers
|
||||
# meant for the read prompts below, so "Continue? [y/N]" gets EOF instead of
|
||||
# your keypress. Under `set -e` that aborts the script with no message at all.
|
||||
ssh_run() { ssh -n -i "$SSH_KEY" -o ConnectTimeout=15 "$TARGET" "$@"; }
|
||||
# -t for anything that talks to a human (beets prompts, sudo password). No -n
|
||||
# here - these deliberately DO want your terminal.
|
||||
ssh_tty() { ssh -t -i "$SSH_KEY" -o ConnectTimeout=15 "$TARGET" "$@"; }
|
||||
|
||||
# --- 0. Preflight ------------------------------------------------------------
|
||||
command -v rsync >/dev/null || die "rsync is not installed here."
|
||||
[ -f "$SSH_KEY" ] || die "SSH key not found: $SSH_KEY (override with MUSIC_SSH_KEY=)"
|
||||
|
||||
say "Checking ${MUSIC_HOST} is reachable..."
|
||||
if ! ssh_run -o BatchMode=yes true 2>/dev/null; then
|
||||
die "Cannot reach ${TARGET}.
|
||||
Is the tailnet up? tailscale status | grep ${MUSIC_HOST}
|
||||
MagicDNS resolving? getent hosts ${MUSIC_HOST}"
|
||||
fi
|
||||
|
||||
# --- 1. Where is the music? --------------------------------------------------
|
||||
SRC="${1:-}"
|
||||
if [ -z "$SRC" ]; then
|
||||
printf '\n%sPath to the album or folder to add%s %s(edit or tab-complete)%s\n' "$B" "$N" "$DIM" "$N"
|
||||
# -e gives readline (tab completion, arrow keys); -i pre-fills the buffer
|
||||
# with the default so it is already typed and editable in place.
|
||||
# -i is ignored when stdin is not a terminal, hence the ${SRC:-} fallback:
|
||||
# piping into this script, or just pressing Enter, still yields the default.
|
||||
read -e -i "$DEFAULT_SRC" -r -p "> " SRC || true
|
||||
SRC="${SRC:-$DEFAULT_SRC}"
|
||||
fi
|
||||
|
||||
# People paste paths with surrounding quotes (drag-and-drop does this) and with
|
||||
# a trailing slash. Both change rsync's meaning, so normalise before using it.
|
||||
SRC="${SRC%\"}"; SRC="${SRC#\"}"
|
||||
SRC="${SRC%\'}"; SRC="${SRC#\'}"
|
||||
SRC="${SRC/#\~/$HOME}"
|
||||
while [ "${SRC}" != "/" ] && [ "${SRC: -1}" = "/" ]; do SRC="${SRC%/}"; done
|
||||
|
||||
[ -n "$SRC" ] || die "No path given."
|
||||
[ "$SRC" != "/" ] && [ "$SRC" != "$HOME" ] || die "Refusing to sync '$SRC' - that is almost certainly not what you meant."
|
||||
[ -e "$SRC" ] || die "Not found: $SRC"
|
||||
[ -r "$SRC" ] || die "Not readable: $SRC"
|
||||
|
||||
BASENAME="$(basename -- "$SRC")"
|
||||
if [ -d "$SRC" ]; then
|
||||
COUNT=$(find "$SRC" -type f | wc -l | tr -d ' ')
|
||||
SIZE=$(du -sh "$SRC" 2>/dev/null | cut -f1)
|
||||
[ "$COUNT" -gt 0 ] || die "$SRC contains no files."
|
||||
else
|
||||
COUNT=1
|
||||
SIZE=$(du -h "$SRC" 2>/dev/null | cut -f1)
|
||||
fi
|
||||
|
||||
# --- 2. Does it fit? ---------------------------------------------------------
|
||||
# /srv/incoming and /srv/music share one filesystem, so the beets move is a
|
||||
# rename and costs nothing extra - but the transfer itself still needs room.
|
||||
AVAIL=$(ssh_run "df -Pk ${INCOMING} | awk 'NR==2 {print \$4}'" 2>/dev/null || echo 0)
|
||||
NEED=$(du -sk "$SRC" 2>/dev/null | cut -f1)
|
||||
if [ "${AVAIL:-0}" -gt 0 ] && [ "${NEED:-0}" -gt "${AVAIL}" ]; then
|
||||
die "Not enough space on ${MUSIC_HOST}: need $((NEED/1024)) MB, have $((AVAIL/1024)) MB.
|
||||
The library shares the 50 GB root disk - see roles/navidrome/README.md."
|
||||
fi
|
||||
|
||||
printf '\n'
|
||||
say "About to add:"
|
||||
printf ' source %s\n' "$SRC"
|
||||
printf ' files %s (%s)\n' "$COUNT" "${SIZE:-?}"
|
||||
printf ' lands in %s:%s/%s\n' "$MUSIC_HOST" "$INCOMING" "$BASENAME"
|
||||
printf ' free %s MB on the box\n\n' "$((${AVAIL:-0}/1024))"
|
||||
printf '%sYour local files are only read, never moved or deleted.%s\n\n' "$DIM" "$N"
|
||||
|
||||
read -r -p "Continue? [y/N] " REPLY
|
||||
case "$REPLY" in [yY]*) ;; *) say "Nothing done."; exit 0 ;; esac
|
||||
|
||||
# --- 3. Push -----------------------------------------------------------------
|
||||
printf '\n'
|
||||
say "Pushing to ${MUSIC_HOST}:${INCOMING}/ ..."
|
||||
# No trailing slash on "$SRC": that copies the DIRECTORY, not its contents, so
|
||||
# the album arrives as its own folder and beets sees one album per directory.
|
||||
# --chmod is not cosmetic. `rsync -a` preserves the SOURCE mode, and beets
|
||||
# moves files into the library with rename(2), which keeps their group and
|
||||
# mode. A track that leaves this machine mode 0600 therefore arrives in
|
||||
# /srv/music mode 0600 owned by the curator's group - and Navidrome cannot read
|
||||
# it. It does not error; the track is simply absent from the library. The beets
|
||||
# `permissions` plugin also normalises this on the box; this is the same fix
|
||||
# applied at the door so /srv/incoming is sane too.
|
||||
rsync -av --info=progress2 --human-readable --chmod=D755,F644 \
|
||||
-e "ssh -i $SSH_KEY -o ConnectTimeout=15" \
|
||||
"$SRC" "${TARGET}:${INCOMING}/"
|
||||
|
||||
# --- 4. Tag ------------------------------------------------------------------
|
||||
printf '\n'
|
||||
say "Running beets. This is interactive - see the key guide below."
|
||||
printf '%s A apply M more candidates E enter search I paste MB id%s\n' "$DIM" "$N"
|
||||
printf '%s U use tags as-is S skip T as tracks G group albums%s\n\n' "$DIM" "$N"
|
||||
|
||||
# As counterweight (NOT sudo) so it finds ~/.config/beets/config.yaml.
|
||||
# `|| true` because beets exits non-zero when you skip or abort an album, and
|
||||
# that is a normal outcome, not a script failure.
|
||||
ssh_tty "beet import ${INCOMING}/$(printf '%q' "$BASENAME")" || true
|
||||
|
||||
# --- 5. What is left behind? -------------------------------------------------
|
||||
printf '\n'
|
||||
LEFT=$(ssh_run "find ${INCOMING} -type f 2>/dev/null | wc -l" | tr -d ' \r')
|
||||
if [ "${LEFT:-0}" -gt 0 ]; then
|
||||
warn "${LEFT} file(s) still in ${INCOMING} - skipped, or beets could not match them."
|
||||
warn "Re-run later with: ssh ${MUSIC_HOST} 'beet import ${INCOMING}'"
|
||||
warn "Details: ssh ${MUSIC_HOST} 'tail /var/lib/beets/import.log'"
|
||||
else
|
||||
printf '%s==>%s %sEverything imported; %s is empty.%s\n' "$B" "$N" "$G" "$INCOMING" "$N"
|
||||
fi
|
||||
|
||||
# --- 6. Optional post-processing ---------------------------------------------
|
||||
# replaygain and lyrics are auto:no in the beets config on purpose - on a single
|
||||
# core they turn a 2 minute import into a 20 minute one. Offered here so they do
|
||||
# not get forgotten entirely, but off by default.
|
||||
printf '\n'
|
||||
read -r -p "Run replaygain now? (slow on 1 core) [y/N] " REPLY
|
||||
case "$REPLY" in [yY]*) say "Running beet replaygain..."; ssh_tty "beet replaygain" || warn "replaygain reported a problem." ;; esac
|
||||
|
||||
# --- 7. Let Navidrome see it -------------------------------------------------
|
||||
printf '\n'
|
||||
say "Restarting Navidrome so it picks up the new files..."
|
||||
ssh_tty "sudo systemctl restart navidrome"
|
||||
|
||||
say "Waiting for it to come back..."
|
||||
for i in $(seq 1 20); do
|
||||
if ssh_run "curl -sf -m 5 -o /dev/null http://127.0.0.1:4533/app/" 2>/dev/null; then
|
||||
printf '%s==>%s %sNavidrome is up.%s\n' "$B" "$N" "$G" "$N"
|
||||
break
|
||||
fi
|
||||
[ "$i" -eq 20 ] && warn "Navidrome did not answer within ~60s. Check: ssh ${MUSIC_HOST} 'systemctl status navidrome'"
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# --- 8. Summary --------------------------------------------------------------
|
||||
printf '\n'
|
||||
say "Library now holds:"
|
||||
ssh_run "beet stats" 2>/dev/null | sed 's/^/ /' || warn "could not read beet stats"
|
||||
printf '\n%s==>%s %sDone.%s https://music.contrapeso.xyz\n' "$B" "$N" "$G" "$N"
|
||||
Loading…
Add table
Add a link
Reference in a new issue