Add CD ripping scripts with multi-drive parallel support

rip.sh: rips audio CDs to MP3 (Artist/Album/NN - Title.mp3 layout),
queries MusicBrainz by disc ID with manual-input fallback, detects
duplicate tracks via audio-content hash (flock-protected index for
concurrent writes), and supports multiple drives ripping in parallel
with a per-drive confirmation table to prevent disc mixups.

install-deps.sh: auto-detects the package manager (pacman/apt/dnf/
zypper/brew) and installs required and optional dependencies, skipping
anything already present.

Both scripts accept --help / -h.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
The_miro 2026-05-18 13:28:05 +02:00
commit 2a84f1fc38
2 changed files with 782 additions and 0 deletions

235
install-deps.sh Executable file
View File

@ -0,0 +1,235 @@
#!/usr/bin/env bash
# Install rip.sh dependencies — supports apt, dnf, pacman, zypper, brew
set -euo pipefail
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
log() { printf '==> %s\n' "$*"; }
info() { printf ' %s\n' "$*"; }
usage() {
cat <<'EOF'
Usage: install-deps.sh [--help]
Install dependencies required by rip.sh.
Required tools (cdparanoia ffmpeg lame curl jq flock) are installed
unconditionally; optional tools (discid id3v2) are installed after prompting.
The package manager is detected automatically (pacman, apt, dnf, zypper, brew).
EOF
}
# ---------------------------------------------------------------------------
# Package map: tool -> (apt dnf pacman zypper brew)
# ---------------------------------------------------------------------------
# Format per manager: "pkg" or "" if not available / handled separately
declare -A APT PACMAN DNF ZYPPER BREW
# required
APT[cdparanoia]=cdparanoia
APT[ffmpeg]=ffmpeg
APT[lame]=lame
APT[curl]=curl
APT[jq]=jq
APT[flock]=util-linux # almost always pre-installed
# optional
APT[discid]=discid
APT[id3v2]=id3v2
PACMAN[cdparanoia]=cdparanoia
PACMAN[ffmpeg]=ffmpeg
PACMAN[lame]=lame
PACMAN[curl]=curl
PACMAN[jq]=jq
PACMAN[flock]=util-linux # almost always pre-installed
PACMAN[discid]=libdiscid # provides the `discid` binary
PACMAN[id3v2]=id3v2
DNF[cdparanoia]=cdparanoia
DNF[ffmpeg]=ffmpeg # needs RPM Fusion enabled
DNF[lame]=lame # needs RPM Fusion enabled
DNF[curl]=curl
DNF[jq]=jq
DNF[flock]=util-linux
DNF[discid]=libdiscid
DNF[id3v2]=id3v2
ZYPPER[cdparanoia]=cdparanoia
ZYPPER[ffmpeg]=ffmpeg
ZYPPER[lame]=lame
ZYPPER[curl]=curl
ZYPPER[jq]=jq
ZYPPER[flock]=util-linux
ZYPPER[discid]=libdiscid-tools
ZYPPER[id3v2]=id3v2
BREW[cdparanoia]=cdparanoia
BREW[ffmpeg]=ffmpeg
BREW[lame]=lame
BREW[curl]=curl
BREW[jq]=jq
BREW[flock]=flock # standalone formula on macOS
BREW[discid]=libdiscid
BREW[id3v2]=id3v2
REQUIRED=(cdparanoia ffmpeg lame curl jq flock)
OPTIONAL=(discid id3v2)
# ---------------------------------------------------------------------------
# Detect package manager
# ---------------------------------------------------------------------------
detect_pm() {
if command -v pacman &>/dev/null; then echo pacman; return; fi
if command -v apt-get &>/dev/null; then echo apt; return; fi
if command -v dnf &>/dev/null; then echo dnf; return; fi
if command -v zypper &>/dev/null; then echo zypper; return; fi
if command -v brew &>/dev/null; then echo brew; return; fi
echo ""
}
# ---------------------------------------------------------------------------
# Check which tools are already installed
# ---------------------------------------------------------------------------
missing_tools() {
local -n _list="$1"
local missing=()
for tool in "${_list[@]}"; do
# discid ships the binary as `discid`; cd-discid is an alternative
if [[ "$tool" == "discid" ]]; then
command -v discid &>/dev/null && continue
command -v cd-discid &>/dev/null && continue
missing+=("$tool")
else
command -v "$tool" &>/dev/null || missing+=("$tool")
fi
done
echo "${missing[@]:-}"
}
# ---------------------------------------------------------------------------
# Install
# ---------------------------------------------------------------------------
install_packages() {
local pm="$1"
shift
local -a pkgs=("$@")
[[ ${#pkgs[@]} -eq 0 ]] && return 0
case "$pm" in
pacman)
sudo pacman -S --needed --noconfirm "${pkgs[@]}" ;;
apt)
sudo apt-get update -qq
sudo apt-get install -y "${pkgs[@]}" ;;
dnf)
sudo dnf install -y "${pkgs[@]}" ;;
zypper)
sudo zypper install -y "${pkgs[@]}" ;;
brew)
brew install "${pkgs[@]}" ;;
esac
}
pkg_name() {
local pm="$1" tool="$2"
case "$pm" in
pacman) echo "${PACMAN[$tool]:-}" ;;
apt) echo "${APT[$tool]:-}" ;;
dnf) echo "${DNF[$tool]:-}" ;;
zypper) echo "${ZYPPER[$tool]:-}" ;;
brew) echo "${BREW[$tool]:-}" ;;
esac
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
[[ "${1:-}" == "--help" || "${1:-}" == "-h" ]] && { usage; exit 0; }
local pm
pm=$(detect_pm)
if [[ -z "$pm" ]]; then
die "No supported package manager found (apt, dnf, pacman, zypper, brew)"
fi
log "Package manager: $pm"
# Warn about RPM Fusion for dnf (ffmpeg/lame are in restricted repos)
if [[ "$pm" == "dnf" ]]; then
info "Note: ffmpeg and lame require RPM Fusion (https://rpmfusion.org)."
info " Enable with: sudo dnf install https://download1.rpmfusion.org/free/fedora/rpmfusion-free-release-\$(rpm -E %fedora).noarch.rpm"
echo ""
fi
# --- Required ---
local -a req_missing
read -ra req_missing <<< "$(missing_tools REQUIRED)"
if [[ ${#req_missing[@]} -gt 0 ]]; then
log "Installing required packages: ${req_missing[*]}"
local -a req_pkgs=()
for tool in "${req_missing[@]}"; do
local pkg
pkg=$(pkg_name "$pm" "$tool")
[[ -n "$pkg" ]] && req_pkgs+=("$pkg")
done
install_packages "$pm" "${req_pkgs[@]}"
else
log "All required tools already installed"
fi
# --- Optional ---
local -a opt_missing
read -ra opt_missing <<< "$(missing_tools OPTIONAL)"
if [[ ${#opt_missing[@]} -gt 0 ]]; then
echo ""
log "Optional tools missing: ${opt_missing[*]}"
info " discid — MusicBrainz disc ID lookup (enables automatic metadata)"
info " id3v2 — writes ID3 tags to MP3 files"
echo ""
read -rp "Install optional tools? [Y/n] " ans
if [[ "${ans,,}" != "n" ]]; then
local -a opt_pkgs=()
for tool in "${opt_missing[@]}"; do
local pkg
pkg=$(pkg_name "$pm" "$tool")
[[ -n "$pkg" ]] && opt_pkgs+=("$pkg")
done
install_packages "$pm" "${opt_pkgs[@]}"
fi
else
log "All optional tools already installed"
fi
# --- Final check ---
echo ""
log "Verification:"
local all_ok=true
for tool in "${REQUIRED[@]}" "${OPTIONAL[@]}"; do
local status
if [[ "$tool" == "discid" ]]; then
(command -v discid &>/dev/null || command -v cd-discid &>/dev/null) \
&& status="ok" || status="MISSING"
else
command -v "$tool" &>/dev/null && status="ok" || status="MISSING"
fi
printf ' %-15s %s\n' "$tool" "$status"
[[ "$status" == "ok" ]] || all_ok=false
done
echo ""
if [[ "$all_ok" == true ]]; then
log "All tools ready. Run: ./rip.sh"
else
log "Some required tools are still missing — check the output above."
exit 1
fi
}
main "$@"

547
rip.sh Executable file
View File

@ -0,0 +1,547 @@
#!/usr/bin/env bash
# CD ripper — multi-drive parallel ripping with MusicBrainz metadata and audio-hash deduplication
# Layout: MUSIC_DIR/Artist/Album/NN - Title.mp3
#
# Usage:
# ./rip.sh auto-detect all drives
# ./rip.sh --drives /dev/sr0 ... specify drives explicitly
# ./rip.sh --index build hash index from existing MP3s
set -euo pipefail
MUSIC_DIR="${MUSIC_DIR:-./music}"
TEMP_DIR="$(mktemp -d)"
MB_API="https://musicbrainz.org/ws/2"
MB_UA="CD-Ripper/1.0 (amir@abdelbaki.eu)"
HASH_INDEX= # set after MUSIC_DIR is created
cleanup() { rm -rf "$TEMP_DIR"; }
trap cleanup EXIT
log() { printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*"; }
dlog() { printf '[%s][%-5s] %s\n' "$(date '+%H:%M:%S')" "$1" "${*:2}"; }
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
warn() { printf 'WARN: %s\n' "$*"; }
drive_label() { basename "$1"; } # /dev/sr0 -> sr0
drive_dir() { echo "$TEMP_DIR/$(drive_label "$1")"; }
# ---------------------------------------------------------------------------
# Dependencies
# ---------------------------------------------------------------------------
check_deps() {
local missing=()
for cmd in cdparanoia ffmpeg lame curl jq flock; do
command -v "$cmd" &>/dev/null || missing+=("$cmd")
done
[[ ${#missing[@]} -eq 0 ]] || die "Missing required tools: ${missing[*]}"
command -v discid &>/dev/null || \
command -v cd-discid &>/dev/null || \
warn "'discid'/'cd-discid' not found — MusicBrainz lookup disabled"
command -v id3v2 &>/dev/null || \
warn "'id3v2' not found — MP3 tags will not be written"
}
# ---------------------------------------------------------------------------
# Drive detection
# ---------------------------------------------------------------------------
detect_drives() {
local real_seen=":" # colon-delimited set of real paths already seen
local -a found=()
# Prefer /dev/sr* (real devices), then common symlink names
for dev in /dev/sr{0..7} /dev/cdrom /dev/cdrw /dev/dvd /dev/dvdrw; do
[[ -b "$dev" ]] || { [[ -L "$dev" ]] && [[ -b "$(realpath "$dev" 2>/dev/null || true)" ]]; } || continue
local real
real=$(realpath "$dev" 2>/dev/null) || continue
[[ -b "$real" ]] || continue
# Deduplicate by real path (cdrom / dvd are usually symlinks to sr0)
[[ "$real_seen" == *":$real:"* ]] && continue
real_seen+=":$real:"
log "Probing $dev..."
if timeout 15 cdparanoia -d "$dev" -Q &>/dev/null 2>&1; then
log " Audio disc detected in $dev"
found+=("$dev")
else
log " No audio disc in $dev"
fi
done
printf '%s\n' "${found[@]+"${found[@]}"}"
}
# ---------------------------------------------------------------------------
# CD helpers (drive-parameterised)
# ---------------------------------------------------------------------------
count_cd_tracks() {
local drive="$1"
local out
out=$(cdparanoia -d "$drive" -Q 2>&1) || true
printf '%s\n' "$out" | grep -cE '^\s+[0-9]+\.' || true
}
get_disc_id() {
local drive="$1"
if command -v discid &>/dev/null; then
discid "$drive" 2>/dev/null || true
elif command -v cd-discid &>/dev/null; then
cd-discid "$drive" 2>/dev/null | awk '{print $1}' || true
fi
}
# ---------------------------------------------------------------------------
# MusicBrainz
# ---------------------------------------------------------------------------
query_mb() {
local disc_id="$1"
curl -sS --max-time 15 -A "$MB_UA" \
"${MB_API}/discid/${disc_id}?fmt=json&inc=artists+recordings+artist-credits" \
2>/dev/null || true
}
# Prints selected release_id; returns 1 if user cancels
pick_release() {
local response="$1" label="$2"
local releases_json
releases_json=$(echo "$response" | jq -c '
if .releases then .releases
elif .release then [.release]
else []
end
' 2>/dev/null) || return 1
local count
count=$(echo "$releases_json" | jq 'length' 2>/dev/null) || return 1
[[ "$count" -gt 0 ]] || return 1
if [[ "$count" -eq 1 ]]; then
echo "$releases_json" | jq -r '.[0].id'
return 0
fi
echo ""
echo "[$label] Multiple releases found — pick one:"
local i=0
while IFS=$'\t' read -r _id title date artist; do
echo " $((i+1))) $artist $title ($date)"
((i++))
done < <(echo "$releases_json" | jq -r \
'.[] | "\(.id)\t\(.title)\t\(.date // "?")\t\((.[\"artist-credit\"] // []) | map(.name) | join(\", \"))"')
echo " $((i+1))) None (manual input)"
local choice
while true; do
read -rp " [$label] Select [1-$((i+1))]: " choice
[[ "$choice" =~ ^[0-9]+$ ]] \
&& [[ "$choice" -ge 1 ]] \
&& [[ "$choice" -le "$((i+1))" ]] \
&& break
done
[[ "$choice" -eq "$((i+1))" ]] && return 1
echo "$releases_json" | jq -r ".[$((choice-1))].id"
}
# Write release fields into drive_dir files (artist, album, year, tracks)
parse_mb_release() {
local response="$1" release_id="$2" ddir="$3"
local rel
rel=$(echo "$response" | jq --arg rid "$release_id" '
if .releases then .releases[] | select(.id == $rid)
elif .release then .release
else empty
end
' 2>/dev/null) || return 1
echo "$rel" | jq -r '(.[\"artist-credit\"] // []) | map(.name) | join(", ")' > "$ddir/artist"
echo "$rel" | jq -r '.title' > "$ddir/album"
echo "$rel" | jq -r '.date // "" | split("-")[0]' > "$ddir/year"
echo "$rel" | jq -r '
(.media // [{}])[0].tracks // [] | sort_by(.position) | .[].title
' > "$ddir/tracks"
}
# ---------------------------------------------------------------------------
# Audio hashing — raw PCM only, strips all headers/tags
# Normalises to 44100 Hz / stereo / 16-bit so the hash is stable across formats.
# ---------------------------------------------------------------------------
audio_hash() {
ffmpeg -i "$1" -vn -f s16le -acodec pcm_s16le -ar 44100 -ac 2 - 2>/dev/null \
| sha256sum | cut -d' ' -f1
}
# Atomically check for a duplicate and, if none, claim the hash entry.
# Prints the existing path if a duplicate exists; prints nothing if the hash was
# new and is now claimed. Returns 0 in both cases (check the output instead).
# Uses flock so concurrent rip jobs don't race on the index.
check_and_claim_hash() {
local hash="$1" path="$2"
local lock="${HASH_INDEX}.lock"
(
flock -x 200
local existing
existing=$(grep -m1 "^${hash}|" "$HASH_INDEX" 2>/dev/null | cut -d'|' -f2- || true)
if [[ -n "$existing" ]]; then
printf '%s' "$existing"
else
printf '%s|%s\n' "$hash" "$path" >> "$HASH_INDEX"
fi
) 200>"$lock"
}
# ---------------------------------------------------------------------------
# Build hash index from existing MP3s (first-run or repair)
# ---------------------------------------------------------------------------
build_index() {
log "Scanning existing tracks to build hash index..."
local count=0
while IFS= read -r -d '' mp3; do
local h
h=$(audio_hash "$mp3")
if [[ -n "$h" ]]; then
printf '%s|%s\n' "$h" "$mp3" >> "$HASH_INDEX"
((count++))
fi
done < <(find "$MUSIC_DIR" -name "*.mp3" -print0 2>/dev/null)
log "Indexed $count track(s)"
}
# ---------------------------------------------------------------------------
# Misc
# ---------------------------------------------------------------------------
sanitize() {
printf '%s' "$1" | tr '/:*?"<>|\\' '_' | sed 's/ */ /g; s/^ //; s/ $//'
}
# ---------------------------------------------------------------------------
# Phase 1: gather metadata for one drive (interactive, sequential)
# Writes results to drive_dir files; returns 1 if drive should be skipped.
# ---------------------------------------------------------------------------
gather_metadata() {
local drive="$1"
local label ddir
label=$(drive_label "$drive")
ddir=$(drive_dir "$drive")
mkdir -p "$ddir"
local num_tracks
num_tracks=$(count_cd_tracks "$drive")
if [[ "${num_tracks:-0}" -eq 0 ]]; then
warn "[$label] No audio tracks — skipping"
echo "skip" > "$ddir/status"
return 1
fi
echo "$num_tracks" > "$ddir/num_tracks"
local disc_id
disc_id=$(get_disc_id "$drive")
printf '%s' "$disc_id" > "$ddir/disc_id"
local use_mb=false
if [[ -n "$disc_id" ]]; then
log "[$label] Disc ID: $disc_id — querying MusicBrainz..."
local mb_resp release_id
mb_resp=$(query_mb "$disc_id")
if [[ -n "$mb_resp" ]] && ! echo "$mb_resp" | jq -e '.error' &>/dev/null 2>&1; then
if release_id=$(pick_release "$mb_resp" "$label"); then
if parse_mb_release "$mb_resp" "$release_id" "$ddir"; then
local artist album track_count
artist=$(cat "$ddir/artist" 2>/dev/null || true)
album=$(cat "$ddir/album" 2>/dev/null || true)
track_count=$(wc -l < "$ddir/tracks" 2>/dev/null || echo 0)
if [[ -n "$artist" ]] && [[ -n "$album" ]] && [[ "$track_count" -gt 0 ]]; then
use_mb=true
log "[$label] MB match: $artist $album"
fi
fi
fi
fi
else
log "[$label] No disc ID — falling back to manual input"
fi
if [[ "$use_mb" == false ]]; then
echo ""
echo "=== [$label] Manual metadata ($num_tracks tracks) ==="
local artist album year
read -rp " Artist : " artist
read -rp " Album : " album
read -rp " Year (blank to skip): " year
printf '%s' "$artist" > "$ddir/artist"
printf '%s' "$album" > "$ddir/album"
printf '%s' "$year" > "$ddir/year"
: > "$ddir/tracks"
for ((i=1; i<=num_tracks; i++)); do
local t
read -rp " Track $i: " t
printf '%s\n' "$t" >> "$ddir/tracks"
done
fi
# Warn on track count mismatch between CD and MB
local mb_count
mb_count=$(wc -l < "$ddir/tracks" 2>/dev/null || echo 0)
if [[ "$mb_count" -ne "$num_tracks" ]]; then
warn "[$label] MB has $mb_count track(s) but CD has $num_tracks — titles may be offset"
fi
return 0
}
# ---------------------------------------------------------------------------
# Phase 2: show confirmation table, catch disc mixups
# ---------------------------------------------------------------------------
confirm_drives() {
local -a drives=("$@")
echo ""
echo "┌────────────────────────────────────────────────────────────────────┐"
echo "│ DISC ASSIGNMENT SUMMARY │"
echo "└────────────────────────────────────────────────────────────────────┘"
printf ' %-8s %-36s %-6s %s\n' "Drive" "Artist Album" "Year" "Tracks"
printf ' %-8s %-36s %-6s %s\n' "--------" "------------------------------------" "------" "------"
local disc_id_seen=":" # ":id1::id2:" for duplicate disc-ID detection
for drive in "${drives[@]}"; do
local label ddir artist album year num_tracks disc_id display
label=$(drive_label "$drive")
ddir=$(drive_dir "$drive")
artist=$(cat "$ddir/artist" 2>/dev/null || echo "?")
album=$(cat "$ddir/album" 2>/dev/null || echo "?")
year=$(cat "$ddir/year" 2>/dev/null || echo "")
num_tracks=$(cat "$ddir/num_tracks" 2>/dev/null || echo "?")
disc_id=$(cat "$ddir/disc_id" 2>/dev/null || echo "")
display="$artist $album"
[[ ${#display} -gt 36 ]] && display="${display:0:33}..."
printf ' %-8s %-36s %-6s %s\n' \
"$label" "$display" "${year:---}" "$num_tracks tracks"
if [[ -n "$disc_id" ]]; then
if [[ "$disc_id_seen" == *":$disc_id:"* ]]; then
echo ""
warn "Disc ID '$disc_id' appears in multiple drives!"
warn "You may have inserted the same CD twice — please check."
echo ""
fi
disc_id_seen+=":$disc_id:"
fi
done
echo ""
echo " Physically verify that each drive contains the disc shown above."
echo " If anything looks wrong, answer N and re-seat the discs."
echo ""
read -rp " All discs confirmed? [Y/n] " ok
[[ "${ok,,}" == "n" ]] && return 1
return 0
}
# ---------------------------------------------------------------------------
# Phase 3: rip one drive — called in a background subshell
# ---------------------------------------------------------------------------
rip_drive() {
local drive="$1"
local label ddir
label=$(drive_label "$drive")
ddir=$(drive_dir "$drive")
local artist album year num_tracks
artist=$(cat "$ddir/artist" 2>/dev/null || echo "Unknown Artist")
album=$(cat "$ddir/album" 2>/dev/null || echo "Unknown Album")
year=$(cat "$ddir/year" 2>/dev/null || echo "")
num_tracks=$(cat "$ddir/num_tracks" 2>/dev/null || echo 0)
local -a tracks=()
[[ -f "$ddir/tracks" ]] && readarray -t tracks < "$ddir/tracks"
local out_dir="$MUSIC_DIR/$(sanitize "$artist")/$(sanitize "$album")"
mkdir -p "$out_dir"
local wav_dir="$ddir/wav"
mkdir -p "$wav_dir"
local ripped=0 skipped=0 failed=0
for ((n=1; n<=num_tracks; n++)); do
local title="${tracks[$((n-1))]:-"Track $(printf '%02d' "$n")"}"
local wav="$wav_dir/track$(printf '%02d' "$n").wav"
local mp3="$out_dir/$(printf '%02d' "$n") - $(sanitize "$title").mp3"
dlog "$label" "[$n/$num_tracks] $title"
# Rip track
if ! cdparanoia -d "$drive" "$n" "$wav" 2>/dev/null; then
dlog "$label" " FAILED to rip track $n"
((failed++)); continue
fi
# Deduplicate: atomically claim hash or detect collision
local hash dup
hash=$(audio_hash "$wav")
dup=$(check_and_claim_hash "$hash" "$mp3")
if [[ -n "$dup" ]]; then
dlog "$label" " DUPLICATE of '$dup' — skipping"
((skipped++)); rm -f "$wav"; continue
fi
# Encode
if ! lame -V2 --quiet "$wav" "$mp3" 2>/dev/null; then
dlog "$label" " FAILED to encode track $n"
((failed++)); rm -f "$wav"; continue
fi
# Tag
if command -v id3v2 &>/dev/null; then
local tag_args=(-a "$artist" -A "$album" -t "$title" -T "$n/$num_tracks")
[[ -n "$year" ]] && tag_args+=(-y "$year")
id3v2 "${tag_args[@]}" "$mp3" 2>/dev/null || true
fi
rm -f "$wav"
((ripped++))
dlog "$label" " -> $mp3"
done
printf '%d %d %d\n' "$ripped" "$skipped" "$failed" > "$ddir/result"
dlog "$label" "Finished: $ripped ripped, $skipped skipped, $failed failed"
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
usage() {
cat <<'EOF'
Usage: rip.sh [OPTIONS]
Rip audio CDs to MP3, organised as MUSIC_DIR/Artist/Album/NN - Title.mp3.
MusicBrainz metadata is used where available, with a manual-input fallback.
Duplicate tracks are detected by audio-content hash (headers/tags ignored).
Options:
--drives DEV... Specify one or more CD device paths (e.g. /dev/sr0 /dev/sr1).
Default: auto-detect all drives that contain an audio disc.
--index Scan MUSIC_DIR for existing MP3s and build/rebuild the
audio hash index used for duplicate detection. Run this once
if you have tracks ripped before using this script.
--help Show this help and exit.
Environment:
MUSIC_DIR Root of the music library (default: ./music)
Examples:
rip.sh
rip.sh --drives /dev/sr0 /dev/sr1
MUSIC_DIR=/mnt/nas/music rip.sh
rip.sh --index
EOF
}
main() {
check_deps
mkdir -p "$MUSIC_DIR"
HASH_INDEX="$MUSIC_DIR/.audio_hashes"
touch "$HASH_INDEX"
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
usage; exit 0
fi
if [[ "${1:-}" == "--index" ]]; then
build_index; exit 0
fi
# Drive selection: explicit or auto-detected
local -a ALL_DRIVES=()
if [[ "${1:-}" == "--drives" ]]; then
ALL_DRIVES=("${@:2}")
[[ ${#ALL_DRIVES[@]} -gt 0 ]] || die "--drives requires at least one device path"
else
log "Scanning for drives with audio discs..."
while IFS= read -r d; do
[[ -n "$d" ]] && ALL_DRIVES+=("$d")
done < <(detect_drives)
fi
[[ ${#ALL_DRIVES[@]} -gt 0 ]] || die "No drives with audio discs found."
log "Using ${#ALL_DRIVES[@]} drive(s): ${ALL_DRIVES[*]}"
# ── Phase 1: metadata (sequential — user interaction required) ────────
echo ""
local -a ACTIVE_DRIVES=()
for drive in "${ALL_DRIVES[@]}"; do
local label
label=$(drive_label "$drive")
echo "━━━━━━━━━━━━ $label ━━━━━━━━━━━━"
if gather_metadata "$drive"; then
ACTIVE_DRIVES+=("$drive")
fi
echo ""
done
[[ ${#ACTIVE_DRIVES[@]} -gt 0 ]] || die "No drives with usable metadata."
# ── Phase 2: confirmation table — disc mixup guard ────────────────────
if ! confirm_drives "${ACTIVE_DRIVES[@]}"; then
echo "Aborted. Correct the disc placement and re-run."
exit 1
fi
# ── Phase 3: parallel ripping ─────────────────────────────────────────
echo ""
log "Starting parallel rip on ${#ACTIVE_DRIVES[@]} drive(s)..."
local -a pids=()
for drive in "${ACTIVE_DRIVES[@]}"; do
rip_drive "$drive" &
pids+=($!)
done
for pid in "${pids[@]}"; do
wait "$pid" || true
done
# ── Phase 4: combined report ──────────────────────────────────────────
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
printf ' %-8s %7s %7s %7s\n' "Drive" "Ripped" "Skipped" "Failed"
echo " ──────────────────────────────────────────"
local total_r=0 total_s=0 total_f=0
for drive in "${ACTIVE_DRIVES[@]}"; do
local label result r=0 s=0 f=0
label=$(drive_label "$drive")
result=$(cat "$(drive_dir "$drive")/result" 2>/dev/null || echo "0 0 0")
read -r r s f <<< "$result"
printf ' %-8s %7d %7d %7d\n' "$label" "$r" "$s" "$f"
((total_r += r)); ((total_s += s)); ((total_f += f))
done
echo " ──────────────────────────────────────────"
printf ' %-8s %7d %7d %7d\n' "TOTAL" "$total_r" "$total_s" "$total_f"
echo ""
log "Output: $MUSIC_DIR"
}
main "$@"