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>
248 lines
9.7 KiB
Bash
Executable File
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
|