#!/usr/bin/env zsh emulate -L zsh setopt err_exit pipefail no_unset prune_root="${BEET_PRUNE_ROOT:-/music/pruned}" dry_run=0 vim_keys="${BEET_PRUNE_VIM_KEYS:-0}" usage() { cat >&2 <<'EOF' usage: beet-prune-albums [options] [beets-query...] Interactively select albums with fzf, move them to a pruned quarantine folder, then remove them from the beets database. Options: --dest DIR Quarantine root. Default: /music/pruned --dry-run Show what would happen, but do not move or remove anything. --vim-keys Bind raw j/k for down/up. This makes typing j/k into search unusable. -h, --help Show this help. Environment: BEET_PRUNE_ROOT=/music/pruned BEET_PRUNE_VIM_KEYS=1 Examples: beet-prune-albums beet-prune-albums --vim-keys beet-prune-albums albumartist:=~"Taylor Swift" beet-prune-albums genre:soundtrack beet-prune-albums --dry-run EOF } while (( $# )); do case "$1" in --dest) prune_root="${2:?missing argument for --dest}" shift 2 ;; --dry-run) dry_run=1 shift ;; --vim-keys) vim_keys=1 shift ;; -h|--help) usage exit 0 ;; --) shift break ;; -*) print -u2 "error: unknown option: $1" usage exit 2 ;; *) break ;; esac done query=( "$@" ) if ! command -v fzf >/dev/null 2>&1; then print -u2 "error: fzf is required" print -u2 "install with: brew install fzf" exit 1 fi if ! command -v beet >/dev/null 2>&1; then print -u2 "error: beet is not in PATH" exit 1 fi timestamp="$(date +%Y%m%d-%H%M%S)" batch_dir="$prune_root/$timestamp" marker="prune_candidate_$timestamp" tmpdir="$(mktemp -d)" trap 'rm -rf "$tmpdir"' EXIT albums_raw="$tmpdir/albums.raw.tsv" counts="$tmpdir/counts.tsv" all="$tmpdir/all.tsv" selected="$tmpdir/selected.tsv" # Album rows. Keep this simple and stable. # id, artist, album, year beet ls -a -f $'$id\t$albumartist\t$album\t$year' "${query[@]}" > "$albums_raw" if [[ ! -s "$albums_raw" ]]; then print "No albums matched." exit 0 fi # Compute album track counts from item rows. # This avoids using $tracktotal, which is not reliable in album-mode formatting. beet ls -f '$album_id' | awk 'NF { count[$0]++ } END { for (id in count) print id "\t" count[id] }' | sort -n > "$counts" # Build fzf input. # # Full row structure: # 1 = album id, hidden # 2 = fixed-width display row, shown # 3 = albumartist # 4 = album # 5 = year # 6 = track count # # Widths are intentionally conservative for normal terminal widths. awk -F '\t' ' NR == FNR { count[$1] = $2 next } { id = $1 artist = $2 album = $3 year = $4 tracks = ((id in count) ? count[id] : "?") if (year == "") year = "----" display = sprintf("%-30.30s %-72.72s %4.4s %4s tracks", artist, album, year, tracks) printf "%s\t%s\t%s\t%s\t%s\t%s\n", id, display, artist, album, year, tracks } ' "$counts" "$albums_raw" > "$all" fzf_binds=( '--bind=space:toggle' '--bind=ctrl-a:select-all' '--bind=ctrl-x:deselect-all' '--bind=ctrl-t:toggle-all' '--bind=ctrl-j:down,ctrl-k:up' '--bind=ctrl-d:preview-half-page-down,ctrl-u:preview-half-page-up' '--bind=ctrl-f:preview-page-down,ctrl-b:preview-page-up' ) if [[ "$vim_keys" == "1" ]]; then fzf_binds+=( '--bind=j:down,k:up' ) fi # Clear inherited FZF_DEFAULT_OPTS so a global --height or layout setting # does not make this cramped. FZF_DEFAULT_OPTS= fzf \ --multi \ --height=100% \ --layout=reverse \ --border=rounded \ --info=inline \ --prompt='prune albums > ' \ --pointer='▶' \ --marker='✓' \ --delimiter=$'\t' \ --with-nth='2' \ --header=$'SPACE = toggle | CTRL-A = select matches | CTRL-X = deselect matches | CTRL-T = toggle matches | ENTER = confirm | CTRL-J/K = down/up | ESC = cancel' \ --preview=$'beet ls -f \'$disc-$track $artist - $title\' album_id:{1} | sed -n \'1,240p\'' \ --preview-window='right,55%,border-left' \ "${fzf_binds[@]}" \ < "$all" > "$selected" || { print "Cancelled." exit 0 } if [[ ! -s "$selected" ]]; then print "Nothing selected." exit 0 fi print print "Selected albums:" awk -F '\t' '{ printf " [%s] %s\n", $1, $2 }' "$selected" print print "Marking selected albums with temporary marker: $marker" while IFS=$'\t' read -r album_id _rest; do beet modify -a -y "id:$album_id" "$marker=1" >/dev/null done < "$selected" cleanup_marker() { beet modify -a -y "$marker:1" "$marker!" >/dev/null 2>&1 || true } trap 'cleanup_marker; rm -rf "$tmpdir"' EXIT print print "Albums marked for pruning:" beet ls -a "$marker:1" print beet stats "$marker:1" || true print print "Quarantine destination:" print " $batch_dir" if (( dry_run )); then print print "Dry run. No files moved and no database rows removed." print print "Preview move command:" print " beet move -a -d ${(q)batch_dir} ${(q)marker:1}" print print "Preview DB removal command:" print " beet remove -a ${(q)marker:1}" exit 0 fi print print "This will:" print " 1. Move selected albums out of the active library into:" print " $batch_dir" print " 2. Remove those albums from the beets database." print " 3. Leave the moved files in the quarantine folder." print read -q "confirm?Proceed? [y/N] " print if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then print "Aborted. Cleaning temporary marker." exit 1 fi mkdir -p "$batch_dir" print print "Moving selected albums to quarantine..." beet move -a -d "$batch_dir" "$marker:1" print print "Verifying moved files exist under quarantine..." missing=0 while IFS=$'\t' read -r album_id _rest; do paths="$(beet ls -f '$path' "album_id:$album_id" || true)" if [[ -z "$paths" ]]; then print -u2 "warning: no paths found in DB for album id $album_id after move" missing=1 continue fi while IFS= read -r filepath; do [[ -z "$filepath" ]] && continue if [[ "$filepath" != "$batch_dir"/* ]]; then print -u2 "warning: path is not under quarantine for album id $album_id:" print -u2 " $filepath" missing=1 elif [[ ! -e "$filepath" ]]; then print -u2 "warning: moved path does not exist for album id $album_id:" print -u2 " $filepath" missing=1 fi done <<< "$paths" done < "$selected" if (( missing )); then print -u2 print -u2 "Refusing to remove albums from the DB because verification failed." print -u2 "The DB may now point some selected albums at the quarantine folder." print -u2 "Inspect with:" print -u2 " beet ls -a '$marker:1'" print -u2 " beet ls -f '\$path' '$marker:1'" exit 1 fi print print "Removing selected albums from beets DB only..." beet remove -a -f "$marker:1" print print "Done." print "Pruned files are here:" print " $batch_dir"