Compare commits

...
13 Commits
Author SHA1 Message Date
Nathan Rashleigh f06d3aa0f9 py 2026-06-21 10:50:46 +10:00
Nathan Rashleigh 96d0331ff3 spot init 2026-06-21 10:16:36 +10:00
Nathan Rashleigh 9ccc3cea95 the downfall has begun 2026-06-09 18:46:25 +10:00
Nathan Rashleigh f71a2a3512 ghidra 2026-06-08 23:22:16 +10:00
Nathan Rashleigh 6a6553077b idk 2026-05-26 18:14:46 +10:00
Nathan Rashleigh 1e49010aa9 ts 2026-05-26 16:27:50 +10:00
Nathan Rashleigh 32adb5ddac kaitai py 2026-05-26 16:27:47 +10:00
Nathan Rashleigh 6a32066069 kaitai 2026-05-23 10:17:35 +10:00
Nathan Rashleigh f804c327de mut rgs 2025-09-27 09:35:26 +10:00
Nathan Rashleigh 690f92f565 errores 2025-09-25 08:58:09 +10:00
Nathan Rashleigh 076c60658d fix alignment of tables 2025-09-24 20:49:05 +10:00
Nathan Rashleigh 13ee0dcf01 errors 2025-09-24 16:33:01 +10:00
Nathan Rashleigh d35b51d4c3 errors 2025-09-23 22:42:18 +10:00
82 changed files with 21085 additions and 273 deletions

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
---
name: pdb-maple
description: Updates maple's pdb serializer (maple-py/src/maple/pdb/) to match decoded format rules from the kaitai schema + FORMAT_SPEC, then regenerates and diffs the AceMo pdb against rekordbox's. Use after pdb-re records a finding.
tools: Bash, Read, Edit, Write, Grep, Glob
model: inherit
---
You make maple's writer emit `export.pdb` byte-for-byte like rekordbox, using the
format rules the `pdb-re` agent records. You implement the writer; you do NOT
reverse-engineer the binary.
## Standing context
- maple writer: `maple-py/src/maple/pdb/{writer,pager,rows,strings}.py`, model in
`maple-py/src/maple/model.py`.
- Format truth (your inputs): `maple-py/src/kaitai/rekordbox_pdb.py` (layout/types)
and `maple-py/re/FORMAT_SPEC.md` (value-construction rules). Read both each run.
- Reference real export: `maple-py/data/acemo/PIONEER/rekordbox/export.pdb`.
- Regenerate maple's AceMo pdb (identical-content reconstruction):
```bash
cd maple-py && uv run python tools/build_acemo.py # -> captures/maple_acemo.pdb
```
- Already matched (don't regress): 20 tables; page_flags 0x24/0x34 per table
(`PAGE_FLAG_34` in writer.py); 2-pages-per-table layout (strange odd / data even,
overflow 41+, `CORE_TABLES`); keys row; empty history_playlists/entries.
## Your loop, per finding
1. Read the latest `FORMAT_SPEC.md` rule + any changed kaitai fields.
2. Implement it in the writer (`rows.py` for row fields, `pager.py`/`writer.py`
for page/layout). Match the rule exactly; if a field is a "stored, not
reproducible" value, set the closest faithful default and note it in a code
comment.
3. `uv run python tools/build_acemo.py`, then diff against the reference at three
levels and report what still differs:
- file header + per-table row counts + page-header flags/free/used
- raw row bytes (e.g. track id=1) byte-by-byte vs the real row
Reuse/extend the diff approach in `/tmp/diff_pdb.py` + `/tmp/rowdiff.py` (or
write equivalents). `uv run pytest -q` must still pass.
4. Return: what you changed, the new diff result (which bytes/fields still
differ), and what pdb-re needs to decode next (the first remaining mismatch).
Goal: ZERO diff between `captures/maple_acemo.pdb` and the reference. Do not
excuse ANY difference as harmless — every byte must be explained or matched:
- `sequence`: increment a real counter, don't hardcode 1. If you can't match the
reference's exact value, flag it for pdb-re to check what the firmware parser
does with it (it's rekordbox's internal transaction counter).
- page layout / `next_unused` / overflow-page placement: match it. If the
reference looks incrementally-grown (path-dependent gaps), flag that the
reference should be a FRESH from-scratch rekordbox export, not an incremental one.
- density / free / used: investigate, never assume harmless.
Report any remaining nonzero diff with a concrete explanation, not a dismissal.
+59
View File
@@ -0,0 +1,59 @@
---
name: pdb-re
description: Reverse-engineers rekordbox export.pdb serialization by driving Ghidra (headless) and records each decoded field/formula in the kaitai schema + the shared FORMAT_SPEC. Use to decode an unknown/wrong pdb field.
tools: Bash, Read, Edit, Write, Grep, Glob
model: inherit
---
You reverse-engineer how rekordbox writes `export.pdb` and record what you find
so the `pdb-maple` agent can reproduce it byte-for-byte. You decode the FORMAT
and the VALUE-CONSTRUCTION RULES; you do not touch maple's writer.
## Standing context (already established — do not rediscover)
- Target binary: `rekordbox.exe` (89MB, JUCE C++). `export.pdb` is written by its
bundled **DeviceSQL "Page Manager"** (`dsql*`/`edb_dyn_*`).
- Analyzed Ghidra project: `~/ghidra_proj` (project `rbx`, program `rekordbox.exe`),
already imported + analyzed. JDK at `~/jdk/jdk-21.0.11+10`. Ghidra at
`/mnt/c/Users/ntr/code/ghidra_12.1.2_PUBLIC_20260605/ghidra_12.1.2_PUBLIC`.
- Run a Ghidra script (no re-analysis):
```bash
export JAVA_HOME=~/jdk/jdk-21.0.11+10; export GHIDRA_HEADLESS_MAXMEM=10G
GH=/mnt/c/Users/ntr/code/ghidra_12.1.2_PUBLIC_20260605/ghidra_12.1.2_PUBLIC
"$GH/support/analyzeHeadless" ~/ghidra_proj rbx -process rekordbox.exe -noanalysis \
-scriptPath ~/ghidra_scripts -postScript DecPdb.java
```
Edit `~/ghidra_scripts/DecPdb.java`'s ADDRS to decompile any addresses; output
→ `~/ghidra_proj/decpdb.txt`. Other scripts: FindPdb, CallTree, FindHash. Copies
also live in `maple-py/re/scripts/`.
- Address rebasing: Ghidra static = `0x140000000 + module_offset`. Frida traces
print `rekordbox.exe+0xOFFSET` (the agent `maple-py/tools/rbx_pdb_capture.js`);
never hardcode the ASLR runtime base.
- The serializer call stack is in `~/ghidra_proj/decpdb.txt` / `re/findings/`.
- Reference real export: `maple-py/data/acemo/PIONEER/rekordbox/export.pdb` (6
tracks). Parse with the kaitai schema (`maple-py/src/kaitai/rekordbox_pdb.py`)
via `uv run` from `maple-py/`.
## Open unknowns (current priorities)
- track row `0x14` (u4, per-track, 28-bit) — varies per track, not plain crc32;
likely a stored `djmdContent` value or a seeded hash. `0x18`/`0x1a` = per-export
constant.
- track row `0x02` `index_shift` — positional (~0x20/row, resets per page); exact
formula unknown. maple writes 0.
## Your loop, per field
1. Pick the unknown/wrong field you were asked about.
2. Decompile the relevant serializer function(s) in Ghidra (DecPdb on the call
stack; use FindHash/CallTree to locate the builder). Read the C; if it's a
stored DB value with no runtime formula, SAY SO explicitly.
3. Cross-check against real values dumped from the reference pdb (raw row bytes).
4. Record the finding in BOTH:
- `maple-py/src/kaitai/rekordbox_pdb.py` — rename the field / fix its type if
the layout was wrong.
- `maple-py/re/FORMAT_SPEC.md` — the value-construction RULE in prose +
pseudocode (this is what pdb-maple implements). Create the file if missing.
5. Return a concise summary: field, offset, decoded meaning, the exact rule (or
"stored value, not reproducible"), and the Ghidra evidence (function @addr).
Be rigorous: prefer reading the decompiled code over guessing. If you can only
partially decode, state confidence and what's still unknown. Keep edits to the
kaitai schema minimal and correct — pdb-maple depends on them.
+6
View File
@@ -20,3 +20,9 @@ maple.sublime-workspace
*.secret.yaml
etc/sldl-macos.yaml
__pycache__/
*.py[cod]
.venv/
maple-py/captures
maple-py/node_modules
+14
View File
@@ -0,0 +1,14 @@
{
"mcpServers": {
"ghidra": {
"command": "uv",
"args": [
"run",
"--script",
"/mnt/c/Users/ntr/Downloads/GhidraMCP-release-1-4/GhidraMCP-release-1-4/bridge_mcp_ghidra.py",
"--ghidra-server",
"http://127.0.0.1:8080/"
]
}
}
}
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -1,7 +1,7 @@
path = "/data"
index-path = "/data/_metadata/index.sldl.csv"
username = "${SLDL_USER}"
password = "${SLDL_PASS}"
path = "${SOCKSEEK_DATA}"
index-path = "${SOCKSEEK_DATA}/_metadata/index.sldl.csv"
username = "${SOCKSEEK_USER}"
password = "${SOCKSEEK_PASS}"
name-format = "{artist(/)album(/)track( )title|(_missing_tags/)slsk-foldername(/)slsk-filename}"
+23
View File
@@ -28,9 +28,32 @@ dl trg:
link plist dir:
python3 maple-py/link.py {{plist}} {{dir}}
# Dump tracks from Spotify playlists (ids, urls, or spotify: uris)
spotify ids="7f5tPIfj21UJI4zfDmH4rx 6sAl8LhU6ediFNJRuQQIQX":
cd maple-py && uv run src/spotify.py {{ids}} --csv
# Download via sockseek (csv path, url, search string, ...) using sldl.tpl.conf settings
sockseek input:
cd maple-py && uv run src/sockseek.py {{input}}
sync args:
s5cmd {{args}}
usb dst="/Volumes/MAPLE":
COPYFILE_DISABLE=1 rsync -rtvh --delete --size-only --no-perms --no-owner --no-group --modify-window=2 maple-py/data/usb/Contents/ {{dst}}/Contents/
COPYFILE_DISABLE=1 rsync -rtvh --delete --size-only --no-perms --no-owner --no-group --modify-window=2 --exclude='USBANLZ/' maple-py/data/usb/PIONEER/ {{dst}}/PIONEER/
rm -f {{dst}}/PIONEER/rekordbox/RBFLTR.DAT
usb-pdb dst="/Volumes/MAPLE":
@test -d {{dst}}/PIONEER/rekordbox || (echo "no rekordbox dir at {{dst}}/PIONEER"; exit 1)
COPYFILE_DISABLE=1 cp maple-py/data/MAPLE/PIONEER/rekordbox/export.pdb {{dst}}/PIONEER/rekordbox/export.pdb
usb-reset dst="/Volumes/MAPLE":
@test -d {{dst}} || (echo "USB not mounted at {{dst}}"; exit 1)
rm -rf {{dst}}/Contents {{dst}}/PIONEER
COPYFILE_DISABLE=1 rsync -rtvh --no-perms --no-owner --no-group --modify-window=2 maple-py/data/MAPLE/Contents/ {{dst}}/Contents/
COPYFILE_DISABLE=1 rsync -rtvh --no-perms --no-owner --no-group --modify-window=2 maple-py/data/MAPLE/PIONEER/ {{dst}}/PIONEER/
maple-setup:
bash ./etc/maple_setup.sh
+1
View File
@@ -0,0 +1 @@
3.14
+112
View File
@@ -0,0 +1,112 @@
# maple-py — rekordbox `export.pdb` writer + reverse-engineering notes
Python tooling to generate a rekordbox USB export (`PIONEER/rekordbox/export.pdb`
+ `exportExt.pdb` + `USBANLZ` analysis files) so a CDJ/XDJ can read a
self-assembled music library. The writer lives in `src/maple/pdb/`.
This README also documents what we learned reverse-engineering rekordbox's own
exporter, because the on-disk format has real unknowns / magic numbers that the
public kaitai schema (`src/kaitai/rekordbox_pdb.py`) only partially decodes.
## The format, briefly
`export.pdb` is a **DeviceSQL** database: a flat file of 4096-byte **pages**,
grouped into 20 **tables** (tracks, artists, albums, playlists, artwork,
colors, columns, history, and several still-unknown ones). Page 0 is the file
header + table directory; each table is a linked list of pages. See
`src/maple/pdb/{writer,pager,rows,strings}.py`.
## Reverse-engineering findings (rekordbox 7.0.5, Windows)
### Where the export code lives
- The exporter is in **`rekordbox.exe`** (the 89 MB main binary), **not**
`rekordboxAgent.exe`. Confirmed by string scan: only `rekordbox.exe` contains
`USBANLZ`, `PIONEER`, `djmd…` table names, `.pdb`. It's a JUCE C++ app.
- `export.pdb` is written by rekordbox's **bundled DeviceSQL "Page Manager"**
engine — functions prefixed `dsql*` / `edb_dyn_*` (error strings:
`dsqlOpenDeviceDB`, `edb_dyn_CriticalSectionOn ERROR`,
`Page Manager (Close/)Open returns ERROR`).
- **Key consequence:** the page-level "magic numbers" maple guesses
(page flags `0x64`/`0x44`, `0x1fff` markers, `0x3ec`, free/used sizes) are
**DeviceSQL internals**, not rekordbox-specific. They are NOT immediate
constants in the code — they're produced by the page-manager logic, so they
must be read out of the `dsql*`/`edb_dyn_*` functions, not grep'd.
### A rekordbox USB export writes three databases
| file | format | writer |
|------|--------|--------|
| `export.pdb` | DeviceSQL (CDJ reads this) | DeviceSQL Page Manager |
| `exportExt.pdb` | DeviceSQL extended | same |
| `exportLibrary.db` | **SQLite** (rekordbox's own) | bundled `sqlite3_*` |
| `USBANLZ/**/ANLZ*.DAT/.EXT/.2EX` | per-track waveform/beatgrid | main app |
### `export.pdb` serializer call stack (live `WriteFile` backtrace → Ghidra)
Ghidra static address = `0x140000000 + module_offset` (Ghidra image base is
`0x140000000`; the ASLR runtime base varies per launch — never hardcode it,
read `module+offset` from Frida).
```
0x1420e8804 calls WriteFile ─┐
0x1420d7358 │ DeviceSQL write plumbing
0x1420d6e74 │ (transactions, page I/O)
0x1420d62a4 │
0x1420eac94 │
0x1420e07d8 │
0x1420de140 ─┘
FUN_141d75c20 ← dsqlOpenDeviceDB / Page Manager (export.pdb-specific)
0x141cad100
0x141cac390
0x141b8b4c0
0x141aca850
0x1423c9ab0 ← outer export driver
```
Other anchors found by string xref: `FUN_14119d2d0` (export orchestrator,
builds `/PIONEER/rekordbox/export.pdb`, walks "Contents"), `FUN_140f37e50`
(builds `PIONEER`/`USBANLZ` paths).
### Gotchas
- **Incremental writes:** rekordbox rewrites only the *changed* pages (seeked
`WriteFile`s), so a Frida capture is partial. The complete file is on the USB
(`F:\PIONEER\rekordbox\export.pdb`) — copy it from there for a full diff.
- **CIG / injection:** `rekordboxAgent.exe` refuses live Frida injection
(Code Integrity Guard). The pdb writer (main `rekordbox.exe`) is hookable, but
only via **child-gating at spawn** — Windows has **no spawn-gating**.
## Tooling
### Static analysis (Ghidra, headless, in WSL)
- JDK 21 (`~/jdk/jdk-21.0.11+10`), Ghidra 12.1.2. Ghidra rejects JDK 25 — use 21.
- Analyzed project: `~/ghidra_proj` (project `rbx`, program `rekordbox.exe`).
Full analysis ≈ 1 h with `GHIDRA_HEADLESS_MAXMEM=10G -max-cpu 8`.
- Re-run a script without re-analyzing:
```bash
"$GHIDRA/support/analyzeHeadless" ~/ghidra_proj rbx -process rekordbox.exe \
-noanalysis -scriptPath ~/ghidra_scripts -postScript FindPdb.java
```
Scripts: `~/ghidra_scripts/FindPdb.java` (string+constant xref search),
`DecPdb.java` (decompile an address list), `CallTree.java` (call-tree drill).
### Dynamic capture (Frida, on Windows)
- `tools/rbx_pdb_capture.js` — Frida agent. Hooks `NtCreateFile`/`NtWriteFile`/
`NtClose` + `NtCreateSection`/`NtMapViewOfSection` (memory-mapped writes).
Reconstructs files under `PIONEER`/`*.pdb` to `captures/`, prints each write's
backtrace as `rekordbox.exe+0xOFFSET` (→ Ghidra `0x140000000 + offset`).
- `tools/capture.mjs` — Node host (`npm i frida`). Attaches to all rekordbox
processes, streams `[pid]`-tagged output live, stays alive, **child-gates** so
the spawn writer is hooked at birth. Clears `captures/` on start.
```powershell
npm i frida # once
node tools\capture.mjs --spawn # launch rekordbox under the gate, then Export to USB
```
## Diffing maple's output vs rekordbox
`compare_pdb.py` parses two pdbs with the kaitai schema and summarises tables/
rows. Reference real exports: `data/PIONEER/rekordbox/export.pdb`,
`data/acemo/PIONEER/rekordbox/export.pdb`.
## Status / next steps
- Writer fully located; capture + diff pipeline works.
- To decode a specific page magic number: search the `dsql*`/`edb_dyn_*`
functions (image region `0x142000000+`) for the page-header builder.
- To fix a rejected export: diff a complete USB `export.pdb` against maple's
output field-by-field; resolve any wrong value via the matching `dsql` function.
+57
View File
@@ -0,0 +1,57 @@
"""Parse two .pdb files with the kaitai schema and summarise differences."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / "src"))
from io import BytesIO
from kaitaistruct import KaitaiStream
from kaitai.rekordbox_pdb import RekordboxPdb
PAGE_TYPE_NAMES = {t.value: t.name for t in RekordboxPdb.PageType}
def summarise(path: Path):
data = path.read_bytes()
pdb = RekordboxPdb(is_ext=False, _io=KaitaiStream(BytesIO(data)))
print(f"\n=== {path} ({path.stat().st_size} bytes) ===")
print(f"page_size={pdb.len_page} num_tables={pdb.num_tables} next_unused_page={pdb.next_unused_page} sequence={pdb.sequence}")
for tbl in pdb.tables:
ptype = PAGE_TYPE_NAMES.get(tbl.type.value, str(tbl.type))
page_count = 0
row_count = 0
err = None
try:
ref = tbl.first_page
while True:
page = ref.body
page_count += 1
if page.is_data_page:
for rg in page.row_groups:
for rpw in rg.rows:
if rpw.present:
row_count += 1
if ref.index == tbl.last_page.index:
break
ref = page.next_page
if page_count > 5000:
break
except Exception as e:
err = f"{type(e).__name__}: {e}"
line = f" {ptype:24s} first={tbl.first_page.index:>4} last={tbl.last_page.index:>4} pages={page_count:>3} rows={row_count}"
if err:
line += f" ERR={err}"
print(line)
return pdb
def main():
base = Path(__file__).parent
ref = base / "data/MAPLE/PIONEER/rekordbox/export.pdb"
ours = base / "data/usb/PIONEER/rekordbox/export.pdb"
summarise(ref)
summarise(ours)
if __name__ == "__main__":
main()
Generated Executable
+501
View File
@@ -0,0 +1,501 @@
{
"name": "maple-py",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"frida": "^17.11.0"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/frida": {
"version": "17.11.0",
"resolved": "https://registry.npmjs.org/frida/-/frida-17.11.0.tgz",
"integrity": "sha512-ZymNV1mr8lWMP2vo1Ed4D7w1/8VQRVeoZ439sCZN/vKjj/E0UhfQP4BYsRAUrCQZ4nBzfRNN4BouA+WvOZ1z5w==",
"hasInstallScript": true,
"license": "LGPL-2.0 WITH WxWindows-exception-3.1",
"dependencies": {
"bindings": "^1.5.0",
"minimatch": "^10.0.1",
"prebuild-install": "^7.1.3"
},
"engines": {
"node": ">=16"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/node-abi": {
"version": "3.92.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
"integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
"integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"dependencies": {
"frida": "^17.11.0"
}
}
+27
View File
@@ -0,0 +1,27 @@
[project]
name = "maple"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Nathan Rashleigh", email = "nathan.rashleigh@switchdin.com" }
]
requires-python = ">=3.14"
dependencies = [
"kaitaistruct>=0.11",
"mutagen>=1.47.0",
"pillow>=12.2.0",
"pyrekordbox>=0.4.4",
]
[project.scripts]
maple = "maple.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[dependency-groups]
dev = [
"pytest>=9.0.3",
]
+609
View File
@@ -0,0 +1,609 @@
# export.pdb value-construction rules (RE findings)
This file records the exact rules rekordbox uses to populate `export.pdb`
fields that maple currently writes wrong or as zero. Each section is a
self-contained spec for the `maple` writer to implement. Evidence is from
reverse-engineering `rekordbox.exe` (DeviceSQL "Page Manager", `dsql*` /
`edb_dyn_*`) in Ghidra plus byte-level analysis of the reference export
`maple-py/data/acemo/PIONEER/rekordbox/export.pdb` (6 tracks, 1 album,
1 artist, 1 key, 6 artwork).
Reference-pdb facts used throughout:
- `len_page` = 4096.
- Row index grows backward from end of page; each group spans 36 (0x24) bytes:
`group_base = len_page - group_index*36`. Within a group, `present_flags`
(u2) live at `group_base-4`; the row offset for slot `i` (0..15) is the u2 at
`group_base-(6+2*i)`; offsets are relative to the heap start (0x28, i.e. end
of the 0x28-byte page header).
---
## 1. Track row `index_shift` (offset 0x02, u2) — SOLVED
**Field:** `TrackRow.index_shift` (also present on artist/album/genre/label/
key rows — every row type whose layout begins `subtype:u2, index_shift:u2`).
**Rule:**
```
index_shift = slot_index * 0x20 # i.e. slot_index << 5
```
where `slot_index` is the row's **0-based position in the page row index**
(`group_index*16 + slot_within_group`), NOT its ordinal among present rows and
NOT its position in the heap. It resets to 0 at the start of every data page.
Key consequences for the writer:
- The first row index slot (slot 0) gets `index_shift = 0`.
- Deleted/absent slots still consume a slot number, so a gap in
`present_flags` produces a gap in `index_shift`. (See evidence: the AceMo
page has slot 5 deleted, so the row in slot 6 has `index_shift = 0xc0`,
skipping `0xa0`.)
- `index_shift` is purely a function of the row's slot, so the value is fully
reproducible from the page layout maple already controls — there is no
external/runtime input. If maple writes each track to slot `i`, set
`index_shift = i*0x20`.
**Evidence:**
- Reference AceMo `tracks` page (page 2): present slots are 1,2,3,4,6 (slots 0
and 5 are deleted), with `index_shift` = 0x20,0x40,0x60,0x80,0xc0
respectively. Overflow page (page 48) has one row in slot 1 →
`index_shift = 0x20`. The 0x80→0xc0 jump across the deleted slot 5 proves the
value tracks `slot_index`, not present-count.
- Cross-checked against `artists` (page 5) and `albums` (page 7) rows: every
row satisfies `index_shift == slot_index*0x20` with **0 mismatches** over all
present rows in the reference file
(`maple-py/re/scripts`-style raw parse, 8 rows checked, see RE notes).
- Ghidra: the value is assigned inside the DeviceSQL Page Manager record-insert
path, which is reached only through vtable dispatch
(`(**(code**)(*obj + 0x4d0))(obj,...)` etc. in `FUN_141b8b4c0` /
`FUN_141cac390`), so there is no single statically-anchored
`index_shift = slot<<5` instruction to cite; a constant-scan for `shl ,5`
paired with the row-index masks (0x1fff/0x7ff) and stride 0x24 produced only
CRT/`printf("%a")` false positives (`FUN_14232a86c`). The empirical rule
above is exact and zero-mismatch, so confidence is **high** on the formula;
confidence on "no hidden runtime input" is high because the value is fully
explained by slot position alone.
**Confidence:** high.
---
## 2. Track row 0x14 / 0x18 / 0x1a (opaque id blob) — SOLVED (stored value, not a formula)
**Fields:** `TrackRow.opaque_track_id_lo` (u4 @0x14), `TrackRow.opaque_seed_lo`
(u2 @0x18), `TrackRow.opaque_seed_hi` (u2 @0x1a). Together these are one
8-byte little-endian value:
```
blob_u64 = opaque_track_id_lo | (opaque_seed << 32)
opaque_seed = opaque_seed_lo | (opaque_seed_hi << 16) # u4 at offset 0x18
```
(@flesniak's schema labels these "Some ID? unknown", "always 19048?",
"always 30967?". The "19048"/"30967" = 0x4A68/0x78F7 are only correct for some
older exports; they are NOT universal — see below.)
**What they are (proven empirically across 5 GENUINE rekordbox exports):**
> NOTE (2026-06-09 correction): a prior draft included a 5th row
> "usb (176), seq=1, seed 0x78f74a68, lo all 0" and concluded rekordbox writes
> lo=0 / seed 0x78f74a68 for unanalyzed tracks. **That file
> (`data/usb/.../export.pdb`) is maple's OWN output, not a rekordbox export**
> (it is the compare_pdb.py "ours" path). Reading it back was circular; that
> claim is WRONG and has been dropped. Provenance rule: maple output = seed
> `0x78f74a68` + ALL per-track lo == 0; genuine rekordbox = per-install nonzero
> seed + nonzero per-track lo.
| export (tracks) | header.seq | opaque_seed | opaque_track_id_lo |
|----------------------------|-----------:|---------------|-------------------------------|
| acemo (6) | 353 | `0x21b4a7ab` | 6/6 nonzero |
| acemo_fresh (7, same lib) | 35 | `0x21b4a7ab` | 7/7 nonzero |
| lib_multi (10, same lib) | 56 | `0x21b4a7ab` | 10/10 nonzero |
| MAPLE (176, install B) | 546 | `0xd6d3a567` | 176/176 nonzero |
| data/PIONEER (847, inst. B)| 4211 | `0xd6d3a567` | 847/847 nonzero |
Across **all 1046 real tracks in 5 genuine exports: 100% nonzero per-track lo,
exactly one nonzero per-install seed per file. There is NO evidence rekordbox
ever writes lo=0 or seed `0x78f74a68`.** The lo values span nearly the full
28-bit space (`0x0003edeb`..`0x0ffe7f4e`).
Findings:
1. **`opaque_seed` is constant within a single export** (exactly one value per
file) and is **per-rekordbox-library/install**: acemo/acemo_fresh/lib_multi
are three exports of the same library and all share `0x21b4a7ab`; the separate
install behind MAPLE + data/PIONEER uses `0xd6d3a567`. (The old maple hardcode
`0xa567`/`0xd6d3` = `0xd6d3a567` was correct only for install B.) The seed is
NOT a binary constant (proven: no `0xa7ab`/`0x21b4`/`0xd6d3`/`0xa567`
immediate or data-byte sequence in rekordbox.exe), so it is derived at runtime
from the per-library DB (most likely a hash/truncation of the master.db's
per-library UUID or creation marker). maple cannot reproduce a specific
install's seed (see recommendation).
2. **`opaque_track_id_lo` is a stable per-track value within a library.** Every
track that appears in both the acemo and acemo_fresh exports has an
**identical** lo (matched by title): "All My Life"→`0x07ff5a56`,
"Smooth Move"→`0x0a20de34`, "Jelly Fish Jam"→`0x0f8fd598`, etc. It is NOT
per-export-random.
3. **It is NOT derivable from the track's file metadata.** Tested and ruled out
(no match, 28-bit and full, utf-8/utf-16, several hash funcs): crc32 / adler32
/ md5 / sha1 / fnv1 / fnv1a / djb2 of the title, filename, full
`/Contents/...` path, and crc32 of the ANLZ `.DAT`/`.EXT` and the `.mp3`
bytes. Also not the USBANLZ folder id, not sequential, not evenly spaced.
4. **The values are runtime-derived, not binary constants.** A full
instruction-immediate scan of `rekordbox.exe` found NO occurrence of either
real seed (`0x21b4a7ab`, `0xd6d3a567`), the per-track lo values, or the
track-row `bitmask` constant `0xc0700`. So these come from rekordbox's
internal database (master.db / `djmdContent` + a per-library seed), copied
into the row by the DeviceSQL record builder (vtable-dispatched, not
statically anchorable). (`0x78f74a68` does not appear either — and per the
correction above it was never a real rekordbox value here, just maple's
placeholder.)
**Round-2 evidence (2026-06-09, after team-lead supplied acemo_fresh and asked
to chase an audio-content hash):**
5. **Audio-content-hash hypothesis: DISPROVEN.** Hashed the actual `.mp3`
bytes in 8 framings — full file, ID3v2-stripped (audio frames only),
first 1k/2k/4k/64k, last 64k, ID3-stripped-first-64k — each with crc32,
adler32, md5 (lo/hi, LE+BE), sha1 (LE+BE), sha256, fnv1a, both full-32 and
masked-to-28-bit. NONE matched any track's `opaque_track_id_lo`. So it is
not a hash of the audio file content (at least not of the file bytes; a hash
of decoded PCM cannot be ruled out but there is no evidence for it and it is
not reproducible anyway).
6. **Source DB confirmed, and it is ENCRYPTED.** Both `exportLibrary.db` (next
to the pdb) and the captured `master.db`
(`maple-py/captures/p17328_master.db`) fail to open as plain SQLite
("file is not a database") — rekordbox 6/7 encrypts them with SQLCipher.
The `opaque_track_id_lo` originates there; it cannot be read without the key.
7. **The value is a pure stored field, used nowhere else.** It appears in the
export.pdb ONLY at each track row's own offset 0x14 (present and stale/deleted
rows alike); it does NOT appear in exportExt.pdb (no tag_tracks/ContentID
link), nor in the ANLZ `.DAT`/`.EXT` (searched LE+BE), nor does it equal the
USBANLZ folder id. So it is not a foreign key the player resolves.
8. **Range note (suggestive, not conclusive):** across the 5 real exports the
lo values span nearly the full 28-bit space (`0x0003edeb`..`0x0ffe7f4e`),
the band of rekordbox-6/7 9-digit `djmdContent.ID` integers, so the most
likely identity is "the rekordbox internal Content ID" — a stored
random/sequential DB id, NOT a content-derived hash. Consistent with every
other finding and with it being unreadable (encrypted DB).
**Conclusion:** this 8-byte blob is a **stored rekordbox-internal id (most
likely the encrypted-DB Content ID), not a function of the track metadata or
audio bytes maple has. It cannot be reproduced byte-for-byte** for an arbitrary
track without rekordbox's own (encrypted) DB. The audio-content-hash idea is
ruled out. There is **no "unanalyzed/zero" form** in any real export — every
real track carries a nonzero seed and a nonzero lo.
**Recommendation for maple's writer:**
- Players/crate-digger treat this blob as opaque (never decoded) and play fine
regardless — so any value works for playback.
- For maximum faithfulness to genuine rekordbox output (which never has
lo=0/seed `0x78f74a68`), maple should write a **nonzero per-install seed and a
nonzero, stable per-track lo**:
- `opaque_seed`: use a real per-install seed. If maple has no install of its
own, `0x21b4a7ab` (this RE machine's install) is a known-real value;
keep it constant across all tracks in one export. Do NOT use `0x78f74a68`
(that was maple's own placeholder, not a rekordbox value).
- `opaque_track_id_lo`: a nonzero 28-bit value, stable per track. Since the
real value is an opaque DB id maple can't recover, a deterministic stand-in
(e.g. a 28-bit hash of a stable per-track key, or the track's own pdb id
offset into the ContentID band) is fine — the goal is "looks like a real
nonzero ContentID", not byte-parity.
- Either way these 8 bytes will NOT match a specific reference export
byte-for-byte (the real ids live in that install's encrypted DB) — record the
divergence in re/KNOWN_DIVERGENCE.md.
**Confidence:** high (5-export empirical proof: 1046/1046 real tracks nonzero;
exhaustive negative hash search incl. audio bytes; binary const-absence). The
earlier "write 0 / 0x78f74a68" advice was based on a maple-output file and is
retracted.
**Round-3 (task #10): can pyrekordbox read the real ids out of master.db?**
User insight: pyrekordbox holds the SQLCipher key, so the DBs are not truly
unreadable. Validated — partly true, but blocked on the data we have:
- pyrekordbox 0.4.4 + `sqlcipher3-wheels` installs and wires up correctly. Its
master.db key is the single **global** rekordbox-6 key
`402fd482c38817c35ffa8ffb8c7d93143b749e7d315df7a81732a1ff43608497`
(`deobfuscate(BLOB)`, must start with `402fd`) — same for every install, NOT
per-install. So a VALID master.db *would* open and expose `DjmdContent`.
- BUT the captured `maple-py/captures/p17328_master.db` is **corrupt/truncated**:
1,024,000 bytes but only 4.4% non-zero, and the first 32 KB (the mandatory
SQLCipher salt + page 1) is **all zeros**. No key can decrypt a DB with a
zeroed salt. `p17328_master.backup.db` is 0 bytes. The Frida NtWriteFile
capture only caught scattered later pages, never the header. → unusable.
- The genuine `exportLibrary.db` shipped next to export.pdb (valid 16-byte salt
`ed0d3a3c…`) does NOT open with the global master.db key (different key
scheme) and pyrekordbox has **no exportLibrary.db support**. → can't read it
here.
- Strong inference (untested for lack of a readable DB): the per-track
`opaque_track_id_lo` is almost certainly `DjmdContent.ID` (the 9-digit
rekordbox Content ID; our lo range 0x0003edeb..0x0ffe7f4e fits). If maple can
open the user's LIVE `%APPDATA%/Pioneer/rekordbox6/master.db` with pyrekordbox
(global key), `db.get_content()` would give `(ID, …)` per track, and the blob
would become EXACTLY reproducible: `opaque_track_id_lo = DjmdContent.ID & 0xFFFFFFFF`.
- The per-install `opaque_seed` (0x21b4a7ab here) is NOT a DjmdContent column;
it is one value for the whole export and most likely derived from a
master.db-level per-library marker (a USB/library UUID or an `agentRegistry`/
`local_usn`-style value). Identifying it needs a readable master.db to inspect
candidate columns; unconfirmed.
**pyrekordbox write / export capability (asked in #10):**
- It CAN write the desktop master.db: `add_content`/`add_album`/`add_artist`/
`create_playlist`/`commit`, plus `set_local_usn`, `update_content_path`. But
`add_content` lets rekordbox ASSIGN the Content ID; there is no API to force a
specific ID. So it is a source of real ids, not a way to dictate them.
- It does NOT drive a USB export — only `get_device` (read). The actual
`export.pdb`/`exportLibrary.db` writing is rekordbox.exe's DeviceSQL engine
(the thing maple reimplements). pyrekordbox manipulates only the desktop
library DB, not the USB export. So pyrekordbox cannot replace maple's writer;
its only use to maple is as a READ source of the real `DjmdContent.ID` (and
potentially the seed) from a valid master.db.
**Net for #10:** the approach is sound (pyrekordbox + global key reads a real
master.db → Content IDs), so `opaque_track_id_lo` is likely reproducible *if
maple reads the live master.db*. It is NOT reproducible from the corrupt
captured DB we have, and the seed source is still unidentified. pyrekordbox
cannot produce the USB pdb itself. Recommended next step for maple/RE: point
pyrekordbox at the user's LIVE rekordbox6 master.db
(`%APPDATA%/Pioneer/rekordbox6/master.db`), confirm
`DjmdContent.ID == opaque_track_id_lo` for the AceMo tracks, then hunt the seed
in that DB. Until then, the nonzero stand-in (above) stays.
---
## 3. File-header `sequence` (and per-page `sequence`)
**Field:** `RekordboxPdb.sequence` (u4 @0x14 of the file header) and
`Page.sequence` (u4 in each page header).
**Rule / meaning (confirmed from schema docs + the 5 genuine exports above):**
- `sequence` is a monotonically increasing **edit counter** for the database.
The file-header value is the **next** page-sequence number; each page records
the counter value as of its last edit (so `header.sequence >=
max(page.sequence)`). Observed header values in genuine rekordbox exports:
35, 56, 353, 546, 4211 — scales with edit history, not track count. (No real
export with sequence==1 is available; `data/usb`'s seq==1 is maple's own
output, not a rekordbox sample — see §2 correction.)
- The firmware/players (and crate-digger) use page `sequence` only as
informational ordering metadata; **they do not validate it** for playback.
No special value is required beyond keeping `header.sequence` greater than any
page's `sequence`.
- For maple it is enough to (a) pick a header `sequence` larger than every page
`sequence` it writes, and (b) give each page a `sequence` <= header. A simple
scheme (all pages = N, header = N+1) is accepted. This is what task #3
(incrementing sequence) implements.
**Confidence:** high for semantics and "not validated by players"; the exact
increment rekordbox uses per edit is not needed for a valid export.
---
## 4. File-header offset 0x10 (u4, schema `_unnamed4`) — HISTORY-DEPENDENT; not byte-reproducible
> CORRECTION (2026-06-09): a prior draft cited a "usb fresh, sequence==1,
> _unnamed4==0" row as proof that 0 is the fresh value. That file is maple's own
> output, not a rekordbox export, so it is removed below. The remaining proof
> (same-library divergence) does not depend on it.
**Field:** the unnamed u4 between `next_unused_page` (0x0c) and `sequence`
(0x14). maple writes 0; the AceMo ref has 4.
**Finding (5 genuine rekordbox exports):** an **edit/allocator accumulator**,
not a structural value derivable from the tables. It does NOT equal track count,
page count, or `next_unused_page - npages`:
| export | sequence | _unnamed4@0x10 | next_unused | npages |
|---------------|---------:|---------------:|------------:|-------:|
| acemo_fresh | 35 | 5 | 52 | 49 |
| lib_multi | 56 | 4 | 57 | 52 |
| acemo | 353 | 4 | 53 | 49 |
| MAPLE | 546 | 5 | — | — |
| data/PIONEER | 4211 | 1 | 174 | 174 |
**Decisive (same-library proof):** acemo and acemo_fresh are the SAME library
(identical track set + identical per-track opaque ids) yet have `_unnamed4` =
4 vs 5. A structural field would be identical; differing proves it is edit
state. (We have NO genuine rekordbox export with `sequence==1`, so the exact
never-edited value is unconfirmed — but it is clearly not derivable from the
file's structure.)
**Rule for maple:** there is no reproducible rule. Write **0** (maple already
does) as a **reasonable minimal default — NOT a confirmed match** (every genuine
export we have is nonzero here, and we have no never-edited rekordbox export to
check what fresh rekordbox writes). Record the divergence in
re/KNOWN_DIVERGENCE.md. It cannot be matched to the AceMo ref's `4` without that
DB's edit history.
**Confidence:** high that it is edit-history/allocator state and not
structurally reproducible; the specific fresh-write value is unconfirmed (no
seq==1 rekordbox sample available).
---
## 5. Page-header offset 0x26 (u2, "unknown7"/strange-page) — HISTORY-DEPENDENT; maple's 0 is a reasonable default
> CORRECTION (2026-06-09): do NOT cite `data/usb` (sequence==1) here — it is
> maple's OWN output, so "maple's 0 matches usb" would be circular. The
> observations below are from the five GENUINE rekordbox exports only.
**Field:** the second of the two trailing u2s in the page header (after
`transaction_row_index` @0x22 and the "1004 for strange blocks" u2 @0x24).
@flesniak: "always 0 except 1 for history pages, num entries for strange
pages?". maple writes 0; the AceMo ref has 0/1/2 on various tables' first
("strange") pages.
**Finding (5 genuine rekordbox exports — all incrementally edited):**
- On DATA pages it is **always 0** (all five exports). This is a solid
observation about genuine rekordbox output.
- It is nonzero only on a table's first/"strange" page (flags 0x64), and the
value does NOT correlate with that table's data-page count or row count
(acemo tracks: 2 data pages → 2; data/PIONEER tracks: 105 data pages → 23).
It is transaction/edit-tracking state.
- **Decisive (same-library proof):** acemo and acemo_fresh hold the identical
tracks table, yet its strange-page unknown7 = 2 (acemo) vs 1 (acemo_fresh).
Same data → different value ⇒ not structural, it is edit state. (lib_multi:
tracks strange unk7 = 2 with 3 data pages / 10 rows — again no count relation.)
**Rule for maple:** write **0** on every page (maple already does). The data
pages being 0 across all five genuine exports makes 0 a well-supported value
*for data pages*. For strange pages the real value is per-export edit state
(0/1/2…) that cannot be reproduced — maple's 0 there is a **reasonable minimal
default, NOT a confirmed match** (we have no never-edited rekordbox export to
check what fresh rekordbox stamps on a strange page). Record the strange-page
divergence in re/KNOWN_DIVERGENCE.md.
**Confidence:** high that it is history-dependent and that data-page 0 matches
real rekordbox; the fresh-write strange-page value is unconfirmed (no seq==1
rekordbox sample).
---
## 6. Row-group trailing u2 (`transaction_row_flags`) — write `present_flags` (NOT 16); residual mismatch is edit history
> CORRECTION (2026-06-09): an earlier draft of this section claimed "a fresh
> export writes 16 everywhere, so maple's 16 is correct." That was based on
> `data/usb/.../export.pdb`, which on re-check is **maple's OWN output**, not a
> rekordbox export (opaque seed `0x78f74a68` + all-zero per-track lo +
> `sequence`==low). Reading it back was circular. The analysis below uses ONLY
> the five genuine rekordbox exports (provenance verified by nonzero per-track
> opaque ids + per-install seed): acemo, acemo_fresh, lib_multi, MAPLE,
> data/PIONEER.
**Field:** the last u2 of each 36-byte row-group block in the page row index
(crate-digger `transaction_row_flags`, at `base`; in maple's `pager.py` it is
`pack("<H", 16) # unknown (rekordcrate default)`).
**Finding (5 genuine rekordbox exports):** this u2 is the
**"rows touched by the last transaction" bitmask** (same bit layout as
`row_present_flags`), and is therefore **edit-history state, not reproducible**:
- **No genuine rekordbox export writes 16 as the field's value** on settled
pages (count of groups == 16 across the five exports: 0, 0, 0, 1, 8 — and
those few are coincidental transaction bitmasks, not a default). So maple's
constant **16 does NOT match rekordbox** — it is a rekordcrate invention.
- The dominant value in heavily-edited tables is **0x0** (last transaction
touched no rows on that page), e.g. data/PIONEER 170/294 groups = 0x0.
- Three DIFFERENT patterns coexist in ONE file, proving it is not structural:
(a) some pages `trailing == present_flags` (columns/unk17/unk18); (b) artwork
pages consistently use `last group = 1<<(rows_in_last_group-1)`, earlier groups
= 0 (data/PIONEER p28 g6 10rows→0x200, p147 g2 13rows→0x1000; acemo_fresh
artwork 7rows→0x40); (c) churned tracks pages use an arbitrary single bit
(0x1/0x2/0x4/0x8/0x10/0x40…) = whatever row that page's last edit touched.
A candidate clean rule `[last grp=1<<(rows-1), others=0]` matches only
**181/257** fully-present pages in data/PIONEER — confirming NO single formula.
- **Same-library proof:** acemo and acemo_fresh hold identical data, yet the
tracks page-2 group differs (acemo present=0x5e trailing=0x20; acemo_fresh
present=0xbb trailing=0x4) — same rows, different value ⇒ not structural.
The previously-flagged artwork `0x40` is likewise transaction state
(acemo_fresh artwork=0x40 vs lib_multi artwork p49=0x4), not an open
reproducible byte.
**Rule for maple (DECISION): write `present_flags`, NOT the constant 16.**
maple's old constant 16 (rekordcrate's placeholder) matches **0/14** real groups;
`trailing = row_present_flags` matches **12/14** clean groups in the real
`data/acemo_fresh` export (8 rows→0xff, 16→0xffff, 11→0x7ff, 6→0x3f, 1→0x1). The
2 misses (page2→0x4, page28→0x40, page40→0xc0) are the last-transaction bitmask
on pages that were edited after creation — i.e. residual edit history, not a
different rule. So for a from-scratch single-transaction write the correct value
IS `present_flags` (all rows touched in the one creating transaction). maple now
ships `present_flags` (pager.py) — this is the best achievable; reverting to 16
would regress 12 groups. Remaining mismatch vs an EDITED reference (the per-page
last-tx bitmask) is unavoidable edit history → KNOWN_DIVERGENCE.
> NOTE: the earlier "keep 16 / fresh usb writes 0x10 everywhere" framing was
> WRONG — it read `data/usb`, which is MAPLE'S OWN OUTPUT (an old build that
> emitted the rekordcrate-default 16), not a real export. `data/usb` must NOT be
> used as a 'fresh rekordbox' baseline for ANY field (same contamination that
> produced the wrong opaque-blob default in §2). The fresh-write value is
> established from the REAL `data/acemo_fresh` as `present_flags`.
**Confidence:** high that it is history-dependent and that real rekordbox never
uses 16; the correct fresh-write value is an open question (no seq==1 sample).
---
## Note on the three "reproducible header fields" (task #9)
After multi-export analysis across the FIVE genuine rekordbox exports (acemo,
acemo_fresh, lib_multi, MAPLE, data/PIONEER — provenance verified by nonzero
per-track opaque ids + per-install seed), all three fields are
**edit-history / transaction state, NOT structurally reproducible**:
- §4 file-header 0x10: allocator/edit accumulator. → write 0, DIVERGENCE.
- §5 page 0x26 unknown7: 0 on all data pages (exact); per-table 0/1/2 on strange
pages is edit state. → write 0, strange-page DIVERGENCE.
- §6 row-group trailing u2: "rows touched by last transaction" bitmask. Fresh
single-transaction value = `present_flags` (12/14 in real data/acemo_fresh;
constant 16 = 0/14). → maple writes `present_flags`; residual mismatch vs an
EDITED ref is the per-page last-tx bitmask = edit history, DIVERGENCE.
The decisive evidence is the **same-library pair** (acemo vs acemo_fresh:
identical track data, yet header 0x10 = 4 vs 5, tracks strange unk7 = 2 vs 1,
tracks-page trailing differs) — a structural field would be identical.
maple's values: §4 header0x10 = 0 and §5 unknown7 = 0 are reasonable minimal
defaults (confirmed = real exports on data pages; fresh strange-page value
unconfirmed). §6 trailing u2 = `present_flags` is the established fresh-write
value (NOT the old constant 16). No further writer change needed; remaining
mismatches vs edited references are unavoidable edit history.
IMPORTANT corrections to the earlier draft: (1) `data/usb/.../export.pdb` is
maple's OWN output (an old build that emitted the rekordcrate-default 16), not a
rekordbox export — so any "fresh usb" claim was circular; `data/usb` is NOT an
authority for ANY field (same contamination that produced the wrong opaque-blob
default in §2). (2) We have NO genuine rekordbox export with sequence==1, so the
never-edited header0x10 / strange-page-unknown7 values are unconfirmed. (3) The
row-group trailing u2 fresh value is `present_flags`, established from the REAL
data/acemo_fresh (12/14); the rekordcrate "Default=16" is a placeholder that
matches 0/14 real groups.
All three go to re/KNOWN_DIVERGENCE.md, not the writer.
---
## 7. Artwork + USBANLZ path schemes (task #11)
Decoded from genuine `data/MAPLE` (176 tracks/176 artwork) + acemo. The guiding
principle (per the task): for **player acceptance only PATH SELF-CONSISTENCY
matters** — every path stored in the pdb must resolve to a real on-disk file.
Matching rekordbox's exact id-derivation is NOT required; maple's own consistent
scheme is acceptable.
### 7a. Artwork — SOLVED, exactly reproducible (maple already matches)
- Path: `/PIONEER/Artwork/{folder:05d}/a{artwork_id}.jpg` (80×80) plus a
`a{artwork_id}_m.jpg` (240×240) variant in the same folder (rekordbox writes
both; verified on disk in data/MAPLE).
- **Folder bucket rule: `folder = artwork_id // 20 + 1`** (20 ids per bucket;
bucket 00001 = ids 1–19 because ids are 1-based, 00002 = 20–39, 00003 = 40–59,
…). Verified **176/176** in data/MAPLE.
- maple's `artwork._bucket()` is literally `f"{artwork_id // 20 + 1:05d}"` and
`extract_artwork` writes both jpg variants at that path → **byte-identical to
rekordbox AND self-consistent**. No change needed.
### 7b. USBANLZ — opaque id; maple's own scheme is self-consistent (DIVERGENCE from rekordbox)
- Path: `/PIONEER/USBANLZ/P{bucket}/{8-hex}/ANLZ0000.DAT` (+`.EXT`).
- rekordbox's `{8-hex}` is a per-track **opaque analysis id** (range seen
0x117..0x309fd over 176 tracks; NOT == track id, NOT == the row-0x14
`opaque_track_id_lo`, NOT a crc of path/title — tested) and the `P{bucket}`
(0–127, 94 distinct over 176) is a spreading bucket that is **NOT a derivable
function** of the 8-hex or any track field (tested: `&0x7f`, `>>k`, `%127/128`,
low-byte — all fail). Like the row-0x14 blob, these come from rekordbox's
internal DB and are **not reproducible** without it.
- maple (cli.py `build()`): generates its OWN deterministic scheme —
`bucket = P{(crc32(file_path) >> 20) & 0xFFF:03X}`,
`analyze_id = {crc32(file_path):08X}`, then `write_anlz()` writes the .DAT at
exactly that path. This is **self-consistent by construction** (the path it
records is the path it writes) and is the correct approach per the
self-consistency principle. It does NOT match rekordbox's id/bucket (expected
DIVERGENCE).
- Minor cosmetic note (not a correctness issue): maple's bucket is 3 hex
digits `P000..PFFF` whereas rekordbox uses `P00..P7F` (0–127). Harmless;
optionally narrow to `& 0x7F` (`P{..:02X}`) to look more rekordbox-like.
### 7c. Self-consistency verification
- **Genuine rekordbox (data/MAPLE): 176/176 ANLZ paths and 176/176 artwork
paths resolve to on-disk files.** (rekordbox is fully self-consistent — sanity
check that confirms the parser and the principle.)
- **maple production `build()` (cli.py): self-consistent BY CONSTRUCTION** —
`extract_artwork` (artwork.py:62-65) writes `a{id}.jpg`/`a{id}_m.jpg` at the
recorded path; `write_anlz` (cli.py:93) writes `ANLZ0000.DAT` at the recorded
`analyze_path`. Each pdb path is computed and the file is written there in the
same step, so every path resolves.
- CAVEAT — the byte-diff harness `tools/build_acemo.py` is NOT the production
path: it MIRRORS a reference rekordbox export (copies its `analyze_path`/
artwork `path` strings verbatim, writes no ANLZ/artwork files), so the
`data/usb` export it produces references the reference's USBANLZ files which
are not co-located → those paths don't resolve. This is expected for a
structural-diff fixture and says nothing about production self-consistency.
**Confidence:** high. Artwork rule is exact (176/176). USBANLZ rekordbox ids are
opaque/non-reproducible (consistent with the row-0x14 finding); maple's own
USBANLZ scheme is self-consistent, which is all the player requires.
---
## 8. Page-manager free-space: `empty_candidate` + free pool (task #13 — THE FREEZE)
DECK TEST: maple's pdb FROZE the player (parser hang, not reject). Decoded the
page-manager/free-space divergence against genuine rekordbox exports
(`data/working` 9trk seq44, `data/MAPLE` 176trk, `data/PIONEER` 847trk — all
load) vs maple output (`captures/maple_acemo.pdb`).
### Data-page `next_page` SELF-LOOP — ROOT CAUSE (confirmed divergence; high confidence)
- The player reads each table by following `first_page → next_page → …`. maple's
**last data page of each multi-page chain set `next_page == its own index`**
(page 2→2, 6→6, 8→8, 12, 14, 28, 34, 36, 38, 40 — 10 self-loops in the 9-track
build). A page whose `next_page` is itself is an **infinite loop → FREEZE**.
- **Genuine rekordbox has ZERO self-loops.** A terminal data page uses
`next_page = 0` (the "no next" sentinel; seen on pages 10/16/18/20/…/41-47 in
`data/working`) OR points FORWARD to a distinct free page (never to itself).
- FIX (maple, done in writer.py): point every chain's last page at a single
dedicated zero-filled free-pool page (`free_sentinel`, just past the overflow
region) instead of itself; materialise that page and set `next_unused` beyond
it. Verified on the rebuilt pdb: 10 self-loops → 0; all 20 chains terminate at
the blank sentinel; player tolerates last-page→free-page (genuine exports do
the same). This is the primary freeze fix.
### Table-dir `empty_candidate` (u4 after page_type) — SECONDARY divergence (also wrong; lower-confidence freeze contributor)
- Layout: each 16-byte table-dir entry is `type:u4, empty_candidate:u4,
first_page:u4, last_page:u4`.
- `empty_candidate` is the head of that table's **empty/free-page list** (the
page the Page Manager grabs when the table needs to grow).
- **In EVERY genuine rekordbox export all 20 `empty_candidate`s are NONZERO and
distinct**, and each points to an **all-zero "free" page** — either an
in-file allocated-but-empty page (e.g. `last_page+1`, or a pooled page like
41–47/49) or a **beyond-EOF sentinel** (index ≥ npages, e.g. 50/51/52 when
npages=49 — the firmware tolerates beyond-EOF here). It NEVER points to a
used data page and NEVER to 0.
- **maple writes `empty_candidate = 0` for all 20 tables.** Page 0 is the FILE
HEADER. The firmware Page Manager, walking a table's empty-page list, follows
`0` → reads the 4 KB file header AS a page → its "next_page" field (header
offset 0x0c = `next_unused_page`) and bogus type/flags send the walk into a
loop or out-of-bounds read → **hang/FREEZE**. This is the highest-confidence
cause: it is the only systematic, data-independent divergence, and `0` is
never valid (collides with the header).
### Free pool / page layout
- rekordbox keeps the data pages in chains `1..40,48,…` and pre-allocates a
pool of all-zero "free" pages past them; `next_unused_page` = one past the
highest allocated page (so `next_unused > npages` is normal, e.g. 53 > 49).
- maple **compacts**: 41 pages, every page used, `next_unused == npages == 41`,
no free pool. The compaction itself is structurally legal (chains are clean),
but it left `empty_candidate = 0` with no free page to point at.
- Beyond-EOF links (a page's `next_page` ≥ npages, or `empty_candidate` ≥
npages) are TOLERATED — the genuine `working` file has many (page 4→53,
46→52, empty_candidate 51/52/50/49 all ≥ 49). So beyond-EOF is a valid
"no-more-pages / no-free-page" sentinel; it is NOT the freeze.
### Strange-page free-map — NOT the cause (decoded, maple matches)
- Each table's first/"strange" page (flags 0x64) carries, after a small prefix
`[this_page, next_or_0x03ffffff, 0x03ffffff, 0, 0x1fff0000]`, a heap filled
with **1004 × `0x1ffffff8`** (`f8 ff ff 1f` LE) — the empty-heap free-map
(count == the `m1004`=1004 marker at header 0x24). `0x03ffffff` in the 2nd u4
is the legitimate **"this table has no data page"** sentinel (genuine
rekordbox writes it for every empty table; e.g. labels/pltree/plentries).
- maple reproduces this free-map correctly; its strange-page prefixes match
rekordbox's pattern byte-for-byte (data table → u1=first_data_page; empty
table → u1=0x03ffffff). **So the free-map is NOT the freeze.**
### FIX (for maple → deck test)
PRIMARY (high confidence, DONE): **eliminate the data-page `next_page` self-loop**
— last page of each chain must point to a blank free page (or use `next_page=0`),
never to itself. maple now points all last pages at one zero-filled `free_sentinel`
page. Verified: 0 self-loops in the rebuilt pdb.
SECONDARY (cheap, also a real divergence — fold into the same build): **stop
writing `empty_candidate = 0`.** Point it at the same `free_sentinel`
(`pack("<I", free_sentinel)` instead of 0). Genuine rekordbox NEVER writes 0
(0 = the header page); it always points each table's empty_candidate at a blank
free page. Lower-confidence as a freeze cause for a read-only player than the
self-loop, but wrong and trivially fixable with the page maple already
materialised.
If the deck STILL hangs after both, next suspects (lower confidence):
`next_unused_page == npages` (give a little slack), and whether the single shared
sentinel needs to be per-table-distinct (rekordbox uses distinct free pages).
**Confidence:** HIGH that the self-loop was the freeze (self-referential
next_page is an unconditional infinite loop; 0/all genuine exports self-loop).
empty_candidate=0 is independently wrong (0/all genuine exports use it; collides
with header). Exact firmware behaviour is inferred (no firmware to trace); the
deck test confirms.
is the confirmation.
+167
View File
@@ -0,0 +1,167 @@
# KNOWN_DIVERGENCE.md — fields maple's writer intentionally does NOT match
This is the canonical list of bytes that differ between maple's
`captures/maple_acemo.pdb` and the AceMo reference
`data/acemo/PIONEER/rekordbox/export.pdb` **on purpose**, because they are
*history-dependent* artifacts of the particular reference file rather than rules
of the `.pdb` format.
## TL;DR
The AceMo reference `export.pdb` is **not a fresh from-scratch export — it is an
incrementally-edited rekordbox database.** rekordbox's DeviceSQL page manager
edits rows *in place*: when a row changes it appends a new copy, clears the old
slot's presence bit, and advances `used_size` past the dead bytes **without
compacting**. The reference therefore carries the residue of its entire edit
history.
maple writes a **fresh, fully-compacted** database (tightly 4-byte-aligned rows,
no dead rows, no leftover slots). That output is byte-correct for a *from-scratch*
rekordbox export, but it cannot — and should not — reproduce another file's edit
history. The fields below are the consequences of that, and the team has decided
**not to chase them**. They only become matchable against a fresh from-scratch
reference export (export to an empty USB, never edited afterward).
Everything NOT listed here is fair game and must match. The task-#7 diff should
filter out (and reference) this list, then drive the remaining bytes to zero.
## Evidence (tracks table, page 2 of the reference)
5 present rows, each with ~377–417 bytes of real content, yet:
| row id | ofs | real content len | next ofs | gap | gap bytes are… |
|--------|-------|------------------|----------|-------|---------------------------|
| 1 | 428 | 377 | 856 | 51 | zero |
| 5 | 856 | 377 | 1324 | 91 | partial stale string |
| 2 | 1324 | 417 | 1792 | 51 | zero |
| 3 | 1792 | 386 | 2656 | 478 | a whole dead track row |
| 4 | 2656 | 404 | 3996* | 936 | two whole dead track rows |
`used_size=3996`, `free_size=38`. The gap after id=3 holds a dead copy of a row
ending `"...Jelly Fish Jam.mp3"`; the gap after id=4 holds dead copies of
`"Myrtle Ave Party Track"` and `"Smooth Move"` rows. These are previous versions
of edited rows, left uncompacted. (Verified by ASCII-dumping the gap bytes.)
## The fields we do NOT match (and why)
1. **File / page count and `next_unused_page`.**
Reference: 49 pages, `next_unused=53`. maple: 41 pages, `next_unused=41`.
The reference is larger purely because dead-row bloat pushed live rows onto
extra pages. A compacted export needs fewer pages.
2. **Overflow / data-page placement (tracks `last_page`).**
Reference tracks table spans pages **1 → 48** (live track id=7 spilled onto
overflow page 48 because dead rows "filled" page 2). maple spans **1 → 2**
(all 6 tracks fit one compacted page). This is the *only* table-directory
entry that differs — the other 19 tables match first/last page exactly. The
divergence is entirely a function of the dead-row bloat above.
3. **File-header `sequence` value.**
Reference: `353`. maple: an incrementing counter (reads the destination
export's sequence + 1, default 1; see `writer._next_sequence`). rekordbox's
value comes from its internal `master.db` transaction counter and is not
reproducible from content. We match the *semantics* (monotonic increment),
not the absolute value.
4. **Per-page `sequence` (page-header offset 16).**
Reference pages carry assorted values (e.g. 26, 45, 320, 342, 347) — each a
snapshot of the global counter at the moment that page was last rewritten.
maple writes a clean per-build value. Same root cause as (3); not reproducible.
5. **`transaction_row_count` / `transaction_row_index` (page-header u2 pair).**
Reference: nonzero per edited page (e.g. `1/5`, `1/8`, `2/0`). maple: `0/0`.
These track the last in-place transaction on the page; meaningless for a
freshly written, never-edited page.
6. **Dead-row offset arrays + `num_row_offsets` > `num_rows`.**
Reference row groups list offsets for dead slots too: e.g. tracks page 2 has
`nro=9, nrows=5` with offsets `[0,428,856,1324,1792,2224,2656,3112,3568]`
(slots 0,5,7,8 dead); artists page 6 has `nro=9, nrows=1`. maple emits exactly
`nro == nrows` with no dead slots. Reproducing dead offsets would mean
reproducing the edit history.
7. **Live row in a non-zero slot.**
Reference artists page 6 keeps its single live row in **slot 8** (slots 0–7
are dead). maple places the single live row in **slot 0**. A from-scratch
export also writes slot 0.
8. **`free_size` / `used_size` totals on data pages.**
Reference `used+free` totals vary (4034, 4050, …) because `used_size` covers
the dead-row region and the strange-block markers differ. maple's pages total
a clean `4020` (= heap capacity for a single 36-byte row group). Direct
consequence of (6) — dead bytes are counted as "used".
9. **Track row opaque id blob — `0x14`/`0x18`/`0x1a` (8 bytes/track).**
One 8-byte LE value `opaque_track_id_lo | (opaque_seed << 32)`, both halves
stored in rekordbox's internal master.db (a per-install `opaque_seed` + a
per-track `opaque_track_id_lo`). Proven non-derivable from track metadata
across every real export + an exhaustive negative hash search
(FORMAT_SPEC.md s2). **Every real rekordbox export has BOTH halves non-zero**
(this install's seed = `0x21b4a7ab`; `data/MAPLE` = `0xd6d3a567`); the
"unanalyzed default" lo=0/seed=0x78f74a68 was a false lead — it came from
maple's own earlier output (`data/usb`), not a real export. maple emits a
plausible non-zero stand-in of the right shape: `opaque_seed = 0x21b4a7ab`
(module const `TRACK_OPAQUE_SEED`, overridable per install) and a stable
per-track `opaque_track_id_lo` mapped into the real Content-ID band
(`OPAQUE_LO_MIN..MAX` = 0x08000000..0x0F900000, where every observed AceMo lo
lands) via `crc32(id|file_path)`. This will NOT match rekordbox's master.db
id byte-for-byte — unreproducible without its DB — but it is non-zero,
in-range and deterministic, the safest shape for deck acceptance. Players
treat the blob as opaque.
10. **Strange-page free-map extension + per-page seq on tracks/history.**
On the tracks (type 0) and history (type 19) strange pages only, the
free-space map carries an extra accounting word (body offset 16: `01 00`,
offset 20: a u4 = the data page's used_size for history=320; tracks differs
because of its dead-row/overflow state). maple writes the plain free-map.
Tied to the same content/overflow state as items (1)/(2); not reproducible
for a compacted writer. (The rest of every strange page — header, free-map
head, 1004× `f8 ff ff 1f`, the data-page pointer word, and unknown7 — now
matches the fresh reference byte-for-byte.)
11. **Table-directory `empty_candidate` (u32, table_dir entry +4) + header
`next_unused_page`.** Reference: a nonzero per-table `empty_candidate`
(e.g. 0x33, 0x04, 0x2e…); maple: 0. rekordcrate documents this field as
"maybe links to a chain of empty pages if the database is ever garbage
collected", and its fixtures show non-sequential free-pool page indices
(47, 4, 49, 8, 50, 46, 42, 16…). It is free-list/GC allocator state, not
derivable from content. `next_unused_page` (rekordcrate: "points past end of
file", unused) is the same class — see also item (1).
> Canonical reference note: the diff target is now the FRESH export
> `data/acemo_fresh/PIONEER/rekordbox/export.pdb` (7 tracks). It is cleaner than
> the edited `data/acemo` (6 tracks) but still incrementally built per-file, so
> the divergences above still apply — just smaller (e.g. track 7's dead-row span
> is 464B not 3568B, but it still overflows to page 48).
## What IS reproducible (NOW matched against the fresh reference)
- The 20-table directory layout and page placement: an empty table is
strange-page-only; a table gets a data page IFF it has rows (`CORE_TABLES` is
now empty — the old genres data page was an edit-history artifact of the
edited ref).
- `page_flags`: data pages are `0x34` for **tracks and history only**, `0x24`
for every other table (the wider `{0,2,3,13,17,19}` was edited-ref noise);
empty data pages `0x44`; strange pages `0x64`.
- Strange-page body: the full DeviceSQL free-space map — `u4(page_index)`,
`u4(first_data_page or 0x03ffffff)`, the 12-byte const tail, 1004×
`f8 ff ff 1f`, 20 zero bytes — plus header `free=used=0`, `txc=txi=0x1fff`,
`next_page = page_index+1`, and `unknown7 = 1` for tracks/history else 0.
- Track row `index_shift` (`slot_index<<5`) and every other live-row content
field except the opaque blob in divergence item (9).
- Three header fields `re` decoded (FORMAT_SPEC §4/§5/§6) as edit-history state
whose FRESH-export value maple already writes — so they're matched on a fresh
export and only diverge against edited refs:
- file-header offset 16 (`_unnamed4`): fresh=`0` (maple writes 0); edited DBs
accumulate a small nonzero with no structural meaning.
- strange/data-page `unknown7` (0x26): fresh=`0` everywhere (maple writes 0);
nonzero only on edited DBs' strange pages.
- row-group trailing u2: it is the "rows-touched-in-last-transaction" bitmask.
On a fresh export it equals the row-present mask, so maple writes
`presence` (matches 12/14 groups in `data/acemo_fresh`; the 2 misses are
edited pages — divergence). NOTE: `re`'s "keep constant 16" advice came from
reading `data/usb`, which is maple's OWN output, not a real export — a real
fresh export uses the presence value, not 16.
See `re/FORMAT_SPEC.md` for the value-construction rules and `re/README.md` for
the open RE questions.
+54
View File
@@ -0,0 +1,54 @@
# re/ — rekordbox `export.pdb` reverse-engineering
Shared record of the Ghidra/Frida work behind maple's pdb writer. See the
top-level `README.md` for the high-level findings; this dir holds the raw
artifacts so others can reproduce and extend the analysis.
## What's here
`scripts/` — Ghidra headless scripts (run against an analyzed `rekordbox.exe`):
- `FindPdb.java` — locate the writer via string + magic-constant xrefs
- `DecPdb.java` — decompile an explicit list of addresses
- `CallTree.java` — walk a call tree, flag file-I/O / 4096-using functions
- `FindHash.java` — hunt the per-track hash: decompile `formatForSearch` + callers + content-string xrefs
`findings/` — script output (decompiled C + xref maps):
- `findpdb.txt` — string xrefs → the export functions; magic-constant hit counts
- `decpdb.txt` — decompiled `export.pdb` serializer call stack (DeviceSQL Page Manager)
- `calltree.txt` — call tree under the export orchestrator `FUN_14119d2d0`
- `hash.txt` — `formatForSearch` + callers + `djmdContent`/`ContentID` xrefs
## Reproduce (not committed: the binary + Ghidra project)
`rekordbox.exe` and the analyzed Ghidra project are **not** in the repo
(copyright + size). To rebuild from your own rekordbox install (Windows path
visible from WSL):
```bash
export JAVA_HOME=~/jdk/jdk-21.0.11+10 # Ghidra needs JDK 21
GH=/path/to/ghidra_12.1.2_PUBLIC
# one-time: import + analyze (~1h, 10G heap, 8 cores)
GHIDRA_HEADLESS_MAXMEM=10G "$GH/support/analyzeHeadless" ~/ghidra_proj rbx \
-import "/mnt/c/Program Files/rekordbox/rekordbox 7.0.5/rekordbox.exe" \
-overwrite -max-cpu 8 -analysisTimeoutPerFile 7200
# then run any script without re-analyzing:
"$GH/support/analyzeHeadless" ~/ghidra_proj rbx -process rekordbox.exe -noanalysis \
-scriptPath re/scripts -postScript FindPdb.java
```
Runtime→Ghidra address map: `static = 0x140000000 + module_offset` (Ghidra image
base `0x140000000`; ASLR runtime base varies — read `rekordbox.exe+0xOFFSET` from
the Frida agent, never hardcode it).
## Live capture (Frida, Windows)
`../tools/capture.mjs` (Node, `npm i frida`) + `../tools/rbx_pdb_capture.js`
(agent). Child-gates so the CIG-protected writer is hooked at spawn:
`node tools/capture.mjs --spawn`.
## Open questions
- per-track `0x14` (28-bit) — varies per track, not a plain crc32; likely a
stored `djmdContent` value. `0x18`/`0x1a` are a per-export constant.
- `index_shift` (row `0x02`) — positional (`~0x20`/row, resets per page) but the
exact formula isn't pinned. maple writes `0`; rekordbox doesn't.
+470
View File
@@ -0,0 +1,470 @@
# REFERENCE_COMPARISON.md — pyrekordbox & rekordcrate vs maple
What the two mature open-source rekordbox libraries can decode/encode, how
that lines up with maple's writer and our RE findings, and the concrete GAPS
maple must close for a full from-scratch USB export.
- **pyrekordbox** (Python, dylanljones) — https://pyrekordbox.readthedocs.io
- **rekordcrate** (Rust, Holzhaus) — https://holzhaus.github.io/rekordcrate/
local source at `/mnt/c/Users/ntr/code/rekordcrate/src`
- **maple** — `maple-py/src/maple/*`, this `re/` dir.
Scope note (coordination): the per-format **library-capability** comparison and
the from-scratch-export gaps are mine (task #12). The master.db SQLCipher key
mechanics and opaque Content ID identity are owned by `re` (#10); the exact
USBANLZ/artwork on-disk path schemes by `re` (#11). Where those are cited below
they are tagged `[see re #10]` / `[see re #11]` and should be treated as
`re`'s authority, not re-derived here.
---
## 0. One-screen capability matrix
| Format | pyrekordbox | rekordcrate | maple |
|---|---|---|---|
| **export.pdb** | parse only (via the `pdb` parser, read) | **parse + WRITE** (`BinWrite` on rows/pages, full roundtrip) | **WRITE only** (from-scratch, the whole point) |
| **exportExt.pdb** | not supported | not supported | not written |
| **ANLZ (.DAT/.EXT/.2EX)** | **parse only** ("creating … is planned") | parse + *partial* write (binrw `BinWrite` derived on waveform/cue structs; not a whole-file builder) | **WRITE** (minimal: PMAI + PPTH only) |
| **artwork (.jpg thumbs)** | reads `ImagePath` from db only | n/a (just the pdb `Artwork` row path string) | **WRITE** (extracts embedded art) |
| **master.db (db6/7)** | **READ + WRITE** (SQLCipher unlock) | not supported | not used |
| **mysetting (\*SETTING.DAT)** | **READ + WRITE** (build from scratch) | parse only (`setting` module) | not written |
| **rekordbox XML** | **READ + WRITE** | parse only (`xml` module) | not used |
The headline: **no existing library writes a complete from-scratch USB export.**
rekordcrate writes `export.pdb` rows but not ANLZ/exportExt and has no
metadata source; pyrekordbox writes master.db + mysetting + XML but not the USB
`export.pdb`/ANLZ. maple is filling a real gap. The reference libs are most
valuable to maple as (a) a **read-back oracle** (parse maple's output and diff)
and (b) for pyrekordbox, a **source of the real opaque Content IDs** from
master.db.
---
## 1. export.pdb
### What rekordcrate does (the closest reference writer)
`pdb/mod.rs` decodes **and writes** every row type maple emits: Track, Artist,
Album, Artwork, Color, Genre, Key, Label, PlaylistTreeNode, PlaylistEntry,
ColumnEntry, HistoryPlaylist, HistoryEntry (`pub enum Row`, lines ~1064-1103).
It has a genuine page serializer: `write_page_contents` (mod.rs:212),
`write_options_and_get_row_offset` (mod.rs:463), per-row-type alignment
(`align_by`, mod.rs:1121-1133), and a `Track` `BinWrite` impl (mod.rs:934).
### Where rekordcrate CONFIRMS maple's layout (high-value confirmations)
- **Track fixed prefix = 0x5e bytes, then a 21×u2 (0x2a) offset block.**
rekordcrate asserts `start_of_string_section - base_position == 0x5e` and
seeks `0x2a` for 21 string offsets (mod.rs ~986-996). maple's
`TRACK_FIXED_PREFIX = 0x5E`, `TRACK_HEADER_LEN = 0x5E + 21*2`
(`rows.py:8-10`) and its 21-string list match field-for-field, including the
exact string order (isrc, …, analyze_path, analyze_date, comment, title,
unknown_string8, filename, file_path). **Independent confirmation of maple's
track row.**
- **Field order of the 0x5e prefix** is identical between rekordcrate's
`Track::write_options` and maple's `serialize_track`: unknown1/index_shift/
bitmask/sample_rate/composer_id/file_size/`unknown2`(=opaque lo @0x14)/
`unknown3`+`unknown4`(=seed @0x18/0x1a)/artwork_id/key_id/orig_artist_id/
label_id/remixer_id/bitrate/track_number/tempo/genre_id/album_id/artist_id/
id/disc/play_count/year/sample_depth/duration/unknown5/color/rating/unknown6/
unknown7.
- **DeviceSQL string encoding.** rekordcrate `pdb/string.rs` roundtrips the
same three forms maple's `strings.py` writes: short-ASCII
(`(len<<1)|1`), long-ASCII (`0x40`), long-UTF16LE (`0x90`). Its tests encode
`"I ❤ Rust"` as 0x90 and a long string as 0x40 — exactly maple's selection
rule. The empty string is `0x03` in both. **Confirms strings.py.**
- **Row-group / page-index mechanics.** rekordcrate's `RowGroup` (mod.rs:451,
2102) uses the same 16-rows-per-group, backward-growing offset index, and
presence bitmask maple builds in `pager.py`. `Header` (mod.rs:162) carries
the same 13-bit num_row_offsets / 11-bit num_rows packing maple writes
(`pager.py:131`).
### Where rekordcrate caught a maple BUG — `next_page` chain termination (the FREEZE)
rekordcrate `pdb/mod.rs` documents `Page::next_page` (lines 262-265) precisely:
> *"Index of the next page with the same page type. If this page is the last one
> of that type, the page index stored in the field will point **past the end of
> the file**."*
maple's writer originally set the **last** page of each table chain to
`next_page = its own page_index` (a self-pointer). That is exactly what
rekordcrate says must NOT happen — the last page must point *forward, past the
allocated pages*. A self-referencing `next_page` makes a firmware page-walk that
terminates on "next_page past EOF" **loop forever on the last page → the deck
freezes.** This is the root cause being fixed in task #13: `writer.py` now points
every last page at a dedicated free-pool sentinel page just past the overflow
region (`free_sentinel`, materialised zero-filled) and sets `next_unused_page`
beyond it — which removes the self-loop.
(rekordcrate's own `read_pages`, mod.rs:185-196, terminates the walk on
`page_index == table.last_page` from the directory, so its *parser* tolerates a
self-pointer — which is why maple's read-back oracle never flagged it. The
*firmware* uses the chain-walk, so only a real deck exposed the freeze.
Lesson: a clean rekordcrate parse is NOT proof the chain is firmware-valid.)
> **Verified against a real export — the EXACT rule (not just "past EOF").**
> I decoded `data/PIONEER/rekordbox/export.pdb` (847 tracks, 174 pages,
> `next_unused=177`). rekordcrate's "points past the end of file" is a
> simplification; the real, consistent rule across **all 20 tables** is:
> **each table's last/only page sets `next_page == that table's
> `empty_candidate`**, where `empty_candidate` (table-dir entry +4) is a
> *distinct, nonzero* page index `< next_unused` — the head of that table's
> free-page chain. Evidence: every last page's `next_page` equalled its own
> dir `empty_candidate` (type0 173→174=ec; type1 4→53=ec; type19 40→41=ec; …),
> and those targets (174,53,158,163,54,49,42,46,170,20,22,175,176,165,30,32,
> 43,44,45,41) are all `< next_unused`. Strange-only tables already match
> (next_page = page_idx+1 = their ec; free-map `data_ptr=0x03ffffff`) — maple's
> `build_strange_page` does this correctly.
>
> So maple's current #13 fix (a **single shared sentinel** for every data
> table + `empty_candidate=0` in the directory) removes the freeze but still
> **diverges** from real rekordbox, which uses a **distinct per-table
> `empty_candidate`** that the directory entry also records (nonzero). If
> firmware ever cross-checks `next_page` against the directory's
> `empty_candidate`, the shared sentinel + zero ec could still misbehave.
> Recommended (reported to `maple`): give each data table its own free page,
> set `next_page = that page` AND write that index into the table-dir
> `empty_candidate`, keep them all `< next_unused`. This is straightforwardly
> reproducible (unlike the *value* of `empty_candidate` on an edited ref, which
> is GC state — here we only need a valid distinct free page, which a
> from-scratch writer fully controls).
### Where rekordcrate is WRONG and maple is RIGHT — page row-count header (>255 rows)
Audited because rekordcrate's `Page` header (mod.rs:277-377) reads the row count
as **`num_rows_small: u8` @0x18** plus **`num_rows_large: u16` @0x22** with a
`calculate_num_rows` rule (`large if large>small && large!=0x1fff else small`).
maple instead writes the **canonical crate-digger .ksy bitfield** at 0x18:
13-bit `num_row_offsets` + 11-bit `num_rows`, then `page_flags` @0x1b
(`pager.py:131`). These are two different readings of the same bytes — so I
**verified against a real export** (`data/PIONEER`, which has 284-row
playlist-entry pages):
- Real page 18 bytes @0x18 = `1c 81 23 24`. The 24-bit LE value `0x23811c`
decodes (kaitai/maple way) to `num_row_offsets=284, num_rows=284` — **correct**.
- maple's packer reproduces `1c 81 23` **exactly** for n=284 (and `a6 c0 14` for
166, `7e c0 0f` for 126 — all matching real pages byte-for-byte).
- rekordcrate's reading of the *same* bytes: `num_rows_small = 0x1c = 28` (just
the low byte) and `num_rows_large @0x22 = 283` (which is really the
`transaction_row_index` edit field), giving `calculate_num_rows = 283` — i.e.
rekordcrate would report **283 rows on a 284-row page, and 28 if the
transaction field were 0.** Its small/large model only coincidentally agrees
when `n <= 255`.
- **Conclusion:** maple's header encoding is correct and firmware-faithful for
pages up to 2047 rows (the 11-bit max); rekordcrate's `num_rows_small`/
`num_rows_large` is an older/divergent decode that breaks for `n > 255`. This
matters because maple *can* emit >255-row pages (playlist_entries fits ~284/
page, colors ~394). No maple change needed — this is a maple-is-right result.
### Where rekordcrate's row alignment diverges (maple matches real rekordbox)
rekordcrate aligns each row by its Rust struct's natural `align_of_val`
(mod.rs:1114-1136), which would 2-align u16-only row types. maple aligns **every
row to 4** (`pager._align4`). Checked real exports: **every row in
`data/PIONEER` (3567/3567) and `rb_ref` (101/101) is 4-aligned.** So maple's
universal 4-alignment matches rekordbox; rekordcrate's per-type scheme is the
divergent one (its own code comments admit "we should survey all the different
kind of rows and ensure they're aligned properly", mod.rs:1110). No maple change
needed.
### Where maple's RE goes BEYOND rekordcrate (we know more than the reference)
These are fields rekordcrate leaves as opaque caller-supplied "unknown"s but
maple's RE solved/characterised — important because it means **rekordcrate
cannot be used to validate these; maple's FORMAT_SPEC is the authority**:
- **`index_shift` (row +0x02).** rekordcrate calls it `index_shift` but writes
whatever the caller stored — it does **not** know the value (mod.rs:104-105
"Unknown field, called `index_shift` by flesniak"). maple **derives** it as
`slot_index << 5` and patches it at page-build time (`pager.py:142-145`,
FORMAT_SPEC.md §1). maple is correct where rekordcrate is agnostic.
- **The 8-byte opaque blob (0x14 lo / 0x18+0x1a seed).** rekordcrate:
`unknown2: u32`, `unknown3: u16` ("always 19048?"), `unknown4: u16`
("always 30967?") — three blind unknowns it round-trips verbatim
(mod.rs:115-119). maple's RE (FORMAT_SPEC.md §2) proved this is one LE u64 =
`opaque_track_id_lo | (opaque_seed<<32)`, a per-track Content ID + per-install
seed stored in master.db, **non-derivable** from metadata, and that the
"19048/30967" constants are NOT universal (they only hold for some old
exports). maple emits a non-zero stand-in; the real values live in master.db
`[see re #10]`. maple's understanding is strictly deeper than rekordcrate's.
- **`bitmask` (0x06).** rekordcrate: opaque "unknown" round-tripped. maple
writes a fixed `788224` (0xC0700) — itself not yet RE'd to a formula, but
maple at least pins a known-good value.
- **Strange/"first" pages.** rekordcrate leaves the strange-page heap **zero**
(it round-trips parsed input); maple **constructs** the full DeviceSQL
free-space map from scratch byte-for-byte (`pager.build_strange_page`,
verified vs `data/acemo_fresh` and the real strange pages in data/PIONEER),
including the `f8 ff ff 1f`×1004 map, the `0x03ffffff` no-data sentinel, and
`unknown7=1` for tracks/history. `re` confirms maple is **strictly more
faithful than rekordcrate on strange pages.** This is from-scratch generation
rekordcrate never had to do.
- **RowGroup trailing u2.** rekordcrate writes a constant `16` placeholder
(`Default::default`, mod.rs:454) and does **not** decode it; `re` confirms
genuine rekordbox **never** uses 16 there — it is the "rows touched by the
last transaction" bitmask (FORMAT_SPEC §6). maple writes `present_flags` (the
single-transaction value), closer to real output; either way it is
edit-history state (KNOWN_DIVERGENCE §6), not a rekordcrate-validatable field.
### Where rekordcrate (and our RE) AGREE the field is unreproducible — with a caveat
rekordcrate documents `empty_candidate` ("maybe links to a chain of empty
pages if the database is ever garbage collected") and `next_unused_page`
("points past end of file") as allocator/GC state — matching
KNOWN_DIVERGENCE.md items 11 and 1. The **historical VALUE** on an edited
reference is indeed unreproducible. **BUT** my data-page audit (see the FREEZE
section above) found `empty_candidate` is **load-bearing structure**, not just
GC trivia: in data/PIONEER every table's last page has
`next_page == that table's empty_candidate`, a distinct free page `< next_unused`.
So a from-scratch writer must still emit a *valid* distinct `empty_candidate`
per table (which it fully controls) even though it can't match an edited ref's
specific value. **Confirms** these are correctly on
maple's divergence list, not bugs.
### GAP — none structural for export.pdb
maple writes a complete, players-accepted export.pdb. Remaining export.pdb
divergences from a reference file are all **edit-history artifacts**
(KNOWN_DIVERGENCE.md) that neither library can reproduce from content either.
The one content-level gap is the opaque blob (see §4).
---
## 2. exportExt.pdb — GAP (nobody writes it; maybe nobody needs to)
- **pyrekordbox:** no support.
- **rekordcrate:** no support (its `pdb` module is export.pdb only).
- **maple:** does not emit it.
Format facts (Deep Symmetry export analysis; reverse-engineered by Dominik
Stolz, crate-digger issue #11): `exportExt.pdb` is a *second, smaller*
DeviceSQL file beside `export.pdb` holding only two table types — **Tags
(type 03)** and **Tag Tracks (type 04)** — i.e. the "My Tag" categorisation
feature and its tag→track associations. It uses the same page/row container
format as export.pdb.
**Assessment / recommendation:** exportExt.pdb is **optional**. It only carries
the My-Tag feature; players that don't find it simply show no custom tags, and
tracks/playlists from export.pdb play normally. maple has no tag data to
populate anyway. **Do not block a v1 export on this.** If maple ever supports
tags, the existing `pager.py`/`PageBuilder` already produces the container
format — only two new row serialisers (tag row, tag-track row) and a second
`write_pdb`-style call writing the 2-table directory would be needed.
Confidence the file is skippable: high (it post-dates the core format and is
absent from older exports that still play).
---
## 3. ANLZ (.DAT / .EXT / .2EX) — the biggest real GAP
### What the references decode
- **pyrekordbox** (`pyrekordbox.anlz`): full **parser** for PMAI + all section
tags — PPTH, PQTZ/PQT2 (beat grids), PCOB/PCO2 (cues/loops), PVBR, PSSI
(phrase/song-structure, XOR-masked), PWAV/PWV2 (previews), PWV3/4/5
(detailed + colour waveforms), PWV6/7/PWVC (.2EX). Writing is **"planned …
but the full structure has to be understood first"** — i.e. **read-only
today.**
- **rekordcrate** (`anlz.rs`): parses the same tag set; magic constants present
in source: PMAI, PPTH, PQTZ, PVBR, PWAV, PWV2, PCOB, PCO2, PCPT, PCP2, PSSI,
PWV3/4/5. It derives `BinWrite` on the waveform-column structs
(`WaveformPreviewColumn` etc., anlz.rs:368-428) and has a `write_encrypted`
for the XOR-masked PSSI (anlz.rs:902) — so it has *piece-level* write code,
but **no whole-file ANLZ builder / CLI to emit a valid .DAT/.EXT from track
audio.**
### What maple does
`anlz.py` writes a **minimal** `ANLZ0000.DAT`: a 28-byte PMAI header + a single
**PPTH** section (u4 len + UTF-16BE path). No beat grid, no waveform, no cues,
no `.EXT`, no `.2EX`. Rationale in the module docstring: firmware reads PPTH to
confirm which audio file the analysis belongs to, and a track whose
`analyze_path` points at *nothing* can be hidden from the library, so PPTH
alone makes the track "appear analysed enough to surface."
### CONFIRMATIONS
- maple's PMAI/PPTH layout (4-cc + u4 len_header + u4 len_tag; PPTH = u4
len_path + UTF-16**BE**, big-endian throughout) matches the format both
references parse. The big-endian-for-ANLZ-vs-little-endian-for-pdb split that
`anlz.py` calls out is corroborated by both libraries.
### GAPS maple must close for full faithfulness
1. **No `.EXT` file.** Modern CDJs/XDJs (NXS2+) read `ANLZ0000.EXT` for colour
waveforms (PWV4/5), extended cues (PCO2), and phrase data (PSSI). Without it
maple tracks show **no colour waveform and no phrase/structure** on newer
players. The `analyze_path` in the pdb points at the `.DAT`; the player
derives the `.EXT` sibling. maple writes neither beyond the `.DAT` PPTH stub.
2. **No beat grid (PQTZ).** Without a beat grid, beat-sync / quantize / loop
features have nothing to lock to; the track plays but degrades the CDJ
experience. This is the highest-value ANLZ section to add after PPTH.
3. **No waveform (PWAV/PWV3-5).** Cosmetic but conspicuous — tracks show a flat
or absent waveform. pyrekordbox can *parse* these to learn the exact byte
layout, and rekordcrate's `BinWrite` waveform-column structs are a concrete
reference for the encoding, but **maple must generate the waveform data
itself** (decode audio → downsample → colour-band), which neither library
provides.
4. **Generation, not just layout, is the hard part.** The byte format is fully
documented (both libs + Deep Symmetry); what's missing everywhere is the
DSP that *produces* beat positions and waveform columns from PCM. That DSP
is maple's to build — the references only validate the container.
**Recommendation:** keep the PPTH-only `.DAT` as the working baseline (it is
what gets tracks visible), and treat PQTZ beat grid + a `.EXT` with PWV5 colour
waveform as the next ANLZ milestones. Use pyrekordbox's parser as the
round-trip oracle (write a section, parse it back, compare). Confidence the
PPTH-only stub is sufficient for *visibility/playback*: medium-high (matches
maple's own deck testing rationale); confidence it's sufficient for a *complete*
experience: low — beat grid/waveform are expected by users.
---
## 4. master.db (db6/7) + the opaque Content IDs — GAP maple can close via pyrekordbox
### What pyrekordbox does (this is its core strength) — VALIDATED by re #10
- Reads **and writes** the encrypted `master.db`. The db is **SQLCipher4**
encrypted with a **single GLOBAL key, identical on every install** (not
machine/license-derived). `re` validated the concrete key (FORMAT_SPEC §2
Round-3): `402fd482c38817c35ffa8ffb8c7d93143b749e7d315df7a81732a1ff43608497`.
pyrekordbox 0.4.4 + `sqlcipher3-wheels` installs and wires up correctly, so a
**valid** master.db opens and exposes `DjmdContent` `[see re #10]`.
- Exposes `DjmdContent` (one row per track) with: **`ID`** (the rekordbox
internal Content ID), `FolderPath`, `FileNameL`, **`AnalysisDataPath`** (the
`/PIONEER/USBANLZ/...` analysis path), **`ImagePath`** (artwork), and FK ids
to Artist/Album/Genre/Label/Key/Composer; plus `djmdCue`, `djmdPlaylist`/
`djmdSongPlaylist`, `djmdHistory`, `djmdMixerParam`, `djmdDevice`, etc.
> **Reality check from re #10 — currently BLOCKED on data, not on method:**
> - The captured `maple-py/captures/p17328_master.db` is **corrupt/truncated**
> (zeroed SQLCipher salt + page 1) — no key can open it. The genuine
> `exportLibrary.db` shipped beside export.pdb uses a *different* key scheme
> that the global master.db key does not open, and pyrekordbox has no
> exportLibrary.db support. So the IDs have **not yet been read** on this RE
> machine — the path works only against a **live**
> `%APPDATA%/Pioneer/rekordbox6/master.db`.
> - `opaque_track_id_lo == DjmdContent.ID & 0xFFFFFFFF` is a **strong inference,
> still UNTESTED** (the lo range fits the 9-digit Content-ID band), not a
> confirmed equality.
> - The per-install **seed** (`0x21b4a7ab`) is **NOT** a DjmdContent column and
> its source is **still unidentified** (likely a library/USB UUID or
> agentRegistry/local_usn-style marker; needs a readable master.db to find).
> - pyrekordbox `add_content` lets *rekordbox* assign the Content ID — there is
> **no API to force a specific ID** — and it does **not** drive a USB export
> (`get_device` is read-only). So pyrekordbox is a **read source of real IDs**,
> not a writer/exporter and not a way to dictate IDs.
### Why this matters for maple (ties to FORMAT_SPEC §2 / KNOWN_DIVERGENCE #9)
maple's RE proved the export.pdb track blob at 0x14/0x18 is the rekordbox
**Content ID** (per-track, 28-bit band) plus a **per-install seed**, both stored
in master.db and **not derivable** from the audio/metadata maple has. maple
currently emits a non-zero *stand-in* (`crc32(id|file_path)` lo +
`TRACK_OPAQUE_SEED = 0x21b4a7ab`). Therefore:
- **GAP (closable in principle, not yet proven): real Content IDs.** If maple
runs on a machine with rekordbox installed, it can use **pyrekordbox + the
global key to open the LIVE `%APPDATA%/Pioneer/rekordbox6/master.db` and read
`DjmdContent.ID`** per track, then write those into the pdb blob instead of
the stand-in. `re` validated the *mechanism* (global key, DjmdContent schema)
but could NOT confirm `ID == opaque_track_id_lo` here because the only
captured master.db is corrupt — so this is a **strong-but-untested** lead, not
a closed item. Next step (re #10): point pyrekordbox at a live master.db and
confirm the equality for the AceMo tracks. The per-install **seed**
`0x21b4a7ab` source is still unidentified, so maple keeps the configurable
`TRACK_OPAQUE_SEED` either way.
- **Caveats:** (a) works only *with a rekordbox install present and a readable
master.db* (not the corrupt capture), and only for tracks rekordbox already
knows; (b) pyrekordbox `add_content` cannot **force** a chosen ID (rekordbox
assigns it) and does **not** drive a USB export, so it can supply IDs but
cannot replace maple's writer. Pure from-scratch (no rekordbox install) stays
a documented divergence (KNOWN_DIVERGENCE #9), with maple's non-zero stand-in.
### rekordcrate
No master.db support at all — it is a *device-export* (USB) library, not a
*library-database* library. So it cannot help with Content IDs.
---
## 5. mysettings (\*SETTING.DAT) — GAP (small, easy) maple doesn't write
- **pyrekordbox:** reads **and writes/builds** `DEVSETTING.DAT`,
`MYSETTING.DAT` (40B, CDJ play settings), `MYSETTING2.DAT` (40B), and
`DJMMYSETTING.DAT` (52B, DJM mixer) — dict-style get/set + save. (`DEVSETTING`
payload still partly ununderstood.)
- **rekordcrate:** `setting` module **parses** these (read-only).
- **maple:** does **not** write any of them.
These live in `PIONEER/` on the USB alongside `rekordbox/`. They are **optional
preference files** — absent, the player just uses its own defaults; tracks and
playlists are unaffected. **Recommendation:** low priority. If desired,
pyrekordbox can *build* default ones in a few lines (it's read/write), so maple
could shell out to / import pyrekordbox to drop standard `MYSETTING.DAT` +
`MYSETTING2.DAT` rather than re-implementing the 40-byte structs. Confidence
they're optional for playback: high.
---
## 6. Overall directory layout + path schemes (cross-check) `[see re #11 / FORMAT_SPEC §7]`
Both references and Deep Symmetry agree on the USB tree maple targets:
`/PIONEER/rekordbox/export.pdb` (+ optional `exportExt.pdb`),
`/PIONEER/USBANLZ/<...>/ANLZ0000.{DAT,EXT,2EX}`,
`/PIONEER/Artwork/<...>/a<id>.jpg`, plus optional `/PIONEER/*SETTING.DAT`.
maple writes `PIONEER/rekordbox/export.pdb` (`cli.py:156`) and
`PIONEER/USBANLZ/<bucket>/<id>/ANLZ0000.DAT` (`cli.py:92`).
`re` resolved both path schemes (#11, FORMAT_SPEC §7):
- **Artwork — EXACTLY reproducible, and maple already matches.** Real rekordbox
uses `/PIONEER/Artwork/{id//20+1:05d}/a{id}.jpg` (+ a `a{id}_m.jpg` 240px
thumb), 20 ids per bucket folder (bucket 1 = ids 1-19). `re` verified
176/176 in data/MAPLE, and maple's `artwork._bucket()` already implements this
rule. **Reproducible, confirmed — no gap.**
- **USBANLZ — NOT reproducible; maple's own scheme is correct-by-construction.**
Real rekordbox uses `/PIONEER/USBANLZ/P{bucket}/{8hex}/ANLZ0000.DAT` where the
8-hex is an **opaque per-track analysis id** (NOT the track id, NOT the row
0x14 lo, NOT any crc of the path — `re` ruled these out) and the P-bucket
(0-127) is **not a derivable function** of it. Like the 0x14 blob, it is
DB-sourced and non-reproducible. maple synthesises its own
`P{(crc32(path)>>20)&0xFFF:03X}/{crc32(path):08X}` and writes the `.DAT`
there, so the pdb's `analyze_path` and the on-disk file always agree. **This
is fine:** the player only requires every pdb path to resolve to a real
on-disk file, not to match rekordbox's id scheme.
> **Gotcha for the gaps below (from `re`):** `tools/build_acemo.py` MIRRORS a
> reference export's paths verbatim and writes **no** ANLZ/artwork files (it's a
> byte-diff fixture builder). The **production** `cli.py build()` is the one that
> actually writes each ANLZ/artwork file at the recorded path. Don't conflate
> the two when reasoning about what maple emits on disk.
---
## 7. Bottom line — prioritised GAP list for a full from-scratch export
1. **(high, lead — untested) Real opaque Content IDs via pyrekordbox/master.db**
— mechanism validated by `re` (global SQLCipher key `402fd482…` +
`Rekordbox6Database` reads `DjmdContent`), but `DjmdContent.ID ==
opaque_track_id_lo` is a **strong INFERENCE, NOT validated** (the only
captured master.db is corrupt — zeroed salt — so `re` never confirmed the
equality on real data). Would close KNOWN_DIVERGENCE #9 *if* confirmed against
a readable **live** `%APPDATA%/Pioneer/rekordbox6/master.db`.
`[see re #10 / FORMAT_SPEC §2 Round-3]`
2. **(OPEN — Ghidra trace pending) Per-install `opaque_seed` source.** The seed
(`0x21b4a7ab` / `0xd6d3a567`) is **NOT** a `DjmdContent` column and `re` has
**not located it** — likely a per-library UUID/agent/usn marker or a computed
value. It needs a readable master.db to inspect candidate rows, OR a Ghidra
trace of rekordbox.exe (which `re` can now attempt — the Ghidra MCP just came
online), so it is **not permanently blocked.** Until resolved, maple keeps the
configurable `TRACK_OPAQUE_SEED` stand-in. This is the one genuinely open
question touching the opaque blob. `[see re #10 — OPEN; FORMAT_SPEC §2 Round-3]`
3. **(high) ANLZ beat grid (PQTZ) + an `.EXT` colour waveform (PWV5)** — needs
DSP maple must build; references validate the byte format only. Biggest
functional gap on modern players.
4. **(structural — in progress) Per-table `empty_candidate` / last-page
`next_page`** — the FREEZE fix (#13). maple must point each table's last page
at a distinct free page recorded in the dir's `empty_candidate`, not a shared
sentinel with `empty_candidate=0`. See the FREEZE section in §1.
5. **(low) mysettings** — optional; pyrekordbox can build them.
6. **(low/skip) exportExt.pdb** — optional My-Tag data; only if maple adds tags.
7. **(reference, not a gap) Use rekordcrate as a read-back oracle** — parse
maple's `export.pdb` with rekordcrate's CLI to catch container regressions;
use pyrekordbox's anlz parser as the oracle for any new ANLZ sections.
**Caveat (proven this session):** rekordcrate's parser tolerates things the
firmware rejects (the `next_page` self-loop) and is itself wrong on some
fields (>255-row count, per-type alignment) — so it is a *regression* oracle,
NOT a correctness authority. Validate load-critical fields against REAL
export bytes (e.g. data/PIONEER), not against rekordcrate.
**Headline confirmation for the team:** rekordcrate's *writer* independently
validates maple's export.pdb track-row layout, DeviceSQL string encoding, and
page/row-group container down to the 0x5e/0x2a offsets — but it is *agnostic*
exactly where maple's RE added knowledge (`index_shift` formula, the opaque-id
semantics). So maple's FORMAT_SPEC is the authority for those fields, and the
remaining real work is **ANLZ generation** and **pulling the real Content IDs
from master.db** — neither of which any existing library does end-to-end.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+595
View File
@@ -0,0 +1,595 @@
// HIT 0x4a68 @141f0a08b in FUN_141f09e70@141f09e70 : LEA RCX,[RDI + 0x4a68]
// HIT 0x4a68 @141f0a08b in FUN_141f09e70@141f09e70 : LEA RCX,[RDI + 0x4a68]
// HIT 0x4a68 @141f15d17 in FUN_141f15bf0@141f15bf0 : LEA RCX,[RBX + 0x4a68]
// HIT 0x4a68 @141f15d17 in FUN_141f15bf0@141f15bf0 : LEA RCX,[RBX + 0x4a68]
// HIT 0x4a68 @141f637b7 in FUN_141f63210@141f63210 : MOV RAX,qword ptr [RBX + 0x4a68]
// HIT 0x4a68 @141f637b7 in FUN_141f63210@141f63210 : MOV RAX,qword ptr [RBX + 0x4a68]
// HIT 0x4a68 @141f637be in FUN_141f63210@141f63210 : LEA RCX,[RBX + 0x4a68]
// HIT 0x4a68 @141f637be in FUN_141f63210@141f63210 : LEA RCX,[RBX + 0x4a68]
// HIT 0x4a68 @141f637da in FUN_141f63210@141f63210 : LEA RCX,[RBX + 0x4a68]
// HIT 0x4a68 @141f637da in FUN_141f63210@141f63210 : LEA RCX,[RBX + 0x4a68]
// HIT 0x4a68 @141f637e1 in FUN_141f63210@141f63210 : MOV RAX,qword ptr [RBX + 0x4a68]
// HIT 0x4a68 @141f637e1 in FUN_141f63210@141f63210 : MOV RAX,qword ptr [RBX + 0x4a68]
// --- decompiling containing functions ---
//===== FUN_141f09e70 @ 141f09e70 =====
undefined8 *
FUN_141f09e70(undefined8 *param_1,undefined4 param_2,undefined8 param_3,longlong param_4)
{
undefined8 *puVar1;
char cVar2;
undefined8 uVar3;
undefined8 *puVar4;
undefined8 *puVar5;
longlong lVar6;
undefined8 *puVar7;
undefined8 *puVar8;
longlong local_res20;
undefined1 local_f8 [4];
undefined8 local_f4;
undefined1 local_ec;
undefined4 local_e8;
undefined1 local_e0 [8];
undefined1 local_d8 [8];
undefined1 local_d0 [8];
undefined1 local_c8 [8];
undefined2 local_c0;
undefined1 local_b8 [8];
undefined1 local_b0;
undefined1 local_a8 [8];
undefined4 local_a0;
undefined1 local_98 [8];
undefined1 local_90 [8];
undefined1 local_88 [8];
undefined1 local_80 [8];
char *local_78;
undefined1 local_70 [16];
undefined1 local_60 [16];
undefined1 local_50 [24];
puVar8 = (undefined8 *)0x0;
uVar3 = FUN_1423d5980(&local_res20,param_3);
FUN_141f09b00(param_1,0xffffffff,uVar3);
FUN_14246e8b0(param_1 + 0x134);
*param_1 = djplay::HidMapCDJ2000NXS2::vftable;
param_1[0x14] = djplay::HidMapCDJ2000NXS2::vftable;
puVar1 = param_1 + 0x15;
*puVar1 = djplay::HidMapCDJ2000NXS2::vftable;
param_1[0x134] = djplay::HidMapCDJ2000NXS2::vftable;
param_1[0x138] = djplay::HidMapCDJ2000NXS2::vftable;
param_1[0x139] = djplay::HidMapCDJ2000NXS2::vftable;
param_1[0x13a] = djplay::HidMapCDJ2000NXS2::vftable;
*(undefined4 *)(param_1 + 0x13b) = 0x25f8;
param_1[0x1a3] = 0;
param_1[0x1a4] = 0;
param_1[0x1bb] = 0;
param_1[0x1bd] = 0;
param_1[0x1be] = 0;
param_1[0x1bf] = 0;
param_1[0x1c0] = 0;
*(undefined4 *)(param_1 + 0x1c1) = 0;
*(undefined2 *)((longlong)param_1 + 0xe0c) = 0;
*(undefined1 *)((longlong)param_1 + 0xe0e) = 0;
*(undefined4 *)(param_1 + 0x1c2) = 3;
puVar7 = param_1 + 0x1c4;
*puVar7 = 0;
param_1[0x1c5] = 0;
param_1[0x1c6] = 0;
param_1[0x1c8] = 0;
FUN_141ee27c0(param_1 + 0x1c9);
_eh_vector_constructor_iterator_(param_1 + 0x2d5,0x470,4,FUN_141ee3f50,FUN_141ee3fe0);
FUN_141ee3f50(param_1 + 0x50d);
FUN_141ee4d90(param_1 + 0x59c);
FUN_141ee5080(param_1 + 0x62b);
FUN_141ee5400(param_1 + 0x6ba);
FUN_141ee5970(param_1 + 0x74a);
FUN_141ee5db0(param_1 + 0x7da);
FUN_141ee6210(param_1 + 0x86a);
FUN_141ee97f0(param_1 + 0x8fb);
FUN_141ee9a10(param_1 + 0x907);
FUN_141ee9c10(param_1 + 0x915);
FUN_141ee9c10(param_1 + 0x920);
FUN_141ee9c10(param_1 + 0x92b);
FUN_141ee9c10(param_1 + 0x936);
FUN_141ee9e30(param_1 + 0x941);
FUN_141eea060(param_1 + 0x94d);
FUN_141eea150(param_1 + 0x959);
FUN_141eea330(param_1 + 0x964);
*(undefined2 *)(param_1 + 0x96f) = 0;
*(undefined1 *)((longlong)param_1 + 0x4b7a) = 0;
*(undefined4 *)((longlong)param_1 + 0x4b7c) = 0;
*(undefined1 *)(param_1 + 0x970) = 1;
param_1[0x197] = 0;
puVar4 = (undefined8 *)FUN_1423ce840(local_98,"CDJ-TOUR1");
cVar2 = FUN_1423d2890(param_3,*puVar4);
if (cVar2 != '\0') {
*(undefined1 *)((longlong)param_1 + 0xe0b) = 1;
}
*(undefined4 *)((longlong)param_1 + 0xe1a) = 0;
*(undefined1 *)((longlong)param_1 + 0xe1e) = 0;
LOCK();
*(undefined4 *)(param_1 + 0x1c8) = 0;
UNLOCK();
LOCK();
*(undefined4 *)((longlong)param_1 + 0xe44) = 0;
UNLOCK();
puVar5 = (undefined8 *)FUN_14217c644(0x40);
puVar4 = puVar8;
if (puVar5 != (undefined8 *)0x0) {
FUN_1423aa4f0(puVar5);
puVar5[6] = 0;
*(undefined4 *)(puVar5 + 7) = 8;
puVar4 = puVar5;
}
param_1[0x1c7] = puVar4;
lVar6 = FUN_14217c644(0x478);
puVar4 = puVar8;
if (lVar6 != 0) {
puVar4 = (undefined8 *)FUN_141ee41c0(lVar6,8);
}
param_1[0x59b] = puVar4;
local_f8[0] = 0;
local_f4 = 0;
local_ec = 0;
local_e8 = 0;
FUN_1423d59e0(local_e0);
FUN_1423d59e0(local_d8);
FUN_1423d59e0(local_d0);
FUN_1423d59e0(local_c8);
local_c0 = 0;
FUN_1423d59e0(local_b8);
local_b0 = 0;
FUN_1423d59e0(local_a8);
local_a0 = 0;
lVar6 = param_1[0x1c5];
if (lVar6 == param_1[0x1c6]) {
FUN_141edc340(puVar7,lVar6,local_f8);
}
else {
FUN_141068df0(lVar6,local_f8);
param_1[0x1c5] = param_1[0x1c5] + 0x60;
}
lVar6 = param_1[0x1c5];
if (lVar6 == param_1[0x1c6]) {
FUN_141edc340(puVar7,lVar6,local_f8);
}
else {
FUN_141068df0(lVar6,local_f8);
param_1[0x1c5] = param_1[0x1c5] + 0x60;
}
lVar6 = param_1[0x1c5];
if (lVar6 == param_1[0x1c6]) {
FUN_141edc340(puVar7,lVar6,local_f8);
}
else {
FUN_141068df0(lVar6,local_f8);
param_1[0x1c5] = param_1[0x1c5] + 0x60;
}
lVar6 = param_1[0x1c5];
if (lVar6 == param_1[0x1c6]) {
FUN_141edc340(puVar7,lVar6,local_f8);
}
else {
FUN_141068df0(lVar6,local_f8);
param_1[0x1c5] = param_1[0x1c5] + 0x60;
}
lVar6 = param_1[0x1c5];
if (lVar6 == param_1[0x1c6]) {
FUN_141edc340(puVar7,lVar6,local_f8);
}
else {
FUN_141068df0(lVar6,local_f8);
param_1[0x1c5] = param_1[0x1c5] + 0x60;
}
lVar6 = param_1[0x1c5];
if (lVar6 == param_1[0x1c6]) {
FUN_141edc340(puVar7,lVar6,local_f8);
}
else {
FUN_141068df0(lVar6,local_f8);
param_1[0x1c5] = param_1[0x1c5] + 0x60;
}
lVar6 = param_1[0x1c5];
if (lVar6 == param_1[0x1c6]) {
FUN_141edc340(puVar7,lVar6,local_f8);
}
else {
FUN_141068df0(lVar6,local_f8);
param_1[0x1c5] = param_1[0x1c5] + 0x60;
}
lVar6 = param_1[0x1c5];
if (lVar6 == param_1[0x1c6]) {
FUN_141edc340(puVar7,lVar6,local_f8);
}
else {
FUN_141068df0(lVar6,local_f8);
param_1[0x1c5] = param_1[0x1c5] + 0x60;
}
if (param_4 == 0) {
uVar3 = FUN_1423e00f0(local_60,0x41,1);
local_78 = "@Activate";
FUN_1423dfec0(local_70,uVar3);
FUN_141f07e80(param_1 + 0x132,local_50,&local_78);
FUN_1423dffc0(local_70);
FUN_1423dffc0(local_60);
puVar4 = (undefined8 *)FUN_14217c644(0x30);
puVar7 = puVar8;
if (puVar4 != (undefined8 *)0x0) {
*puVar4 = djplay::HidExtentFormat::vftable;
*(undefined2 *)(puVar4 + 1) = 0xf0;
puVar4[2] = 0;
puVar4[3] = 0;
puVar4[4] = 0;
puVar4[5] = 0;
puVar7 = puVar4;
}
puVar4 = (undefined8 *)param_1[0x1be];
param_1[0x1be] = puVar7;
if (puVar4 != (undefined8 *)0x0) {
(**(code **)*puVar4)(puVar4,1);
}
uVar3 = FUN_1423d5720(&local_res20,"@AuthReq");
FUN_141f1be20(param_1,0,0x11,uVar3,puVar1);
uVar3 = FUN_1423d5720(local_90,"@AuthChallengeA");
FUN_141f1be20(param_1,0,0x12,uVar3,puVar1);
uVar3 = FUN_1423d5720(local_88,"@AuthResponseA");
FUN_141f1be20(param_1,0,0x13,uVar3,puVar1);
uVar3 = FUN_1423d5720(local_80,"@AuthResponseE");
FUN_141f1be20(param_1,0,0x14,uVar3,puVar1);
uVar3 = FUN_1423d5720(local_98,"@AuthEnd");
FUN_141f1be20(param_1,0,0x15,uVar3,puVar1);
local_res20 = FUN_14217c644(0x1d0);
if (local_res20 != 0) {
puVar8 = (undefined8 *)FUN_141f093a0(local_res20,puVar1,0);
}
param_1[0x1bd] = puVar8;
*(undefined4 *)(puVar8 + 0x2f) = 4;
if (param_1[0x1bd] != 0) {
FUN_141f69980(param_1[0x1bd],param_1 + 0x13a);
}
}
else {
param_1[0x1bd] = param_4;
*(undefined1 *)((longlong)param_1 + 0xe09) = 1;
*(undefined4 *)((longlong)param_1 + 0x4b7c) = param_2;
FUN_141f40750(param_1);
}
FUN_1423d59b0(local_a8);
FUN_1423d59b0(local_b8);
FUN_1423d59b0(local_c8);
FUN_1423d59b0(local_d0);
FUN_1423d59b0(local_d8);
FUN_1423d59b0(local_e0);
FUN_1423d59b0(param_3);
return param_1;
}
//===== FUN_141f15bf0 @ 141f15bf0 =====
void FUN_141f15bf0(undefined8 *param_1)
{
undefined8 *puVar1;
LPCRITICAL_SECTION lpCriticalSection;
longlong *plVar2;
undefined8 uVar3;
int iVar4;
*param_1 = djplay::HidMapCDJ2000NXS2::vftable;
param_1[0x14] = djplay::HidMapCDJ2000NXS2::vftable;
param_1[0x15] = djplay::HidMapCDJ2000NXS2::vftable;
param_1[0x134] = djplay::HidMapCDJ2000NXS2::vftable;
param_1[0x138] = djplay::HidMapCDJ2000NXS2::vftable;
param_1[0x139] = djplay::HidMapCDJ2000NXS2::vftable;
param_1[0x13a] = djplay::HidMapCDJ2000NXS2::vftable;
if (*(char *)((longlong)param_1 + 0xe09) == '\0') {
if (param_1[0x1bd] != 0) {
FUN_141f72e10(param_1[0x1bd],param_1 + 0x13a);
}
puVar1 = (undefined8 *)param_1[0x1bd];
if (puVar1 != (undefined8 *)0x0) {
(**(code **)*puVar1)(puVar1,1);
}
}
uVar3 = FUN_14106db40();
FUN_141ed5ce0(uVar3,param_1 + 0x138);
uVar3 = FUN_1414518c0();
FUN_141ede760(uVar3,param_1 + 0x139);
lpCriticalSection = (LPCRITICAL_SECTION)param_1[0x1c7];
if (lpCriticalSection != (LPCRITICAL_SECTION)0x0) {
DeleteCriticalSection(lpCriticalSection);
thunk_FUN_14217cc5c(lpCriticalSection,0x40);
}
puVar1 = (undefined8 *)param_1[0x59b];
if (puVar1 != (undefined8 *)0x0) {
(**(code **)*puVar1)(puVar1,1);
}
FUN_141eea370(param_1 + 0x964);
FUN_141eea1a0(param_1 + 0x959);
FUN_141eea0a0(param_1 + 0x94d);
FUN_141ee9e80(param_1 + 0x941);
FUN_141ee9c60(param_1 + 0x936);
FUN_141ee9c60(param_1 + 0x92b);
FUN_141ee9c60(param_1 + 0x920);
FUN_141ee9c60(param_1 + 0x915);
FUN_141ee9a70(param_1 + 0x907);
FUN_141ee9860(param_1 + 0x8fb);
FUN_141ee6340(param_1 + 0x86a);
FUN_141ee5ee0(param_1 + 0x7da);
FUN_141ee5aa0(param_1 + 0x74a);
FUN_141ee54a0(param_1 + 0x6ba);
FUN_141ee5120(param_1 + 0x62b);
FUN_141ee4ea0(param_1 + 0x59c);
FUN_141ee3fe0(param_1 + 0x50d);
_eh_vector_destructor_iterator_(param_1 + 0x2d5,0x470,4,FUN_141ee3fe0);
FUN_141ee2880(param_1 + 0x1c9);
FUN_141edd2a0(param_1 + 0x1c4);
iVar4 = 0;
if (0 < *(int *)((longlong)param_1 + 0xe04)) {
do {
FUN_1423dffc0((longlong)iVar4 * 0x10 + param_1[0x1bf]);
iVar4 = iVar4 + 1;
} while (iVar4 < *(int *)((longlong)param_1 + 0xe04));
}
*(undefined4 *)((longlong)param_1 + 0xe04) = 0;
FUN_14230889c(param_1[0x1bf]);
puVar1 = (undefined8 *)param_1[0x1be];
if (puVar1 != (undefined8 *)0x0) {
(**(code **)*puVar1)(puVar1,1);
}
plVar2 = (longlong *)param_1[0x1bb];
if (plVar2 != (longlong *)0x0) {
(**(code **)(*plVar2 + 0x38))(plVar2,1);
}
puVar1 = (undefined8 *)param_1[0x1a4];
if (puVar1 != (undefined8 *)0x0) {
(**(code **)*puVar1)(puVar1,1);
}
puVar1 = (undefined8 *)param_1[0x1a3];
if (puVar1 != (undefined8 *)0x0) {
(**(code **)*puVar1)(puVar1,1);
}
param_1[0x13a] = djplay::DeviceAuth::Listener::vftable;
param_1[0x139] = djplay::TrackInformationManager::Listener::vftable;
param_1[0x138] = djplay::DisplayDataManager::Listener::vftable;
FUN_14246e800(param_1 + 0x134);
FUN_141f15b10(param_1);
return;
}
//===== FUN_141f63210 @ 141f63210 =====
void FUN_141f63210(longlong param_1,undefined8 *param_2,undefined4 *param_3,undefined8 param_4,
undefined1 param_5)
{
char cVar1;
undefined8 uVar2;
undefined8 local_18 [2];
switch(param_5) {
case 0x21:
cVar1 = (**(code **)(*(longlong *)(param_1 + 0xe48) + 0x10))(param_1 + 0xe48);
if (cVar1 != '\0') {
local_18[0] = 0;
uVar2 = (**(code **)(*(longlong *)(param_1 + 0xe48) + 8))(param_1 + 0xe48,local_18,param_4);
*param_2 = uVar2;
goto LAB_141f63871;
}
break;
case 0x22:
cVar1 = (**(code **)(*(longlong *)(param_1 + 0x16a8) + 0x10))(param_1 + 0x16a8);
if (cVar1 != '\0') {
local_18[0] = 0;
uVar2 = (**(code **)(*(longlong *)(param_1 + 0x16a8) + 8))(param_1 + 0x16a8,local_18,param_4);
*param_2 = uVar2;
goto LAB_141f63871;
}
break;
case 0x23:
cVar1 = (**(code **)(*(longlong *)(param_1 + 0x1b18) + 0x10))(param_1 + 0x1b18);
if (cVar1 != '\0') {
local_18[0] = 0;
uVar2 = (**(code **)(*(longlong *)(param_1 + 0x1b18) + 8))(param_1 + 0x1b18,local_18,param_4);
*param_2 = uVar2;
goto LAB_141f63871;
}
break;
case 0x24:
cVar1 = (**(code **)(*(longlong *)(param_1 + 0x1f88) + 0x10))(param_1 + 0x1f88);
if (cVar1 != '\0') {
local_18[0] = 0;
uVar2 = (**(code **)(*(longlong *)(param_1 + 0x1f88) + 8))(param_1 + 0x1f88,local_18,param_4);
*param_2 = uVar2;
goto LAB_141f63871;
}
break;
case 0x25:
cVar1 = (**(code **)(*(longlong *)(param_1 + 0x23f8) + 0x10))(param_1 + 0x23f8);
if (cVar1 != '\0') {
local_18[0] = 0;
uVar2 = (**(code **)(*(longlong *)(param_1 + 0x23f8) + 8))(param_1 + 0x23f8,local_18,param_4);
*param_2 = uVar2;
goto LAB_141f63871;
}
break;
case 0x26:
cVar1 = (**(code **)(*(longlong *)(param_1 + 0x2868) + 0x10))(param_1 + 0x2868);
if (cVar1 != '\0') {
local_18[0] = 0;
uVar2 = (**(code **)(*(longlong *)(param_1 + 0x2868) + 8))(param_1 + 0x2868,local_18,param_4);
*param_2 = uVar2;
goto LAB_141f63871;
}
break;
case 0x2a:
if ((*(longlong **)(param_1 + 0x2cd8) != (longlong *)0x0) &&
(cVar1 = (**(code **)(**(longlong **)(param_1 + 0x2cd8) + 0x10))(), cVar1 != '\0')) {
local_18[0] = 0;
uVar2 = (**(code **)(**(longlong **)(param_1 + 0x2cd8) + 8))
(*(longlong **)(param_1 + 0x2cd8),local_18,param_4);
*param_2 = uVar2;
goto LAB_141f63871;
}
break;
case 0x2b:
cVar1 = (**(code **)(*(longlong *)(param_1 + 0x2ce0) + 0x10))(param_1 + 0x2ce0);
if (cVar1 != '\0') {
local_18[0] = 0;
uVar2 = (**(code **)(*(longlong *)(param_1 + 0x2ce0) + 8))(param_1 + 0x2ce0,local_18,param_4);
*param_2 = uVar2;
goto LAB_141f63871;
}
break;
case 0x2c:
cVar1 = (**(code **)(*(longlong *)(param_1 + 0x3158) + 0x10))(param_1 + 0x3158);
if (cVar1 != '\0') {
local_18[0] = 0;
uVar2 = (**(code **)(*(longlong *)(param_1 + 0x3158) + 8))(param_1 + 0x3158,local_18,param_4);
*param_2 = uVar2;
goto LAB_141f63871;
}
break;
case 0x2d:
cVar1 = (**(code **)(*(longlong *)(param_1 + 0x35d0) + 0x10))(param_1 + 0x35d0);
if (cVar1 != '\0') {
local_18[0] = 0;
uVar2 = (**(code **)(*(longlong *)(param_1 + 0x35d0) + 8))(param_1 + 0x35d0,local_18,param_4);
*param_2 = uVar2;
goto LAB_141f63871;
}
break;
case 0x2e:
if ((*(char *)(param_1 + 0xe0c) == '\0') &&
(cVar1 = (**(code **)(*(longlong *)(param_1 + 0x3a50) + 0x10))(param_1 + 0x3a50),
cVar1 != '\0')) {
local_18[0] = 0;
uVar2 = (**(code **)(*(longlong *)(param_1 + 0x3a50) + 8))(param_1 + 0x3a50,local_18,param_4);
*param_2 = uVar2;
goto LAB_141f63871;
}
break;
case 0x2f:
if ((*(char *)(param_1 + 0xe0c) == '\0') &&
(cVar1 = (**(code **)(*(longlong *)(param_1 + 0x3ed0) + 0x10))(param_1 + 0x3ed0),
cVar1 != '\0')) {
local_18[0] = 0;
uVar2 = (**(code **)(*(longlong *)(param_1 + 0x3ed0) + 8))(param_1 + 0x3ed0,local_18,param_4);
*param_2 = uVar2;
goto LAB_141f63871;
}
break;
case 0x30:
if ((*(char *)(param_1 + 0xe0c) == '\0') &&
(cVar1 = (**(code **)(*(longlong *)(param_1 + 0x4350) + 0x10))(param_1 + 0x4350),
cVar1 != '\0')) {
local_18[0] = 0;
uVar2 = (**(code **)(*(longlong *)(param_1 + 0x4350) + 8))(param_1 + 0x4350,local_18,param_4);
*param_2 = uVar2;
goto LAB_141f63871;
}
break;
case 0x40:
cVar1 = (**(code **)(*(longlong *)(param_1 + 0x47d8) + 0x10))(param_1 + 0x47d8);
if (cVar1 != '\0') {
local_18[0] = 0;
uVar2 = (**(code **)(*(longlong *)(param_1 + 0x47d8) + 8))(param_1 + 0x47d8,local_18,param_4);
*param_2 = uVar2;
goto LAB_141f63871;
}
break;
case 0x41:
cVar1 = (**(code **)(*(longlong *)(param_1 + 0x4838) + 0x10))(param_1 + 0x4838);
if (cVar1 != '\0') {
local_18[0] = 0;
uVar2 = (**(code **)(*(longlong *)(param_1 + 0x4838) + 8))(param_1 + 0x4838,local_18,param_4);
*param_2 = uVar2;
goto LAB_141f63871;
}
break;
case 0x42:
cVar1 = (**(code **)(*(longlong *)(param_1 + 0x48a8) + 0x10))(param_1 + 0x48a8);
if (cVar1 != '\0') {
local_18[0] = 0;
uVar2 = (**(code **)(*(longlong *)(param_1 + 0x48a8) + 8))(param_1 + 0x48a8,local_18,param_4);
*param_2 = uVar2;
goto LAB_141f63871;
}
break;
case 0x43:
cVar1 = (**(code **)(*(longlong *)(param_1 + 0x4900) + 0x10))(param_1 + 0x4900);
if (cVar1 != '\0') {
local_18[0] = 0;
uVar2 = (**(code **)(*(longlong *)(param_1 + 0x4900) + 8))(param_1 + 0x4900,local_18,param_4);
*param_2 = uVar2;
goto LAB_141f63871;
}
break;
case 0x44:
cVar1 = (**(code **)(*(longlong *)(param_1 + 0x4958) + 0x10))(param_1 + 0x4958);
if (cVar1 != '\0') {
local_18[0] = 0;
uVar2 = (**(code **)(*(longlong *)(param_1 + 0x4958) + 8))(param_1 + 0x4958,local_18,param_4);
*param_2 = uVar2;
goto LAB_141f63871;
}
break;
case 0x45:
cVar1 = (**(code **)(*(longlong *)(param_1 + 0x49b0) + 0x10))(param_1 + 0x49b0);
if (cVar1 != '\0') {
local_18[0] = 0;
uVar2 = (**(code **)(*(longlong *)(param_1 + 0x49b0) + 8))(param_1 + 0x49b0,local_18,param_4);
*param_2 = uVar2;
goto LAB_141f63871;
}
break;
case 0x46:
cVar1 = (**(code **)(*(longlong *)(param_1 + 0x4a08) + 0x10))(param_1 + 0x4a08);
if (cVar1 != '\0') {
local_18[0] = 0;
uVar2 = (**(code **)(*(longlong *)(param_1 + 0x4a08) + 8))(param_1 + 0x4a08,local_18,param_4);
*param_2 = uVar2;
goto LAB_141f63871;
}
break;
case 0x47:
cVar1 = (**(code **)(*(longlong *)(param_1 + 0x4a68) + 0x10))(param_1 + 0x4a68);
if (cVar1 != '\0') {
local_18[0] = 0;
uVar2 = (**(code **)(*(longlong *)(param_1 + 0x4a68) + 8))(param_1 + 0x4a68,local_18,param_4);
*param_2 = uVar2;
goto LAB_141f63871;
}
break;
case 0x48:
cVar1 = (**(code **)(*(longlong *)(param_1 + 0x4ac8) + 0x10))(param_1 + 0x4ac8);
if (cVar1 != '\0') {
local_18[0] = 0;
uVar2 = (**(code **)(*(longlong *)(param_1 + 0x4ac8) + 8))(param_1 + 0x4ac8,local_18,param_4);
*param_2 = uVar2;
goto LAB_141f63871;
}
break;
case 0x4f:
cVar1 = (**(code **)(*(longlong *)(param_1 + 0x4b20) + 0x10))(param_1 + 0x4b20);
if (cVar1 != '\0') {
local_18[0] = 0;
uVar2 = (**(code **)(*(longlong *)(param_1 + 0x4b20) + 8))(param_1 + 0x4b20,local_18,param_4);
*param_2 = uVar2;
goto LAB_141f63871;
}
}
local_18[0]._0_4_ = 0;
*param_2 = 0;
LAB_141f63871:
*param_3 = (undefined4)local_18[0];
return;
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+85
View File
@@ -0,0 +1,85 @@
// Walk the call tree down from the export orchestrator and surface the page
// serializer: flag functions that call file-I/O APIs or use the 4096 page size,
// then decompile those candidates. Output -> ~/ghidra_proj/calltree.txt
import java.io.PrintWriter;
import java.util.*;
import ghidra.app.script.GhidraScript;
import ghidra.app.decompiler.*;
import ghidra.program.model.listing.*;
import ghidra.program.model.scalar.Scalar;
public class CallTree extends GhidraScript {
static final String ROOT = "0x14119d2d0";
static final int MAX_DEPTH = 4;
boolean hasPageConst(Function f) {
InstructionIterator it = currentProgram.getListing().getInstructions(f.getBody(), true);
while (it.hasNext()) {
Instruction insn = it.next();
for (int op = 0; op < insn.getNumOperands(); op++)
for (Object o : insn.getOpObjects(op))
if (o instanceof Scalar) {
long v = ((Scalar) o).getUnsignedValue();
if (v == 0x1000L || v == 4096L) return true;
}
}
return false;
}
String ioCalls(Function f) {
StringBuilder sb = new StringBuilder();
for (Function c : f.getCalledFunctions(monitor)) {
String n = c.getName().toLowerCase();
if (n.contains("write") || n.contains("createfile") || n.contains("fopen")
|| n.contains("fwrite") || n.contains("mapviewof") || n.contains("setfilepointer")
|| n.contains("flushview") || n.contains("fclose"))
sb.append(c.getName()).append(" ");
}
return sb.toString();
}
@Override
public void run() throws Exception {
PrintWriter out = new PrintWriter("/home/ntr/ghidra_proj/calltree.txt");
FunctionManager fm = currentProgram.getFunctionManager();
Function root = fm.getFunctionContaining(currentProgram.getAddressFactory().getAddress(ROOT));
Map<Function, Integer> depth = new LinkedHashMap<>();
Deque<Function> q = new ArrayDeque<>();
depth.put(root, 0); q.add(root);
while (!q.isEmpty() && !monitor.isCancelled()) {
Function f = q.poll();
int d = depth.get(f);
if (d >= MAX_DEPTH) continue;
for (Function c : f.getCalledFunctions(monitor))
if (!depth.containsKey(c)) { depth.put(c, d + 1); q.add(c); }
}
out.println("call tree from " + root.getName() + " depth<=" + MAX_DEPTH + ": " + depth.size() + " funcs\n");
List<Function> candidates = new ArrayList<>();
for (Map.Entry<Function, Integer> e : depth.entrySet()) {
Function f = e.getKey();
boolean page = hasPageConst(f);
String io = ioCalls(f);
String flags = (page ? "[4096]" : "") + (io.isEmpty() ? "" : "[io:" + io.trim() + "]");
out.println(String.format("d%d %-12s size=%-6d %s %s",
e.getValue(), f.getEntryPoint(), f.getBody().getNumAddresses(), f.getName(), flags));
if (page || !io.isEmpty()) candidates.add(f);
}
DecompInterface di = new DecompInterface();
di.openProgram(currentProgram);
out.println("\n===== CANDIDATE DECOMPILES (" + candidates.size() + ") =====");
for (Function f : candidates) {
if (monitor.isCancelled()) break;
out.println("\n//===== " + f.getName() + " @ " + f.getEntryPoint() + " =====");
DecompileResults r = di.decompileFunction(f, 60, monitor);
if (r != null && r.decompileCompleted())
out.println(r.getDecompiledFunction().getC());
else
out.println("// decompile failed");
}
out.flush(); out.close();
println("wrote calltree.txt: " + depth.size() + " funcs, " + candidates.size() + " candidates");
}
}
+44
View File
@@ -0,0 +1,44 @@
// Decompile the pdb export functions found via string xrefs.
import java.io.PrintWriter;
import ghidra.app.script.GhidraScript;
import ghidra.app.decompiler.*;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
public class DecPdb extends GhidraScript {
// export.pdb serializer call stack from a live Frida WriteFile backtrace.
// module+offset -> Ghidra static = 0x140000000 + offset (base confirmed).
static final String[] ADDRS = {
"0x1420e883e", // calls WriteFile
"0x1420d73a6",
"0x1420d6e92",
"0x1420d6368",
"0x1420eae07",
"0x1420e0852",
"0x1420de1c4",
"0x141d75d21", // export.pdb-specific frame
"0x141cad188",
"0x141cac585",
"0x141b8ba75",
"0x141acaf8c",
"0x1423c9b3b",
};
@Override public void run() throws Exception {
PrintWriter out = new PrintWriter("/home/ntr/ghidra_proj/decpdb.txt");
DecompInterface di = new DecompInterface();
di.openProgram(currentProgram);
for (String a : ADDRS) {
Address addr = currentProgram.getAddressFactory().getAddress(a);
Function f = currentProgram.getFunctionManager().getFunctionContaining(addr);
if (f == null) { out.println("// no function at " + a); continue; }
out.println("\n//================ " + f.getName() + " @ " + f.getEntryPoint() + " ================");
DecompileResults r = di.decompileFunction(f, 90, monitor);
if (r != null && r.decompileCompleted())
out.println(r.getDecompiledFunction().getC());
else
out.println("// decompile failed: " + (r != null ? r.getErrorMessage() : "null"));
}
out.flush(); out.close();
println("wrote decpdb.txt");
}
}
+31
View File
@@ -0,0 +1,31 @@
// Decompile the export root FUN_14119d2d0 and selected callees, to find the
// per-track row-insert that sets index_shift (0x02) and the 0x14/0x18/0x1a blob.
// Also dump callees of FUN_14117be20 (rekordbox/export.pdb string xref).
// Output -> ~/ghidra_proj/decroot.txt
import java.io.PrintWriter;
import java.util.*;
import ghidra.app.script.GhidraScript;
import ghidra.app.decompiler.*;
import ghidra.program.model.listing.*;
public class DecRoot extends GhidraScript {
PrintWriter out; DecompInterface di; FunctionManager fm;
Set<String> done = new HashSet<>();
void dec(String a, String why) {
Function f = fm.getFunctionContaining(currentProgram.getAddressFactory().getAddress(a));
if (f == null) { out.println("// no fn at " + a + " (" + why + ")"); return; }
if (!done.add(f.getEntryPoint().toString())) return;
out.println("\n//===== " + f.getName() + " @ " + f.getEntryPoint() + " [" + why + "] =====");
DecompileResults r = di.decompileFunction(f, 120, monitor);
out.println(r != null && r.decompileCompleted() ? r.getDecompiledFunction().getC() : "// decompile failed: "+(r!=null?r.getErrorMessage():"null"));
}
@Override public void run() throws Exception {
out = new PrintWriter("/home/ntr/ghidra_proj/decroot.txt");
di = new DecompInterface(); di.openProgram(currentProgram);
fm = currentProgram.getFunctionManager();
dec("0x14119d2d0", "export root (/.PIONEER/rekordbox/export.pdb)");
dec("0x14117be20", "rekordbox/export.pdb xref");
out.flush(); out.close();
println("wrote decroot.txt");
}
}
+79
View File
@@ -0,0 +1,79 @@
// Decode the track-row builder: the function that maps a djmdContent record into
// the on-disk DeviceSQL row (offset 0x02 index_shift, 0x14 u4 hash, 0x18/0x1a u2).
// Decompiles FUN_141c04be0 (AnalysisDataPath/SearchStr/MasterContentID mapper) and
// FUN_141ae4860 (djmdContent), plus scans for code referencing the constant
// 0x21b4a7ab / 0xa7ab and for `<<5` row-index assignment.
// Output -> ~/ghidra_proj/decrow.txt
import java.io.PrintWriter;
import java.util.*;
import ghidra.app.script.GhidraScript;
import ghidra.app.decompiler.*;
import ghidra.program.model.address.*;
import ghidra.program.model.listing.*;
import ghidra.program.model.scalar.Scalar;
import ghidra.program.model.lang.OperandType;
public class DecRow extends GhidraScript {
PrintWriter out;
DecompInterface di;
Set<String> done = new HashSet<>();
FunctionManager fm;
void dec(String a, String why) {
Function f = fm.getFunctionContaining(currentProgram.getAddressFactory().getAddress(a));
if (f == null) { out.println("// no function at " + a + " (" + why + ")"); return; }
if (!done.add(f.getEntryPoint().toString())) return;
out.println("\n//===== " + f.getName() + " @ " + f.getEntryPoint() + " [" + why + "] =====");
DecompileResults r = di.decompileFunction(f, 90, monitor);
out.println(r != null && r.decompileCompleted() ? r.getDecompiledFunction().getC() : "// decompile failed");
}
@Override public void run() throws Exception {
out = new PrintWriter("/home/ntr/ghidra_proj/decrow.txt");
di = new DecompInterface(); di.openProgram(currentProgram);
fm = currentProgram.getFunctionManager();
dec("0x141c04be0", "AnalysisDataPath/SearchStr/MasterContentID mapper");
dec("0x141c07230", "SearchStr 2");
dec("0x141c076d0", "SearchStr 3");
dec("0x141ae4860", "djmdContent xrefs");
// Scan all instructions for the per-export constants used in 0x18/0x1a
// (0xa7ab=42923, 0x21b4=8628, combined u4 0x21b4a7ab=565353387) and for
// an immediate 0x20 / shift-by-5 pattern that would build index_shift.
long[] consts = {0xa7abL, 0x21b4L, 0x21b4a7abL, 0xa7ab21b4L};
Set<String> hitFns = new LinkedHashSet<>();
Listing lst = currentProgram.getListing();
InstructionIterator it = lst.getInstructions(true);
int scanned = 0;
while (it.hasNext() && !monitor.isCancelled()) {
Instruction ins = it.next();
scanned++;
for (int op = 0; op < ins.getNumOperands(); op++) {
Object[] objs = ins.getOpObjects(op);
for (Object o : objs) {
if (o instanceof Scalar) {
long v = ((Scalar) o).getUnsignedValue();
for (long c : consts) {
if (v == c) {
Function f = fm.getFunctionContaining(ins.getAddress());
String tag = "const 0x" + Long.toHexString(c) + " @" + ins.getAddress()
+ " in " + (f != null ? f.getName() + "@" + f.getEntryPoint() : "?")
+ " : " + ins.toString();
out.println("// SCAN " + tag);
if (f != null) hitFns.add(f.getEntryPoint().toString() + "|const0x" + Long.toHexString(c));
}
}
}
}
}
}
out.println("// scanned " + scanned + " instructions");
for (String h : hitFns) {
String[] parts = h.split("\\|");
dec("0x" + parts[0], parts[1]);
}
out.flush(); out.close();
println("wrote decrow.txt");
}
}
+30
View File
@@ -0,0 +1,30 @@
// Decompile the prime index_shift candidate FUN_14232a86c (shl5 + mask 0x7ff) and
// a few neighboring DeviceSQL page-manager functions. Output -> ~/ghidra_proj/decshift.txt
import java.io.PrintWriter;
import java.util.*;
import ghidra.app.script.GhidraScript;
import ghidra.app.decompiler.*;
import ghidra.program.model.listing.*;
public class DecShift extends GhidraScript {
PrintWriter out; DecompInterface di; FunctionManager fm; Set<String> done=new HashSet<>();
void dec(String a, String why) {
Function f = fm.getFunctionContaining(currentProgram.getAddressFactory().getAddress(a));
if (f == null) { out.println("// no fn at " + a + " (" + why + ")"); return; }
if (!done.add(f.getEntryPoint().toString())) return;
out.println("\n//===== " + f.getName() + " @ " + f.getEntryPoint() + " [" + why + "] =====");
DecompileResults r = di.decompileFunction(f, 120, monitor);
out.println(r != null && r.decompileCompleted() ? r.getDecompiledFunction().getC() : "// decompile failed");
}
@Override public void run() throws Exception {
out = new PrintWriter("/home/ntr/ghidra_proj/decshift.txt");
di = new DecompInterface(); di.openProgram(currentProgram);
fm = currentProgram.getFunctionManager();
dec("0x14232a86c", "shl5+mask7ff PRIME index_shift candidate");
// neighbors in DeviceSQL page-manager range
dec("0x1423e1550", "page-mgr neighbor");
dec("0x1423e12a0", "page-mgr neighbor");
out.flush(); out.close();
println("wrote decshift.txt");
}
}
+47
View File
@@ -0,0 +1,47 @@
// @flesniak documented track-row 0x18=u2 "always 19048" (0x4A68) and 0x1a=u2
// "always 30967" (0x78F7). Our AceMo export has 0xa7ab / 0x21b4 instead, and a
// const-scan found no 0xa7ab/0x21b4 immediates. So locate every reference to the
// documented defaults 0x4A68 and 0x78F7 (and combined 0x78F74A68 / 0x4A6878F7),
// decompile the containing functions, and report. This should reveal how 0x18/0x1a
// are produced (literal default vs computed). Output -> ~/ghidra_proj/find4a68.txt
import java.io.PrintWriter;
import java.util.*;
import ghidra.app.script.GhidraScript;
import ghidra.app.decompiler.*;
import ghidra.program.model.listing.*;
import ghidra.program.model.scalar.Scalar;
public class Find4A68 extends GhidraScript {
@Override public void run() throws Exception {
PrintWriter out = new PrintWriter("/home/ntr/ghidra_proj/find4a68.txt");
FunctionManager fm = currentProgram.getFunctionManager();
DecompInterface di = new DecompInterface(); di.openProgram(currentProgram);
long[] consts = {0x4A68L, 0x78F7L, 0x78F74A68L, 0x4A6878F7L, 19048L, 30967L};
Set<String> fns = new LinkedHashSet<>();
InstructionIterator it = currentProgram.getListing().getInstructions(true);
while (it.hasNext() && !monitor.isCancelled()) {
Instruction ins = it.next();
for (int op=0; op<ins.getNumOperands(); op++)
for (Object o : ins.getOpObjects(op))
if (o instanceof Scalar) {
long v=((Scalar)o).getUnsignedValue();
for (long c: consts) if (v==c) {
Function f=fm.getFunctionContaining(ins.getAddress());
out.println("// HIT 0x"+Long.toHexString(c)+" @"+ins.getAddress()
+" in "+(f!=null?f.getName()+"@"+f.getEntryPoint():"?")+" : "+ins.toString());
if (f!=null) fns.add(f.getEntryPoint().toString());
}
}
}
// also search defined data for the byte patterns
out.println("// --- decompiling containing functions ---");
for (String a: fns) {
Function f=fm.getFunctionContaining(currentProgram.getAddressFactory().getAddress(a));
out.println("\n//===== "+f.getName()+" @ "+f.getEntryPoint()+" =====");
DecompileResults r=di.decompileFunction(f,90,monitor);
out.println(r!=null&&r.decompileCompleted()?r.getDecompiledFunction().getC():"// decompile failed");
}
out.flush(); out.close();
println("wrote find4a68.txt");
}
}
+44
View File
@@ -0,0 +1,44 @@
// Anchor the track-row builder via the track-row bitmask 0xc0700 (constant in
// every AceMo track row at offset 0x04) and the subtype/0x24 nearby. Find any
// instruction with immediate 0xc0700 and decompile the function. Output ->
// ~/ghidra_proj/findbitmask.txt
import java.io.PrintWriter;
import java.util.*;
import ghidra.app.script.GhidraScript;
import ghidra.app.decompiler.*;
import ghidra.program.model.listing.*;
import ghidra.program.model.scalar.Scalar;
public class FindBitmask extends GhidraScript {
@Override public void run() throws Exception {
PrintWriter out = new PrintWriter("/home/ntr/ghidra_proj/findbitmask.txt");
FunctionManager fm = currentProgram.getFunctionManager();
DecompInterface di = new DecompInterface(); di.openProgram(currentProgram);
long[] consts = {0xc0700L, 0x700L, 0xc07L};
Set<String> fns = new LinkedHashSet<>();
InstructionIterator it = currentProgram.getListing().getInstructions(true);
while (it.hasNext() && !monitor.isCancelled()) {
Instruction ins = it.next();
for (int op=0; op<ins.getNumOperands(); op++)
for (Object o : ins.getOpObjects(op))
if (o instanceof Scalar) {
long v=((Scalar)o).getUnsignedValue();
if (v==0xc0700L) {
Function f=fm.getFunctionContaining(ins.getAddress());
out.println("// HIT 0xc0700 @"+ins.getAddress()+" in "
+(f!=null?f.getName()+"@"+f.getEntryPoint():"?")+" : "+ins.toString());
if (f!=null) fns.add(f.getEntryPoint().toString());
}
}
}
out.println("// --- decompiling ---");
for (String a: fns) {
Function f=fm.getFunctionContaining(currentProgram.getAddressFactory().getAddress(a));
out.println("\n//===== "+f.getName()+" @ "+f.getEntryPoint()+" =====");
DecompileResults r=di.decompileFunction(f,120,monitor);
out.println(r!=null&&r.decompileCompleted()?r.getDecompiledFunction().getC():"// fail");
}
out.flush(); out.close();
println("wrote findbitmask.txt");
}
}
+28
View File
@@ -0,0 +1,28 @@
// Confirm 0x78f74a68 / 0x4a68 / 0x78f7 is rekordbox's default opaque_seed by
// searching DEFINED DATA (not just instruction immediates) for the byte pattern
// 68 4a f7 78 (LE u4) and the two u2s, and report containing context.
// Output -> ~/ghidra_proj/finddefault.txt
import java.io.PrintWriter;
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.*;
import ghidra.program.model.mem.*;
public class FindDefault extends GhidraScript {
@Override public void run() throws Exception {
PrintWriter out = new PrintWriter("/home/ntr/ghidra_proj/finddefault.txt");
Memory mem = currentProgram.getMemory();
byte[] pat = { (byte)0x68,(byte)0x4a,(byte)0xf7,(byte)0x78 }; // 0x78f74a68 LE
Address a = mem.getMinAddress();
int found=0;
while (a != null && found < 50) {
Address hit = mem.findBytes(a, pat, null, true, monitor);
if (hit == null) break;
out.println("// 0x78f74a68 bytes @ " + hit);
found++;
a = hit.add(1);
}
out.println("// total occurrences of 0x78f74a68 LE in memory image: " + found);
out.flush(); out.close();
println("wrote finddefault.txt found="+found);
}
}
+78
View File
@@ -0,0 +1,78 @@
// Hunt for the track-row builder / the per-track 8-byte hash (row offsets
// 0x14 u4, 0x18 u2, 0x1a u2) and index_shift (0x02). Decompiles the string-hash
// function formatForSearch + its callers, and dumps xrefs of track/content/
// search-related strings. Output -> ~/ghidra_proj/hash.txt
import java.io.PrintWriter;
import java.util.*;
import ghidra.app.script.GhidraScript;
import ghidra.app.decompiler.*;
import ghidra.program.model.listing.*;
import ghidra.program.model.symbol.*;
public class FindHash extends GhidraScript {
PrintWriter out;
DecompInterface di;
Set<String> done = new HashSet<>();
FunctionManager fm;
ReferenceManager rm;
void dec(Function f, String why) {
if (f == null) return;
if (!done.add(f.getEntryPoint().toString())) return;
out.println("\n//===== " + f.getName() + " @ " + f.getEntryPoint() + " [" + why + "] =====");
DecompileResults r = di.decompileFunction(f, 60, monitor);
out.println(r != null && r.decompileCompleted() ? r.getDecompiledFunction().getC() : "// decompile failed");
}
Function fn(String a) {
return fm.getFunctionContaining(currentProgram.getAddressFactory().getAddress(a));
}
@Override
public void run() throws Exception {
out = new PrintWriter("/home/ntr/ghidra_proj/hash.txt");
di = new DecompInterface(); di.openProgram(currentProgram);
fm = currentProgram.getFunctionManager();
rm = currentProgram.getReferenceManager();
// 1. formatForSearch (string hash) + its callers
Function fmt = fn("0x141a1afc0");
dec(fmt, "formatForSearch");
if (fmt != null) {
int n = 0;
for (Reference ref : rm.getReferencesTo(fmt.getEntryPoint())) {
if (n++ >= 10) break;
dec(fm.getFunctionContaining(ref.getFromAddress()), "caller of formatForSearch");
}
}
// 2. strings that hint at the track/content row builder -> xref functions
String[] kw = {"djmdContent", "SearchStr", "formatForSearch", "AnalysisDataPath",
"ContentID", "rb_data_status", "rb_local", "dsqlInsert", "AddTrack"};
DataIterator dit = currentProgram.getListing().getDefinedData(true);
int shown = 0;
while (dit.hasNext() && !monitor.isCancelled()) {
Data d = dit.next();
if (d == null || !d.hasStringValue()) continue;
Object v = d.getValue();
if (!(v instanceof String)) continue;
String s = (String) v;
for (String k : kw) {
if (s.contains(k)) {
out.println("\n// STRING @" + d.getAddress() + " : " + s);
int n = 0;
for (Reference ref : rm.getReferencesTo(d.getAddress())) {
if (n++ >= 5) break;
Function c = fm.getFunctionContaining(ref.getFromAddress());
out.println("// xref in " + (c != null ? c.getName() + " @" + c.getEntryPoint() : ref.getFromAddress()));
}
shown++;
break;
}
}
if (shown >= 30) break;
}
out.flush(); out.close();
println("wrote hash.txt");
}
}
+96
View File
@@ -0,0 +1,96 @@
// Locate the rekordbox .pdb serializer via known strings + magic-number constants.
// Run headless: analyzeHeadless <proj> rbx -process rekordbox.exe -noanalysis \
// -scriptPath ~/ghidra_scripts -postScript FindPdb.java
// Output goes to ~/ghidra_proj/findpdb.txt (and the log).
import java.io.PrintWriter;
import java.util.*;
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.*;
import ghidra.program.model.scalar.Scalar;
import ghidra.program.model.symbol.*;
public class FindPdb extends GhidraScript {
// Strings that mark the export code paths.
static final String[] KEYWORDS = {
"export.pdb", "exportExt.pdb", "USBANLZ", "PIONEER",
"ANLZ", "HOT CUE BANK", "ORIGINAL ARTIST", "rekordbox/export"
};
// Magic numbers maple currently guesses (various widths/combinations).
static final long[] MAGICS = {
42343L, 54995L, // 0xA567 / 0xD6D3 per-track markers
0xD6D3A567L, 0xA567D6D3L, // combined 32-bit forms
788224L, // 0x000C0700 track bitmask
1004L, // 0x03EC "strange page" marker
0x64L, 0x44L, 0x24L, // page-flag bytes
19048L, 30967L // the schema author's original guesses
};
PrintWriter out;
void emit(String s) { println(s); if (out != null) out.println(s); }
@Override
public void run() throws Exception {
out = new PrintWriter("/home/ntr/ghidra_proj/findpdb.txt");
Listing listing = currentProgram.getListing();
ReferenceManager rm = currentProgram.getReferenceManager();
FunctionManager fm = currentProgram.getFunctionManager();
emit("===== STRING XREFS =====");
DataIterator di = listing.getDefinedData(true);
while (di.hasNext() && !monitor.isCancelled()) {
Data d = di.next();
if (d == null || !d.hasStringValue()) continue;
Object val = d.getValue();
if (!(val instanceof String)) continue;
String s = (String) val;
for (String kw : KEYWORDS) {
if (s.contains(kw)) {
emit("\nSTRING @ " + d.getAddress() + " : " + s.replace("\n", "\\n"));
ReferenceIterator ri = rm.getReferencesTo(d.getAddress());
int n = 0;
while (ri.hasNext() && n++ < 25) {
Reference r = ri.next();
Function f = fm.getFunctionContaining(r.getFromAddress());
emit(" xref from " + r.getFromAddress()
+ (f != null ? " in " + f.getName() + " @ " + f.getEntryPoint() : " (no func)"));
}
break;
}
}
}
emit("\n===== CONSTANT XREFS =====");
Set<Long> targets = new HashSet<>();
for (long m : MAGICS) targets.add(m);
Map<Long, Integer> counts = new TreeMap<>();
InstructionIterator ii = listing.getInstructions(true);
while (ii.hasNext() && !monitor.isCancelled()) {
Instruction insn = ii.next();
for (int op = 0; op < insn.getNumOperands(); op++) {
for (Object o : insn.getOpObjects(op)) {
if (o instanceof Scalar) {
long v = ((Scalar) o).getUnsignedValue();
if (targets.contains(v)) {
Function f = fm.getFunctionContaining(insn.getAddress());
emit(String.format("CONST 0x%X (%d) @ %s %s in %s", v, v,
insn.getAddress(), insn.toString(),
f != null ? f.getName() + " @ " + f.getEntryPoint() : "(no func)"));
counts.merge(v, 1, Integer::sum);
}
}
}
}
}
emit("\n===== CONSTANT HIT COUNTS =====");
for (Map.Entry<Long, Integer> e : counts.entrySet())
emit(String.format("0x%X (%d): %d hits", e.getKey(), e.getKey(), e.getValue()));
out.flush();
out.close();
emit("\nwrote /home/ntr/ghidra_proj/findpdb.txt");
}
}
+51
View File
@@ -0,0 +1,51 @@
// Find the DeviceSQL row-index code that computes index_shift = slot << 5.
// Heuristic: functions that contain BOTH a left-shift-by-5 (SHL r,5 / LEA r,[r*0x20])
// AND reference the row-index masks (0x1fff / 0x7ff) or stride 0x24, AND a u2 store.
// Output -> ~/ghidra_proj/findshift.txt
import java.io.PrintWriter;
import java.util.*;
import ghidra.app.script.GhidraScript;
import ghidra.program.model.listing.*;
import ghidra.program.model.scalar.Scalar;
public class FindShift extends GhidraScript {
@Override public void run() throws Exception {
PrintWriter out = new PrintWriter("/home/ntr/ghidra_proj/findshift.txt");
FunctionManager fm = currentProgram.getFunctionManager();
Listing lst = currentProgram.getListing();
// accumulate per-function evidence
Map<String,Set<String>> ev = new HashMap<>();
InstructionIterator it = lst.getInstructions(true);
while (it.hasNext() && !monitor.isCancelled()) {
Instruction ins = it.next();
Function f = fm.getFunctionContaining(ins.getAddress());
if (f == null) continue;
String key = f.getEntryPoint().toString();
String mn = ins.getMnemonicString();
String txt = ins.toString();
boolean shl5 = (mn.equalsIgnoreCase("SHL") && txt.endsWith(",0x5"))
|| (mn.equalsIgnoreCase("LEA") && txt.contains("*0x20"));
if (shl5) ev.computeIfAbsent(key,k->new HashSet<>()).add("shl5@"+ins.getAddress());
for (int op=0; op<ins.getNumOperands(); op++)
for (Object o : ins.getOpObjects(op))
if (o instanceof Scalar) {
long v = ((Scalar)o).getUnsignedValue();
if (v==0x1fff) ev.computeIfAbsent(key,k->new HashSet<>()).add("mask1fff@"+ins.getAddress());
if (v==0x7ff) ev.computeIfAbsent(key,k->new HashSet<>()).add("mask7ff@"+ins.getAddress());
if (v==0x24) ev.computeIfAbsent(key,k->new HashSet<>()).add("stride24@"+ins.getAddress());
}
}
// report functions that have shl5 AND (mask or stride)
for (Map.Entry<String,Set<String>> e : ev.entrySet()) {
Set<String> tags = e.getValue();
boolean hasShl = tags.stream().anyMatch(t->t.startsWith("shl5"));
boolean hasCtx = tags.stream().anyMatch(t->t.startsWith("mask1fff")||t.startsWith("mask7ff")||t.startsWith("stride24"));
if (hasShl && hasCtx) {
Function f = fm.getFunctionContaining(currentProgram.getAddressFactory().getAddress(e.getKey()));
out.println("CANDIDATE " + (f!=null?f.getName():"?") + " @" + e.getKey() + " : " + tags);
}
}
out.flush(); out.close();
println("wrote findshift.txt");
}
}
View File
Whitespace-only changes.
+673
View File
@@ -0,0 +1,673 @@
meta:
id: rekordbox_anlz
title: rekordbox track analysis file
application: rekordbox
file-extension:
- dat
- ext
license: EPL-1.0
endian: be
doc: |
These files are created by rekordbox when analyzing audio tracks
to facilitate DJ performance. They include waveforms, beat grids
(information about the precise time at which each beat occurs),
time indices to allow efficient seeking to specific positions
inside variable bit-rate audio streams, and lists of memory cues
and loop points. They are used by Pioneer professional DJ
equipment.
The format has been reverse-engineered to facilitate sophisticated
integrations with light and laser shows, videos, and other musical
instruments, by supporting deep knowledge of what is playing and
what is coming next through monitoring the network communications
of the players.
doc-ref: https://reverseengineering.stackexchange.com/questions/4311/help-reversing-a-edb-database-file-for-pioneers-rekordbox-software
seq:
- id: magic
contents: "PMAI"
doc: Identifies this as an analysis file.
- id: len_header
type: u4
doc: |
The number of bytes of this header section.
- id: len_file
type: u4
doc: |
The number of bytes in the entire file.
- size: len_header - _io.pos
- id: sections
type: tagged_section
repeat: eos
doc: |
The remainder of the file is a sequence of type-tagged sections,
identified by a four-byte magic sequence.
types:
tagged_section:
doc: |
A type-tagged file section, identified by a four-byte magic
sequence, with a header specifying its length, and whose payload
is determined by the type tag.
seq:
- id: fourcc
type: s4
enum: section_tags
doc: |
A tag value indicating what kind of section this is.
- id: len_header
type: u4
doc: |
The size, in bytes, of the header portion of the tag.
- id: len_tag
type: u4
doc: |
The size, in bytes, of this entire tag, counting the header.
- id: body
size: len_tag - 12
type:
switch-on: fourcc
cases:
'section_tags::cues_2': cue_extended_tag # PCO2
'section_tags::cues': cue_tag # PCOB
'section_tags::path': path_tag # PPTH
'section_tags::beat_grid': beat_grid_tag # PQTZ
'section_tags::vbr': vbr_tag # PVBR
'section_tags::wave_preview': wave_preview_tag # PWAV
'section_tags::wave_tiny': wave_preview_tag # PWV2
'section_tags::wave_scroll': wave_scroll_tag # PWV3, seen in .EXT
'section_tags::wave_color_preview': wave_color_preview_tag # PWV4, in .EXT
'section_tags::wave_color_scroll': wave_color_scroll_tag # PWV5, in .EXT
'section_tags::wave_3band_preview': wave_3band_preview_tag # P@V6, in .2EX
'section_tags::wave_3band_scroll': wave_3band_scroll_tag # PWV7, in .2EX
'section_tags::song_structure': song_structure_tag # PSSI, in .EXT
_: unknown_tag
-webide-representation: '{fourcc}'
beat_grid_tag:
doc: |
Holds a list of all the beats found within the track, recording
their bar position, the time at which they occur, and the tempo
at that point.
seq:
- type: u4
- type: u4 # @flesniak says this is always 0x80000
- id: num_beats
type: u4
doc: |
The number of beat entries which follow.
- id: beats
type: beat_grid_beat
repeat: expr
repeat-expr: num_beats
doc: The entries of the beat grid.
beat_grid_beat:
doc: |
Describes an individual beat in a beat grid.
seq:
- id: beat_number
type: u2
doc: |
The position of the beat within its musical bar, where beat 1
is the down beat.
- id: tempo
type: u2
doc: |
The tempo at the time of this beat, in beats per minute,
multiplied by 100.
- id: time
type: u4
doc: |
The time, in milliseconds, at which this beat occurs when
the track is played at normal (100%) pitch.
cue_tag:
doc: |
Stores either a list of ordinary memory cues and loop points, or
a list of hot cues and loop points.
seq:
- id: type
type: u4
enum: cue_list_type
doc: |
Identifies whether this tag stores ordinary or hot cues.
- size: 2
- id: num_cues
type: u2
doc: |
The length of the cue list.
- id: memory_count
type: u4
doc: |
Unsure what this means.
- id: cues
type: cue_entry
repeat: expr
repeat-expr: num_cues
cue_entry:
doc: |
A cue list entry. Can either represent a memory cue or a loop.
seq:
- id: magic
contents: "PCPT"
doc: Identifies this as a cue list entry (cue point).
- id: len_header
type: u4
- id: len_entry
type: u4
- id: hot_cue
type: u4
doc: |
If zero, this is an ordinary memory cue, otherwise this a
hot cue with the specified number.
- id: status
type: u4
enum: cue_entry_status
doc: |
Indicates if this is an active loop.
- type: u4 # Seems to always be 0x10000
- id: order_first
type: u2
doc: |
@flesniak says: "0xffff for first cue, 0,1,3 for next"
- id: order_last
type: u2
doc: |
@flesniak says: "1,2,3 for first, second, third cue, 0xffff for last"
- id: type
type: u1
enum: cue_entry_type
doc: |
Indicates whether this is a memory cue or a loop.
- size: 3 # seems to always be 1000
- id: time
type: u4
doc: |
The position, in milliseconds, at which the cue point lies
in the track.
- id: loop_time
type: u4
doc: |
The position, in milliseconds, at which the player loops
back to the cue time if this is a loop.
- size: 16
cue_extended_tag:
doc: |
A variation of cue_tag which was introduced with the nxs2 line,
and adds descriptive names. (Still comes in two forms, either
holding memory cues and loop points, or holding hot cues and
loop points.) Also includes hot cues D through H and color assignment.
seq:
- id: type
type: u4
enum: cue_list_type
doc: |
Identifies whether this tag stores ordinary or hot cues.
- id: num_cues
type: u2
doc: |
The length of the cue comment list.
- size: 2
- id: cues
type: cue_extended_entry
repeat: expr
repeat-expr: num_cues
cue_extended_entry:
doc: |
A cue extended list entry. Can either describe a memory cue or a
loop.
seq:
- id: magic
contents: "PCP2"
doc: Identifies this as an extended cue list entry (cue point).
- id: len_header
type: u4
- id: len_entry
type: u4
- id: hot_cue
type: u4
doc: |
If zero, this is an ordinary memory cue, otherwise this a
hot cue with the specified number.
- id: type
type: u1
enum: cue_entry_type
doc: |
Indicates whether this is a regular cue point or a loop.
- size: 3 # seems to always be 1000
- id: time
type: u4
doc: |
The position, in milliseconds, at which the cue point lies
in the track.
- id: loop_time
type: u4
doc: |
The position, in milliseconds, at which the player loops
back to the cue time if this is a loop.
- id: color_id
type: u1
doc: |
References a row in the colors table if this is a memory cue or loop
and has been assigned a color.
- size: 7
- id: loop_numerator
type: u2
doc: |
The numerator of the loop length in beats.
Zero if the loop is not quantized.
- id: loop_denominator
type: u2
doc: |
The denominator of the loop length in beats.
Zero if the loop is not quantized.
- id: len_comment
type: u4
if: len_entry > 43
- id: comment
type: str
size: len_comment
encoding: utf-16be
doc: |
The comment assigned to this cue by the DJ, if any, with a trailing NUL.
if: len_entry > 43
- id: color_code
type: u1
doc: |
A lookup value for a color table? We use this to index to the hot cue colors shown in rekordbox.
if: (len_entry - len_comment) > 44
- id: color_red
type: u1
doc: |
The red component of the hot cue color to be displayed.
if: (len_entry - len_comment) > 45
- id: color_green
type: u1
doc: |
The green component of the hot cue color to be displayed.
if: (len_entry - len_comment) > 46
- id: color_blue
type: u1
doc: |
The blue component of the hot cue color to be displayed.
if: (len_entry - len_comment) > 47
- size: len_entry - 48 - len_comment # The remainder after the color
if: (len_entry - len_comment) > 48
path_tag:
doc: |
Stores the file path of the audio file to which this analysis
applies.
seq:
- id: len_path
type: u4
- id: path
type: str
size: len_path - 2
encoding: utf-16be
if: len_path > 1
-webide-representation: '{path}'
vbr_tag:
doc: |
Stores an index allowing rapid seeking to particular times
within a variable-bitrate audio file.
seq:
- type: u4
- id: index
type: u4
repeat: expr
repeat-expr: 400
wave_preview_tag:
doc: |
Stores a waveform preview image suitable for display above
the touch strip for jumping to a track position.
seq:
- id: len_data
type: u4
doc: |
The length, in bytes, of the preview data itself. This is
slightly redundant because it can be computed from the
length of the tag.
- type: u4 # This seems to always have the value 0x10000
- id: data
size: len_data
doc: |
The actual bytes of the waveform preview.
if: _parent.len_tag > _parent.len_header
wave_scroll_tag:
doc: |
A larger waveform image suitable for scrolling along as a track
plays.
seq:
- id: len_entry_bytes
type: u4
doc: |
The size of each entry, in bytes. Seems to always be 1.
- id: len_entries
type: u4
doc: |
The number of waveform data points, each of which takes one
byte.
- type: u4 # Always 0x960000?
- id: entries
size: len_entries * len_entry_bytes
wave_color_preview_tag:
doc: |
A larger, colorful waveform preview image suitable for display
above the touch strip for jumping to a track position on newer
high-resolution players.
seq:
- id: len_entry_bytes
type: u4
doc: |
The size of each entry, in bytes. Seems to always be 6.
- id: len_entries
type: u4
doc: |
The number of waveform data points, each of which takes one
byte for each of six channels of information.
- type: u4
- id: entries
size: len_entries * len_entry_bytes
wave_color_scroll_tag:
doc: |
A larger, colorful waveform image suitable for scrolling along
as a track plays on newer high-resolution hardware. Also
contains a higher-resolution blue/white waveform.
seq:
- id: len_entry_bytes
type: u4
doc: |
The size of each entry, in bytes. Seems to always be 2.
- id: len_entries
type: u4
doc: |
The number of columns of waveform data (this matches the
non-color waveform length.
- type: u4
- id: entries
size: len_entries * len_entry_bytes
wave_3band_preview_tag:
doc: |
The minimalist CDJ-3000 waveform preview image suitable for display
above the touch strip for jumping to a track position.
seq:
- id: len_entry_bytes
type: u4
doc: |
The size of each entry, in bytes. Seems to always be 3.
- id: len_entries
type: u4
doc: |
The number of waveform data points, each of which takes one
byte for each of six channels of information.
- id: entries
size: len_entries * len_entry_bytes
wave_3band_scroll_tag:
doc: |
The minimalist CDJ-3000 waveform image suitable for scrolling along
as a track plays on newer high-resolution hardware.
seq:
- id: len_entry_bytes
type: u4
doc: |
The size of each entry, in bytes. Seems to always be 3.
- id: len_entries
type: u4
doc: |
The number of columns of waveform data (this matches the
non-color waveform length.
- type: u4
- id: entries
size: len_entries * len_entry_bytes
song_structure_tag:
doc: |
Stores the song structure, also known as phrases (intro, verse,
bridge, chorus, up, down, outro).
seq:
- id: len_entry_bytes
type: u4
doc: |
The size of each entry, in bytes. Seems to always be 24.
- id: len_entries
type: u2
doc: |
The number of phrases.
- id: body
type: song_structure_body
doc: |
The rest of the tag, which needs to be unmasked before it
can be parsed.
size-eos: true
process: 'xor(is_masked ? mask : [0])'
instances:
c:
value: len_entries
mask:
value: |
[
(0xCB+c).as<s1>, (0xE1+c).as<s1>, (0xEE+c).as<s1>, (0xFA+c).as<s1>, (0xE5+c).as<s1>, (0xEE+c).as<s1>, (0xAD+c).as<s1>, (0xEE+c).as<s1>,
(0xE9+c).as<s1>, (0xD2+c).as<s1>, (0xE9+c).as<s1>, (0xEB+c).as<s1>, (0xE1+c).as<s1>, (0xE9+c).as<s1>, (0xF3+c).as<s1>, (0xE8+c).as<s1>,
(0xE9+c).as<s1>, (0xF4+c).as<s1>, (0xE1+c).as<s1>
].as<bytes>
raw_mood:
type: u2
pos: 6
doc: |
This is a way to tell whether the rest of the tag has been masked. The value is supposed
to range from 1 to 3, but in masked files it will be much larger.
is_masked:
value: 'raw_mood > 20' # This is almost certainly not true for an unmasked file.
-webide-representation: '{body.mood}'
song_structure_body:
doc: |
Stores the rest of the song structure tag, which can only be
parsed after unmasking.
seq:
- id: mood
type: u2
enum: track_mood
doc: |
The mood which rekordbox assigns the track as a whole during phrase analysis.
- size: 6
- id: end_beat
type: u2
doc: |
The beat number at which the last phrase ends. The track may
continue after the last phrase ends. If this is the case, it will
mostly be silence.
- size: 2
- id: raw_bank
type: u1
doc: |
Number identifying a stylistic bank which can be assigned to the track in rekordbox Lighting mode.
- size: 1
- id: entries
type: song_structure_entry
repeat: expr
repeat-expr: _parent.len_entries
instances:
bank:
value: raw_bank
enum: track_bank
doc: |
The stylistic bank which can be assigned to the track in rekordbox Lighting mode, if raw_bank has a legal value.
if: 'raw_bank < 9'
song_structure_entry:
doc: |
A song structure entry, represents a single phrase.
seq:
- id: index
type: u2
doc: |
The absolute number of the phrase, starting at one.
- id: beat
type: u2
doc: |
The beat number at which the phrase starts.
- id: kind
type:
switch-on: _parent.mood
cases:
'track_mood::high': phrase_high
'track_mood::mid': phrase_mid
'track_mood::low': phrase_low
_: phrase_mid # We don't recognize this mood, so pick a generic interpretation.
doc: |
The kind of phrase as displayed in rekordbox.
- size: 1
- id: k1
type: u1
doc: One of three flags that identify phrase kind variants in high-mood tracks.
- size: 1
- id: k2
type: u1
doc: One of three flags that identify phrase kind variants in high-mood tracks.
- size: 1
- id: b
type: u1
doc: |
Flags how many more beat numbers are in a high-mood "Up 3" phrase.
- id: beat2
type: u2
doc: |
Extra beat number (falling within phrase) always present in high-mood "Up 3" phrases.
- id: beat3
type: u2
doc: |
Extra beat number (falling within phrase, larger than beat2)
present in high-mood "Up 3" phrases when b has value 1.
- id: beat4
type: u2
doc: |
Extra beat number (falling within phrase, larger than beat3)
present in high-mood "Up 3" phrases when b has value 1.
- size: 1
- id: k3
type: u1
doc: One of three flags that identify phrase kind variants in high-mood tracks.
- size: 1
- id: fill
type: u1
doc: |
If nonzero, fill-in is present at end of phrase.
- id: beat_fill
type: u2
doc: |
The beat number at which fill-in starts.
-webide-representation: '{kind.id}'
phrase_high:
seq:
- id: id
type: u2
enum: mood_high_phrase
phrase_mid:
seq:
- id: id
type: u2
enum: mood_mid_phrase
phrase_low:
seq:
- id: id
type: u2
enum: mood_low_phrase
unknown_tag: {}
enums:
section_tags:
0x50434f42: cues # PCOB
0x50434f32: cues_2 # PCO2 (seen in .EXT)
0x50505448: path # PPTH
0x50564252: vbr # PVBR
0x5051545a: beat_grid # PQTZ
0x50574156: wave_preview # PWAV
0x50575632: wave_tiny # PWV2
0x50575633: wave_scroll # PWV3 (seen in .EXT)
0x50575634: wave_color_preview # PWV4 (seen in .EXT)
0x50575635: wave_color_scroll # PWV5 (seen in .EXT)
0x50535349: song_structure # PSSI (seen in .EXT)
0x50575636: wave_3band_preview # PWV6 (seen in .2EX)
0x50575637: wave_3band_scroll # PWV7 (seen in .2EX)
cue_list_type:
0: memory_cues
1: hot_cues
cue_entry_type:
1: memory_cue
2: loop
cue_entry_status:
0: disabled
1: enabled
4: active_loop
track_mood:
1: high
2: mid
3: low
mood_low_phrase:
1: intro
2: verse_1
3: verse_1b # Just displayed as "Verse 1" in rekordbox.
4: verse_1c # Just displayed as "Verse 1" in rekordbox.
5: verse_2
6: verse_2b # Just displayed as "Verse 2" in rekordbox.
7: verse_2c # Just displayed as "Verse 2" in rekordbox.
8: bridge
9: chorus
10: outro
mood_mid_phrase:
1: intro
2: verse_1
3: verse_2
4: verse_3
5: verse_4
6: verse_5
7: verse_6
8: bridge
9: chorus
10: outro
mood_high_phrase:
1: intro
2: up
3: down
5: chorus
6: outro
track_bank:
0: default
1: cool
2: natural
3: hot
4: subtle
5: warm
6: vivid
7: club_1
8: club_2
+897
View File
@@ -0,0 +1,897 @@
# This is a generated file! Please edit source .ksy file and use kaitai-struct-compiler to rebuild
# type: ignore
import kaitaistruct
from kaitaistruct import KaitaiStruct, KaitaiStream, BytesIO
from enum import IntEnum
import struct
if getattr(kaitaistruct, 'API_VERSION', (0, 9)) < (0, 11):
raise Exception("Incompatible Kaitai Struct Python API: 0.11 or later is required, but you have %s" % (kaitaistruct.__version__))
class RekordboxAnlz(KaitaiStruct):
"""These files are created by rekordbox when analyzing audio tracks
to facilitate DJ performance. They include waveforms, beat grids
(information about the precise time at which each beat occurs),
time indices to allow efficient seeking to specific positions
inside variable bit-rate audio streams, and lists of memory cues
and loop points. They are used by Pioneer professional DJ
equipment.
The format has been reverse-engineered to facilitate sophisticated
integrations with light and laser shows, videos, and other musical
instruments, by supporting deep knowledge of what is playing and
what is coming next through monitoring the network communications
of the players.
.. seealso::
Source - https://reverseengineering.stackexchange.com/questions/4311/help-reversing-a-edb-database-file-for-pioneers-rekordbox-software
"""
class CueEntryStatus(IntEnum):
disabled = 0
enabled = 1
active_loop = 4
class CueEntryType(IntEnum):
memory_cue = 1
loop = 2
class CueListType(IntEnum):
memory_cues = 0
hot_cues = 1
class MoodHighPhrase(IntEnum):
intro = 1
up = 2
down = 3
chorus = 5
outro = 6
class MoodLowPhrase(IntEnum):
intro = 1
verse_1 = 2
verse_1b = 3
verse_1c = 4
verse_2 = 5
verse_2b = 6
verse_2c = 7
bridge = 8
chorus = 9
outro = 10
class MoodMidPhrase(IntEnum):
intro = 1
verse_1 = 2
verse_2 = 3
verse_3 = 4
verse_4 = 5
verse_5 = 6
verse_6 = 7
bridge = 8
chorus = 9
outro = 10
class SectionTags(IntEnum):
cues_2 = 1346588466
cues = 1346588482
path = 1347441736
beat_grid = 1347507290
song_structure = 1347638089
vbr = 1347830354
wave_preview = 1347895638
wave_tiny = 1347900978
wave_scroll = 1347900979
wave_color_preview = 1347900980
wave_color_scroll = 1347900981
wave_3band_preview = 1347900982
wave_3band_scroll = 1347900983
class TrackBank(IntEnum):
default = 0
cool = 1
natural = 2
hot = 3
subtle = 4
warm = 5
vivid = 6
club_1 = 7
club_2 = 8
class TrackMood(IntEnum):
high = 1
mid = 2
low = 3
def __init__(self, _io, _parent=None, _root=None):
super(RekordboxAnlz, self).__init__(_io)
self._parent = _parent
self._root = _root or self
self._read()
def _read(self):
self.magic = self._io.read_bytes(4)
if not self.magic == b"\x50\x4D\x41\x49":
raise kaitaistruct.ValidationNotEqualError(b"\x50\x4D\x41\x49", self.magic, self._io, u"/seq/0")
self.len_header = self._io.read_u4be()
self.len_file = self._io.read_u4be()
self._unnamed3 = self._io.read_bytes(self.len_header - self._io.pos())
self.sections = []
i = 0
while not self._io.is_eof():
self.sections.append(RekordboxAnlz.TaggedSection(self._io, self, self._root))
i += 1
def _fetch_instances(self):
pass
for i in range(len(self.sections)):
pass
self.sections[i]._fetch_instances()
class BeatGridBeat(KaitaiStruct):
"""Describes an individual beat in a beat grid.
"""
def __init__(self, _io, _parent=None, _root=None):
super(RekordboxAnlz.BeatGridBeat, self).__init__(_io)
self._parent = _parent
self._root = _root
self._read()
def _read(self):
self.beat_number = self._io.read_u2be()
self.tempo = self._io.read_u2be()
self.time = self._io.read_u4be()
def _fetch_instances(self):
pass
class BeatGridTag(KaitaiStruct):
"""Holds a list of all the beats found within the track, recording
their bar position, the time at which they occur, and the tempo
at that point.
"""
def __init__(self, _io, _parent=None, _root=None):
super(RekordboxAnlz.BeatGridTag, self).__init__(_io)
self._parent = _parent
self._root = _root
self._read()
def _read(self):
self._unnamed0 = self._io.read_u4be()
self._unnamed1 = self._io.read_u4be()
self.num_beats = self._io.read_u4be()
self.beats = []
for i in range(self.num_beats):
self.beats.append(RekordboxAnlz.BeatGridBeat(self._io, self, self._root))
def _fetch_instances(self):
pass
for i in range(len(self.beats)):
pass
self.beats[i]._fetch_instances()
class CueEntry(KaitaiStruct):
"""A cue list entry. Can either represent a memory cue or a loop.
"""
def __init__(self, _io, _parent=None, _root=None):
super(RekordboxAnlz.CueEntry, self).__init__(_io)
self._parent = _parent
self._root = _root
self._read()
def _read(self):
self.magic = self._io.read_bytes(4)
if not self.magic == b"\x50\x43\x50\x54":
raise kaitaistruct.ValidationNotEqualError(b"\x50\x43\x50\x54", self.magic, self._io, u"/types/cue_entry/seq/0")
self.len_header = self._io.read_u4be()
self.len_entry = self._io.read_u4be()
self.hot_cue = self._io.read_u4be()
self.status = KaitaiStream.resolve_enum(RekordboxAnlz.CueEntryStatus, self._io.read_u4be())
self._unnamed5 = self._io.read_u4be()
self.order_first = self._io.read_u2be()
self.order_last = self._io.read_u2be()
self.type = KaitaiStream.resolve_enum(RekordboxAnlz.CueEntryType, self._io.read_u1())
self._unnamed9 = self._io.read_bytes(3)
self.time = self._io.read_u4be()
self.loop_time = self._io.read_u4be()
self._unnamed12 = self._io.read_bytes(16)
def _fetch_instances(self):
pass
class CueExtendedEntry(KaitaiStruct):
"""A cue extended list entry. Can either describe a memory cue or a
loop.
"""
def __init__(self, _io, _parent=None, _root=None):
super(RekordboxAnlz.CueExtendedEntry, self).__init__(_io)
self._parent = _parent
self._root = _root
self._read()
def _read(self):
self.magic = self._io.read_bytes(4)
if not self.magic == b"\x50\x43\x50\x32":
raise kaitaistruct.ValidationNotEqualError(b"\x50\x43\x50\x32", self.magic, self._io, u"/types/cue_extended_entry/seq/0")
self.len_header = self._io.read_u4be()
self.len_entry = self._io.read_u4be()
self.hot_cue = self._io.read_u4be()
self.type = KaitaiStream.resolve_enum(RekordboxAnlz.CueEntryType, self._io.read_u1())
self._unnamed5 = self._io.read_bytes(3)
self.time = self._io.read_u4be()
self.loop_time = self._io.read_u4be()
self.color_id = self._io.read_u1()
self._unnamed9 = self._io.read_bytes(7)
self.loop_numerator = self._io.read_u2be()
self.loop_denominator = self._io.read_u2be()
if self.len_entry > 43:
pass
self.len_comment = self._io.read_u4be()
if self.len_entry > 43:
pass
self.comment = (self._io.read_bytes(self.len_comment)).decode(u"UTF-16BE")
if self.len_entry - self.len_comment > 44:
pass
self.color_code = self._io.read_u1()
if self.len_entry - self.len_comment > 45:
pass
self.color_red = self._io.read_u1()
if self.len_entry - self.len_comment > 46:
pass
self.color_green = self._io.read_u1()
if self.len_entry - self.len_comment > 47:
pass
self.color_blue = self._io.read_u1()
if self.len_entry - self.len_comment > 48:
pass
self._unnamed18 = self._io.read_bytes((self.len_entry - 48) - self.len_comment)
def _fetch_instances(self):
pass
if self.len_entry > 43:
pass
if self.len_entry > 43:
pass
if self.len_entry - self.len_comment > 44:
pass
if self.len_entry - self.len_comment > 45:
pass
if self.len_entry - self.len_comment > 46:
pass
if self.len_entry - self.len_comment > 47:
pass
if self.len_entry - self.len_comment > 48:
pass
class CueExtendedTag(KaitaiStruct):
"""A variation of cue_tag which was introduced with the nxs2 line,
and adds descriptive names. (Still comes in two forms, either
holding memory cues and loop points, or holding hot cues and
loop points.) Also includes hot cues D through H and color assignment.
"""
def __init__(self, _io, _parent=None, _root=None):
super(RekordboxAnlz.CueExtendedTag, self).__init__(_io)
self._parent = _parent
self._root = _root
self._read()
def _read(self):
self.type = KaitaiStream.resolve_enum(RekordboxAnlz.CueListType, self._io.read_u4be())
self.num_cues = self._io.read_u2be()
self._unnamed2 = self._io.read_bytes(2)
self.cues = []
for i in range(self.num_cues):
self.cues.append(RekordboxAnlz.CueExtendedEntry(self._io, self, self._root))
def _fetch_instances(self):
pass
for i in range(len(self.cues)):
pass
self.cues[i]._fetch_instances()
class CueTag(KaitaiStruct):
"""Stores either a list of ordinary memory cues and loop points, or
a list of hot cues and loop points.
"""
def __init__(self, _io, _parent=None, _root=None):
super(RekordboxAnlz.CueTag, self).__init__(_io)
self._parent = _parent
self._root = _root
self._read()
def _read(self):
self.type = KaitaiStream.resolve_enum(RekordboxAnlz.CueListType, self._io.read_u4be())
self._unnamed1 = self._io.read_bytes(2)
self.num_cues = self._io.read_u2be()
self.memory_count = self._io.read_u4be()
self.cues = []
for i in range(self.num_cues):
self.cues.append(RekordboxAnlz.CueEntry(self._io, self, self._root))
def _fetch_instances(self):
pass
for i in range(len(self.cues)):
pass
self.cues[i]._fetch_instances()
class PathTag(KaitaiStruct):
"""Stores the file path of the audio file to which this analysis
applies.
"""
def __init__(self, _io, _parent=None, _root=None):
super(RekordboxAnlz.PathTag, self).__init__(_io)
self._parent = _parent
self._root = _root
self._read()
def _read(self):
self.len_path = self._io.read_u4be()
if self.len_path > 1:
pass
self.path = (self._io.read_bytes(self.len_path - 2)).decode(u"UTF-16BE")
def _fetch_instances(self):
pass
if self.len_path > 1:
pass
class PhraseHigh(KaitaiStruct):
def __init__(self, _io, _parent=None, _root=None):
super(RekordboxAnlz.PhraseHigh, self).__init__(_io)
self._parent = _parent
self._root = _root
self._read()
def _read(self):
self.id = KaitaiStream.resolve_enum(RekordboxAnlz.MoodHighPhrase, self._io.read_u2be())
def _fetch_instances(self):
pass
class PhraseLow(KaitaiStruct):
def __init__(self, _io, _parent=None, _root=None):
super(RekordboxAnlz.PhraseLow, self).__init__(_io)
self._parent = _parent
self._root = _root
self._read()
def _read(self):
self.id = KaitaiStream.resolve_enum(RekordboxAnlz.MoodLowPhrase, self._io.read_u2be())
def _fetch_instances(self):
pass
class PhraseMid(KaitaiStruct):
def __init__(self, _io, _parent=None, _root=None):
super(RekordboxAnlz.PhraseMid, self).__init__(_io)
self._parent = _parent
self._root = _root
self._read()
def _read(self):
self.id = KaitaiStream.resolve_enum(RekordboxAnlz.MoodMidPhrase, self._io.read_u2be())
def _fetch_instances(self):
pass
class SongStructureBody(KaitaiStruct):
"""Stores the rest of the song structure tag, which can only be
parsed after unmasking.
"""
def __init__(self, _io, _parent=None, _root=None):
super(RekordboxAnlz.SongStructureBody, self).__init__(_io)
self._parent = _parent
self._root = _root
self._read()
def _read(self):
self.mood = KaitaiStream.resolve_enum(RekordboxAnlz.TrackMood, self._io.read_u2be())
self._unnamed1 = self._io.read_bytes(6)
self.end_beat = self._io.read_u2be()
self._unnamed3 = self._io.read_bytes(2)
self.raw_bank = self._io.read_u1()
self._unnamed5 = self._io.read_bytes(1)
self.entries = []
for i in range(self._parent.len_entries):
self.entries.append(RekordboxAnlz.SongStructureEntry(self._io, self, self._root))
def _fetch_instances(self):
pass
for i in range(len(self.entries)):
pass
self.entries[i]._fetch_instances()
@property
def bank(self):
"""The stylistic bank which can be assigned to the track in rekordbox Lighting mode, if raw_bank has a legal value.
"""
if hasattr(self, '_m_bank'):
return self._m_bank
if self.raw_bank < 9:
pass
self._m_bank = KaitaiStream.resolve_enum(RekordboxAnlz.TrackBank, self.raw_bank)
return getattr(self, '_m_bank', None)
class SongStructureEntry(KaitaiStruct):
"""A song structure entry, represents a single phrase.
"""
def __init__(self, _io, _parent=None, _root=None):
super(RekordboxAnlz.SongStructureEntry, self).__init__(_io)
self._parent = _parent
self._root = _root
self._read()
def _read(self):
self.index = self._io.read_u2be()
self.beat = self._io.read_u2be()
_on = self._parent.mood
if _on == RekordboxAnlz.TrackMood.high:
pass
self.kind = RekordboxAnlz.PhraseHigh(self._io, self, self._root)
elif _on == RekordboxAnlz.TrackMood.low:
pass
self.kind = RekordboxAnlz.PhraseLow(self._io, self, self._root)
elif _on == RekordboxAnlz.TrackMood.mid:
pass
self.kind = RekordboxAnlz.PhraseMid(self._io, self, self._root)
else:
pass
self.kind = RekordboxAnlz.PhraseMid(self._io, self, self._root)
self._unnamed3 = self._io.read_bytes(1)
self.k1 = self._io.read_u1()
self._unnamed5 = self._io.read_bytes(1)
self.k2 = self._io.read_u1()
self._unnamed7 = self._io.read_bytes(1)
self.b = self._io.read_u1()
self.beat2 = self._io.read_u2be()
self.beat3 = self._io.read_u2be()
self.beat4 = self._io.read_u2be()
self._unnamed12 = self._io.read_bytes(1)
self.k3 = self._io.read_u1()
self._unnamed14 = self._io.read_bytes(1)
self.fill = self._io.read_u1()
self.beat_fill = self._io.read_u2be()
def _fetch_instances(self):
pass
_on = self._parent.mood
if _on == RekordboxAnlz.TrackMood.high:
pass
self.kind._fetch_instances()
elif _on == RekordboxAnlz.TrackMood.low:
pass
self.kind._fetch_instances()
elif _on == RekordboxAnlz.TrackMood.mid:
pass
self.kind._fetch_instances()
else:
pass
self.kind._fetch_instances()
class SongStructureTag(KaitaiStruct):
"""Stores the song structure, also known as phrases (intro, verse,
bridge, chorus, up, down, outro).
"""
def __init__(self, _io, _parent=None, _root=None):
super(RekordboxAnlz.SongStructureTag, self).__init__(_io)
self._parent = _parent
self._root = _root
self._read()
def _read(self):
self.len_entry_bytes = self._io.read_u4be()
self.len_entries = self._io.read_u2be()
self._raw__raw_body = self._io.read_bytes_full()
self._raw_body = KaitaiStream.process_xor_many(self._raw__raw_body, (self.mask if self.is_masked else b"\x00"))
_io__raw_body = KaitaiStream(BytesIO(self._raw_body))
self.body = RekordboxAnlz.SongStructureBody(_io__raw_body, self, self._root)
def _fetch_instances(self):
pass
self.body._fetch_instances()
_ = self.raw_mood
if hasattr(self, '_m_raw_mood'):
pass
@property
def c(self):
if hasattr(self, '_m_c'):
return self._m_c
self._m_c = self.len_entries
return getattr(self, '_m_c', None)
@property
def is_masked(self):
if hasattr(self, '_m_is_masked'):
return self._m_is_masked
self._m_is_masked = self.raw_mood > 20
return getattr(self, '_m_is_masked', None)
@property
def mask(self):
if hasattr(self, '_m_mask'):
return self._m_mask
self._m_mask = struct.pack('19B', (203 + self.c), (225 + self.c), (238 + self.c), (250 + self.c), (229 + self.c), (238 + self.c), (173 + self.c), (238 + self.c), (233 + self.c), (210 + self.c), (233 + self.c), (235 + self.c), (225 + self.c), (233 + self.c), (243 + self.c), (232 + self.c), (233 + self.c), (244 + self.c), (225 + self.c))
return getattr(self, '_m_mask', None)
@property
def raw_mood(self):
"""This is a way to tell whether the rest of the tag has been masked. The value is supposed
to range from 1 to 3, but in masked files it will be much larger.
"""
if hasattr(self, '_m_raw_mood'):
return self._m_raw_mood
_pos = self._io.pos()
self._io.seek(6)
self._m_raw_mood = self._io.read_u2be()
self._io.seek(_pos)
return getattr(self, '_m_raw_mood', None)
class TaggedSection(KaitaiStruct):
"""A type-tagged file section, identified by a four-byte magic
sequence, with a header specifying its length, and whose payload
is determined by the type tag.
"""
def __init__(self, _io, _parent=None, _root=None):
super(RekordboxAnlz.TaggedSection, self).__init__(_io)
self._parent = _parent
self._root = _root
self._read()
def _read(self):
self.fourcc = KaitaiStream.resolve_enum(RekordboxAnlz.SectionTags, self._io.read_s4be())
self.len_header = self._io.read_u4be()
self.len_tag = self._io.read_u4be()
_on = self.fourcc
if _on == RekordboxAnlz.SectionTags.beat_grid:
pass
self._raw_body = self._io.read_bytes(self.len_tag - 12)
_io__raw_body = KaitaiStream(BytesIO(self._raw_body))
self.body = RekordboxAnlz.BeatGridTag(_io__raw_body, self, self._root)
elif _on == RekordboxAnlz.SectionTags.cues:
pass
self._raw_body = self._io.read_bytes(self.len_tag - 12)
_io__raw_body = KaitaiStream(BytesIO(self._raw_body))
self.body = RekordboxAnlz.CueTag(_io__raw_body, self, self._root)
elif _on == RekordboxAnlz.SectionTags.cues_2:
pass
self._raw_body = self._io.read_bytes(self.len_tag - 12)
_io__raw_body = KaitaiStream(BytesIO(self._raw_body))
self.body = RekordboxAnlz.CueExtendedTag(_io__raw_body, self, self._root)
elif _on == RekordboxAnlz.SectionTags.path:
pass
self._raw_body = self._io.read_bytes(self.len_tag - 12)
_io__raw_body = KaitaiStream(BytesIO(self._raw_body))
self.body = RekordboxAnlz.PathTag(_io__raw_body, self, self._root)
elif _on == RekordboxAnlz.SectionTags.song_structure:
pass
self._raw_body = self._io.read_bytes(self.len_tag - 12)
_io__raw_body = KaitaiStream(BytesIO(self._raw_body))
self.body = RekordboxAnlz.SongStructureTag(_io__raw_body, self, self._root)
elif _on == RekordboxAnlz.SectionTags.vbr:
pass
self._raw_body = self._io.read_bytes(self.len_tag - 12)
_io__raw_body = KaitaiStream(BytesIO(self._raw_body))
self.body = RekordboxAnlz.VbrTag(_io__raw_body, self, self._root)
elif _on == RekordboxAnlz.SectionTags.wave_3band_preview:
pass
self._raw_body = self._io.read_bytes(self.len_tag - 12)
_io__raw_body = KaitaiStream(BytesIO(self._raw_body))
self.body = RekordboxAnlz.Wave3bandPreviewTag(_io__raw_body, self, self._root)
elif _on == RekordboxAnlz.SectionTags.wave_3band_scroll:
pass
self._raw_body = self._io.read_bytes(self.len_tag - 12)
_io__raw_body = KaitaiStream(BytesIO(self._raw_body))
self.body = RekordboxAnlz.Wave3bandScrollTag(_io__raw_body, self, self._root)
elif _on == RekordboxAnlz.SectionTags.wave_color_preview:
pass
self._raw_body = self._io.read_bytes(self.len_tag - 12)
_io__raw_body = KaitaiStream(BytesIO(self._raw_body))
self.body = RekordboxAnlz.WaveColorPreviewTag(_io__raw_body, self, self._root)
elif _on == RekordboxAnlz.SectionTags.wave_color_scroll:
pass
self._raw_body = self._io.read_bytes(self.len_tag - 12)
_io__raw_body = KaitaiStream(BytesIO(self._raw_body))
self.body = RekordboxAnlz.WaveColorScrollTag(_io__raw_body, self, self._root)
elif _on == RekordboxAnlz.SectionTags.wave_preview:
pass
self._raw_body = self._io.read_bytes(self.len_tag - 12)
_io__raw_body = KaitaiStream(BytesIO(self._raw_body))
self.body = RekordboxAnlz.WavePreviewTag(_io__raw_body, self, self._root)
elif _on == RekordboxAnlz.SectionTags.wave_scroll:
pass
self._raw_body = self._io.read_bytes(self.len_tag - 12)
_io__raw_body = KaitaiStream(BytesIO(self._raw_body))
self.body = RekordboxAnlz.WaveScrollTag(_io__raw_body, self, self._root)
elif _on == RekordboxAnlz.SectionTags.wave_tiny:
pass
self._raw_body = self._io.read_bytes(self.len_tag - 12)
_io__raw_body = KaitaiStream(BytesIO(self._raw_body))
self.body = RekordboxAnlz.WavePreviewTag(_io__raw_body, self, self._root)
else:
pass
self._raw_body = self._io.read_bytes(self.len_tag - 12)
_io__raw_body = KaitaiStream(BytesIO(self._raw_body))
self.body = RekordboxAnlz.UnknownTag(_io__raw_body, self, self._root)
def _fetch_instances(self):
pass
_on = self.fourcc
if _on == RekordboxAnlz.SectionTags.beat_grid:
pass
self.body._fetch_instances()
elif _on == RekordboxAnlz.SectionTags.cues:
pass
self.body._fetch_instances()
elif _on == RekordboxAnlz.SectionTags.cues_2:
pass
self.body._fetch_instances()
elif _on == RekordboxAnlz.SectionTags.path:
pass
self.body._fetch_instances()
elif _on == RekordboxAnlz.SectionTags.song_structure:
pass
self.body._fetch_instances()
elif _on == RekordboxAnlz.SectionTags.vbr:
pass
self.body._fetch_instances()
elif _on == RekordboxAnlz.SectionTags.wave_3band_preview:
pass
self.body._fetch_instances()
elif _on == RekordboxAnlz.SectionTags.wave_3band_scroll:
pass
self.body._fetch_instances()
elif _on == RekordboxAnlz.SectionTags.wave_color_preview:
pass
self.body._fetch_instances()
elif _on == RekordboxAnlz.SectionTags.wave_color_scroll:
pass
self.body._fetch_instances()
elif _on == RekordboxAnlz.SectionTags.wave_preview:
pass
self.body._fetch_instances()
elif _on == RekordboxAnlz.SectionTags.wave_scroll:
pass
self.body._fetch_instances()
elif _on == RekordboxAnlz.SectionTags.wave_tiny:
pass
self.body._fetch_instances()
else:
pass
self.body._fetch_instances()
class UnknownTag(KaitaiStruct):
def __init__(self, _io, _parent=None, _root=None):
super(RekordboxAnlz.UnknownTag, self).__init__(_io)
self._parent = _parent
self._root = _root
self._read()
def _read(self):
pass
def _fetch_instances(self):
pass
class VbrTag(KaitaiStruct):
"""Stores an index allowing rapid seeking to particular times
within a variable-bitrate audio file.
"""
def __init__(self, _io, _parent=None, _root=None):
super(RekordboxAnlz.VbrTag, self).__init__(_io)
self._parent = _parent
self._root = _root
self._read()
def _read(self):
self._unnamed0 = self._io.read_u4be()
self.index = []
for i in range(400):
self.index.append(self._io.read_u4be())
def _fetch_instances(self):
pass
for i in range(len(self.index)):
pass
class Wave3bandPreviewTag(KaitaiStruct):
"""The minimalist CDJ-3000 waveform preview image suitable for display
above the touch strip for jumping to a track position.
"""
def __init__(self, _io, _parent=None, _root=None):
super(RekordboxAnlz.Wave3bandPreviewTag, self).__init__(_io)
self._parent = _parent
self._root = _root
self._read()
def _read(self):
self.len_entry_bytes = self._io.read_u4be()
self.len_entries = self._io.read_u4be()
self.entries = self._io.read_bytes(self.len_entries * self.len_entry_bytes)
def _fetch_instances(self):
pass
class Wave3bandScrollTag(KaitaiStruct):
"""The minimalist CDJ-3000 waveform image suitable for scrolling along
as a track plays on newer high-resolution hardware.
"""
def __init__(self, _io, _parent=None, _root=None):
super(RekordboxAnlz.Wave3bandScrollTag, self).__init__(_io)
self._parent = _parent
self._root = _root
self._read()
def _read(self):
self.len_entry_bytes = self._io.read_u4be()
self.len_entries = self._io.read_u4be()
self._unnamed2 = self._io.read_u4be()
self.entries = self._io.read_bytes(self.len_entries * self.len_entry_bytes)
def _fetch_instances(self):
pass
class WaveColorPreviewTag(KaitaiStruct):
"""A larger, colorful waveform preview image suitable for display
above the touch strip for jumping to a track position on newer
high-resolution players.
"""
def __init__(self, _io, _parent=None, _root=None):
super(RekordboxAnlz.WaveColorPreviewTag, self).__init__(_io)
self._parent = _parent
self._root = _root
self._read()
def _read(self):
self.len_entry_bytes = self._io.read_u4be()
self.len_entries = self._io.read_u4be()
self._unnamed2 = self._io.read_u4be()
self.entries = self._io.read_bytes(self.len_entries * self.len_entry_bytes)
def _fetch_instances(self):
pass
class WaveColorScrollTag(KaitaiStruct):
"""A larger, colorful waveform image suitable for scrolling along
as a track plays on newer high-resolution hardware. Also
contains a higher-resolution blue/white waveform.
"""
def __init__(self, _io, _parent=None, _root=None):
super(RekordboxAnlz.WaveColorScrollTag, self).__init__(_io)
self._parent = _parent
self._root = _root
self._read()
def _read(self):
self.len_entry_bytes = self._io.read_u4be()
self.len_entries = self._io.read_u4be()
self._unnamed2 = self._io.read_u4be()
self.entries = self._io.read_bytes(self.len_entries * self.len_entry_bytes)
def _fetch_instances(self):
pass
class WavePreviewTag(KaitaiStruct):
"""Stores a waveform preview image suitable for display above
the touch strip for jumping to a track position.
"""
def __init__(self, _io, _parent=None, _root=None):
super(RekordboxAnlz.WavePreviewTag, self).__init__(_io)
self._parent = _parent
self._root = _root
self._read()
def _read(self):
self.len_data = self._io.read_u4be()
self._unnamed1 = self._io.read_u4be()
if self._parent.len_tag > self._parent.len_header:
pass
self.data = self._io.read_bytes(self.len_data)
def _fetch_instances(self):
pass
if self._parent.len_tag > self._parent.len_header:
pass
class WaveScrollTag(KaitaiStruct):
"""A larger waveform image suitable for scrolling along as a track
plays.
"""
def __init__(self, _io, _parent=None, _root=None):
super(RekordboxAnlz.WaveScrollTag, self).__init__(_io)
self._parent = _parent
self._root = _root
self._read()
def _read(self):
self.len_entry_bytes = self._io.read_u4be()
self.len_entries = self._io.read_u4be()
self._unnamed2 = self._io.read_u4be()
self.entries = self._io.read_bytes(self.len_entries * self.len_entry_bytes)
def _fetch_instances(self):
pass
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+2
View File
@@ -0,0 +1,2 @@
def hello() -> str:
return "Hello from maple!"
+3
View File
@@ -0,0 +1,3 @@
from maple.cli import main
raise SystemExit(main())
+41
View File
@@ -0,0 +1,41 @@
"""Generate a minimal rekordbox ANLZ0000.DAT analysis file.
The CDJ/XDJ reads the path from a `PPTH` section to confirm which audio
file the analysis applies to. We emit just that section -- no beat grid,
no waveforms -- which is enough for the player to consider the track's
analysis "present" and surface the track in the library. Without an
analyze file at the path the pdb references, some firmware hides the
track entirely.
All multi-byte fields in ANLZ files are BIG-endian, unlike the pdb.
"""
from pathlib import Path
from struct import pack
def _section_pth(path: str) -> bytes:
"""PPTH section: u4 len_path, then UTF-16BE bytes (null-terminated)."""
utf16 = path.encode("utf-16-be") + b"\x00\x00"
len_path = len(utf16)
body = pack(">I", len_path) + utf16
# Section header: 4 fourcc + u4 len_header + u4 len_tag
fourcc = b"PPTH"
len_header = 16 # section header size (incl. fourcc + 2 u4s + len_path u4)
len_tag = 12 + len(body) # full section size
return fourcc + pack(">II", len_header, len_tag) + body
def build_minimal_anlz(audio_path: str) -> bytes:
"""Return bytes of a minimal ANLZ0000.DAT pointing at `audio_path`."""
pth = _section_pth(audio_path)
header_len = 28
file_len = header_len + len(pth)
header = b"PMAI" + pack(">II", header_len, file_len)
header += b"\x00" * (header_len - len(header))
assert len(header) == header_len
return header + pth
def write_anlz(audio_path: str, dest: Path) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(build_minimal_anlz(audio_path))
+67
View File
@@ -0,0 +1,67 @@
"""Extract embedded album art and write Pioneer-format JPEGs.
Pioneer's CDJ/XDJ players expect two square JPEGs per artwork:
/PIONEER/Artwork/{bucket:05d}/a{id}.jpg -> 80x80
/PIONEER/Artwork/{bucket:05d}/a{id}_m.jpg -> 240x240
where bucket = id // 20 + 1. The pdb's artwork_row.path points at the
small (80x80) variant; the player auto-resolves the `_m` companion.
"""
import hashlib
import io
from pathlib import Path
from mutagen.id3 import ID3, ID3NoHeaderError
from PIL import Image
SMALL = 80
LARGE = 240
def _read_apic(mp3: Path) -> bytes | None:
try:
tags = ID3(mp3)
except ID3NoHeaderError:
return None
frames = tags.getall("APIC")
if not frames:
return None
return frames[0].data
def _bucket(artwork_id: int) -> str:
return f"{artwork_id // 20 + 1:05d}"
def _resize_jpeg(data: bytes, size: int) -> bytes:
img = Image.open(io.BytesIO(data))
if img.mode != "RGB":
img = img.convert("RGB")
img = img.resize((size, size), Image.LANCZOS)
out = io.BytesIO()
img.save(out, format="JPEG", quality=85)
return out.getvalue()
def extract_artwork(mp3: Path, usb_root: Path, next_id: int, cache: dict[str, int]) -> tuple[int, str | None]:
"""Return (artwork_id, file_path_for_pdb). artwork_id is 0 if no art."""
data = _read_apic(mp3)
if data is None:
return 0, None
sha = hashlib.sha256(data).hexdigest()
cached = cache.get(sha)
if cached is not None:
return cached, None
aid = next_id
bucket = _bucket(aid)
rel_small = f"/PIONEER/Artwork/{bucket}/a{aid}.jpg"
rel_large = f"/PIONEER/Artwork/{bucket}/a{aid}_m.jpg"
abs_small = usb_root / rel_small.lstrip("/")
abs_large = usb_root / rel_large.lstrip("/")
abs_small.parent.mkdir(parents=True, exist_ok=True)
if not abs_small.exists():
abs_small.write_bytes(_resize_jpeg(data, SMALL))
if not abs_large.exists():
abs_large.write_bytes(_resize_jpeg(data, LARGE))
cache[sha] = aid
return aid, rel_small
+166
View File
@@ -0,0 +1,166 @@
"""maple CLI: scan a directory of audio + m3u8 playlists and build a rekordbox USB."""
import argparse
import os
import sys
from concurrent.futures import ThreadPoolExecutor
from datetime import date
from pathlib import Path
from maple.anlz import write_anlz
from maple.artwork import extract_artwork
from maple.convert import convert_flac_to_mp3, verify_duration
from maple.copy import stage_audio, staged_path
from maple.meta import read_tags
from maple.model import Album, Artist, Artwork, Database, Genre, PlaylistEntry, PlaylistNode, Track
from maple.pdb.writer import write_pdb
from maple.scan import scan_audio, scan_playlists
def build(input_dir: Path, usb_root: Path, with_playlists: bool = True) -> Database:
db = Database()
today = date.today().isoformat()
artist_index: dict[str, int] = {}
album_index: dict[tuple[int, str], int] = {}
genre_index: dict[str, int] = {}
track_by_src: dict[Path, int] = {}
artwork_cache: dict[str, int] = {}
audio_files = scan_audio(input_dir)
initial_meta = {p: read_tags(p) for p in audio_files}
flac_jobs: list[tuple[Path, Path]] = []
for original in audio_files:
if original.suffix.lower() == ".flac":
m = initial_meta[original]
dest_abs, _ = staged_path(usb_root, m.artist, m.album, original.with_suffix(".mp3").name)
flac_jobs.append((original, dest_abs))
def _job(pair: tuple[Path, Path]) -> tuple[Path, Path, bool]:
src, dst = pair
return src, dst, convert_flac_to_mp3(src, dst)
workers = max(1, os.cpu_count() or 1)
with ThreadPoolExecutor(max_workers=workers) as ex:
for src, dst, converted in ex.map(_job, flac_jobs):
print(f"{'convert' if converted else 'cached '} {src.relative_to(input_dir)}")
verify_duration(src, dst)
for original in audio_files:
meta = initial_meta[original]
artist_id = artist_index.get(meta.artist)
if artist_id is None:
artist_id = len(db.artists) + 1
artist_index[meta.artist] = artist_id
db.artists.append(Artist(id=artist_id, name=meta.artist))
album_key = (artist_id, meta.album)
album_id = album_index.get(album_key)
if album_id is None:
album_id = len(db.albums) + 1
album_index[album_key] = album_id
db.albums.append(Album(id=album_id, name=meta.album, artist_id=artist_id))
if original.suffix.lower() == ".flac":
dest_abs, file_path = staged_path(usb_root, meta.artist, meta.album, original.with_suffix(".mp3").name)
meta = read_tags(dest_abs)
else:
dest_abs, file_path = stage_audio(original, usb_root, meta.artist, meta.album)
next_artwork_id = len(db.artwork) + 1
artwork_id, art_file_path = extract_artwork(dest_abs, usb_root, next_artwork_id, artwork_cache)
if art_file_path is not None:
db.artwork.append(Artwork(id=artwork_id, path=art_file_path))
genre_id = 0
if meta.genre:
genre_id = genre_index.get(meta.genre, 0)
if genre_id == 0:
genre_id = len(db.genres) + 1
genre_index[meta.genre] = genre_id
db.genres.append(Genre(id=genre_id, name=meta.genre))
track_id = len(db.tracks) + 1
# Reference exports always populate analyze_path even when the
# ANLZ files are real; tracks with an empty analyze_path appear
# to be hidden from the player's library. Synthesise a stable
# path keyed off the track id even if we don't write the .DAT.
import zlib
h = zlib.crc32(file_path.encode()) & 0xFFFFFFFF
# rekordbox's USBANLZ P-bucket is 0..127 (P00..P7F) and the 8-hex id is an
# opaque master.db value -- not reproducible without its DB (FORMAT_SPEC
# §7), so maple derives both from a stable crc32(path). Mask the bucket to
# 0x7F so it has rekordbox's P00..P7F shape; the path used here is the same
# one written to the pdb row, so it's self-consistent by construction.
bucket = f"P{(h >> 20) & 0x7F:02X}"
analyze_id = f"{h & 0xFFFFFFFF:08X}"
analyze_path = f"/PIONEER/USBANLZ/{bucket}/{analyze_id}/ANLZ0000.DAT"
write_anlz(file_path, usb_root / analyze_path.lstrip("/"))
db.tracks.append(Track(
id=track_id,
title=meta.title,
artist_id=artist_id,
album_id=album_id,
filename=Path(file_path).name,
file_path=file_path,
file_size=meta.file_size,
sample_rate=meta.sample_rate,
sample_depth=meta.sample_depth,
bitrate=meta.bitrate,
duration=meta.duration,
track_number=meta.track_number,
year=meta.year,
tempo=meta.tempo,
date_added=today,
analyze_path=analyze_path,
analyze_date=today,
artwork_id=artwork_id,
genre_id=genre_id,
source_path=original,
))
track_by_src[original.resolve()] = track_id
if not with_playlists:
return db
playlists = scan_playlists(input_dir)
for sort_order, (name, paths) in enumerate(playlists.items(), start=1):
playlist_id = len(db.playlist_nodes) + 1
db.playlist_nodes.append(PlaylistNode(
id=playlist_id,
parent_id=0,
sort_order=sort_order,
name=name,
is_folder=False,
))
for entry_index, p in enumerate(paths, start=1):
tid = track_by_src.get(p.resolve())
if tid is None:
print(f"warning: playlist {name!r} references missing file {p}", file=sys.stderr)
continue
db.playlist_entries.append(PlaylistEntry(
playlist_id=playlist_id,
track_id=tid,
entry_index=entry_index,
))
return db
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="maple", description="Build a rekordbox USB from a folder of audio + m3u8 playlists.")
parser.add_argument("input_dir", nargs="?", type=Path, default=Path("./data/input"))
parser.add_argument("output_root", nargs="?", type=Path, default=Path("./data/usb"))
parser.add_argument("--no-playlists", action="store_true", help="Skip m3u8 playlist scanning")
args = parser.parse_args(argv)
if not args.input_dir.is_dir():
parser.error(f"input_dir {args.input_dir} is not a directory")
db = build(args.input_dir, args.output_root, with_playlists=not args.no_playlists)
pdb_path = args.output_root / "PIONEER" / "rekordbox" / "export.pdb"
write_pdb(db, pdb_path)
print(f"wrote {len(db.tracks)} tracks, {len(db.artists)} artists, {len(db.albums)} albums, "
f"{len(db.playlist_nodes)} playlists ({len(db.playlist_entries)} entries) to {pdb_path}")
return 0
+82
View File
@@ -0,0 +1,82 @@
"""FLAC -> MP3 V0 joint-stereo conversion via ffmpeg, with source-hash dedup.
The SHA-256 of the source FLAC is stamped into a TXXX:MAPLE_SRC_SHA256 ID3
frame on the produced MP3. Reruns skip conversion when the existing MP3
carries the matching hash, so the cache is content-addressed against inputs.
"""
import hashlib
import shutil
import subprocess
from pathlib import Path
from mutagen import File as MutagenFile
from mutagen.id3 import ID3, ID3NoHeaderError, TXXX
HASH_FRAME_DESC = "MAPLE_SRC_SHA256"
def file_sha256(path: Path) -> str:
with path.open("rb") as f:
return hashlib.file_digest(f, "sha256").hexdigest()
def _read_source_hash(mp3: Path) -> str | None:
try:
tags = ID3(mp3)
except ID3NoHeaderError:
return None
for frame in tags.getall("TXXX"):
if frame.desc == HASH_FRAME_DESC and frame.text:
return str(frame.text[0])
return None
def _write_source_hash(mp3: Path, sha: str) -> None:
try:
tags = ID3(mp3)
except ID3NoHeaderError:
tags = ID3()
tags.delall(f"TXXX:{HASH_FRAME_DESC}")
tags.add(TXXX(encoding=3, desc=HASH_FRAME_DESC, text=sha))
tags.save(mp3)
def convert_flac_to_mp3(flac: Path, mp3: Path) -> bool:
"""Convert flac to mp3 (V0, joint stereo). Returns True if conversion ran."""
src_hash = file_sha256(flac)
if mp3.exists() and _read_source_hash(mp3) == src_hash:
return False
if not shutil.which("ffmpeg"):
raise RuntimeError("ffmpeg not found on PATH")
mp3.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
[
"ffmpeg", "-nostdin", "-loglevel", "error", "-y",
"-i", str(flac),
"-codec:a", "libmp3lame",
"-q:a", "0",
"-joint_stereo", "1",
"-map_metadata", "0",
str(mp3),
],
check=True,
)
_write_source_hash(mp3, src_hash)
return True
def _duration(path: Path) -> float:
audio = MutagenFile(path)
info = getattr(audio, "info", None)
return float(getattr(info, "length", 0.0) or 0.0)
def verify_duration(src: Path, dst: Path, tolerance: float = 0.1) -> None:
"""Raise if dst's audio length differs from src's by more than `tolerance` seconds."""
a, b = _duration(src), _duration(dst)
if abs(a - b) > tolerance:
raise RuntimeError(
f"duration mismatch: {src.name} = {a:.3f}s vs {dst.name} = {b:.3f}s (Δ {abs(a-b):.3f}s)"
)
+66
View File
@@ -0,0 +1,66 @@
"""Copy audio files into the USB Contents layout.
Targets are assumed to be FAT32-formatted USBs, which constrain us to:
- per-component name length <= 255 LFN chars
- total path (from drive root) <= 260 chars
- no '\\ / : * ? " < > |' or control chars in any component
We truncate per-component to a safe 64 chars (rekordbox firmware is
fussier than raw FAT32) and refuse to emit anything that would still
overflow the 260-char path budget.
"""
import re
import shutil
from pathlib import Path
_BAD = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
MAX_COMPONENT = 48
MAX_PATH = 260
# Pioneer firmware appears to only handle the DeviceSQL short-ASCII form
# for `file_path` in the pdb (length byte < 127). The verified reference
# export uses short-form for every track. Keep the on-pdb path strictly
# inside that budget so every track stays loadable.
MAX_PDB_PATH = 126
def sanitise(component: str, max_len: int = MAX_COMPONENT) -> str:
s = _BAD.sub("_", component).strip(" .")
if len(s) > max_len:
s = s[:max_len].rstrip(" .")
return s or "_"
def _truncate_filename(name: str, max_len: int) -> str:
"""Truncate while preserving the extension."""
cleaned = _BAD.sub("_", name).strip(" .")
if len(cleaned) <= max_len:
return cleaned or "_"
stem, dot, ext = cleaned.rpartition(".")
if dot and len(ext) < max_len - 1:
keep = max_len - len(ext) - 1
return (stem[:keep].rstrip(" .") + "." + ext) or "_"
return cleaned[:max_len].rstrip(" .") or "_"
def staged_path(usb_root: Path, artist: str, album: str, filename: str) -> tuple[Path, str]:
artist_s = sanitise(artist)
album_s = sanitise(album)
prefix = f"/Contents/{artist_s}/{album_s}/"
budget = min(MAX_COMPONENT, MAX_PDB_PATH - len(prefix))
if budget < 8:
raise ValueError(f"no room for filename within MAX_PDB_PATH={MAX_PDB_PATH} (prefix uses {len(prefix)}): {prefix}")
name_s = _truncate_filename(filename, budget)
dest_rel = Path("Contents") / artist_s / album_s / name_s
dest_abs = usb_root / dest_rel
file_path = "/" + dest_rel.as_posix()
if len(file_path) > MAX_PDB_PATH:
raise ValueError(f"pdb path budget exceeded ({len(file_path)} > {MAX_PDB_PATH}): {file_path}")
return dest_abs, file_path
def stage_audio(src: Path, usb_root: Path, artist: str, album: str) -> tuple[Path, str]:
dest_abs, file_path = staged_path(usb_root, artist, album, src.name)
dest_abs.parent.mkdir(parents=True, exist_ok=True)
if not dest_abs.exists():
shutil.copy2(src, dest_abs)
return dest_abs, file_path
+105
View File
@@ -0,0 +1,105 @@
"""Audio metadata extraction via mutagen."""
from dataclasses import dataclass
from pathlib import Path
from mutagen import File as MutagenFile
DEFAULT_TEMPO_X100 = 12000 # 120.00 BPM, used when no BPM tag is present
@dataclass
class TrackMeta:
title: str
artist: str
album: str
genre: str
track_number: int
year: int
duration: int
bitrate: int
sample_rate: int
sample_depth: int
file_size: int
tempo: int = DEFAULT_TEMPO_X100 # BPM * 100 (rekordbox storage format)
def _first(value) -> str:
if value is None:
return ""
if isinstance(value, list):
return str(value[0]) if value else ""
return str(value)
def _int(value) -> int:
s = _first(value).strip()
if not s:
return 0
# Handle "3/12" track numbers
s = s.split("/")[0]
try:
return int(s)
except ValueError:
return 0
def _tempo_x100(value) -> int:
"""Parse a BPM tag into integer BPM * 100. 0 means "not present"."""
s = _first(value).strip()
if not s:
return 0
try:
return int(round(float(s) * 100))
except ValueError:
return 0
def read_tags(path: Path) -> TrackMeta:
file_size = path.stat().st_size
audio = MutagenFile(path, easy=True)
if audio is None:
return TrackMeta(
title=path.stem,
artist="Unknown Artist",
album="Unknown Album",
genre="",
track_number=0,
year=0,
duration=0,
bitrate=0,
sample_rate=44100,
sample_depth=16,
file_size=file_size,
)
tags = audio.tags or {}
title = _first(tags.get("title")) or path.stem
artist = _first(tags.get("artist")) or "Unknown Artist"
album = _first(tags.get("album")) or "Unknown Album"
genre = _first(tags.get("genre"))
track_number = _int(tags.get("tracknumber"))
year = _int(tags.get("date") or tags.get("year"))
tempo = _tempo_x100(tags.get("bpm")) or DEFAULT_TEMPO_X100
info = getattr(audio, "info", None)
duration = int(getattr(info, "length", 0) or 0)
bitrate_bps = int(getattr(info, "bitrate", 0) or 0)
bitrate = bitrate_bps // 1000 if bitrate_bps > 1000 else bitrate_bps
sample_rate = int(getattr(info, "sample_rate", 44100) or 44100)
sample_depth = int(getattr(info, "bits_per_sample", 16) or 16)
return TrackMeta(
title=title,
artist=artist,
album=album,
genre=genre,
track_number=track_number,
year=year,
duration=duration,
bitrate=bitrate,
sample_rate=sample_rate,
sample_depth=sample_depth,
file_size=file_size,
tempo=tempo,
)
+211
View File
@@ -0,0 +1,211 @@
"""Domain model for the in-memory database that becomes a .pdb file."""
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class Artist:
id: int
name: str
@dataclass
class Album:
id: int
name: str
artist_id: int
@dataclass
class Genre:
id: int
name: str
@dataclass
class Color:
id: int
name: str
@dataclass
class Key:
id: int
name: str
id2: int | None = None # mirrors id in real exports
@dataclass
class Track:
id: int
title: str
artist_id: int
album_id: int
filename: str
file_path: str
file_size: int = 0
sample_rate: int = 44100
sample_depth: int = 16
bitrate: int = 0
duration: int = 0
track_number: int = 0
year: int = 0
disc_number: int = 0
tempo: int = 0
comment: str = ""
date_added: str = ""
release_date: str = ""
mix_name: str = ""
analyze_path: str = ""
analyze_date: str = ""
artwork_id: int = 0
genre_id: int = 0
source_path: Path | None = None
@dataclass
class Artwork:
id: int
path: str
@dataclass
class Column:
id: int
code: int
name: str
# Reverse-engineered from a verified rekordbox export. Each entry is
# (column_id, internal column code, display name). Code values are
# non-contiguous because rekordbox skips reserved slots.
DEFAULT_COLUMNS: list[Column] = [
Column(1, 0x80, "GENRE"),
Column(2, 0x81, "ARTIST"),
Column(3, 0x82, "ALBUM"),
Column(4, 0x83, "TRACK"),
Column(5, 0x85, "BPM"),
Column(6, 0x86, "RATING"),
Column(7, 0x87, "YEAR"),
Column(8, 0x88, "REMIXER"),
Column(9, 0x89, "LABEL"),
Column(10, 0x8A, "ORIGINAL ARTIST"),
Column(11, 0x8B, "KEY"),
Column(12, 0x8D, "CUE"),
Column(13, 0x8E, "COLOR"),
Column(14, 0x92, "TIME"),
Column(15, 0x93, "BITRATE"),
Column(16, 0x94, "FILE NAME"),
Column(17, 0x84, "PLAYLIST"),
Column(18, 0x98, "HOT CUE BANK"),
Column(19, 0x95, "HISTORY"),
Column(20, 0x91, "SEARCH"),
Column(21, 0x96, "COMMENTS"),
Column(22, 0x8C, "DATE ADDED"),
Column(23, 0x97, "DJ PLAY COUNT"),
Column(24, 0x90, "FOLDER"),
Column(25, 0xA1, "DEFAULT"),
Column(26, 0xA2, "ALPHABET"),
Column(27, 0xAA, "MATCHING"),
]
# Reference-derived rows for the un-decoded `unknown_17` and `unknown_18`
# tables. Each tuple is (a, b, c, d) packed as four little-endian u2s.
# These appear to drive the player's "Filter by ->" menu (which columns
# are filterable, sort order, filter type code). Speculative purpose;
# verified to match the reference byte-for-byte.
UNKNOWN_17_ROWS: list[tuple[int, int, int, int]] = [
(1, 1, 355, 0), (5, 6, 261, 0), (6, 7, 355, 0), (7, 8, 355, 0),
(8, 9, 355, 0), (9, 10, 355, 0), (10, 11, 355, 0), (13, 15, 355, 0),
(14, 19, 260, 0), (15, 20, 262, 0), (16, 21, 355, 0), (18, 23, 355, 0),
(2, 2, 2, 1), (3, 3, 3, 2), (4, 4, 1, 3), (11, 12, 99, 4),
(17, 5, 99, 5), (19, 22, 99, 6), (20, 18, 99, 7), (27, 26, 611, 8),
(24, 17, 99, 9), (22, 27, 99, 10),
]
UNKNOWN_18_ROWS: list[tuple[int, int, int, int]] = [
(1, 6, 1, 0), (21, 7, 1, 0), (14, 8, 1, 0), (8, 9, 1, 0),
(9, 10, 1, 0), (10, 11, 1, 0), (15, 13, 1, 0), (13, 15, 1, 0),
(23, 16, 1, 0), (22, 17, 1, 0), (25, 0, 256, 0), (26, 1, 512, 0),
(2, 2, 768, 0), (3, 3, 1024, 0), (5, 4, 1280, 0), (6, 5, 1536, 0),
(11, 12, 1792, 0),
]
# The standard rekordbox color label palette.
STANDARD_COLORS: list[Color] = [
Color(1, "Pink"),
Color(2, "Red"),
Color(3, "Orange"),
Color(4, "Yellow"),
Color(5, "Green"),
Color(6, "Aqua"),
Color(7, "Blue"),
Color(8, "Purple"),
]
@dataclass
class PlaylistNode:
id: int
parent_id: int
sort_order: int
name: str
is_folder: bool = False
@dataclass
class PlaylistEntry:
playlist_id: int
track_id: int
entry_index: int
@dataclass
class Database:
tracks: list[Track] = field(default_factory=list)
artists: list[Artist] = field(default_factory=list)
albums: list[Album] = field(default_factory=list)
playlist_nodes: list[PlaylistNode] = field(default_factory=list)
playlist_entries: list[PlaylistEntry] = field(default_factory=list)
artwork: list[Artwork] = field(default_factory=list)
genres: list[Genre] = field(default_factory=list)
colors: list[Color] = field(default_factory=list)
keys: list[Key] = field(default_factory=list)
def validate_referential_integrity(db: "Database") -> list[str]:
"""Return a list of dangling-foreign-key errors. Every FK must be 0 ("none")
or resolve to a real row in its target table. A track that references a
genre/artist/album/artwork id with no matching row is freeze-class: the
player follows the FK into an empty/short table and hangs (this is exactly
how build_acemo.py's omitted-genres bug froze the deck). Empty string = OK.
"""
errors: list[str] = []
artist_ids = {a.id for a in db.artists}
album_ids = {a.id for a in db.albums}
artwork_ids = {a.id for a in db.artwork}
genre_ids = {g.id for g in db.genres}
key_ids = {k.id for k in db.keys}
track_ids = {t.id for t in db.tracks}
playlist_ids = {n.id for n in db.playlist_nodes}
def check(kind: str, owner_id: int, field: str, value: int, valid: set[int]) -> None:
# 0 means "unset / no reference" everywhere in the pdb schema.
if value and value not in valid:
errors.append(f"{kind} id={owner_id}: {field}={value} has no matching row")
for t in db.tracks:
check("track", t.id, "artist_id", t.artist_id, artist_ids)
check("track", t.id, "album_id", t.album_id, album_ids)
check("track", t.id, "artwork_id", t.artwork_id, artwork_ids)
check("track", t.id, "genre_id", t.genre_id, genre_ids)
for a in db.albums:
check("album", a.id, "artist_id", a.artist_id, artist_ids)
for e in db.playlist_entries:
check("playlist_entry", e.playlist_id, "track_id", e.track_id, track_ids)
check("playlist_entry", e.playlist_id, "playlist_id", e.playlist_id, playlist_ids)
# key_ids is currently unreferenced by Track in maple's model; included for
# completeness so adding a track.key_id later is covered.
_ = key_ids
return errors
View File
Whitespace-only changes.
+172
View File
@@ -0,0 +1,172 @@
"""Page builder for the rekordbox .pdb format."""
from struct import pack
PAGE_SIZE = 4096
HEADER_SIZE = 40
ROW_GROUP_SIZE = 36
ROWS_PER_GROUP = 16
def _align4(offset: int) -> int:
return (offset + 3) & ~3
def _num_row_groups(n: int) -> int:
return (n + ROWS_PER_GROUP - 1) // ROWS_PER_GROUP
# Strange-page heap is a DeviceSQL free-space map, byte-exact in a fresh export:
# u4(page_index) + u4(data_page_ptr) + 12-byte const tail
# + (f8 ff ff 1f)*1004 + 20 trailing zeros.
# data_page_ptr is the table's first data-page index, or the sentinel
# 0x03ffffff when the table is strange-only (no data page). The 1004 repeat
# count is the same value stored in the header's unknown6 field (0x03ec).
# Verified byte-for-byte against data/acemo_fresh strange pages.
_STRANGE_HEAP_NO_DATA = 0x03FFFFFF
_STRANGE_HEAP_TAIL_CONST = bytes.fromhex("ffffff03000000000000ff1f")
_STRANGE_HEAP_PATTERN = bytes.fromhex("f8ffff1f")
_STRANGE_HEAP_REPEAT = 1004
_STRANGE_HEAP_TAIL = 20
# Strange pages on these table types carry unknown7=1; all others 0. In a fresh
# export this is set for tracks (first table) and history (last table). Other
# fields (free/used=0, txc/txi=0x1fff) match across all strange pages.
STRANGE_UNKNOWN7 = frozenset({0, 19})
def build_strange_page(page_index: int, page_type: int, next_page: int,
data_page: int | None = None) -> bytes:
"""Empty placeholder page that the reference exports use as the first page of
every table chain. Reproduces a FRESH rekordbox export byte-for-byte: header
flags/markers plus the DeviceSQL free-space heap map. `next_page` should be
the reserved data-page index (page_index+1) even for strange-only tables --
rekordbox always points the strange page at its (possibly-blank) data slot.
`data_page` is the table's first data-page index, or None when the table is
strange-only (the free-map then stores the 0x03ffffff sentinel there).
"""
header = b""
header += pack("<I", 0) # magic
header += pack("<I", page_index)
header += pack("<I", page_type)
header += pack("<I", next_page)
header += pack("<I", 1) # sequence
header += pack("<I", 0) # reserved
# packed: num_row_offsets=0, num_rows=0, page_flags=0x64
header += bytes([0, 0, 0, 0x64])
header += pack("<H", 0) # free_size (fresh export = 0)
header += pack("<H", 0) # used_size (fresh export = 0)
header += pack("<H", 0x1fff) # transaction_row_count = failed
header += pack("<H", 0x1fff) # transaction_row_index = failed
header += pack("<H", 0x03ec) # 1004 (free-map repeat count)
header += pack("<H", 1 if page_type in STRANGE_UNKNOWN7 else 0) # unknown7
assert len(header) == HEADER_SIZE
data_ptr = _STRANGE_HEAP_NO_DATA if data_page is None else data_page
heap = pack("<II", page_index, data_ptr) + _STRANGE_HEAP_TAIL_CONST
heap += _STRANGE_HEAP_PATTERN * _STRANGE_HEAP_REPEAT
heap += b"\x00" * _STRANGE_HEAP_TAIL
assert len(heap) == PAGE_SIZE - HEADER_SIZE
return header + heap
class PageBuilder:
def __init__(self, page_index: int, page_type: int, max_rows: int = 0,
data_flag: int = 0x24, has_index_shift: bool = False):
self.page_index = page_index
self.page_type = page_type
self.next_page = page_index # caller patches before finalize
self.max_rows = max_rows
self.data_flag = data_flag # page_flags for a populated data page
# Row types whose layout begins `subtype:u2, index_shift:u2` (tracks,
# artists, albums) carry a slot-based index_shift at row offset 0x02.
# See FORMAT_SPEC.md s1: index_shift = slot_index << 5, where slot_index
# is the row's 0-based position in the page row index. maple never
# deletes rows, so slot_index == the row's ordinal on the page.
self.has_index_shift = has_index_shift
self.row_bytes: list[bytes] = []
self.row_offsets: list[int] = [] # relative to heap_pos
self.heap_cursor = 0 # position relative to heap_pos
def try_add(self, row: bytes) -> bool:
aligned = _align4(self.heap_cursor)
end = aligned + len(row)
footer_after = _num_row_groups(len(self.row_bytes) + 1) * ROW_GROUP_SIZE
if end + HEADER_SIZE + footer_after > PAGE_SIZE:
return False
if self.max_rows and len(self.row_bytes) >= self.max_rows:
return False
self.row_offsets.append(aligned)
self.row_bytes.append(row)
self.heap_cursor = end
return True
def num_rows(self) -> int:
return len(self.row_bytes)
def finalize(self) -> bytes:
n = len(self.row_bytes)
num_rgs = _num_row_groups(n)
footer_size = num_rgs * ROW_GROUP_SIZE
heap_padding = PAGE_SIZE - HEADER_SIZE - footer_size
used_size = self.heap_cursor
free_size = heap_padding - used_size
if free_size < 0:
raise ValueError("page overflow")
# Data pages with rows: rekordbox uses 0x24 (and 0x34 for some tables --
# see writer's PAGE_FLAG_34). maple previously wrote 0x00, which is
# missing the 0x20|0x04 bits every real rekordbox data page sets, and
# the players reject it. Empty data pages stay 0x44.
page_flags = self.data_flag if n > 0 else 0x44
header = b""
header += pack("<I", 0) # magic
header += pack("<I", self.page_index)
header += pack("<I", self.page_type)
header += pack("<I", self.next_page)
header += pack("<I", 0) # unknown1
header += pack("<I", 0) # unknown2
# 24 bits little-endian: low 13 = num_row_offsets, next 11 = num_rows,
# then u1 page_flags. Kaitai reads these as read_bits_int_le.
packed = ((n & 0x7FF) << 13) | (n & 0x1FFF)
header += bytes([packed & 0xFF, (packed >> 8) & 0xFF, (packed >> 16) & 0xFF, page_flags])
header += pack("<HH", free_size, used_size)
header += pack("<H", 0) # transaction_row_count
header += pack("<H", 0) # transaction_row_index
header += pack("<H", 0) # unknown6
header += pack("<H", 0) # unknown7
assert len(header) == HEADER_SIZE
heap = bytearray(heap_padding)
for slot, (ofs, row) in enumerate(zip(self.row_offsets, self.row_bytes)):
if self.has_index_shift:
# Patch index_shift (u2 at row offset 0x02) from the placeholder
# to slot_index<<5 (FORMAT_SPEC.md s1).
row = row[:2] + pack("<H", (slot << 5) & 0xFFFF) + row[4:]
heap[ofs:ofs + len(row)] = row
# Footers go after the heap in the file. Group 0 (rows 0-15) sits closest
# to the end of the page; group num_rgs-1 sits closest to the heap. So
# emit footers in reverse group order.
footer = b""
for g in range(num_rgs - 1, -1, -1):
start = g * ROWS_PER_GROUP
grp_offsets = self.row_offsets[start:start + ROWS_PER_GROUP]
grp_count = len(grp_offsets)
padded = grp_offsets + [0] * (ROWS_PER_GROUP - grp_count)
presence = (1 << grp_count) - 1
rg = b""
for ofs in reversed(padded):
rg += pack("<H", ofs)
rg += pack("<H", presence)
# Trailing u2: on a fresh, fully-packed page (no deleted slots) this
# equals the presence mask. Verified across every clean row group in
# data/acemo_fresh; pages where ref's value differs from presence are
# exactly the dead-slot/edit-history pages (see KNOWN_DIVERGENCE.md).
# Was a constant 16 (rekordcrate default), which never matched.
rg += pack("<H", presence)
assert len(rg) == ROW_GROUP_SIZE
footer += rg
assert len(footer) == footer_size
return header + bytes(heap) + footer
+245
View File
@@ -0,0 +1,245 @@
"""Row serialisers for the rekordbox export.pdb format."""
import zlib
from struct import pack
from maple.model import Album, Artist, Artwork, Color, Column, Genre, PlaylistEntry, PlaylistNode, Track
from maple.pdb.strings import encode_devicesql
TRACK_FIXED_PREFIX = 0x5E
TRACK_OFS_STRINGS_START = TRACK_FIXED_PREFIX
TRACK_HEADER_LEN = TRACK_OFS_STRINGS_START + 21 * 2
# Per-rekordbox-install seed for the track opaque-id blob (row 0x18, u4). It is
# a master.db value, constant across a single install, not derivable from track
# metadata (FORMAT_SPEC.md s2). EVERY real rekordbox export we have carries a
# NON-ZERO seed (this install = 0x21b4a7ab; data/MAPLE = 0xd6d3a567); the
# 0x78f74a68 "unanalyzed default" was a false lead -- it came from maple's own
# earlier output (data/usb), not a real export. Default to this install's real
# value; override per-install if deck testing shows it matters. Players treat
# the whole blob as opaque.
TRACK_OPAQUE_SEED = 0x21B4A7AB
# Range maple maps its stand-in opaque_track_id_lo (row 0x14) into, so it has the
# shape of a real rekordbox Content ID. The real AceMo lo values all fall in this
# band (observed 0x02c0a654 .. 0x0f8fd598), i.e. ~134M-261M. We can't reproduce
# the true (encrypted-DB) Content ID, so we map a stable per-track hash into the
# band -- non-zero, in-range, deterministic. Documented divergence (FORMAT_SPEC s2).
OPAQUE_LO_MIN = 0x08000000
OPAQUE_LO_MAX = 0x0F900000
def _pad2(b: bytes) -> bytes:
return b + b"\x00" if len(b) & 1 else b
def serialize_track(t: Track) -> bytes:
strings = [
"", # 0 isrc
"", # 1 texter
"1", # 2 unknown_string_2
"2", # 3 unknown_string_3
"", # 4 unknown_string_4
"", # 5 message
"ON", # 6 kuvo_public
"ON", # 7 autoload_hot_cues
"", # 8 unknown_string_5
"", # 9 unknown_string_6
t.date_added, # 10 date_added
t.release_date, # 11 release_date
t.mix_name, # 12 mix_name
"", # 13 unknown_string_7
t.analyze_path, # 14 analyze_path
t.analyze_date, # 15 analyze_date
t.comment, # 16 comment
t.title, # 17 title
"", # 18 unknown_string_8
t.filename, # 19 filename
t.file_path, # 20 file_path
]
encoded = [encode_devicesql(s) for s in strings]
offsets = []
cursor = TRACK_HEADER_LEN
blob_parts = []
for enc in encoded:
offsets.append(cursor)
blob_parts.append(enc)
cursor += len(enc)
blob = b"".join(blob_parts)
prefix = b""
prefix += pack("<HH", 0x24, 0) # subtype, index_shift
prefix += pack("<I", 788224) # bitmask
prefix += pack("<I", t.sample_rate) # sample_rate
prefix += pack("<I", 0) # composer_id
prefix += pack("<I", t.file_size) # file_size
# offsets 0x14/0x18/0x1a are ONE 8-byte LE opaque id:
# blob_u64 = opaque_track_id_lo | (opaque_seed << 32)
# Both halves are rekordbox-internal stored values (master.db / djmdContent
# + a per-install seed), NOT a function of the track metadata maple has --
# proven across the real exports + an exhaustive negative hash search
# (FORMAT_SPEC.md s2). They cannot be reproduced byte-for-byte without
# rekordbox's DB; that is a documented divergence (KNOWN_DIVERGENCE.md).
# Every REAL export has BOTH halves non-zero, so emit a non-zero stand-in of
# the right SHAPE: the per-install seed, and a per-track lo mapped into the
# real Content-ID band (OPAQUE_LO_MIN..MAX) -- which is where every observed
# AceMo lo lands. Stable hash -> band, so it's deterministic and in-range.
# (Was the 0xa567/0xd6d3 marker, which matched no real export; lo=0/
# seed=0x78f74a68 was also wrong -- that came from maple's own output
# mistaken for an "unanalyzed library", not a real rekordbox export.)
h = zlib.crc32(f"{t.id}|{t.file_path}".encode()) & 0xFFFFFFFF
opaque_lo = OPAQUE_LO_MIN + (h % (OPAQUE_LO_MAX - OPAQUE_LO_MIN))
prefix += pack("<I", opaque_lo) # opaque_track_id_lo (stand-in, per track)
prefix += pack("<I", TRACK_OPAQUE_SEED) # opaque_seed (u4 @0x18 = lo|hi<<16)
prefix += pack("<I", t.artwork_id) # artwork_id
prefix += pack("<I", 0) # key_id
prefix += pack("<I", 0) # original_artist_id
prefix += pack("<I", 0) # label_id
prefix += pack("<I", 0) # remixer_id
prefix += pack("<I", t.bitrate) # bitrate
prefix += pack("<I", t.track_number) # track_number
prefix += pack("<I", t.tempo) # tempo
prefix += pack("<I", t.genre_id) # genre_id
prefix += pack("<I", t.album_id) # album_id
prefix += pack("<I", t.artist_id) # artist_id
prefix += pack("<I", t.id) # id
prefix += pack("<H", t.disc_number) # disc_number
prefix += pack("<H", 0) # play_count
prefix += pack("<H", t.year) # year
prefix += pack("<H", t.sample_depth) # sample_depth
prefix += pack("<H", t.duration) # duration
prefix += pack("<H", 41) # unknown ("always 41?")
prefix += pack("<BB", 0, 0) # color_id, rating
prefix += pack("<H", 1) # unknown ("always 1?")
prefix += pack("<H", 3) # unknown ("alternating 2 or 3")
assert len(prefix) == TRACK_FIXED_PREFIX, len(prefix)
ofs_block = b"".join(pack("<H", o) for o in offsets)
row = prefix + ofs_block + blob
return _pad2(row)
def serialize_artist(a: Artist) -> bytes:
name = encode_devicesql(a.name)
near_ofs = 0x0A
far_threshold = 0x100
if near_ofs < far_threshold:
subtype = 0x60
row = b""
# index_shift is slot-based (slot_index<<5); the pager patches it at
# page-build time -- see FORMAT_SPEC.md s1. Write 0 as a placeholder.
row += pack("<HH", subtype, 0) # subtype, index_shift
row += pack("<I", a.id) # id
row += pack("<B", 0x03) # always 0x03
row += pack("<B", near_ofs) # ofs_name_near
assert len(row) == near_ofs
row += name
else:
subtype = 0x64
# Far form: fixed prefix + u2 ofs_name_far at row+0x0a
# Layout: subtype u2, index_shift u2, id u4, 0x03 u1, ofs_name_near u1, ofs_name_far u2 -> name follows at offset 0x0e
far = 0x0E
row = b""
# index_shift patched by the pager (slot_index<<5); 0 placeholder.
row += pack("<HH", subtype, 0)
row += pack("<I", a.id)
row += pack("<B", 0x03)
row += pack("<B", 0)
row += pack("<H", far)
row += name
return _pad2(row)
def serialize_album(a: Album) -> bytes:
name = encode_devicesql(a.name)
near_ofs = 0x16
subtype = 0x80
row = b""
# index_shift patched by the pager (slot_index<<5); 0 placeholder.
row += pack("<HH", subtype, 0) # subtype, index_shift
row += pack("<I", 0) # unknown
row += pack("<I", a.artist_id) # artist_id
row += pack("<I", a.id) # id
row += pack("<I", 0) # unknown
row += pack("<B", 0x03) # always 0x03
row += pack("<B", near_ofs) # ofs_name_near
assert len(row) == near_ofs
row += name
return _pad2(row)
def serialize_playlist_tree(n: PlaylistNode) -> bytes:
row = b""
row += pack("<I", n.parent_id)
row += pack("<I", 0)
row += pack("<I", n.sort_order)
row += pack("<I", n.id)
row += pack("<I", 1 if n.is_folder else 0)
row += encode_devicesql(n.name)
return _pad2(row)
def serialize_playlist_entry(e: PlaylistEntry) -> bytes:
return pack("<III", e.entry_index, e.track_id, e.playlist_id)
def serialize_artwork(a: Artwork) -> bytes:
row = pack("<I", a.id) + encode_devicesql(a.path)
return _pad2(row)
def serialize_genre(g: Genre) -> bytes:
row = pack("<I", g.id) + encode_devicesql(g.name)
return _pad2(row)
def serialize_key(key_id: int, name: str, id2: int | None = None) -> bytes:
"""key_row: u4 id, u4 id2 (mirrors id in the reference), devicesql name."""
if id2 is None:
id2 = key_id
return _pad2(pack("<II", key_id, id2) + encode_devicesql(name))
def serialize_history_playlist(playlist_id: int, name: str) -> bytes:
"""history_playlist_row: u4 id, devicesql name."""
return _pad2(pack("<I", playlist_id) + encode_devicesql(name))
def serialize_history_entry(track_id: int, playlist_id: int, entry_index: int) -> bytes:
"""history_entry_row: u4 track_id, u4 playlist_id, u4 entry_index."""
return pack("<III", track_id, playlist_id, entry_index)
def serialize_history_blob(date_added: str) -> bytes:
"""The `history` table row format isn't decoded by the kaitai schema.
A 40-byte blob copied from the reference, with the date substituted.
"""
date_str = encode_devicesql(date_added) # short ASCII "2026-05-26"
bucket = encode_devicesql("1000")
empty = encode_devicesql("")
head = pack("<HHH", 0x0280, 0, 0xb0) + b"\x00" * 6
body = head + date_str + b"\x19\x1e" + bucket + empty
# pad to 40 bytes
if len(body) < 40:
body += b"\x00" * (40 - len(body))
return body[:40]
def serialize_color(c: Color) -> bytes:
# Schema: 5 bytes padding, u2 id, u1 (zero), then name string.
# The reference puts the low byte of id at byte 4 (last of the padding).
row = b"\x00\x00\x00\x00" + bytes([c.id & 0xFF]) + pack("<H", c.id & 0xFFFF) + b"\x00" + encode_devicesql(c.name)
return _pad2(row)
def serialize_column(c: Column) -> bytes:
"""A column row is: u2 id, u2 code, then a 'long' devicesql string whose
UTF-16 body is wrapped by the sentinel codepoints U+FFFA / U+FFFB.
"""
body = b"\xfa\xff" + c.name.encode("utf-16-le") + b"\xfb\xff"
total_length = len(body) + 4 # 4 = 0x90 marker + u2 length + u1 pad
name_str = b"\x90" + pack("<H", total_length) + b"\x00" + body
row = pack("<HH", c.id, c.code) + name_str
return _pad2(row)
+35
View File
@@ -0,0 +1,35 @@
"""DeviceSQL variable-length string encoding.
Three on-disk forms (see rekordbox-pdb.ksy `device_sql_string`):
- odd length_and_kind byte -> short ASCII (< 127 chars)
- 0x40 -> long ASCII (any length, ASCII bytes)
- 0x90 -> long UTF-16LE (for non-ASCII content)
We pick the cheapest form that fits the content. Long-but-ASCII paths
must use 0x40, NOT 0x90 -- some player firmwares decode 0x90 strings
as UTF-16 and then fail the case-sensitive filesystem lookup, so any
track whose `file_path` lands in the wrong form silently goes missing.
"""
EMPTY = b"\x03"
def encode_devicesql(s: str) -> bytes:
if not s:
return EMPTY
try:
ascii_bytes = s.encode("ascii")
except UnicodeEncodeError:
ascii_bytes = None
if ascii_bytes is not None:
if len(ascii_bytes) < 127:
length = len(ascii_bytes) + 1
length_and_kind = ((length << 1) | 1) & 0xFF
return bytes([length_and_kind]) + ascii_bytes
total_length = len(ascii_bytes) + 4 # marker + u2 length + u1 pad
return bytes([0x40]) + total_length.to_bytes(2, "little") + b"\x00" + ascii_bytes
payload = s.encode("utf-16-le")
total_length = len(payload) + 4
return bytes([0x90]) + total_length.to_bytes(2, "little") + b"\x00" + payload
+270
View File
@@ -0,0 +1,270 @@
"""Top-level rekordbox .pdb file writer."""
from pathlib import Path
from struct import pack, unpack_from
from maple.model import (
DEFAULT_COLUMNS,
STANDARD_COLORS,
UNKNOWN_17_ROWS,
UNKNOWN_18_ROWS,
Database,
validate_referential_integrity,
)
from maple.pdb.pager import PAGE_SIZE, PageBuilder, build_strange_page
from maple.pdb.rows import (
serialize_album,
serialize_artist,
serialize_artwork,
serialize_color,
serialize_column,
serialize_genre,
serialize_history_blob,
serialize_key,
serialize_playlist_entry,
serialize_playlist_tree,
serialize_track,
)
PAGE_TYPE_TRACKS = 0
PAGE_TYPE_GENRES = 1
PAGE_TYPE_ARTISTS = 2
PAGE_TYPE_ALBUMS = 3
PAGE_TYPE_LABELS = 4
PAGE_TYPE_KEYS = 5
PAGE_TYPE_COLORS = 6
PAGE_TYPE_PLAYLIST_TREE = 7
PAGE_TYPE_PLAYLIST_ENTRIES = 8
PAGE_TYPE_UNKNOWN_9 = 9
PAGE_TYPE_UNKNOWN_10 = 10
PAGE_TYPE_HISTORY_PLAYLISTS = 11
PAGE_TYPE_HISTORY_ENTRIES = 12
PAGE_TYPE_ARTWORK = 13
PAGE_TYPE_UNKNOWN_14 = 14
PAGE_TYPE_UNKNOWN_15 = 15
PAGE_TYPE_COLUMNS = 16
PAGE_TYPE_UNKNOWN_17 = 17
PAGE_TYPE_UNKNOWN_18 = 18
PAGE_TYPE_HISTORY = 19
ALL_PAGE_TYPES = list(range(20))
FILE_HEADER_SIZE = 28 # 0,page_size,num_tables,next_unused,unknown,sequence,gap = 7*u4
TABLE_ENTRY_SIZE = 16 # page_type,empty_candidate,first_page,last_page = 4*u4
# Page types whose populated data pages rekordbox tags 0x34 (vs the 0x24 the
# rest get). In a FRESH from-scratch export (data/acemo_fresh) only tracks and
# history carry 0x34; every other table's data page is 0x24. (The wider set
# {0,2,3,13,17,19} was matched against the EDITED reference, whose extra 0x34
# pages were edit-history artifacts.) These are the same two tables that get
# strange-page unknown7=1 -- see pager.STRANGE_UNKNOWN7.
PAGE_FLAG_34 = frozenset({0, 19}) # tracks, history
# Tables that get a real data page even when they have zero rows. A FRESH
# from-scratch rekordbox export (data/acemo_fresh) gives a data page to a table
# IFF it has rows -- every empty table is strange-page-only. The earlier
# non-empty CORE_TABLES set was matched against the EDITED reference
# (data/acemo), whose genres table (type 1) carried a leftover empty data page
# from a deleted genre row -- an edit-history artifact, not a format rule (see
# KNOWN_DIVERGENCE.md). Against the fresh reference the correct rule is
# "data page iff rows>0", i.e. no table is forced. Kept as an (empty) hook in
# case a future fresh export proves some table is always materialised.
CORE_TABLES: frozenset[int] = frozenset()
# Row types whose layout begins `subtype:u2, index_shift:u2`, so they carry a
# slot-based index_shift the pager must patch (FORMAT_SPEC.md s1): tracks,
# artists, albums.
INDEX_SHIFT_TABLES = frozenset({0, 2, 3})
SEQUENCE_OFFSET = 20 # u4 offset of `sequence` in the file header (page 0)
def _next_sequence(path: Path) -> int:
"""rekordbox's file-header `sequence` is an internal transaction counter from
its master.db -- we can't reproduce its exact value. The faithful behaviour is
to monotonically increment: read the sequence already in the destination
export (if any) and add 1, defaulting to 1 for a brand-new file. This keeps
each export's sequence strictly greater than the last, as rekordbox does.
"""
try:
head = path.read_bytes()[:FILE_HEADER_SIZE]
except (FileNotFoundError, OSError):
return 1
if len(head) < FILE_HEADER_SIZE:
return 1
existing = unpack_from("<I", head, SEQUENCE_OFFSET)[0]
return existing + 1 if existing else 1
def write_pdb(db: Database, path: str | Path) -> None:
# Refuse to emit a pdb with dangling foreign keys -- a track pointing at a
# genre/artist/album/artwork row that doesn't exist makes the player follow
# the FK into an empty table and freeze (freeze-class, like the deck hang).
fk_errors = validate_referential_integrity(db)
if fk_errors:
raise ValueError("dangling foreign keys (would freeze the player):\n " + "\n ".join(fk_errors))
# Layout (matching rekordbox): page 0 is the file header. Pages 1..2*N are
# reserved 2-per-table -- strange page at the odd index 1+2k, first data page
# at the even index 2+2k. Overflow data pages come from the pool at 2*N+1.
serialized_tracks = [serialize_track(t) for t in db.tracks]
serialized_artists = [serialize_artist(a) for a in db.artists]
serialized_albums = [serialize_album(a) for a in db.albums]
serialized_pl_tree = [serialize_playlist_tree(n) for n in db.playlist_nodes]
serialized_pl_entries = [serialize_playlist_entry(e) for e in db.playlist_entries]
serialized_artwork = [serialize_artwork(a) for a in db.artwork]
serialized_columns = [serialize_column(c) for c in DEFAULT_COLUMNS]
serialized_genres = [serialize_genre(g) for g in db.genres]
# If the caller didn't populate colors, always emit the standard palette
# so the player's Color filter has a non-empty value set.
colors = db.colors if db.colors else STANDARD_COLORS
serialized_colors = [serialize_color(c) for c in colors]
serialized_keys = [serialize_key(k.id, k.name, k.id2) for k in db.keys]
# unknown_17/unknown_18 row format isn't decoded by the kaitai schema;
# we emit the reference's byte-for-byte rows (four little-endian u2s).
serialized_unknown_17 = [pack("<HHHH", *r) for r in UNKNOWN_17_ROWS]
serialized_unknown_18 = [pack("<HHHH", *r) for r in UNKNOWN_18_ROWS]
# A real rekordbox export leaves history_playlists and history_entries
# EMPTY (only the `history` table itself carries a row). maple used to
# fabricate a dummy "HISTORY 001" playlist + entry referencing a track,
# which the player doesn't expect -- drop them and match the reference.
today = db.tracks[0].date_added if db.tracks else "2026-01-01"
serialized_history_playlists: list[bytes] = []
serialized_history_entries: list[bytes] = []
serialized_history = [serialize_history_blob(today)]
rows_by_type: dict[int, list[bytes]] = {
PAGE_TYPE_TRACKS: serialized_tracks,
PAGE_TYPE_GENRES: serialized_genres,
PAGE_TYPE_ARTISTS: serialized_artists,
PAGE_TYPE_ALBUMS: serialized_albums,
PAGE_TYPE_KEYS: serialized_keys,
PAGE_TYPE_COLORS: serialized_colors,
PAGE_TYPE_PLAYLIST_TREE: serialized_pl_tree,
PAGE_TYPE_PLAYLIST_ENTRIES: serialized_pl_entries,
PAGE_TYPE_ARTWORK: serialized_artwork,
PAGE_TYPE_COLUMNS: serialized_columns,
PAGE_TYPE_UNKNOWN_17: serialized_unknown_17,
PAGE_TYPE_UNKNOWN_18: serialized_unknown_18,
PAGE_TYPE_HISTORY_PLAYLISTS: serialized_history_playlists,
PAGE_TYPE_HISTORY_ENTRIES: serialized_history_entries,
PAGE_TYPE_HISTORY: serialized_history,
}
num_tables = len(ALL_PAGE_TYPES) # 20
overflow_next = 1 + 2 * num_tables # first overflow page (41)
pages_by_index: dict[int, bytes] = {} # page_index -> 4096-byte page
table_dir: list[tuple[int, int, int]] = [] # (page_type, first_page, last_page)
last_builders: list[PageBuilder] = [] # last data page of each chain, patched below
for k, ptype in enumerate(ALL_PAGE_TYPES):
strange_idx = 1 + 2 * k
data_idx = 2 + 2 * k
rows = rows_by_type.get(ptype, [])
data_flag = 0x34 if ptype in PAGE_FLAG_34 else 0x24
has_index_shift = ptype in INDEX_SHIFT_TABLES
# Optional table with no rows: strange page only (its even page stays
# blank). rekordbox still points the strange page at the reserved data
# index (strange_idx+1 == data_idx), and the table dir's last_page is the
# strange page itself.
if not rows and ptype not in CORE_TABLES:
pages_by_index[strange_idx] = build_strange_page(
strange_idx, ptype, next_page=data_idx, data_page=None)
table_dir.append((ptype, strange_idx, strange_idx))
continue
# First data page at the reserved even index; overflow pages from the pool.
builders: list[PageBuilder] = [
PageBuilder(data_idx, ptype, data_flag=data_flag, has_index_shift=has_index_shift)
]
for row in rows:
if not builders[-1].try_add(row):
nb = PageBuilder(overflow_next, ptype, data_flag=data_flag,
has_index_shift=has_index_shift)
overflow_next += 1
if not nb.try_add(row):
raise ValueError(f"row of size {len(row)} too large for an empty page")
builders.append(nb)
pages_by_index[strange_idx] = build_strange_page(
strange_idx, ptype, next_page=data_idx, data_page=data_idx)
for i, b in enumerate(builders):
# Intermediate pages chain forward to the next data page and are
# finalised now. The LAST page's next_page is patched after the loop
# to a free-pool sentinel (FREEZE FIX) -- never at itself -- so it is
# finalised there, not here.
if i != len(builders) - 1:
b.next_page = builders[i + 1].page_index
pages_by_index[b.page_index] = b.finalize()
last_builders.append(builders[-1])
table_dir.append((ptype, strange_idx, builders[-1].page_index))
# FREEZE FIX (#13): no data page may self-loop its next_page. In every
# genuine rekordbox export (data/MAPLE, lib_multi) the last page of each
# chain points FORWARD to a free/unallocated page; the firmware Page Manager
# walks next_page and treats an index at/after next_unused (incl. past EOF)
# as "end of chain", but an index == the current page is an infinite loop ->
# the deck froze. maple used to set last.next_page = its own index. Point
# every last page at a single dedicated free-pool sentinel just past the
# overflow region, materialise that page (zero-filled, as real exports do),
# and set next_unused beyond it.
free_sentinel = overflow_next
for b in last_builders:
b.next_page = free_sentinel
for b in last_builders:
pages_by_index[b.page_index] = b.finalize()
# Materialise a small free pool of zero-filled pages and set next_unused PAST
# it, mirroring a real export (e.g. data/MAPLE: 71 pages, next_unused=74 ->
# ~3 free pages past the last live page). A genuine export always keeps
# allocated-but-empty pages after the data and points next_unused beyond the
# file; maple previously had next_unused == EOF with no free pool, which can
# starve the firmware's free-list/allocator walk of a valid sentinel page.
FREE_POOL_PAGES = 4
for j in range(FREE_POOL_PAGES):
pages_by_index[free_sentinel + j] = b"\x00" * PAGE_SIZE
# File ends after the materialised free pool; next_unused points a couple
# pages PAST EOF, exactly as real exports do (data/MAPLE: file=71 pages,
# next_unused=74). The firmware tolerates a past-EOF next_unused.
file_page_count = free_sentinel + FREE_POOL_PAGES # number of pages incl. page 0
next_unused_page = file_page_count + 2
sequence = _next_sequence(Path(path))
file_header = b""
file_header += pack("<I", 0) # magic
file_header += pack("<I", PAGE_SIZE)
file_header += pack("<I", num_tables)
file_header += pack("<I", next_unused_page)
file_header += pack("<I", 0) # unknown
file_header += pack("<I", sequence) # incrementing transaction counter
file_header += pack("<I", 0) # gap
assert len(file_header) == FILE_HEADER_SIZE
tables_block = b""
for ptype, first, last in table_dir:
tables_block += pack("<I", ptype)
tables_block += pack("<I", 0) # empty_candidate
tables_block += pack("<I", first)
tables_block += pack("<I", last)
assert len(tables_block) == num_tables * TABLE_ENTRY_SIZE
header_full = file_header + tables_block
pad_to_page = PAGE_SIZE - len(header_full)
if pad_to_page < 0:
raise ValueError("too many tables for one page")
page0 = header_full + b"\x00" * pad_to_page
blank = b"\x00" * PAGE_SIZE
out_path = Path(path)
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("wb") as f:
f.write(page0)
# Write pages 1..file_page_count-1 (reserved-but-unused written blank).
# next_unused_page intentionally points a couple pages PAST this EOF.
for idx in range(1, file_page_count):
page = pages_by_index.get(idx, blank)
assert len(page) == PAGE_SIZE
f.write(page)
View File
Whitespace-only changes.
+34
View File
@@ -0,0 +1,34 @@
"""Discover audio files and m3u8 playlists in an input directory."""
from pathlib import Path
AUDIO_EXTS = {".mp3", ".flac", ".wav", ".aiff", ".aif", ".m4a"}
def scan_audio(input_dir: Path) -> list[Path]:
out = []
for p in sorted(input_dir.rglob("*")):
if p.is_file() and p.suffix.lower() in AUDIO_EXTS:
out.append(p)
return out
def parse_m3u8(playlist_path: Path) -> list[Path]:
base = playlist_path.parent
entries = []
with playlist_path.open("r", encoding="utf-8", errors="replace") as f:
for line in f:
s = line.strip()
if not s or s.startswith("#"):
continue
p = Path(s)
if not p.is_absolute():
p = (base / p).resolve()
entries.append(p)
return entries
def scan_playlists(input_dir: Path) -> dict[str, list[Path]]:
out: dict[str, list[Path]] = {}
for p in sorted(input_dir.rglob("*.m3u8")):
out[p.stem] = parse_m3u8(p)
return out
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.14"
# dependencies = []
# ///
"""Thin binding around the `sockseek` CLI.
Creates the data directories and invokes sockseek with the CLI-argument
equivalents of etc/sldl.tpl.conf, so the two stay in sync without rendering a
config file. Reads SOCKSEEK_DATA / SOCKSEEK_USER / SOCKSEEK_PASS from the
environment (loaded from .env by just).
just sockseek <input> # csv path, url, search string, ...
just sockseek "tracks.csv -a" # extra flags pass through / override
Any args after the input are forwarded to sockseek verbatim, placed after the
defaults so they win on conflict.
"""
import os
import subprocess
import sys
from pathlib import Path
# Mirror of etc/sldl.tpl.conf. Keep these in sync with that file.
NAME_FORMAT = "{artist(/)album(/)track( )title|(_missing_tags/)slsk-foldername(/)slsk-filename}"
def env(key):
val = os.environ.get(key)
if not val:
sys.exit(f"missing env var: {key}")
return val
def config_args(data, user, password):
"""CLI equivalents of etc/sldl.tpl.conf (no-progress has no sockseek flag)."""
return [
"--path", data,
"--index-path", f"{data}/_metadata/index.sldl.csv",
"--user", user,
"--pass", password,
"--name-format", NAME_FORMAT,
"--pref-format", "flac,mp3",
"--pref-min-bitrate", "200",
"--pref-max-bitrate", "2500",
"--strict-conditions",
"--fast-search",
"--concurrent-jobs", "4",
]
def main():
if not sys.argv[1:]:
sys.exit("usage: sockseek.py <input> [extra sockseek args...]")
inp, *extra = sys.argv[1:]
data = env("SOCKSEEK_DATA")
user = env("SOCKSEEK_USER")
password = env("SOCKSEEK_PASS")
Path(data, "_metadata").mkdir(parents=True, exist_ok=True)
args = config_args(data, user, password)
cmd = ["sockseek", inp, *args, *extra]
masked = ["***" if a == password else a for a in cmd]
print(" ".join(masked), file=sys.stderr)
raise SystemExit(subprocess.run(cmd).returncode)
if __name__ == "__main__":
main()
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.14"
# dependencies = []
# ///
"""Dump tracks from one or more Spotify playlists (Client Credentials flow).
just spotify # uses the placeholder ids below
just spotify "<url-or-id> ..." # explicit playlists
just spotify "--file lists.txt --json"
just spotify "<url-or-id> --csv" # single sockseek CSV (stdout)
just spotify "<url-or-id> --csv --out tracks.csv"
Playlists can be ids, full URLs, or spotify: URIs. Works for any public
playlist; no user login needed. Requires SPOTIFY_CLIENT_ID /
SPOTIFY_CLIENT_SECRET in the environment (loaded from .env by just).
--csv writes one combined CSV with Artist,Title,Album,Length columns (Length
in seconds), the format consumed by sockseek/sldl. Each playlist is preceded
by a `# <name>` comment line as a separator. Writes to stdout, or to a file
with --out <path>.
"""
import base64
import csv
import json
import os
import re
import sys
import urllib.parse
import urllib.request
def get_token(client_id, client_secret):
auth = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
data = urllib.parse.urlencode({"grant_type": "client_credentials"}).encode()
req = urllib.request.Request(
"https://accounts.spotify.com/api/token",
data=data,
headers={"Authorization": f"Basic {auth}"},
)
with urllib.request.urlopen(req) as resp:
return json.load(resp)["access_token"]
def api_get(url, token):
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
with urllib.request.urlopen(req) as resp:
return json.load(resp)
def parse_playlist_id(arg):
m = re.search(r"playlist[/:]([A-Za-z0-9]+)", arg)
return m.group(1) if m else arg.strip()
def read_list_file(path):
out = []
with open(path) as fh:
for line in fh:
line = line.split("#", 1)[0].strip()
if line:
out.append(line)
return out
def fetch_playlist(pid, token):
meta = api_get(
f"https://api.spotify.com/v1/playlists/{pid}"
f"?fields={urllib.parse.quote('name,owner(display_name)')}",
token,
)
fields = "items(track(name,artists(name),album(name),duration_ms)),next"
url = (
f"https://api.spotify.com/v1/playlists/{pid}/tracks"
f"?limit=100&fields={urllib.parse.quote(fields)}"
)
tracks = []
while url:
page = api_get(url, token)
for item in page.get("items", []):
t = item.get("track")
if not t:
continue
tracks.append(
{
"title": t["name"],
"artists": [a["name"] for a in t["artists"]],
"album": t["album"]["name"],
"duration_ms": t["duration_ms"],
}
)
url = page.get("next")
return {"id": pid, "name": meta.get("name"), "tracks": tracks}
def write_csv(playlists, fh):
"""Single combined sockseek/sldl CSV, one `# <name>` comment per playlist."""
w = csv.writer(fh)
w.writerow(["Artist", "Title", "Album", "Length"])
for pl in playlists:
fh.write(f"# {pl['name']}\n")
for t in pl["tracks"]:
w.writerow(
[
", ".join(t["artists"]),
t["title"],
t["album"],
round(t["duration_ms"] / 1000),
]
)
def main():
argv = sys.argv[1:]
as_json = "--json" in argv
as_csv = "--csv" in argv
argv = [a for a in argv if a not in ("--json", "--csv")]
inputs = []
out_path = None
i = 0
while i < len(argv):
if argv[i] == "--file":
inputs += read_list_file(argv[i + 1])
i += 2
elif argv[i] == "--out":
out_path = argv[i + 1]
i += 2
else:
inputs.append(argv[i])
i += 1
if not inputs:
sys.exit(
"usage: spotify.py [--json|--csv] <url-or-id ...> "
"[--file list.txt] [--out path]"
)
client_id = os.environ.get("SPOTIFY_CLIENT_ID")
client_secret = os.environ.get("SPOTIFY_CLIENT_SECRET")
if not (client_id and client_secret):
sys.exit("set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET")
token = get_token(client_id, client_secret)
playlists = [fetch_playlist(parse_playlist_id(x), token) for x in inputs]
if as_json:
print(json.dumps(playlists, indent=2, ensure_ascii=False))
return
if as_csv:
if out_path:
with open(out_path, "w", newline="") as fh:
write_csv(playlists, fh)
print(f"wrote {sum(len(p['tracks']) for p in playlists)} tracks to {out_path}")
else:
write_csv(playlists, sys.stdout)
return
for pl in playlists:
print(f"\n=== {pl['name']} ({len(pl['tracks'])} tracks) ===")
for n, t in enumerate(pl["tracks"], 1):
print(f"{n:>3}. {', '.join(t['artists'])} - {t['title']}")
if __name__ == "__main__":
main()
+30
View File
@@ -0,0 +1,30 @@
from maple.pdb.strings import EMPTY, encode_devicesql
def test_empty():
assert encode_devicesql("") == EMPTY
def test_short_ascii():
out = encode_devicesql("ON")
# length=3 -> length_and_kind = (3<<1)|1 = 7
assert out == bytes([7]) + b"ON"
def test_longer_ascii():
s = "Hello, World!"
out = encode_devicesql(s)
length = len(s) + 1
assert out[0] == ((length << 1) | 1)
assert out[1:] == s.encode("ascii")
def test_utf16():
s = "ЛаBа"
out = encode_devicesql(s)
assert out[0] == 0x90
# Total length stored = payload_len + 4
payload = s.encode("utf-16-le")
assert out[1:3] == (len(payload) + 4).to_bytes(2, "little")
assert out[3] == 0
assert out[4:] == payload
+29
View File
@@ -0,0 +1,29 @@
from pathlib import Path
from maple.model import Album, Artist, Database, PlaylistEntry, PlaylistNode, Track
from maple.pdb.writer import write_pdb
def test_smoke(tmp_path: Path):
db = Database()
db.artists.append(Artist(id=1, name="AceMo"))
db.albums.append(Album(id=1, name="All My Life", artist_id=1))
db.tracks.append(Track(
id=1, title="Where They At", artist_id=1, album_id=1,
filename="ace.mp3", file_path="/Contents/AceMo/All My Life/ace.mp3",
file_size=14547692, sample_rate=44100, sample_depth=16, bitrate=320,
duration=436, track_number=7, year=2019, date_added="2025-01-04",
))
db.playlist_nodes.append(PlaylistNode(id=1, parent_id=0, sort_order=1, name="MyList"))
db.playlist_entries.append(PlaylistEntry(playlist_id=1, track_id=1, entry_index=1))
out = tmp_path / "export.pdb"
write_pdb(db, out)
assert out.exists()
data = out.read_bytes()
assert len(data) % 4096 == 0
assert data[:4] == b"\x00\x00\x00\x00"
# page_size at offset 4
assert int.from_bytes(data[4:8], "little") == 4096
# num_tables at offset 8 -- rekordbox always emits all 20 page-type tables
assert int.from_bytes(data[8:12], "little") == 20
+80
View File
@@ -0,0 +1,80 @@
"""Build a maple export.pdb from the SAME tracks as the real rekordbox AceMo
export, so the two can be diffed and the maple pdb tested on a player against
the audio/USBANLZ already on the USB.
Reads metadata out of data/acemo/PIONEER/rekordbox/export.pdb (the real export)
and reconstructs an identical-content maple Database, then writes
captures/maple_acemo.pdb.
"""
import sys
from io import BytesIO
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from kaitaistruct import KaitaiStream
from kaitai.rekordbox_pdb import RekordboxPdb
from maple.model import Album, Artist, Artwork, Database, Key, Track
from maple.pdb.writer import write_pdb
REAL = ROOT / "data/acemo/PIONEER/rekordbox/export.pdb"
OUT = ROOT / "captures/maple_acemo.pdb"
PT = {t.value: t.name for t in RekordboxPdb.PageType}
def _rows(tbl):
out, ref = [], tbl.first_page
while True:
pg = ref.body
if pg.is_data_page:
for rg in pg.row_groups:
for r in rg.rows:
if r.present:
out.append(r.body)
if ref.index == tbl.last_page.index:
break
ref = pg.next_page
return out
def _txt(v):
try:
return v.body.text
except Exception:
return ""
def main():
pdb = RekordboxPdb(is_ext=False, _io=KaitaiStream(BytesIO(REAL.read_bytes())))
by = {PT.get(t.type.value): _rows(t) for t in pdb.tables}
db = Database()
for a in by.get("artists", []):
db.artists.append(Artist(id=a.id, name=_txt(a.name)))
for a in by.get("albums", []):
db.albums.append(Album(id=a.id, name=_txt(a.name), artist_id=a.artist_id))
for k in by.get("keys", []):
db.keys.append(Key(id=k.id, name=_txt(k.name), id2=k.id2))
for a in by.get("artwork", []):
db.artwork.append(Artwork(id=a.id, path=_txt(a.path)))
for t in by.get("tracks", []):
db.tracks.append(Track(
id=t.id, title=_txt(t.title), artist_id=t.artist_id, album_id=t.album_id,
filename=_txt(t.filename), file_path=_txt(t.file_path), file_size=t.file_size,
sample_rate=t.sample_rate, sample_depth=t.sample_depth, bitrate=t.bitrate,
duration=t.duration, track_number=t.track_number, year=t.year,
disc_number=t.disc_number, tempo=t.tempo, comment=_txt(t.comment),
date_added=_txt(t.date_added), release_date=_txt(t.release_date),
mix_name=_txt(t.mix_name), analyze_path=_txt(t.analyze_path),
analyze_date=_txt(t.analyze_date), artwork_id=t.artwork_id, genre_id=t.genre_id))
write_pdb(db, OUT)
sz = OUT.stat().st_size
print(f"wrote {OUT} ({sz} bytes, page-mult={sz % 4096 == 0}) "
f"tracks={len(db.tracks)} artists={len(db.artists)} albums={len(db.albums)} "
f"keys={len(db.keys)} artwork={len(db.artwork)}")
if __name__ == "__main__":
main()
+104
View File
@@ -0,0 +1,104 @@
// Frida host (Node ESM) for the pdb-capture agent.
// - Attaches the agent (rbx_pdb_capture.js) to rekordbox processes.
// - Streams ALL their output to THIS console (agent lines are [pid]-tagged).
// - STAYS ALIVE via the Node event loop -- no frida-CLI REPL/EOF problem.
// - Uses CHILD-GATING (Windows-supported; spawn-gating is not) so children of a
// gated process are instrumented AT BIRTH. This is how we reach the
// rekordboxAgent that writes export.pdb: it's CIG-protected and refuses
// post-hoc injection, but as a freshly-spawned child it can be hooked.
// - Clears the captures/ folder on start.
//
// Setup (Windows, repo root, once): npm i frida
// Run:
// node tools\capture.mjs --spawn (RECOMMENDED: launch rekordbox under the
// gate so every child incl. the DB agent is
// hooked at birth. Close rekordbox first.)
// node tools\capture.mjs (attach to running procs + gate their
// future children; restart rekordbox to
// respawn the agent as a gated child.)
import * as fridaNs from "frida";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const frida = fridaNs.default ?? fridaNs;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REKORDBOX = "C:\\Program Files\\rekordbox\\rekordbox 7.0.5\\rekordbox.exe";
const AGENT = fs.readFileSync(path.join(__dirname, "rbx_pdb_capture.js"), "utf8");
const CAP = path.join(__dirname, "..", "captures");
const SPAWN = process.argv.includes("--spawn");
function clearCaptures() {
fs.mkdirSync(CAP, { recursive: true });
let n = 0;
for (const f of fs.readdirSync(CAP)) {
try { fs.rmSync(path.join(CAP, f), { force: true, recursive: true }); n++; } catch (e) {}
}
console.log(`[host] cleared ${n} file(s) from ${CAP}`);
}
function isRbx(name) { return /rekordbox/i.test(name || ""); }
// Attach, load the agent, and gate this process's future children too.
async function instrument(device, pid, label) {
try {
const session = await device.attach(pid);
session.detached.connect(reason => console.log(`[host] detached ${label} (${pid}): ${reason}`));
const script = await session.createScript(AGENT);
script.logHandler = (level, text) => console.log(text);
script.message.connect(msg => {
if (msg.type === "error")
console.error(`[host] ${label} (${pid}) script error:`, msg.stack || msg.description || msg);
});
await script.load();
try { await session.enableChildGating(); } catch (e) { /* not always available */ }
console.log(`[host] instrumented ${label} (${pid})`);
return session;
} catch (e) {
console.error(`[host] FAILED to instrument ${label} (${pid}): ${e.message}`);
return null;
}
}
async function main() {
clearCaptures();
const device = await frida.getLocalDevice();
// Children of any gated process are suspended at birth -> instrument, resume.
device.childAdded.connect(async child => {
const name = child.identifier || child.path || String(child.pid);
try {
console.log(`[host] child spawned: ${name} (${child.pid})`);
if (isRbx(name)) await instrument(device, child.pid, "child:" + name);
} catch (e) {
console.error(`[host] child handler error (${child.pid}): ${e.message}`);
} finally {
try { await device.resume(child.pid); } catch (_) {}
}
});
if (SPAWN) {
console.log(`[host] spawning ${REKORDBOX}`);
const pid = await device.spawn([REKORDBOX]);
await instrument(device, pid, "main"); // enables child gating on main
await device.resume(pid);
console.log(`[host] resumed main rekordbox (${pid}); children caught at birth`);
} else {
for (const p of await device.enumerateProcesses()) {
if (isRbx(p.name)) await instrument(device, p.pid, p.name);
}
console.log("[host] attached to running rekordbox processes (+child gating).");
console.log("[host] >>> the export.pdb writer (rekordboxAgent) refuses live injection.");
console.log("[host] >>> RESTART rekordbox -- the new agent spawns as a GATED CHILD and");
console.log("[host] >>> gets hooked at birth. Then Export and watch for [SAVED] ...export.pdb");
console.log("[host] >>> (or re-run with --spawn to launch rekordbox under the gate.)");
}
console.log("[host] ready. Ctrl-C to stop.");
process.stdin.resume();
process.on("SIGINT", () => { console.log("\n[host] stopping."); process.exit(0); });
}
main().catch(e => { console.error("[host] fatal:", e); process.exit(1); });
+209
View File
@@ -0,0 +1,209 @@
// Capture rekordbox's .pdb writes with Frida (run on the WINDOWS host).
//
// Hooks at the ntdll layer (NtCreateFile / NtWriteFile / NtClose) so it catches
// file IO no matter whether the app calls kernel32, kernelbase, or the CRT.
// Logs EVERY file opened for writing (so you can see temp names / the real
// filenames even if the .pdb filter is wrong), reconstructs files whose path
// matches SAVE_RE byte-for-byte, and saves them to OUT_DIR via Frida's File API.
// Prints a backtrace the first time each saved file is written -> the
// module+offset of the serializer to open in Ghidra.
//
// Usage (Windows PowerShell, rekordbox running):
// mkdir C:\Users\ntr\code\maple\maple-py\captures # OUT_DIR must exist
// frida-ps | Select-String rekordbox # find all PIDs
// frida -n rekordbox.exe -l tools\rbx_pdb_capture.js # or -p <pid>
// then trigger "Export to USB". If you see [w-open] lines but the export file
// isn't among them, widen SAVE_RE. If you see NOTHING, attach to another PID.
//
// Set VERBOSE = true to log every write-open path; false logs only SAVE_RE hits.
var OUT_DIR = "C:\\Users\\ntr\\code\\maple\\maple-py\\captures\\"; // must exist; Frida won't mkdir
var SAVE_RE = /export.*\.pdb|\.pdb$|PIONEER/i; // which opened files to reconstruct + save
var VERBOSE = true; // log ALL write-opens, not just SAVE_RE
var PID = Process.id;
var TAG = "[" + PID + "] ";
var DEBUG = true; // log every interesting open + all mapping activity
var DEBUG_RE = /export|\.pdb|PIONEER|rekordbox|Contents|USBANLZ/i; // what DEBUG opens to print
var ntdll = Process.getModuleByName("ntdll.dll");
function nt(name) { return ntdll.getExportByName(name); }
// Resolve + confirm a hook target so we can see in the console what attached.
function resolve(name) {
var a = ntdll.findExportByName(name);
console.log(TAG + "hook " + name + (a ? " -> " + a : " MISSING"));
return a;
}
// handle (string) -> { path, pos, end, chunks:[{pos,ab}], save, traced }
var files = {};
var counters = {};
function baseName(path) {
var parts = path.replace(/\\/g, "/").replace(/\/+$/, "").split("/");
return (parts[parts.length - 1] || "capture.bin").replace(/[^A-Za-z0-9._-]/g, "");
}
// Read the wide path out of an OBJECT_ATTRIBUTES* (x64 layout).
function readObjName(pObjAttr) {
if (pObjAttr.isNull()) return null;
var pUni = pObjAttr.add(16).readPointer(); // OBJECT_ATTRIBUTES.ObjectName
if (pUni.isNull()) return null;
var len = pUni.readU16(); // UNICODE_STRING.Length (bytes)
if (!len) return null;
var buf = pUni.add(8).readPointer(); // UNICODE_STRING.Buffer
if (buf.isNull()) return null;
return buf.readUtf16String(len / 2);
}
// NtCreateFile / NtOpenFile: args[0]=PHANDLE out, [1]=ACCESS_MASK, [2]=OBJECT_ATTRIBUTES*
function hookOpen(name) {
var addr = resolve(name);
if (!addr) return;
Interceptor.attach(addr, {
onEnter: function (args) {
this.pHandle = args[0];
this.access = args[1].toUInt32();
this.path = readObjName(args[2]);
},
onLeave: function (retval) {
if (!this.path) return;
var WRITE = 0x40000000 /*GENERIC_WRITE*/ | 0x10000000 /*GENERIC_ALL*/ |
0x2 /*FILE_WRITE_DATA*/ | 0x4 /*FILE_APPEND_DATA*/;
var isWrite = (this.access & WRITE) !== 0;
var save = SAVE_RE.test(this.path);
// DEBUG: show every interesting open (incl. read-only) + its access mask,
// so we can see if/how export.pdb is opened even when our filters miss it.
if (DEBUG && DEBUG_RE.test(this.path))
console.log(TAG + "[open] access=0x" + this.access.toString(16)
+ (isWrite ? " W" : " r") + (save ? " *SAVE" : "") + " " + this.path);
if (!isWrite && !save) return;
var handleStr = this.pHandle.readPointer().toString();
files[handleStr] = { path: this.path, pos: 0, end: 0, chunks: [], save: save, traced: false };
if (!DEBUG && (save || VERBOSE)) console.log(TAG + "[w-open] " + this.path);
},
});
}
hookOpen("NtCreateFile");
hookOpen("NtOpenFile");
// NtWriteFile: [0]=handle [5]=buffer [6]=length [7]=PLARGE_INTEGER ByteOffset
Interceptor.attach(resolve("NtWriteFile"), {
onEnter: function (args) {
var f = files[args[0].toString()];
if (!f) return;
var n = args[6].toUInt32();
if (n <= 0) return;
var pos = f.pos;
var pOff = args[7];
if (!pOff.isNull()) {
var hi = pOff.add(4).readU32();
if (hi !== 0xffffffff) pos = hi * 0x100000000 + pOff.readU32(); // skip sentinels
}
if (f.save) {
f.chunks.push({ pos: pos, ab: args[5].readByteArray(n) });
if (!f.traced) {
f.traced = true;
console.log(TAG + "[trace] writer for " + f.path + ":");
Thread.backtrace(this.context, Backtracer.ACCURATE)
.slice(0, 14)
.forEach(function (a) {
// module+offset maps straight to Ghidra: static = 0x140000000 + offset
var m = Process.findModuleByAddress(a);
var loc = m ? (m.name + "+0x" + a.sub(m.base).toString(16)) : a.toString();
console.log(" " + loc + " " + DebugSymbol.fromAddress(a));
});
}
}
f.pos = pos + n;
if (f.pos > f.end) f.end = f.pos;
},
});
// Shared: write a captured buffer to OUT_DIR with a PID-tagged, de-duped name.
function saveBuffer(path, arrayBuffer, size) {
var name = baseName(path);
var c = counters[name] || 0;
counters[name] = c + 1;
var dest = OUT_DIR + "p" + PID + "_" + name + (c === 0 ? "" : "." + c);
var fh = new File(dest, "wb");
fh.write(arrayBuffer);
fh.close();
console.log(TAG + "[SAVED] " + dest + " (" + size + " bytes)");
}
// NtClose: reconstruct + flush saved files written via NtWriteFile
function flush(handleStr) {
var f = files[handleStr];
if (!f) return;
delete files[handleStr];
if (!f.save || f.end === 0) return;
var out = new Uint8Array(f.end);
for (var i = 0; i < f.chunks.length; i++) out.set(new Uint8Array(f.chunks[i].ab), f.chunks[i].pos);
saveBuffer(f.path, out.buffer, f.end);
}
Interceptor.attach(resolve("NtClose"), { onEnter: function (args) { flush(args[0].toString()); } });
// ---- memory-mapped writes (the .pdb is likely built via a file mapping) ----
// CreateFileMapping/MapViewOfFile funnel through these ntdll calls. We link
// section -> backing file (NtCreateSection takes the file handle we already
// track), record the mapped view, and snapshot its memory when it's unmapped.
var sections = {}; // sectionHandle -> { path, save, maxSize }
var views = {}; // baseAddr string -> { base, size, path, save, maxSize }
function hookSections() {
var cs = resolve("NtCreateSection");
if (cs) Interceptor.attach(cs, {
onEnter: function (args) {
this.pSection = args[0];
this.pMax = args[3]; // PLARGE_INTEGER MaximumSize (may be null)
this.fileHandle = args[6].toString();// HANDLE FileHandle
},
onLeave: function (ret) {
if (ret.toInt32() !== 0) return; // not STATUS_SUCCESS
var f = files[this.fileHandle];
if (!f) return; // only file-backed sections we track
var maxSize = (this.pMax && !this.pMax.isNull()) ? this.pMax.readU32() : 0;
sections[this.pSection.readPointer().toString()] = { path: f.path, save: f.save, maxSize: maxSize };
},
});
var mv = resolve("NtMapViewOfSection");
if (mv) Interceptor.attach(mv, {
onEnter: function (args) {
this.section = args[0].toString();
this.pBase = args[2]; // PVOID* BaseAddress
this.pViewSize = args[6]; // PSIZE_T ViewSize
},
onLeave: function (ret) {
if (ret.toInt32() < 0) return; // negative NTSTATUS = error
var s = sections[this.section];
if (!s) return;
var base = this.pBase.readPointer();
var size = (this.pViewSize && !this.pViewSize.isNull()) ? this.pViewSize.readU32() : 0;
views[base.toString()] = { base: base, size: size, path: s.path, save: s.save, maxSize: s.maxSize };
if (s.save || VERBOSE) console.log(TAG + "[mmap] " + s.path + " @ " + base + " view=" + size + " max=" + s.maxSize);
},
});
function captureView(base) {
var v = views[base.toString()];
if (!v) return;
delete views[base.toString()];
if (!v.save) return;
var size = v.maxSize || v.size;
if (!size) { var r = Process.findRangeByAddress(base); size = r ? r.size : 0; }
if (!size) return;
try { saveBuffer(v.path, Memory.readByteArray(base, size), size); }
catch (e) { console.log(TAG + "[mmap] read failed @ " + base + ": " + e); }
}
var um = ntdll.findExportByName("NtUnmapViewOfSection");
if (um) Interceptor.attach(um, { onEnter: function (args) { captureView(args[1]); } });
}
hookSections();
console.log(TAG + "ntdll + mmap hooks installed -- trigger 'Export to USB' in rekordbox now");
+366
View File
@@ -0,0 +1,366 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "bidict"
version = "0.23.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093, upload-time = "2024-02-18T19:09:05.748Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "construct"
version = "2.10.70"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/77/8c84b98eca70d245a2a956452f21d57930d22ab88cbeed9290ca630cf03f/construct-2.10.70.tar.gz", hash = "sha256:4d2472f9684731e58cc9c56c463be63baa1447d674e0d66aeb5627b22f512c29", size = 86337, upload-time = "2023-11-29T08:44:49.545Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/fb/08b3f4bf05da99aba8ffea52a558758def16e8516bc75ca94ff73587e7d3/construct-2.10.70-py3-none-any.whl", hash = "sha256:c80be81ef595a1a821ec69dc16099550ed22197615f4320b57cc9ce2a672cb30", size = 63020, upload-time = "2023-11-29T08:44:46.876Z" },
]
[[package]]
name = "greenlet"
version = "3.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" },
{ url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" },
{ url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" },
{ url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" },
{ url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" },
{ url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" },
{ url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" },
{ url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" },
{ url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" },
{ url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" },
{ url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" },
{ url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" },
{ url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" },
{ url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" },
{ url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" },
{ url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" },
{ url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" },
{ url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" },
{ url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" },
{ url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" },
{ url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" },
{ url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" },
{ url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" },
{ url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" },
{ url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" },
{ url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" },
{ url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" },
{ url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "kaitaistruct"
version = "0.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/27/b8/ca7319556912f68832daa4b81425314857ec08dfccd8dbc8c0f65c992108/kaitaistruct-0.11.tar.gz", hash = "sha256:053ee764288e78b8e53acf748e9733268acbd579b8d82a427b1805453625d74b", size = 11519, upload-time = "2025-09-08T15:46:25.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/4a/cf14bf3b1f5ffb13c69cf5f0ea78031247790558ee88984a8bdd22fae60d/kaitaistruct-0.11-py2.py3-none-any.whl", hash = "sha256:5c6ce79177b4e193a577ecd359e26516d1d6d000a0bffd6e1010f2a46a62a561", size = 11372, upload-time = "2025-09-08T15:46:23.635Z" },
]
[[package]]
name = "maple"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "kaitaistruct" },
{ name = "mutagen" },
{ name = "pillow" },
{ name = "pyrekordbox" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "kaitaistruct", specifier = ">=0.11" },
{ name = "mutagen", specifier = ">=1.47.0" },
{ name = "pillow", specifier = ">=12.2.0" },
{ name = "pyrekordbox", specifier = ">=0.4.4" },
]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=9.0.3" }]
[[package]]
name = "mutagen"
version = "1.47.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/81/e6/64bc71b74eef4b68e61eb921dcf72dabd9e4ec4af1e11891bbd312ccbb77/mutagen-1.47.0.tar.gz", hash = "sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99", size = 1274186, upload-time = "2023-09-03T16:33:33.411Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/7a/620f945b96be1f6ee357d211d5bf74ab1b7fe72a9f1525aafbfe3aee6875/mutagen-1.47.0-py3-none-any.whl", hash = "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719", size = 194391, upload-time = "2023-09-03T16:33:29.955Z" },
]
[[package]]
name = "numpy"
version = "2.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" },
{ url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" },
{ url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" },
{ url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" },
{ url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" },
{ url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" },
{ url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" },
{ url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" },
{ url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" },
{ url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" },
{ url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" },
{ url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" },
{ url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" },
{ url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" },
{ url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" },
{ url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" },
{ url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" },
{ url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pillow"
version = "12.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "psutil"
version = "7.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
{ url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
{ url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
{ url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
{ url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
{ url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
{ url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
{ url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
{ url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
{ url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
{ url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
{ url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
{ url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyrekordbox"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bidict" },
{ name = "construct" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "psutil" },
{ name = "python-dateutil" },
{ name = "sqlalchemy" },
{ name = "sqlcipher3-wheels" },
]
sdist = { url = "https://files.pythonhosted.org/packages/39/8b/8fb7f21f4eb676df35c53214129ad33624634e2b678069abbf6a88509c6b/pyrekordbox-0.4.4.tar.gz", hash = "sha256:8bdb59f7c45c743bd2021613a72604b7607092d386e25f20cce78adfe30bf9a4", size = 2310256, upload-time = "2025-08-17T20:09:18.773Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/a8/28d28f02683bee40e3cfa32caed7c46e47daffb51482798fea9af0ab23cb/pyrekordbox-0.4.4-py3-none-any.whl", hash = "sha256:2de933870126b9a0ef04bea372f1c5934efcfbb2501efb6b47d8ace0e17fb3d8", size = 76000, upload-time = "2025-08-17T20:09:16.985Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.50"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", size = 2158519, upload-time = "2026-05-24T19:27:56.472Z" },
{ url = "https://files.pythonhosted.org/packages/5a/76/e703d2f7681d7d66c4c891af3f07c7ccf4c76ad7f18351de035b5eda007a/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb", size = 3282063, upload-time = "2026-05-24T20:09:38.57Z" },
{ url = "https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", size = 3287069, upload-time = "2026-05-24T20:17:21.942Z" },
{ url = "https://files.pythonhosted.org/packages/c2/15/765acc2bc693bccc43ca4a95d5b69750da8aaf6db1b5c616536e087f8920/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d", size = 3230453, upload-time = "2026-05-24T20:09:40.398Z" },
{ url = "https://files.pythonhosted.org/packages/63/61/08e03c3adbf5db0087a0b6816746fec8f3032fb2f7fc899a9bb9b2a48ce4/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f", size = 3252413, upload-time = "2026-05-24T20:17:24.067Z" },
{ url = "https://files.pythonhosted.org/packages/03/0c/370a1f2db38436c615e10134c8a37de3688e74084792380695f3f5083860/sqlalchemy-2.0.50-cp314-cp314-win32.whl", hash = "sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8", size = 2120063, upload-time = "2026-05-24T19:50:11.08Z" },
{ url = "https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.whl", hash = "sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39", size = 2145830, upload-time = "2026-05-24T19:50:12.452Z" },
{ url = "https://files.pythonhosted.org/packages/cc/ff/e5640a98a0b2f491eb8fde10fb6c773621a2e44340de231fafcc9370f4a9/sqlalchemy-2.0.50-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70", size = 2178435, upload-time = "2026-05-24T19:42:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/b7/85/337116e186f1236375b5fb70c21cfac98e8e8ab0d3a47be838dc47a59e08/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086", size = 3566059, upload-time = "2026-05-24T20:01:20.848Z" },
{ url = "https://files.pythonhosted.org/packages/96/34/bb0e190e161c3c2c24314a65add57218be14a4a9486886b7f5047c1ff7c8/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52", size = 3535366, upload-time = "2026-05-24T20:03:56.768Z" },
{ url = "https://files.pythonhosted.org/packages/df/5a/a7f759f97e4fd499c5d4e4488c760d5a7fbecf3028b465a04274fcd52384/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a", size = 3474879, upload-time = "2026-05-24T20:01:23.058Z" },
{ url = "https://files.pythonhosted.org/packages/9d/d9/2907ea38eb60687d297bf9c39e5ee58053c87b57fe8a9cae97090cecbf10/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d", size = 3486117, upload-time = "2026-05-24T20:03:59.052Z" },
{ url = "https://files.pythonhosted.org/packages/f2/e3/5aa06f167559f8c0bdae487e297d23ba548150ab016a3418265d617a4985/sqlalchemy-2.0.50-cp314-cp314t-win32.whl", hash = "sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e", size = 2150823, upload-time = "2026-05-24T20:08:58.644Z" },
{ url = "https://files.pythonhosted.org/packages/65/9b/112fb8f977582d7489d036e409e3723948bcf5320b3ac465f3c481bbe8f9/sqlalchemy-2.0.50-cp314-cp314t-win_amd64.whl", hash = "sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51", size = 2185794, upload-time = "2026-05-24T20:09:00.319Z" },
{ url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" },
]
[[package]]
name = "sqlcipher3-wheels"
version = "0.5.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/61/ec/d79c2e5f5b9246471f217cdd5f703dc8d804bce557a047e1bfad4f33c376/sqlcipher3_wheels-0.5.7.tar.gz", hash = "sha256:5fd344abba62c487c4d857a6fb55cb8dff8a97d4c4fbb4f9394b473e8e315dee", size = 2669821, upload-time = "2026-01-07T09:53:25.48Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/b5/8ca4fe7928d1e7151ecdf221ad757540e9a815ea10d854f047491859cd0c/sqlcipher3_wheels-0.5.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6831fd121adf8374ecfb67b63d9bdfc3bc405e7103e35dd3152f7f2d2bbe87ae", size = 4946253, upload-time = "2026-01-07T09:51:46.013Z" },
{ url = "https://files.pythonhosted.org/packages/cd/39/5eca1cced6b3a78cb20201232103b58ded2d57e87c413212bfd2a30c7209/sqlcipher3_wheels-0.5.7-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9d03491d9d4023cca969a4c87f01ea845368defcc52ea7cd71ce93deac3df286", size = 2898557, upload-time = "2026-01-07T09:51:47.261Z" },
{ url = "https://files.pythonhosted.org/packages/34/93/8c34dd286ff8bea8785896eee401a5e15ab37901687ba09d2bf0df584712/sqlcipher3_wheels-0.5.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1f3325fee4698fadbfa9b8245f213f7f3ecc3f0c86a761a2b26c787d54d3ecd1", size = 3163380, upload-time = "2026-01-07T09:51:48.384Z" },
{ url = "https://files.pythonhosted.org/packages/6d/2e/5b62f3c4181a64ab041589c6fdb509a70d69c2653f91d35b19c7550ad0d3/sqlcipher3_wheels-0.5.7-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:f80b8c72df20f8e2696e3b23577145e94b3f5a10217fef02d5c78d0ef2e578ba", size = 7259742, upload-time = "2026-01-07T09:51:49.698Z" },
{ url = "https://files.pythonhosted.org/packages/09/18/d78100264c66c96aec0723b2ddaf2b4edc72374e60c25b5ed8f3afb5e4f0/sqlcipher3_wheels-0.5.7-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:9feb22952dd44a6e86bbb1e95a0a603bd7cc61332d0b1adad2484d2ccdc61704", size = 6875763, upload-time = "2026-01-07T09:51:51.009Z" },
{ url = "https://files.pythonhosted.org/packages/8b/e0/0529fc2d3cb0d58950557e2ac229d338506e9522a7d3eb03540acd4a6ee7/sqlcipher3_wheels-0.5.7-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:79fd014b64fefd85086cff6af85286cfb3b5761473f0a541f973a02a3c0e184b", size = 7413959, upload-time = "2026-01-07T09:51:52.344Z" },
{ url = "https://files.pythonhosted.org/packages/06/81/290107b3bd5a84edcc7a542ce1e7fe42e401cd94f4a8b6ab22e546ba034d/sqlcipher3_wheels-0.5.7-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:92462368d7afc2c396466eeb4cfed85dccc12bef06a5f3a9a43a6598890ddd15", size = 7389084, upload-time = "2026-01-07T09:51:53.786Z" },
{ url = "https://files.pythonhosted.org/packages/54/f1/9237d5d14807d1a6c7900330b55cad40e192f54dd4d5f732239a2271922d/sqlcipher3_wheels-0.5.7-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:663f814ca785d064408ec96c7abdc6e940809efac34cdc1656fb3536cffb8ff5", size = 7039875, upload-time = "2026-01-07T09:51:55.236Z" },
{ url = "https://files.pythonhosted.org/packages/b9/46/ba150f6d1c38f0742055d5a693f5c3532a85c4aac3c8789d130ab8492dd9/sqlcipher3_wheels-0.5.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:47b5ae81361c0ca45fab27ce09f94ff795a7763767c9bcbbbce19923c82d2f65", size = 7252142, upload-time = "2026-01-07T09:51:56.581Z" },
{ url = "https://files.pythonhosted.org/packages/b0/d5/e493a4a19dedc2a9c25751bcc1a7eebc29a5cbc8859108b814ef20a8742e/sqlcipher3_wheels-0.5.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:869f77c3f8bfc94f05352b6ca42236adfb1b46f6c92082829a608cd561793a54", size = 6948368, upload-time = "2026-01-07T09:51:57.855Z" },
{ url = "https://files.pythonhosted.org/packages/6e/ac/e82f8e26d91859570c79e3c07d2de07c65001dde49b25cb60f058f694929/sqlcipher3_wheels-0.5.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:98f1b5f0f54316f105f553ec254961986c8dc617947f67daa11d49a19cbc9a58", size = 7385735, upload-time = "2026-01-07T09:51:59.874Z" },
{ url = "https://files.pythonhosted.org/packages/c2/2e/3152b89c2b5ce3f7c830b675451957349e4e0d2006d0d90354a50f5c8a01/sqlcipher3_wheels-0.5.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e4ca2cd825be2c30e6530c20ba6112545aaa409ad77478fb66285f98f44aac78", size = 7027637, upload-time = "2026-01-07T09:52:03.298Z" },
{ url = "https://files.pythonhosted.org/packages/76/8b/a1b3e8044128ab6a6343ef181a658298b598d0a5a2973d33e7f8fa5e45c0/sqlcipher3_wheels-0.5.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a823f48efe2927a781d28ce31a11263be0e836016ea7ef26e53da18efba2569b", size = 7061293, upload-time = "2026-01-07T09:52:04.997Z" },
{ url = "https://files.pythonhosted.org/packages/cb/2f/350f8ab4e061c588ad90e12dec59f609a7e4b51c7dc921b9c62eee8c020c/sqlcipher3_wheels-0.5.7-cp314-cp314-win32.whl", hash = "sha256:0f3a11b144c46a33cb34235b253a46b1b255968be34430f9bd7f9ed710283d41", size = 2033959, upload-time = "2026-01-07T09:52:06.307Z" },
{ url = "https://files.pythonhosted.org/packages/a0/f4/ecb143774ccf0c6c8b7c718b97ac5a69ef59aa57d89c2422aa564d4a9b2f/sqlcipher3_wheels-0.5.7-cp314-cp314-win_amd64.whl", hash = "sha256:f210fdbbb794cc1b3739c7bff879b8bcc5949567f5c222e251882ec076e69b72", size = 2560254, upload-time = "2026-01-07T09:52:07.614Z" },
{ url = "https://files.pythonhosted.org/packages/2e/bc/82cd7b7ecc0ad647d9641c9115a875ea6fe8bbc04b83bfd583ebf954260f/sqlcipher3_wheels-0.5.7-cp314-cp314-win_arm64.whl", hash = "sha256:30f825607048436ab6134450ddcc49ad05f17479640a830c0ea8d78f1700e6e0", size = 2727306, upload-time = "2026-01-07T09:52:08.83Z" },
{ url = "https://files.pythonhosted.org/packages/8e/06/dcad55d438eeb9b9b46349434a75ee98f2c42196077a63d7e263969195eb/sqlcipher3_wheels-0.5.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:b822211ea0d50500bc8dc82863fa570543e0f224359ccb46b7675a24c89983f5", size = 4948699, upload-time = "2026-01-07T09:52:10.513Z" },
{ url = "https://files.pythonhosted.org/packages/31/c2/66fcd2a7c3200323517e69c2ff49d48bb6aad15bc47d6f7d75710531a5c2/sqlcipher3_wheels-0.5.7-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:670f493bf5a9e70e86b0ae680a8bbf96aeaaf526c5018262186e6d7c52f021b1", size = 2900350, upload-time = "2026-01-07T09:52:11.766Z" },
{ url = "https://files.pythonhosted.org/packages/7b/3a/94bebb8260624be45531af2692e9f5329ee1163237f027a3716ce3f2ae39/sqlcipher3_wheels-0.5.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8c4a6dd38e9c4eff2304ea0751e8c5612394f277e63f007ae85118d9ce49b427", size = 3167268, upload-time = "2026-01-07T09:52:13.036Z" },
{ url = "https://files.pythonhosted.org/packages/a1/04/00d4d7e8905d9b53cc3cf490223b151df9a794e10138692fd4822962408b/sqlcipher3_wheels-0.5.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:7438567a502bfde64ca8732d1466ee6801deb97856315ac06d168a68456b2783", size = 7301506, upload-time = "2026-01-07T09:52:14.366Z" },
{ url = "https://files.pythonhosted.org/packages/95/81/75e439c4f13cbf79b243de08394916450c812eb8e949b726a758089aaf3b/sqlcipher3_wheels-0.5.7-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:098112cdba65feefdbaba3eebbefe45b5481158882d21a7d133f475614a2fff0", size = 6915748, upload-time = "2026-01-07T09:52:16.166Z" },
{ url = "https://files.pythonhosted.org/packages/48/3c/36e5aa4fbfe25884d7c930215cc2003b4ac7709866992086179982d04bc5/sqlcipher3_wheels-0.5.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3f54e5284ebfeb2b3578ec35d3c4ad209ec1d4a3fd31b910608eadd95169cf83", size = 7453665, upload-time = "2026-01-07T09:52:18.415Z" },
{ url = "https://files.pythonhosted.org/packages/ec/62/9eddf7efdd523b3a109416f31185361608d1db60b550eb6e787a209765bc/sqlcipher3_wheels-0.5.7-cp314-cp314t-manylinux_2_28_s390x.whl", hash = "sha256:8d3bcfbb799abfa4e31e70e61c779e963b993748bf56bd0603007a47cb2a3c3f", size = 7431832, upload-time = "2026-01-07T09:52:22.041Z" },
{ url = "https://files.pythonhosted.org/packages/61/86/3e1f57a9e65d42f67f6e2e07b6c1eee14ef12caa08eedcbeebdba4389ba4/sqlcipher3_wheels-0.5.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:97617b4c838ad82877a342df111aecbd8653dd6c4c7dc3dccd1ef6389247d614", size = 7077205, upload-time = "2026-01-07T09:52:23.85Z" },
{ url = "https://files.pythonhosted.org/packages/98/0a/4c062770cb79860cf2cdced2b34887aa27e400fd261c553025b74ba0de74/sqlcipher3_wheels-0.5.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b750c3d51134b82a4c82c9e5a6463de495ad75b7e073d03b1377083332b13ac8", size = 7296097, upload-time = "2026-01-07T09:52:25.622Z" },
{ url = "https://files.pythonhosted.org/packages/58/c7/6a6cec55e666ddd998c2c15e37a40e4fcab3c0b67c9d03c3d12b5f30d56a/sqlcipher3_wheels-0.5.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:dbebc62280b9fd005511189d887c080c7595226bb7242c11f1298a0b25a4c123", size = 6988446, upload-time = "2026-01-07T09:52:26.983Z" },
{ url = "https://files.pythonhosted.org/packages/fd/f4/ce2198d1296f63d0917fde374a20c09c5c5e454d65681bf8c2ee4b59591b/sqlcipher3_wheels-0.5.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a806f240ede8da4547f3e50dc3ad88c0d244712270fe361a9b7e3f5d52b4bc39", size = 7425807, upload-time = "2026-01-07T09:52:28.416Z" },
{ url = "https://files.pythonhosted.org/packages/af/4b/d8735ffcf0b463bd54e23be7df46d77d40d43a672bc01a7a8da3e8f6daf3/sqlcipher3_wheels-0.5.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:7198ee5b4c51aae6a7dfab93da35fe82a3c8ed37b4bf6cf55d6f1a0eea387d72", size = 7067479, upload-time = "2026-01-07T09:52:29.867Z" },
{ url = "https://files.pythonhosted.org/packages/97/54/663f4c349ae59a1a9b2180f2b06c9579ddc611649b50e2ad88c2e910582c/sqlcipher3_wheels-0.5.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:af83b12791a1221f1799d7ad6f79edb74a3be642326a9d780dd9f86a290f9c37", size = 7097843, upload-time = "2026-01-07T09:52:31.172Z" },
{ url = "https://files.pythonhosted.org/packages/f3/8e/f8737c593394f050a73742a81e47be2b46b6413f24e96fa0b10d01dc2555/sqlcipher3_wheels-0.5.7-cp314-cp314t-win32.whl", hash = "sha256:90994590f474975d6b480af96ae5ca3887e17303b1144bfea80a38c2e9fdc157", size = 2036431, upload-time = "2026-01-07T09:52:32.444Z" },
{ url = "https://files.pythonhosted.org/packages/2a/05/573df32b1b1738779dc5326b71d0d611d28d386e7141526ed27043d476a1/sqlcipher3_wheels-0.5.7-cp314-cp314t-win_amd64.whl", hash = "sha256:85422846b4c86e63a2b495a2a60945f7a1a04641d1600859cb65fc68a1ce53ed", size = 2564275, upload-time = "2026-01-07T09:52:33.574Z" },
{ url = "https://files.pythonhosted.org/packages/54/ee/40e79926f469d630bc1df03b131e8e84740b82dc7e202fe272d2da9110e3/sqlcipher3_wheels-0.5.7-cp314-cp314t-win_arm64.whl", hash = "sha256:aeeacd6da1fc293b97641a794d15d862d7928408ddad9ea5072fad99229970aa", size = 2728867, upload-time = "2026-01-07T09:52:34.718Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
-244
View File
@@ -1,244 +0,0 @@
use rekordcrate::pdb::RowGroup;
use rekordcrate::pdb::Artwork;
use rekordcrate::pdb::ArtworkId;
use rekordcrate::pdb::Page;
use rekordcrate::pdb::PageType;
use rekordcrate::pdb::PageIndex;
use std::io::Write;
use std::io::{Seek, SeekFrom};
use binrw::BinWrite;
use rekordcrate::pdb::LabelId;
use rekordcrate::pdb::KeyId;
use rekordcrate::pdb::GenreId;
use rekordcrate::pdb::ArtistId;
use rekordcrate::pdb::AlbumId;
use rekordcrate::pdb::Album;
use rekordcrate::pdb::Artist;
use rekordcrate::pdb::Genre;
use rekordcrate::pdb::Key;
use rekordcrate::pdb::Label;
use rekordcrate::pdb::Table;
use std::fs::File;
use std::path::Path;
use binrw::BinRead;
use std::collections::BTreeMap;
use binrw::Endian;
use rekordcrate::pdb::{
Header, Row,
Track, TrackId,
PlaylistTreeNode, PlaylistTreeNodeId,
PlaylistEntry,
};
#[derive(Debug)]
pub enum DbError {
UnknownPlaylist,
UnknownTrack,
PositionOutOfBounds,
}
#[derive(Debug)]
pub struct Database {
pub header: Header,
pub tracks: BTreeMap<TrackId, Track>,
pub albums: BTreeMap<AlbumId, Album>,
pub artists: BTreeMap<ArtistId, Artist>,
pub artwork: BTreeMap<ArtworkId, Artwork>,
pub genres: BTreeMap<GenreId, Genre>,
pub keys: BTreeMap<KeyId, Key>,
pub labels: BTreeMap<LabelId, Label>,
pub nodes: BTreeMap<PlaylistTreeNodeId, PlaylistTreeNode>,
// Keep playlist entries ordered by entry_index
pub entries: BTreeMap<PlaylistTreeNodeId, Vec<PlaylistEntry>>,
}
const PAGE_SIZE: u16 = 4096;
impl Database {
pub fn export(self, path: &Path) -> Result<(), binrw::Error> {
let pages = Table::from_rows(PageType::Tracks, 1, PAGE_SIZE, self.tracks.values().cloned().map(Row::Track))?;
println!("{:?}", pages.len());
println!("{:?}", pages.last());
Ok(())
}
// pub fn export(self, path: &Path) -> std::io::Result<()> {
// // 1) Reserve header page
// let mut f = File::create(path)?;
// f.write_all(&vec![0u8; PAGE_SIZE as usize])?;
// // 2) Emit tables linearly, collect table metadata
// let mut next_page_index: u32 = 1;
// // ok now we're getting somewhere
// // smash rows into row_groups of 16
// // try to write those into a page until it is full
// // yield pages until all the rows are in
// // set the indices
// // 420 blaze it
// let (tracks_first, tracks_last) =
// Self::write_table(&mut f, PageType::Tracks, next_page_index, self.tracks.values().cloned().map(Row::Track))?;
// next_page_index = tracks_last + 1;
// let (tree_first, tree_last) =
// Self::write_table(&mut f, PageType::PlaylistTree, next_page_index, self.nodes.values().cloned().map(Row::PlaylistTreeNode))?;
// next_page_index = tree_last + 1;
// let plist_entries = self.entries.iter().flat_map(|(_pid, vec)| {
// // ensure contiguous entry_index
// let mut v = vec.clone();
// v.sort_by_key(|e| e.entry_index);
// v.into_iter().map(Row::PlaylistEntry)
// });
// let (entries_first, entries_last) =
// Self::write_table(&mut f, PageType::PlaylistEntries, next_page_index, plist_entries)?;
// next_page_index = entries_last + 1;
// let (art_first, art_last) =
// Self::write_table(&mut f, PageType::Artwork, next_page_index, self.artwork.values().cloned().map(Row::Artwork))?;
// next_page_index = art_last + 1;
// // 3) Build header and back-patch at page 0
// let header = Header {
// // fill other required fields in your crate
// sequence: 1, // or previous+1
// next_unused_page: PageIndex(next_page_index),
// tables: vec![
// Table { page_type: PageType::Tracks, first_page: PageIndex(tracks_first), last_page: PageIndex(tracks_last), empty_candidate: 0, /* … */ },
// Table { page_type: PageType::PlaylistTree, first_page: PageIndex(tree_first), last_page: PageIndex(tree_last), empty_candidate: 0, /* … */ },
// Table { page_type: PageType::PlaylistEntries, first_page: PageIndex(entries_first), last_page: PageIndex(entries_last), empty_candidate: 0, /* … */ },
// Table { page_type: PageType::Artwork, first_page: PageIndex(art_first), last_page: PageIndex(art_last), empty_candidate: 0, /* … */ },
// ],
// ..Default::default()
// };
// // Write header at offset 0 and pad to one page
// f.seek(SeekFrom::Start(0))?;
// header.write(&mut f);
// // pad residual of page 0 if BinWrite didn’t fill it:
// let pos = f.stream_position()?;
// if pos < PAGE_SIZE as u64 {
// f.write_all(&vec![0u8; (PAGE_SIZE as u64 - pos) as usize])?;
// }
// Ok(())
// }
// fn write_table<I>(
// f: &mut File,
// page_type: PageType,
// start_index: u32,
// rows: I,
// ) -> std::io::Result<(u32, u32)>
// where
// I: IntoIterator<Item = Row>,
// {
// let mut page_index = start_index;
// let mut first = 0;
// let mut last = 0;
// // Pack rows into pages. Use your crate’s Page builder to handle row groups.
// let mut packer = PagePacker::new(page_type, PAGE_SIZE);
// for row in rows {
// if !packer.try_push(&row) {
// // flush full page
// let mut page = packer.finish(page_index, /*next_page=*/0);
// // Temporarily set next_page=0; we will fix it after writing the next page.
// Self::write_page(f, &mut page)?;
// if first == 0 { first = page_index; }
// last = page_index;
// page_index += 1;
// // start new page with the row that didn’t fit
// packer = PagePacker::new(page_type, PAGE_SIZE);
// assert!(packer.try_push(&row));
// }
// }
// // flush final page if it has content or if this is an empty table create one empty page
// if packer.is_empty() && first == 0 {
// let mut page = Page::empty(page_type, page_index, /*next_page=*/0, PAGE_SIZE);
// Self::write_page(f, &mut page)?;
// first = page_index;
// last = page_index;
// } else {
// let mut page = packer.finish(page_index, /*next_page=*/0);
// Self::write_page(f, &mut page)?;
// if first == 0 { first = page_index; }
// last = page_index;
// }
// // Fix `next_page` pointers by rewriting page headers if your Page writer requires it.
// // Simpler: during emission, remember the previous page file offset and seek back
// // to patch its `next_page` to `page_index`.
// Ok((first, last))
// }
// fn write_page(f: &mut File, page: &mut Page) -> std::io::Result<()> {
// page.write(f);
// Ok(())
// }
pub fn load_from_pdb(path: impl AsRef<Path>) -> rekordcrate::Result<Self> {
let mut f = std::fs::File::open(path)?;
let header = Header::read(&mut f)?; // uses the type’s endianness
let mut tracks: BTreeMap<TrackId, Track> = BTreeMap::new();
let mut albums: BTreeMap<AlbumId, Album> = BTreeMap::new();
let mut artists: BTreeMap<ArtistId, Artist> = BTreeMap::new();
let mut artwork: BTreeMap<ArtworkId, Artwork> = BTreeMap::new();
let mut genres: BTreeMap<GenreId, Genre> = BTreeMap::new();
let mut keys: BTreeMap<KeyId, Key> = BTreeMap::new();
let mut labels: BTreeMap<LabelId, Label> = BTreeMap::new();
let mut nodes: BTreeMap<PlaylistTreeNodeId, PlaylistTreeNode> = BTreeMap::new();
let mut entries: BTreeMap<PlaylistTreeNodeId, Vec<PlaylistEntry>> = BTreeMap::new();
for table in &header.tables {
let pages = header.read_pages(&mut f, Endian::NATIVE, (&table.first_page, &table.last_page))?;
for page in pages {
for rg in page.row_groups {
for row in rg.present_rows() {
match row {
Row::Track(t) => {
tracks.insert(t.id, t.clone());
}
Row::Artist(a) => {
artists.insert(a.id, a.clone());
}
Row::Album(a) => {
albums.insert(a.id, a.clone());
}
Row::Genre(g) => {
genres.insert(g.id, g.clone());
}
Row::Key(k) => {
keys.insert(k.id, k.clone());
}
Row::Label(l) => {
labels.insert(l.id, l.clone());
}
Row::PlaylistTreeNode(n) => {
nodes.insert(n.id, n.clone());
}
Row::PlaylistEntry(e) => {
entries.entry(e.playlist_id).or_default().push(e.clone());
}
_ => {}
}
}
}
}
}
// Ensure deterministic ordering within each playlist
for v in entries.values_mut() {
v.sort_by_key(|e| e.entry_index); // needs `pub entry_index`
}
Ok(Self { header, tracks, albums, artists, artwork, genres, keys, labels, nodes, entries })
}
}
+16 -23
View File
@@ -1,31 +1,24 @@
mod db;
extern crate walkdir;
use rekordcrate::pdb::Page;
use rekordcrate::pdb::PageIndex;
use rekordcrate::pdb::Row::PlaylistEntry;
use rekordcrate::pdb::string::DeviceSQLString;
use rekordcrate::pdb::RowGroup;
use rekordcrate::pdb::TrackId;
use rekordcrate::pdb::PageType;
use rekordcrate::pdb::Table;
use rekordcrate::pdb::{Header, PlaylistTreeNode, PlaylistTreeNodeId};
use walkdir::WalkDir;
use std::env;
use std::fs::create_dir;
use std::fs::create_dir_all;
use std::{fs::exists, path::Path};
use db::Database;
use rekordcrate::util::RekordcrateError;
use rekordcrate::pdb::Database;
use std::fs::File;
use std::path::Path;
const PAGE_SIZE: u32 = 4096;
// Example
fn main() -> rekordcrate::Result<()> {
let db = Database::load_from_pdb("ntr.pdb")?;
println!("tracks: {}, playlists: {}", db.tracks.len(), db.nodes.len());
println!("tables: {:?}", db.header);
println!("tables: {:?}", db.header.tables);
fn main() -> Result<(), RekordcrateError> {
let db = Database::load_from_pdb("ntr.pdb").unwrap();
db.export(&Path::new("./data/output.pdb"))?;
println!("{:?}", db.colors);
let output = Path::new("./data/output.pdb");
let mut f = File::create(output)?;
db.export(PAGE_SIZE, &mut f)?;
// let db = Database::load_from_pdb("./data/output.pdb").unwrap();
// println!("{:?}", db);
// for n in db.nodes {
// println!("{:?}", n);
+17
View File
@@ -0,0 +1,17 @@
# maple-ts
TypeScript/Bun port of `maple-py`. Builds a rekordbox `export.pdb` USB layout from a folder of audio files and `.m3u8` playlists.
## Install
```sh
bun install
```
## Run
```sh
bun run src/cli.ts ./data/input ./data/usb
```
Requires `ffmpeg` on `PATH` for FLAC -> MP3 conversion.
+22
View File
@@ -0,0 +1,22 @@
{
"name": "maple",
"version": "0.1.0",
"description": "Build a rekordbox USB from a folder of audio + m3u8 playlists",
"type": "module",
"module": "src/cli.ts",
"bin": {
"maple": "src/cli.ts"
},
"scripts": {
"maple": "bun run src/cli.ts",
"test": "bun test"
},
"devDependencies": {
"@types/bun": "latest",
"@types/node-id3": "^0.2.6"
},
"dependencies": {
"music-metadata": "^10.5.0",
"node-id3": "^0.2.6"
}
}
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env bun
/** maple CLI: scan a directory of audio + m3u8 playlists and build a rekordbox USB. */
import { stat } from "node:fs/promises";
import { basename, extname, join, resolve } from "node:path";
import os from "node:os";
import { convertFlacToMp3, verifyDuration } from "./convert.ts";
import { stageAudio, stagedPath } from "./copy.ts";
import { readTags } from "./meta.ts";
import { newDatabase, newTrack, type Database } from "./model.ts";
import { writePdb } from "./pdb/writer.ts";
import { scanAudio, scanPlaylists } from "./scan.ts";
function today(): string {
return new Date().toISOString().slice(0, 10);
}
function withSuffix(file: string, newExt: string): string {
const ext = extname(file);
return file.slice(0, file.length - ext.length) + newExt;
}
async function isDir(p: string): Promise<boolean> {
try { return (await stat(p)).isDirectory(); } catch { return false; }
}
async function pMap<T, R>(items: T[], workers: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const out: R[] = new Array(items.length);
let i = 0;
const runners = Array.from({ length: Math.max(1, Math.min(workers, items.length)) }, async () => {
while (true) {
const idx = i++;
if (idx >= items.length) return;
out[idx] = await fn(items[idx]!);
}
});
await Promise.all(runners);
return out;
}
export async function build(inputDir: string, usbRoot: string): Promise<Database> {
const db = newDatabase();
const dateAdded = today();
const artistIndex = new Map<string, number>();
const albumIndex = new Map<string, number>();
const trackBySrc = new Map<string, number>();
const audioFiles = await scanAudio(inputDir);
const initialMeta = new Map<string, Awaited<ReturnType<typeof readTags>>>();
for (const p of audioFiles) initialMeta.set(p, await readTags(p));
const flacJobs: Array<{ src: string; dst: string }> = [];
for (const original of audioFiles) {
if (extname(original).toLowerCase() === ".flac") {
const m = initialMeta.get(original)!;
const mp3Name = withSuffix(basename(original), ".mp3");
const { destAbs } = stagedPath(usbRoot, m.artist, m.album, mp3Name);
flacJobs.push({ src: original, dst: destAbs });
}
}
const workers = Math.max(1, os.cpus().length || 1);
const results = await pMap(flacJobs, workers, async (job) => {
const converted = await convertFlacToMp3(job.src, job.dst);
return { ...job, converted };
});
for (const r of results) {
const rel = r.src.startsWith(inputDir) ? r.src.slice(inputDir.length).replace(/^[\/\\]+/, "") : r.src;
console.log(`${r.converted ? "convert" : "cached "} ${rel}`);
await verifyDuration(r.src, r.dst);
}
for (const original of audioFiles) {
let meta = initialMeta.get(original)!;
let artistId = artistIndex.get(meta.artist);
if (artistId === undefined) {
artistId = db.artists.length + 1;
artistIndex.set(meta.artist, artistId);
db.artists.push({ id: artistId, name: meta.artist });
}
const albumKey = `${artistId}\x00${meta.album}`;
let albumId = albumIndex.get(albumKey);
if (albumId === undefined) {
albumId = db.albums.length + 1;
albumIndex.set(albumKey, albumId);
db.albums.push({ id: albumId, name: meta.album, artist_id: artistId });
}
let filePath: string;
if (extname(original).toLowerCase() === ".flac") {
const mp3Name = withSuffix(basename(original), ".mp3");
const s = stagedPath(usbRoot, meta.artist, meta.album, mp3Name);
filePath = s.filePath;
meta = await readTags(s.destAbs);
} else {
const s = await stageAudio(original, usbRoot, meta.artist, meta.album);
filePath = s.filePath;
}
const trackId = db.tracks.length + 1;
db.tracks.push(newTrack({
id: trackId,
title: meta.title,
artist_id: artistId,
album_id: albumId,
filename: basename(filePath),
file_path: filePath,
file_size: meta.file_size,
sample_rate: meta.sample_rate,
sample_depth: meta.sample_depth,
bitrate: meta.bitrate,
duration: meta.duration,
track_number: meta.track_number,
year: meta.year,
date_added: dateAdded,
source_path: original,
}));
trackBySrc.set(resolve(original), trackId);
}
const playlists = await scanPlaylists(inputDir);
let sortOrder = 1;
for (const [name, paths] of playlists) {
const playlistId = db.playlist_nodes.length + 1;
db.playlist_nodes.push({
id: playlistId,
parent_id: 0,
sort_order: sortOrder,
name,
is_folder: false,
});
sortOrder += 1;
let entryIndex = 1;
for (const p of paths) {
const tid = trackBySrc.get(resolve(p));
if (tid === undefined) {
console.error(`warning: playlist '${name}' references missing file ${p}`);
continue;
}
db.playlist_entries.push({
playlist_id: playlistId,
track_id: tid,
entry_index: entryIndex,
});
entryIndex += 1;
}
}
return db;
}
async function main(argv: string[]): Promise<number> {
const args = argv.slice(2);
const inputDir = args[0] ?? "./data/input";
const outputRoot = args[1] ?? "./data/usb";
if (!(await isDir(inputDir))) {
console.error(`error: input_dir ${inputDir} is not a directory`);
return 2;
}
const db = await build(inputDir, outputRoot);
const pdbPath = join(outputRoot, "PIONEER", "rekordbox", "export.pdb");
await writePdb(db, pdbPath);
console.log(
`wrote ${db.tracks.length} tracks, ${db.artists.length} artists, ${db.albums.length} albums, ` +
`${db.playlist_nodes.length} playlists (${db.playlist_entries.length} entries) to ${pdbPath}`
);
return 0;
}
if (import.meta.main) {
process.exit(await main(process.argv));
}
+90
View File
@@ -0,0 +1,90 @@
/**
* FLAC -> MP3 V0 joint-stereo conversion via ffmpeg, with source-hash dedup.
*
* The SHA-256 of the source FLAC is stamped into a TXXX:MAPLE_SRC_SHA256 ID3
* frame on the produced MP3. Reruns skip conversion when the existing MP3
* carries the matching hash, so the cache is content-addressed against inputs.
*/
import { mkdir, access } from "node:fs/promises";
import { dirname } from "node:path";
import { parseFile } from "music-metadata";
import NodeID3 from "node-id3";
const HASH_FRAME_DESC = "MAPLE_SRC_SHA256";
export async function fileSha256(path: string): Promise<string> {
const hasher = new Bun.CryptoHasher("sha256");
const stream = Bun.file(path).stream();
for await (const chunk of stream) hasher.update(chunk);
return hasher.digest("hex");
}
async function exists(p: string): Promise<boolean> {
try { await access(p); return true; } catch { return false; }
}
function readSourceHash(mp3: string): string | null {
const tags = NodeID3.read(mp3);
const frames = tags.userDefinedText;
if (!frames) return null;
for (const f of frames) {
if (f.description === HASH_FRAME_DESC && f.value) return f.value;
}
return null;
}
function writeSourceHash(mp3: string, sha: string): void {
NodeID3.update({ userDefinedText: [{ description: HASH_FRAME_DESC, value: sha }] }, mp3);
}
async function which(cmd: string): Promise<boolean> {
const p = Bun.spawn({ cmd: ["which", cmd], stdout: "pipe", stderr: "pipe" });
await p.exited;
return p.exitCode === 0;
}
export async function convertFlacToMp3(flac: string, mp3: string): Promise<boolean> {
const srcHash = await fileSha256(flac);
if (await exists(mp3)) {
try {
if (readSourceHash(mp3) === srcHash) return false;
} catch {}
}
if (!(await which("ffmpeg"))) throw new Error("ffmpeg not found on PATH");
await mkdir(dirname(mp3), { recursive: true });
const proc = Bun.spawn({
cmd: [
"ffmpeg", "-nostdin", "-loglevel", "error", "-y",
"-i", flac,
"-codec:a", "libmp3lame",
"-q:a", "0",
"-joint_stereo", "1",
"-map_metadata", "0",
mp3,
],
stdout: "inherit",
stderr: "inherit",
});
await proc.exited;
if (proc.exitCode !== 0) throw new Error(`ffmpeg failed for ${flac}`);
writeSourceHash(mp3, srcHash);
return true;
}
async function duration(path: string): Promise<number> {
try {
const md = await parseFile(path, { duration: true });
return md.format.duration ?? 0;
} catch {
return 0;
}
}
export async function verifyDuration(src: string, dst: string, tolerance = 0.1): Promise<void> {
const [a, b] = await Promise.all([duration(src), duration(dst)]);
if (Math.abs(a - b) > tolerance) {
throw new Error(`duration mismatch: ${src} = ${a.toFixed(3)}s vs ${dst} = ${b.toFixed(3)}s (Δ ${Math.abs(a - b).toFixed(3)}s)`);
}
}
+29
View File
@@ -0,0 +1,29 @@
import { dirname, join, basename } from "node:path";
import { mkdir, copyFile, access } from "node:fs/promises";
const BAD = /[\\/:*?"<>|\x00-\x1f]/g;
export function sanitise(component: string): string {
const s = component.replace(BAD, "_").replace(/^[ .]+|[ .]+$/g, "");
return s || "_";
}
export function stagedPath(usbRoot: string, artist: string, album: string, filename: string): { destAbs: string; filePath: string } {
const relParts = ["Contents", sanitise(artist), sanitise(album), sanitise(filename)];
const destAbs = join(usbRoot, ...relParts);
const filePath = "/" + relParts.join("/");
return { destAbs, filePath };
}
async function exists(p: string): Promise<boolean> {
try { await access(p); return true; } catch { return false; }
}
export async function stageAudio(src: string, usbRoot: string, artist: string, album: string): Promise<{ destAbs: string; filePath: string }> {
const { destAbs, filePath } = stagedPath(usbRoot, artist, album, basename(src));
await mkdir(dirname(destAbs), { recursive: true });
if (!(await exists(destAbs))) {
await copyFile(src, destAbs);
}
return { destAbs, filePath };
}
+62
View File
@@ -0,0 +1,62 @@
import { parseFile } from "music-metadata";
import { basename, extname } from "node:path";
import { stat } from "node:fs/promises";
export interface TrackMeta {
title: string;
artist: string;
album: string;
track_number: number;
year: number;
duration: number;
bitrate: number;
sample_rate: number;
sample_depth: number;
file_size: number;
}
function toInt(v: unknown): number {
if (v === null || v === undefined) return 0;
let s = String(Array.isArray(v) ? v[0] : v).trim();
if (!s) return 0;
s = s.split("/")[0]!;
const n = parseInt(s, 10);
return Number.isFinite(n) ? n : 0;
}
export async function readTags(path: string): Promise<TrackMeta> {
const { size: file_size } = await stat(path);
const stem = basename(path, extname(path));
try {
const md = await parseFile(path, { duration: true });
const c = md.common;
const info = md.format;
const bitrate_bps = info.bitrate ?? 0;
const bitrate = bitrate_bps > 1000 ? Math.floor(bitrate_bps / 1000) : bitrate_bps;
return {
title: c.title || stem,
artist: c.artist || "Unknown Artist",
album: c.album || "Unknown Album",
track_number: toInt(c.track?.no),
year: toInt(c.year ?? c.date),
duration: Math.floor(info.duration ?? 0),
bitrate,
sample_rate: info.sampleRate ?? 44100,
sample_depth: info.bitsPerSample ?? 16,
file_size,
};
} catch {
return {
title: stem,
artist: "Unknown Artist",
album: "Unknown Album",
track_number: 0,
year: 0,
duration: 0,
bitrate: 0,
sample_rate: 44100,
sample_depth: 16,
file_size,
};
}
}
+83
View File
@@ -0,0 +1,83 @@
export interface Artist {
id: number;
name: string;
}
export interface Album {
id: number;
name: string;
artist_id: number;
}
export interface Track {
id: number;
title: string;
artist_id: number;
album_id: number;
filename: string;
file_path: string;
file_size: number;
sample_rate: number;
sample_depth: number;
bitrate: number;
duration: number;
track_number: number;
year: number;
disc_number: number;
tempo: number;
comment: string;
date_added: string;
release_date: string;
mix_name: string;
analyze_path: string;
analyze_date: string;
source_path: string | null;
}
export interface PlaylistNode {
id: number;
parent_id: number;
sort_order: number;
name: string;
is_folder: boolean;
}
export interface PlaylistEntry {
playlist_id: number;
track_id: number;
entry_index: number;
}
export interface Database {
tracks: Track[];
artists: Artist[];
albums: Album[];
playlist_nodes: PlaylistNode[];
playlist_entries: PlaylistEntry[];
}
export function newDatabase(): Database {
return { tracks: [], artists: [], albums: [], playlist_nodes: [], playlist_entries: [] };
}
export function newTrack(init: Pick<Track, "id" | "title" | "artist_id" | "album_id" | "filename" | "file_path"> & Partial<Track>): Track {
return {
file_size: 0,
sample_rate: 44100,
sample_depth: 16,
bitrate: 0,
duration: 0,
track_number: 0,
year: 0,
disc_number: 0,
tempo: 0,
comment: "",
date_added: "",
release_date: "",
mix_name: "",
analyze_path: "",
analyze_date: "",
source_path: null,
...init,
};
}
+44
View File
@@ -0,0 +1,44 @@
/** Small little-endian write helpers backed by Uint8Array. */
export class LE {
parts: Uint8Array[] = [];
private len = 0;
push(u: Uint8Array): void { this.parts.push(u); this.len += u.length; }
u8(v: number): void {
const b = new Uint8Array(1); b[0] = v & 0xff; this.push(b);
}
u16(v: number): void {
const b = new Uint8Array(2);
b[0] = v & 0xff;
b[1] = (v >> 8) & 0xff;
this.push(b);
}
u32(v: number): void {
const b = new Uint8Array(4);
b[0] = v & 0xff;
b[1] = (v >>> 8) & 0xff;
b[2] = (v >>> 16) & 0xff;
b[3] = (v >>> 24) & 0xff;
this.push(b);
}
bytes(b: Uint8Array): void { this.push(b); }
length(): number { return this.len; }
build(): Uint8Array {
const out = new Uint8Array(this.len);
let o = 0;
for (const p of this.parts) { out.set(p, o); o += p.length; }
return out;
}
}
export function concat(parts: Uint8Array[]): Uint8Array {
let len = 0;
for (const p of parts) len += p.length;
const out = new Uint8Array(len);
let o = 0;
for (const p of parts) { out.set(p, o); o += p.length; }
return out;
}
+95
View File
@@ -0,0 +1,95 @@
import { LE } from "./buf.ts";
export const PAGE_SIZE = 4096;
const HEADER_SIZE = 40;
const ROW_GROUP_SIZE = 36;
const ROWS_PER_GROUP = 16;
function align4(o: number): number { return (o + 3) & ~3; }
function numRowGroups(n: number): number { return Math.floor((n + ROWS_PER_GROUP - 1) / ROWS_PER_GROUP); }
export class PageBuilder {
pageIndex: number;
pageType: number;
nextPage: number;
rowBytes: Uint8Array[] = [];
rowOffsets: number[] = [];
heapCursor = 0;
constructor(pageIndex: number, pageType: number) {
this.pageIndex = pageIndex;
this.pageType = pageType;
this.nextPage = pageIndex;
}
tryAdd(row: Uint8Array): boolean {
const aligned = align4(this.heapCursor);
const end = aligned + row.length;
const footerAfter = numRowGroups(this.rowBytes.length + 1) * ROW_GROUP_SIZE;
if (end + HEADER_SIZE + footerAfter > PAGE_SIZE) return false;
this.rowOffsets.push(aligned);
this.rowBytes.push(row);
this.heapCursor = end;
return true;
}
numRows(): number { return this.rowBytes.length; }
finalize(): Uint8Array {
const n = this.rowBytes.length;
const numRgs = numRowGroups(n);
const footerSize = numRgs * ROW_GROUP_SIZE;
const heapPadding = PAGE_SIZE - HEADER_SIZE - footerSize;
const usedSize = this.heapCursor;
const freeSize = heapPadding - usedSize;
if (freeSize < 0) throw new Error("page overflow");
const pageFlags = n > 0 ? 0x00 : 0x44;
const h = new LE();
h.u32(0); // magic
h.u32(this.pageIndex);
h.u32(this.pageType);
h.u32(this.nextPage);
h.u32(0); // unknown1
h.u32(0); // unknown2
h.u8(n & 0xff); h.u8(0); h.u8(0); h.u8(pageFlags);
h.u16(freeSize); h.u16(usedSize);
h.u16(0); // unknown5
h.u16(n); // num_rows_large
h.u16(0); // unknown6
h.u16(0); // unknown7
const header = h.build();
if (header.length !== HEADER_SIZE) throw new Error(`hdr len ${header.length}`);
const heap = new Uint8Array(heapPadding);
for (let i = 0; i < this.rowBytes.length; i++) {
heap.set(this.rowBytes[i]!, this.rowOffsets[i]!);
}
const footer = new LE();
for (let g = numRgs - 1; g >= 0; g--) {
const start = g * ROWS_PER_GROUP;
const grpOffsets = this.rowOffsets.slice(start, start + ROWS_PER_GROUP);
const grpCount = grpOffsets.length;
const padded = grpOffsets.slice();
while (padded.length < ROWS_PER_GROUP) padded.push(0);
const presence = (1 << grpCount) - 1;
const rg = new LE();
for (let i = padded.length - 1; i >= 0; i--) rg.u16(padded[i]!);
rg.u16(presence);
rg.u16(16);
const rgBytes = rg.build();
if (rgBytes.length !== ROW_GROUP_SIZE) throw new Error("rg size");
footer.bytes(rgBytes);
}
const footerBytes = footer.build();
if (footerBytes.length !== footerSize) throw new Error("footer size");
const out = new Uint8Array(PAGE_SIZE);
out.set(header, 0);
out.set(heap, HEADER_SIZE);
out.set(footerBytes, HEADER_SIZE + heapPadding);
return out;
}
}
+148
View File
@@ -0,0 +1,148 @@
import type { Album, Artist, PlaylistEntry, PlaylistNode, Track } from "../model.ts";
import { encodeDevicesql } from "./strings.ts";
import { LE, concat } from "./buf.ts";
const TRACK_FIXED_PREFIX = 0x5e;
const TRACK_OFS_STRINGS_START = TRACK_FIXED_PREFIX;
const TRACK_HEADER_LEN = TRACK_OFS_STRINGS_START + 21 * 2;
function pad2(b: Uint8Array): Uint8Array {
if (b.length & 1) {
const out = new Uint8Array(b.length + 1);
out.set(b);
return out;
}
return b;
}
export function serializeTrack(t: Track): Uint8Array {
const strings: string[] = [
"", // 0 isrc
"", // 1 texter
"1", // 2 unknown_string_2
"2", // 3 unknown_string_3
"", // 4 unknown_string_4
"", // 5 message
"ON", // 6 kuvo_public
"ON", // 7 autoload_hot_cues
"", // 8 unknown_string_5
"", // 9 unknown_string_6
t.date_added, // 10
t.release_date, // 11
t.mix_name, // 12
"", // 13
t.analyze_path, // 14
t.analyze_date, // 15
t.comment, // 16
t.title, // 17
"", // 18
t.filename, // 19
t.file_path, // 20
];
const encoded = strings.map(encodeDevicesql);
const offsets: number[] = [];
let cursor = TRACK_HEADER_LEN;
for (const e of encoded) {
offsets.push(cursor);
cursor += e.length;
}
const blob = concat(encoded);
const p = new LE();
p.u16(0x24); p.u16(0); // subtype, index_shift
p.u32(788224); // bitmask
p.u32(t.sample_rate);
p.u32(0); // composer_id
p.u32(t.file_size);
p.u32(0); // unknown
p.u16(19048);
p.u16(30967);
p.u32(0); // artwork_id
p.u32(0); // key_id
p.u32(0); // original_artist_id
p.u32(0); // label_id
p.u32(0); // remixer_id
p.u32(t.bitrate);
p.u32(t.track_number);
p.u32(t.tempo);
p.u32(0); // genre_id
p.u32(t.album_id);
p.u32(t.artist_id);
p.u32(t.id);
p.u16(t.disc_number);
p.u16(0); // play_count
p.u16(t.year);
p.u16(t.sample_depth);
p.u16(t.duration);
p.u16(41);
p.u8(0); p.u8(0); // color_id, rating
p.u16(1);
p.u16(3);
const prefix = p.build();
if (prefix.length !== TRACK_FIXED_PREFIX) throw new Error(`prefix len ${prefix.length}`);
const ofs = new LE();
for (const o of offsets) ofs.u16(o);
return pad2(concat([prefix, ofs.build(), blob]));
}
export function serializeArtist(a: Artist): Uint8Array {
const name = encodeDevicesql(a.name);
const nearOfs = 0x0a;
const farThreshold = 0x100;
const r = new LE();
if (nearOfs < farThreshold) {
r.u16(0x60); r.u16(a.id & 0xffff);
r.u32(a.id);
r.u8(0x03);
r.u8(nearOfs);
if (r.length() !== nearOfs) throw new Error("artist near offset mismatch");
r.bytes(name);
} else {
const far = 0x0e;
r.u16(0x64); r.u16(a.id & 0xffff);
r.u32(a.id);
r.u8(0x03);
r.u8(0);
r.u16(far);
r.bytes(name);
}
return pad2(r.build());
}
export function serializeAlbum(a: Album): Uint8Array {
const name = encodeDevicesql(a.name);
const nearOfs = 0x16;
const r = new LE();
r.u16(0x80); r.u16(a.id & 0xffff);
r.u32(0);
r.u32(a.artist_id);
r.u32(a.id);
r.u32(0);
r.u8(0x03);
r.u8(nearOfs);
if (r.length() !== nearOfs) throw new Error("album near offset mismatch");
r.bytes(name);
return pad2(r.build());
}
export function serializePlaylistTree(n: PlaylistNode): Uint8Array {
const r = new LE();
r.u32(n.parent_id);
r.u32(0);
r.u32(n.sort_order);
r.u32(n.id);
r.u32(n.is_folder ? 1 : 0);
r.bytes(encodeDevicesql(n.name));
return pad2(r.build());
}
export function serializePlaylistEntry(e: PlaylistEntry): Uint8Array {
const r = new LE();
r.u32(e.entry_index);
r.u32(e.track_id);
r.u32(e.playlist_id);
return r.build();
}
+33
View File
@@ -0,0 +1,33 @@
const EMPTY = new Uint8Array([0x03]);
function isAscii(s: string): boolean {
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 0x7f) return false;
return true;
}
export function encodeDevicesql(s: string): Uint8Array {
if (!s) return EMPTY;
if (isAscii(s) && s.length < 127) {
const ascii = new TextEncoder().encode(s);
const length = ascii.length + 1;
const lengthAndKind = ((length << 1) | 1) & 0xff;
const out = new Uint8Array(1 + ascii.length);
out[0] = lengthAndKind;
out.set(ascii, 1);
return out;
}
const utf16 = new Uint8Array(s.length * 2);
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
utf16[i * 2] = c & 0xff;
utf16[i * 2 + 1] = (c >> 8) & 0xff;
}
const totalLength = utf16.length + 4;
const out = new Uint8Array(4 + utf16.length);
out[0] = 0x90;
out[1] = totalLength & 0xff;
out[2] = (totalLength >> 8) & 0xff;
out[3] = 0x00;
out.set(utf16, 4);
return out;
}
+122
View File
@@ -0,0 +1,122 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import type { Database } from "../model.ts";
import { PAGE_SIZE, PageBuilder } from "./pager.ts";
import {
serializeAlbum,
serializeArtist,
serializePlaylistEntry,
serializePlaylistTree,
serializeTrack,
} from "./rows.ts";
import { LE } from "./buf.ts";
const PAGE_TYPE_TRACKS = 0;
const PAGE_TYPE_ARTISTS = 2;
const PAGE_TYPE_ALBUMS = 3;
const PAGE_TYPE_PLAYLIST_TREE = 7;
const PAGE_TYPE_PLAYLIST_ENTRIES = 8;
const FILE_HEADER_SIZE = 28;
const TABLE_ENTRY_SIZE = 16;
interface Chain {
pageType: number;
pages: Uint8Array[];
first: number;
last: number;
}
function buildChain(pageType: number, rows: Uint8Array[], startIndex: number): Chain {
const builders: PageBuilder[] = [];
let idx = startIndex;
let current = new PageBuilder(idx, pageType);
for (const row of rows) {
if (!current.tryAdd(row)) {
builders.push(current);
idx += 1;
current = new PageBuilder(idx, pageType);
if (!current.tryAdd(row)) {
throw new Error(`row of size ${row.length} too large for an empty page`);
}
}
}
builders.push(current);
const out: Uint8Array[] = [];
for (let i = 0; i < builders.length; i++) {
const p = builders[i]!;
if (i === builders.length - 1) p.nextPage = p.pageIndex;
else p.nextPage = p.pageIndex + 1;
out.push(p.finalize());
}
return { pageType, pages: out, first: startIndex, last: idx };
}
export async function writePdb(db: Database, path: string): Promise<void> {
const chains: Chain[] = [];
let nextIdx = 1;
const tables: Array<[number, Uint8Array[]]> = [
[PAGE_TYPE_TRACKS, db.tracks.map(serializeTrack)],
[PAGE_TYPE_ARTISTS, db.artists.map(serializeArtist)],
[PAGE_TYPE_ALBUMS, db.albums.map(serializeAlbum)],
[PAGE_TYPE_PLAYLIST_TREE, db.playlist_nodes.map(serializePlaylistTree)],
[PAGE_TYPE_PLAYLIST_ENTRIES, db.playlist_entries.map(serializePlaylistEntry)],
];
for (const [ptype, rows] of tables) {
const c = buildChain(ptype, rows, nextIdx);
chains.push(c);
nextIdx = c.last + 1;
}
const numTables = chains.length;
const nextUnused = nextIdx;
const h = new LE();
h.u32(0);
h.u32(PAGE_SIZE);
h.u32(numTables);
h.u32(nextUnused);
h.u32(0);
h.u32(1);
h.u32(0);
const fileHeader = h.build();
if (fileHeader.length !== FILE_HEADER_SIZE) throw new Error("file header size");
const tbl = new LE();
for (const c of chains) {
tbl.u32(c.pageType);
tbl.u32(0);
tbl.u32(c.first);
tbl.u32(c.last);
}
const tablesBlock = tbl.build();
if (tablesBlock.length !== numTables * TABLE_ENTRY_SIZE) throw new Error("tables size");
const headerFull = new Uint8Array(fileHeader.length + tablesBlock.length);
headerFull.set(fileHeader, 0);
headerFull.set(tablesBlock, fileHeader.length);
if (headerFull.length > PAGE_SIZE) throw new Error("too many tables for one page");
const page0 = new Uint8Array(PAGE_SIZE);
page0.set(headerFull, 0);
let totalLen = PAGE_SIZE;
for (const c of chains) totalLen += c.pages.length * PAGE_SIZE;
const out = new Uint8Array(totalLen);
let off = 0;
out.set(page0, off); off += PAGE_SIZE;
for (const c of chains) {
for (const p of c.pages) {
if (p.length !== PAGE_SIZE) throw new Error("page size mismatch");
out.set(p, off); off += PAGE_SIZE;
}
}
await mkdir(dirname(path), { recursive: true });
await writeFile(path, out);
}
+46
View File
@@ -0,0 +1,46 @@
import { readdir } from "node:fs/promises";
import { join, extname, basename, dirname, isAbsolute, resolve } from "node:path";
const AUDIO_EXTS = new Set([".mp3", ".flac", ".wav", ".aiff", ".aif", ".m4a"]);
async function walk(dir: string): Promise<string[]> {
const out: string[] = [];
const entries = await readdir(dir, { withFileTypes: true });
for (const e of entries) {
const p = join(dir, e.name);
if (e.isDirectory()) {
out.push(...(await walk(p)));
} else if (e.isFile()) {
out.push(p);
}
}
return out;
}
export async function scanAudio(inputDir: string): Promise<string[]> {
const all = await walk(inputDir);
return all.filter((p) => AUDIO_EXTS.has(extname(p).toLowerCase())).sort();
}
export async function parseM3u8(playlistPath: string): Promise<string[]> {
const base = dirname(playlistPath);
const text = await Bun.file(playlistPath).text();
const entries: string[] = [];
for (const raw of text.split(/\r?\n/)) {
const s = raw.trim();
if (!s || s.startsWith("#")) continue;
entries.push(isAbsolute(s) ? resolve(s) : resolve(join(base, s)));
}
return entries;
}
export async function scanPlaylists(inputDir: string): Promise<Map<string, string[]>> {
const all = await walk(inputDir);
const playlists = all.filter((p) => extname(p).toLowerCase() === ".m3u8").sort();
const out = new Map<string, string[]>();
for (const p of playlists) {
const stem = basename(p, extname(p));
out.set(stem, await parseM3u8(p));
}
return out;
}
+24
View File
@@ -0,0 +1,24 @@
import { expect, test } from "bun:test";
import { encodeDevicesql } from "../src/pdb/strings.ts";
test("empty encodes to 0x03", () => {
expect(Array.from(encodeDevicesql(""))).toEqual([0x03]);
});
test("short ascii uses short form", () => {
const out = encodeDevicesql("ABC");
// length = 3+1 = 4; (4<<1)|1 = 9
expect(out[0]).toBe(9);
expect(Array.from(out.slice(1))).toEqual([0x41, 0x42, 0x43]);
});
test("non-ascii uses utf-16 form", () => {
const out = encodeDevicesql("é");
expect(out[0]).toBe(0x90);
// total_length = utf16 bytes (2) + 4 = 6
expect(out[1]).toBe(6);
expect(out[2]).toBe(0);
expect(out[3]).toBe(0);
expect(out[4]).toBe(0xe9);
expect(out[5]).toBe(0x00);
});
+30
View File
@@ -0,0 +1,30 @@
import { expect, test } from "bun:test";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { newDatabase, newTrack } from "../src/model.ts";
import { writePdb } from "../src/pdb/writer.ts";
import { PAGE_SIZE } from "../src/pdb/pager.ts";
test("writes a multi-page-aligned pdb file", async () => {
const dir = await mkdtemp(join(tmpdir(), "maple-ts-"));
try {
const db = newDatabase();
db.artists.push({ id: 1, name: "A" });
db.albums.push({ id: 1, name: "Alb", artist_id: 1 });
db.tracks.push(newTrack({
id: 1, title: "T", artist_id: 1, album_id: 1,
filename: "t.mp3", file_path: "/Contents/A/Alb/t.mp3",
}));
db.playlist_nodes.push({ id: 1, parent_id: 0, sort_order: 1, name: "P", is_folder: false });
db.playlist_entries.push({ playlist_id: 1, track_id: 1, entry_index: 1 });
const out = join(dir, "export.pdb");
await writePdb(db, out);
const buf = await readFile(out);
expect(buf.length % PAGE_SIZE).toBe(0);
expect(buf.length).toBeGreaterThanOrEqual(PAGE_SIZE * 6);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["bun-types"],
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"noEmit": true,
"verbatimModuleSyntax": false
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
+2 -2
View File
@@ -8,10 +8,10 @@
"path": "h:ome\\ntr\\code\\rekordcrate",
},
{
"path": "C:\\Users\\ntr\\code\\rekordcrate",
"path": "U:sers\\nathanrashleigh\\code\\code\\rekordcrate",
},
{
"path": "/home/ntr/code/rekordcrate"
"path": "C:\\Users\\ntr\\code\\rekordcrate"
},
],
"settings":