Snapshot of the existing codebase before working through the TASKS.md backlog. Real library data (data/) and the iTunes import fixture (itunes-test-library/) are gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
180 lines
6.9 KiB
Python
180 lines
6.9 KiB
Python
#!/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 '<lib>-<PID>.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: <xml folder>/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()
|