Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- """
- Music Library Transcoder
- Scans, caches, and processes a music library from IN to OUT with metadata normalization.
- Written largely by GLM preview
- """
- import json
- import logging
- import os
- import re
- import shutil
- import subprocess
- import sys
- import unicodedata
- from pathlib import Path
- from typing import Optional
- import mutagen
- from mutagen.flac import FLAC
- from mutagen.mp3 import MP3
- from mutagen.easymp4 import EasyMP4
- from mutagen.oggopus import OggOpus
- from mutagen.id3 import ID3
- # =============================================================================
- # USER-CONFIGURABLE CONSTANTS
- # =============================================================================
- BASEP = Path("/mnt/Vault/Media/Music/Library")
- IN_PATH = BASEP / "Library"
- OUT_PATH = BASEP / "Library-squashed"
- VALID_MUSIC_EXTENSIONS = {"opus", "mp3", "m4a", "flac"}
- VALID_ART_EXTENSIONS = {"jpg", "png"}
- # Transcoding settings
- OPUS_BITRATE = "192" # kbps VBR
- # =============================================================================
- # INTERNAL CONSTANTS
- # =============================================================================
- SCRIPT_NAME = Path(__file__).stem
- CACHE_FILE = Path(__file__).parent / f"{SCRIPT_NAME}.cache.json"
- DEBUG_LOG_FILE = Path(__file__).parent / f"{SCRIPT_NAME}.debug.log"
- FORBIDDEN_FILENAME_CHARS = r'<>:"/\\|?*'
- # Artist image filenames (case-insensitive match)
- ARTIST_IMAGE_NAMES = {"artist"}
- # Album cover filenames (case-insensitive match)
- ALBUM_COVER_NAMES = {"cover", "folder", "front"}
- # =============================================================================
- # LOGGING SETUP
- # =============================================================================
- def setup_logging() -> logging.Logger:
- """Configure dual logging: INFO to console, DEBUG to file."""
- logger = logging.getLogger(SCRIPT_NAME)
- logger.setLevel(logging.DEBUG)
- # Console handler (INFO and above)
- console_handler = logging.StreamHandler(sys.stdout)
- console_handler.setLevel(logging.INFO)
- console_format = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S")
- console_handler.setFormatter(console_format)
- # File handler (DEBUG and above)
- file_handler = logging.FileHandler(DEBUG_LOG_FILE, mode="w", encoding="utf-8")
- file_handler.setLevel(logging.DEBUG)
- file_format = logging.Formatter("%(asctime)s [%(levelname)s] %(funcName)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
- file_handler.setFormatter(file_format)
- logger.addHandler(console_handler)
- logger.addHandler(file_handler)
- return logger
- log = setup_logging()
- # =============================================================================
- # UTILITY FUNCTIONS
- # =============================================================================
- def normalize_for_out(name: str) -> str:
- """Normalize unicode to NFC and remove forbidden characters for OUT filenames only."""
- normalized = unicodedata.normalize("NFC", name)
- original = normalized
- for char in FORBIDDEN_FILENAME_CHARS:
- if char in normalized:
- log.warning(f"Removing forbidden character '{char}' from OUT filename: {original}")
- normalized = normalized.replace(char, "")
- return normalized
- def get_file_mtime(path: Path) -> float:
- """Get file modification time as timestamp."""
- return os.path.getmtime(path)
- def extract_year_from_folder(folder_name: str) -> tuple[Optional[int], str]:
- """
- Extract year from folder name if present in parentheses.
- Returns (year, album_name) tuple.
- Album name is for metadata tags only - folder name preserved as-is.
- """
- match = re.match(r"^\((\d{4})\)\s*(.+)$", folder_name)
- if match:
- year = int(match.group(1))
- album_name = match.group(2).strip()
- log.debug(f"Extracted year {year} from folder '{folder_name}', album name: '{album_name}'")
- return year, album_name
- log.debug(f"No year prefix in folder '{folder_name}'")
- return None, folder_name
- def extract_track_from_filename(filename: str) -> tuple[Optional[int], str]:
- """
- Extract track number from filename if present.
- Returns (track_number, title) tuple.
- """
- name = Path(filename).stem
- match = re.match(r"^(\d+)[^\w]*(.+)$", name)
- if match:
- track = int(match.group(1))
- title = match.group(2).strip()
- log.debug(f"Extracted track {track} from filename '{filename}', title: '{title}'")
- return track, title
- log.debug(f"No track prefix in filename '{filename}'")
- return None, name
- def get_extension(path: Path) -> str:
- """Get lowercase file extension without dot."""
- return path.suffix.lower().lstrip(".")
- # =============================================================================
- # AUDIO METADATA FUNCTIONS
- # =============================================================================
- def read_audio_metadata(file_path: Path) -> dict:
- """Read metadata from audio file using mutagen."""
- metadata = {}
- ext = get_extension(file_path)
- try:
- if ext == "flac":
- audio = FLAC(file_path)
- elif ext == "mp3":
- audio = MP3(file_path, ID3=ID3)
- elif ext == "m4a":
- audio = EasyMP4(file_path)
- elif ext == "opus":
- audio = OggOpus(file_path)
- else:
- audio = mutagen.File(file_path, easy=True)
- if audio is not None:
- for tag in ["artist", "album", "title", "tracknumber", "date", "genre"]:
- if tag in audio:
- value = audio[tag]
- if isinstance(value, list):
- value = value[0] if value else None
- metadata[tag] = str(value) if value else None
- if ext == "mp3" and "date" not in metadata:
- try:
- audio_full = MP3(file_path)
- if "TDRC" in audio_full:
- metadata["date"] = str(audio_full["TDRC"])
- elif "TYER" in audio_full:
- metadata["date"] = str(audio_full["TYER"])
- except Exception:
- pass
- log.debug(f"Read metadata from {file_path.name}: {metadata}")
- except Exception as e:
- log.debug(f"Failed to read metadata from {file_path}: {e}")
- return metadata
- def strip_all_tags(file_path: Path) -> None:
- """Remove all tags/metadata from audio file."""
- ext = get_extension(file_path)
- try:
- if ext == "flac":
- audio = FLAC(file_path)
- audio.delete()
- elif ext == "mp3":
- audio = MP3(file_path, ID3=ID3)
- audio.delete()
- elif ext == "m4a":
- audio = EasyMP4(file_path)
- audio.delete()
- elif ext == "opus":
- audio = OggOpus(file_path)
- audio.delete()
- log.debug(f"Stripped tags from {file_path.name}")
- except Exception as e:
- log.debug(f"Error stripping tags from {file_path}: {e}")
- def write_tags(file_path: Path, metadata: dict) -> None:
- """Write normalized metadata to audio file."""
- ext = get_extension(file_path)
- try:
- if ext == "opus":
- audio = OggOpus(file_path)
- elif ext == "mp3":
- from mutagen.easyid3 import EasyID3
- audio = EasyID3(file_path)
- elif ext == "m4a":
- audio = EasyMP4(file_path)
- else:
- log.warning(f"Cannot write tags to {ext} file: {file_path}")
- return
- audio.delete()
- if metadata.get("artist"):
- audio["artist"] = metadata["artist"]
- if metadata.get("album"):
- audio["album"] = metadata["album"]
- if metadata.get("title"):
- audio["title"] = metadata["title"]
- if metadata.get("tracknumber"):
- audio["tracknumber"] = str(metadata["tracknumber"])
- if metadata.get("date"):
- audio["date"] = str(metadata["date"])
- if metadata.get("genre"):
- audio["genre"] = metadata["genre"]
- audio.save()
- log.debug(f"Wrote tags to {file_path.name}")
- except Exception as e:
- log.error(f"Failed to write tags to {file_path}: {e}")
- # =============================================================================
- # CACHE MANAGEMENT
- # =============================================================================
- class Cache:
- """Manages cached metadata and file modification times."""
- def __init__(self, cache_path: Path):
- self.cache_path = cache_path
- self.data: dict = {}
- self.load()
- def load(self) -> None:
- """Load cache from disk."""
- if self.cache_path.exists():
- try:
- with open(self.cache_path, "r", encoding="utf-8") as f:
- self.data = json.load(f)
- log.info(f"Loaded cache with {len(self.data)} entries")
- except Exception as e:
- log.warning(f"Failed to load cache: {e}")
- self.data = {}
- else:
- self.data = {}
- def save(self) -> None:
- """Save cache to disk."""
- try:
- with open(self.cache_path, "w", encoding="utf-8") as f:
- json.dump(self.data, f, indent=2, ensure_ascii=False)
- log.debug(f"Saved cache to {self.cache_path}")
- except Exception as e:
- log.error(f"Failed to save cache: {e}")
- def get_entry(self, file_path: Path) -> Optional[dict]:
- """Get cached entry if file hasn't been modified."""
- key = str(file_path)
- if key not in self.data:
- return None
- cached = self.data[key]
- current_mtime = get_file_mtime(file_path)
- if cached.get("mtime") == current_mtime:
- log.debug(f"Cache hit for {file_path.name}")
- return cached
- else:
- log.debug(f"Cache miss (modified) for {file_path.name}")
- return None
- def set_entry(self, file_path: Path, metadata: dict) -> None:
- """Store metadata in cache with current modification time."""
- key = str(file_path)
- self.data[key] = {
- "mtime": get_file_mtime(file_path),
- **metadata,
- }
- # =============================================================================
- # LIBRARY SCANNING
- # =============================================================================
- def scan_library(in_path: Path, cache: Cache) -> dict:
- """
- Scan the input library and return structured data.
- IN paths and names are preserved exactly as-is.
- """
- library = {"artists": {}}
- log.info(f"Scanning library at {in_path}")
- for artist_folder in sorted(in_path.iterdir()):
- if not artist_folder.is_dir():
- continue
- # Preserve exact IN artist folder name
- artist_folder_name = artist_folder.name
- log.info(f"Scanning artist: {artist_folder_name}")
- artist_data = {
- "path": str(artist_folder),
- "folder_name": artist_folder_name, # Exact IN name
- "albums": {},
- "artist_image": None,
- }
- # Check for artist image
- for file in artist_folder.iterdir():
- if file.is_file() and get_extension(file) in VALID_ART_EXTENSIONS:
- if file.stem.lower() in ARTIST_IMAGE_NAMES:
- artist_data["artist_image"] = str(file)
- log.debug(f"Found artist image: {file.name}")
- break
- for album_folder in sorted(artist_folder.iterdir()):
- if not album_folder.is_dir():
- continue
- # Preserve exact IN album folder name
- album_folder_name = album_folder.name
- year, album_name = extract_year_from_folder(album_folder_name)
- log.debug(f"Scanning album: {album_folder_name} (year={year}, name='{album_name}')")
- album_data = {
- "path": str(album_folder),
- "folder_name": album_folder_name, # Exact IN name
- "album_name": album_name, # For metadata tags (year stripped)
- "year": year,
- "tracks": {},
- "cover_image": None,
- }
- # Check for album cover
- for file in album_folder.iterdir():
- if file.is_file() and get_extension(file) in VALID_ART_EXTENSIONS:
- if file.stem.lower() in ALBUM_COVER_NAMES:
- album_data["cover_image"] = str(file)
- log.debug(f"Found album cover: {file.name}")
- break
- # Find all audio tracks
- for audio_file in sorted(album_folder.iterdir()):
- if not audio_file.is_file():
- continue
- ext = get_extension(audio_file)
- if ext not in VALID_MUSIC_EXTENSIONS:
- continue
- # Check cache first
- cached = cache.get_entry(audio_file)
- if cached:
- track_data = {
- "path": str(audio_file),
- "filename": audio_file.name, # Exact IN filename
- "title": cached.get("title"),
- "tracknumber": cached.get("tracknumber"),
- "year": cached.get("year"),
- "genre": cached.get("genre"),
- "artist": cached.get("artist"),
- "album": cached.get("album"),
- "extension": ext,
- }
- else:
- # Extract metadata from file and path
- file_meta = read_audio_metadata(audio_file)
- track_from_name, title_from_name = extract_track_from_filename(audio_file.name)
- # Get raw track number from metadata or filename
- raw_track = file_meta.get("tracknumber") or track_from_name
- # Sanitize track number: handle "01/12", "1-1/10", etc. else None
- if raw_track:
- track_str = str(raw_track).strip()
- track_str = track_str.split('-')[-1]
- track_str = track_str.split('/')[0]
- digits = re.search(r'\d+', track_str)
- sanitized_track = digits.group(0) if digits else None
- else:
- sanitized_track = None
- track_data = {
- "path": str(audio_file),
- "filename": audio_file.name, # Exact IN filename
- "title": file_meta.get("title") or title_from_name,
- "tracknumber": sanitized_track or track_from_name,
- "year": file_meta.get("date"),
- "genre": file_meta.get("genre"),
- "artist": file_meta.get("artist"),
- "album": file_meta.get("album"),
- "extension": ext,
- }
- cache.set_entry(
- audio_file,
- {
- "title": track_data["title"],
- "tracknumber": track_data["tracknumber"],
- "year": track_data["year"],
- "genre": track_data["genre"],
- "artist": track_data["artist"],
- "album": track_data["album"],
- },
- )
- album_data["tracks"][audio_file.name] = track_data
- log.debug(f"Found track: {audio_file.name}")
- artist_data["albums"][album_folder_name] = album_data
- library["artists"][artist_folder_name] = artist_data
- log.info(f"Scan complete: {len(library['artists'])} artists found")
- return library
- # =============================================================================
- # FILE PROCESSING
- # =============================================================================
- def should_process(in_file: Path, out_file: Path) -> bool:
- """
- Determine if a file needs to be processed.
- Returns True if OUT doesn't exist or is older than IN.
- """
- if not out_file.exists():
- log.debug(f"OUT file doesn't exist: {out_file.name}")
- return True
- in_mtime = get_file_mtime(in_file)
- out_mtime = get_file_mtime(out_file)
- if in_mtime > out_mtime:
- log.debug(f"IN file is newer: {in_file.name}")
- return True
- return False
- def transcode_flac_to_opus(in_file: Path, out_file: Path) -> bool:
- """Transcode FLAC to Opus using ffmpeg."""
- try:
- cmd = [
- "ffmpeg",
- "-i", str(in_file),
- "-c:a", "libopus",
- "-b:a", f"{OPUS_BITRATE}k",
- "-vn",
- "-y",
- str(out_file),
- ]
- log.debug(f"Running: {' '.join(cmd)}")
- result = subprocess.run(
- cmd,
- capture_output=True,
- text=True,
- )
- if result.returncode != 0:
- log.error(f"ffmpeg failed: {result.stderr}")
- return False
- log.info(f"Transcoded: {in_file.name} -> {out_file.name}")
- return True
- except Exception as e:
- log.error(f"Transcoding failed: {e}")
- return False
- def copy_audio(in_file: Path, out_file: Path) -> bool:
- """Copy audio file as-is (for non-FLAC lossy formats)."""
- try:
- shutil.copy2(in_file, out_file)
- log.info(f"Copied: {in_file.name}")
- return True
- except Exception as e:
- log.error(f"Copy failed: {e}")
- return False
- def copy_art(in_file: Path, out_file: Path) -> bool:
- """Copy art file as-is."""
- try:
- shutil.copy2(in_file, out_file)
- log.info(f"Copied art: {in_file.name}")
- return True
- except Exception as e:
- log.error(f"Art copy failed: {e}")
- return False
- def process_track(
- track_data: dict,
- artist_folder_name: str,
- album_data: dict,
- out_artist_path: Path,
- out_album_path: Path,
- ) -> bool:
- """Process a single track: copy/transcode and write tags."""
- in_file = Path(track_data["path"])
- ext = track_data["extension"]
- # Build output filename (normalized for OUT only)
- track_num = track_data.get("tracknumber")
- title = track_data.get("title") or "Unknown"
- if track_num is not None:
- out_filename = f"{int(track_num):02d} - {title}"
- else:
- out_filename = title
- # Determine output extension
- out_ext = "opus" if ext == "flac" else ext
- # Normalize ONLY for OUT filename
- out_filename = normalize_for_out(f"{out_filename}.{out_ext}")
- out_file = out_album_path / out_filename
- if not should_process(in_file, out_file):
- log.debug(f"Skipping (up-to-date): {out_filename}")
- return True
- out_album_path.mkdir(parents=True, exist_ok=True)
- success = False
- if ext == "flac":
- success = transcode_flac_to_opus(in_file, out_file)
- else:
- success = copy_audio(in_file, out_file)
- if success:
- strip_all_tags(out_file)
- if not success:
- return False
- # Build final metadata - normalize for tags
- final_metadata = {
- "artist": normalize_for_out(artist_folder_name),
- "album": normalize_for_out(album_data["album_name"]),
- "title": normalize_for_out(title),
- "tracknumber": track_num,
- "date": track_data.get("year") or album_data.get("year"),
- "genre": track_data.get("genre"),
- }
- write_tags(out_file, final_metadata)
- return True
- # =============================================================================
- # ORPHAN CLEANUP
- # =============================================================================
- def find_orphaned_files(library: dict, out_path: Path) -> list[Path]:
- """Find files in OUT that don't exist in IN."""
- orphaned = []
- if not out_path.exists():
- return orphaned
- expected_out_paths = set()
- for artist_folder_name, artist_data in library["artists"].items():
- # OUT folder names match IN folder names exactly
- out_artist_path = out_path / artist_folder_name
- expected_out_paths.add(out_artist_path)
- if artist_data.get("artist_image"):
- art_file = Path(artist_data["artist_image"])
- out_art = out_artist_path / art_file.name # Preserve exact filename
- expected_out_paths.add(out_art)
- for album_folder_name, album_data in artist_data["albums"].items():
- out_album_path = out_artist_path / album_folder_name # Preserve exact folder name
- expected_out_paths.add(out_album_path)
- if album_data.get("cover_image"):
- cover_file = Path(album_data["cover_image"])
- out_cover = out_album_path / cover_file.name # Preserve exact filename
- expected_out_paths.add(out_cover)
- for track_name, track_data in album_data["tracks"].items():
- ext = track_data["extension"]
- track_num = track_data.get("tracknumber")
- title = track_data.get("title") or "Unknown"
- if track_num is not None:
- out_filename = f"{int(track_num):02d} - {title}"
- else:
- out_filename = title
- out_ext = "opus" if ext == "flac" else ext
- out_filename = normalize_for_out(f"{out_filename}.{out_ext}")
- out_track = out_album_path / out_filename
- expected_out_paths.add(out_track)
- # Find actual OUT files
- for root, dirs, files in os.walk(out_path):
- root_path = Path(root)
- for d in dirs:
- dir_path = root_path / d
- if dir_path not in expected_out_paths:
- orphaned.append(dir_path)
- for f in files:
- file_path = root_path / f
- if file_path not in expected_out_paths:
- orphaned.append(file_path)
- orphaned.sort(key=lambda p: len(str(p)), reverse=True)
- return orphaned
- def confirm_and_delete_orphans(orphaned: list[Path]) -> bool:
- """Ask for confirmation once, then delete all orphaned files."""
- if not orphaned:
- log.info("No orphaned files to clean up")
- return True
- print("\n" + "=" * 60)
- print("ORPHANED FILES DETECTED (not in IN library):")
- print("=" * 60)
- for p in orphaned:
- print(f" {p}")
- print("=" * 60)
- print(f"Total: {len(orphaned)} items")
- response = input("\nDelete these orphaned files? [y/N]: ").strip().lower()
- if response == "y":
- for p in orphaned:
- try:
- if p.is_dir():
- shutil.rmtree(p)
- else:
- p.unlink()
- log.info(f"Deleted orphaned: {p}")
- except Exception as e:
- log.error(f"Failed to delete {p}: {e}")
- return True
- else:
- log.info("Orphan deletion cancelled by user")
- return False
- # =============================================================================
- # MAIN PROCESSING
- # =============================================================================
- def process_library(library: dict, out_path: Path) -> None:
- """Process entire library: copy art and process tracks."""
- log.info(f"Processing library to {out_path}")
- for artist_folder_name, artist_data in library["artists"].items():
- # OUT folder structure mirrors IN exactly
- out_artist_path = out_path / artist_folder_name
- log.info(f"Processing artist: {artist_folder_name}")
- # Copy artist image if exists
- if artist_data.get("artist_image"):
- in_art = Path(artist_data["artist_image"])
- out_art = out_artist_path / in_art.name # Preserve exact filename
- if should_process(in_art, out_art):
- out_artist_path.mkdir(parents=True, exist_ok=True)
- copy_art(in_art, out_art)
- for album_folder_name, album_data in artist_data["albums"].items():
- out_album_path = out_artist_path / album_folder_name # Preserve exact folder name
- log.info(f" Processing album: {album_folder_name}")
- # Copy album cover if exists
- if album_data.get("cover_image"):
- in_cover = Path(album_data["cover_image"])
- out_cover = out_album_path / in_cover.name # Preserve exact filename
- if should_process(in_cover, out_cover):
- out_album_path.mkdir(parents=True, exist_ok=True)
- copy_art(in_cover, out_cover)
- for track_name, track_data in album_data["tracks"].items():
- process_track(
- track_data,
- artist_folder_name,
- album_data,
- out_artist_path,
- out_album_path,
- )
- # =============================================================================
- # MAIN ENTRY POINT
- # =============================================================================
- def main() -> None:
- """Main entry point."""
- log.info("=" * 60)
- log.info("Music Library Transcoder")
- log.info("=" * 60)
- log.info(f"IN: {IN_PATH}")
- log.info(f"OUT: {OUT_PATH}")
- log.info("=" * 60)
- if not IN_PATH.exists():
- log.error(f"IN path does not exist: {IN_PATH}")
- sys.exit(1)
- cache = Cache(CACHE_FILE)
- library = scan_library(IN_PATH, cache)
- cache.save()
- orphaned = find_orphaned_files(library, OUT_PATH)
- if orphaned:
- if not confirm_and_delete_orphans(orphaned):
- log.warning("Proceeding without deleting orphans (may cause inconsistencies)")
- process_library(library, OUT_PATH)
- log.info("=" * 60)
- log.info("Processing complete!")
- log.info("=" * 60)
- if __name__ == "__main__":
- main()
Advertisement