This commit is contained in:
Nathan Rashleigh committed 2026-05-26 16:27:50 +10:00
1 parent 32adb5ddac
commit 1e49010aa9
17 files changed
+1049

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
# maple-ts
TypeScript/Bun port of `maple-py`. Builds a rekordbox `export.pdb` USB layout from a folder of audio files and `.m3u8` playlists.
## Install
```sh
bun install
```
## Run
```sh
bun run src/cli.ts ./data/input ./data/usb
```
Requires `ffmpeg` on `PATH` for FLAC -> MP3 conversion.
+22
View File
@@ -0,0 +1,22 @@
{
"name": "maple",
"version": "0.1.0",
"description": "Build a rekordbox USB from a folder of audio + m3u8 playlists",
"type": "module",
"module": "src/cli.ts",
"bin": {
"maple": "src/cli.ts"
},
"scripts": {
"maple": "bun run src/cli.ts",
"test": "bun test"
},
"devDependencies": {
"@types/bun": "latest",
"@types/node-id3": "^0.2.6"
},
"dependencies": {
"music-metadata": "^10.5.0",
"node-id3": "^0.2.6"
}
}
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env bun
/** maple CLI: scan a directory of audio + m3u8 playlists and build a rekordbox USB. */
import { stat } from "node:fs/promises";
import { basename, extname, join, resolve } from "node:path";
import os from "node:os";
import { convertFlacToMp3, verifyDuration } from "./convert.ts";
import { stageAudio, stagedPath } from "./copy.ts";
import { readTags } from "./meta.ts";
import { newDatabase, newTrack, type Database } from "./model.ts";
import { writePdb } from "./pdb/writer.ts";
import { scanAudio, scanPlaylists } from "./scan.ts";
function today(): string {
return new Date().toISOString().slice(0, 10);
}
function withSuffix(file: string, newExt: string): string {
const ext = extname(file);
return file.slice(0, file.length - ext.length) + newExt;
}
async function isDir(p: string): Promise<boolean> {
try { return (await stat(p)).isDirectory(); } catch { return false; }
}
async function pMap<T, R>(items: T[], workers: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const out: R[] = new Array(items.length);
let i = 0;
const runners = Array.from({ length: Math.max(1, Math.min(workers, items.length)) }, async () => {
while (true) {
const idx = i++;
if (idx >= items.length) return;
out[idx] = await fn(items[idx]!);
}
});
await Promise.all(runners);
return out;
}
export async function build(inputDir: string, usbRoot: string): Promise<Database> {
const db = newDatabase();
const dateAdded = today();
const artistIndex = new Map<string, number>();
const albumIndex = new Map<string, number>();
const trackBySrc = new Map<string, number>();
const audioFiles = await scanAudio(inputDir);
const initialMeta = new Map<string, Awaited<ReturnType<typeof readTags>>>();
for (const p of audioFiles) initialMeta.set(p, await readTags(p));
const flacJobs: Array<{ src: string; dst: string }> = [];
for (const original of audioFiles) {
if (extname(original).toLowerCase() === ".flac") {
const m = initialMeta.get(original)!;
const mp3Name = withSuffix(basename(original), ".mp3");
const { destAbs } = stagedPath(usbRoot, m.artist, m.album, mp3Name);
flacJobs.push({ src: original, dst: destAbs });
}
}
const workers = Math.max(1, os.cpus().length || 1);
const results = await pMap(flacJobs, workers, async (job) => {
const converted = await convertFlacToMp3(job.src, job.dst);
return { ...job, converted };
});
for (const r of results) {
const rel = r.src.startsWith(inputDir) ? r.src.slice(inputDir.length).replace(/^[\/\\]+/, "") : r.src;
console.log(`${r.converted ? "convert" : "cached "} ${rel}`);
await verifyDuration(r.src, r.dst);
}
for (const original of audioFiles) {
let meta = initialMeta.get(original)!;
let artistId = artistIndex.get(meta.artist);
if (artistId === undefined) {
artistId = db.artists.length + 1;
artistIndex.set(meta.artist, artistId);
db.artists.push({ id: artistId, name: meta.artist });
}
const albumKey = `${artistId}\x00${meta.album}`;
let albumId = albumIndex.get(albumKey);
if (albumId === undefined) {
albumId = db.albums.length + 1;
albumIndex.set(albumKey, albumId);
db.albums.push({ id: albumId, name: meta.album, artist_id: artistId });
}
let filePath: string;
if (extname(original).toLowerCase() === ".flac") {
const mp3Name = withSuffix(basename(original), ".mp3");
const s = stagedPath(usbRoot, meta.artist, meta.album, mp3Name);
filePath = s.filePath;
meta = await readTags(s.destAbs);
} else {
const s = await stageAudio(original, usbRoot, meta.artist, meta.album);
filePath = s.filePath;
}
const trackId = db.tracks.length + 1;
db.tracks.push(newTrack({
id: trackId,
title: meta.title,
artist_id: artistId,
album_id: albumId,
filename: basename(filePath),
file_path: filePath,
file_size: meta.file_size,
sample_rate: meta.sample_rate,
sample_depth: meta.sample_depth,
bitrate: meta.bitrate,
duration: meta.duration,
track_number: meta.track_number,
year: meta.year,
date_added: dateAdded,
source_path: original,
}));
trackBySrc.set(resolve(original), trackId);
}
const playlists = await scanPlaylists(inputDir);
let sortOrder = 1;
for (const [name, paths] of playlists) {
const playlistId = db.playlist_nodes.length + 1;
db.playlist_nodes.push({
id: playlistId,
parent_id: 0,
sort_order: sortOrder,
name,
is_folder: false,
});
sortOrder += 1;
let entryIndex = 1;
for (const p of paths) {
const tid = trackBySrc.get(resolve(p));
if (tid === undefined) {
console.error(`warning: playlist '${name}' references missing file ${p}`);
continue;
}
db.playlist_entries.push({
playlist_id: playlistId,
track_id: tid,
entry_index: entryIndex,
});
entryIndex += 1;
}
}
return db;
}
async function main(argv: string[]): Promise<number> {
const args = argv.slice(2);
const inputDir = args[0] ?? "./data/input";
const outputRoot = args[1] ?? "./data/usb";
if (!(await isDir(inputDir))) {
console.error(`error: input_dir ${inputDir} is not a directory`);
return 2;
}
const db = await build(inputDir, outputRoot);
const pdbPath = join(outputRoot, "PIONEER", "rekordbox", "export.pdb");
await writePdb(db, pdbPath);
console.log(
`wrote ${db.tracks.length} tracks, ${db.artists.length} artists, ${db.albums.length} albums, ` +
`${db.playlist_nodes.length} playlists (${db.playlist_entries.length} entries) to ${pdbPath}`
);
return 0;
}
if (import.meta.main) {
process.exit(await main(process.argv));
}
+90
View File
@@ -0,0 +1,90 @@
/**
* FLAC -> MP3 V0 joint-stereo conversion via ffmpeg, with source-hash dedup.
*
* The SHA-256 of the source FLAC is stamped into a TXXX:MAPLE_SRC_SHA256 ID3
* frame on the produced MP3. Reruns skip conversion when the existing MP3
* carries the matching hash, so the cache is content-addressed against inputs.
*/
import { mkdir, access } from "node:fs/promises";
import { dirname } from "node:path";
import { parseFile } from "music-metadata";
import NodeID3 from "node-id3";
const HASH_FRAME_DESC = "MAPLE_SRC_SHA256";
export async function fileSha256(path: string): Promise<string> {
const hasher = new Bun.CryptoHasher("sha256");
const stream = Bun.file(path).stream();
for await (const chunk of stream) hasher.update(chunk);
return hasher.digest("hex");
}
async function exists(p: string): Promise<boolean> {
try { await access(p); return true; } catch { return false; }
}
function readSourceHash(mp3: string): string | null {
const tags = NodeID3.read(mp3);
const frames = tags.userDefinedText;
if (!frames) return null;
for (const f of frames) {
if (f.description === HASH_FRAME_DESC && f.value) return f.value;
}
return null;
}
function writeSourceHash(mp3: string, sha: string): void {
NodeID3.update({ userDefinedText: [{ description: HASH_FRAME_DESC, value: sha }] }, mp3);
}
async function which(cmd: string): Promise<boolean> {
const p = Bun.spawn({ cmd: ["which", cmd], stdout: "pipe", stderr: "pipe" });
await p.exited;
return p.exitCode === 0;
}
export async function convertFlacToMp3(flac: string, mp3: string): Promise<boolean> {
const srcHash = await fileSha256(flac);
if (await exists(mp3)) {
try {
if (readSourceHash(mp3) === srcHash) return false;
} catch {}
}
if (!(await which("ffmpeg"))) throw new Error("ffmpeg not found on PATH");
await mkdir(dirname(mp3), { recursive: true });
const proc = Bun.spawn({
cmd: [
"ffmpeg", "-nostdin", "-loglevel", "error", "-y",
"-i", flac,
"-codec:a", "libmp3lame",
"-q:a", "0",
"-joint_stereo", "1",
"-map_metadata", "0",
mp3,
],
stdout: "inherit",
stderr: "inherit",
});
await proc.exited;
if (proc.exitCode !== 0) throw new Error(`ffmpeg failed for ${flac}`);
writeSourceHash(mp3, srcHash);
return true;
}
async function duration(path: string): Promise<number> {
try {
const md = await parseFile(path, { duration: true });
return md.format.duration ?? 0;
} catch {
return 0;
}
}
export async function verifyDuration(src: string, dst: string, tolerance = 0.1): Promise<void> {
const [a, b] = await Promise.all([duration(src), duration(dst)]);
if (Math.abs(a - b) > tolerance) {
throw new Error(`duration mismatch: ${src} = ${a.toFixed(3)}s vs ${dst} = ${b.toFixed(3)}s (Δ ${Math.abs(a - b).toFixed(3)}s)`);
}
}
+29
View File
@@ -0,0 +1,29 @@
import { dirname, join, basename } from "node:path";
import { mkdir, copyFile, access } from "node:fs/promises";
const BAD = /[\\/:*?"<>|\x00-\x1f]/g;
export function sanitise(component: string): string {
const s = component.replace(BAD, "_").replace(/^[ .]+|[ .]+$/g, "");
return s || "_";
}
export function stagedPath(usbRoot: string, artist: string, album: string, filename: string): { destAbs: string; filePath: string } {
const relParts = ["Contents", sanitise(artist), sanitise(album), sanitise(filename)];
const destAbs = join(usbRoot, ...relParts);
const filePath = "/" + relParts.join("/");
return { destAbs, filePath };
}
async function exists(p: string): Promise<boolean> {
try { await access(p); return true; } catch { return false; }
}
export async function stageAudio(src: string, usbRoot: string, artist: string, album: string): Promise<{ destAbs: string; filePath: string }> {
const { destAbs, filePath } = stagedPath(usbRoot, artist, album, basename(src));
await mkdir(dirname(destAbs), { recursive: true });
if (!(await exists(destAbs))) {
await copyFile(src, destAbs);
}
return { destAbs, filePath };
}
+62
View File
@@ -0,0 +1,62 @@
import { parseFile } from "music-metadata";
import { basename, extname } from "node:path";
import { stat } from "node:fs/promises";
export interface TrackMeta {
title: string;
artist: string;
album: string;
track_number: number;
year: number;
duration: number;
bitrate: number;
sample_rate: number;
sample_depth: number;
file_size: number;
}
function toInt(v: unknown): number {
if (v === null || v === undefined) return 0;
let s = String(Array.isArray(v) ? v[0] : v).trim();
if (!s) return 0;
s = s.split("/")[0]!;
const n = parseInt(s, 10);
return Number.isFinite(n) ? n : 0;
}
export async function readTags(path: string): Promise<TrackMeta> {
const { size: file_size } = await stat(path);
const stem = basename(path, extname(path));
try {
const md = await parseFile(path, { duration: true });
const c = md.common;
const info = md.format;
const bitrate_bps = info.bitrate ?? 0;
const bitrate = bitrate_bps > 1000 ? Math.floor(bitrate_bps / 1000) : bitrate_bps;
return {
title: c.title || stem,
artist: c.artist || "Unknown Artist",
album: c.album || "Unknown Album",
track_number: toInt(c.track?.no),
year: toInt(c.year ?? c.date),
duration: Math.floor(info.duration ?? 0),
bitrate,
sample_rate: info.sampleRate ?? 44100,
sample_depth: info.bitsPerSample ?? 16,
file_size,
};
} catch {
return {
title: stem,
artist: "Unknown Artist",
album: "Unknown Album",
track_number: 0,
year: 0,
duration: 0,
bitrate: 0,
sample_rate: 44100,
sample_depth: 16,
file_size,
};
}
}
+83
View File
@@ -0,0 +1,83 @@
export interface Artist {
id: number;
name: string;
}
export interface Album {
id: number;
name: string;
artist_id: number;
}
export interface Track {
id: number;
title: string;
artist_id: number;
album_id: number;
filename: string;
file_path: string;
file_size: number;
sample_rate: number;
sample_depth: number;
bitrate: number;
duration: number;
track_number: number;
year: number;
disc_number: number;
tempo: number;
comment: string;
date_added: string;
release_date: string;
mix_name: string;
analyze_path: string;
analyze_date: string;
source_path: string | null;
}
export interface PlaylistNode {
id: number;
parent_id: number;
sort_order: number;
name: string;
is_folder: boolean;
}
export interface PlaylistEntry {
playlist_id: number;
track_id: number;
entry_index: number;
}
export interface Database {
tracks: Track[];
artists: Artist[];
albums: Album[];
playlist_nodes: PlaylistNode[];
playlist_entries: PlaylistEntry[];
}
export function newDatabase(): Database {
return { tracks: [], artists: [], albums: [], playlist_nodes: [], playlist_entries: [] };
}
export function newTrack(init: Pick<Track, "id" | "title" | "artist_id" | "album_id" | "filename" | "file_path"> & Partial<Track>): Track {
return {
file_size: 0,
sample_rate: 44100,
sample_depth: 16,
bitrate: 0,
duration: 0,
track_number: 0,
year: 0,
disc_number: 0,
tempo: 0,
comment: "",
date_added: "",
release_date: "",
mix_name: "",
analyze_path: "",
analyze_date: "",
source_path: null,
...init,
};
}
+44
View File
@@ -0,0 +1,44 @@
/** Small little-endian write helpers backed by Uint8Array. */
export class LE {
parts: Uint8Array[] = [];
private len = 0;
push(u: Uint8Array): void { this.parts.push(u); this.len += u.length; }
u8(v: number): void {
const b = new Uint8Array(1); b[0] = v & 0xff; this.push(b);
}
u16(v: number): void {
const b = new Uint8Array(2);
b[0] = v & 0xff;
b[1] = (v >> 8) & 0xff;
this.push(b);
}
u32(v: number): void {
const b = new Uint8Array(4);
b[0] = v & 0xff;
b[1] = (v >>> 8) & 0xff;
b[2] = (v >>> 16) & 0xff;
b[3] = (v >>> 24) & 0xff;
this.push(b);
}
bytes(b: Uint8Array): void { this.push(b); }
length(): number { return this.len; }
build(): Uint8Array {
const out = new Uint8Array(this.len);
let o = 0;
for (const p of this.parts) { out.set(p, o); o += p.length; }
return out;
}
}
export function concat(parts: Uint8Array[]): Uint8Array {
let len = 0;
for (const p of parts) len += p.length;
const out = new Uint8Array(len);
let o = 0;
for (const p of parts) { out.set(p, o); o += p.length; }
return out;
}
+95
View File
@@ -0,0 +1,95 @@
import { LE } from "./buf.ts";
export const PAGE_SIZE = 4096;
const HEADER_SIZE = 40;
const ROW_GROUP_SIZE = 36;
const ROWS_PER_GROUP = 16;
function align4(o: number): number { return (o + 3) & ~3; }
function numRowGroups(n: number): number { return Math.floor((n + ROWS_PER_GROUP - 1) / ROWS_PER_GROUP); }
export class PageBuilder {
pageIndex: number;
pageType: number;
nextPage: number;
rowBytes: Uint8Array[] = [];
rowOffsets: number[] = [];
heapCursor = 0;
constructor(pageIndex: number, pageType: number) {
this.pageIndex = pageIndex;
this.pageType = pageType;
this.nextPage = pageIndex;
}
tryAdd(row: Uint8Array): boolean {
const aligned = align4(this.heapCursor);
const end = aligned + row.length;
const footerAfter = numRowGroups(this.rowBytes.length + 1) * ROW_GROUP_SIZE;
if (end + HEADER_SIZE + footerAfter > PAGE_SIZE) return false;
this.rowOffsets.push(aligned);
this.rowBytes.push(row);
this.heapCursor = end;
return true;
}
numRows(): number { return this.rowBytes.length; }
finalize(): Uint8Array {
const n = this.rowBytes.length;
const numRgs = numRowGroups(n);
const footerSize = numRgs * ROW_GROUP_SIZE;
const heapPadding = PAGE_SIZE - HEADER_SIZE - footerSize;
const usedSize = this.heapCursor;
const freeSize = heapPadding - usedSize;
if (freeSize < 0) throw new Error("page overflow");
const pageFlags = n > 0 ? 0x00 : 0x44;
const h = new LE();
h.u32(0); // magic
h.u32(this.pageIndex);
h.u32(this.pageType);
h.u32(this.nextPage);
h.u32(0); // unknown1
h.u32(0); // unknown2
h.u8(n & 0xff); h.u8(0); h.u8(0); h.u8(pageFlags);
h.u16(freeSize); h.u16(usedSize);
h.u16(0); // unknown5
h.u16(n); // num_rows_large
h.u16(0); // unknown6
h.u16(0); // unknown7
const header = h.build();
if (header.length !== HEADER_SIZE) throw new Error(`hdr len ${header.length}`);
const heap = new Uint8Array(heapPadding);
for (let i = 0; i < this.rowBytes.length; i++) {
heap.set(this.rowBytes[i]!, this.rowOffsets[i]!);
}
const footer = new LE();
for (let g = numRgs - 1; g >= 0; g--) {
const start = g * ROWS_PER_GROUP;
const grpOffsets = this.rowOffsets.slice(start, start + ROWS_PER_GROUP);
const grpCount = grpOffsets.length;
const padded = grpOffsets.slice();
while (padded.length < ROWS_PER_GROUP) padded.push(0);
const presence = (1 << grpCount) - 1;
const rg = new LE();
for (let i = padded.length - 1; i >= 0; i--) rg.u16(padded[i]!);
rg.u16(presence);
rg.u16(16);
const rgBytes = rg.build();
if (rgBytes.length !== ROW_GROUP_SIZE) throw new Error("rg size");
footer.bytes(rgBytes);
}
const footerBytes = footer.build();
if (footerBytes.length !== footerSize) throw new Error("footer size");
const out = new Uint8Array(PAGE_SIZE);
out.set(header, 0);
out.set(heap, HEADER_SIZE);
out.set(footerBytes, HEADER_SIZE + heapPadding);
return out;
}
}
+148
View File
@@ -0,0 +1,148 @@
import type { Album, Artist, PlaylistEntry, PlaylistNode, Track } from "../model.ts";
import { encodeDevicesql } from "./strings.ts";
import { LE, concat } from "./buf.ts";
const TRACK_FIXED_PREFIX = 0x5e;
const TRACK_OFS_STRINGS_START = TRACK_FIXED_PREFIX;
const TRACK_HEADER_LEN = TRACK_OFS_STRINGS_START + 21 * 2;
function pad2(b: Uint8Array): Uint8Array {
if (b.length & 1) {
const out = new Uint8Array(b.length + 1);
out.set(b);
return out;
}
return b;
}
export function serializeTrack(t: Track): Uint8Array {
const strings: string[] = [
"", // 0 isrc
"", // 1 texter
"1", // 2 unknown_string_2
"2", // 3 unknown_string_3
"", // 4 unknown_string_4
"", // 5 message
"ON", // 6 kuvo_public
"ON", // 7 autoload_hot_cues
"", // 8 unknown_string_5
"", // 9 unknown_string_6
t.date_added, // 10
t.release_date, // 11
t.mix_name, // 12
"", // 13
t.analyze_path, // 14
t.analyze_date, // 15
t.comment, // 16
t.title, // 17
"", // 18
t.filename, // 19
t.file_path, // 20
];
const encoded = strings.map(encodeDevicesql);
const offsets: number[] = [];
let cursor = TRACK_HEADER_LEN;
for (const e of encoded) {
offsets.push(cursor);
cursor += e.length;
}
const blob = concat(encoded);
const p = new LE();
p.u16(0x24); p.u16(0); // subtype, index_shift
p.u32(788224); // bitmask
p.u32(t.sample_rate);
p.u32(0); // composer_id
p.u32(t.file_size);
p.u32(0); // unknown
p.u16(19048);
p.u16(30967);
p.u32(0); // artwork_id
p.u32(0); // key_id
p.u32(0); // original_artist_id
p.u32(0); // label_id
p.u32(0); // remixer_id
p.u32(t.bitrate);
p.u32(t.track_number);
p.u32(t.tempo);
p.u32(0); // genre_id
p.u32(t.album_id);
p.u32(t.artist_id);
p.u32(t.id);
p.u16(t.disc_number);
p.u16(0); // play_count
p.u16(t.year);
p.u16(t.sample_depth);
p.u16(t.duration);
p.u16(41);
p.u8(0); p.u8(0); // color_id, rating
p.u16(1);
p.u16(3);
const prefix = p.build();
if (prefix.length !== TRACK_FIXED_PREFIX) throw new Error(`prefix len ${prefix.length}`);
const ofs = new LE();
for (const o of offsets) ofs.u16(o);
return pad2(concat([prefix, ofs.build(), blob]));
}
export function serializeArtist(a: Artist): Uint8Array {
const name = encodeDevicesql(a.name);
const nearOfs = 0x0a;
const farThreshold = 0x100;
const r = new LE();
if (nearOfs < farThreshold) {
r.u16(0x60); r.u16(a.id & 0xffff);
r.u32(a.id);
r.u8(0x03);
r.u8(nearOfs);
if (r.length() !== nearOfs) throw new Error("artist near offset mismatch");
r.bytes(name);
} else {
const far = 0x0e;
r.u16(0x64); r.u16(a.id & 0xffff);
r.u32(a.id);
r.u8(0x03);
r.u8(0);
r.u16(far);
r.bytes(name);
}
return pad2(r.build());
}
export function serializeAlbum(a: Album): Uint8Array {
const name = encodeDevicesql(a.name);
const nearOfs = 0x16;
const r = new LE();
r.u16(0x80); r.u16(a.id & 0xffff);
r.u32(0);
r.u32(a.artist_id);
r.u32(a.id);
r.u32(0);
r.u8(0x03);
r.u8(nearOfs);
if (r.length() !== nearOfs) throw new Error("album near offset mismatch");
r.bytes(name);
return pad2(r.build());
}
export function serializePlaylistTree(n: PlaylistNode): Uint8Array {
const r = new LE();
r.u32(n.parent_id);
r.u32(0);
r.u32(n.sort_order);
r.u32(n.id);
r.u32(n.is_folder ? 1 : 0);
r.bytes(encodeDevicesql(n.name));
return pad2(r.build());
}
export function serializePlaylistEntry(e: PlaylistEntry): Uint8Array {
const r = new LE();
r.u32(e.entry_index);
r.u32(e.track_id);
r.u32(e.playlist_id);
return r.build();
}
+33
View File
@@ -0,0 +1,33 @@
const EMPTY = new Uint8Array([0x03]);
function isAscii(s: string): boolean {
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 0x7f) return false;
return true;
}
export function encodeDevicesql(s: string): Uint8Array {
if (!s) return EMPTY;
if (isAscii(s) && s.length < 127) {
const ascii = new TextEncoder().encode(s);
const length = ascii.length + 1;
const lengthAndKind = ((length << 1) | 1) & 0xff;
const out = new Uint8Array(1 + ascii.length);
out[0] = lengthAndKind;
out.set(ascii, 1);
return out;
}
const utf16 = new Uint8Array(s.length * 2);
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
utf16[i * 2] = c & 0xff;
utf16[i * 2 + 1] = (c >> 8) & 0xff;
}
const totalLength = utf16.length + 4;
const out = new Uint8Array(4 + utf16.length);
out[0] = 0x90;
out[1] = totalLength & 0xff;
out[2] = (totalLength >> 8) & 0xff;
out[3] = 0x00;
out.set(utf16, 4);
return out;
}
+122
View File
@@ -0,0 +1,122 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import type { Database } from "../model.ts";
import { PAGE_SIZE, PageBuilder } from "./pager.ts";
import {
serializeAlbum,
serializeArtist,
serializePlaylistEntry,
serializePlaylistTree,
serializeTrack,
} from "./rows.ts";
import { LE } from "./buf.ts";
const PAGE_TYPE_TRACKS = 0;
const PAGE_TYPE_ARTISTS = 2;
const PAGE_TYPE_ALBUMS = 3;
const PAGE_TYPE_PLAYLIST_TREE = 7;
const PAGE_TYPE_PLAYLIST_ENTRIES = 8;
const FILE_HEADER_SIZE = 28;
const TABLE_ENTRY_SIZE = 16;
interface Chain {
pageType: number;
pages: Uint8Array[];
first: number;
last: number;
}
function buildChain(pageType: number, rows: Uint8Array[], startIndex: number): Chain {
const builders: PageBuilder[] = [];
let idx = startIndex;
let current = new PageBuilder(idx, pageType);
for (const row of rows) {
if (!current.tryAdd(row)) {
builders.push(current);
idx += 1;
current = new PageBuilder(idx, pageType);
if (!current.tryAdd(row)) {
throw new Error(`row of size ${row.length} too large for an empty page`);
}
}
}
builders.push(current);
const out: Uint8Array[] = [];
for (let i = 0; i < builders.length; i++) {
const p = builders[i]!;
if (i === builders.length - 1) p.nextPage = p.pageIndex;
else p.nextPage = p.pageIndex + 1;
out.push(p.finalize());
}
return { pageType, pages: out, first: startIndex, last: idx };
}
export async function writePdb(db: Database, path: string): Promise<void> {
const chains: Chain[] = [];
let nextIdx = 1;
const tables: Array<[number, Uint8Array[]]> = [
[PAGE_TYPE_TRACKS, db.tracks.map(serializeTrack)],
[PAGE_TYPE_ARTISTS, db.artists.map(serializeArtist)],
[PAGE_TYPE_ALBUMS, db.albums.map(serializeAlbum)],
[PAGE_TYPE_PLAYLIST_TREE, db.playlist_nodes.map(serializePlaylistTree)],
[PAGE_TYPE_PLAYLIST_ENTRIES, db.playlist_entries.map(serializePlaylistEntry)],
];
for (const [ptype, rows] of tables) {
const c = buildChain(ptype, rows, nextIdx);
chains.push(c);
nextIdx = c.last + 1;
}
const numTables = chains.length;
const nextUnused = nextIdx;
const h = new LE();
h.u32(0);
h.u32(PAGE_SIZE);
h.u32(numTables);
h.u32(nextUnused);
h.u32(0);
h.u32(1);
h.u32(0);
const fileHeader = h.build();
if (fileHeader.length !== FILE_HEADER_SIZE) throw new Error("file header size");
const tbl = new LE();
for (const c of chains) {
tbl.u32(c.pageType);
tbl.u32(0);
tbl.u32(c.first);
tbl.u32(c.last);
}
const tablesBlock = tbl.build();
if (tablesBlock.length !== numTables * TABLE_ENTRY_SIZE) throw new Error("tables size");
const headerFull = new Uint8Array(fileHeader.length + tablesBlock.length);
headerFull.set(fileHeader, 0);
headerFull.set(tablesBlock, fileHeader.length);
if (headerFull.length > PAGE_SIZE) throw new Error("too many tables for one page");
const page0 = new Uint8Array(PAGE_SIZE);
page0.set(headerFull, 0);
let totalLen = PAGE_SIZE;
for (const c of chains) totalLen += c.pages.length * PAGE_SIZE;
const out = new Uint8Array(totalLen);
let off = 0;
out.set(page0, off); off += PAGE_SIZE;
for (const c of chains) {
for (const p of c.pages) {
if (p.length !== PAGE_SIZE) throw new Error("page size mismatch");
out.set(p, off); off += PAGE_SIZE;
}
}
await mkdir(dirname(path), { recursive: true });
await writeFile(path, out);
}
+46
View File
@@ -0,0 +1,46 @@
import { readdir } from "node:fs/promises";
import { join, extname, basename, dirname, isAbsolute, resolve } from "node:path";
const AUDIO_EXTS = new Set([".mp3", ".flac", ".wav", ".aiff", ".aif", ".m4a"]);
async function walk(dir: string): Promise<string[]> {
const out: string[] = [];
const entries = await readdir(dir, { withFileTypes: true });
for (const e of entries) {
const p = join(dir, e.name);
if (e.isDirectory()) {
out.push(...(await walk(p)));
} else if (e.isFile()) {
out.push(p);
}
}
return out;
}
export async function scanAudio(inputDir: string): Promise<string[]> {
const all = await walk(inputDir);
return all.filter((p) => AUDIO_EXTS.has(extname(p).toLowerCase())).sort();
}
export async function parseM3u8(playlistPath: string): Promise<string[]> {
const base = dirname(playlistPath);
const text = await Bun.file(playlistPath).text();
const entries: string[] = [];
for (const raw of text.split(/\r?\n/)) {
const s = raw.trim();
if (!s || s.startsWith("#")) continue;
entries.push(isAbsolute(s) ? resolve(s) : resolve(join(base, s)));
}
return entries;
}
export async function scanPlaylists(inputDir: string): Promise<Map<string, string[]>> {
const all = await walk(inputDir);
const playlists = all.filter((p) => extname(p).toLowerCase() === ".m3u8").sort();
const out = new Map<string, string[]>();
for (const p of playlists) {
const stem = basename(p, extname(p));
out.set(stem, await parseM3u8(p));
}
return out;
}
+24
View File
@@ -0,0 +1,24 @@
import { expect, test } from "bun:test";
import { encodeDevicesql } from "../src/pdb/strings.ts";
test("empty encodes to 0x03", () => {
expect(Array.from(encodeDevicesql(""))).toEqual([0x03]);
});
test("short ascii uses short form", () => {
const out = encodeDevicesql("ABC");
// length = 3+1 = 4; (4<<1)|1 = 9
expect(out[0]).toBe(9);
expect(Array.from(out.slice(1))).toEqual([0x41, 0x42, 0x43]);
});
test("non-ascii uses utf-16 form", () => {
const out = encodeDevicesql("é");
expect(out[0]).toBe(0x90);
// total_length = utf16 bytes (2) + 4 = 6
expect(out[1]).toBe(6);
expect(out[2]).toBe(0);
expect(out[3]).toBe(0);
expect(out[4]).toBe(0xe9);
expect(out[5]).toBe(0x00);
});
+30
View File
@@ -0,0 +1,30 @@
import { expect, test } from "bun:test";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { newDatabase, newTrack } from "../src/model.ts";
import { writePdb } from "../src/pdb/writer.ts";
import { PAGE_SIZE } from "../src/pdb/pager.ts";
test("writes a multi-page-aligned pdb file", async () => {
const dir = await mkdtemp(join(tmpdir(), "maple-ts-"));
try {
const db = newDatabase();
db.artists.push({ id: 1, name: "A" });
db.albums.push({ id: 1, name: "Alb", artist_id: 1 });
db.tracks.push(newTrack({
id: 1, title: "T", artist_id: 1, album_id: 1,
filename: "t.mp3", file_path: "/Contents/A/Alb/t.mp3",
}));
db.playlist_nodes.push({ id: 1, parent_id: 0, sort_order: 1, name: "P", is_folder: false });
db.playlist_entries.push({ playlist_id: 1, track_id: 1, entry_index: 1 });
const out = join(dir, "export.pdb");
await writePdb(db, out);
const buf = await readFile(out);
expect(buf.length % PAGE_SIZE).toBe(0);
expect(buf.length).toBeGreaterThanOrEqual(PAGE_SIZE * 6);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["bun-types"],
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"noEmit": true,
"verbatimModuleSyntax": false
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}