Files
musicmouse/scripts/sync-library.sh
Martin Bauer 61104d1e25 sync-library.sh: run the closing report under sh, not the remote login shell
The report was an ssh command string, which the remote user's login shell runs - and
on the devices root's is fish, which rejects `if ...; then`. The copy itself finished,
but the script then exited 127 at the very end, so a successful sync looked failed.
Feed the report to `sh -s` on stdin instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:22:21 +02:00

248 lines
9.7 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Copy a local music library, and the expensive parts of its scan cache, onto a
# MusicMouse device.
#
# scripts/sync-library.sh --host musicdolphin
#
# The cache is the reason this is a script rather than one rsync line. Its three kinds
# of content behave completely differently when they move between machines (see
# python-backend/musicmouse/library/cache.py):
#
# covers/<album_id>.jpg album_id = sha1(folder path relative to the library
# root). Portable, as long as the shelf layout under the
# target root matches the source - which it does, because
# this script syncs the shelves verbatim.
#
# analysis/<track_key>.json track_key = sha1("<name>:<size>:<int mtime>"). Portable
# *only if mtimes survive the copy*. That is why every
# rsync here is -a and why you must never reach for scp
# without -p: losing mtimes silently invalidates every
# analysis file, each of which costs minutes of DSP to
# rebuild.
#
# index.json NOT copied. It holds absolute source-machine paths, and
# scan_library() reuses a cached album whenever its
# (name, size, mtime) fingerprint matches, without
# re-checking the path. Copying it makes every album come
# back pointing at /home/<you>/Music/..., and playback
# fails on files that are sitting right there. It is the
# cheap part of the cache - the device rebuilds it on its
# first scan, reusing every cover and analysis file we did
# copy.
set -euo pipefail
HOST="musicdolphin"
SSH_USER="root"
SRC="${HOME}/Music"
CACHE=""
DEST="/media/musicmouse"
DRY_RUN=0
COPY_CACHE=1
# Shelves the scanner knows about (musicmouse/library/sections.py). Syncing these by
# name rather than the whole directory is what makes --delete safe: config.yml,
# tippen-curriculum.yml, tippen-progress.json and .musicmouse-cache all live in $DEST
# on the device and must survive.
SECTIONS=("Figuren" "Musik" "Hörbücher" "Kinderpodcasts")
# Leave this much room on the target after the copy. These devices are SD cards with
# little to spare, and a full root filesystem breaks far more than the music player.
HEADROOM_BYTES=$((1024 * 1024 * 1024))
usage() {
cat <<'USAGE'
Usage: scripts/sync-library.sh [options]
--host HOST Target device (default: musicdolphin)
--user USER SSH user (default: root)
--src DIR Local library (default: ~/Music)
--dest DIR Library root on target (default: /media/musicmouse)
--cache DIR Local cache directory (default: <src>/.musicmouse-cache, falling
back to python-backend/.musicmouse-cache next to this script)
--no-cache Skip the cache; the device recomputes everything from scratch
--dry-run Show what would be transferred, change nothing
-h, --help This message
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--host) HOST="$2"; shift 2 ;;
--user) SSH_USER="$2"; shift 2 ;;
--src) SRC="$2"; shift 2 ;;
--dest) DEST="$2"; shift 2 ;;
--cache) CACHE="$2"; shift 2 ;;
--no-cache) COPY_CACHE=0; shift ;;
--dry-run) DRY_RUN=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
done
SSH_TARGET="${SSH_USER}@${HOST}"
SRC="${SRC%/}"
DEST="${DEST%/}"
die() { echo "error: $*" >&2; exit 1; }
step() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
human() {
# Bytes -> something a person can judge at a glance.
numfmt --to=iec --suffix=B "$1" 2>/dev/null || echo "$1 bytes"
}
# ---------------------------------------------------------------------- cache location
if [[ -z "$CACHE" ]]; then
if [[ -d "${SRC}/.musicmouse-cache" ]]; then
CACHE="${SRC}/.musicmouse-cache"
else
# The dev setup keeps config.yml (and therefore the cache, which resolves
# relative to it) in python-backend/ rather than beside the music.
CACHE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/python-backend/.musicmouse-cache"
fi
fi
# ------------------------------------------------------------------------- preflight
step "Preflight"
[[ -d "$SRC" ]] || die "source library not found: $SRC"
present_sections=()
for section in "${SECTIONS[@]}"; do
[[ -d "${SRC}/${section}" ]] && present_sections+=("$section")
done
[[ ${#present_sections[@]} -gt 0 ]] \
|| die "no known shelves under $SRC (looked for: ${SECTIONS[*]})"
echo "source: $SRC"
echo "shelves: ${present_sections[*]}"
if [[ $COPY_CACHE -eq 1 ]]; then
if [[ -d "$CACHE" ]]; then
echo "cache: $CACHE"
else
echo "cache: none found at $CACHE - continuing without it"
COPY_CACHE=0
fi
else
echo "cache: skipped (--no-cache)"
fi
ssh -o ConnectTimeout=10 -o BatchMode=yes "$SSH_TARGET" true 2>/dev/null \
|| die "cannot ssh to $SSH_TARGET (needs key auth, no password prompt)"
echo "target: ${SSH_TARGET}:${DEST}"
# Size the transfer. This is an upper bound: rsync skips files already present and
# identical, so a repeat run moves far less than this.
src_bytes=0
for section in "${present_sections[@]}"; do
section_bytes=$(du -sb "${SRC}/${section}" | cut -f1)
src_bytes=$((src_bytes + section_bytes))
printf ' %-18s %s\n' "$section" "$(human "$section_bytes")"
done
if [[ $COPY_CACHE -eq 1 ]]; then
for part in covers analysis; do
if [[ -d "${CACHE}/${part}" ]]; then
part_bytes=$(du -sb "${CACHE}/${part}" | cut -f1)
src_bytes=$((src_bytes + part_bytes))
printf ' %-18s %s\n' "cache/${part}" "$(human "$part_bytes")"
fi
done
fi
# df on the parent: $DEST may not exist yet on a first run.
avail_kb=$(ssh "$SSH_TARGET" "df -Pk '$(dirname "$DEST")' | awk 'NR==2 {print \$4}'")
[[ -n "$avail_kb" ]] || die "could not read free space on $HOST"
avail_bytes=$((avail_kb * 1024))
echo
echo "to transfer (upper bound): $(human "$src_bytes")"
echo "free on target: $(human "$avail_bytes")"
if (( src_bytes + HEADROOM_BYTES > avail_bytes )); then
# Already-synced content counts against src_bytes but costs nothing, so this is a
# warning on a repeat run and a hard stop only when nothing is there yet.
remote_used=$(ssh "$SSH_TARGET" "du -sb '$DEST' 2>/dev/null | cut -f1" || echo 0)
remote_used=${remote_used:-0}
if (( remote_used > 0 )); then
echo
echo "warning: the full library would not fit in the free space, but $(human "$remote_used")"
echo " is already at ${DEST}. rsync only moves the difference; watch the"
echo " free space as it runs."
else
die "not enough space: need $(human $((src_bytes + HEADROOM_BYTES))) including $(human "$HEADROOM_BYTES") headroom, have $(human "$avail_bytes")"
fi
fi
# --------------------------------------------------------------------------- transfer
RSYNC_OPTS=(-aH --info=progress2 --human-readable)
[[ $DRY_RUN -eq 1 ]] && RSYNC_OPTS+=(-n)
if [[ $DRY_RUN -eq 1 ]]; then
echo
echo "(dry run - nothing will be written)"
else
step "Creating ${DEST} on ${HOST}"
ssh "$SSH_TARGET" "mkdir -p '$DEST'"
fi
step "Library"
for section in "${present_sections[@]}"; do
echo "--- ${section}"
# Per-shelf, with --delete scoped to that shelf. A --delete on $DEST as a whole
# would take out config.yml and the cache, which live in the same directory on the
# device but have no counterpart here.
rsync "${RSYNC_OPTS[@]}" --delete \
"${SRC}/${section}/" "${SSH_TARGET}:${DEST}/${section}/"
done
if [[ $COPY_CACHE -eq 1 ]]; then
step "Cache (covers and analysis; index.json deliberately not copied)"
for part in covers analysis; do
[[ -d "${CACHE}/${part}" ]] || continue
echo "--- ${part}"
# rsync creates the last path component but not a missing one above it, and on a
# fresh device neither .musicmouse-cache nor its subdirectory exists yet.
[[ $DRY_RUN -eq 1 ]] || ssh "$SSH_TARGET" "mkdir -p '${DEST}/.musicmouse-cache/${part}'"
# No --delete: the device may have analysed tracks this machine never saw.
rsync "${RSYNC_OPTS[@]}" \
"${CACHE}/${part}/" "${SSH_TARGET}:${DEST}/.musicmouse-cache/${part}/"
done
fi
# ----------------------------------------------------------------------------- report
step "Done"
if [[ $DRY_RUN -eq 1 ]]; then
echo "Dry run only - nothing was written to ${HOST}."
exit 0
fi
# Through `sh -s` rather than as an ssh command string: that string is run by the
# remote user's login shell, and on the devices root's is fish, which rejects
# `if ...; then`. The heredoc expands $DEST here; the escaped \$ ones run remotely.
ssh "$SSH_TARGET" sh -s <<EOF
echo "library: \$(du -sh '$DEST' 2>/dev/null | cut -f1)"
echo "free: \$(df -Ph '$DEST' | awk 'NR==2 {print \$4}')"
if [ -d '${DEST}/.musicmouse-cache' ]; then
echo "covers: \$(find '${DEST}/.musicmouse-cache/covers' -type f 2>/dev/null | wc -l) files"
echo "analysis: \$(find '${DEST}/.musicmouse-cache/analysis' -type f 2>/dev/null | wc -l) files"
fi
EOF
cat <<EOF
The backend rescans on its next start - a few minutes for a large library - and writes
a fresh index.json with this device's own paths. Every cover and analysis file copied
above is reused, so the expensive work does not happen again.
ssh ${SSH_TARGET} systemctl restart musicmouse
ssh ${SSH_TARGET} journalctl -u musicmouse -f
EOF