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:
2026-06-26 21:12:01 -04:00
commit f6d35fa594
70 changed files with 8992 additions and 0 deletions

179
scripts/audit_artwork.py Normal file
View 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()