diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/.DS_Store differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..687985f --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# Beautify Audio + +One-file CLI to make tracks library-ready: convert to 320 kbps MP3, wipe old tags, retag via OneTagger Auto Tag, and rename to `Artist - Title.mp3` with collision safety. + +What it does: +- Converts any ffmpeg-readable source to 44.1 kHz stereo 320 kbps CBR MP3 (optional loudness normalization, default -11.5 LUFS). +- Strips existing metadata before retagging. +- Runs OneTagger Auto Tag with `onetagger-autotagger.json` (Discogs/Spotify creds can be injected). +- Renames the result using tagged artist/title; appends `-1`, `-2`, etc. to avoid overwrites. + +## Quick start +1) Install Python deps: `pip install -r requirements.txt` +2) Install `ffmpeg` and ensure it is on PATH. +3) Install OneTagger CLI (≥1.7). On macOS, build from source and place the resulting `onetagger-cli` on PATH or next to this script. https://github.com/OneTagger/OneTagger +4) Run: `python beautify-audio.py /path/to/track.wav` + +## Requirements +- Python 3.9+. +- `ffmpeg` on PATH. +- Python deps from `requirements.txt` (currently `mutagen`). +- OneTagger CLI on PATH or placed next to the script as `onetagger-cli` (not bundled here because it is GPL-licensed). The Auto Tag profile is provided as `onetagger-autotagger.json`. +- The helper module `onetagger_integration.py` must remain in the repo root (the script prepends the parent dir to `PYTHONPATH`). + +## Usage +```bash +python beautify-audio.py /path/to/track.wav +``` + +Useful flags: +- `--output-dir DIR` – where to place the processed MP3 (default: source folder). +- `--lufs -9.5` – change loudness target; use `--no-loudness` to disable normalization. +- `--artist/--title/--album` – seed metadata hints for tagging/renaming. +- `--onetagger-cli /path/to/onetagger-cli` – override CLI path (otherwise tries script folder or PATH). +- `--skip-onetagger` – only convert/strip/rename, skip tagging. +- Auth: `--discogs-token`, `--spotify-id`, `--spotify-secret`, or `--auth-file auth.json`. + +### Authentication resolution +Order of precedence: +1) CLI flags +2) `--auth-file` JSON with `discogs_token`, `spotify_id`, `spotify_secret` +3) Environment variables `DISCOGS_TOKEN`, `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET` +4) `auth.json` next to the script (if present) + +Missing Spotify/Discogs creds cause those platforms to be dropped from the config copy for that run. + +## Workflow +1) Pick output path with collision-safe suffixing. +2) Convert source to MP3 (optional loudness normalization). +3) Strip all metadata (skipped if `mutagen` is missing). +4) Optionally rename for better OneTagger filename hints. +5) Run OneTagger Auto Tag. +6) Rename final file to a sanitized `Artist - Title.mp3`. + +## Example (with credentials file) +```bash +python beautify-audio.py ~/Downloads/Track.wav \ + --output-dir ~/Music/Prep \ + --lufs -12 \ + --auth-file ~/secrets/onetagger-auth.json +``` + +## Notes +- Automator-friendly: one input file in, one clean MP3 out. +- If OneTagger is missing or fails, conversion and cleanup still run; renaming uses best-effort metadata. diff --git a/auth.json b/auth.json new file mode 100644 index 0000000..1ea799d --- /dev/null +++ b/auth.json @@ -0,0 +1,5 @@ +{ + "discogs_token": "", + "spotify_id": "", + "spotify_secret": "" +} \ No newline at end of file diff --git a/beautify-audio.py b/beautify-audio.py new file mode 100644 index 0000000..ae18aa4 --- /dev/null +++ b/beautify-audio.py @@ -0,0 +1,481 @@ +#!/usr/bin/env python3 +"""Beautify Audio CLI. + +Pipeline: +- Convert any supported audio file to a 320 kbps CBR MP3 (optional LUFS loudness correction). +- Strip existing metadata. +- Tag the file with OneTagger using the bundled AutoTagger profile (tokens/creds can be passed in). +- Rename the output using tagged artist/title (with fallbacks). +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +# Keep this script self-contained except for the OneTagger integration helper. +REPO_ROOT = Path(__file__).resolve().parent.parent +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +try: + import mutagen # type: ignore +except ImportError: + mutagen = None + +from onetagger_integration import ( # type: ignore + OneTaggerCliNotFoundError, + tag_audio_with_onetagger, +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Beautify Audio: convert, clean, tag, and rename a single audio file." + ) + parser.add_argument("input_file", type=Path, help="Path to the source audio file") + parser.add_argument( + "--lufs", + type=float, + default=-11.5, + help="Integrated loudness target (LUFS). Valid range: -16 to 0. Default: -11.5", + ) + parser.add_argument( + "--no-loudness", + action="store_true", + help="Disable loudness correction (conversion still runs at 320 kbps CBR).", + ) + parser.add_argument( + "--output-dir", + type=Path, + help="Output directory (defaults to the input file's directory).", + ) + parser.add_argument( + "--onetagger-cli", + type=Path, + help="Path to onetagger-cli. Defaults to PATH lookup or the script folder copy.", + ) + parser.add_argument("--artist", help="Optional artist name to help with renaming.") + parser.add_argument("--title", help="Optional title to help with renaming.") + parser.add_argument("--album", help="Optional album name (passed to OneTagger config copy).") + parser.add_argument( + "--discogs-token", + default=None, + help="Discogs token for OneTagger (CLI overrides auth file/env).", + ) + parser.add_argument( + "--spotify-id", + default=None, + help="Spotify client ID for OneTagger (CLI overrides auth file/env).", + ) + parser.add_argument( + "--spotify-secret", + default=None, + help="Spotify client secret for OneTagger (CLI overrides auth file/env).", + ) + parser.add_argument( + "--auth-file", + type=Path, + help="Optional JSON file with discogs_token/spotify_id/spotify_secret. Defaults to auth.json next to the script if present.", + ) + parser.add_argument( + "--skip-onetagger", + action="store_true", + help="Skip the OneTagger tagging step (conversion + cleanup still happen).", + ) + return parser.parse_args() + + +def validate_inputs(args: argparse.Namespace) -> tuple[Path, Path]: + src = args.input_file.expanduser().resolve() + if not src.exists() or not src.is_file(): + raise FileNotFoundError(f"Input file not found: {src}") + if not (-16.0 <= args.lufs <= 0.0): + raise ValueError("LUFS must be between -16.0 and 0.0") + out_dir = args.output_dir.expanduser().resolve() if args.output_dir else src.parent + out_dir.mkdir(parents=True, exist_ok=True) + return src, out_dir + + +def next_available_path(path: Path) -> Path: + """Return a unique path by appending '-' before the suffix if needed.""" + if not path.exists(): + return path + stem = path.stem + suffix = path.suffix + counter = 1 + while True: + candidate = path.with_name(f"{stem}-{counter}{suffix}") + if not candidate.exists(): + return candidate + counter += 1 + + +def find_onetagger_cli(user_supplied: Path | None) -> Path | None: + """Resolve onetagger-cli from user input, script folder, or PATH.""" + candidates: list[Path] = [] + if user_supplied: + candidates.append(user_supplied) + script_dir = Path(__file__).resolve().parent + candidates.append(script_dir / "onetagger-cli") + found_in_path = shutil.which("onetagger-cli") + if found_in_path: + candidates.append(Path(found_in_path)) + + for cand in candidates: + if cand and cand.exists(): + if os.access(cand, os.X_OK): + return cand.resolve() + raise OneTaggerCliNotFoundError(f"onetagger-cli is not executable: {cand}") + return None + + +def convert_to_mp3( + src: Path, + dest: Path, + loudness_enabled: bool, + target_lufs: float, +) -> Path: + """Convert to 320 kbps CBR MP3 with optional loudness correction.""" + if not shutil.which("ffmpeg"): + raise RuntimeError("ffmpeg not found in PATH. Please install ffmpeg.") + cmd = ["ffmpeg", "-y", "-i", str(src), "-vn"] + if loudness_enabled: + cmd += ["-af", f"loudnorm=I={target_lufs}:TP=-1.5:LRA=11"] + cmd += ["-c:a", "libmp3lame", "-b:a", "320k", "-ar", "44100", "-ac", "2", str(dest)] + print(f"🔊 Converting → {dest}") + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError( + f"ffmpeg failed (exit {result.returncode}).\nSTDERR:\n{result.stderr or result.stdout}" + ) + return dest + + +def read_simple_tags(path: Path) -> tuple[str | None, str | None, str | None]: + """Read (title, artist, album) tags using mutagen if available.""" + if mutagen is None: + return None, None, None + try: + audio = mutagen.File(str(path), easy=True) + if not audio: + return None, None, None + title = audio.get("title", [None])[0] + artist = audio.get("artist", [None])[0] + album = audio.get("album", [None])[0] + return title, artist, album + except Exception: + return None, None, None + + +def parse_filename_artist_title(path: Path) -> tuple[str | None, str | None]: + """If filename looks like 'Artist - Title', split and return parts.""" + stem = path.stem + if stem.count("-") != 1: + return None, None + artist, title = [part.strip() for part in stem.split("-", 1)] + return (artist or None, title or None) + + +def collect_seed_metadata( + path: Path, cli_artist: str | None, cli_title: str | None, cli_album: str | None +) -> dict[str, str | None]: + """Choose the best available artist/title/album for tagging and renaming.""" + tag_title, tag_artist, tag_album = read_simple_tags(path) + fname_artist, fname_title = parse_filename_artist_title(path) + + artist = cli_artist or tag_artist or fname_artist + title = cli_title or tag_title or fname_title + album = cli_album or tag_album + + return { + "artist": artist, + "title": title, + "album": album, + "fname_artist": fname_artist, + "fname_title": fname_title, + "tag_artist": tag_artist, + "tag_title": tag_title, + "tag_album": tag_album, + } + + +def load_auth_credentials(auth_file: Path | None) -> dict[str, str]: + """Load auth data from JSON if present; keys: discogs_token, spotify_id, spotify_secret.""" + data = {"discogs_token": "", "spotify_id": "", "spotify_secret": ""} + candidate = auth_file + if candidate and candidate.exists(): + try: + with candidate.open("r", encoding="utf-8") as f: + parsed = json.load(f) + data["discogs_token"] = parsed.get("discogs_token", "") or "" + data["spotify_id"] = parsed.get("spotify_id", "") or "" + data["spotify_secret"] = parsed.get("spotify_secret", "") or "" + print(f"🔑 Loaded auth from {candidate}") + except Exception as exc: + print(f"⚠️ Failed to read auth file {candidate}: {exc}") + return data + + +def strip_all_metadata(path: Path) -> None: + """Remove all tags from the file in-place.""" + if mutagen is None: + print("⚠️ mutagen not installed; skipping metadata strip.") + return + try: + audio = mutagen.File(str(path)) + if audio is None: + return + if hasattr(audio, "delete"): + audio.delete() + elif audio.tags is not None: + audio.tags.clear() + audio.save() + print("🧹 Stripped existing metadata.") + except Exception as exc: # pragma: no cover - defensive + print(f"⚠️ Failed to strip metadata: {exc}") + + +def prepare_onetagger_config( + base_config: Path, + discogs_token: str, + spotify_id: str, + spotify_secret: str, + album: str | None = None, + seed_artist: str | None = None, + seed_title: str | None = None, +) -> Path: + """Copy the base config and inject credentials when provided.""" + with base_config.open("r", encoding="utf-8") as f: + cfg = json.load(f) + + cfg.setdefault("custom", {}) + cfg.setdefault("platforms", cfg.get("platforms") or []) + + if discogs_token: + cfg["custom"].setdefault("discogs", {})["token"] = discogs_token + if "discogs" not in cfg["platforms"]: + cfg["platforms"].append("discogs") + if spotify_id and spotify_secret: + cfg["spotify"] = {"clientId": spotify_id, "clientSecret": spotify_secret} + if "spotify" not in cfg["platforms"]: + cfg["platforms"].append("spotify") + + defaults = cfg.setdefault("custom", {}).setdefault("defaults", {}) + if album: + defaults["album"] = album + if seed_artist: + defaults["artist"] = seed_artist + if seed_title: + defaults["title"] = seed_title + + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".json", prefix="onetagger_cfg_") + with open(tmp.name, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2) + return Path(tmp.name) + + +def run_onetagger( + audio_path: Path, + cli_path: Path, + base_config: Path, + discogs_token: str, + spotify_id: str, + spotify_secret: str, + album: str | None, + seed_artist: str | None, + seed_title: str | None, +) -> bool: + cfg_copy = prepare_onetagger_config( + base_config, + discogs_token, + spotify_id, + spotify_secret, + album, + seed_artist, + seed_title, + ) + try: + res = tag_audio_with_onetagger(audio_file=audio_path, onetagger_cli_path=cli_path, config_path=cfg_copy) + if res.success: + print("🪪 OneTagger tagging succeeded.") + return True + print(f"⚠️ OneTagger tagging failed: {res.stderr or 'unknown error'}") + return False + except OneTaggerCliNotFoundError as exc: + print(f"⚠️ {exc}") + return False + except Exception as exc: # pragma: no cover - defensive + print(f"⚠️ OneTagger error: {exc}") + return False + finally: + try: + cfg_copy.unlink() + except Exception: + pass + + +def build_safe_basename(path: Path, fallback_artist: str | None, fallback_title: str | None) -> str: + """Derive a sanitized base filename using tags when available.""" + tag_title, tag_artist, _ = read_simple_tags(path) + artist = tag_artist or fallback_artist + title = tag_title or fallback_title + if artist and title: + base = f"{artist} - {title}" + elif artist: + base = f"{artist} - {path.stem}" + elif title: + base = f"{path.stem} - {title}" + else: + base = path.stem + return re.sub(r"[\\/:*?\"<>|]", "_", base).strip("_ ").strip() + + +def rename_output( + path: Path, + output_dir: Path, + fallback_artist: str | None, + fallback_title: str | None, +) -> Path: + base = build_safe_basename(path, fallback_artist, fallback_title) + desired = output_dir / f"{base}{path.suffix}" + if desired.resolve() == path.resolve(): + return path + dest = next_available_path(desired) + if dest.resolve() == path.resolve(): + return path + dest.parent.mkdir(parents=True, exist_ok=True) + path.rename(dest) + print(f"📝 Renamed to {dest.name}") + return dest + + +def rename_for_tagging(path: Path, output_dir: Path, artist: str | None, title: str | None) -> Path: + """Rename the working file so OneTagger sees a helpful filename.""" + if not artist or not title: + return path + desired = output_dir / f"{artist} - {title}{path.suffix}" + if desired.resolve() == path.resolve(): + return path + target = next_available_path(desired) + if target.resolve() == path.resolve(): + return path + try: + path.rename(target) + print(f"🏷️ Using filename hint for tagging: {target.name}") + return target + except Exception: + return path + + +def main() -> None: + args = parse_args() + src, out_dir = validate_inputs(args) + seed_info = collect_seed_metadata(src, args.artist, args.title, args.album) + album = args.album or seed_info.get("tag_album") + + primary_artist = args.artist or seed_info.get("tag_artist") or seed_info.get("fname_artist") + primary_title = args.title or seed_info.get("tag_title") or seed_info.get("fname_title") + + secondary_artist = None + secondary_title = None + if seed_info.get("fname_artist") and seed_info.get("fname_title"): + if (seed_info.get("fname_artist") != primary_artist) or (seed_info.get("fname_title") != primary_title): + secondary_artist = seed_info.get("fname_artist") + secondary_title = seed_info.get("fname_title") + + # Derive the initial output path with collision avoidance + base_target = out_dir / f"{src.stem}.mp3" + target_mp3 = next_available_path(base_target) + + loudness_on = not args.no_loudness + print( + f"🚀 Beautifying '{src.name}' → '{target_mp3.name}' " + f"(loudness {'on' if loudness_on else 'off'}, target {args.lufs} LUFS)" + ) + + # Auth resolution precedence: + # 1) CLI flags + # 2) Explicit auth file (--auth-file) + # 3) Environment variables + # 4) Default auth.json next to the script (only if nothing else provided) + explicit_auth = load_auth_credentials(args.auth_file) + discogs_token = args.discogs_token or explicit_auth["discogs_token"] or os.environ.get("DISCOGS_TOKEN", "") + spotify_id = args.spotify_id or explicit_auth["spotify_id"] or os.environ.get("SPOTIFY_CLIENT_ID", "") + spotify_secret = args.spotify_secret or explicit_auth["spotify_secret"] or os.environ.get("SPOTIFY_CLIENT_SECRET", "") + + if ( + not args.auth_file + and not discogs_token + and not spotify_id + and not spotify_secret + ): + default_auth_path = Path(__file__).resolve().parent / "auth.json" + default_auth = load_auth_credentials(default_auth_path) + discogs_token = default_auth["discogs_token"] + spotify_id = default_auth["spotify_id"] + spotify_secret = default_auth["spotify_secret"] + + converted = convert_to_mp3(src, target_mp3, loudness_on, args.lufs) + strip_all_metadata(converted) + + working_file = rename_for_tagging(converted, out_dir, primary_artist, primary_title) + tagged = working_file + tag_success = False + cli_path = None + if not args.skip_onetagger: + cli_path = find_onetagger_cli(args.onetagger_cli) + cfg_path = Path(__file__).resolve().parent / "onetagger-autotagger.json" + if cli_path and cfg_path.exists(): + tag_success = run_onetagger( + audio_path=working_file, + cli_path=cli_path, + base_config=cfg_path, + discogs_token=discogs_token, + spotify_id=spotify_id, + spotify_secret=spotify_secret, + album=album, + seed_artist=primary_artist, + seed_title=primary_title, + ) + if not tag_success and secondary_artist and secondary_title: + working_file = rename_for_tagging(working_file, out_dir, secondary_artist, secondary_title) + tagged = working_file + tag_success = run_onetagger( + audio_path=working_file, + cli_path=cli_path, + base_config=cfg_path, + discogs_token=discogs_token, + spotify_id=spotify_id, + spotify_secret=spotify_secret, + album=album, + seed_artist=secondary_artist, + seed_title=secondary_title, + ) + else: + print("⚠️ OneTagger skipped (binary or config not found).") + else: + print("⏭️ Skipping OneTagger tagging step as requested.") + + fallback_artist = primary_artist or secondary_artist + fallback_title = primary_title or secondary_title + final_path = rename_output(tagged, out_dir, fallback_artist, fallback_title) + print(f"✅ Done: {final_path}") + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\nAborted by user.") + sys.exit(1) + except Exception as exc: + print(f"❌ Error: {exc}") + sys.exit(1) diff --git a/onetagger-autotagger.json b/onetagger-autotagger.json new file mode 100644 index 0000000..6cf4caa --- /dev/null +++ b/onetagger-autotagger.json @@ -0,0 +1,101 @@ +{ + "platforms": [ + "beatport", + "spotify", + "discogs", + "bandcamp" + ], + "path": null, + "tags": [ + "genre", + "style", + "releaseDate", + "label", + "albumArt", + "artist", + "album", + "title" + ], + "separators": { + "id3": ", ", + "vorbis": ", ", + "mp4": ", " + }, + "id3v24": true, + "overwrite": true, + "overwriteTags": [], + "threads": 16, + "strictness": 0.8, + "mergeGenres": false, + "albumArtFile": true, + "camelot": false, + "parseFilename": true, + "filenameTemplate": "%artists% - %title%", + "shortTitle": true, + "matchDuration": false, + "maxDurationDifference": 30, + "matchById": false, + "multipleMatches": "Default", + "postCommand": null, + "stylesOptions": "default", + "stylesCustomTag": { + "id3": "STYLE", + "vorbis": "STYLE", + "mp4": "STYLE" + }, + "trackNumberLeadingZeroes": 0, + "enableShazam": true, + "forceShazam": false, + "skipTagged": false, + "includeSubfolders": false, + "onlyYear": false, + "titleRegex": null, + "moveSuccess": false, + "moveSuccessPath": null, + "moveFailed": false, + "moveFailedPath": null, + "writeLrc": false, + "enhancedLrc": false, + "capitalizeGenres": false, + "id3CommLang": null, + "removeAllCovers": false, + "multiplatform": false, + "fetchAllResults": false, + "albumTagging": false, + "albumTaggingRatio": 0.5, + "coverFilename": null, + "custom": { + "bpmsupreme": { + "email": "", + "library": "Supreme", + "password": "" + }, + "itunes": { + "art_resolution": 1000 + }, + "discogs": { + "max_albums": 4, + "token": "", + "track_number_int": true + }, + "beatsource": { + "art_resolution": 500 + }, + "beatport": { + "art_resolution": 500, + "ignore_version": false, + "max_pages": 1 + }, + "deezer": { + "art_resolution": 1200, + "content_language": "en-US" + }, + "bandcamp": { + "match_artist": true + } + }, + "spotify": { + "clientId": "", + "clientSecret": "" + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..dcb7c52 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +mutagen>=1.47,<2.0