This is a simple vibecoded python script that allows you to query Virtual DJ databases from the command line.
python parse_vdj.py --db D: -t arty
+---------------+--------------------------------------------------------+---------+-------+-------------------------------------------------------------------------------------------------+
| Artist | Title | BPM | Key | File Path |
|---------------+--------------------------------------------------------+---------+-------+-------------------------------------------------------------------------------------------------|
| Ferry Corsten | Punk (Arty Rock-n-Rolla Mix) | 132 | Am | D:\dj_tracks\A State of Trance 550 (Mixed by Armin va\4-13 Punk (Arty Rock-n-Rolla Mix).m4a |
| ARTY | The Wall (Arty Re-Mode Radio Edit) [feat. Tania Zygar] | 130.002 | Dm | D:\dj_tracks\A State of Trance 550 (Mixed by Armin va\4-12 The Wall (Arty Re-Mode Radio Ed.m4a |
| Reverse | Absolute Reality (Arty Remix) | 129.996 | A# | D:\dj_tracks\A State of Trance 550 (Mixed by Armin va\4-10 Absolute Reality (Arty Remix).m4a |
| Tilt | The World Doesn't Know (Arty Remix) | 132 | Gm | D:\dj_tracks\A State of Trance 550 (Mixed by Armin va\4-11 The World Doesn't Know (Arty Re.m4a |
| ARTY | Twilight Tonight (Arty Remode Edit) | 130.009 | Eb | D:\dj_tracks\A State of Trance 550 (Mixed by Armin va\4-09 Twilight Tonight (Arty Remode E.m4a |
| Sneijder | Away from Here (Arty Remix) | 133.002 | Gm | D:\dj_tracks\A State of Trance 550 (Mixed by Armin va\4-08 Away from Here (Arty Remix).m4a |
| Kyau & Albert | Are You Fine? (Arty Remix) | 130.002 | C#m | D:\dj_tracks\A State of Trance 550 (Mixed by Armin va\4-05 Are You Fine_ (Arty Remix).m4a |
| D-Mad | She Gave Happiness (Arty Remix Radio Edit) | 132 | F#m | D:\dj_tracks\A State of Trance 550 (Mixed by Armin va\4-04 She Gave Happiness (Arty Remix.m4a |
| D-Mad | She Gave Happiness (Arty Remix Radio Edit) | 132 | F#m | D:\dj_tracks\A State of Trance 550 (Mixed by Armin va\4-04 She Gave Happiness (Arty Remix 1.m4a |
+---------------+--------------------------------------------------------+---------+-------+-------------------------------------------------------------------------------------------------+
python parse_vdj.py --db d: -t arty -b +131
+---------------+--------------------------------------------+---------+-------+-------------------------------------------------------------------------------------------------+
| Artist | Title | BPM | Key | File Path |
|---------------+--------------------------------------------+---------+-------+-------------------------------------------------------------------------------------------------|
| Ferry Corsten | Punk (Arty Rock-n-Rolla Mix) | 132 | Am | D:\dj_tracks\A State of Trance 550 (Mixed by Armin va\4-13 Punk (Arty Rock-n-Rolla Mix).m4a |
| Tilt | The World Doesn't Know (Arty Remix) | 132 | Gm | D:\dj_tracks\A State of Trance 550 (Mixed by Armin va\4-11 The World Doesn't Know (Arty Re.m4a |
| Sneijder | Away from Here (Arty Remix) | 133.002 | Gm | D:\dj_tracks\A State of Trance 550 (Mixed by Armin va\4-08 Away from Here (Arty Remix).m4a |
| D-Mad | She Gave Happiness (Arty Remix Radio Edit) | 132 | F#m | D:\dj_tracks\A State of Trance 550 (Mixed by Armin va\4-04 She Gave Happiness (Arty Remix.m4a |
| D-Mad | She Gave Happiness (Arty Remix Radio Edit) | 132 | F#m | D:\dj_tracks\A State of Trance 550 (Mixed by Armin va\4-04 She Gave Happiness (Arty Remix 1.m4a |
+---------------+--------------------------------------------+---------+-------+-------------------------------------------------------------------------------------------------+
import argparse
import os
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union
try:
from tabulate import tabulate, tabulate_formats
except ImportError:
print("Error: 'tabulate' library is required. Install it using: pip install tabulate", file=sys.stderr)
sys.exit(1)
class VirtualDJDatabase:
"""Parses a VirtualDJ database.xml file into structured Python objects."""
def __init__(self, db_path: Optional[Union[str, Path]] = None):
self.db_path = self._resolve_db_path(db_path) if db_path else self._get_default_db_path()
if not self.db_path.exists():
raise FileNotFoundError(f"VirtualDJ database not found at: {self.db_path}")
self.tracks: List[Dict] = []
self.version: Optional[str] = None
@staticmethod
def _is_wsl() -> bool:
if sys.platform.startswith("linux"):
try:
with open("/proc/version", "r") as f:
return "microsoft" in f.read().lower()
except OSError:
return False
return False
@classmethod
def _get_wsl_windows_user(cls) -> Optional[str]:
import subprocess
try:
cmd = ["cmd.exe", "/c", "echo %USERNAME%"]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
win_user = result.stdout.strip()
return win_user if win_user else None
except (subprocess.SubprocessError, FileNotFoundError):
return None
@classmethod
def _resolve_db_path(cls, path_input: Union[str, Path]) -> Path:
"""Resolves drive letters (D:, E:) or volume names (MyUsb) to a database.xml path."""
p_str = str(path_input).strip()
is_wsl = cls._is_wsl()
# Handle drive letters (e.g., 'd:', 'D:', 'e:\', 'E:/')
drive_match = re.match(r"^([a-zA-Z]):?[/\\]*$", p_str)
if drive_match:
drive_letter = drive_match.group(1).lower()
if is_wsl:
drive_root = Path(f"/mnt/{drive_letter}")
elif sys.platform.startswith("win"):
drive_root = Path(f"{drive_letter.upper()}:\\")
else:
drive_root = Path(p_str)
candidate = drive_root / "VirtualDJ" / "database.xml"
if candidate.exists():
return candidate
return drive_root / "database.xml"
# Handle volume names / custom paths
given_path = Path(p_str)
# 1. Direct path check (if full path was provided)
if given_path.is_file():
return given_path
if (given_path / "database.xml").exists():
return given_path / "database.xml"
if (given_path / "VirtualDJ" / "database.xml").exists():
return given_path / "VirtualDJ" / "database.xml"
# 2. Volume search on macOS (/Volumes/<name>)
if sys.platform == "darwin":
mac_vol = Path("/Volumes") / p_str
if (mac_vol / "VirtualDJ" / "database.xml").exists():
return mac_vol / "VirtualDJ" / "database.xml"
if (mac_vol / "database.xml").exists():
return mac_vol / "database.xml"
# 3. Volume search on Linux / WSL (/media/<user>/<name> or /mnt/<name>)
if sys.platform.startswith("linux"):
for base_dir in [Path("/media"), Path("/mnt")]:
if base_dir.exists():
for match in base_dir.rglob(p_str):
if match.is_dir():
if (match / "VirtualDJ" / "database.xml").exists():
return match / "VirtualDJ" / "database.xml"
if (match / "database.xml").exists():
return match / "database.xml"
return given_path
@classmethod
def _get_default_db_path(cls) -> Path:
if cls._is_wsl():
win_user = cls._get_wsl_windows_user()
if win_user:
win_home = Path(f"/mnt/c/Users/{win_user}")
new_path = win_home / "AppData" / "Local" / "VirtualDJ" / "database.xml"
if new_path.exists():
return new_path
legacy_path = win_home / "Documents" / "VirtualDJ" / "database.xml"
if legacy_path.exists():
return legacy_path
raise FileNotFoundError("Could not auto-detect VirtualDJ database in Windows user profile from WSL.")
if sys.platform.startswith("win"):
appdata = os.environ.get("LOCALAPPDATA")
if appdata:
new_path = Path(appdata) / "VirtualDJ" / "database.xml"
if new_path.exists():
return new_path
return Path.home() / "Documents" / "VirtualDJ" / "database.xml"
if sys.platform == "darwin":
home = Path.home()
new_path = home / "Library" / "Application Support" / "VirtualDJ" / "database.xml"
if new_path.exists():
return new_path
return home / "Documents" / "VirtualDJ" / "database.xml"
raise NotImplementedError(f"Unsupported operating system: {sys.platform}")
def parse(self) -> List[Dict]:
tree = ET.parse(self.db_path)
root = tree.getroot()
self.version = root.attrib.get("Version")
self.tracks = []
for song in root.findall("Song"):
track_info = {
"file_path": song.attrib.get("FilePath", ""),
"title": "",
"artist": "",
"bpm": None,
"key": "",
}
for child in song:
tag = child.tag.lower()
if tag == "tags":
track_info["title"] = child.attrib.get("Title", "")
track_info["artist"] = child.attrib.get("Author", "")
if child.attrib.get("Key"):
track_info["key"] = child.attrib.get("Key")
elif tag == "scan":
raw_bpm = child.attrib.get("Bpm")
if raw_bpm:
try:
val = float(raw_bpm)
if val > 0:
track_info["bpm"] = 60.0 / val if val < 10 else val
except ValueError:
track_info["bpm"] = None
if not track_info["key"] and child.attrib.get("Key"):
track_info["key"] = child.attrib.get("Key")
self.tracks.append(track_info)
return self.tracks
def parse_bpm_range(bpm_str: str) -> Tuple[Optional[float], Optional[float]]:
"""Parses BPM criteria (+128, -128, 120-132) into (min_bpm, max_bpm)."""
bpm_str = bpm_str.strip()
if bpm_str.startswith("+"):
return float(bpm_str[1:]), None
elif bpm_str.startswith("-"):
return None, float(bpm_str[1:])
elif "-" in bpm_str:
parts = bpm_str.split("-")
return float(parts[0]), float(parts[1])
else:
val = float(bpm_str)
return val, val
def filter_tracks(
tracks: List[Dict],
artist_regex: Optional[str] = None,
title_regex: Optional[str] = None,
bpm_range: Optional[str] = None,
keys: Optional[List[str]] = None,
) -> List[Dict]:
filtered = []
artist_pattern = re.compile(artist_regex, re.IGNORECASE) if artist_regex else None
title_pattern = re.compile(title_regex, re.IGNORECASE) if title_regex else None
min_bpm, max_bpm = parse_bpm_range(bpm_range) if bpm_range else (None, None)
key_set = {k.strip().lower() for k in keys} if keys else None
for track in tracks:
if artist_pattern and not artist_pattern.search(track["artist"]):
continue
if title_pattern and not title_pattern.search(track["title"]):
continue
if min_bpm is not None or max_bpm is not None:
track_bpm = track["bpm"]
if track_bpm is None:
continue
if min_bpm is not None and track_bpm < min_bpm:
continue
if max_bpm is not None and track_bpm > max_bpm:
continue
if key_set:
track_key = track["key"].lower()
if track_key not in key_set:
continue
filtered.append(track)
return filtered
def main():
parser = argparse.ArgumentParser(
description="Query and filter a VirtualDJ database.xml file."
)
parser.add_argument(
"--db",
type=str,
help="Path, drive letter (e.g. D:), or volume name for database.xml",
)
parser.add_argument(
"-a", "--artist",
type=str,
help="Regex filter for track artist",
)
parser.add_argument(
"-t", "--title",
type=str,
help="Regex filter for track title",
)
parser.add_argument(
"-b", "--bpm",
type=str,
help="BPM range: +128 (>=128), -128 (<=128), or 120-132 (range)",
)
parser.add_argument(
"-k", "--key",
type=str,
help="Comma-separated list of musical keys (e.g., 8A,8B,9A)",
)
parser.add_argument(
"-f", "--table-format",
type=str,
default="psql",
help="Output format for tabulate (default: psql)",
)
parser.add_argument(
"--list-table-formats",
action="store_true",
help="List available table formats and exit",
)
args = parser.parse_args()
if args.list_table_formats:
print("Available tabulate table formats:")
for fmt in tabulate_formats:
print(f" - {fmt}")
sys.exit(0)
try:
vdj_db = VirtualDJDatabase(db_path=args.db)
tracks = vdj_db.parse()
except Exception as err:
print(f"Error loading database: {err}", file=sys.stderr)
sys.exit(1)
key_list = [k.strip() for k in args.key.split(",")] if args.key else None
results = filter_tracks(
tracks,
artist_regex=args.artist,
title_regex=args.title,
bpm_range=args.bpm,
keys=key_list,
)
table_data = []
for t in results:
bpm_fmt = f"{t['bpm']:.3f}" if t["bpm"] is not None else ""
table_data.append([t["artist"], t["title"], bpm_fmt, t["key"], t["file_path"]])
headers = ["Artist", "Title", "BPM", "Key", "File Path"]
try:
print(tabulate(table_data, headers=headers, tablefmt=args.table_format))
except ValueError as e:
print(f"Format error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
Prompt
Write a Python CLI script using argparse to parse and query VirtualDJ database.xml files cross-platform (native Windows, macOS, and WSL).
Requirements
Database Resolution (VirtualDJDatabase Class)
-
Default Locations: If
--dbis omitted, auto-detect the default user database location: -
Windows: Check
%LOCALAPPDATA%\VirtualDJ\database.xml(VDJ 2023+), falling back to~/Documents/VirtualDJ/database.xml. -
macOS: Check
~/Library/Application Support/VirtualDJ/database.xml, falling back to~/Documents/VirtualDJ/database.xml. -
WSL: Detect WSL via
/proc/version. Querycmd.exe /c echo %USERNAME%to resolve the Windows user profile, then locate the database under/mnt/c/Users/<WinUser>/AppData/Local/VirtualDJ/database.xml(or legacyDocuments). -
Volume/Drive Specifiers: If
--dbis passed as a drive letter (e.g.,D:,e:) or volume name (e.g.,MyUsbStick), check mounted locations (/mnt/<drive>under WSL,D:\on Windows,/Volumes/<name>on macOS,/media/or/mnt/on Linux) forVirtualDJ/database.xmlordatabase.xmlbefore falling back to direct path evaluation.
XML Parsing & Data Extraction
- Parse
<Song>elements usingxml.etree.ElementTree. - Map the
<Tags>attributeAuthorto the track's artist field (track_info["artist"]), and readTitleandKey. - Read BPM from
<Scan Bpm="...">. VirtualDJ stores BPM as period duration (seconds per beat). Convert it to beats per minute ($\textBPM = 60 / \textval$) if the raw float value is $<10$. - Extract
FilePathfrom<Song>attributes.
Filtering (filter_tracks)
-a,--artist: Case-insensitive regex search against the artist name (Author).-t,--title: Case-insensitive regex search against track title.-b,--bpm: Support exact values (128), threshold filters (+128for $\ge 128$,-128for $\le 128$), and range filters (120-132for $120 \le \textBPM \le 132$).-k,--key: Comma-separated list of musical keys (e.g.,8A,8B,9A), evaluated case-insensitively.
CLI & Output Formatting
- Format query output using the
tabulatelibrary displaying Artist, Title, BPM (formatted to 3 decimal places), Key, and File Path. - Set the default
--table-formattopsql(-f/--table-formatto override). - Include a
--list-table-formatsflag that prints availabletabulateformat strings and exits cleanly. - Wrap database resolution/parsing errors in informative
sys.stderrerror exits.