WIP. A little custom music server
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: scan file for ilst

+143 -6
+125 -6
backend/src/m4a/service.ts
··· 1 - import { Effect } from "effect"; 1 + import { Effect, Schema } from "effect"; 2 2 import { FileSystem } from "@effect/platform"; 3 3 import { BunFileSystem } from "@effect/platform-bun"; 4 4 import type { MetadataWithFilepathSchema } from "~/metadata"; 5 5 import { M4aUnsupportedFileError } from "./errors"; 6 6 import { readBytes } from "~/lib"; 7 + import { ParseIListBuffer } from "./transformers"; 7 8 8 9 const CHUNK_HEADER_SIZE = 8; // 8 bytes for the chunk header 9 10 const HEADER_CHUNK_SIZE = 4; // 4 bytes of chunk size 10 11 const HEADER_TYPE_SIZE = 4; // 4 bytes for the header type 12 + 13 + const CHUNK_TYPES = [ 14 + "ftyp", 15 + "mdat", 16 + "moov", 17 + "pnot", 18 + "udta", 19 + "uuid", 20 + "moof", 21 + "free", 22 + "skip", 23 + "jP2 ", 24 + "wide", 25 + "load", 26 + "ctab", 27 + "imap", 28 + "matt", 29 + "kmat", 30 + "clip", 31 + "crgn", 32 + "sync", 33 + "chap", 34 + "tmcd", 35 + "scpt", 36 + "ssrc", 37 + "PICT", 38 + ] as const; 39 + type ChunkType = (typeof CHUNK_TYPES)[number] | (string & {}); 40 + 41 + const SANE_NUMBER_OF_CHUNK_BLOCKS = 200; 11 42 12 43 export class M4aService extends Effect.Service<M4aService>()("@boombox/backend/m4a/service/M4aService", { 13 44 dependencies: [BunFileSystem.layer], ··· 20 51 const header = yield* readBytes(file, offset, CHUNK_HEADER_SIZE, path); 21 52 const chunkSize = new DataView(header.buffer).getUint32(0); 22 53 const chunkType = utf.decode(header.slice(HEADER_CHUNK_SIZE, CHUNK_HEADER_SIZE)); 23 - return { chunkSize, chunkType }; 54 + return { chunkSize, chunkType: chunkType as ChunkType }; 24 55 }); 25 56 26 57 const checkSignature = Effect.fn("m4a-checkSignature")(function* (file: FileSystem.File, path: string) { 27 58 const { chunkSize, chunkType } = yield* readHeader(file, 0, path); 28 59 29 60 if (chunkType !== "ftyp") { 30 - yield* M4aUnsupportedFileError.make({ message: "Invalid signature" }); 61 + return yield* M4aUnsupportedFileError.make({ message: "Invalid signature" }); 31 62 } 32 63 33 64 const ftypData = yield* readBytes(file, CHUNK_HEADER_SIZE, chunkSize - CHUNK_HEADER_SIZE, path); 34 65 const text = utf.decode(ftypData); 35 66 36 67 if (text.startsWith("M4A ") === false) { 37 - yield* M4aUnsupportedFileError.make({ message: "Invalid signature" }); 68 + return yield* M4aUnsupportedFileError.make({ message: "Invalid signature" }); 69 + } 70 + 71 + return chunkSize; 72 + }); 73 + 74 + // if we are in a root node i wanna be able to look for moov node and then look inside of it for other nested notes. 75 + // start and end are to determing ranges of this 76 + const findBlock = Effect.fn("m4a-find-block")(function* ({ 77 + file, 78 + start, 79 + end, 80 + search, 81 + path, 82 + }: { 83 + file: FileSystem.File; 84 + start: number; 85 + end: number; 86 + search: ChunkType; 87 + path: string; 88 + }) { 89 + if (start > end) { 90 + return yield* Effect.fail(new Error("Start is greater than end")); 91 + } 92 + 93 + if (start < 0 || end < 0) { 94 + return yield* Effect.fail(new Error("Start or end is less than 0")); 95 + } 96 + 97 + let offset = start; 98 + let currentHeader = yield* readHeader(file, offset, path); // reads 8 bytes 99 + let iterations = 0; 100 + while (currentHeader.chunkType !== search) { 101 + offset += currentHeader.chunkSize; 102 + if (offset > end) { 103 + return yield* Effect.fail(new Error("Offset is greater than end")); 104 + } 105 + 106 + if (iterations > SANE_NUMBER_OF_CHUNK_BLOCKS) { 107 + return yield* Effect.fail(new Error("Too many chunk blocks")); 108 + } 109 + iterations++; 110 + currentHeader = yield* readHeader(file, offset, path); 38 111 } 112 + 113 + return yield* Effect.succeed({ offset: offset + CHUNK_HEADER_SIZE, currentHeader }); 39 114 }); 40 115 41 116 const readMetadata = Effect.fn("m4a-readMetadata")( 42 117 function* (path: string) { 43 118 const file = yield* fs.open(path, { flag: "r" }); 44 - yield* checkSignature(file, path); 119 + let offset = 0; 120 + 121 + offset = yield* checkSignature(file, path); 122 + 123 + const moov = yield* findBlock({ 124 + file, 125 + start: offset, 126 + end: 500000000000, 127 + search: "moov", 128 + path, 129 + }); 130 + offset = moov.offset; 131 + 132 + const udta = yield* findBlock({ 133 + file, 134 + start: offset, 135 + end: offset + moov.currentHeader.chunkSize, 136 + search: "udta", 137 + path, 138 + }); 139 + 140 + offset = udta.offset; 141 + const meta = yield* findBlock({ 142 + file, 143 + start: offset, 144 + end: offset + udta.currentHeader.chunkSize, 145 + search: "meta", 146 + path, 147 + }); 148 + 149 + offset = meta.offset + 4; // <--- meta is a 'full box' (meaning it has extra 4 bytes for version) 150 + 151 + const ilst = yield* findBlock({ 152 + file, 153 + start: offset, 154 + end: offset + meta.currentHeader.chunkSize, 155 + search: "ilst", 156 + path, 157 + }); 158 + 159 + offset = ilst.offset; 160 + 161 + const ilistBuffer = yield* readBytes(file, ilst.offset, ilst.currentHeader.chunkSize, path); 162 + 163 + yield* Effect.log({ moov, udta, meta, ilst, ilistBuffer }); 45 164 46 165 return yield* Effect.succeed({} as Partial<typeof MetadataWithFilepathSchema.Type>); 47 166 }, 48 - (effect) => Effect.scoped(effect), 167 + (effect) => Effect.scoped(effect) 49 168 ); 50 169 51 170 return {
+18
backend/src/m4a/transformers.ts
··· 1 + import { Effect, Schema } from "effect"; 2 + import { ParseResult } from "effect"; 3 + 4 + const IListBufferInput = Schema.Struct({ 5 + uint8Array: Schema.Uint8ArrayFromSelf, 6 + offset: Schema.Number, 7 + length: Schema.Number, 8 + }); 9 + 10 + export const ParseIListBuffer = Schema.transformOrFail(IListBufferInput, Schema.Any, { 11 + strict: true, 12 + decode({ uint8Array, offset, length }, _, ast) { 13 + return ParseResult.succeed(uint8Array); 14 + }, 15 + encode(x, _, ast) { 16 + return ParseResult.fail(new ParseResult.Type(ast, x, "Unimplemented")); 17 + }, 18 + });