WIP. A little custom music server
0
fork

Configure Feed

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

polish flac parsting logic

+185 -299
+2 -7
backend/src/file-parser.ts
··· 47 47 48 48 if (assumedType === "flac") { 49 49 const flacService = yield* FlacService; 50 - 51 - const isFlac = yield* flacService.isFlac(path); 52 - if (isFlac) { 53 - const metadata = yield* flacService.readMetadata(path); 54 - 55 - return metadata; 56 - } 50 + const metadata = yield* flacService.readMetadata(path); 51 + return metadata; 57 52 } 58 53 return yield* Effect.fail(new UnsupportedFileError({ message: "File is unsupported" })); 59 54 });
-241
backend/src/flac.ts
··· 1 - import { FileSystem } from "@effect/platform"; 2 - import { Console, Data, Effect, Option, ParseResult, Schema } from "effect"; 3 - 4 - import { type Metadata, MetadataSchema, MetadataWithFilepathSchema } from "./metadata"; 5 - import { Bit, Int24, safeParseInt, Uint7 } from "./utils/utils"; 6 - 7 - const VORBIS_STREAMINFO = 4; 8 - 9 - const FlacHeader = Schema.Struct({ 10 - isLast: Bit, // 1 bit 11 - streamInfo: Uint7, // 7 bits 12 - length: Int24, // 3 bytes 13 - }); 14 - 15 - // Error class 16 - export class FlacError extends Data.TaggedError("FlacError")<{ message: string; cause?: unknown }> {} 17 - 18 - // Schema Transformers 19 - const FlacHeaderFromUint8Array = Schema.transformOrFail( 20 - Schema.Struct({ 21 - uint8Array: Schema.Uint8ArrayFromSelf, 22 - offset: Schema.Number, 23 - }), 24 - FlacHeader, 25 - { 26 - strict: true, 27 - decode({ uint8Array, offset }, _, ast) { 28 - return Effect.gen(function* () { 29 - if (uint8Array.length < 4) { 30 - return yield* ParseResult.fail(new ParseResult.Type(ast, uint8Array, "UIntArray is too short")); 31 - } 32 - 33 - const dataView = new DataView(uint8Array.buffer, offset, 4); 34 - 35 - const header = { 36 - isLast: ((dataView.getUint8(0) & 0x80) === 0x80 ? 1 : 0) as Bit, // 1 bit 37 - streamInfo: dataView.getUint8(0) & 0x7f, // 7 bits 38 - length: dataView.getUint32(0, false) & 0xffffff, // 3 byte 39 - }; 40 - 41 - return yield* ParseResult.succeed(header); 42 - }); 43 - }, 44 - encode(x, _, ast) { 45 - return ParseResult.fail(new ParseResult.Type(ast, x, "Unimplemented")); 46 - }, 47 - }, 48 - ); 49 - 50 - const MetadataFromUint8Array = Schema.transformOrFail( 51 - Schema.Struct({ 52 - uint8Array: Schema.Uint8ArrayFromSelf, 53 - offset: Schema.Number, 54 - length: Schema.Number, 55 - }), 56 - MetadataSchema, 57 - { 58 - strict: true, 59 - decode({ uint8Array, offset, length }, _, ast) { 60 - return Effect.gen(function* () { 61 - const dv = new DataView(uint8Array.buffer, offset, length); 62 - 63 - let cursor = 0; 64 - 65 - const vendorStringLength = dv.getUint32(cursor, true); 66 - cursor += 4; 67 - 68 - cursor += vendorStringLength; 69 - 70 - const numberOfFields = dv.getUint32(cursor, true); 71 - cursor += 4; 72 - 73 - const metadata: Partial<Metadata & { artists: string[] }> = {}; 74 - 75 - for (let i = 0; i < numberOfFields; i++) { 76 - const fieldLength = dv.getUint32(cursor, true); 77 - cursor += 4; 78 - 79 - const fieldValue = uint8Array.slice(cursor, cursor + fieldLength).toString(); 80 - cursor += fieldLength; 81 - 82 - const parsedField = parseFieldValue(fieldValue); 83 - 84 - if (!parsedField) { 85 - continue; 86 - } 87 - 88 - const { key, value } = parsedField; 89 - 90 - switch (key.toUpperCase()) { 91 - case "ALBUM": 92 - metadata.album = value; 93 - break; 94 - case "ARTIST": 95 - if (!metadata.artists) { 96 - metadata.artists = []; 97 - } 98 - metadata.artists.push(value); 99 - break; 100 - case "ALBUM ARTIST": 101 - metadata.albumArtist = value; 102 - break; 103 - case "TITLE": 104 - metadata.title = value; 105 - break; 106 - case "TRACKNUMBER": 107 - const parsedNumber = yield* safeParseInt(value, 10); 108 - if (Option.isSome(parsedNumber)) { 109 - metadata.trackNumber = parsedNumber.value; 110 - } 111 - break; 112 - default: 113 - continue; 114 - } 115 - } 116 - 117 - metadata.albumArtist ??= metadata.artists?.at(0); 118 - 119 - const valid = Schema.decodeUnknownOption(MetadataSchema)(metadata); 120 - 121 - return yield* Option.match(valid, { 122 - onSome: (value) => ParseResult.succeed(value), 123 - onNone: () => ParseResult.fail(new ParseResult.Type(ast, metadata, "Metadata is not valid")), 124 - }); 125 - }); 126 - }, 127 - encode(x, _, ast) { 128 - return ParseResult.fail(new ParseResult.Type(ast, x, "Unimplemented")); 129 - }, 130 - }, 131 - ); 132 - 133 - // Helper functions 134 - 135 - function parseFieldValue(str: string): { key: string; value: string } | null { 136 - const [key, value] = str.split("=").map((x) => x.trim()); 137 - if (!key || !value) { 138 - return null; 139 - } 140 - return { key: key.toUpperCase(), value }; 141 - } 142 - 143 - //function readHeader(file: Uint8Array, offset: number) { 144 - //return Effect.gen(function* () { 145 - //const header = yield* Schema.decode(FlacHeaderFromUint8Array)({ 146 - //uint8Array: file, 147 - //offset: offset, 148 - //}); 149 - 150 - //return header; 151 - //}).pipe( 152 - //Effect.withSpan("flac-readHeader"), 153 - //Effect.mapError( 154 - //(e) => 155 - //new FlacError({ 156 - //cause: e, 157 - //message: "Failed reading header", 158 - //}), 159 - //), 160 - //); 161 - //} 162 - 163 - const readHeader = Effect.fn("flac-readHeader")(function* (file: Uint8Array, offset: number) { 164 - const result = yield* Schema.decode(FlacHeaderFromUint8Array)({ 165 - uint8Array: file, 166 - offset: offset, 167 - }).pipe( 168 - Effect.mapError( 169 - (e) => 170 - new FlacError({ 171 - cause: e, 172 - message: "Failed reading header", 173 - }), 174 - ), 175 - ); 176 - 177 - return result; 178 - }); 179 - 180 - const readVorbisComment = Effect.fn("readVorbisComment")(function* (file: Uint8Array, offset: number, length: number) { 181 - const slice = file.slice(offset, offset + length); 182 - 183 - const vorbisComment = yield* Schema.decode(MetadataFromUint8Array)({ 184 - uint8Array: slice, 185 - offset, 186 - length, 187 - }).pipe( 188 - Effect.mapError( 189 - (e) => 190 - new FlacError({ 191 - cause: e, 192 - message: "Failed to parse Vorbis Comment", 193 - }), 194 - ), 195 - ); 196 - 197 - return vorbisComment; 198 - }); 199 - 200 - // Public API 201 - 202 - export const isFlac = Effect.fn("isFlac")(function* (path: string) { 203 - const fs = yield* FileSystem.FileSystem; 204 - const file = yield* fs.readFile(path); 205 - const slice = file.slice(0, 4).toString(); 206 - return slice === "fLaC"; 207 - }); 208 - 209 - export const readMetadata = Effect.fn("flac-readMetadata")(function* (path: string) { 210 - const fs = yield* FileSystem.FileSystem; 211 - 212 - const fileIsFlac = yield* isFlac(path); 213 - if (!fileIsFlac) { 214 - return yield* Effect.fail( 215 - new FlacError({ 216 - message: "The file you are trying to parse as FLAC is NOT FLAC", 217 - }), 218 - ); 219 - } 220 - 221 - const file = yield* fs.readFile(path); 222 - 223 - let offset = 4; 224 - let header = yield* readHeader(file, offset); 225 - 226 - while (!header.isLast && header.streamInfo !== VORBIS_STREAMINFO) { 227 - offset = offset + 4 + header.length; 228 - header = yield* readHeader(file, offset); 229 - } 230 - 231 - offset += 4; 232 - 233 - const vorbisComment = yield* readVorbisComment(file, offset, header.length); 234 - 235 - const result = MetadataWithFilepathSchema.make({ 236 - ...vorbisComment, 237 - filePath: path, 238 - }); 239 - 240 - return result; 241 - });
+66 -29
backend/src/flac/service.ts
··· 5 5 import { MetadataWithFilepathSchema } from "~/metadata"; 6 6 import { BunFileSystem } from "@effect/platform-bun"; 7 7 8 - const VORBIS_STREAMINFO = 4; 8 + const VORBIS_COMMENT = 4; 9 + const MAX_METADATA_BLOCKS = 128; // FLAC spec allows max 128 metadata blocks 9 10 10 11 export class FlacService extends Effect.Service<FlacService>()("FlacService", { 11 12 dependencies: [BunFileSystem.layer], 12 13 effect: Effect.gen(function* () { 13 14 const fs = yield* FileSystem.FileSystem; 14 15 15 - const isFlac = Effect.fn("isFlac")(function* (path: string) { 16 + const readMetadata = Effect.fn("flac-readMetadata")(function* (path: string) { 16 17 const file = yield* fs.readFile(path); 17 - const slice = file.slice(0, 4).toString(); 18 - return slice === "fLaC"; 19 - }); 20 18 21 - const readMetadata = Effect.fn("flac-readMetadata")(function* (path: string) { 22 - const fileIsFlac = yield* isFlac(path); 23 - if (!fileIsFlac) { 19 + // Check if file is FLAC 20 + if (file.length < 4 || file.slice(0, 4).toString() !== "fLaC") { 24 21 return yield* Effect.fail( 25 22 new FlacError({ 26 - message: "The file you are trying to parse as FLAC is NOT FLAC", 23 + message: `File is not a valid FLAC file: ${path}`, 27 24 }), 28 25 ); 29 26 } 30 27 31 - const file = yield* fs.readFile(path); 32 - 33 28 let offset = 4; 34 - let header = yield* readHeader(file, offset); 29 + let iterations = 0; 35 30 36 - while (!header.isLast && header.streamInfo !== VORBIS_STREAMINFO) { 37 - offset = offset + 4 + header.length; 38 - header = yield* readHeader(file, offset); 39 - } 31 + // Find the Vorbis Comment block 32 + while (iterations < MAX_METADATA_BLOCKS) { 33 + // Bounds check: ensure we can read a header 34 + if (offset + 4 > file.length) { 35 + return yield* Effect.fail( 36 + new FlacError({ 37 + message: `Malformed FLAC file: unexpected end at offset ${offset} in ${path}`, 38 + }), 39 + ); 40 + } 41 + 42 + const header = yield* readHeader(file, offset, path); 43 + 44 + if (header.streamInfo === VORBIS_COMMENT) { 45 + // Found Vorbis Comment block 46 + offset += 4; 47 + 48 + // Bounds check: ensure we can read the vorbis comment 49 + if (offset + header.length > file.length) { 50 + return yield* Effect.fail( 51 + new FlacError({ 52 + message: `Malformed FLAC file: Vorbis Comment extends beyond file at offset ${offset} in ${path}`, 53 + }), 54 + ); 55 + } 56 + 57 + const vorbisComment = yield* readVorbisComment(file, offset, header.length, path); 58 + 59 + const result = MetadataWithFilepathSchema.make({ 60 + ...vorbisComment, 61 + filePath: path, 62 + }); 40 63 41 - offset += 4; 64 + return result; 65 + } 42 66 43 - const vorbisComment = yield* readVorbisComment(file, offset, header.length); 67 + if (header.isLast) { 68 + return yield* Effect.fail( 69 + new FlacError({ 70 + message: `No Vorbis Comment block found in FLAC file: ${path}`, 71 + }), 72 + ); 73 + } 44 74 45 - const result = MetadataWithFilepathSchema.make({ 46 - ...vorbisComment, 47 - filePath: path, 48 - }); 75 + offset = offset + 4 + header.length; 76 + iterations++; 77 + } 49 78 50 - return result; 79 + return yield* Effect.fail( 80 + new FlacError({ 81 + message: `Too many metadata blocks (>${MAX_METADATA_BLOCKS}) in FLAC file: ${path}`, 82 + }), 83 + ); 51 84 }); 52 85 53 86 return { 54 - isFlac, 55 87 readMetadata, 56 88 } as const; 57 89 }), 58 90 }) {} 59 91 60 92 // Helpers 61 - const readHeader = Effect.fn("flac-readHeader")(function* (file: Uint8Array, offset: number) { 93 + const readHeader = Effect.fn("flac-readHeader")(function* (file: Uint8Array, offset: number, path: string) { 62 94 const result = yield* Schema.decode(FlacHeaderFromUint8Array)({ 63 95 uint8Array: file, 64 96 offset: offset, ··· 67 99 (e) => 68 100 new FlacError({ 69 101 cause: e, 70 - message: "Failed reading header", 102 + message: `Failed reading FLAC header at offset ${offset} in ${path}`, 71 103 }), 72 104 ), 73 105 ); ··· 75 107 return result; 76 108 }); 77 109 78 - const readVorbisComment = Effect.fn("readVorbisComment")(function* (file: Uint8Array, offset: number, length: number) { 110 + const readVorbisComment = Effect.fn("readVorbisComment")(function* ( 111 + file: Uint8Array, 112 + offset: number, 113 + length: number, 114 + path: string, 115 + ) { 79 116 const slice = file.slice(offset, offset + length); 80 117 81 118 const vorbisComment = yield* Schema.decode(MetadataFromUint8Array)({ 82 119 uint8Array: slice, 83 - offset, 120 + offset: 0, // slice starts at 0 84 121 length, 85 122 }).pipe( 86 123 Effect.mapError( 87 124 (e) => 88 125 new FlacError({ 89 126 cause: e, 90 - message: "Failed to parse Vorbis Comment", 127 + message: `Failed to parse Vorbis Comment at offset ${offset} in ${path}`, 91 128 }), 92 129 ), 93 130 );
+84 -14
backend/src/flac/transformers.ts
··· 22 22 return yield* ParseResult.fail(new ParseResult.Type(ast, uint8Array, "UIntArray is too short")); 23 23 } 24 24 25 - const dataView = new DataView(uint8Array.buffer, offset, 4); 25 + const dataView = new DataView(uint8Array.buffer, uint8Array.byteOffset + offset, 4); 26 26 27 27 const header = { 28 28 isLast: ((dataView.getUint8(0) & 0x80) === 0x80 ? 1 : 0) as Bit, // 1 bit ··· 44 44 length: Schema.Number, 45 45 }); 46 46 47 + const FIELD_MAPPING = { 48 + ALBUM: "album", 49 + ARTIST: "artist", 50 + "ALBUM ARTIST": "albumArtist", 51 + TITLE: "title", 52 + TRACKNUMBER: "trackNumber", 53 + MUSICBRAINZ_RELEASEGROUPID: "musicBrainzReleaseGroupId", 54 + MUSICBRAINZ_ARTISTID: "musicBrainzArtistId", 55 + MUSICBRAINZ_TRACKID: "musicBrainzTrackId", 56 + } as const; 57 + 58 + const MAX_VORBIS_FIELDS = 10000; // Reasonable limit for metadata fields 59 + const MAX_FIELD_LENGTH = 1024 * 1024; // 1MB max per field 60 + 47 61 export const MetadataFromUint8Array = Schema.transformOrFail(MetadataInput, MetadataSchema, { 48 62 strict: true, 49 63 decode({ uint8Array, offset, length }, _, ast) { 50 64 return Effect.gen(function* () { 51 - const dv = new DataView(uint8Array.buffer, offset, length); 65 + const dv = new DataView(uint8Array.buffer, uint8Array.byteOffset + offset, length); 66 + const decoder = new TextDecoder("utf-8"); 52 67 53 68 let cursor = 0; 54 69 70 + // Bounds check: ensure we can read vendor string length 71 + if (cursor + 4 > length) { 72 + return yield* ParseResult.fail( 73 + new ParseResult.Type(ast, uint8Array, "Buffer too short to read vendor string length"), 74 + ); 75 + } 76 + 55 77 const vendorStringLength = dv.getUint32(cursor, true); 56 78 cursor += 4; 57 79 80 + // Validate vendor string length 81 + if (vendorStringLength > MAX_FIELD_LENGTH || cursor + vendorStringLength > length) { 82 + return yield* ParseResult.fail( 83 + new ParseResult.Type(ast, uint8Array, `Invalid vendor string length: ${vendorStringLength}`), 84 + ); 85 + } 86 + 58 87 cursor += vendorStringLength; 59 88 89 + // Bounds check: ensure we can read number of fields 90 + if (cursor + 4 > length) { 91 + return yield* ParseResult.fail( 92 + new ParseResult.Type(ast, uint8Array, "Buffer too short to read number of fields"), 93 + ); 94 + } 95 + 60 96 const numberOfFields = dv.getUint32(cursor, true); 61 97 cursor += 4; 62 98 63 - const metadata: Partial<Metadata & { artists: string[] }> = {}; 99 + // Validate number of fields to prevent DoS 100 + if (numberOfFields > MAX_VORBIS_FIELDS) { 101 + return yield* ParseResult.fail( 102 + new ParseResult.Type(ast, uint8Array, `Too many fields: ${numberOfFields} (max ${MAX_VORBIS_FIELDS})`), 103 + ); 104 + } 105 + 106 + const metadata: Partial<Metadata & { artists: string[] }> = { 107 + artists: [], 108 + }; 64 109 65 110 for (let i = 0; i < numberOfFields; i++) { 111 + // Bounds check: ensure we can read field length 112 + if (cursor + 4 > length) { 113 + return yield* ParseResult.fail( 114 + new ParseResult.Type(ast, uint8Array, `Buffer too short to read field ${i} length at cursor ${cursor}`), 115 + ); 116 + } 117 + 66 118 const fieldLength = dv.getUint32(cursor, true); 67 119 cursor += 4; 68 120 69 - const fieldValue = uint8Array.slice(cursor, cursor + fieldLength).toString(); 121 + // Validate field length 122 + if (fieldLength > MAX_FIELD_LENGTH) { 123 + return yield* ParseResult.fail( 124 + new ParseResult.Type(ast, uint8Array, `Field ${i} length too large: ${fieldLength}`), 125 + ); 126 + } 127 + 128 + // Bounds check: ensure we can read field value 129 + if (cursor + fieldLength > length) { 130 + return yield* ParseResult.fail( 131 + new ParseResult.Type( 132 + ast, 133 + uint8Array, 134 + `Buffer too short to read field ${i} value (need ${fieldLength} bytes at cursor ${cursor})`, 135 + ), 136 + ); 137 + } 138 + 139 + const fieldBytes = uint8Array.slice(offset + cursor, offset + cursor + fieldLength); 140 + const fieldValue = decoder.decode(fieldBytes); 70 141 cursor += fieldLength; 71 142 72 143 const parsedField = parseFieldValue(fieldValue); ··· 77 148 78 149 const { key, value } = parsedField; 79 150 80 - switch (key.toUpperCase()) { 151 + switch (key) { 81 152 case "ALBUM": 82 153 metadata.album = value; 83 154 break; 84 155 case "ARTIST": 85 - if (!metadata.artists) { 86 - metadata.artists = []; 87 - } 88 - metadata.artists.push(value); 156 + metadata.artists!.push(value); 89 157 break; 90 158 case "ALBUM ARTIST": 91 159 metadata.albumArtist = value; ··· 99 167 metadata.trackNumber = parsedNumber.value; 100 168 } 101 169 break; 102 - 103 170 case "MUSICBRAINZ_RELEASEGROUPID": 104 171 metadata.musicBrainzReleaseGroupId = value; 105 172 break; 106 - 107 173 case "MUSICBRAINZ_ARTISTID": 108 174 metadata.musicBrainzArtistId = value; 109 175 break; 110 - 111 176 case "MUSICBRAINZ_TRACKID": 112 177 metadata.musicBrainzTrackId = value; 113 178 break; ··· 132 197 }); 133 198 134 199 function parseFieldValue(str: string): { key: string; value: string } | null { 135 - const [key, value] = str.split("=").map((x) => x.trim()); 200 + const equalIndex = str.indexOf("="); 201 + if (equalIndex === -1) { 202 + return null; 203 + } 204 + const key = str.slice(0, equalIndex).trim().toUpperCase(); 205 + const value = str.slice(equalIndex + 1).trim(); 136 206 if (!key || !value) { 137 207 return null; 138 208 } 139 - return { key: key.toUpperCase(), value }; 209 + return { key, value }; 140 210 }
+33 -8
backend/test/flac.test.ts
··· 1 1 import { expect, it as test } from "@effect/vitest"; 2 2 import { Effect } from "effect/index"; 3 - import { isFlac, readMetadata } from "../src/flac"; 3 + import { FlacService } from "../src/flac/service"; 4 4 import { BunContext } from "@effect/platform-bun/index"; 5 5 6 6 const provide = Effect.provide(BunContext.layer); 7 7 8 - test.effect("isFlac should return weather file is flac", () => 8 + test.effect("readMetadata should return error if file is not flac", () => 9 + Effect.gen(function* () { 10 + const flacService = yield* FlacService; 11 + const inputNonFlac = "./test-data/01 - hover.mp3"; 12 + const actualNonFlac = yield* flacService.readMetadata(inputNonFlac).pipe( 13 + Effect.as(true), 14 + Effect.catchTag("FlacError", (_) => Effect.succeed(false)), 15 + ); 16 + 17 + expect(actualNonFlac).toBe(false); 18 + }).pipe(provide), 19 + ); 20 + 21 + test.effect("readMetadata should NOT error if file is FLAC", () => 9 22 Effect.gen(function* () { 10 - const inputFlac = "./test-data/11 - Tropical Fish.flac"; 11 - const actualFlac = yield* isFlac(inputFlac); 12 - expect(actualFlac).toBeTruthy(); 23 + const flacService = yield* FlacService; 24 + const input = "./test-data/11 - Tropical Fish.flac"; 25 + const actual = yield* flacService.readMetadata(input).pipe( 26 + Effect.as(true), 27 + Effect.catchTag("FlacError", (_) => Effect.succeed(false)), 28 + ); 13 29 14 - const inputNonFlac = "./test-data/01 - hover.mp3"; 15 - const actualNonFlac = yield* isFlac(inputNonFlac); 16 - expect(actualNonFlac).toBeFalsy(); 30 + expect(actual).toBe(true); 31 + }).pipe(provide), 32 + ); 33 + 34 + test.effect("readMetadata should return album, artist and title", () => 35 + Effect.gen(function* () { 36 + const flacService = yield* FlacService; 37 + const input = "./test-data/11 - Tropical Fish.flac"; 38 + const actual = yield* flacService.readMetadata(input); 39 + expect(actual).toHaveProperty("artist", "濱田金吾"); 40 + expect(actual).toHaveProperty("album", "「midnight cruisin'」+「MUGSHOT」"); 41 + expect(actual).toHaveProperty("title", "TROPICAL FISH"); 17 42 }).pipe(provide), 18 43 ); 19 44