Files
beets-setup/scripts/prune-library
2026-05-18 12:49:49 -04:00

291 lines
6.7 KiB
Bash
Executable File

#!/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"