e969234ca5
gates / consistency-and-conventions (push) Successful in 1m28s
- lib/registry.sh: shared query API over POS_* headers (reg_scan, reg_list, reg_lookup, reg_tools_in, reg_each, reg_config_scopes/keys). Replaces per-consumer sed/grep header parsing. - bin/pos-tree + bin/pos _pos_category_help(): migrated to registry API. Category help now shows [deps: ...] annotations. Tree output preserved. - New optional headers # POS_DEPS: and # POS_EXAMPLES: in tool metadata. Added to pos-network-download (aria2c jq curl), pos-media-sync (lsblk jq), pos-system-backup (tar), pos-docker-ps (docker) as initial adopters. - scripts/gen-docs.sh: extended tools array with deps/examples fields; conditional column rendering in gen_dispatch; deps annotation in gen_tree. Fixed URL-unsafe // joiner (→ middle dot ·) and \x1f caption delimiter collision in config-ui. - bin/pos-ai-alias: rewrote activation from bash aliases (source-time-frozen) to executable wrapper scripts at ~/.local/bin. Staleness eliminated: edits apply on next invocation with no shell reload. _alias_sync() reconciliation on every subcommand, marker-guarded lifecycle, collision refusal, legacy .sh retirement. Fixed dup-table bug (option 4 no-op). - lib/config-ui.sh: @caption/@[KEY=alt] conditional captions, *providers=<tag> tagged wildcards, uniform typography tier (bold/cyan/dim), honest prompt. Active provider keys bold, inactive dimmed with reason. Backward-compatible. - bin/pos-system-uninstall: marker-scan for wrapper script cleanup. - Docs synced: AGENTS.md (new headers + registry), DOC/SCRIPTS.md (registry section + lib list), DOC/POS.md (alias wrapper activation), MAINTENANCE.md (M-024). Lint fixed: pos-ai-alias registered in INTERACTIVE_CMDS. Gates: make gen && make check && make lint = 0 FAIL, 0 WARN
220 lines
7.3 KiB
Bash
Executable File
220 lines
7.3 KiB
Bash
Executable File
#!/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 <<EOF
|
|
Usage: pos media sync [options]
|
|
|
|
Incrementally copy your music onto a USB stick. Add/update only — files on
|
|
the stick that are no longer in the source are left alone, never deleted.
|
|
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
|
|
--source <dir> 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 <dir>"
|
|
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
|