diff --git a/tagger.py b/tagger.py new file mode 100644 index 0000000..2b7a5fa --- /dev/null +++ b/tagger.py @@ -0,0 +1,219 @@ +#!/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() \ No newline at end of file