commit 8bcb6256fa65a9450f46901106a3b7974088d8ac Author: nicolas Date: Wed Aug 19 23:39:40 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9750f62 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +*.xml +!example.xml +.DS_Store +Thumbs.db diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..39fd22b --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..61a07d1 --- /dev/null +++ b/README.md @@ -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 diff --git a/rekordbox_xml.py b/rekordbox_xml.py new file mode 100644 index 0000000..71e84b5 --- /dev/null +++ b/rekordbox_xml.py @@ -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 = ['', + '', + ' ', + f' '] + 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(" ") + lines.append(" ") + + def playlist_node(dp, files, name, depth): + ind = " " + " " * depth + out = [f"{ind}"] + for fn in files: + k = ids.get(os.path.join(dp, fn)) + if k: + out.append(f'{ind} ') + out.append(f"{ind}") + 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}") + out += playlist_node(dp, files, name, depth + 1) + for s in subs: + out += node(s, depth + 1) + out.append(f"{ind}") + elif subs: + out.append(f"{ind}") + for s in subs: + out += node(s, depth + 1) + out.append(f"{ind}") + 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(" ") + lines.append(f' ') + 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(" ") + lines.append(" ") + lines.append("") + + 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() diff --git a/validate_rekordbox_xml.py b/validate_rekordbox_xml.py new file mode 100644 index 0000000..eb454dd --- /dev/null +++ b/validate_rekordbox_xml.py @@ -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())