Files
musicmouse/scripts/sync-library.sh
Martin Bauer fa3d92189c Keep only the newest 50 episodes of each podcast
A show that has published for years is unbounded: GEOlino Spezial alone is 358
episodes and 5.8 GB, and the device it syncs onto is a 30 GB SD card that also
holds the rest of the library. Nothing stopped the 6-hourly poll from eventually
filling it.

Cap each show's folder at general.podcast_episode_limit (default 50, null to
keep everything), pruning the oldest past that after each sync pass.

The same limit caps what is downloaded, and it has to be one number for both.
Prune to the newest N but keep fetching everything the feed offers, and every
poll would re-download exactly the episodes the previous one deleted - forever,
at full size, since missing_episodes() decides purely from what is on disk.
There is a test for that specific loop.

Pruning only touches files named the way this module names them
(YYYYMMDD - Title.ext), so feed.txt, folder.jpg, the failed-download record and
anything placed by hand are all left alone; a parse that fails means "not ours",
not "delete it". An episode's sidecar cover goes with it. A pass that only
deleted still reports a change, because the library needs the rescan just as
much as it does after a download.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:40:53 +02:00

245 lines
9.5 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
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 <<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