Files
NovaSonic/scripts/reorganize-music.sh

195 lines
8.9 KiB
Bash

#!/usr/bin/env bash
# reorganize-music.sh
#
# Reads metadata tags from audio files and reorganises them into the
# NovaSonic folder/filename convention:
#
# <dest>/<AlbumArtist>/<Album>/<NN - Artist - Title.ext>
#
# The sanitize(), folder and filename rules exactly mirror
# backend/lib/ytdlp.js and backend/lib/metadata.js.
#
# Usage:
# ./reorganize-music.sh <source_dir> <dest_dir> [--copy] [--move] [--clean]
#
# <source_dir> Root of the old genre-folder tree (searched recursively)
# <dest_dir> 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 <source_dir>
#
# Requires: ffprobe (part of ffmpeg)
set -uo pipefail
# ── Args ──────────────────────────────────────────────────────────────────────
if [[ $# -lt 2 ]]; then
echo "Usage: $0 <source_dir> <dest_dir> [--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