195 lines
8.9 KiB
Bash
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
|