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()

View File

@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""One-time migration: clear iTunes' guessed ("computed") ratings from an
already-imported lintunes library, without touching anything else.
Reads the iTunes XML to find which tracks had `Rating Computed` set, then
zeroes `rating`/`album_rating` for those tracks in <data-dir>/library.json,
matching by persistent ID (track IDs are not stable across exports).
Usage:
python3 scripts/clear_computed_ratings.py \
--xml "itunes-test-library/iTunes Library.xml" --data-dir ./data
"""
import argparse
import json
import plistlib
import sys
from pathlib import Path
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--xml", required=True, help="iTunes Library XML file")
parser.add_argument("--data-dir", required=True, help="lintunes data dir")
parser.add_argument("--dry-run", action="store_true",
help="Report what would change without writing")
args = parser.parse_args()
xml_path = Path(args.xml)
library_path = Path(args.data_dir) / "library.json"
if not xml_path.exists():
sys.exit(f"XML not found: {xml_path}")
if not library_path.exists():
sys.exit(f"Library not found: {library_path}")
print(f"Reading {xml_path}...")
with open(xml_path, "rb") as f:
plist = plistlib.load(f)
computed_rating_pids = set()
computed_album_pids = set()
for itunes_track in plist.get("Tracks", {}).values():
pid = itunes_track.get("Persistent ID")
if not pid:
continue
if itunes_track.get("Rating Computed"):
computed_rating_pids.add(pid)
if itunes_track.get("Album Rating Computed"):
computed_album_pids.add(pid)
print(f"XML: {len(computed_rating_pids)} computed track ratings, "
f"{len(computed_album_pids)} computed album ratings")
with open(library_path, "r", encoding="utf-8") as f:
tracks = json.load(f)
rated_before = sum(1 for t in tracks.values() if t.get("rating"))
cleared = album_cleared = 0
for track in tracks.values():
pid = track.get("persistent_id", "")
if pid in computed_rating_pids and track.get("rating"):
del track["rating"]
cleared += 1
if pid in computed_album_pids:
if track.pop("album_rating", None) is not None:
album_cleared += 1
track.pop("album_rating_computed", None)
rated_after = sum(1 for t in tracks.values() if t.get("rating"))
print(f"Track ratings: {rated_before} before -> {rated_after} after "
f"({cleared} guessed ratings cleared, {album_cleared} album ratings)")
if args.dry_run:
print("Dry run — nothing written.")
return
tmp = library_path.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(tracks, f, indent=2, ensure_ascii=False)
tmp.replace(library_path)
print(f"Wrote {library_path}")
if __name__ == "__main__":
main()

170
scripts/recover_artwork.py Normal file
View File

@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Embed album art that iTunes had but never wrote into the audio files.
lintunes shows art read live from each file's embedded tags, so any art that
lived only in iTunes' Album Artwork cache is invisible until it's embedded. This
backfills it, in order of reliability, and writes nothing it isn't sure about:
tier2 (album) for a track with no embedded art, copy a same-album track's
embedded art (iTunes shows one cover per album)
tier3 (cache) decode iTunes' Album Artwork/Cache/<lib>-<PID>.itc (keyed by
the track's persistent id) and embed it
Download/Store-only art (an opaque id with no track link from here) is *not*
guessed at -- it's reported so you can decide separately (see --audit).
Defaults to --dry-run: nothing is modified until you pass --write. Run the audit
first (scripts/audit_artwork.py) to see how many tracks each tier covers.
Usage:
python3 scripts/recover_artwork.py --data-dir ./data # preview
python3 scripts/recover_artwork.py --data-dir ./data --write # apply
"""
import argparse
import glob
import json
import os
import sys
from collections import defaultdict
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from lintunes import tagging # noqa: E402
from lintunes.itc import decode_itc_file # noqa: E402
def album_key(t: dict):
album = (t.get("album") or "").strip().lower()
if not album:
return None
who = (t.get("album_artist") or t.get("artist") or "").strip().lower()
return (who, album)
def build_cache_index(artwork_dir: Path) -> dict[str, str]:
"""persistent id (upper) -> path of its Cache/*.itc."""
index = {}
for f in glob.glob(str(artwork_dir / "Cache" / "**" / "*.itc"),
recursive=True):
token = os.path.basename(f).rsplit(".", 1)[0].split("-")[-1].upper()
index.setdefault(token, f)
return index
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--data-dir", required=True, help="lintunes data dir")
parser.add_argument("--artwork-dir",
help="iTunes 'Album Artwork' dir (default: alongside the "
"music drive root inferred from track locations)")
parser.add_argument("--write", action="store_true",
help="actually embed art (default is a dry run)")
parser.add_argument("--limit", type=int, default=0,
help="process only the first N tracks")
args = parser.parse_args()
dry = not args.write
library_path = Path(args.data_dir) / "library.json"
if not library_path.exists():
sys.exit(f"Library not found: {library_path}")
tracks = json.load(open(library_path, encoding="utf-8"))
items = list(tracks.values())
if args.limit:
items = items[:args.limit]
# Locate the Album Artwork dir: a sibling of "iTunes Media" on the drive.
artwork_dir = Path(args.artwork_dir) if args.artwork_dir else None
if artwork_dir is None:
for t in items:
loc = t.get("location") or ""
idx = loc.find("iTunes Media")
if idx != -1:
artwork_dir = Path(loc[:idx]) / "Album Artwork"
break
if artwork_dir is None or not artwork_dir.exists():
sys.exit("Could not find the 'Album Artwork' dir; pass --artwork-dir.")
print(f"{'DRY RUN' if dry else 'WRITING'} | Album Artwork: {artwork_dir}\n")
cache_index = build_cache_index(artwork_dir)
# Pass 1: embedded-art map + album grouping (one mutagen read per file).
embedded = {}
album_art_source = {} # album key -> a location that has embedded art
for t in items:
loc = t.get("location") or ""
has = bool(loc) and os.path.exists(loc) and \
tagging.read_embedded_artwork(loc) is not None
embedded[id(t)] = has
if has:
key = album_key(t)
if key is not None:
album_art_source.setdefault(key, loc)
# Pass 2: recover.
stats = defaultdict(int)
errors = []
for t in items:
if embedded[id(t)]:
continue
loc = t.get("location") or ""
if not loc or not os.path.exists(loc):
stats["file_missing"] += 1
continue
pid = (t.get("persistent_id") or "").upper()
label = f"{t.get('artist','?')}{t.get('name','?')}"
# tier2: an album-mate has embedded art.
key = album_key(t)
src = album_art_source.get(key) if key is not None else None
if src and src != loc:
data = tagging.read_embedded_artwork(src)
if data:
mime = "image/png" if data[:8] == b"\x89PNG\r\n\x1a\n" \
else "image/jpeg"
stats["tier2_album"] += 1
if not dry:
try:
tagging.write_artwork(loc, data, mime)
except Exception as e: # noqa: BLE001
errors.append((label, repr(e)))
stats["tier2_album"] -= 1
stats["error"] += 1
continue
# tier3: this track's own Cache/*.itc.
itc = cache_index.get(pid)
if itc:
decoded = decode_itc_file(itc)
if decoded is None:
stats["tier3_raw_skip"] += 1 # ARGb/PNGf raw, can't recover
continue
data, mime = decoded
stats["tier3_cache"] += 1
if not dry:
try:
tagging.write_artwork(loc, data, mime)
except Exception as e: # noqa: BLE001
errors.append((label, repr(e)))
stats["tier3_cache"] -= 1
stats["error"] += 1
continue
stats["unrecovered"] += 1
print("Result:")
for k in ("tier2_album", "tier3_cache", "tier3_raw_skip", "unrecovered",
"file_missing", "error"):
print(f" {k:<16}{stats[k]:>8}")
recovered = stats["tier2_album"] + stats["tier3_cache"]
verb = "would embed" if dry else "embedded"
print(f"\n{verb} art on {recovered} tracks.")
if dry:
print("Re-run with --write to apply.")
if errors:
print(f"\n{len(errors)} write error(s):")
for label, err in errors[:20]:
print(f" - {label}: {err}")
if __name__ == "__main__":
main()