Files
rekordbox_xml/validate_rekordbox_xml.py
Nizz05andClaude Opus 5 8bcb6256fa 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>
2026-08-19 23:39:40 +02:00

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