ts
This commit is contained in:
1 parent
32adb5ddac
commit
1e49010aa9
17 files changed
+1049
No files matched your search
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user