Add rekordbox XML generator and validator
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>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user