diff options
Diffstat (limited to 'ar')
| -rwxr-xr-x | ar/.local/bin/mtag | 308 |
1 files changed, 308 insertions, 0 deletions
diff --git a/ar/.local/bin/mtag b/ar/.local/bin/mtag new file mode 100755 index 0000000..5107b4b --- /dev/null +++ b/ar/.local/bin/mtag @@ -0,0 +1,308 @@ +#!/bin/sh + +# mtag — rebuild MP3 ID3 tags from each file's path/name, then file the song +# under <music>/<artist>/<album>/<title>.mp3 (qndl's layout). +# +# ncmpcpp/MPD show the *embedded* ID3 tags, not the filename — so renaming a +# file or folder doesn't change what you see. mtag reads the tags back out of +# the on-disk layout (so a rename "sticks") and then moves the file to match. +# +# Resolution rules (is_known = non-empty and not "Unknown Artist"/"Unknown Album"): +# title = filename without extension +# artist = grandparent folder, if known +# album = parent folder, if known +# When the artist is NOT known from a folder, a "Artist - Title" filename is +# parsed (split on the FIRST " - ") to recover the artist and title. +# When no real album is known, the album falls back to the title (single style). +# If the artist still can't be determined, its TAG is left untouched, and the +# move target falls back to the existing tag, then "Unknown Artist". + +set -u + +MUSIC="${XDG_MUSIC_DIR:-$HOME/Music}" +PLAYLIST="${XDG_CONFIG_HOME:-$HOME/.config}/mpd/playlists/entire.m3u" +TITLES="${XDG_DOTFILES_DIR:-$HOME/.dotfiles}/global/Music/.music_titles.txt" +DRY=0 + +usage() { + cat <<EOF +Usage: mtag [--dry-run|-n] [DIR] + +By default mtag REWRITES tags AND MOVES each *.mp3 into +<music>/<artist>/<album>/<title>.mp3, prunes emptied folders, regenerates the +'entire.m3u' playlist (if present), syncs qndl's '.music_titles.txt' restore/ +delete labels (if present), and refreshes MPD. + + (no args) apply everything (tags + move + playlist + mpc update) + --dry-run, -n preview only — show planned tag and move changes, touch nothing + DIR scan DIR instead of the whole music dir ($MUSIC) + -h, --help this help + +Tags are written as ID3v2.3 (UTF-16) so Korean/Japanese text is safe, and +ffmpeg -c copy preserves all other embedded data (e.g. the source URL that +dmenudelmusic relies on). Collisions during a move are skipped, never clobbered. +EOF +} + +# --- argument parsing ------------------------------------------------------- +SCAN="" +for arg in "$@"; do + case "$arg" in + --dry-run | -n) DRY=1 ;; + -h | --help) + usage + exit 0 + ;; + -*) + printf 'mtag: unknown option: %s\n\n' "$arg" >&2 + usage >&2 + exit 2 + ;; + *) SCAN="$arg" ;; + esac +done +[ -n "$SCAN" ] || SCAN="$MUSIC" + +# --- prerequisites ---------------------------------------------------------- +for bin in ffmpeg ffprobe; do + command -v "$bin" >/dev/null 2>&1 || { + printf 'mtag: %s not found (required)\n' "$bin" >&2 + exit 1 + } +done +[ -d "$SCAN" ] || { + printf 'mtag: not a directory: %s\n' "$SCAN" >&2 + exit 1 +} + +# --- helpers ---------------------------------------------------------------- +is_known() { + case "$1" in + "" | "Unknown Artist" | "Unknown Album") return 1 ;; + *) return 0 ;; + esac +} + +# Make a string safe as a single path component: '/' is the only byte Linux +# forbids in a name, so replace just that (keeps spaces, brackets, CJK as-is). +san() { + printf '%s' "$1" | tr '/' '_' +} + +# Show a path relative to the music root when possible (nicer output). +reldisp() { + case "$1" in + "$MUSIC"/*) printf '%s' "${1#"$MUSIC"/}" ;; + *) printf '%s' "$1" ;; + esac +} + +get_tag() { + # get_tag <file> <tag-name> + ffprobe -v error -show_entries "format_tags=$2" \ + -of default=nw=1:nk=1 "$1" 2>/dev/null | head -n 1 +} + +get_vid() { + # Recover the YouTube ID from the embedded source URL (same method as + # dmenudelmusic), or empty if there is none. + strings "$1" 2>/dev/null | grep 'watch?v=' | + sed 's/.*watch?v=\([A-Za-z0-9_-]*\).*/\1/' | head -n 1 +} + +# --- main loop -------------------------------------------------------------- +_list="$(mktemp)" +_titlemap="$(mktemp)" +trap 'rm -f "$_list" "$_titlemap"' EXIT INT TERM +find "$SCAN" -type f -name '*.mp3' | sort >"$_list" + +_total=0 +_changed=0 +_moved=0 + +while IFS= read -r f; do + [ -n "$f" ] || continue + _total=$((_total + 1)) + + # Path relative to the music root, so artist/album come from the right depth + # regardless of where SCAN points. + case "$f" in + "$MUSIC"/*) rel="${f#"$MUSIC"/}" ;; + *) rel="$f" ;; + esac + + fname="$(basename "$rel")" + base="${fname%.*}" # title candidate (drop extension) + + # Determine artist/album folders by depth under the music root. + dir="$(dirname "$rel")" + artistdir="" + albumdir="" + if [ "$dir" != "." ]; then + albumdir="$(basename "$dir")" # immediate parent + pdir="$(dirname "$dir")" + [ "$pdir" != "." ] && artistdir="$(basename "$pdir")" + # Two-level case (artist/title.mp3): the single folder is the ARTIST, + # not the album. + if [ -z "$artistdir" ]; then + artistdir="$albumdir" + albumdir="" + fi + fi + + title="$base" + artist="" + album="" + is_known "$artistdir" && artist="$artistdir" + is_known "$albumdir" && album="$albumdir" + + # Recover artist from a "Artist - Title" filename only when we couldn't get + # one from a folder — avoids mangling titles that legitimately contain " - ". + if [ -z "$artist" ]; then + case "$base" in + *" - "*) + artist="${base%%" - "*}" + title="${base#*" - "}" + ;; + esac + fi + + # Single style: no real album -> album is the title. + [ -n "$album" ] || album="$title" + + old_title="$(get_tag "$f" title)" + old_artist="$(get_tag "$f" artist)" + old_album="$(get_tag "$f" album)" + + # What would actually change? (artist only counts when we have a value) + diff=0 + [ "$old_title" != "$title" ] && diff=1 + [ "$old_album" != "$album" ] && diff=1 + [ -n "$artist" ] && [ "$old_artist" != "$artist" ] && diff=1 + [ "$diff" -eq 1 ] && _changed=$((_changed + 1)) + + # Move target: <music>/<artist>/<album>/<title>.mp3. The path needs a + # concrete artist even when we won't write the tag, so fall back to the + # existing tag, then "Unknown Artist". + path_artist="$artist" + [ -n "$path_artist" ] || path_artist="$old_artist" + [ -n "$path_artist" ] || path_artist="Unknown Artist" + target="$MUSIC/$(san "$path_artist")/$(san "$album")/$(san "$title").mp3" + needmove=0 + [ "$target" != "$f" ] && needmove=1 + [ "$needmove" -eq 1 ] && _moved=$((_moved + 1)) + + # Display + mark=" " + { [ "$diff" -eq 1 ] || [ "$needmove" -eq 1 ]; } && mark="* " + printf '%s%s\n' "$mark" "$rel" + printf ' title : %s\n' "$title" + if [ -n "$artist" ]; then + printf ' artist: %s\n' "$artist" + else + printf ' artist: (tag left as-is: %s)\n' "${old_artist:-<empty>}" + fi + printf ' album : %s\n' "$album" + [ "$needmove" -eq 1 ] && printf ' move : -> %s\n' "$(reldisp "$target")" + + [ "$DRY" -eq 1 ] && continue + + # Record the restore/delete label for qndl's .music_titles.txt, keyed by the + # embedded YouTube ID, so 'qndl -r'/'-d' show the corrected "artist - title" + # instead of the stale download-time label. Done before any move (the file is + # still at "$f"). Files without an embedded ID are skipped. + vid="$(get_vid "$f")" + [ -n "$vid" ] && printf '%s\t%s - %s\n' "$vid" "$path_artist" "$title" >>"$_titlemap" + + # Write tags (in place) first, so the moved file carries the new tags. + # -map 0 + -c copy keep the audio stream and ALL other metadata (cover art, + # the embedded source URL, etc.) untouched — only the listed tags change. + if [ "$diff" -eq 1 ]; then + tmp="${f%.mp3}.mtag.$$.mp3" + set -- ffmpeg -v error -y -i "$f" -map 0 -c copy -id3v2_version 3 \ + -metadata "title=$title" -metadata "album=$album" + [ -n "$artist" ] && set -- "$@" -metadata "artist=$artist" + set -- "$@" "$tmp" + if "$@" && [ -s "$tmp" ]; then + mv -f "$tmp" "$f" + else + rm -f "$tmp" + printf ' !! ffmpeg failed, left unchanged\n' >&2 + fi + fi + + # Then relocate, skipping collisions and pruning emptied source folders. + if [ "$needmove" -eq 1 ]; then + if [ -e "$target" ]; then + printf ' !! target exists, not moved: %s\n' "$(reldisp "$target")" >&2 + _moved=$((_moved - 1)) + else + mkdir -p "$(dirname "$target")" + if mv -n "$f" "$target"; then + d="$(dirname "$f")" + while [ "$d" != "$MUSIC" ] && [ "$d" != "/" ] && [ "$d" != "." ]; do + rmdir "$d" 2>/dev/null || break + d="$(dirname "$d")" + done + else + printf ' !! move failed, left in place\n' >&2 + _moved=$((_moved - 1)) + fi + fi + fi +done <"$_list" + +# --- finish ----------------------------------------------------------------- +printf '\n' +if [ "$DRY" -eq 1 ]; then + printf 'mtag: dry-run — %d tag change(s), %d move(s), of %d file(s).\n' \ + "$_changed" "$_moved" "$_total" + printf 'mtag: (apply would also sync entire.m3u and qndl .music_titles.txt labels.)\n' + printf 'mtag: run without --dry-run to apply.\n' + exit 0 +fi + +printf 'mtag: updated %d tag(s), moved %d file(s), of %d total.\n' \ + "$_changed" "$_moved" "$_total" + +# Keep qndl's playlist consistent: it lists music-relative *.mp3 paths, which +# moves invalidate. Regenerate it the same way qndl does, but only if it +# already exists (don't create the artifact on setups that don't use it). +if [ "$_moved" -gt 0 ] && [ -f "$PLAYLIST" ]; then + find "$MUSIC" -name '*.mp3' | sed "s|$MUSIC/||" | sort >"$PLAYLIST" + printf 'mtag: regenerated %s\n' "$(reldisp "$PLAYLIST")" +fi + +# Keep qndl's restore/delete labels (.music_titles.txt) in sync with the new +# tags. Upsert by YouTube ID: update existing lines, append IDs not yet listed. +# Only rewrite the file when its content actually changes (avoids dotfiles noise). +if [ -s "$_titlemap" ] && [ -f "$TITLES" ]; then + _tnew="$(mktemp)" + awk ' + FNR==NR { + t = index($0, "\t"); if (t == 0) next + id = substr($0, 1, t - 1); lab = substr($0, t + 1) + map[id] = lab; if (!(id in known)) { order[++n] = id; known[id] = 1 } + next + } + { + t = index($0, "\t"); lid = (t ? substr($0, 1, t - 1) : $0) + if (lid in map) { print lid "\t" map[lid]; seen[lid] = 1 } + else print $0 + } + END { for (i = 1; i <= n; i++) { id = order[i]; if (!(id in seen)) print id "\t" map[id] } } + ' "$_titlemap" "$TITLES" >"$_tnew" + if cmp -s "$_tnew" "$TITLES"; then + rm -f "$_tnew" + else + mv "$_tnew" "$TITLES" + printf 'mtag: synced labels in %s\n' "$(reldisp "$TITLES")" + fi +fi + +if command -v mpc >/dev/null 2>&1; then + printf 'mtag: running mpc update...\n' + mpc update --wait >/dev/null 2>&1 && printf 'mtag: MPD database refreshed.\n' +else + printf 'mtag: mpc not found — skipped database refresh.\n' +fi |
