#!/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 # POS_SUBCMDS: menu # POS_DEPS: lsblk jq # POS_EXAMPLES: pos media sync --mp3 | Sync only MP3 files to USB # POS_EXAMPLES: pos media sync --mp4 --dry-run | Preview MP4 sync without copying 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" source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null || source "$(dirname "$0")/menu-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. Bare \`pos media sync\` on a terminal (or \`pos media sync menu\`) opens an interactive menu wrapping these same actions; flags stay scriptable. 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 } # ── Interactive menu (opt-in front door, Pattern B via lib/menu-lib.sh) ── menu_change_source() { local val val="$(menu_ask_value "Source folder [current: $SRC]")" || return 0 if [ ! -d "$val" ]; then warn "Not a folder: $val" return 0 fi SRC="$val" log "Source set to $SRC" } run_menu() { menu_guard || exit 1 while true; do local choice choice="$(menu_run "Music sync" \ "Sync now — mp3 + mp4" \ "Preview only (--dry-run)" \ "Sync mp3 only" \ "Sync mp4 only" \ "Change source folder (current: $SRC)")" || return 0 case "$choice" in 1) MP3=1; MP4=1; DRY_RUN=0; cmd_sync ;; 2) MP3=1; MP4=1; DRY_RUN=1; cmd_sync ;; 3) MP3=1; MP4=0; DRY_RUN=0; cmd_sync ;; 4) MP3=0; MP4=1; DRY_RUN=0; cmd_sync ;; 5) menu_change_source ;; esac done } cmd_sync() { [ -d "$SRC" ] || err "Source not found: $SRC" 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" return 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 || printf 0)" ds="$(stat -c %s "$dst" 2>/dev/null || printf 0)" sm="$(stat -c %Y "$src" 2>/dev/null || printf 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 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 -H "$SRC" "${find_expr[@]}") 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 -H "$SRC" "${find_expr[@]}" | sort) 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" notify_send "Music sync completed: ${added} added, ${updated} updated → $dest_root" fi } # Menu door: explicit verb, or zero args on a terminal. Everything below — # including zero args without a terminal — stays byte-compatible with the # pre-menu CLI. if [ "${1:-}" = "menu" ]; then run_menu exit 0 fi if [ $# -eq 0 ] && [ -t 0 ]; then run_menu exit 0 fi 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; } cmd_sync