Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f06d3aa0f9 | ||
|
|
96d0331ff3 | ||
|
|
9ccc3cea95 | ||
|
|
f71a2a3512 | ||
|
|
6a6553077b | ||
|
|
1e49010aa9 | ||
|
|
32adb5ddac | ||
|
|
6a32066069 | ||
|
|
f804c327de | ||
|
|
690f92f565 | ||
|
|
076c60658d | ||
|
|
13ee0dcf01 | ||
|
|
d35b51d4c3 |
No files matched your search
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -20,3 +20,9 @@ maple.sublime-workspace
|
|||||||
*.secret.yaml
|
*.secret.yaml
|
||||||
|
|
||||||
etc/sldl-macos.yaml
|
etc/sldl-macos.yaml
|
||||||
|
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
maple-py/captures
|
||||||
|
maple-py/node_modules
|
||||||
@@ -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
@@ -1,7 +1,7 @@
|
|||||||
path = "/data"
|
path = "${SOCKSEEK_DATA}"
|
||||||
index-path = "/data/_metadata/index.sldl.csv"
|
index-path = "${SOCKSEEK_DATA}/_metadata/index.sldl.csv"
|
||||||
username = "${SLDL_USER}"
|
username = "${SOCKSEEK_USER}"
|
||||||
password = "${SLDL_PASS}"
|
password = "${SOCKSEEK_PASS}"
|
||||||
|
|
||||||
name-format = "{artist(/)album(/)track( )title|(_missing_tags/)slsk-foldername(/)slsk-filename}"
|
name-format = "{artist(/)album(/)track( )title|(_missing_tags/)slsk-foldername(/)slsk-filename}"
|
||||||
|
|
||||||
|
|||||||
@@ -28,9 +28,32 @@ dl trg:
|
|||||||
link plist dir:
|
link plist dir:
|
||||||
python3 maple-py/link.py {{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:
|
sync args:
|
||||||
s5cmd {{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:
|
maple-setup:
|
||||||
bash ./etc/maple_setup.sh
|
bash ./etc/maple_setup.sh
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
@@ -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.
|
||||||
@@ -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()
|
||||||
+501
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"frida": "^17.11.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Whitespace-only changes.
@@ -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
|
||||||
@@ -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
@@ -0,0 +1,2 @@
|
|||||||
|
def hello() -> str:
|
||||||
|
return "Hello from maple!"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from maple.cli import main
|
||||||
|
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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))
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
Whitespace-only changes.
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
Whitespace-only changes.
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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); });
|
||||||
@@ -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");
|
||||||
Generated
+366
@@ -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" },
|
||||||
|
]
|
||||||
@@ -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
@@ -1,31 +1,24 @@
|
|||||||
mod db;
|
|
||||||
|
|
||||||
extern crate walkdir;
|
extern crate walkdir;
|
||||||
|
|
||||||
use rekordcrate::pdb::Page;
|
use rekordcrate::util::RekordcrateError;
|
||||||
use rekordcrate::pdb::PageIndex;
|
use rekordcrate::pdb::Database;
|
||||||
use rekordcrate::pdb::Row::PlaylistEntry;
|
use std::fs::File;
|
||||||
use rekordcrate::pdb::string::DeviceSQLString;
|
use std::path::Path;
|
||||||
use rekordcrate::pdb::RowGroup;
|
|
||||||
use rekordcrate::pdb::TrackId;
|
const PAGE_SIZE: u32 = 4096;
|
||||||
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;
|
|
||||||
|
|
||||||
// Example
|
// Example
|
||||||
fn main() -> rekordcrate::Result<()> {
|
fn main() -> Result<(), RekordcrateError> {
|
||||||
let db = Database::load_from_pdb("ntr.pdb")?;
|
let db = Database::load_from_pdb("ntr.pdb").unwrap();
|
||||||
println!("tracks: {}, playlists: {}", db.tracks.len(), db.nodes.len());
|
|
||||||
println!("tables: {:?}", db.header);
|
|
||||||
println!("tables: {:?}", db.header.tables);
|
|
||||||
|
|
||||||
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 {
|
// for n in db.nodes {
|
||||||
// println!("{:?}", n);
|
// println!("{:?}", n);
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -8,10 +8,10 @@
|
|||||||
"path": "h:ome\\ntr\\code\\rekordcrate",
|
"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":
|
"settings":
|
||||||
|
|||||||
Reference in new issue
Block a user