#!/usr/bin/env python3 """Bulk-rename music files in-place using ID3 tags via mutagen. Reads artist/title from file metadata and renames to `Artist - Title.ext`. Handles collisions, special characters, and files already correctly named. Usage: python3 bulk-rename.py /path/to/music/dir Place in ~/.hermes/skills/media/music-library-management/scripts/ """ import os import sys from mutagen import File def sanitize(name): return name.replace('/', '-').replace('\x00', '') def rename_tree(base): renamed = 0 skipped_tag = 0 skipped_ok = 0 errors = [] for root, dirs, files in os.walk(base): for fname in files: if not fname.lower().endswith(('.mp3', '.flac', '.m4a', '.ogg')): continue fullpath = os.path.join(root, fname) try: audio = File(fullpath, easy=True) except Exception as e: errors.append(f"READ {fullpath}: {e}") continue if audio is None or not audio.tags: skipped_tag += 1 continue artist = audio.tags.get('artist', [None])[0] title = audio.tags.get('title', [None])[0] if not artist or not title: skipped_tag += 1 continue ext = os.path.splitext(fname)[1] new_name = f"{sanitize(artist)} - {sanitize(title)}{ext}" new_path = os.path.join(root, new_name) if fullpath == new_path: skipped_ok += 1 continue # Handle collisions counter = 1 while os.path.exists(new_path) and new_path != fullpath: name_no_ext = f"{sanitize(artist)} - {sanitize(title)} ({counter})" new_path = os.path.join(root, name_no_ext + ext) counter += 1 try: os.rename(fullpath, new_path) renamed += 1 except OSError as e: errors.append(f"RENAME {fullpath}: {e}") print(f"Renamed: {renamed}") print(f"Skipped (no tags): {skipped_tag}") print(f"Skipped (already correct): {skipped_ok}") print(f"Errors: {len(errors)}") for e in errors[:20]: print(f" {e}") if __name__ == '__main__': if len(sys.argv) < 2: print(f"Usage: {sys.argv[0]} /path/to/music/dir") sys.exit(1) rename_tree(sys.argv[1])