Skip to content

Instantly share code, notes, and snippets.

@keithchambers
Last active January 26, 2025 18:29
Show Gist options
  • Select an option

  • Save keithchambers/202edeb34f518beb38091913eab9aa30 to your computer and use it in GitHub Desktop.

Select an option

Save keithchambers/202edeb34f518beb38091913eab9aa30 to your computer and use it in GitHub Desktop.
Copy audio file ID3 and artwork
#!/usr/bin/env python3
import argparse
import io
import os
import sys
import mutagen
from mutagen.id3 import ID3, ID3NoHeaderError
from mutagen.id3 import error as ID3Error
from mutagen.mp3 import MP3
from mutagen.wave import WAVE
from mutagen.aiff import AIFF
# Added ".id3" to supported extensions
SUPPORTED_EXTENSIONS = (".wav", ".aif", ".aiff", ".mp3", ".id3")
def parse_args():
parser = argparse.ArgumentParser(
description=(
"Copy ALL ID3 metadata (e.g., title, artist, album art, etc.) "
"from a source audio/.id3 file to a target audio/.id3 file."
)
)
parser.add_argument(
"-s", "--source",
required=True,
help="Path to the source (.wav, .aif, .aiff, .mp3, or .id3)."
)
parser.add_argument(
"-t", "--target",
required=True,
help="Path to the target (.wav, .aif, .aiff, .mp3, or .id3)."
)
parser.add_argument(
"-f", "--force",
action="store_true",
help="Force overwriting ID3 metadata without prompt."
)
return parser.parse_args()
def is_supported_extension(path):
"""Check if the file path ends with one of the supported extensions."""
return path.lower().endswith(SUPPORTED_EXTENSIONS)
def read_id3_from_id3_file(path):
"""
Read raw ID3 data from a .id3 file and return an ID3 tag object.
Raises an error if invalid or empty.
"""
try:
with open(path, "rb") as f:
raw_data = f.read()
if not raw_data:
raise ID3Error(f"File '{path}' is empty or invalid.")
return ID3(fileobj=io.BytesIO(raw_data))
except Exception as e:
raise ID3Error(str(e))
def write_id3_to_id3_file(path, id3_tags):
"""
Write ID3 tag data to a .id3 file in raw binary format.
Overwrites any existing file with the same name.
"""
try:
# Create a new standard ID3 object
new_id3 = ID3()
for frame_id, frame in id3_tags.items():
# Copy each frame by creating a new instance with the same data
# This avoids issues with frame objects lacking a 'copy' method
frame_data = frame.__dict__.copy()
# Remove internal Mutagen attributes that shouldn't be passed to the constructor
frame_data.pop('_mapping', None)
frame_data.pop('_fields', None)
# Initialize a new frame with the copied data
new_frame = frame.__class__(**frame_data)
new_id3.add(new_frame)
# Save the new ID3 object to the .id3 file with ID3v2.3
new_id3.save(path, v2_version=3)
except Exception as e:
raise ID3Error(str(e))
def read_id3_tags(path):
"""
Return an ID3 tag object from the given path.
- If path ends with .id3, treat it as a raw ID3 file.
- Otherwise, attempt to open it via mutagen as an audio file and read its ID3 tags.
"""
if path.lower().endswith(".id3"):
return read_id3_from_id3_file(path)
else:
audio = mutagen.File(path)
if not audio:
raise ID3Error(f"'{path}' is not a valid or supported audio file.")
tags = getattr(audio, "tags", None)
if not isinstance(tags, ID3):
raise ID3Error(f"'{path}' does not contain valid ID3 tags.")
return tags
def write_id3_tags(path, id3_tags):
"""
Write ID3 tags to the specified path.
- If path ends with .id3, write raw ID3 data to the .id3 file.
- Otherwise, open as mutagen audio and store the ID3 data there.
"""
if path.lower().endswith(".id3"):
# Overwrite existing .id3 file with the new ID3 data
write_id3_to_id3_file(path, id3_tags)
else:
# Open target audio file and overwrite its ID3 tags
audio = mutagen.File(path)
if not audio:
raise ID3Error(f"'{path}' not recognized or unsupported for tagging.")
# If no tags, add them
if not hasattr(audio, "tags") or audio.tags is None:
audio.add_tags()
if not isinstance(audio.tags, ID3):
raise ID3Error(f"'{path}' does not support ID3 tags in this script.")
# Clear existing frames in the target
audio.tags.delete()
# For standard ID3 objects (e.g., mp3, aiff, etc.), we can upgrade to v2.3 if possible
if hasattr(audio.tags, "update_to_v23"):
audio.tags.update_to_v23()
# Copy frames from source to target
for frame_id, frame in id3_tags.items():
audio.tags.add(frame)
# Finally, save the updated tags
audio.tags.save(path, v2_version=3)
def copy_id3_metadata(source_path, target_path, force=False):
"""
Copies all ID3 metadata from source_path to target_path.
Also handles .id3 files for reading/writing raw ID3 data.
"""
# --- 1) Validate file extensions ---
if not is_supported_extension(source_path):
print(f"Error: Source file '{source_path}' must end with one of {SUPPORTED_EXTENSIONS}.")
sys.exit(1)
if not is_supported_extension(target_path):
print(f"Error: Target file '{target_path}' must end with one of {SUPPORTED_EXTENSIONS}.")
sys.exit(1)
# --- 2) Check file existence (source must exist, target must exist unless it's a .id3 file) ---
if not os.path.isfile(source_path):
print(f"Error: Source file '{source_path}' does not exist.")
sys.exit(1)
# Allow creating a new .id3 file if it doesn't exist:
if not os.path.isfile(target_path) and not target_path.lower().endswith(".id3"):
print(f"Error: Target file '{target_path}' does not exist.")
sys.exit(1)
# --- 3) Read source ID3 ---
try:
src_tags = read_id3_tags(source_path)
except ID3Error as e:
print(f"Error: Could not read ID3 tags from source '{source_path}': {e}")
sys.exit(1)
# --- 4) Check for existing ID3 data in target if not .id3 or if .id3 file already exists ---
target_exists = os.path.isfile(target_path)
if target_exists and not target_path.lower().endswith(".id3"):
# If target is an audio file with existing ID3 tags, prompt before overwriting
try:
tgt_audio = mutagen.File(target_path)
if tgt_audio and hasattr(tgt_audio, "tags") and tgt_audio.tags:
if len(tgt_audio.tags.keys()) > 0:
warning_message = (
f"Warning: Target file '{target_path}' already has ID3 tags. "
"All existing metadata will be overwritten."
)
if force:
print(f"{warning_message} (force=True, proceeding without prompt)")
else:
print(warning_message)
print("Press 'y' to continue overwriting (no Enter needed), or any other key to cancel: ",
end="", flush=True)
import termios, tty
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setraw(fd)
ch = sys.stdin.read(1)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
if ch.lower() != 'y':
print("\nOverwrite cancelled.")
sys.exit(0)
except Exception:
# If we can't read tags from the target, we simply proceed
pass
# If the target is an existing .id3 file, we might prompt similarly
elif target_exists and target_path.lower().endswith(".id3") and not force:
print(
f"Warning: Target file '{target_path}' already exists and will be overwritten. "
"Press 'y' to continue, or any other key to cancel: ",
end="", flush=True
)
import termios, tty
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setraw(fd)
ch = sys.stdin.read(1)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
if ch.lower() != 'y':
print("\nOverwrite cancelled.")
sys.exit(0)
# --- 5) Write the source ID3 data to the target ---
try:
write_id3_tags(target_path, src_tags)
print(f"All ID3 metadata successfully copied from '{source_path}' to '{target_path}'.")
except ID3Error as e:
print(f"Error: Could not write ID3 tags to '{target_path}': {e}")
sys.exit(1)
except Exception as e:
print(f"Error: Could not write ID3 tags to '{target_path}': {e}")
sys.exit(1)
def main():
args = parse_args()
copy_id3_metadata(
source_path=args.source,
target_path=args.target,
force=args.force
)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment