Template
105 lines
3.3 KiB
Bash
Executable File
105 lines
3.3 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage: cleanup-library.sh --library PATH [--dry-run|--execute]
|
|
|
|
Default mode is --dry-run. --execute is required to delete files.
|
|
The script only handles audio files below PATH and same-basename sidecars.
|
|
EOF
|
|
}
|
|
|
|
LIBRARY=""
|
|
MODE="dry-run"
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--library) [[ $# -ge 2 ]] || { usage >&2; exit 2; }; LIBRARY="$2"; shift 2 ;;
|
|
--dry-run) MODE="dry-run"; shift ;;
|
|
--execute) MODE="execute"; shift ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
[[ -n "$LIBRARY" && -d "$LIBRARY" ]] || { echo "Library directory is required" >&2; exit 2; }
|
|
command -v ffprobe >/dev/null || { echo "ffprobe is required" >&2; exit 2; }
|
|
command -v ffmpeg >/dev/null || { echo "ffmpeg is required" >&2; exit 2; }
|
|
|
|
is_audio() {
|
|
local ext="${1##*.}"
|
|
ext="${ext,,}"
|
|
case "$ext" in
|
|
mp3|m4a|mp4|aac|flac|ogg|oga|opus|wma|wav|ape|aiff|aif|wv|tta) return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
has_cover_file() {
|
|
[[ -s "$1/cover.jpg" || -s "$1/cover.jpeg" || -s "$1/cover.png" ]]
|
|
}
|
|
|
|
embedded_stream() {
|
|
ffprobe -v error -select_streams v \
|
|
-show_entries stream=index:stream_disposition=attached_pic \
|
|
-of csv=p=0 "$1" 2>/dev/null | awk -F, '$2 == 1 { print $1; exit }'
|
|
}
|
|
|
|
extract_cover() {
|
|
local audio="$1" album_dir="$2" stream codec ext target
|
|
stream="$(embedded_stream "$audio")"
|
|
[[ -n "$stream" ]] || return 1
|
|
codec="$(ffprobe -v error -show_entries stream=index,codec_name -of csv=p=0 "$audio" 2>/dev/null | awk -F, -v idx="$stream" '$1 == idx { print $2; exit }')"
|
|
case "$codec" in
|
|
mjpeg|jpeg) ext="jpg"; target="$album_dir/cover.jpg" ;;
|
|
png) ext="png"; target="$album_dir/cover.png" ;;
|
|
*) ext="jpg"; target="$album_dir/cover.jpg" ;;
|
|
esac
|
|
[[ -e "$target" ]] && return 0
|
|
if [[ "$MODE" == "dry-run" ]]; then
|
|
echo "WOULD EXTRACT $target FROM $audio"
|
|
return 0
|
|
fi
|
|
if [[ "$ext" == "jpg" && "$codec" =~ ^(mjpeg|jpeg)$ || "$ext" == "png" ]]; then
|
|
ffmpeg -v error -y -i "$audio" -map "0:$stream" -frames:v 1 -c copy "$target" 2>/dev/null
|
|
else
|
|
ffmpeg -v error -y -i "$audio" -map "0:$stream" -frames:v 1 "$target" 2>/dev/null
|
|
fi
|
|
[[ -s "$target" ]]
|
|
}
|
|
|
|
delete_sidecars() {
|
|
local audio="$1" dir base sidecar ext
|
|
dir="${audio%/*}"; base="${audio##*/}"; base="${base%.*}"
|
|
for sidecar in "$dir/$base".*; do
|
|
[[ -f "$sidecar" && "$sidecar" != "$audio" ]] || continue
|
|
ext="${sidecar##*.}"
|
|
case "${ext,,}" in
|
|
lrc|cue|json|txt|jpg|jpeg|png|webp)
|
|
[[ "${sidecar##*/}" == cover.jpg || "${sidecar##*/}" == cover.jpeg || "${sidecar##*/}" == cover.png ]] && continue
|
|
if [[ "$MODE" == "dry-run" ]]; then echo "WOULD DELETE SIDECAR $sidecar"; else rm -f -- "$sidecar"; fi ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
removed=0
|
|
sidecars=0
|
|
extracted=0
|
|
while IFS= read -r -d '' audio; do
|
|
album_dir="${audio%/*}"
|
|
if has_cover_file "$album_dir"; then continue; fi
|
|
if extract_cover "$audio" "$album_dir"; then
|
|
((extracted += 1)) || true
|
|
continue
|
|
fi
|
|
if [[ "$MODE" == "dry-run" ]]; then
|
|
echo "WOULD DELETE AUDIO $audio"
|
|
else
|
|
rm -f -- "$audio"
|
|
fi
|
|
delete_sidecars "$audio"
|
|
((removed += 1)) || true
|
|
done < <(find "$LIBRARY" -type f -print0 | while IFS= read -r -d '' f; do is_audio "$f" && printf '%s\0' "$f"; done)
|
|
|
|
echo "mode=$MODE extracted=$extracted audio_removed=$removed"
|