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