From 28428aa36232a3c6d281dc2aa6b0b34922bd87de Mon Sep 17 00:00:00 2001 From: Honey Date: Thu, 4 Jun 2026 22:37:00 +0200 Subject: [PATCH] feat: add reorganize-music.sh to migrate genre folders to Artist/Album structure Reads ffprobe metadata from every audio file and places it into: {dest}/{AlbumArtist}/{Album}/{NN - Artist - Title.ext} Exactly mirrors the sanitize(), folder and filename rules in backend/lib/ytdlp.js. Dry-run by default; --move to execute, --clean to remove empty source dirs afterwards. Co-Authored-By: Claude Sonnet 4.6 --- scripts/reorganize-music.sh | 180 ++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 scripts/reorganize-music.sh diff --git a/scripts/reorganize-music.sh b/scripts/reorganize-music.sh new file mode 100644 index 0000000..9b1f44d --- /dev/null +++ b/scripts/reorganize-music.sh @@ -0,0 +1,180 @@ +#!/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 [--move] [--clean] +# +# Root of the old genre-folder tree (searched recursively) +# Music root to write into (can be the same as source) +# --move Actually move files (default: dry-run preview only) +# --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_MOVE=false +DO_CLEAN=false +for arg in "${@:3}"; do + [[ "$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)" +else + echo "Mode : DRY-RUN (nothing will change — add --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 + 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 + 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" +else + echo "Dry-run: $MOVED would be moved, $SKIPPED already correct or conflicting" + echo "" + echo "Re-run with --move to execute. Add --clean to also remove empty source dirs." +fi