WIP. A little custom music server
0
fork

Configure Feed

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

refactor: move api logic into a service

+161 -196
+1 -1
api-testing/get-album-by-id.bru
··· 11 11 } 12 12 13 13 params:path { 14 - id: 019989a6-4774-7004-a3e8-f349e5896c98 14 + id: 019a215f-3f4e-7000-b79e-b0f965f0a75c 15 15 } 16 16 17 17 settings {
+1 -1
api-testing/get-file-by-id.bru
··· 11 11 } 12 12 13 13 params:path { 14 - id: 01998fe6-970d-7000-9e80-9324eab853ab 14 + id: 019a215f-3f42-7000-8204-0bc1e3c4c5c3 15 15 } 16 16 17 17 settings {
+159 -194
backend/src/api.ts
··· 1 - import { Console, Context, Data, Effect, Layer, ManagedRuntime } from "effect"; 2 - import { Elysia, status, t } from "elysia"; 1 + import { Data, Effect, Layer, ManagedRuntime } from "effect"; 2 + import { Elysia, status, StatusMap, t, type HTTPHeaders } from "elysia"; 3 3 import { DatabaseLive } from "./db"; 4 4 import { BunContext } from "@effect/platform-bun"; 5 5 import { EnvLive } from "./env"; 6 - import { albumTable, artistTable, artistToAlbumTable, fileTable, songTable, songToArtistTable } from "./db/schema"; 6 + import { albumTable, artistTable, fileTable, songTable, songToArtistTable } from "./db/schema"; 7 7 import { eq } from "drizzle-orm"; 8 8 import { openapi } from "@elysiajs/openapi"; 9 - import type { Album, AlbumWithArtist, Artist, Song } from "./db/types"; 10 - import type { SqlError } from "@effect/sql"; 11 9 import { pipe } from "effect"; 12 - import type { start } from "effect/ScheduleIntervals"; 10 + import type { ElysiaCookie } from "elysia/cookies"; 13 11 14 12 class FileNotFoundError extends Data.TaggedError("FileNotFoundError")<{ 15 13 message: string; ··· 20 18 cause?: unknown; 21 19 }> {} 22 20 23 - type SongWithArtists = Song & { artists: Artist[] }; 24 - 25 - type GetAlbum = Album & { 26 - artists: Artist[]; 27 - songs: Array<Omit<SongWithArtists, "albumId">>; 21 + type ElysiaSet = { 22 + headers: HTTPHeaders; 23 + status?: number | keyof StatusMap; 24 + redirect?: string; 25 + cookie?: Record<string, ElysiaCookie>; 28 26 }; 29 27 30 - class ApiService extends Context.Tag("ApiService")< 31 - ApiService, 32 - { 33 - readonly getAlbumList: () => Effect.Effect<AlbumWithArtist[], SqlError.SqlError, DatabaseLive>; 34 - readonly getAlbum: ( 35 - id: string, 36 - ) => Effect.Effect<GetAlbum, SqlError.SqlError | AlbumNotFoundError, DatabaseLive>; 37 - } 38 - >() {} 39 - 40 - const ApiLive = Layer.effect( 41 - ApiService, 42 - Effect.gen(function* () { 28 + class ApiService extends Effect.Service<ApiService>()("@boombox/backend/api/ApiService", { 29 + dependencies: [DatabaseLive.Default], 30 + accessors: true, 31 + effect: Effect.gen(function* () { 43 32 const db = yield* DatabaseLive; 44 33 45 - return { 46 - getAlbum: (id: string) => 47 - Effect.gen(function* () { 48 - const album = yield* db.query.albumTable 49 - .findMany({ 50 - where: eq(albumTable.id, id), 51 - limit: 1, 34 + const getAlbumById = Effect.fn("getAlbum")(function* (id: string) { 35 + const album = yield* db.query.albumTable 36 + .findMany({ 37 + where: eq(albumTable.id, id), 38 + limit: 1, 39 + with: { 40 + artists: { 41 + with: { 42 + artist: true, 43 + }, 44 + }, 45 + songs: { 52 46 with: { 53 47 artists: { 54 48 with: { 55 49 artist: true, 56 50 }, 57 51 }, 58 - songs: { 59 - with: { 60 - artists: { 61 - with: { 62 - artist: true, 63 - }, 64 - }, 65 - }, 66 - }, 67 52 }, 68 - }) 69 - .pipe( 70 - Effect.map((x) => x.at(0)), 71 - Effect.flatMap(Effect.fromNullable), 72 - Effect.mapError( 73 - (e) => 74 - new AlbumNotFoundError({ 75 - message: "Album not found", 76 - cause: e, 77 - }), 78 - ), 79 - ); 53 + }, 54 + }, 55 + }) 56 + .pipe( 57 + Effect.map((x) => x.at(0)), 58 + Effect.flatMap(Effect.fromNullable), 59 + Effect.mapError( 60 + (e) => 61 + new AlbumNotFoundError({ 62 + message: "Album not found", 63 + cause: e, 64 + }), 65 + ), 66 + ); 67 + 68 + const artists = album.artists.map((a) => a.artist); 69 + const songs = album.songs.map((s) => ({ 70 + ...s, 71 + artists: s.artists.map((a) => a.artist), 72 + })); 80 73 81 - const artists = album.artists.map((a) => a.artist); 82 - const songs = album.songs.map((s) => ({ 83 - ...s, 84 - artists: s.artists.map((a) => a.artist), 85 - })); 74 + return { 75 + ...album, 76 + artists, 77 + songs, 78 + }; 79 + }); 86 80 87 - return { 88 - ...album, 89 - artists, 90 - songs, 91 - }; 92 - }), 93 - getAlbumList: () => 94 - Effect.gen(function* () { 95 - const rows = yield* db.query.albumTable.findMany({ 96 - with: { 97 - artists: { 98 - with: { 99 - artist: true, 100 - }, 81 + const getAlbumList = Effect.fn("getAlbumList")(function* () { 82 + const rows = yield* db.query.albumTable 83 + .findMany({ 84 + with: { 85 + artists: { 86 + with: { 87 + artist: true, 101 88 }, 102 89 }, 103 - }); 90 + }, 91 + }) 92 + .pipe(Effect.catchAll((_) => Effect.succeed([]))); 104 93 105 - return rows.map((r) => ({ 106 - ...r, 107 - artists: r.artists.map((a) => a.artist), 108 - })); 109 - }), 110 - }; 111 - }), 112 - ); 94 + return rows.map((r) => ({ 95 + ...r, 96 + artists: r.artists.map((a) => a.artist), 97 + })); 98 + }); 113 99 114 - export function startApi() { 115 - return new Elysia() 116 - .use(openapi()) 117 - .get("/", "Hello Elysia") 118 - .get("/albums", () => 119 - pipe( 120 - ApiService, 121 - Effect.andThen((x) => x.getAlbumList()), 122 - runtime.runPromise, 123 - ), 124 - ) 100 + const getFileById = Effect.fn("getFileById")(function* (id: string, set: ElysiaSet) { 101 + const files = yield* db.select().from(fileTable).where(eq(fileTable.id, id)).limit(1); 102 + 103 + const file = files.at(0); 104 + 105 + if (!file) { 106 + return yield* Effect.fail( 107 + new FileNotFoundError({ 108 + message: `Failed to find file with id ${id}`, 109 + cause: { id }, 110 + }), 111 + ); 112 + } 113 + 114 + const fileHandle = Bun.file(file.path); 115 + 116 + const fileExists = yield* Effect.tryPromise(() => fileHandle.exists()); 117 + 118 + // Check if file exists 119 + if (!fileExists) { 120 + return yield* Effect.fail( 121 + new FileNotFoundError({ 122 + message: `File not found on disk: ${file.path}`, 123 + cause: { id, path: file.path }, 124 + }), 125 + ); 126 + } 125 127 126 - .get("/album2/:id", ({ params: { id } }) => 127 - pipe( 128 - ApiService, 129 - Effect.andThen((x) => x.getAlbum2(id)), 130 - runtime.runPromise, 131 - ), 132 - ) 133 - .get("/album/:id", ({ params: { id } }) => 134 - pipe( 135 - ApiService, 136 - Effect.andThen((x) => x.getAlbum(id)), 137 - runtime.runPromise, 138 - ), 139 - ) 140 - .get( 141 - "/file/:id", 142 - ({ params: { id }, set }) => 143 - runtime.runPromise( 144 - Effect.gen(function* () { 145 - const db = yield* DatabaseLive; 128 + const filename = file.path.split("/").pop(); 129 + const encodedFilename = encodeURIComponent(filename ?? ""); 146 130 147 - const files = yield* db.select().from(fileTable).where(eq(fileTable.id, id)).limit(1); 131 + set.headers["Content-Disposition"] = 132 + `attachment; filename="${encodedFilename}"; filename*=UTF-8''${encodedFilename}`; 133 + set.headers["Content-Type"] = "audio/flac"; 134 + set.headers["Accept-Ranges"] = "bytes"; 148 135 149 - const file = files.at(0); 136 + return fileHandle; 137 + }); 150 138 151 - if (!file) { 152 - return yield* Effect.fail( 153 - new FileNotFoundError({ 154 - message: `Failed to find file with id ${id}`, 155 - cause: { id }, 156 - }), 157 - ); 158 - } 139 + const getAllSongs = Effect.fn("getAllSongs")(function* () { 140 + const rows = yield* Effect.tryPromise(() => 141 + db 142 + .select({ 143 + song: songTable, 144 + album: albumTable, 145 + artist: artistTable, 146 + }) 147 + .from(songTable) 148 + .innerJoin(albumTable, eq(songTable.albumId, albumTable.id)) 149 + .innerJoin(songToArtistTable, eq(songToArtistTable.songId, songTable.id)) 150 + .innerJoin(artistTable, eq(songToArtistTable.artistId, artistTable.id)) 151 + .all(), 152 + ); 159 153 160 - const fileHandle = Bun.file(file.path); 154 + const reduced = rows.reduce<Record<string, GetSongType>>((acc, row) => { 155 + const { song, album, artist } = row; 161 156 162 - const fileExists = yield* Effect.tryPromise(() => fileHandle.exists()); 157 + if (!acc[song.id]) { 158 + acc[song.id] = { 159 + id: song.id, 160 + fileId: song.fileId, 161 + title: song.title, 162 + album: { 163 + id: album.id, 164 + title: album.title, 165 + }, 166 + artists: [], 167 + }; 168 + } 163 169 164 - // Check if file exists 165 - if (!fileExists) { 166 - return yield* Effect.fail( 167 - new FileNotFoundError({ 168 - message: `File not found on disk: ${file.path}`, 169 - cause: { id, path: file.path }, 170 - }), 171 - ); 172 - } 170 + if (artist) { 171 + acc[song.id].artists.push(artist); 172 + } 173 173 174 - const filename = file.path.split("/").pop(); 175 - const encodedFilename = encodeURIComponent(filename ?? ""); 174 + return acc; 175 + }, {}); 176 176 177 - set.headers["Content-Disposition"] = 178 - `attachment; filename="${encodedFilename}"; filename*=UTF-8''${encodedFilename}`; 179 - set.headers["Content-Type"] = "audio/flac"; 180 - set.headers["Accept-Ranges"] = "bytes"; 177 + return Object.values(reduced); 178 + }); 181 179 182 - return fileHandle; 180 + return { 181 + getAlbumById, 182 + getAlbumList, 183 + getFileById, 184 + getAllSongs, 185 + }; 186 + }), 187 + }) {} 183 188 184 - return file; 185 - }).pipe(Effect.catchTag("FileNotFoundError", (e) => Effect.succeed(status(404, e.message)))), 189 + export function startApi() { 190 + return new Elysia() 191 + .use(openapi()) 192 + .get("/", "Hello Elysia") 193 + .get("/albums", () => runtime.runPromise(ApiService.getAlbumList())) 194 + .get("/album/:id", ({ params: { id } }) => runtime.runPromise(ApiService.getAlbumById(id))) 195 + .get( 196 + "/file/:id", 197 + ({ params: { id }, set }) => 198 + pipe( 199 + ApiService.getFileById(id, set), 200 + Effect.catchTag("FileNotFoundError", (e) => Effect.succeed(status(404, e.message))), 201 + runtime.runPromise, 186 202 ), 187 203 { 188 204 params: t.Object({ ··· 190 206 }), 191 207 }, 192 208 ) 193 - .get("/songs", () => 194 - runtime.runPromise( 195 - Effect.gen(function* () { 196 - const db = yield* DatabaseLive; 197 - const rows = yield* Effect.tryPromise(() => 198 - db 199 - .select({ 200 - song: songTable, 201 - album: albumTable, 202 - artist: artistTable, 203 - }) 204 - .from(songTable) 205 - .innerJoin(albumTable, eq(songTable.albumId, albumTable.id)) 206 - .innerJoin(songToArtistTable, eq(songToArtistTable.songId, songTable.id)) 207 - .innerJoin(artistTable, eq(songToArtistTable.artistId, artistTable.id)) 208 - .all(), 209 - ); 210 - 211 - yield* Console.dir(rows); 212 - 213 - const reduced = rows.reduce<Record<string, GetSongType>>((acc, row) => { 214 - const { song, album, artist } = row; 215 - 216 - if (!acc[song.id]) { 217 - acc[song.id] = { 218 - id: song.id, 219 - fileId: song.fileId, 220 - title: song.title, 221 - album: { 222 - id: album.id, 223 - title: album.title, 224 - }, 225 - artists: [], 226 - }; 227 - } 228 - 229 - if (artist) { 230 - acc[song.id].artists.push(artist); 231 - } 232 - 233 - return acc; 234 - }, {}); 235 - 236 - return Object.values(reduced); 237 - }).pipe(Effect.withSpan("/get-songs")), 238 - ), 239 - ) 209 + .get("/songs", () => pipe(ApiService.getAllSongs(), runtime.runPromise)) 240 210 .listen(3003); 241 211 } 242 212 ··· 254 224 }>; 255 225 }; 256 226 257 - const layers = Layer.mergeAll( 258 - BunContext.layer, 259 - EnvLive, 260 - DatabaseLive.Default, 261 - Layer.provide(ApiLive, DatabaseLive.Default), 262 - ); 227 + const layers = Layer.mergeAll(BunContext.layer, EnvLive, DatabaseLive.Default, ApiService.Default); 263 228 const runtime = ManagedRuntime.make(layers); 264 229 265 230 export type ApiType = ReturnType<typeof startApi>;