Walks a music folder tree and emits a rekordbox-compatible DJ_PLAYLISTS XML where each directory becomes a playlist, preserving nesting as folder nodes. - reads ID3, Vorbis and MP4 metadata (title, artist, album, BPM, key, ...) - percent-encoded file:// locations, safe for umlauts and brackets in paths - strips control characters that would make the XML unparseable - skips 0-byte downloads and macOS AppleDouble stubs by default - optional ffprobe fallback for durations mutagen cannot read validate_rekordbox_xml.py checks the result before import: well-formedness, resolvable locations, unique TrackIDs and intact playlist references. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
107 lines
3.8 KiB
Python
107 lines
3.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Sanity-check a rekordbox XML before importing it.
|
|
|
|
Verifies the file is well-formed, that every Location resolves to a file that
|
|
actually exists, that TrackIDs are unique, and that no playlist references a
|
|
track missing from the collection.
|
|
|
|
Usage:
|
|
python validate_rekordbox_xml.py rekordbox.xml
|
|
"""
|
|
import argparse
|
|
import os
|
|
import random
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
from urllib.parse import unquote
|
|
|
|
|
|
def to_path(loc):
|
|
p = unquote(loc.replace("file://localhost", ""))
|
|
if os.name == "nt" and p.startswith("/"):
|
|
p = p[1:]
|
|
return p.replace("/", os.sep)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="Validate a rekordbox XML.")
|
|
ap.add_argument("xml")
|
|
ap.add_argument("--samples", type=int, default=5,
|
|
help="how many random tracks to print (default: 5)")
|
|
args = ap.parse_args()
|
|
|
|
try:
|
|
root = ET.parse(args.xml).getroot()
|
|
except ET.ParseError as e:
|
|
sys.exit(f"FAIL - not well-formed XML: {e}")
|
|
print("well-formed XML : OK")
|
|
|
|
coll = root.find("COLLECTION")
|
|
if coll is None:
|
|
sys.exit("FAIL - no COLLECTION element")
|
|
tracks = coll.findall("TRACK")
|
|
declared = int(coll.get("Entries", 0))
|
|
print(f"collection entries : {len(tracks)} "
|
|
f"(attribute says {declared}) "
|
|
f"{'OK' if declared == len(tracks) else '<-- MISMATCH'}")
|
|
|
|
ids = {t.get("TrackID") for t in tracks}
|
|
print(f"unique TrackIDs : {len(ids)} "
|
|
f"{'OK' if len(ids) == len(tracks) else '<-- DUPLICATES'}")
|
|
|
|
missing = [to_path(t.get("Location")) for t in tracks
|
|
if not os.path.exists(to_path(t.get("Location")))]
|
|
print(f"files not found on disk : {len(missing)}")
|
|
for m in missing[:10]:
|
|
print(" MISSING:", m)
|
|
|
|
print(f"\nwithout title : {sum(1 for t in tracks if not t.get('Name'))}")
|
|
print(f"without artist : {sum(1 for t in tracks if not t.get('Artist'))}")
|
|
print(f"without BPM : {sum(1 for t in tracks if not t.get('AverageBpm'))}")
|
|
print(f"zero length : "
|
|
f"{sum(1 for t in tracks if t.get('TotalTime') in ('0', '', None))}")
|
|
|
|
pl = root.find("PLAYLISTS")
|
|
stats = {"pl": 0, "folder": 0, "refs": 0, "bad": 0, "mismatch": []}
|
|
|
|
def walk(n):
|
|
if n.get("Type") == "1":
|
|
stats["pl"] += 1
|
|
kids = n.findall("TRACK")
|
|
stats["refs"] += len(kids)
|
|
if int(n.get("Entries", -1)) != len(kids):
|
|
stats["mismatch"].append(n.get("Name"))
|
|
for k in kids:
|
|
if k.get("Key") not in ids:
|
|
stats["bad"] += 1
|
|
else:
|
|
stats["folder"] += 1
|
|
for c in n.findall("NODE"):
|
|
walk(c)
|
|
|
|
if pl is not None and pl.find("NODE") is not None:
|
|
walk(pl.find("NODE"))
|
|
print(f"\nplaylists : {stats['pl']}")
|
|
print(f"folder nodes : {stats['folder']}")
|
|
print(f"track references : {stats['refs']}")
|
|
print(f"broken references : {stats['bad']}")
|
|
for m in stats["mismatch"][:10]:
|
|
print(f" Entries mismatch in playlist {m!r}")
|
|
|
|
if tracks and args.samples:
|
|
print("\n--- random sample ---")
|
|
for t in random.sample(tracks, min(args.samples, len(tracks))):
|
|
print(f" {(t.get('Name') or '')[:40]:42} | "
|
|
f"{(t.get('Artist') or '')[:20]:22} | "
|
|
f"{t.get('TotalTime'):>5}s | "
|
|
f"BPM {t.get('AverageBpm') or '-':>6} | {t.get('Kind')}")
|
|
|
|
ok = (not missing and not stats["bad"] and declared == len(tracks)
|
|
and len(ids) == len(tracks))
|
|
print(f"\nRESULT: {'PASS' if ok else 'PROBLEMS FOUND'}")
|
|
return 0 if ok else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|