Last active
January 26, 2025 18:29
-
-
Save keithchambers/202edeb34f518beb38091913eab9aa30 to your computer and use it in GitHub Desktop.
Copy audio file ID3 and artwork
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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