#!/usr/bin/env bash set -euo pipefail # POS: media sync — Incremental Music → USB sync (mp3/mp4, add/update only) # POS_FLAGS: --mp3 --mp4 --source --dry-run source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh" source "$(dirname "$0")/../lib/usb-lib.sh" 2>/dev/null || source "$(dirname "$0")/usb-lib.sh" load_system_env # Deps guards sit before -h|--help (help also errors on a box missing the deps). command -v lsblk &>/dev/null || err "lsblk not found (util-linux) — needed to detect USB storage" command -v jq &>/dev/null || err "jq not found — needed to detect USB storage (sudo apt install jq)" SRC="${MEDIA_SYNC_SOURCE:-$HOME/Music}" DEST_DIR="${MEDIA_SYNC_DEST:-Music}" MP3=0 MP4=0 DRY_RUN=0 usage() { cat </$DEST_DIR. Options: --mp3 Sync only *.mp3 files --mp4 Sync only *.mp4 files --source Source folder (default: $SRC) --dry-run Preview what would be copied (copies nothing) -h, --help This help Examples: pos media sync pos media sync --mp3 pos media sync --mp4 --dry-run pos media sync --source /data/Music Environment: MEDIA_SYNC_SOURCE Source folder (default: \$HOME/Music) MEDIA_SYNC_DEST Subfolder on the USB stick (default: Music) USB_MOUNT_BASE Where to mount an unmounted stick (default: /media) USB_BYID by-id dir used to corroborate USB detection (default: /dev/disk/by-id) (loaded from ~/.config/linux_post_install/system.env unless exported) EOF exit 0 } while [[ $# -gt 0 ]]; do case "$1" in -h|--help) usage ;; --mp3) MP3=1; shift ;; --mp4) MP4=1; shift ;; --dry-run) DRY_RUN=1; shift ;; --source) [ $# -ge 2 ] || err "--source needs a value: pos media sync --source " SRC="$2"; shift 2 ;; --source=*) SRC="${1#--source=}"; shift ;; -*) err "Unknown option: $1 (see --help)" ;; *) err "Unexpected argument: $1 (see --help)" ;; esac done [ "$MP3" -eq 1 ] || [ "$MP4" -eq 1 ] || { MP3=1; MP4=1; } # Normalize trailing slashes on the source: GNU find normalizes ONE trailing # slash on the starting point (find -H /x/ -type f emits /x/a.mp3) but keeps # a doubled one (find -H /x// -type f emits /x//a.mp3), so with SRC=/x// the # rel prefix "${f#"$SRC/"}" never matches and files would nest under # /Music//x/... instead of mirroring the tree. Strip ALL trailing # slashes — a lone "/" or "//" ends up empty and hits the guard below. # Covers both --source /x// and MEDIA_SYNC_SOURCE=...//. while [[ "$SRC" == */ ]]; do SRC="${SRC%/}"; done [ -n "$SRC" ] || err "Source path is empty" [ -d "$SRC" ] || err "Source not found: $SRC" # find -H follows only the command-line source symlink; symlinks INSIDE the # tree are never followed, so their targets silently never sync. Count them # (mindepth 1: a symlink source itself IS followed and must not count) and # surface the skip instead of leaving a partial mirror unexplained. inner_links="$(find -H "$SRC" -mindepth 1 -type l 2>/dev/null | wc -l)" || true inner_links="${inner_links// }" if [ "$inner_links" -gt 0 ]; then warn "${inner_links} symlink(s) inside the source are not followed (find -H) — their targets will not be synced" fi trap 'notify_send "Music sync FAILED"' ERR section "Music sync" echo "Source : $SRC" if [ "$MP3" -eq 1 ] && [ "$MP4" -eq 1 ]; then echo "Filter : mp3 + mp4" find_expr=(-type f \( -iname '*.mp3' -o -iname '*.mp4' \)) elif [ "$MP3" -eq 1 ]; then echo "Filter : mp3 only" find_expr=(-type f -iname '*.mp3') else echo "Filter : mp4 only" find_expr=(-type f -iname '*.mp4') fi usb_pick_root "Sync to" "$DEST_DIR" "no sync performed" || { log "Skipped — no sync performed" exit 0 } dest_root="${USB_ROOT%/}/$DEST_DIR" echo "Target : $dest_root" # Does the destination need this file copied? Missing, or size/mtime differs. needs_copy() { local src="$1" dst="$2" ss="" ds="" sm="" dm="" [ -f "$dst" ] || return 0 ss="$(stat -c %s "$src" 2>/dev/null)" || { return 0; } ds="$(stat -c %s "$dst" 2>/dev/null || printf 0)" sm="$(stat -c %Y "$src" 2>/dev/null)" || { return 0; } dm="$(stat -c %Y "$dst" 2>/dev/null || printf 0)" [ "$ss" = "$ds" ] && [ "$sm" -le "$dm" ] && return 1 return 0 } # Pre-flight: fail early on "won't fit" instead of a mid-copy ENOSPC that # leaves a half-written target. Measures exactly what needs_copy would copy. space_path="$USB_ROOT" if [ "$DRY_RUN" -eq 0 ]; then mkdir -p "$dest_root" space_path="$dest_root" fi # One find pass into a temp list, sorted once, shared by the space scan and # the copy loop so both see the identical file set (the old double find is # gone). The old process substitution swallowed find's exit code and stderr # (invisible to set -e / pipefail): an unreadable subdir made find exit 1 # with "Permission denied" yet the tool still announced a false "Sync # complete" on a partial tree. Capture both and surface them explicitly # instead of continuing silently. find_list="$(mktemp)" find_err="$(mktemp)" trap 'rm -f "$find_list" "$find_err"' EXIT find_rc=0 find -H "$SRC" "${find_expr[@]}" >"$find_list" 2>"$find_err" || find_rc=$? sort -o "$find_list" "$find_list" # One "find reported problems" condition, computed once here and reused at the # final success messages (below) so a partial tree is never announced as fully # synced. find_ok=1 if [ "$find_rc" -ne 0 ] || [ -s "$find_err" ]; then find_ok=0 warn "find of the source reported problems — results may be incomplete:" if [ -s "$find_err" ]; then sed 's/^/ /' "$find_err" fi fi need_kb=0 while IFS= read -r f; do rel="${f#"$SRC/"}" if needs_copy "$f" "$dest_root/$rel"; then sz="$(stat -c %s "$f" 2>/dev/null || printf 0)" need_kb=$((need_kb + (sz + 1023) / 1024)) fi done < "$find_list" have_kb="$(df -Pk "$space_path" 2>/dev/null | awk 'NR==2 {print $4}')" have_kb="${have_kb:-0}" if [ "$need_kb" -gt "$have_kb" ]; then msg="Not enough free space on ${USB_ROOT%/}: need ~${need_kb}K, have ${have_kb}K" if [ "$DRY_RUN" -eq 1 ]; then warn "$msg" else err "$msg" fi fi added=0 updated=0 unchanged=0 while IFS= read -r f; do rel="${f#"$SRC/"}" dest="$dest_root/$rel" if needs_copy "$f" "$dest"; then if [ -f "$dest" ]; then updated=$((updated + 1)) else added=$((added + 1)) fi if [ "$DRY_RUN" -eq 1 ]; then log "would copy $rel" else mkdir -p "$(dirname "$dest")" cp --preserve=timestamps "$f" "$dest" echo " + $rel" fi else unchanged=$((unchanged + 1)) fi done < "$find_list" # Mark the success line + notification as partial when find reported # problems, so the success signal can't contradict the warning above. partial_suffix="" [ "$find_ok" -eq 0 ] && partial_suffix=" (partial — find reported problems)" if [ "$DRY_RUN" -eq 1 ]; then echo "DRY RUN — nothing copied. Would sync: ${added} new, ${updated} updated, ${unchanged} unchanged → $dest_root" else ok "Sync complete: ${added} added, ${updated} updated, ${unchanged} unchanged → $dest_root$partial_suffix" notify_send "Music sync completed: ${added} added, ${updated} updated → $dest_root$partial_suffix" fi