291 lines
6.7 KiB
Bash
Executable File
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"
|