Import existing LinTunes project
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>
This commit is contained in:
179
scripts/audit_artwork.py
Normal file
179
scripts/audit_artwork.py
Normal file
@ -0,0 +1,179 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user