219 lines
No EOL
5.8 KiB
Python
219 lines
No EOL
5.8 KiB
Python
#!/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() |