☰ Dup Goto 📝

VirtualDjDatabaseXmlParser

DAW/dj/virtualdj 08-22 09:40:48
To Pop
380 lines, 1737 words, 17658 chars Saturday 2026-08-22 09:40:48

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)

XML Parsing & Data Extraction

Filtering (filter_tracks)

CLI & Output Formatting