#!/usr/bin/env bash # reorganize-music.sh # # Reads metadata tags from audio files and reorganises them into the # NovaSonic folder/filename convention: # # /// # # The sanitize(), folder and filename rules exactly mirror # backend/lib/ytdlp.js and backend/lib/metadata.js. # # Usage: # ./reorganize-music.sh [--copy] [--move] [--clean] # # Root of the old genre-folder tree (searched recursively) # Music root to write into (can be the same as source) # --copy Copy files to new location (originals kept — safe default) # --move Move files to new location (originals removed) # --clean After moving, remove empty directories under # # Requires: ffprobe (part of ffmpeg) set -uo pipefail # ── Args ────────────────────────────────────────────────────────────────────── if [[ $# -lt 2 ]]; then echo "Usage: $0 [--move] [--clean]" exit 1 fi SOURCE="$(realpath "$1")" DEST="$(realpath "$2")" DO_COPY=false DO_MOVE=false DO_CLEAN=false for arg in "${@:3}"; do [[ "$arg" == "--copy" ]] && DO_COPY=true [[ "$arg" == "--move" ]] && DO_MOVE=true [[ "$arg" == "--clean" ]] && DO_CLEAN=true done MOVED=0; SKIPPED=0; ERRORS=0 # ── sanitize(s): mirrors backend/lib/metadata.js sanitize() ────────────────── # Replace filesystem-illegal chars with _, trim whitespace, cap at 100 chars. sanitize() { local s="${1:-Unknown}" s="$(printf '%s' "$s" | sed 's/[\\/:*?"<>|]/_/g' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" printf '%s' "${s:0:100}" } # ── Read a single ffprobe tag (returns empty string if absent) ──────────────── get_tag() { ffprobe -v quiet \ -show_entries "format_tags=$2" \ -of "default=noprint_wrappers=1:nokey=1" \ "$1" 2>/dev/null | head -1 } # ── First segment before any artist separator (, ; &) ──────────────────────── # Used for the artist *folder* name (album artist, first name only). first_artist() { printf '%s' "$1" | sed 's/[,;&].*//' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' } # ── Normalise all artists for the filename (; → ,) ─────────────────────────── # Mirrors: artistRomaji.replace(/;/g, ',') artists_for_file() { printf '%s' "$1" | sed 's/;[[:space:]]*/\, /g' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' } # ── Print header ────────────────────────────────────────────────────────────── echo "NovaSonic Music Reorganiser" echo "Source : $SOURCE" echo "Dest : $DEST" if $DO_MOVE; then echo "Mode : MOVE (files will be moved, originals removed)" elif $DO_COPY; then echo "Mode : COPY (files will be copied, originals kept)" else echo "Mode : DRY-RUN (nothing will change — add --copy or --move to execute)" fi echo "──────────────────────────────────────────────────────────────────────" # ── Main loop: every audio file under SOURCE ────────────────────────────────── while IFS= read -r -d '' file; do # ── Extension (lowercase) ─────────────────────────────────────────────── filename_base="$(basename "$file")" ext_lower="$(printf '%s' "${filename_base##*.}" | tr '[:upper:]' '[:lower:]')" # ── Read tags via ffprobe ──────────────────────────────────────────────── raw_title=$(get_tag "$file" "title") raw_artist=$(get_tag "$file" "artist") raw_album_artist=$(get_tag "$file" "album_artist") raw_album=$(get_tag "$file" "album") raw_track=$(get_tag "$file" "track") # ── Fallbacks when tags are missing ───────────────────────────────────── # Title: use filename stem (strip extension + leading "NN - " prefix) if [[ -z "$raw_title" ]]; then raw_title="$(printf '%s' "${filename_base%.*}" | sed 's/^[0-9][0-9][[:space:]]*-[[:space:]]*//')" raw_title="$(printf '%s' "$raw_title" | sed 's/[[:space:]]*-[[:space:]]*.*//')" # strip "Artist - " prefix if present [[ -z "$raw_title" ]] && raw_title="Unknown Title" fi artist="${raw_artist:-Unknown Artist}" # Album artist preferred for folder; fall back to track artist main_artist="${raw_album_artist:-$artist}" album="${raw_album:-Singles}" # ── Track number: "5/12" or "05" → zero-padded "05 - " ────────────────── track_num="$(printf '%s' "$raw_track" | cut -d'/' -f1 | tr -dc '0-9')" if [[ -n "$track_num" && "$track_num" -gt 0 ]] 2>/dev/null; then track_prefix="$(printf '%02d - ' "$track_num")" else track_prefix="" fi # ── Build folder path ──────────────────────────────────────────────────── # Convention: {dest}/{first_albumArtist}/{album}/ dir_artist="$(first_artist "$main_artist")" safe_artist_dir="$(sanitize "$dir_artist")" safe_album_dir="$(sanitize "$album")" target_dir="$DEST/$safe_artist_dir/$safe_album_dir" # ── Build filename ─────────────────────────────────────────────────────── # Convention: {NN - }Artist1, Artist2 - Title.ext file_artists="$(artists_for_file "$artist")" safe_artist_file="$(sanitize "$file_artists")" safe_title="$(sanitize "$raw_title")" new_filename="${track_prefix}${safe_artist_file} - ${safe_title}.${ext_lower}" target="$target_dir/$new_filename" # ── Already in the right place? ────────────────────────────────────────── if [[ "$file" == "$target" ]]; then printf ' ✓ already correct %s\n' "$safe_artist_dir/$safe_album_dir/$new_filename" SKIPPED=$((SKIPPED + 1)) continue fi # ── Conflict: target already exists (don't overwrite) ──────────────────── if [[ -e "$target" ]]; then printf ' ⚠ SKIP (exists) %s\n' "$target" SKIPPED=$((SKIPPED + 1)) continue fi # ── Report what we will / would do ─────────────────────────────────────── printf ' → %s/%s/%s\n' "$safe_artist_dir" "$safe_album_dir" "$new_filename" if $DO_MOVE; then if mkdir -p "$target_dir" && mv -- "$file" "$target"; then MOVED=$((MOVED + 1)) else printf ' ERROR moving: %s\n' "$file" >&2 ERRORS=$((ERRORS + 1)) fi elif $DO_COPY; then if mkdir -p "$target_dir" && cp -- "$file" "$target"; then MOVED=$((MOVED + 1)) else printf ' ERROR copying: %s\n' "$file" >&2 ERRORS=$((ERRORS + 1)) fi else MOVED=$((MOVED + 1)) fi done < <(find "$SOURCE" -type f \( \ -iname "*.mp3" -o -iname "*.flac" -o \ -iname "*.m4a" -o -iname "*.opus" -o \ -iname "*.ogg" -o -iname "*.wav" \ \) -print0 | sort -z) # ── Remove empty source dirs (optional) ────────────────────────────────────── if $DO_MOVE && $DO_CLEAN; then # --clean only applies to --move (copy keeps originals) echo "" echo "Cleaning empty directories under $SOURCE …" find "$SOURCE" -mindepth 1 -type d -empty -delete 2>/dev/null && \ echo " Done." || echo " (nothing to clean or permission denied)" fi # ── Summary ────────────────────────────────────────────────────────────────── echo "──────────────────────────────────────────────────────────────────────" if $DO_MOVE; then echo "Done: $MOVED moved, $SKIPPED skipped, $ERRORS errors" elif $DO_COPY; then echo "Done: $MOVED copied, $SKIPPED skipped, $ERRORS errors" else echo "Dry-run: $MOVED would be processed, $SKIPPED already correct or conflicting" echo "" echo "Re-run with --copy to copy (safe) or --move to move. Add --clean to remove empty source dirs after --move." fi