#!/usr/bin/env python3 import re from pathlib import Path from typing import List def prompt(message: str, default=None, input_type=str): """Prompt user with optional default value.""" if default is not None: msg = f"{message} [{default}]: " else: msg = f"{message}: " while True: try: value = input(msg).strip() if not value: if default is not None: return input_type(default) print("This field is required.") continue return input_type(value) except ValueError: print(f"Invalid input. Please enter a valid {input_type.__name__}.") def prompt_bool(message: str, default=True): """Prompt user for yes/no.""" default_str = "Y/n" if default else "y/N" while True: value = input(f"{message} [{default_str}]: ").strip().lower() if not value: return default if value in ("y", "yes"): return True if value in ("n", "no"): return False print("Please enter 'y' or 'n'.") def get_config(): """Interactively gather configuration.""" print("\n" + "=" * 60) print("Audio File Tagger") print("=" * 60 + "\n") # Required: folder path while True: outdir = Path(prompt("Enter folder path containing audio files")) if outdir.is_dir(): break print(f"Folder not found: {outdir}") print("\nOptional settings (press Enter for defaults):\n") album = prompt("Album name", default="The Bongo Cat Collection") artist = prompt("Artist name", default="Bongo Cat") album_artist = prompt("Album artist", default=artist) disc = prompt("Disc number", default="2") start_track = prompt( "Starting track number", default="15", input_type=int ) cover_filename = prompt( "Cover filename (relative to folder)", default="cover.png", ) return { "outdir": outdir, "album": album, "artist": artist, "album_artist": album_artist, "disc": disc, "start_track": start_track, "cover_filename": cover_filename, } def list_audio_files(folder: Path) -> List[Path]: files = list(folder.glob("*.opus")) + list(folder.glob("*.ogg")) def sort_key(p: Path): m = re.match(r"^\s*(\d+)", p.stem) return (int(m.group(1)) if m else 10**9, p.name.lower()) return sorted(files, key=sort_key) def infer_clean_title(p: Path) -> str: name = p.stem name = re.sub(r"^\s*\d+\s*-\s*", "", name, count=1) return name.strip() def tag_one( path: Path, title: str, track_number: int, cover_path: Path, artist: str, album: str, album_artist: str, disc: str, ): from mutagen.oggopus import OggOpus from mutagen.oggvorbis import OggVorbis from mutagen.flac import Picture import base64 audio = None try: audio = OggOpus(path) except Exception: audio = OggVorbis(path) if audio.tags is None: audio.add_tags() audio["TITLE"] = [title] audio["ARTIST"] = [artist] audio["ALBUM"] = [album] audio["ALBUMARTIST"] = [album_artist] audio["DISCNUMBER"] = [disc] audio["TRACKNUMBER"] = [str(track_number)] if cover_path.is_file(): data = cover_path.read_bytes() pic = Picture() pic.type = 3 pic.mime = "image/png" pic.desc = "cover" pic.width = 0 pic.height = 0 pic.depth = 0 pic.colors = 0 pic.data = data b64 = base64.b64encode(pic.write()).decode("ascii") audio["METADATA_BLOCK_PICTURE"] = [b64] if "COVERART" in audio: del audio["COVERART"] if "COVERARTMIME" in audio: del audio["COVERARTMIME"] audio.save() def main(): config = get_config() cover_path = config["outdir"] / config["cover_filename"] if not cover_path.exists(): print( f"\n⚠ Warning: cover not found at {cover_path}; " "will proceed without cover art.\n" ) files = list_audio_files(config["outdir"]) if not files: print("No .opus or .ogg files found.") return # Show summary print("\n" + "=" * 60) print("Configuration Summary:") print("=" * 60) print(f"Folder: {config['outdir']}") print(f"Files to tag: {len(files)}") print(f"Album: {config['album']}") print(f"Artist: {config['artist']}") print(f"Album Artist: {config['album_artist']}") print(f"Disc Number: {config['disc']}") print(f"Starting Track: {config['start_track']}") print(f"Cover file: {cover_path.name} " f"({'found' if cover_path.exists() else 'not found'})") print("=" * 60 + "\n") # Show first few files print("Sample files to be tagged:") for f in files[:5]: title = infer_clean_title(f) print(f" - {f.name} → {title}") if len(files) > 5: print(f" ... and {len(files) - 5} more") print() proceed = prompt_bool("Proceed with tagging?", default=True) if not proceed: print("Cancelled.") exit(0) print(f"\nTagging {len(files)} files...\n") for i, f in enumerate(files, start=1): title = infer_clean_title(f) track_no = config["start_track"] + (i - 1) try: tag_one( f, title, track_no, cover_path, config["artist"], config["album"], config["album_artist"], config["disc"], ) print(f"[{i}/{len(files)}] ✓ {f.name} (track {track_no})") except Exception as e: print(f"[{i}/{len(files)}] ✗ {f.name} → {e}") print("\n✓ Done.") if __name__ == "__main__": main()