diff --git a/justfile b/justfile index e5e1bfe..fd38183 100644 --- a/justfile +++ b/justfile @@ -31,6 +31,18 @@ link plist dir: sync args: s5cmd {{args}} +usb dst="/Volumes/MAPLE": + COPYFILE_DISABLE=1 rsync -rtvh --delete --size-only --no-perms --no-owner --no-group --modify-window=2 maple-py/data/usb/Contents/ {{dst}}/Contents/ + COPYFILE_DISABLE=1 rsync -rtvh --delete --size-only --no-perms --no-owner --no-group --modify-window=2 maple-py/data/usb/PIONEER/ {{dst}}/PIONEER/ + # find {{dst}} -name '._*' -delete + +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/ + find {{dst}} -name '._*' -delete + maple-setup: bash ./etc/maple_setup.sh diff --git a/maple-ts/README.md b/maple-ts/README.md new file mode 100644 index 0000000..5dfa598 --- /dev/null +++ b/maple-ts/README.md @@ -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. diff --git a/maple-ts/package.json b/maple-ts/package.json new file mode 100644 index 0000000..d039581 --- /dev/null +++ b/maple-ts/package.json @@ -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" + } +} diff --git a/maple-ts/src/cli.ts b/maple-ts/src/cli.ts new file mode 100644 index 0000000..9695705 --- /dev/null +++ b/maple-ts/src/cli.ts @@ -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 { + try { return (await stat(p)).isDirectory(); } catch { return false; } +} + +async function pMap(items: T[], workers: number, fn: (item: T) => Promise): Promise { + 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 { + const db = newDatabase(); + const dateAdded = today(); + + const artistIndex = new Map(); + const albumIndex = new Map(); + const trackBySrc = new Map(); + + const audioFiles = await scanAudio(inputDir); + + const initialMeta = new Map>>(); + 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 { + 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)); +} diff --git a/maple-ts/src/convert.ts b/maple-ts/src/convert.ts new file mode 100644 index 0000000..ea67673 --- /dev/null +++ b/maple-ts/src/convert.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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)`); + } +} diff --git a/maple-ts/src/copy.ts b/maple-ts/src/copy.ts new file mode 100644 index 0000000..7b6b42a --- /dev/null +++ b/maple-ts/src/copy.ts @@ -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 { + 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 }; +} diff --git a/maple-ts/src/meta.ts b/maple-ts/src/meta.ts new file mode 100644 index 0000000..a0ab917 --- /dev/null +++ b/maple-ts/src/meta.ts @@ -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 { + 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, + }; + } +} diff --git a/maple-ts/src/model.ts b/maple-ts/src/model.ts new file mode 100644 index 0000000..9edc478 --- /dev/null +++ b/maple-ts/src/model.ts @@ -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 & Partial): 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, + }; +} diff --git a/maple-ts/src/pdb/buf.ts b/maple-ts/src/pdb/buf.ts new file mode 100644 index 0000000..0de201c --- /dev/null +++ b/maple-ts/src/pdb/buf.ts @@ -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; +} diff --git a/maple-ts/src/pdb/pager.ts b/maple-ts/src/pdb/pager.ts new file mode 100644 index 0000000..58b083c --- /dev/null +++ b/maple-ts/src/pdb/pager.ts @@ -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; + } +} diff --git a/maple-ts/src/pdb/rows.ts b/maple-ts/src/pdb/rows.ts new file mode 100644 index 0000000..0a15d0d --- /dev/null +++ b/maple-ts/src/pdb/rows.ts @@ -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(); +} diff --git a/maple-ts/src/pdb/strings.ts b/maple-ts/src/pdb/strings.ts new file mode 100644 index 0000000..265476f --- /dev/null +++ b/maple-ts/src/pdb/strings.ts @@ -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; +} diff --git a/maple-ts/src/pdb/writer.ts b/maple-ts/src/pdb/writer.ts new file mode 100644 index 0000000..0f066c9 --- /dev/null +++ b/maple-ts/src/pdb/writer.ts @@ -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 { + 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); +} diff --git a/maple-ts/src/scan.ts b/maple-ts/src/scan.ts new file mode 100644 index 0000000..d8bfba3 --- /dev/null +++ b/maple-ts/src/scan.ts @@ -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 { + 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 { + const all = await walk(inputDir); + return all.filter((p) => AUDIO_EXTS.has(extname(p).toLowerCase())).sort(); +} + +export async function parseM3u8(playlistPath: string): Promise { + 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> { + const all = await walk(inputDir); + const playlists = all.filter((p) => extname(p).toLowerCase() === ".m3u8").sort(); + const out = new Map(); + for (const p of playlists) { + const stem = basename(p, extname(p)); + out.set(stem, await parseM3u8(p)); + } + return out; +} diff --git a/maple-ts/tests/strings.test.ts b/maple-ts/tests/strings.test.ts new file mode 100644 index 0000000..d326ffb --- /dev/null +++ b/maple-ts/tests/strings.test.ts @@ -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); +}); diff --git a/maple-ts/tests/writer_smoke.test.ts b/maple-ts/tests/writer_smoke.test.ts new file mode 100644 index 0000000..fde99df --- /dev/null +++ b/maple-ts/tests/writer_smoke.test.ts @@ -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 }); + } +}); diff --git a/maple-ts/tsconfig.json b/maple-ts/tsconfig.json new file mode 100644 index 0000000..bacc0c6 --- /dev/null +++ b/maple-ts/tsconfig.json @@ -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"] +}