WIP. A little custom music server
0
fork

Configure Feed

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

feat: ingerate getAlbumById to return different result based on provided include query

+173 -48
+6 -2
api-testing/get-album-by-id.bru
··· 5 5 } 6 6 7 7 get { 8 - url: http://localhost:3003/album/:id 8 + url: http://localhost:3003/album/:id?include=album-artist,song,song-artist 9 9 body: none 10 10 auth: inherit 11 11 } 12 12 13 + params:query { 14 + include: album-artist,song,song-artist 15 + } 16 + 13 17 params:path { 14 - id: album_01K9A4EC5XEEC03Z1X0B05KZJ7 18 + id: album_01KARDKH6MBVJQJKGYRY45XPF2 15 19 } 16 20 17 21 settings {
+163 -35
backend/src/api.ts
··· 1 - import { Data, Effect, Layer, ManagedRuntime, Schema } from "effect"; 1 + import { Console, Data, Effect, Layer, ManagedRuntime, Schema } from "effect"; 2 2 import { Elysia, status, StatusMap, t, type HTTPHeaders } from "elysia"; 3 3 import { DatabaseLive } from "./db"; 4 4 import { BunContext } from "@effect/platform-bun"; ··· 9 9 import { pipe } from "effect"; 10 10 import type { ElysiaCookie } from "elysia/cookies"; 11 11 import { Album, Artist, Song } from "./types"; 12 + import type { Album, Album, Artist } from "./db/types"; 13 + import { decode } from "zod"; 14 + import { decodeUnknown } from "effect/Duration"; 12 15 13 16 class FileNotFoundError extends Data.TaggedError("FileNotFoundError")<{ 14 17 message: string; ··· 26 29 cookie?: Record<string, ElysiaCookie>; 27 30 }; 28 31 32 + const GetAlbumSchema = Schema.Struct({ 33 + ...Album.fields, 34 + artists: Schema.optional(Artist), 35 + songs: Schema.optional( 36 + Schema.Struct({ 37 + ...Song.fields, 38 + artists: Schema.optional(Artist), 39 + }), 40 + ), 41 + }); 42 + 43 + type AlbumQueryInclude = { 44 + artists?: { 45 + with: { 46 + artist: true; 47 + }; 48 + }; 49 + songs?: { 50 + orderBy?: (typeof songTable.trackNumber)[]; 51 + with: { 52 + artists?: { 53 + with: { 54 + artist: true; 55 + }; 56 + }; 57 + }; 58 + }; 59 + }; 60 + 61 + const getAlbumVariants = ["album-artist", "song", "song-artist"] as const; 62 + type GetAlbumVariant = (typeof getAlbumVariants)[number]; 63 + 29 64 class ApiService extends Effect.Service<ApiService>()("@boombox/backend/api/ApiService", { 30 65 dependencies: [DatabaseLive.Default], 31 66 accessors: true, 32 67 effect: Effect.gen(function* () { 33 68 const db = yield* DatabaseLive; 34 69 35 - const getAlbumById = Effect.fn("getAlbum")(function* (id: string) { 36 - const album = yield* db.query.albumTable 37 - .findMany({ 38 - where: eq(albumTable.id, id), 39 - limit: 1, 70 + const getAlbumById = Effect.fn("getAlbum")(function* (id: string, relations: Array<GetAlbumVariant> = []) { 71 + const withArtist = { artists: { with: { artist: true } } } satisfies AlbumQueryInclude; 72 + const withSongArtist = { 73 + songs: { 40 74 with: { 41 75 artists: { 42 76 with: { 43 77 artist: true, 44 78 }, 45 79 }, 46 - songs: { 47 - orderBy: [songTable.trackNumber], 48 - with: { 49 - artists: { 50 - with: { 51 - artist: true, 52 - }, 53 - }, 54 - }, 55 - }, 56 80 }, 81 + }, 82 + } satisfies AlbumQueryInclude; 83 + const withSongs = { 84 + songs: { 85 + orderBy: [songTable.trackNumber], 86 + with: {}, 87 + }, 88 + } satisfies AlbumQueryInclude; 89 + 90 + const include = relations.reduce<AlbumQueryInclude>((acc, rel) => { 91 + switch (rel) { 92 + case "album-artist": 93 + return { ...acc, ...withArtist }; 94 + case "song": 95 + return { ...acc, ...withSongs }; 96 + case "song-artist": 97 + return { ...acc, ...withSongArtist }; 98 + 99 + default: 100 + return acc; 101 + } 102 + }, {}); 103 + 104 + const album = yield* db.query.albumTable 105 + .findMany({ 106 + where: eq(albumTable.id, id), 107 + limit: 1, 108 + with: include, 57 109 }) 58 110 .pipe( 59 111 Effect.map((x) => x.at(0)), ··· 67 119 ), 68 120 ); 69 121 70 - const artists = yield* Effect.all( 71 - album.artists.map(({ artist }) => Schema.decodeUnknown(Artist)(artist)), 72 - { concurrency: "unbounded" }, 73 - ); 122 + const artistDecoder = Schema.decodeUnknown(Artist); 74 123 75 - const songs = yield* Effect.all( 76 - album.songs.map((song) => 124 + const artists = yield* Effect.gen(function* () { 125 + const albumArtists = album.artists; 126 + 127 + if (albumArtists && Object.hasOwn(albumArtists[0], "artist")) { 128 + const decodeArtists = albumArtists?.map((relation) => { 129 + const rel = relation as Extract<typeof relation, { artist: { name: string } }>; 130 + const artist = rel.artist; 131 + 132 + return artistDecoder(artist); 133 + }); 134 + 135 + const artists = yield* Effect.all(decodeArtists, { concurrency: "unbounded" }); 136 + return artists; 137 + } 138 + return undefined; 139 + }); 140 + 141 + const songs = yield* Effect.gen(function* () { 142 + if (!album.songs) return undefined; 143 + 144 + const decodeSongs = album.songs.map((song) => 77 145 Effect.gen(function* () { 78 - const songArtists = yield* Effect.all( 79 - song.artists.map(({ artist }) => Schema.decodeUnknown(Artist)(artist)), 146 + const artists = yield* Effect.gen(function* () { 147 + if (!Object.hasOwn(song, "artists")) return undefined; 148 + const songWithArtists = song as Extract<typeof song, { artists: { songId: string }[] }>; 149 + 150 + const decodeArtists = songWithArtists.artists 151 + .map((relation) => { 152 + if (!Object.hasOwn(relation, "artist")) return undefined; 153 + 154 + const { artist } = relation as Extract< 155 + typeof relation, 156 + { artist: { name: string } } 157 + >; 158 + 159 + return artistDecoder(artist); 160 + }) 161 + .filter(Boolean) 162 + .map((x) => x as typeof x & {}); 163 + 164 + yield* Effect.log(decodeArtists); 165 + 166 + return yield* Effect.all(decodeArtists); 167 + }); 168 + 169 + const songDecoder = Schema.decodeUnknown( 170 + Schema.Struct({ 171 + ...Song.fields, 172 + artists: Schema.optional(Schema.Array(Artist)), 173 + }), 80 174 ); 81 - return yield* Schema.decodeUnknown(Song)({ 175 + 176 + return yield* songDecoder({ 82 177 ...song, 83 - artists: songArtists, 178 + artists, 84 179 }); 85 180 }), 86 - ), 87 - ); 181 + ); 182 + 183 + return yield* Effect.all(decodeSongs, { concurrency: "unbounded" }); 184 + }); 88 185 89 - const result = yield* Schema.decodeUnknown(Album)({ 186 + const result = yield* Schema.decodeUnknown( 187 + Schema.Struct({ 188 + ...Album.fields, 189 + songs: Schema.Struct({ 190 + ...Song.fields, 191 + artists: Artist.pipe(Schema.Array, Schema.optional), 192 + }).pipe(Schema.Array, Schema.optional), 193 + artists: Artist.pipe(Schema.Array, Schema.optional), 194 + }), 195 + )({ 90 196 id: album.id, 91 197 title: album.title, 92 198 artists: artists, ··· 97 203 }); 98 204 99 205 const getAlbumList = Effect.fn("getAlbumList")(function* () { 206 + const AlbumWithArtists = Schema.Struct({ 207 + ...Album.fields, 208 + artists: Schema.Array(Artist), 209 + }); 210 + 100 211 const rows = yield* db.query.albumTable 101 212 .findMany({ 102 213 with: { ··· 111 222 112 223 const result = rows.map((row) => 113 224 Effect.gen(function* () { 114 - const artists = yield* Effect.all( 115 - row.artists.map(({ artist }) => Schema.decodeUnknown(Artist)(artist)), 116 - { concurrency: "unbounded" }, 117 - ); 225 + const artistTasks = row.artists.map(({ artist }) => Schema.decodeUnknown(Artist)(artist)); 226 + const artists = yield* Effect.all(artistTasks, { concurrency: 10 }); 118 227 119 - return yield* Schema.decodeUnknown(Schema.Struct({ ...Album.fields, songs: Schema.Undefined }))({ 228 + return yield* Schema.decodeUnknown(AlbumWithArtists)({ 120 229 ...row, 121 230 artists, 122 231 }); ··· 220 329 .use(openapi()) 221 330 .get("/", "Hello Elysia") 222 331 .get("/albums", () => runtime.runPromise(ApiService.getAlbumList())) 223 - .get("/album/:id", ({ params: { id } }) => runtime.runPromise(ApiService.getAlbumById(id))) 332 + 333 + .get( 334 + "/album/:id", 335 + ({ params: { id }, query: { include } }) => { 336 + if (include?.some((x) => (getAlbumVariants as readonly string[]).includes(x) === false)) { 337 + throw new Error("Unsupported get album variant"); 338 + } 339 + 340 + const realInclude = include as GetAlbumVariant[] | undefined; 341 + 342 + return runtime.runPromise(ApiService.getAlbumById(id, realInclude)); 343 + }, 344 + 345 + { 346 + query: t.Object({ 347 + include: t.Optional(t.Array(t.String())), 348 + }), 349 + }, 350 + ) 351 + 224 352 .get( 225 353 "/file/:id", 226 354 ({ params: { id }, set }) =>
+4 -11
backend/src/types.ts
··· 1 1 import { Schema } from "effect"; 2 2 import { isValid } from "ulid"; 3 3 4 - const ArtistIdSymbol = Symbol.for("ArtistId"); 5 4 const ArtistId = Schema.NonEmptyString.pipe( 6 - Schema.brand(ArtistIdSymbol), 5 + Schema.brand("ArtistId"), 7 6 Schema.startsWith("artist_"), 8 7 Schema.filter((str) => { 9 8 const [, id] = str.split("_"); ··· 18 17 identifier: "Artist", 19 18 }); 20 19 21 - const AudioFileIdSymbol = Symbol.for("AudioFileId"); 22 20 const AudioFileId = Schema.NonEmptyString.pipe( 23 - Schema.brand(AudioFileIdSymbol), 21 + Schema.brand("AudioFileId"), 24 22 Schema.startsWith("file_"), 25 23 Schema.filter((str) => { 26 24 const [, id] = str.split("_"); ··· 35 33 identifier: "AudioFile", 36 34 }); 37 35 38 - const SongIdSymbol = Symbol.for("SongId"); 39 36 const SongId = Schema.NonEmptyString.pipe( 40 - Schema.brand(SongIdSymbol), 37 + Schema.brand("SongId"), 41 38 Schema.startsWith("song_"), 42 39 Schema.filter((str) => { 43 40 const [, id] = str.split("_"); ··· 47 44 const Song = Schema.Struct({ 48 45 id: SongId, 49 46 title: Schema.NonEmptyString, 50 - artists: Schema.Array(Artist), 51 47 // TODO: this can only positive non zero int 52 48 trackNumber: Schema.NullOr(Schema.Int), 53 49 fileId: AudioFileId, ··· 56 52 identifier: "Song", 57 53 }); 58 54 59 - const AlbumIdSymbol = Symbol.for("AlbumId"); 60 55 const AlbumId = Schema.NonEmptyString.pipe( 61 - Schema.brand(AlbumIdSymbol), 56 + Schema.brand("AlbumId"), 62 57 Schema.startsWith("album_"), 63 58 Schema.filter((str) => { 64 59 const [, id] = str.split("_"); ··· 68 63 const Album = Schema.Struct({ 69 64 id: AlbumId, 70 65 title: Schema.NonEmptyString, 71 - artists: Schema.Array(Artist), 72 - songs: Schema.Array(Song), 73 66 }).annotations({ 74 67 title: "albums", 75 68 identifier: "Album",