Add tagger.py
This commit is contained in:
parent
1160d360b4
commit
e917b8de56
1 changed files with 219 additions and 0 deletions
219
tagger.py
Normal file
219
tagger.py
Normal file
|
|
@ -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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue