WIP. A little custom music server
0
fork

Configure Feed

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

add a test case if file doesnt have musicbrainz shit

+176 -49
+11 -4
backend/src/file-parser.ts
··· 2 2 import { FileSystem, Path } from "@effect/platform"; 3 3 import { FlacService } from "./flac/service"; 4 4 import type { MetadataWithFilepathSchema } from "./metadata"; 5 + import { M4aService } from "./m4a/service"; 5 6 6 - const SUPPORTED_EXTENSIONS = ["flac"] as const; 7 + const SUPPORTED_EXTENSIONS = ["flac", "m4a"] as const; 7 8 type SupportedExtension = (typeof SUPPORTED_EXTENSIONS)[number]; 8 9 9 10 class UnsupportedFileError extends Data.TaggedError("UnsupportedFileError")<{ ··· 85 86 }); 86 87 87 88 export const parseFile = Effect.fn("parse-file")(function* (path: string) { 89 + const flacService = yield* FlacService; 90 + const m4aService = yield* M4aService; 91 + 88 92 const extension = path.split(".").pop(); 89 93 const assumedType = 90 94 extension && SUPPORTED_EXTENSIONS.includes(extension as SupportedExtension) ··· 95 99 return yield* Effect.fail(new UnsupportedFileError({ message: "File is unsupported" })); 96 100 } 97 101 98 - if (assumedType === "flac") { 99 - const flacService = yield* FlacService; 100 - const metadata = yield* flacService.readMetadata(path); 102 + // if (assumedType === "flac") { 103 + // const metadata = yield* flacService.readMetadata(path); 104 + // return metadata; 105 + // } 106 + if (assumedType === "m4a") { 107 + const metadata = yield* m4aService.readMetadata(path); 101 108 return metadata; 102 109 } 103 110 return yield* Effect.fail(new UnsupportedFileError({ message: "File is unsupported" }));
+2 -2
backend/src/index.ts
··· 8 8 import { OtelLive } from "./utils/otel"; 9 9 import { startApi } from "./api"; 10 10 import { FlacService } from "./flac/service"; 11 + import { M4aService } from "./m4a/service"; 11 12 12 - const AppLayer = FlacService.Default.pipe( 13 - // asdf 13 + const AppLayer = Layer.merge(FlacService.Default, M4aService.Default).pipe( 14 14 Layer.provideMerge(DatabaseLive.Default), 15 15 Layer.provideMerge(BunContext.layer), 16 16 Layer.merge(OtelLive)
+7 -3
backend/src/m4a/errors.ts
··· 1 - import { Schema } from "effect"; 1 + import { Data, Schema } from "effect"; 2 2 3 3 export class M4aUnsupportedFileError extends Schema.TaggedError<M4aUnsupportedFileError>()("M4aUnsupportedFileError", { 4 4 message: Schema.NonEmptyString, 5 - // cause: Schema.Defect, 6 - }) {} 5 + }) {} 6 + 7 + export class M4aParseError extends Data.TaggedError("M4aParseError")<{ 8 + reason: "invalid_range" | "block_not_found" | "too_many_blocks"; 9 + details?: string; 10 + }> {}
+60 -31
backend/src/m4a/service.test.ts
··· 1 - import { Effect, Exit, Layer } from "effect"; 1 + import { Effect, Exit, Layer, ManagedRuntime } from "effect"; 2 2 import { BunContext } from "@effect/platform-bun"; 3 3 import { M4aService } from "./service"; 4 4 import { expect, it } from "vitest"; 5 5 import os from "node:os"; 6 6 7 7 const layers = Layer.merge(BunContext.layer, M4aService.Default); 8 - 9 - // FIXME: Vitest is breaking right now for some reason. This is a workaround 10 - const test = <A, E>(label: string, effect: () => Effect.Effect<A, E, never>) => { 11 - it(label, () => Effect.runPromise(effect())); 12 - }; 8 + const runtime = ManagedRuntime.make(layers); 13 9 14 10 const m4aFilePathMac = "/Users/johnb/boombox-test-data/15 Downtown.m4a"; 15 11 const nonM4aFilePathMac = "/Users/johnb/boombox-test-data/01 - hover.mp3"; ··· 21 17 const m4aFilePath = os.platform() === "darwin" ? m4aFilePathMac : m4aFilePathArch; 22 18 const nonM4aFilePath = os.platform() === "darwin" ? nonM4aFilePathMac : nonM4aFilePathArch; 23 19 24 - test("readMetadata should throw an error if the file is not an m4a", () => 25 - Effect.gen(function* () { 26 - const m4aService = yield* M4aService; 27 - const input = nonM4aFilePath; 28 - const actual = yield* Effect.exit(m4aService.readMetadata(input)); 29 - expect(Exit.isFailure(actual)).toBe(true); 30 - }).pipe(Effect.provide(layers))); 20 + it("readMetadata should throw an error if the file is not an m4a", () => 21 + runtime.runPromise( 22 + Effect.gen(function* () { 23 + const m4aService = yield* M4aService; 24 + const input = nonM4aFilePath; 25 + const actual = yield* Effect.exit(m4aService.readMetadata(input)); 26 + expect(Exit.isFailure(actual)).toBe(true); 27 + }) 28 + )); 29 + 30 + it("readMetadata should NOT throw an error on a valid m4a file", () => 31 + runtime.runPromise( 32 + Effect.gen(function* () { 33 + const m4aService = yield* M4aService; 34 + const input = m4aFilePath; 35 + const actual = yield* Effect.exit(m4aService.readMetadata(input)); 36 + expect(Exit.isSuccess(actual)).toBe(true); 37 + }) 38 + )); 39 + 40 + it("readMetadata should return album, artist and title", () => 41 + runtime.runPromise( 42 + Effect.gen(function* () { 43 + const m4aService = yield* M4aService; 44 + const input = m4aFilePath; 45 + const actual = yield* m4aService.readMetadata(input); 46 + 47 + yield* Effect.log(actual); 48 + expect(actual.album).toBe("Torches X"); 49 + expect(actual.artists).toContain("Foster the People"); 50 + expect(actual.title).toBe("Downtown"); 51 + expect(actual.trackNumber).toBe(15); 52 + }) 53 + )); 54 + 55 + it("readMetadata should be able to parse musicbrainz tags", () => 56 + runtime.runPromise( 57 + Effect.gen(function* () { 58 + const m4aService = yield* M4aService; 59 + const input = m4aFilePath; 60 + const actual = yield* m4aService.readMetadata(input); 31 61 32 - test("readMetadata should NOT throw an error on a valid m4a file", () => 33 - Effect.gen(function* () { 34 - const m4aService = yield* M4aService; 35 - const input = m4aFilePath; 36 - const actual = yield* Effect.exit(m4aService.readMetadata(input)); 37 - expect(Exit.isSuccess(actual)).toBe(true); 38 - }).pipe(Effect.provide(layers))); 62 + expect(actual.musicBrainzReleaseGroupId).toBe("535748f7-5b3d-4a2a-8f9c-bd8baa587239"); 63 + expect(actual.musicBrainzArtistId).toBe("e0e1a584-dd0a-4bd1-88d1-c4c62895039d"); 64 + expect(actual.musicBrainzTrackId).toBe("be8931c6-f360-41eb-8a19-1ca97342cdb4"); 65 + }) 66 + )); 39 67 40 - test("readMetadata should return album, artist and title", () => 41 - Effect.gen(function* () { 42 - const m4aService = yield* M4aService; 43 - const input = m4aFilePath; 44 - const actual = yield* m4aService.readMetadata(input); 68 + const m4aFilePathNoMusicBrainz = "./test-data/no-musicbrainz.m4a"; 69 + it("readMetadata should work without musicbrainz tags", () => 70 + runtime.runPromise( 71 + Effect.gen(function* () { 72 + const m4aService = yield* M4aService; 73 + const actual = yield* m4aService.readMetadata(m4aFilePathNoMusicBrainz); 45 74 46 - yield* Effect.log(actual); 47 - expect(actual.album).toBe("Torches X"); 48 - expect(actual.artists).toContain("Foster the People"); 49 - expect(actual.title).toBe("Downtown"); 50 - expect(actual.trackNumber).toBe(15); 51 - }).pipe(Effect.provide(layers))); 75 + expect(actual.album).toBe("Torches X"); 76 + expect(actual.musicBrainzReleaseGroupId).toBeUndefined(); 77 + expect(actual.musicBrainzArtistId).toBeUndefined(); 78 + expect(actual.musicBrainzTrackId).toBeUndefined(); 79 + }) 80 + ));
+5 -5
backend/src/m4a/service.ts
··· 2 2 import { FileSystem } from "@effect/platform"; 3 3 import { BunFileSystem } from "@effect/platform-bun"; 4 4 import { MetadataWithFilepathSchema, MetadataSchema } from "~/metadata"; 5 - import { M4aUnsupportedFileError } from "./errors"; 5 + import { M4aUnsupportedFileError, M4aParseError } from "./errors"; 6 6 import { readBytes } from "~/lib"; 7 7 import { MetadataFromIListBuffer } from "./transformers"; 8 8 ··· 87 87 path: string; 88 88 }) { 89 89 if (start > end) { 90 - return yield* Effect.fail(new Error("Start is greater than end")); 90 + return yield* new M4aParseError({ reason: "invalid_range", details: `start ${start} > end ${end}` }); 91 91 } 92 92 93 93 if (start < 0 || end < 0) { 94 - return yield* Effect.fail(new Error("Start or end is less than 0")); 94 + return yield* new M4aParseError({ reason: "invalid_range", details: `negative offset: start=${start}, end=${end}` }); 95 95 } 96 96 97 97 let offset = start; ··· 100 100 while (currentHeader.chunkType !== search) { 101 101 offset += currentHeader.chunkSize; 102 102 if (offset > end) { 103 - return yield* Effect.fail(new Error("Offset is greater than end")); 103 + return yield* new M4aParseError({ reason: "block_not_found", details: `'${search}' not found within range` }); 104 104 } 105 105 106 106 if (iterations > SANE_NUMBER_OF_CHUNK_BLOCKS) { 107 - return yield* Effect.fail(new Error("Too many chunk blocks")); 107 + return yield* new M4aParseError({ reason: "too_many_blocks", details: `exceeded ${SANE_NUMBER_OF_CHUNK_BLOCKS} iterations` }); 108 108 } 109 109 iterations++; 110 110 currentHeader = yield* readHeader(file, offset, path);
+91 -4
backend/src/m4a/transformers.ts
··· 11 11 12 12 const MAX_ATOMS = 200; 13 13 14 + // ---- Freeform atom transformer ---- 15 + 16 + const FreeformAtomInput = Schema.Struct({ 17 + buf: Schema.Uint8ArrayFromSelf, 18 + atomStart: Schema.Number, 19 + atomSize: Schema.Number, 20 + }); 21 + 22 + const FreeformAtomOutput = Schema.Struct({ 23 + mean: Schema.String, 24 + name: Schema.String, 25 + value: Schema.String, 26 + }); 27 + 28 + /** Parse ---- freeform atom which contains mean, name, and data sub-atoms */ 29 + const FreeformAtomFromBuffer = Schema.transformOrFail(FreeformAtomInput, FreeformAtomOutput, { 30 + strict: true, 31 + decode({ buf, atomStart, atomSize }, _, ast) { 32 + const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); 33 + let pos = atomStart + 8; // skip ---- header 34 + const atomEnd = atomStart + atomSize; 35 + 36 + let mean: string | null = null; 37 + let name: string | null = null; 38 + let value: string | null = null; 39 + 40 + while (pos + 8 <= atomEnd) { 41 + const subSize = dv.getUint32(pos); 42 + const subType = latin1.decode(buf.slice(pos + 4, pos + 8)); 43 + 44 + if (subSize < 8 || pos + subSize > atomEnd) break; 45 + 46 + // mean and name have 4 bytes version/flags after header 47 + const contentStart = pos + 12; // 8 header + 4 version/flags 48 + const contentLen = subSize - 12; 49 + 50 + if (contentLen > 0 && contentStart + contentLen <= atomEnd) { 51 + if (subType === "mean") { 52 + mean = utf.decode(buf.slice(contentStart, contentStart + contentLen)); 53 + } else if (subType === "name") { 54 + name = utf.decode(buf.slice(contentStart, contentStart + contentLen)); 55 + } else if (subType === "data") { 56 + // data has 8 bytes (4 type + 4 locale) after header, so content starts at +16 57 + const dataContentStart = pos + 16; 58 + const dataContentLen = subSize - 16; 59 + if (dataContentLen > 0 && dataContentStart + dataContentLen <= atomEnd) { 60 + value = utf.decode(buf.slice(dataContentStart, dataContentStart + dataContentLen)); 61 + } 62 + } 63 + } 64 + pos += subSize; 65 + } 66 + 67 + if (mean && name && value) { 68 + return ParseResult.succeed({ mean, name, value }); 69 + } 70 + return ParseResult.fail(new ParseResult.Type(ast, { mean, name, value }, "Missing mean, name, or value")); 71 + }, 72 + encode(_, __, ast) { 73 + return ParseResult.fail(new ParseResult.Type(ast, _, "Unimplemented")); 74 + }, 75 + }); 76 + 14 77 export const MetadataFromIListBuffer = Schema.transformOrFail(IListBufferInput, MetadataSchema, { 15 78 strict: true, 16 79 decode({ uint8Array, length }, _, ast) { ··· 39 102 break; 40 103 } 41 104 42 - const atomName = latin1.decode(uint8Array.slice(cursor + 4, cursor + 8)); 105 + const atomName = latin1.decode(uint8Array.slice(cursor + 4, cursor + 8)); 43 106 44 - // Inside each tag atom, find the "data" child 107 + // Inside each tag atom, find the "data" child 45 108 // data atom structure: 4 bytes size, 4 bytes "data", 4 bytes type, 4 bytes locale, then value 46 109 const dataOffset = cursor + 8; // skip tag header 47 110 ··· 88 151 } 89 152 break; 90 153 } 91 - // MusicBrainz tags stored as ---- atoms with mean/name 92 - // For now, skip these - they require special handling 154 + case "----": { 155 + // Freeform atom: mean + name + data sub-atoms 156 + const freeformResult = yield* Schema.decode(FreeformAtomFromBuffer)({ 157 + buf: uint8Array, 158 + atomStart: cursor, 159 + atomSize, 160 + }).pipe(Effect.option); 161 + 162 + if (Option.isNone(freeformResult)) break; 163 + 164 + const freeform = freeformResult.value; 165 + if (freeform.mean !== "com.apple.iTunes") break; 166 + 167 + switch (freeform.name) { 168 + case "MusicBrainz Release Group Id": 169 + metadata.musicBrainzReleaseGroupId = freeform.value; 170 + break; 171 + case "MusicBrainz Artist Id": 172 + metadata.musicBrainzArtistId = freeform.value; 173 + break; 174 + case "MusicBrainz Track Id": 175 + metadata.musicBrainzTrackId = freeform.value; 176 + break; 177 + } 178 + break; 179 + } 93 180 } 94 181 95 182 cursor += atomSize;
db.sqlite

This is a binary file and will not be displayed.