WIP. A little custom music server
0
fork

Configure Feed

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

feat: successfully parse m4a title, albumArtist, artists, track number and album

+123 -13
+3 -1
backend/src/m4a/service.test.ts
··· 42 42 const m4aService = yield* M4aService; 43 43 const input = m4aFilePath; 44 44 const actual = yield* m4aService.readMetadata(input); 45 + 46 + yield* Effect.log(actual); 45 47 expect(actual.album).toBe("Torches X"); 46 - expect(actual.artists).toContain("Foster The People"); 48 + expect(actual.artists).toContain("Foster the People"); 47 49 expect(actual.title).toBe("Downtown"); 48 50 expect(actual.trackNumber).toBe(15); 49 51 }).pipe(Effect.provide(layers)));
+21 -6
backend/src/m4a/service.ts
··· 1 - import { Effect, Schema } from "effect"; 1 + import { Effect, ParseResult, Schema } from "effect"; 2 2 import { FileSystem } from "@effect/platform"; 3 3 import { BunFileSystem } from "@effect/platform-bun"; 4 - import type { MetadataWithFilepathSchema } from "~/metadata"; 4 + import { MetadataWithFilepathSchema, MetadataSchema } from "~/metadata"; 5 5 import { M4aUnsupportedFileError } from "./errors"; 6 6 import { readBytes } from "~/lib"; 7 - import { ParseIListBuffer } from "./transformers"; 7 + import { MetadataFromIListBuffer } from "./transformers"; 8 8 9 9 const CHUNK_HEADER_SIZE = 8; // 8 bytes for the chunk header 10 10 const HEADER_CHUNK_SIZE = 4; // 4 bytes of chunk size ··· 158 158 159 159 offset = ilst.offset; 160 160 161 - const ilistBuffer = yield* readBytes(file, ilst.offset, ilst.currentHeader.chunkSize, path); 161 + const ilistContentLength = ilst.currentHeader.chunkSize - CHUNK_HEADER_SIZE; 162 + const ilistBuffer = yield* readBytes(file, ilst.offset, ilistContentLength, path); 163 + 164 + const result = yield* Schema.decode(MetadataFromIListBuffer)({ 165 + uint8Array: ilistBuffer, 166 + length: ilistContentLength, 167 + }); 162 168 163 - yield* Effect.log({ moov, udta, meta, ilst, ilistBuffer }); 169 + const transformer = Schema.transformOrFail(MetadataSchema, MetadataWithFilepathSchema, { 170 + strict: true, 171 + decode: (input, _, ast) => 172 + ParseResult.succeed({ 173 + ...input, 174 + filePath: path, 175 + }), 176 + encode: (x, _, ast) => ParseResult.fail(new ParseResult.Type(ast, x, "Unimplemented")), 177 + }); 178 + const decoder = Schema.decodeUnknown(transformer); 164 179 165 - return yield* Effect.succeed({} as Partial<typeof MetadataWithFilepathSchema.Type>); 180 + return yield* decoder(result); 166 181 }, 167 182 (effect) => Effect.scoped(effect) 168 183 );
+99 -6
backend/src/m4a/transformers.ts
··· 1 - import { Effect, Schema } from "effect"; 2 - import { ParseResult } from "effect"; 1 + import { Effect, Option, ParseResult, Schema } from "effect"; 2 + import { type Metadata, MetadataSchema } from "~/metadata"; 3 3 4 4 const IListBufferInput = Schema.Struct({ 5 5 uint8Array: Schema.Uint8ArrayFromSelf, 6 - offset: Schema.Number, 7 6 length: Schema.Number, 8 7 }); 9 8 10 - export const ParseIListBuffer = Schema.transformOrFail(IListBufferInput, Schema.Any, { 9 + const utf = new TextDecoder("utf-8"); 10 + const latin1 = new TextDecoder("latin1"); 11 + 12 + const MAX_ATOMS = 200; 13 + 14 + export const MetadataFromIListBuffer = Schema.transformOrFail(IListBufferInput, MetadataSchema, { 11 15 strict: true, 12 - decode({ uint8Array, offset, length }, _, ast) { 13 - return ParseResult.succeed(uint8Array); 16 + decode({ uint8Array, length }, _, ast) { 17 + return Effect.gen(function* () { 18 + const dv = new DataView(uint8Array.buffer, uint8Array.byteOffset, length); 19 + let cursor = 0; 20 + 21 + const metadata: Partial<Metadata & { artists: string[] }> = { 22 + artists: [], 23 + }; 24 + 25 + let iterations = 0; 26 + 27 + while (cursor < length) { 28 + if (iterations++ > MAX_ATOMS) { 29 + return yield* ParseResult.fail(new ParseResult.Type(ast, uint8Array, "Too many atoms")); 30 + } 31 + 32 + // Need at least 8 bytes for atom header 33 + if (cursor + 8 > length) { 34 + break; 35 + } 36 + 37 + const atomSize = dv.getUint32(cursor); 38 + if (atomSize < 8 || cursor + atomSize > length) { 39 + break; 40 + } 41 + 42 + const atomName = latin1.decode(uint8Array.slice(cursor + 4, cursor + 8)); 43 + 44 + // Inside each tag atom, find the "data" child 45 + // data atom structure: 4 bytes size, 4 bytes "data", 4 bytes type, 4 bytes locale, then value 46 + const dataOffset = cursor + 8; // skip tag header 47 + 48 + if (dataOffset + 16 > length) { 49 + cursor += atomSize; 50 + continue; 51 + } 52 + 53 + const dataSize = dv.getUint32(dataOffset); 54 + const _dataType = dv.getUint32(dataOffset + 8); // type indicator: 1=UTF-8, 13=JPEG, 14=PNG, 0=int 55 + 56 + const valueStart = dataOffset + 16; // skip data header (8) + type (4) + locale (4) 57 + const valueLength = dataSize - 16; 58 + 59 + if (valueLength <= 0 || valueStart + valueLength > length) { 60 + cursor += atomSize; 61 + continue; 62 + } 63 + 64 + const valueBytes = uint8Array.slice(valueStart, valueStart + valueLength); 65 + 66 + switch (atomName) { 67 + case "©nam": 68 + metadata.title = utf.decode(valueBytes); 69 + break; 70 + case "©ART": 71 + metadata.artists!.push(utf.decode(valueBytes)); 72 + break; 73 + case "aART": 74 + metadata.albumArtist = utf.decode(valueBytes); 75 + break; 76 + case "©alb": 77 + metadata.album = utf.decode(valueBytes); 78 + break; 79 + case "trkn": { 80 + // binary: 2 bytes padding, 2 bytes track, 2 bytes total, 2 bytes padding 81 + if (valueBytes.length >= 4) { 82 + const trackView = new DataView( 83 + valueBytes.buffer, 84 + valueBytes.byteOffset, 85 + valueBytes.byteLength 86 + ); 87 + metadata.trackNumber = trackView.getUint16(2); 88 + } 89 + break; 90 + } 91 + // MusicBrainz tags stored as ---- atoms with mean/name 92 + // For now, skip these - they require special handling 93 + } 94 + 95 + cursor += atomSize; 96 + } 97 + 98 + metadata.albumArtist ??= metadata.artists?.at(0); 99 + 100 + const valid = Schema.decodeUnknownOption(MetadataSchema)(metadata); 101 + 102 + return yield* Option.match(valid, { 103 + onSome: (value) => ParseResult.succeed(value), 104 + onNone: () => ParseResult.fail(new ParseResult.Type(ast, metadata, "Metadata is not valid")), 105 + }); 106 + }); 14 107 }, 15 108 encode(x, _, ast) { 16 109 return ParseResult.fail(new ParseResult.Type(ast, x, "Unimplemented"));