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:
2026-08-19 23:39:40 +02:00
co-authored by Claude Opus 5
commit 8bcb6256fa
5 changed files with 550 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
*.xml
!example.xml
.DS_Store
Thumbs.db
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 nizzgruber
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+82
View File
@@ -0,0 +1,82 @@
# rekordbox_xmlfilecreater
Turn a folder of music into a **rekordbox XML** — your directory structure becomes the playlist tree, with tags, cover-derived metadata, BPM and key carried over.
Point it at a music folder, get a `rekordbox.xml` you can load straight into rekordbox. No database writes, no imports that reorganise your files: the XML only *references* the tracks where they already are.
## Why
rekordbox can import an XML library (the format iTunes/Rekordbox share). Building one by hand for thousands of tracks is not realistic, and dragging folders into rekordbox one by one loses the structure. This script walks the tree once and emits the whole thing.
## Install
```bash
pip install mutagen
```
`ffprobe` (from [FFmpeg](https://ffmpeg.org/)) is optional — it is used only as a fallback to measure duration for files whose headers mutagen cannot read. Without it those tracks get a length of 0, which rekordbox fixes on analysis.
## Usage
```bash
python rekordbox_xml.py "D:/Music" -o rekordbox.xml
```
Options:
| Flag | Effect |
|---|---|
| `-o`, `--output` | output path (default `rekordbox.xml`) |
| `--keep-empty` | include 0-byte files (skipped by default as failed downloads) |
| `--keep-apple` | include `__MACOSX` folders and `._*` AppleDouble stubs |
| `-q`, `--quiet` | suppress progress output |
Then check it before importing:
```bash
python validate_rekordbox_xml.py rekordbox.xml
```
This confirms the XML is well-formed, every `Location` resolves to a file that exists, TrackIDs are unique, and no playlist points at a missing track.
## Import into rekordbox
1. **Preferences → View → Layout** → tick **rekordbox xml**
2. **Preferences → Advanced → Database → rekordbox xml** → set *Imported Library* to your file
3. The tree appears in the sidebar under **rekordbox xml**
4. Right-click a playlist or the collection → **Import Playlist** / **Import to Collection**
Nothing is written to your files, and nothing moves on disk.
## How the tree maps
```
Music/
├── Techno/
│ ├── track1.wav -> playlist "Techno"
│ └── Hard/
│ └── track2.wav -> playlist "Hard" nested under folder "Techno"
└── Charts/
└── track3.mp3 -> playlist "Charts"
```
A directory containing **only** tracks becomes a playlist. A directory containing **only** subdirectories becomes a folder. A directory with **both** becomes a folder holding a same-named playlist for its own tracks, plus the subdirectories.
## Formats and metadata
Reads `.wav` `.mp3` `.flac` `.m4a` `.aiff` `.aif` `.wma` `.ogg` `.opus` `.alac`.
Metadata is pulled from ID3 (MP3/WAV/AIFF), Vorbis comments (FLAC/OGG) and MP4 atoms (M4A): title, artist, album, genre, year, track/disc number, BPM, key, composer, label, remixer. Missing titles fall back to the filename.
## Notes
- **WAV tags**: rekordbox reads ID3v2 chunks in WAV. If your WAVs are untagged, the filename is used as the title — tag them first if you want more.
- **BPM/key**: only copied if already present in the tags. rekordbox computes its own on analysis, which is generally more reliable.
- **Character handling**: control characters that are illegal in XML 1.0 are stripped from tags — they otherwise make the file unparseable. Paths are percent-encoded, so umlauts, emoji and brackets in folder names are safe.
- **Re-running** regenerates the file from scratch; it does not merge with an existing XML.
Tested on a ~19,000-track library across ~700 folders (mixed WAV/MP3/M4A/AIFF).
## License
MIT
+332
View File
@@ -0,0 +1,332 @@
#!/usr/bin/env python3
"""Generate a rekordbox-compatible DJ_PLAYLISTS XML from a music folder tree.
Every directory becomes a playlist. A directory holding both tracks and
subdirectories becomes a folder node with a playlist inside it, so the
rekordbox playlist tree ends up mirroring the folder layout exactly.
Usage:
python rekordbox_xml.py /path/to/music -o rekordbox.xml
Import in rekordbox:
Preferences > View > Layout > enable "rekordbox xml"
Preferences > Advanced > Database > rekordbox xml > select the file
The tree then appears under "rekordbox xml" in the sidebar.
"""
import argparse
import os
import shutil
import subprocess
import sys
import time
from urllib.parse import quote
from xml.sax.saxutils import quoteattr
try:
import mutagen
from mutagen.id3 import ID3
except ImportError:
sys.exit("mutagen is required: pip install mutagen")
AUDIO_EXT = (".wav", ".mp3", ".flac", ".m4a", ".aiff", ".aif", ".wma",
".ogg", ".opus", ".alac")
KIND = {".wav": "WAV File", ".mp3": "MP3 File", ".flac": "FLAC File",
".m4a": "M4A File", ".aiff": "AIFF File", ".aif": "AIFF File",
".wma": "WMA File", ".ogg": "OGG File", ".opus": "OPUS File",
".alac": "ALAC File"}
# ID3 frames (MP3, WAV, AIFF) -> internal field name
ID3MAP = {"TIT2": "title", "TPE1": "artist", "TALB": "album", "TCON": "genre",
"TDRC": "year", "TRCK": "track", "TBPM": "bpm", "TKEY": "key",
"TPOS": "disc", "TCOM": "composer", "TPUB": "label",
"TPE2": "albumartist", "TPE4": "remixer"}
# Vorbis comments (FLAC, OGG)
VORBIS = {"title": "title", "artist": "artist", "album": "album",
"genre": "genre", "date": "year", "tracknumber": "track",
"bpm": "bpm", "initialkey": "key", "discnumber": "disc",
"composer": "composer", "organization": "label",
"albumartist": "albumartist", "remixer": "remixer"}
# MP4 atoms (M4A)
MP4MAP = {"\xa9nam": "title", "\xa9ART": "artist", "\xa9alb": "album",
"\xa9gen": "genre", "\xa9day": "year", "\xa9wrt": "composer",
"aART": "albumartist", "tmpo": "bpm"}
HAVE_FFPROBE = shutil.which("ffprobe") is not None
NOWIN = 0x08000000 if os.name == "nt" else 0
def xmlsafe(s):
"""Drop characters XML 1.0 forbids; control codes leak in from sloppy tags."""
return "".join(c for c in str(s)
if c in "\t\n\r"
or 0x20 <= ord(c) <= 0xD7FF
or 0xE000 <= ord(c) <= 0xFFFD
or 0x10000 <= ord(c) <= 0x10FFFF)
def first(v):
if isinstance(v, (list, tuple)):
return str(v[0]) if v else ""
return str(v) if v is not None else ""
def ffprobe_length(path):
"""Duration fallback for files mutagen cannot measure."""
if not HAVE_FFPROBE:
return 0
try:
r = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nw=1:nk=1", "--", path],
capture_output=True, text=True, encoding="utf-8",
errors="replace", creationflags=NOWIN, timeout=30)
return int(float(r.stdout.strip()))
except Exception:
return 0
def read_meta(path, ext):
"""Return (fields, length_sec, bitrate_kbps, samplerate)."""
f = {}
length = bitrate = samplerate = 0
try:
m = mutagen.File(path)
except Exception:
m = None
if m is not None:
info = getattr(m, "info", None)
if info is not None:
length = int(getattr(info, "length", 0) or 0)
bitrate = int((getattr(info, "bitrate", 0) or 0) / 1000)
samplerate = int(getattr(info, "sample_rate", 0) or 0)
tags = m.tags
if tags is not None:
if isinstance(tags, ID3) or hasattr(tags, "getall"):
for frame, field in ID3MAP.items():
if frame in tags:
fr = tags[frame]
f[field] = first(fr.text if hasattr(fr, "text") else fr)
elif ext == ".m4a":
for k, field in MP4MAP.items():
if k in tags:
f[field] = first(tags[k])
else:
for k, field in VORBIS.items():
if k in tags:
f[field] = first(tags[k])
if not length:
length = ffprobe_length(path)
if not bitrate and samplerate:
bitrate = int(samplerate * 16 * 2 / 1000)
return f, length, bitrate, samplerate
def location(path):
"""rekordbox expects a percent-encoded file://localhost/ URL."""
p = os.path.abspath(path).replace("\\", "/")
if not p.startswith("/"):
p = "/" + p # Windows drive letters
return "file://localhost" + quote(p, safe="/:")
def clean_num(s, maxv=None):
s = (s or "").split("/")[0].strip()
try:
n = int(float(s))
except ValueError:
return 0
if maxv and not (0 < n <= maxv):
return 0
return max(n, 0)
def scan(root, keep_empty=False, keep_apple=False):
"""Map every directory to its playable audio files."""
tree = {}
for dp, dns, fns in os.walk(root):
if not keep_apple:
dns[:] = [d for d in dns if d != "__MACOSX"]
dns.sort()
keep = []
for x in fns:
if os.path.splitext(x)[1].lower() not in AUDIO_EXT:
continue
if not keep_apple and x.startswith("._"):
continue # AppleDouble metadata stub, not audio
if not keep_empty:
try:
if os.path.getsize(os.path.join(dp, x)) == 0:
continue # failed download
except OSError:
continue
keep.append(x)
tree[dp] = sorted(keep)
return tree
def build(root, out_path, tree, quiet=False):
subdirs = {}
for dp in tree:
parent = os.path.dirname(dp)
if parent in tree and parent != dp:
subdirs.setdefault(parent, []).append(dp)
for k in subdirs:
subdirs[k].sort()
cache = {}
def has_audio(dp):
if dp not in cache:
cache[dp] = (bool(tree.get(dp))
or any(has_audio(s) for s in subdirs.get(dp, [])))
return cache[dp]
tracks, ids, tid = [], {}, 0
total = sum(len(v) for v in tree.values())
t0 = time.time()
for dp in sorted(tree):
for name in tree[dp]:
p = os.path.join(dp, name)
ext = os.path.splitext(name)[1].lower()
try:
size = os.path.getsize(p)
mtime = time.strftime("%Y-%m-%d",
time.localtime(os.path.getmtime(p)))
except OSError:
continue
meta, length, bitrate, sr = read_meta(p, ext)
tid += 1
ids[p] = tid
tracks.append({
"id": tid, "path": p, "size": size, "date": mtime,
"kind": KIND.get(ext, "Unknown"), "len": length,
"br": bitrate, "sr": sr,
"title": meta.get("title") or os.path.splitext(name)[0],
"artist": meta.get("artist", ""), "album": meta.get("album", ""),
"genre": meta.get("genre", ""),
"composer": meta.get("composer", ""),
"label": meta.get("label", ""),
"remixer": meta.get("remixer", ""),
"year": clean_num(meta.get("year", "")[:4], 3000),
"track": clean_num(meta.get("track", ""), 999),
"disc": clean_num(meta.get("disc", ""), 99),
"bpm": meta.get("bpm", ""), "key": meta.get("key", ""),
})
if not quiet and len(tracks) % 2500 == 0:
print(f" {len(tracks)}/{total} scanned "
f"({time.time() - t0:.0f}s)", flush=True)
lines = ['<?xml version="1.0" encoding="UTF-8"?>',
'<DJ_PLAYLISTS Version="1.0.0">',
' <PRODUCT Name="rekordbox" Version="6.0.0" Company="AlphaTheta"/>',
f' <COLLECTION Entries="{len(tracks)}">']
for t in tracks:
try:
b = float(str(t["bpm"]).split("/")[0])
bpm = f"{b:.2f}" if 0 < b < 400 else ""
except (ValueError, TypeError):
bpm = ""
attrs = [
("TrackID", str(t["id"])), ("Name", t["title"]),
("Artist", t["artist"]), ("Composer", t["composer"]),
("Album", t["album"]), ("Grouping", ""), ("Genre", t["genre"]),
("Kind", t["kind"]), ("Size", str(t["size"])),
("TotalTime", str(t["len"])), ("DiscNumber", str(t["disc"])),
("TrackNumber", str(t["track"])), ("Year", str(t["year"])),
("AverageBpm", bpm), ("DateAdded", t["date"]),
("BitRate", str(t["br"])), ("SampleRate", str(t["sr"])),
("Comments", ""), ("PlayCount", "0"), ("Rating", "0"),
("Location", location(t["path"])), ("Remixer", t["remixer"]),
("Tonality", t["key"]), ("Label", t["label"]), ("Mix", ""),
]
lines.append(" <TRACK "
+ " ".join(f"{k}={quoteattr(xmlsafe(v))}" for k, v in attrs)
+ "/>")
lines.append(" </COLLECTION>")
def playlist_node(dp, files, name, depth):
ind = " " + " " * depth
out = [f"{ind}<NODE Name={quoteattr(xmlsafe(name))} Type=\"1\" "
f"KeyType=\"0\" Entries=\"{len(files)}\">"]
for fn in files:
k = ids.get(os.path.join(dp, fn))
if k:
out.append(f'{ind} <TRACK Key="{k}"/>')
out.append(f"{ind}</NODE>")
return out
def node(dp, depth):
files = tree.get(dp, [])
subs = [s for s in subdirs.get(dp, []) if has_audio(s)]
name = os.path.basename(dp) or "Playlist"
ind = " " + " " * depth
out = []
if files and subs:
out.append(f"{ind}<NODE Name={quoteattr(xmlsafe(name))} Type=\"0\" "
f"Count=\"{len(subs) + 1}\">")
out += playlist_node(dp, files, name, depth + 1)
for s in subs:
out += node(s, depth + 1)
out.append(f"{ind}</NODE>")
elif subs:
out.append(f"{ind}<NODE Name={quoteattr(xmlsafe(name))} Type=\"0\" "
f"Count=\"{len(subs)}\">")
for s in subs:
out += node(s, depth + 1)
out.append(f"{ind}</NODE>")
elif files:
out += playlist_node(dp, files, name, depth)
return out
top = [s for s in subdirs.get(root, []) if has_audio(s)]
root_files = tree.get(root, [])
lines.append(" <PLAYLISTS>")
lines.append(f' <NODE Type="0" Name="ROOT" '
f'Count="{len(top) + (1 if root_files else 0)}">')
if root_files:
lines += playlist_node(root, root_files,
os.path.basename(root) or "Root", 1)
for d in top:
lines += node(d, 1)
lines.append(" </NODE>")
lines.append(" </PLAYLISTS>")
lines.append("</DJ_PLAYLISTS>")
with open(out_path, "w", encoding="utf-8", newline="\r\n") as fh:
fh.write("\n".join(lines))
return len(tracks), sum(1 for x in lines if 'Type="1"' in x)
def main():
ap = argparse.ArgumentParser(
description="Generate a rekordbox XML from a music folder tree.")
ap.add_argument("music_dir", help="root folder to scan")
ap.add_argument("-o", "--output", default="rekordbox.xml",
help="output XML path (default: rekordbox.xml)")
ap.add_argument("--keep-empty", action="store_true",
help="include 0-byte files (skipped by default)")
ap.add_argument("--keep-apple", action="store_true",
help="include __MACOSX / ._ AppleDouble stubs")
ap.add_argument("-q", "--quiet", action="store_true")
args = ap.parse_args()
root = os.path.abspath(args.music_dir)
if not os.path.isdir(root):
sys.exit(f"not a directory: {root}")
if not HAVE_FFPROBE and not args.quiet:
print("note: ffprobe not found - duration fallback disabled")
t0 = time.time()
tree = scan(root, args.keep_empty, args.keep_apple)
n_tracks, n_pl = build(root, args.output, tree, args.quiet)
print(f"\nTracks : {n_tracks}")
print(f"Playlists : {n_pl}")
print(f"Output : {args.output} "
f"({os.path.getsize(args.output) / 2**20:.1f} MB)")
print(f"Elapsed : {time.time() - t0:.0f}s")
if __name__ == "__main__":
main()
+106
View File
@@ -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())