import { describe, expect, it } from "@suaveplan/testing/runner"; import { parseMp4 } from "./mp4-atom.js"; // --------------------------------------------------------------------------- // Minimal MP4/M4A buffer builders // --------------------------------------------------------------------------- function uint32BE(n: number): number[] { return [(n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]; } function boxBytes(type: string, data: number[]): number[] { const size = 8 + data.length; // Encode type as Latin-1: each char maps directly to its char code (handles © = 0xA9) const typeBytes = [ type.charCodeAt(0) & 0xff, type.charCodeAt(1) & 0xff, type.charCodeAt(2) & 0xff, type.charCodeAt(3) & 0xff, ]; return [...uint32BE(size), ...typeBytes, ...data]; } function textDataBox(text: string): number[] { const textBytes = Array.from(new TextEncoder().encode(text)); // data box: type(4) "data", flags+version(4) = 0x00 00 00 01 (UTF-8), locale(4) = 0 return boxBytes("data", [ 0x00, 0x00, 0x00, 0x01, // flags: text/UTF-8 0x00, 0x00, 0x00, 0x00, // locale ...textBytes, ]); } function itemBox(itemType: string, textContent: string): number[] { return boxBytes(itemType, textDataBox(textContent)); } function buildMinimalMp4WithIlst(ilstItems: number[]): Uint8Array { // ilst box const ilst = boxBytes("ilst", ilstItems); // meta box (needs 4-byte version+flags prefix before children) const metaData = [0x00, 0x00, 0x00, 0x00, ...ilst]; // version+flags + ilst const meta = boxBytes("meta", metaData); // udta box const udta = boxBytes("udta", meta); // moov box const moov = boxBytes("moov", udta); return new Uint8Array(moov); } describe("mp4-atom parser", () => { it("parses ©nam (title)", { timeout: 5000 }, () => { const buf = buildMinimalMp4WithIlst(itemBox("\u00a9nam", "Test Title")); const track = parseMp4(buf); expect(track.title).toBe("Test Title"); expect(track.format).toBe("mp4"); }); it("parses ©ART (artist)", { timeout: 5000 }, () => { const buf = buildMinimalMp4WithIlst( itemBox("\u00a9ART", "Test Artist") ); const track = parseMp4(buf); expect(track.artist).toBe("Test Artist"); }); it("parses ©alb (album)", { timeout: 5000 }, () => { const buf = buildMinimalMp4WithIlst(itemBox("\u00a9alb", "Test Album")); const track = parseMp4(buf); expect(track.album).toBe("Test Album"); }); it("parses aART (album artist)", { timeout: 5000 }, () => { const buf = buildMinimalMp4WithIlst(itemBox("aART", "Various Artists")); const track = parseMp4(buf); expect(track.albumArtist).toBe("Various Artists"); }); it("parses ©day (year)", { timeout: 5000 }, () => { const buf = buildMinimalMp4WithIlst(itemBox("\u00a9day", "2023-05-01")); const track = parseMp4(buf); expect(track.year).toBe(2023); }); it("parses ©cmt (comment)", { timeout: 5000 }, () => { const buf = buildMinimalMp4WithIlst(itemBox("\u00a9cmt", "A comment")); const track = parseMp4(buf); expect(track.comment).toBe("A comment"); }); it("parses ©gen (genre)", { timeout: 5000 }, () => { const buf = buildMinimalMp4WithIlst(itemBox("\u00a9gen", "Electronic")); const track = parseMp4(buf); expect(track.genre).toBe("Electronic"); }); it("parses trkn (track number)", { timeout: 5000 }, () => { // trkn data: 0x00 0x00 (padding) 0x00 0x03 (track 3) 0x00 0x0c (total 12) const trknData = boxBytes("data", [ 0x00, 0x00, 0x00, 0x00, // flags: implicit integer 0x00, 0x00, 0x00, 0x00, // locale 0x00, 0x00, // padding 0x00, 0x03, // track number: 3 0x00, 0x0c, // total: 12 ]); const trknBox = boxBytes("trkn", trknData); const buf = buildMinimalMp4WithIlst(trknBox); const track = parseMp4(buf); expect(track.trackNumber).toBe(3); expect(track.totalTracks).toBe(12); }); it("parses disk (disc number)", { timeout: 5000 }, () => { const diskData = boxBytes("data", [ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, // disc 2 0x00, 0x03, // of 3 ]); const diskBox = boxBytes("disk", diskData); const buf = buildMinimalMp4WithIlst(diskBox); const track = parseMp4(buf); expect(track.discNumber).toBe(2); expect(track.totalDiscs).toBe(3); }); it("returns empty object when no moov box", { timeout: 5000 }, () => { const buf = new Uint8Array([ 0x00, 0x00, 0x00, 0x10, 0x66, 0x72, 0x65, 0x65, ]); // "free" box expect(parseMp4(buf)).toEqual({}); }); it("returns empty object for short buffer", { timeout: 5000 }, () => { expect(parseMp4(new Uint8Array([0x00, 0x01, 0x02]))).toEqual({}); }); it("respects skipCovers option", { timeout: 5000 }, () => { // Build covr item with data box const covrData = boxBytes("data", [ 0x00, 0x00, 0x00, 0x0d, // flags: 0x0d = JPEG 0x00, 0x00, 0x00, 0x00, // locale 0xff, 0xd8, 0xff, // minimal JPEG bytes ]); const covrBox = boxBytes("covr", covrData); const buf = buildMinimalMp4WithIlst(covrBox); const withCover = parseMp4(buf, { skipCovers: false }); const noCover = parseMp4(buf, { skipCovers: true }); // Cover parsing may succeed or not depending on minimal bytes — just check skip works if (withCover.cover) { expect(noCover.cover).toBeUndefined(); } }); it("parses gnre (integer genre)", { timeout: 5000 }, () => { // gnre item box with an integer data box: index=18 (1-based) = "Rock" const gnreData = boxBytes("data", [ 0x00, 0x00, 0x00, 0x00, // flags: implicit integer 0x00, 0x00, 0x00, 0x00, // locale 0x00, 0x12, // index: 18 → "Rock" ]); const gnreBox = boxBytes("gnre", gnreData); const buf = buildMinimalMp4WithIlst(gnreBox); const track = parseMp4(buf); expect(track.genre).toBe("Rock"); }); it("ignores gnre with index 0 (invalid)", { timeout: 5000 }, () => { // idx=0 is not valid (gnre is 1-based), should not set genre const gnreData = boxBytes("data", [ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // index: 0 ]); const gnreBox = boxBytes("gnre", gnreData); const buf = buildMinimalMp4WithIlst(gnreBox); const track = parseMp4(buf); expect(track.genre).toBeUndefined(); }); it("parses ©lyr (lyrics)", { timeout: 5000 }, () => { const buf = buildMinimalMp4WithIlst( itemBox("\u00a9lyr", "Amazing lyrics") ); const track = parseMp4(buf); expect(track.lyrics).toBe("Amazing lyrics"); }); it("parses covr (PNG cover art)", { timeout: 5000 }, () => { // Build a covr data box with PNG flag (0x0e) and some bytes const imgBytes = [0x89, 0x50, 0x4e, 0x47]; // PNG magic bytes const covrData = boxBytes("data", [ 0x00, 0x00, 0x00, 0x0e, // flags: 0x0e = PNG 0x00, 0x00, 0x00, 0x00, // locale ...imgBytes, ]); const covrBox = boxBytes("covr", covrData); const buf = buildMinimalMp4WithIlst(covrBox); const track = parseMp4(buf, { skipCovers: false }); expect(track.cover).toBeDefined(); expect(track.cover?.mimeType).toBe("image/png"); }); it("skips data box with dataSize <= 8 in readItmData", { timeout: 5000, }, () => { // A ©nam item with a data box that has only 8 bytes (the header) — too small for content // This triggers `if (child.dataSize > 8)` FALSE branch → returns null → readItmText returns "" const tinyDataBox = boxBytes("data", [ 0x00, 0x00, 0x00, 0x01, // flags 0x00, 0x00, 0x00, 0x00, // locale (exactly 8 bytes total data content) ]); // Note: tinyDataBox has 8 bytes of data → dataSize = 8, NOT > 8 → returns null const itemWithTinyData = boxBytes("\u00a9nam", tinyDataBox); const buf = buildMinimalMp4WithIlst(itemWithTinyData); const track = parseMp4(buf); expect(track.title).toBeUndefined(); }); it("handles iterBoxes with zero-size box (breaks)", { timeout: 5000, }, () => { // A moov box containing a zero-size box (size=0 means extends to EOF, treated as terminal) // iterBoxes breaks when size === 0 const zeroSizeBox = [0x00, 0x00, 0x00, 0x00, 0x74, 0x65, 0x73, 0x74]; // size=0, type="test" const moovData = [...zeroSizeBox]; const moovSize = 8 + moovData.length; const buf = new Uint8Array([ ...uint32BE(moovSize), 0x6d, 0x6f, 0x6f, 0x76, // moov ...moovData, ]); const track = parseMp4(buf); // No ilst found → returns { format: "mp4" } expect(track.format).toBe("mp4"); }); it("handles iterBoxes with corrupted small-size box (size < 8, breaks)", { timeout: 5000, }, () => { // A moov containing a box with size=4 (too small to be valid) const corruptBox = [0x00, 0x00, 0x00, 0x04, 0x74, 0x65, 0x73, 0x74]; // size=4 const moovData = [...corruptBox]; const moovSize = 8 + moovData.length; const buf = new Uint8Array([ ...uint32BE(moovSize), 0x6d, 0x6f, 0x6f, 0x76, ...moovData, ]); const track = parseMp4(buf); expect(track.format).toBe("mp4"); }); it("skips 64-bit extended-size box in iterBoxes", { timeout: 5000 }, () => { // A moov with a box that has size=1 (64-bit extended size indicator) // iterBoxes skips it and advances by 16 const extendedBox = [ 0x00, 0x00, 0x00, 0x01, // size=1 (64-bit indicator) 0x74, 0x65, 0x73, 0x74, // type="test" 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, // 64-bit size=16 ]; // 16 bytes total, skipped const moovData = [...extendedBox]; const moovSize = 8 + moovData.length; const buf = new Uint8Array([ ...uint32BE(moovSize), 0x6d, 0x6f, 0x6f, 0x76, ...moovData, ]); const track = parseMp4(buf); expect(track.format).toBe("mp4"); }); it("returns format mp4 with no ilst", { timeout: 5000 }, () => { // A minimal moov with no meta/ilst — parseMp4 should return { format: "mp4" } const moovData: number[] = []; const moovSize = 8 + moovData.length; const uint32BE2 = (n: number) => [ (n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff, ]; const buf = new Uint8Array([ ...uint32BE2(moovSize), 0x6d, 0x6f, 0x6f, 0x76, ...moovData, ]); const track = parseMp4(buf); expect(track.format).toBe("mp4"); }); it("returns empty string from readItmText when data box missing", { timeout: 5000, }, () => { // A ©nam item box with NO data child box — readItmText should return "" // and therefore title should remain undefined const emptyItemBox = boxBytes("\u00a9nam", []); // no data child const buf = buildMinimalMp4WithIlst(emptyItemBox); const track = parseMp4(buf); expect(track.title).toBeUndefined(); expect(track.format).toBe("mp4"); }); it("handles trkn item with data box too small for readTrackOrDisc", { timeout: 5000, }, () => { // readTrackOrDisc needs data.length >= 6; < 6 returns {num:0, total:0} // (num=0 → not set, total=0 → not set) const tinyTrackData = boxBytes("data", [ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // only 2 bytes after header (< 6 needed for num+total) ]); const trknBox = boxBytes("trkn", tinyTrackData); const buf = buildMinimalMp4WithIlst(trknBox); const track = parseMp4(buf); // num=0 means trackNumber not set expect(track.trackNumber).toBeUndefined(); }); it("handles covr with data box dataSize <= 8 (continues, returns null)", { timeout: 5000, }, () => { // parseCovr: if child.dataSize <= 8, continue — no cover returned const tinyCovrData = boxBytes( "data", [ 0x00, 0x00, 0x00, 0x0d, 0x00, 0x00, 0x00, 0x00, // only 8 bytes → dataSize = 8, NOT > 8 → continue ] ); const covrBox = boxBytes("covr", tinyCovrData); const buf = buildMinimalMp4WithIlst(covrBox); const track = parseMp4(buf, { skipCovers: false }); expect(track.cover).toBeUndefined(); }); it("returns null from parseCovr when no data child box found", { timeout: 5000, }, () => { // covr item with no data box — parseCovr returns null → cover undefined const covrBoxNoData = boxBytes("covr", []); // no data child const buf = buildMinimalMp4WithIlst(covrBoxNoData); const track = parseMp4(buf, { skipCovers: false }); expect(track.cover).toBeUndefined(); }); it("parseCovr skips non-data child box type before reaching data child", { timeout: 5000, }, () => { // covr item with a non-"data" typed child first (triggers `if (child.type !== "data") continue`) // then a valid data child const nonDataChild = boxBytes("free", [0x00, 0x00]); // type "free" not "data" const imgBytes = [0xff, 0xd8, 0xff]; // minimal JPEG const dataChild = boxBytes("data", [ 0x00, 0x00, 0x00, 0x0d, // flags: JPEG 0x00, 0x00, 0x00, 0x00, // locale ...imgBytes, ]); const covrBox = boxBytes("covr", [...nonDataChild, ...dataChild]); const buf = buildMinimalMp4WithIlst(covrBox); const track = parseMp4(buf, { skipCovers: false }); // After skipping non-data child, data child is found → cover set expect(track.cover).toBeDefined(); }); it("readItmData skips non-data children (child.type !== data else path)", { timeout: 5000, }, () => { // A ©nam item with a non-"data" child — readItmData returns null → title undefined const nonDataChild = boxBytes("name", [0x41, 0x42]); // type "name" not "data" const itemBox = boxBytes("\u00a9nam", nonDataChild); const buf = buildMinimalMp4WithIlst(itemBox); const track = parseMp4(buf); expect(track.title).toBeUndefined(); }); it("finds ilst via moov/meta fallback path (no udta)", { timeout: 5000, }, () => { // Build moov/meta/ilst directly (skip udta) — exercises the fallback path const ilst = boxBytes("ilst", itemBox("\u00a9nam", "FallbackTitle")); const metaData = [0x00, 0x00, 0x00, 0x00, ...ilst]; // version+flags + ilst const meta = boxBytes("meta", metaData); const moov = boxBytes("moov", meta); // no udta — meta is directly inside moov const buf = new Uint8Array(moov); const track = parseMp4(buf); expect(track.title).toBe("FallbackTitle"); expect(track.format).toBe("mp4"); }); it("handles udta with no meta inside (metaInUdta is null)", { timeout: 5000, }, () => { // moov/udta with no meta inside — metaInUdta=null → else path of if(metaInUdta) const udta = boxBytes("udta", []); // udta with no meta child const moov = boxBytes("moov", udta); const buf = new Uint8Array(moov); const track = parseMp4(buf); // No meta anywhere → format: "mp4" (no ilst found) expect(track.format).toBe("mp4"); }); it("handles ©nam item that resolves to empty string (val falsy — else path)", { timeout: 5000, }, () => { // A data box with only null bytes — readItmText returns "" → val is falsy const emptyDataBox = boxBytes("data", [ 0x00, 0x00, 0x00, 0x01, // flags: text 0x00, 0x00, 0x00, 0x00, // locale 0x00, // null byte content → trims to "" ]); const emptyItem = boxBytes("\u00a9nam", emptyDataBox); const buf = buildMinimalMp4WithIlst(emptyItem); const track = parseMp4(buf); expect(track.title).toBeUndefined(); }); it("handles ©ART, ©alb, aART items resolving to empty (val falsy)", { timeout: 5000, }, () => { const emptyDataBox = boxBytes( "data", [0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00] ); const items = [ ...boxBytes("\u00a9ART", emptyDataBox), ...boxBytes("\u00a9alb", emptyDataBox), ...boxBytes("aART", emptyDataBox), ]; const buf = buildMinimalMp4WithIlst(items); const track = parseMp4(buf); expect(track.artist).toBeUndefined(); expect(track.album).toBeUndefined(); expect(track.albumArtist).toBeUndefined(); }); it("handles ©day with non-numeric year (NaN → year not set)", { timeout: 5000, }, () => { const buf = buildMinimalMp4WithIlst( itemBox("\u00a9day", "unknwn-01-01") ); const track = parseMp4(buf); expect(track.year).toBeUndefined(); }); it("handles ©day item with empty text (val falsy — else path)", { timeout: 5000, }, () => { const emptyDataBox = boxBytes( "data", [0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00] ); const buf = buildMinimalMp4WithIlst( boxBytes("\u00a9day", emptyDataBox) ); const track = parseMp4(buf); expect(track.year).toBeUndefined(); }); it("handles disk with num=0 and total=0 (else paths of discNumber/totalDiscs)", { timeout: 5000, }, () => { // disk data with 0x00 for both num and total const diskData = boxBytes("data", [ 0x00, 0x00, 0x00, 0x00, // flags 0x00, 0x00, 0x00, 0x00, // locale 0x00, 0x00, // padding 0x00, 0x00, // num = 0 0x00, 0x00, // total = 0 ]); const diskBox = boxBytes("disk", diskData); const buf = buildMinimalMp4WithIlst(diskBox); const track = parseMp4(buf); // num=0 → discNumber not set; total=0 → totalDiscs not set expect(track.discNumber).toBeUndefined(); expect(track.totalDiscs).toBeUndefined(); }); it("handles gnre with index out of range (idx > GENRES.length — else path)", { timeout: 5000, }, () => { // idx = 999 > GENRES.length (20) → if(genreName) is FALSE const gnreData = boxBytes("data", [ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xe7, // index: 999 ]); const gnreBox = boxBytes("gnre", gnreData); const buf = buildMinimalMp4WithIlst(gnreBox); const track = parseMp4(buf); expect(track.genre).toBeUndefined(); }); it("handles gnre where readItmData returns null (data && data.length >= 2 is false)", { timeout: 5000, }, () => { // gnre item with no data child → readItmData returns null → `if (data && ...)` FALSE const gnreBoxNoData = boxBytes("gnre", []); // no data child const buf = buildMinimalMp4WithIlst(gnreBoxNoData); const track = parseMp4(buf); expect(track.genre).toBeUndefined(); }); it("handles ©gen, ©cmt, ©lyr items resolving to empty (val falsy)", { timeout: 5000, }, () => { const emptyDataBox = boxBytes( "data", [0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00] ); const items = [ ...boxBytes("\u00a9gen", emptyDataBox), ...boxBytes("\u00a9cmt", emptyDataBox), ...boxBytes("\u00a9lyr", emptyDataBox), ]; const buf = buildMinimalMp4WithIlst(items); const track = parseMp4(buf); expect(track.genre).toBeUndefined(); expect(track.comment).toBeUndefined(); expect(track.lyrics).toBeUndefined(); }); it("handles covr with oversized image (exceeds maxCoverSize)", { timeout: 5000, }, () => { // maxCoverSize = 4, but img has 4 bytes — exactly at limit, not over // To exceed: make img 5 bytes with maxCoverSize 4 const imgBytes = [0xff, 0xd8, 0xff, 0xe0, 0x00]; // 5 bytes const covrData = boxBytes("data", [ 0x00, 0x00, 0x00, 0x0d, // JPEG flag 0x00, 0x00, 0x00, 0x00, ...imgBytes, ]); const covrBox = boxBytes("covr", covrData); const buf = buildMinimalMp4WithIlst(covrBox); const track = parseMp4(buf, { skipCovers: false, maxCoverSize: 4 }); expect(track.cover).toBeUndefined(); }); });