#!/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"