#!/usr/bin/env python3 """Audit album-art coverage of an imported lintunes library (read-only). Answers "how far am I from having 100% of the art iTunes had, and why?" by classifying every track into recovery tiers (see scripts/recover_artwork.py): embedded - art is already in the file -> lintunes already shows it tier2_album - no embedded art, but an album-mate has art we can copy tier3_cache - no embedded art, but iTunes' Album Artwork/Cache has a .itc keyed by this track's persistent id residue - iTunes had art (XML "Artwork Count">=1) but it's only in Download/Store under an opaque id we can't map from here none - no art anywhere (genuinely artless track) file_missing - the audio file isn't on disk Nothing is written. Use scripts/recover_artwork.py to actually embed art. Usage: python3 scripts/audit_artwork.py --data-dir ./data \ --xml "/run/media/trav/tummult/music/iTunes Library.xml" """ import argparse import glob import json import os import plistlib import sys from collections import Counter, defaultdict from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from lintunes import tagging # noqa: E402 def album_key(album: str, album_artist: str, artist: str): """Group key for album-mate propagation, or None when album is unknown (we never propagate across an empty/unknown album).""" album = (album or "").strip().lower() if not album: return None who = (album_artist or artist or "").strip().lower() return (who, album) def build_cache_pid_index(artwork_dir: Path) -> set[str]: """Persistent ids that have a Cache/*.itc keyed by '-.itc'.""" pids = set() for f in glob.glob(str(artwork_dir / "Cache" / "**" / "*.itc"), recursive=True): token = os.path.basename(f).rsplit(".", 1)[0].split("-")[-1].upper() pids.add(token) return pids def main(): parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--data-dir", required=True, help="lintunes data dir") parser.add_argument("--xml", required=True, help="iTunes Library XML file") parser.add_argument("--artwork-dir", help="iTunes 'Album Artwork' dir " "(default: /Album Artwork)") parser.add_argument("--limit", type=int, default=0, help="audit only the first N tracks (for a quick look)") parser.add_argument("--examples", type=int, default=15, help="how many residue examples to print") args = parser.parse_args() library_path = Path(args.data_dir) / "library.json" xml_path = Path(args.xml) artwork_dir = (Path(args.artwork_dir) if args.artwork_dir else xml_path.parent / "Album Artwork") for label, p in (("Library", library_path), ("XML", xml_path)): if not p.exists(): sys.exit(f"{label} not found: {p}") print(f"Reading {library_path} ...") tracks = json.load(open(library_path, encoding="utf-8")) print(f"Reading {xml_path} ...") plist = plistlib.load(open(xml_path, "rb")) xml_art = {} for t in plist.get("Tracks", {}).values(): pid = (t.get("Persistent ID") or "").upper() if pid: xml_art[pid] = t.get("Artwork Count", 0) print(f"Indexing Cache/*.itc under {artwork_dir} ...") cache_pids = build_cache_pid_index(artwork_dir) if artwork_dir.exists() \ else set() print(f" {len(cache_pids)} cache entries keyed by persistent id\n") items = list(tracks.values()) if args.limit: items = items[:args.limit] # Pass 1: which tracks have embedded art, grouped by album. embedded = {} # pid -> bool missing = set() # pid (file not on disk) album_has_art = defaultdict(bool) pid_album = {} total = len(items) for i, t in enumerate(items, 1): pid = (t.get("persistent_id") or "").upper() loc = t.get("location") or "" key = album_key(t.get("album"), t.get("album_artist"), t.get("artist")) pid_album[pid] = key if not loc or not os.path.exists(loc): missing.add(pid) embedded[pid] = False continue has = tagging.read_embedded_artwork(loc) is not None embedded[pid] = has if has and key is not None: album_has_art[key] = True if i % 2000 == 0: print(f" scanned {i}/{total} files...") print(f" scanned {total}/{total} files.\n") # Pass 2: classify. buckets = Counter() residue_examples = [] for t in items: pid = (t.get("persistent_id") or "").upper() if pid in missing: buckets["file_missing"] += 1 continue if embedded.get(pid): buckets["embedded"] += 1 continue key = pid_album.get(pid) ac = xml_art.get(pid, 0) if key is not None and album_has_art.get(key): buckets["tier2_album"] += 1 elif pid in cache_pids: buckets["tier3_cache"] += 1 elif ac >= 1: buckets["residue"] += 1 if len(residue_examples) < args.examples: residue_examples.append(t) else: buckets["none"] += 1 order = ["embedded", "tier2_album", "tier3_cache", "residue", "none", "file_missing"] recoverable = buckets["tier2_album"] + buckets["tier3_cache"] art_target = buckets["embedded"] + recoverable + buckets["residue"] print("=" * 56) print(f"{'TIER':<14}{'TRACKS':>10} meaning") print("-" * 56) desc = { "embedded": "already shows in lintunes", "tier2_album": "fix: copy an album-mate's art", "tier3_cache": "fix: decode Cache/*.itc by persistent id", "residue": "iTunes had art, only in Download/Store (hard)", "none": "no art anywhere", "file_missing": "audio file not on disk", } for b in order: print(f"{b:<14}{buckets[b]:>10} {desc[b]}") print("-" * 56) print(f"{'TOTAL':<14}{total:>10}") print("=" * 56) print(f"\nArt-bearing tracks (per iTunes + album-mates): {art_target}") print(f" already embedded : {buckets['embedded']}") print(f" recoverable now : {recoverable} " f"(tier2 {buckets['tier2_album']} + tier3 {buckets['tier3_cache']})") print(f" hard residue : {buckets['residue']}") if art_target: covered = buckets["embedded"] + recoverable print(f" -> after recovery: {covered}/{art_target} " f"= {100 * covered / art_target:.1f}% of art-bearing tracks") if residue_examples: print(f"\nResidue examples (up to {args.examples}):") for t in residue_examples: print(f" - {t.get('artist','?')} — {t.get('album','?')} — " f"{t.get('name','?')}") if __name__ == "__main__": main()