summaryrefslogtreecommitdiff
path: root/ar/.local
diff options
context:
space:
mode:
authorTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-06-23 12:55:14 +0900
committerTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-06-23 12:55:14 +0900
commit0b439f75e6927ae46afc4e9f81606577c8180032 (patch)
treee5b958d42f81ee007e83b95b5731895f7b9ffcde /ar/.local
parent6dde5503dfba5a97609b9400c80fc52f22060f4d (diff)
created bin/mtag
Diffstat (limited to 'ar/.local')
-rwxr-xr-xar/.local/bin/mtag308
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