WIP. A little custom music server
0
fork

Configure Feed

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

fix: update checkSignature to handle differemt brands

+52 -28
+16 -12
backend/src/api.ts
··· 123 123 new AlbumNotFoundError({ 124 124 message: "Album not found", 125 125 cause: e, 126 - }), 127 - ), 126 + }) 127 + ) 128 128 ); 129 129 130 130 yield* Effect.log(album); ··· 164 164 165 165 const result = yield* Schema.decode(transformer)(dbData); 166 166 167 - return result; 167 + return { 168 + ...result, 169 + songs: [...(result.songs ?? [])]?.sort((a, b) => (a.trackNumber ?? 0) - (b.trackNumber ?? 0)), 170 + }; 168 171 }); 169 172 170 173 const getAlbumList = Effect.fn("getAlbumList")(function* () { ··· 197 200 artists, 198 201 musicbrainz: row.musicbrainz ?? undefined, 199 202 }); 200 - }), 203 + }) 201 204 ); 202 205 203 206 return yield* Effect.all(result); ··· 213 216 new FileNotFoundError({ 214 217 message: `Failed to find file with id ${id}`, 215 218 cause: { id }, 216 - }), 219 + }) 217 220 ); 218 221 } 219 222 ··· 227 230 new FileNotFoundError({ 228 231 message: `File not found on disk: ${file.path}`, 229 232 cause: { id, path: file.path }, 230 - }), 233 + }) 231 234 ); 232 235 } 233 236 234 237 const filename = file.path.split("/").pop(); 235 238 const encodedFilename = encodeURIComponent(filename ?? ""); 236 239 237 - set.headers["Content-Disposition"] = 238 - `attachment; filename="${encodedFilename}"; filename*=UTF-8''${encodedFilename}`; 240 + set.headers[ 241 + "Content-Disposition" 242 + ] = `attachment; filename="${encodedFilename}"; filename*=UTF-8''${encodedFilename}`; 239 243 set.headers["Content-Type"] = "audio/flac"; 240 244 set.headers["Accept-Ranges"] = "bytes"; 241 245 ··· 254 258 .innerJoin(albumTable, eq(songTable.albumId, albumTable.id)) 255 259 .innerJoin(songToArtistTable, eq(songToArtistTable.songId, songTable.id)) 256 260 .innerJoin(artistTable, eq(songToArtistTable.artistId, artistTable.id)) 257 - .all(), 261 + .all() 258 262 ); 259 263 260 264 const reduced = rows.reduce<Record<string, GetSongType>>((acc, row) => { ··· 321 325 query: t.Object({ 322 326 include: t.Optional(t.Array(t.String())), 323 327 }), 324 - }, 328 + } 325 329 ) 326 330 327 331 .get( ··· 330 334 pipe( 331 335 ApiService.getFileById(id, set), 332 336 Effect.catchTag("FileNotFoundError", (e) => Effect.succeed(status(404, e.message))), 333 - runtime.runPromise, 337 + runtime.runPromise 334 338 ), 335 339 { 336 340 params: t.Object({ 337 341 id: t.String(), 338 342 }), 339 - }, 343 + } 340 344 ) 341 345 .get("/songs", () => pipe(ApiService.getAllSongs(), runtime.runPromise)); 342 346
+4 -4
backend/src/file-parser.ts
··· 99 99 return yield* Effect.fail(new UnsupportedFileError({ message: "File is unsupported" })); 100 100 } 101 101 102 - // if (assumedType === "flac") { 103 - // const metadata = yield* flacService.readMetadata(path); 104 - // return metadata; 105 - // } 102 + if (assumedType === "flac") { 103 + const metadata = yield* flacService.readMetadata(path); 104 + return metadata; 105 + } 106 106 if (assumedType === "m4a") { 107 107 const metadata = yield* m4aService.readMetadata(path); 108 108 return metadata;
+32 -12
backend/src/m4a/service.ts
··· 54 54 return { chunkSize, chunkType: chunkType as ChunkType }; 55 55 }); 56 56 57 + const SUPPORTED_BRANDS = ["M4A ", "M4B ", "mp41", "mp42", "isom"]; 58 + 57 59 const checkSignature = Effect.fn("m4a-checkSignature")(function* (file: FileSystem.File, path: string) { 58 60 const { chunkSize, chunkType } = yield* readHeader(file, 0, path); 59 61 ··· 62 64 } 63 65 64 66 const ftypData = yield* readBytes(file, CHUNK_HEADER_SIZE, chunkSize - CHUNK_HEADER_SIZE, path); 65 - const text = utf.decode(ftypData); 66 67 67 - if (text.startsWith("M4A ") === false) { 68 - return yield* M4aUnsupportedFileError.make({ message: "Invalid signature" }); 68 + // ftyp content: major_brand (4) + minor_version (4) + compatible_brands (4 each) 69 + const brands: string[] = []; 70 + for (let i = 0; i + 4 <= ftypData.length; i += 4) { 71 + if (i === 4) { 72 + continue; 73 + } // skip minor_version 74 + brands.push(utf.decode(ftypData.slice(i, i + 4))); 75 + } 76 + 77 + const hasSupported = brands.some((brand) => SUPPORTED_BRANDS.includes(brand)); 78 + if (!hasSupported) { 79 + return yield* M4aUnsupportedFileError.make({ message: `Unsupported brands: ${brands.join(", ")}` }); 69 80 } 70 81 71 82 return chunkSize; ··· 91 102 } 92 103 93 104 if (start < 0 || end < 0) { 94 - return yield* new M4aParseError({ reason: "invalid_range", details: `negative offset: start=${start}, end=${end}` }); 105 + return yield* new M4aParseError({ 106 + reason: "invalid_range", 107 + details: `negative offset: start=${start}, end=${end}`, 108 + }); 95 109 } 96 110 97 111 let offset = start; ··· 100 114 while (currentHeader.chunkType !== search) { 101 115 offset += currentHeader.chunkSize; 102 116 if (offset > end) { 103 - return yield* new M4aParseError({ reason: "block_not_found", details: `'${search}' not found within range` }); 117 + return yield* new M4aParseError({ 118 + reason: "block_not_found", 119 + details: `'${search}' not found within range`, 120 + }); 104 121 } 105 122 106 123 if (iterations > SANE_NUMBER_OF_CHUNK_BLOCKS) { 107 - return yield* new M4aParseError({ reason: "too_many_blocks", details: `exceeded ${SANE_NUMBER_OF_CHUNK_BLOCKS} iterations` }); 124 + return yield* new M4aParseError({ 125 + reason: "too_many_blocks", 126 + details: `exceeded ${SANE_NUMBER_OF_CHUNK_BLOCKS} iterations`, 127 + }); 108 128 } 109 129 iterations++; 110 130 currentHeader = yield* readHeader(file, offset, path); ··· 158 178 159 179 offset = ilst.offset; 160 180 161 - const ilistContentLength = ilst.currentHeader.chunkSize - CHUNK_HEADER_SIZE; 162 - const ilistBuffer = yield* readBytes(file, ilst.offset, ilistContentLength, path); 181 + const ilistContentLength = ilst.currentHeader.chunkSize - CHUNK_HEADER_SIZE; 182 + const ilistBuffer = yield* readBytes(file, ilst.offset, ilistContentLength, path); 163 183 164 - const result = yield* Schema.decode(MetadataFromIListBuffer)({ 165 - uint8Array: ilistBuffer, 166 - length: ilistContentLength, 167 - }); 184 + const result = yield* Schema.decode(MetadataFromIListBuffer)({ 185 + uint8Array: ilistBuffer, 186 + length: ilistContentLength, 187 + }); 168 188 169 189 const transformer = Schema.transformOrFail(MetadataSchema, MetadataWithFilepathSchema, { 170 190 strict: true,