#!/usr/bin/env python3 # $Id: set_mp3_artist.py,v 1.4 2026/08/15 14:18:10 jdeifik Exp $ # Copyright Jeff trubo Deifik and claude code May-03-2026. All rights reserved. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """ set_mp3_artist.py — Set artist and album artist tags for all MP3s in a directory. Usage: python set_mp3_artist.py --artist="Bob Smith" [options] Requirements: pip install mutagen Notes: All filesystem paths are handled as raw bytes (not decoded str), so filenames containing characters that don't decode cleanly under the current locale/codeset are never a problem — we never ask Python to interpret them as text. Console output is also configured to degrade gracefully (backslash-escaping) instead of crashing if a filename or message can't be displayed in the terminal's encoding. """ import argparse import os import sys try: from mutagen.id3 import ID3, TPE1, TPE2, ID3NoHeaderError except ImportError: print("Error: 'mutagen' is not installed. Run: pip install mutagen") sys.exit(1) def safe_display(path_bytes: bytes) -> str: """Best-effort text form of a bytes path, safe to print no matter what.""" return os.fsdecode(path_bytes) def set_artist_tags(mp3_path: bytes, artist: str, dry_run: bool = False) -> bool: """Set TPE1 (Artist) and TPE2 (Album Artist) on a single MP3 file. mp3_path is raw bytes so we never need to decode the filename. """ try: try: tags = ID3(mp3_path) except ID3NoHeaderError: tags = ID3() tags["TPE1"] = TPE1(encoding=3, text=artist) tags["TPE2"] = TPE2(encoding=3, text=artist) if not dry_run: tags.save( mp3_path, v2_version=3, # Write ID3v2.3 (widely compatible) v1=0, # Skip ID3v1 tag at end of file # NOTE: previously this reused x.padding directly, which # could come back as an invalid (e.g. negative) value and # raise "invalid padding". Let mutagen pick safe padding # instead. ) return True except Exception as e: print(f" ERROR: {safe_display(mp3_path)} - {e}") return False def find_mp3s(base: bytes, recursive: bool) -> list: """Walk the directory (as bytes) and return sorted bytes paths to .mp3 files.""" results = [] if recursive: for dirpath, _dirnames, filenames in os.walk(base): for fn in filenames: if fn.lower().endswith(b".mp3"): results.append(os.path.join(dirpath, fn)) else: for fn in os.listdir(base): full = os.path.join(base, fn) if fn.lower().endswith(b".mp3") and os.path.isfile(full): results.append(full) return sorted(results) def process_directory(directory: str, artist: str, dry_run: bool = False, recursive: bool = False): # Recover the exact original bytes of the path (argv was decoded with # surrogateescape, so fsencode reverses that losslessly even for names # that aren't valid text under the current locale). base = os.fsencode(directory) if not os.path.exists(base): print(f"Error: Directory '{directory}' does not exist.") sys.exit(1) if not os.path.isdir(base): print(f"Error: '{directory}' is not a directory.") sys.exit(1) mp3_files = find_mp3s(base, recursive) if not mp3_files: print(f"No MP3 files found in '{directory}'.") return mode = "[DRY RUN] " if dry_run else "" print(f"{mode}Setting artist tags to: \"{artist}\"") print(f"Directory: {safe_display(os.path.abspath(base))}") print(f"Files found: {len(mp3_files)}\n") success, failed = 0, 0 for mp3 in mp3_files: label = safe_display(os.path.relpath(mp3, base)) if set_artist_tags(mp3, artist, dry_run=dry_run): print(f" {'[would update]' if dry_run else '[updated]'} {label}") success += 1 else: failed += 1 print(f"\nDone. {success} updated, {failed} failed.") def main(): # Make console output resilient: never crash on a character the # terminal's encoding can't represent (e.g. Cygwin defaulting to # latin-1). Un-encodable characters get backslash-escaped instead. for stream in (sys.stdout, sys.stderr): try: stream.reconfigure(errors="backslashreplace") except AttributeError: pass # very old Python; skip parser = argparse.ArgumentParser( description="Set Artist (TPE1) and Album Artist (TPE2) tags on all MP3s in a directory." ) parser.add_argument("directory", help="Path to the directory containing MP3 files") parser.add_argument("--artist", required=True, metavar="NAME", help='Artist name to set (e.g. --artist="Bob Smith")') parser.add_argument( "-r", "--recursive", action="store_true", help="Recursively process subdirectories" ) parser.add_argument( "--dry-run", action="store_true", help="Preview changes without writing to files" ) args = parser.parse_args() process_directory(args.directory, args.artist, dry_run=args.dry_run, recursive=args.recursive) if __name__ == "__main__": main()