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()
|
||||
82
scripts/clear_computed_ratings.py
Normal file
82
scripts/clear_computed_ratings.py
Normal 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
170
scripts/recover_artwork.py
Normal 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()
|
||||
Reference in New Issue
Block a user