WIP. A little custom music server
0
fork

Configure Feed

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

feat: validate m4a signature

+77 -39
+1 -29
backend/src/flac/service.ts
··· 4 4 import { FlacError } from "./errors"; 5 5 import { MetadataWithFilepathSchema } from "~/metadata"; 6 6 import { BunFileSystem } from "@effect/platform-bun"; 7 + import { readBytes } from "~/lib"; 7 8 8 9 const VORBIS_COMMENT = 4; 9 10 const MAX_METADATA_BLOCKS = 128; ··· 72 73 } as const; 73 74 }), 74 75 }) {} 75 - // Helper: Read specific bytes from file handle 76 - const readBytes = Effect.fn("readBytes")(function* ( 77 - file: FileSystem.File, 78 - offset: number, 79 - length: number, 80 - path: string, 81 - ) { 82 - const buffer = new Uint8Array(length); 83 - // Seek to the desired offset 84 - yield* file.seek(offset, "start"); 85 - // Read the data 86 - const bytesRead = yield* file.read(buffer).pipe( 87 - Effect.mapError( 88 - (e) => 89 - new FlacError({ 90 - cause: e, 91 - message: `Failed to read ${length} bytes at offset ${offset} from ${path}`, 92 - }), 93 - ), 94 - ); 95 - if (bytesRead < length) { 96 - return yield* Effect.fail( 97 - new FlacError({ 98 - message: `Unexpected end of file: expected ${length} bytes, got ${bytesRead} at offset ${offset} in ${path}`, 99 - }), 100 - ); 101 - } 102 - return buffer; 103 - }); 104 76 // Helper: Parse header from bytes 105 77 const parseHeader = Effect.fn("parseHeader")(function* (headerBytes: Uint8Array, offset: number, path: string) { 106 78 return yield* Schema.decode(FlacHeaderFromUint8Array)({
+33
backend/src/lib.ts
··· 1 + import { Data, Effect } from "effect"; 2 + import { FileSystem } from "@effect/platform"; 3 + 4 + export class ReadBytesError extends Data.TaggedError("ReadBytesError")<{ message: string; cause?: unknown }> {} 5 + 6 + export const readBytes = Effect.fn("read-bytes")(function* ( 7 + file: FileSystem.File, 8 + offset: number, 9 + length: number, 10 + path: string, 11 + ) { 12 + const buffer = new Uint8Array(length); 13 + // Seek to the desired offset 14 + yield* file.seek(offset, "start"); 15 + // Read the data 16 + const bytesRead = yield* file.read(buffer).pipe( 17 + Effect.mapError( 18 + (e) => 19 + new ReadBytesError({ 20 + cause: e, 21 + message: `Failed to read ${length} bytes at offset ${offset} from ${path}`, 22 + }), 23 + ), 24 + ); 25 + if (bytesRead < length) { 26 + return yield* Effect.fail( 27 + new ReadBytesError({ 28 + message: `Unexpected end of file: expected ${length} bytes, got ${bytesRead} at offset ${offset} in ${path}`, 29 + }), 30 + ); 31 + } 32 + return yield* Effect.succeed(buffer); 33 + });
+9 -2
backend/src/m4a/service.test.ts
··· 7 7 8 8 // FIXME: Vitest is breaking right now for some reason. This is a workaround 9 9 const test = <A, E>(label: string, effect: () => Effect.Effect<A, E, never>) => { 10 - it(label, () => Effect.runSync(effect())); 10 + it(label, () => Effect.runPromise(effect())); 11 11 }; 12 12 13 13 ··· 19 19 expect(Exit.isFailure(actual)).toBe(true); 20 20 }).pipe(Effect.provide(layers))); 21 21 22 + test("readMetadata should NOT throw an error on a valid m4a file", () =>Effect.gen(function* () { 23 + const m4aService = yield* M4aService; 24 + const input = "/Users/johnb/boombox-test-data/15 Downtown.m4a"; 25 + const actual = yield* Effect.exit(m4aService.readMetadata(input)); 26 + expect(Exit.isSuccess(actual)).toBe(true); 27 + }).pipe(Effect.provide(layers))); 28 + 22 29 test("readMetadata should return album, artist and title", () => 23 30 Effect.gen(function* () { 24 31 const m4aService = yield* M4aService; 25 - const input = "/Users/johnb/boombox-test-data/'15 Downtown.m4a'"; 32 + const input = "/Users/johnb/boombox-test-data/15 Downtown.m4a"; 26 33 const actual = yield* m4aService.readMetadata(input); 27 34 expect(actual.album).toBe("Torches X"); 28 35 expect(actual.artists).toContain("Foster The People");
+34 -8
backend/src/m4a/service.ts
··· 3 3 import { BunFileSystem } from "@effect/platform-bun"; 4 4 import type { MetadataWithFilepathSchema } from "~/metadata"; 5 5 import { M4aUnsupportedFileError } from "./errors"; 6 + import { readBytes } from "~/lib"; 6 7 8 + const CHUNK_HEADER_SIZE = 8; // 8 bytes for the chunk header 9 + const HEADER_CHUNK_SIZE = 4; // 4 bytes of chunk size 10 + const HEADER_TYPE_SIZE = 4; // 4 bytes for the header type 7 11 8 12 export class M4aService extends Effect.Service<M4aService>()("@boombox/backend/m4a/service/M4aService", { 9 13 dependencies: [BunFileSystem.layer], 10 14 effect: Effect.gen(function* () { 11 15 const fs = yield* FileSystem.FileSystem; 12 16 17 + const utf = new TextDecoder("utf-8"); 13 18 14 - const checkSignature = Effect.fn("m4a-checkSignature")(function* (path: string) { 19 + const readHeader = Effect.fn("m4a-readHeader")(function* (file: FileSystem.File, offset: number, path: string) { 20 + const header = yield* readBytes(file, offset, CHUNK_HEADER_SIZE, path); 21 + const chunkSize = new DataView(header.buffer).getUint32(0); 22 + const chunkType = utf.decode(header.slice(HEADER_CHUNK_SIZE, CHUNK_HEADER_SIZE)); 23 + return { chunkSize, chunkType }; 24 + }); 15 25 16 - yield* M4aUnsupportedFileError.make({ message: "Invalid signature"}); 17 - }); 26 + const checkSignature = Effect.fn("m4a-checkSignature")(function* (file: FileSystem.File, path: string) { 27 + const { chunkSize, chunkType } = yield* readHeader(file, 0, path); 18 28 19 - const readMetadata = Effect.fn("m4a-readMetadata")(function* (path: string) { 20 - yield* checkSignature(path); 21 - return yield* Effect.succeed({ 22 - } as Partial<typeof MetadataWithFilepathSchema.Type>); 29 + if (chunkType !== "ftyp") { 30 + yield* M4aUnsupportedFileError.make({ message: "Invalid signature" }); 31 + } 32 + 33 + const ftypData = yield* readBytes(file, CHUNK_HEADER_SIZE, chunkSize - CHUNK_HEADER_SIZE, path); 34 + const text = utf.decode(ftypData); 35 + 36 + if (text.startsWith("M4A ") === false) { 37 + yield* M4aUnsupportedFileError.make({ message: "Invalid signature" }); 38 + } 23 39 }); 24 40 41 + const readMetadata = Effect.fn("m4a-readMetadata")( 42 + function* (path: string) { 43 + const file = yield* fs.open(path, { flag: "r" }); 44 + yield* checkSignature(file, path); 45 + 46 + return yield* Effect.succeed({} as Partial<typeof MetadataWithFilepathSchema.Type>); 47 + }, 48 + (effect) => Effect.scoped(effect), 49 + ); 50 + 25 51 return { 26 52 readMetadata, 27 - } 53 + }; 28 54 }), 29 55 }) {}