190 lines
9 KiB
Bash
190 lines
9 KiB
Bash
|
|
#!/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"
|