diff --git a/scripts/sync-library.sh b/scripts/sync-library.sh new file mode 100755 index 0000000..184581d --- /dev/null +++ b/scripts/sync-library.sh @@ -0,0 +1,241 @@ +#!/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/.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/.json track_key = sha1("::"). 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//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: /.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}" + # 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 + +ssh "$SSH_TARGET" " + 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 +" + +cat <