#!/usr/bin/env python3 import argparse import csv import io import json import re import shutil import subprocess import sys from collections import Counter, defaultdict from dataclasses import dataclass from pathlib import Path from statistics import mean from typing import Optional try: import magic # python-magic except ImportError: magic = None try: from tqdm import tqdm except ImportError: print("ERROR: tqdm is required. Install it with: pip install tqdm", file=sys.stderr) sys.exit(1) ALBUM_DIR_RE = re.compile(r"^(?P.+?)\s*-\s*\[(?P\d{4})\]\s*$", re.IGNORECASE) TRACK_FILE_RE = re.compile(r"^(?P\d+)\.\s*(?P.*)$") IMAGE_MIME_MAP = { "image/jpeg": "JPEG", "image/jpg": "JPG", "image/png": "PNG", "image/webp": "WEBP", "image/gif": "GIF", "image/bmp": "BMP", "image/tiff": "TIFF", "image/heic": "HEIC", "image/heif": "HEIF", "image/avif": "AVIF", } LOSSLESS_CODECS = { "FLAC", "ALAC", "APE", "TTA", "DSD", "WMA_LOSSLESS", "WAVPACK", # usually lossless; ffprobe does not expose mode cleanly } @dataclass(frozen=True) class AlbumKey: artist: str album: str year: str album_dir: Path @dataclass class TrackInfo: artist: str album: str year: str album_dir: Path relative_path: str file_name: str track_number: str track_number_sort: int track_title: str file_format: str codec: str cover_image: str bit_depth: Optional[int] sample_rate_hz: Optional[int] bit_rate_kbps: Optional[float] channels: Optional[int] channel_layout: str sample_format: str duration_seconds: Optional[float] lossless: str def is_hidden(path: Path) -> bool: return any(part.startswith(".") for part in path.parts) def parse_album_dir_name(dirname: str) -> tuple[str, str]: m = ALBUM_DIR_RE.match(dirname) if not m: return dirname, "" return m.group("album").strip(), m.group("year").strip() def parse_track_name(file_name: str) -> tuple[str, int, str]: stem = Path(file_name).stem m = TRACK_FILE_RE.match(stem) if not m: return "", 10**9, stem num = m.group("num") title = m.group("title").strip() or stem return num, int(num), title def safe_int(value) -> Optional[int]: if value in (None, "", "N/A"): return None try: return int(value) except Exception: return None def safe_float(value) -> Optional[float]: if value in (None, "", "N/A"): return None try: return float(value) except Exception: return None def fmt_num(value: Optional[float], digits: int = 2) -> str: if value is None: return "" if digits == 0: return str(int(round(value))) return f"{value:.{digits}f}" def fmt_range_int(values: list[int]) -> str: if not values: return "" lo = min(values) hi = max(values) return str(lo) if lo == hi else f"{lo}-{hi}" def fmt_range_float(values: list[float], digits: int = 2) -> str: if not values: return "" lo = min(values) hi = max(values) if abs(lo - hi) < 1e-9: return fmt_num(lo, digits) return f"{fmt_num(lo, digits)}-{fmt_num(hi, digits)}" def summarize_text(values: list[str]) -> str: clean = sorted({v for v in values if v not in ("", None)}) if not clean: return "" if len(clean) == 1: return clean[0] return f"Mixed({', '.join(clean)})" def serialize_csv_row(row: list[str]) -> str: buf = io.StringIO() writer = csv.writer(buf) writer.writerow(row) return buf.getvalue().rstrip("\r\n") def build_magic(): if magic is None: return None try: return magic.Magic(mime=True) except Exception: return None def detect_mime(path: Path, magic_mime) -> Optional[str]: if magic_mime is not None: try: return magic_mime.from_file(str(path)) except Exception: pass file_exe = shutil.which("file") if file_exe is None: return None try: proc = subprocess.run( [file_exe, "--mime-type", "-b", str(path)], capture_output=True, text=True, check=True, ) out = proc.stdout.strip() return out or None except Exception: return None def detect_image_format(path: Path, magic_mime) -> Optional[str]: mime = detect_mime(path, magic_mime) return IMAGE_MIME_MAP.get(mime) def ffprobe_json(path: Path) -> Optional[dict]: ffprobe = shutil.which("ffprobe") if ffprobe is None: raise RuntimeError("ffprobe not found in PATH") cmd = [ ffprobe, "-v", "error", "-print_format", "json", "-show_format", "-show_streams", str(path), ] try: proc = subprocess.run(cmd, capture_output=True, text=True, check=True) return json.loads(proc.stdout) except Exception: return None def extract_primary_audio_stream(probe: dict) -> Optional[dict]: streams = probe.get("streams") or [] for stream in streams: if stream.get("codec_type") == "audio": return stream return None def has_video_stream(probe: dict) -> bool: streams = probe.get("streams") or [] return any(s.get("codec_type") == "video" for s in streams) def normalize_format(probe: dict, audio_stream: dict) -> str: fmt = probe.get("format") or {} format_name = (fmt.get("format_name") or "").lower() format_long_name = (fmt.get("format_long_name") or "").strip() names = {x.strip() for x in format_name.split(",") if x.strip()} major_brand = ((fmt.get("tags") or {}).get("major_brand") or "").upper() if "flac" in names: return "FLAC" if "mp3" in names: return "MP3" if "wav" in names or "wave" in names: return "WAV" if "aiff" in names: return "AIFF" if "ogg" in names: return "OGG" if "dsf" in names: return "DSF" if "wavpack" in names or "wv" in names: return "WAVPACK" if "ape" in names: return "APE" if "tta" in names: return "TTA" if "asf" in names: return "ASF" if "caf" in names: return "CAF" if "au" in names: return "AU" if "amr" in names: return "AMR" if "matroska" in names: return "MATROSKA" if has_video_stream(probe) else "MKA" if "webm" in names: return "WEBM" if {"mov", "mp4", "m4a", "3gp", "3g2", "mj2"} & names: if major_brand.startswith("M4A") or major_brand.startswith("M4B"): return "M4A" return "MP4" if has_video_stream(probe) else "M4A" codec_name = (audio_stream.get("codec_name") or "").lower() if codec_name == "aac": return "AAC" if codec_name: return codec_name.upper() if names: return "/".join(sorted(x.upper() for x in names)) if format_long_name: return format_long_name.upper().replace(" ", "_") return "UNKNOWN" def normalize_codec(audio_stream: dict, format_label: str) -> str: codec_name = (audio_stream.get("codec_name") or "").lower() codec_map = { "flac": "FLAC", "mp3": "MP3", "aac": "AAC", "alac": "ALAC", "opus": "OPUS", "vorbis": "VORBIS", "ac3": "AC3", "eac3": "EAC3", "dts": "DTS", "ape": "APE", "wavpack": "WAVPACK", "tta": "TTA", "wmav1": "WMA1", "wmav2": "WMA2", "wmapro": "WMA_PRO", "wmalossless": "WMA_LOSSLESS", "atrac3": "ATRAC3", "atrac3p": "ATRAC3P", "dsd_lsbf": "DSD", "dsd_msbf": "DSD", "dsd_lsbf_planar": "DSD", "dsd_msbf_planar": "DSD", } if codec_name in codec_map: return codec_map[codec_name] if codec_name.startswith("pcm_"): return codec_name.upper() if codec_name.startswith("adpcm_"): return codec_name.upper() return codec_name.upper() if codec_name else format_label def normalize_sample_format(audio_stream: dict) -> str: sample_fmt = (audio_stream.get("sample_fmt") or "").strip() return sample_fmt.upper() if sample_fmt else "" def infer_bit_depth_from_sample_fmt(sample_fmt: str) -> Optional[int]: sf = sample_fmt.lower() m = re.search(r"([su])(\d+)", sf) if m: try: return int(m.group(2)) except Exception: return None if sf.startswith("flt"): return 32 if sf.startswith("dbl"): return 64 return None def extract_bit_depth(audio_stream: dict) -> Optional[int]: raw = safe_int(audio_stream.get("bits_per_raw_sample")) if raw and raw > 0: return raw bps = safe_int(audio_stream.get("bits_per_sample")) if bps and bps > 0: return bps return infer_bit_depth_from_sample_fmt(audio_stream.get("sample_fmt") or "") def infer_lossless(codec: str) -> str: cu = codec.upper() if cu.startswith("PCM_"): return "TRUE" if cu in LOSSLESS_CODECS or "LOSSLESS" in cu: return "TRUE" if cu in { "MP3", "AAC", "OPUS", "VORBIS", "AC3", "EAC3", "DTS", "WMA1", "WMA2", "WMA_PRO", "ATRAC3", "ATRAC3P", }: return "FALSE" return "UNKNOWN" def probe_audio_file(path: Path) -> Optional[dict]: probe = ffprobe_json(path) if not probe: return None audio_stream = extract_primary_audio_stream(probe) if not audio_stream: return None fmt = probe.get("format") or {} file_format = normalize_format(probe, audio_stream) codec = normalize_codec(audio_stream, file_format) bit_depth = extract_bit_depth(audio_stream) sample_rate = safe_int(audio_stream.get("sample_rate")) channels = safe_int(audio_stream.get("channels")) channel_layout = (audio_stream.get("channel_layout") or "").strip() sample_format = normalize_sample_format(audio_stream) stream_bitrate = safe_float(audio_stream.get("bit_rate")) format_bitrate = safe_float(fmt.get("bit_rate")) bit_rate = stream_bitrate if stream_bitrate is not None else format_bitrate bit_rate_kbps = (bit_rate / 1000.0) if bit_rate is not None else None duration = safe_float(audio_stream.get("duration")) if duration is None: duration = safe_float(fmt.get("duration")) lossless = infer_lossless(codec) return { "file_format": file_format, "codec": codec, "bit_depth": bit_depth, "sample_rate_hz": sample_rate, "bit_rate_kbps": bit_rate_kbps, "channels": channels, "channel_layout": channel_layout, "sample_format": sample_format, "duration_seconds": duration, "lossless": lossless, } def choose_cover_image_format(hits: list[tuple[bool, str]]) -> str: if not hits: return "FALSE" preferred = [fmt for is_pref, fmt in hits if is_pref] usable = preferred if preferred else [fmt for _, fmt in hits] counts = Counter(usable) return counts.most_common(1)[0][0] def collect_files(root: Path) -> list[tuple[AlbumKey, Path]]: items: list[tuple[AlbumKey, Path]] = [] for artist_dir in sorted(root.iterdir(), key=lambda p: p.name.lower()): if not artist_dir.is_dir() or is_hidden(artist_dir): continue for album_dir in sorted(artist_dir.iterdir(), key=lambda p: p.name.lower()): if not album_dir.is_dir() or is_hidden(album_dir): continue album_name, year = parse_album_dir_name(album_dir.name) album_key = AlbumKey( artist=artist_dir.name, album=album_name, year=year, album_dir=album_dir, ) for path in sorted(album_dir.rglob("*"), key=lambda p: str(p).lower()): if path.is_file() and not is_hidden(path): items.append((album_key, path)) return items def scan_library(root: Path): magic_mime = build_magic() album_cover_hits: dict[AlbumKey, list[tuple[bool, str]]] = defaultdict(list) tracks: list[TrackInfo] = [] all_files = collect_files(root) total_files = len(all_files) with tqdm(total=total_files, desc="Scanning", unit="file") as pbar: for album_key, path in all_files: probed = probe_audio_file(path) if probed is not None: rel_path = str(path.relative_to(root)) track_num, track_num_sort, track_title = parse_track_name(path.name) tracks.append( TrackInfo( artist=album_key.artist, album=album_key.album, year=album_key.year, album_dir=album_key.album_dir, relative_path=rel_path, file_name=path.name, track_number=track_num, track_number_sort=track_num_sort, track_title=track_title, file_format=probed["file_format"], codec=probed["codec"], cover_image="", # filled later bit_depth=probed["bit_depth"], sample_rate_hz=probed["sample_rate_hz"], bit_rate_kbps=probed["bit_rate_kbps"], channels=probed["channels"], channel_layout=probed["channel_layout"], sample_format=probed["sample_format"], duration_seconds=probed["duration_seconds"], lossless=probed["lossless"], ) ) else: img_fmt = detect_image_format(path, magic_mime) if img_fmt: stem_lower = path.stem.lower() preferred = any(x in stem_lower for x in ("cover", "folder", "front", "album", "art")) album_cover_hits[album_key].append((preferred, img_fmt)) pbar.update(1) pbar.set_postfix(audio_tracks=len(tracks)) cover_map = {album_key: choose_cover_image_format(hits) for album_key, hits in album_cover_hits.items()} # Any album with tracks but no image hit should still resolve to FALSE for track in tracks: key = AlbumKey(track.artist, track.album, track.year, track.album_dir) cover = cover_map.get(key, "FALSE") track.cover_image = cover return tracks def track_sort_key(track: TrackInfo): return ( track.artist.lower(), track.album.lower(), track.track_number_sort, track.file_name.lower(), track.relative_path.lower(), ) def build_track_rows(tracks: list[TrackInfo]) -> list[list[str]]: rows = [] for t in sorted(tracks, key=track_sort_key): rows.append([ t.artist, t.album, t.track_number, t.track_title, t.relative_path, t.file_format, t.codec, t.cover_image, str(t.bit_depth) if t.bit_depth is not None else "", str(t.sample_rate_hz) if t.sample_rate_hz is not None else "", fmt_num(t.bit_rate_kbps, 2), str(t.channels) if t.channels is not None else "", t.channel_layout, t.sample_format, fmt_num(t.duration_seconds, 3), t.lossless, t.year, ]) return rows def build_album_rows(tracks: list[TrackInfo]) -> list[list[str]]: albums: dict[AlbumKey, list[TrackInfo]] = defaultdict(list) for track in tracks: key = AlbumKey(track.artist, track.album, track.year, track.album_dir) albums[key].append(track) rows = [] for key in sorted(albums.keys(), key=lambda k: (k.artist.lower(), k.album.lower(), k.year)): album_tracks = sorted(albums[key], key=track_sort_key) bit_depths = [t.bit_depth for t in album_tracks if t.bit_depth is not None] sample_rates = [t.sample_rate_hz for t in album_tracks if t.sample_rate_hz is not None] bitrates = [t.bit_rate_kbps for t in album_tracks if t.bit_rate_kbps is not None] durations = [t.duration_seconds for t in album_tracks if t.duration_seconds is not None] rows.append([ key.artist, key.album, summarize_text([t.file_format for t in album_tracks]), summarize_text([t.codec for t in album_tracks]), album_tracks[0].cover_image if album_tracks else "FALSE", str(len(album_tracks)), key.year, fmt_num(mean(bit_depths), 2) if bit_depths else "", fmt_range_int(bit_depths), fmt_num(mean(sample_rates), 2) if sample_rates else "", fmt_range_int(sample_rates), fmt_num(mean(bitrates), 2) if bitrates else "", fmt_range_float(bitrates, 2), summarize_text([str(t.channels) for t in album_tracks if t.channels is not None]), summarize_text([t.channel_layout for t in album_tracks]), summarize_text([t.sample_format for t in album_tracks]), fmt_num(sum(durations), 3) if durations else "", summarize_text([t.lossless for t in album_tracks]), ]) return rows def write_csv_with_stdout(output_path: Path, header: list[str], rows: list[list[str]], desc: str, unit: str): output_path.parent.mkdir(parents=True, exist_ok=True) with output_path.open("w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(header) tqdm.write(f"# writing {output_path}") tqdm.write(serialize_csv_row(header)) with tqdm(total=len(rows), desc=desc, unit=unit) as pbar: for row in rows: writer.writerow(row) tqdm.write(serialize_csv_row(row)) pbar.update(1) def main(): parser = argparse.ArgumentParser( description="Walk a music library and generate album-level and/or track-level CSV reports." ) parser.add_argument("root", help="Root of the music library") parser.add_argument( "--mode", choices=["album", "track", "both"], default="album", help="Which CSV(s) to emit", ) parser.add_argument("--album-output", help="Album CSV output path") parser.add_argument("--track-output", help="Track CSV output path") args = parser.parse_args() root = Path(args.root).expanduser().resolve() if not root.is_dir(): print(f"ERROR: Not a directory: {root}", file=sys.stderr) sys.exit(1) if shutil.which("ffprobe") is None: print("ERROR: ffprobe is required and was not found in PATH.", file=sys.stderr) sys.exit(1) if args.mode in ("album", "both") and not args.album_output: print("ERROR: --album-output is required for --mode album/both", file=sys.stderr) sys.exit(1) if args.mode in ("track", "both") and not args.track_output: print("ERROR: --track-output is required for --mode track/both", file=sys.stderr) sys.exit(1) tracks = scan_library(root) if not tracks: print("No audio tracks found.", file=sys.stderr) sys.exit(0) album_header = [ "Artist", "Album", "File Format", "Codec", "Cover Image", "Track Count", "Year", "Avg Bit Depth", "Bit Depth Range", "Avg Sample Rate Hz", "Sample Rate Range Hz", "Avg Bit Rate Kbps", "Bit Rate Range Kbps", "Channels", "Channel Layouts", "Sample Formats", "Total Duration Seconds", "Lossless", ] track_header = [ "Artist", "Album", "Track Number", "Track Title", "Relative Path", "File Format", "Codec", "Cover Image", "Bit Depth", "Sample Rate Hz", "Bit Rate Kbps", "Channels", "Channel Layout", "Sample Format", "Duration Seconds", "Lossless", "Year", ] if args.mode in ("album", "both"): album_rows = build_album_rows(tracks) write_csv_with_stdout( Path(args.album_output).expanduser().resolve(), album_header, album_rows, desc="Writing album CSV", unit="album", ) if args.mode in ("track", "both"): track_rows = build_track_rows(tracks) write_csv_with_stdout( Path(args.track_output).expanduser().resolve(), track_header, track_rows, desc="Writing track CSV", unit="track", ) if __name__ == "__main__": main()