feat: menu doors for media-sync/backup/compose/schedule/vbox/download; firewall menu → stderr+/dev/tty mechanics
gates / consistency-and-conventions (push) Successful in 2m10s

This commit is contained in:
Your Name
2026-08-24 14:44:25 -04:00
parent 5b2a0304e1
commit 692cb6b362
13 changed files with 865 additions and 241 deletions
+146 -94
View File
@@ -2,10 +2,12 @@
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
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
@@ -29,6 +31,9 @@ USB storage is detected like \`pos system backup\` (lsblk TRAN + lsusb/by-id
corroboration; unmounted sticks get a mount offer first) and you pick which
one to sync to. The source tree is mirrored under <usb>/$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
@@ -53,6 +58,146 @@ 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 ;;
@@ -68,97 +213,4 @@ while [[ $# -gt 0 ]]; do
esac
done
[ "$MP3" -eq 1 ] || [ "$MP4" -eq 1 ] || { MP3=1; MP4=1; }
[ -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"
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 || 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
cmd_sync