#!/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()