WIP. A little custom music server
0
fork

Configure Feed

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

feat: add musicbrainz query variant to api

+459 -35
+14
backend/drizzle/0008_acoustic_spencer_smythe.sql
··· 1 + PRAGMA foreign_keys=OFF;--> statement-breakpoint 2 + CREATE TABLE `__new_musicbrainz` ( 3 + `id` text PRIMARY KEY NOT NULL, 4 + `releaseGroupId` text, 5 + `artistId` text, 6 + `trackId` text, 7 + `albumId` text NOT NULL, 8 + FOREIGN KEY (`albumId`) REFERENCES `album`(`id`) ON UPDATE no action ON DELETE no action 9 + ); 10 + --> statement-breakpoint 11 + INSERT INTO `__new_musicbrainz`("id", "releaseGroupId", "artistId", "trackId", "albumId") SELECT "id", "releaseGroupId", "artistId", "trackId", "albumId" FROM `musicbrainz`;--> statement-breakpoint 12 + DROP TABLE `musicbrainz`;--> statement-breakpoint 13 + ALTER TABLE `__new_musicbrainz` RENAME TO `musicbrainz`;--> statement-breakpoint 14 + PRAGMA foreign_keys=ON;
+373
backend/drizzle/meta/0008_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "9396fc67-3ae3-49ae-9c92-80fca1727ec5", 5 + "prevId": "6a7b5f7e-becb-45e8-9a78-3668529a4a5e", 6 + "tables": { 7 + "album": { 8 + "name": "album", 9 + "columns": { 10 + "id": { 11 + "name": "id", 12 + "type": "text", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "title": { 18 + "name": "title", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + } 24 + }, 25 + "indexes": { 26 + "album_title_unique": { 27 + "name": "album_title_unique", 28 + "columns": [ 29 + "title" 30 + ], 31 + "isUnique": true 32 + } 33 + }, 34 + "foreignKeys": {}, 35 + "compositePrimaryKeys": {}, 36 + "uniqueConstraints": {}, 37 + "checkConstraints": {} 38 + }, 39 + "artist": { 40 + "name": "artist", 41 + "columns": { 42 + "id": { 43 + "name": "id", 44 + "type": "text", 45 + "primaryKey": true, 46 + "notNull": true, 47 + "autoincrement": false 48 + }, 49 + "name": { 50 + "name": "name", 51 + "type": "text", 52 + "primaryKey": false, 53 + "notNull": true, 54 + "autoincrement": false 55 + } 56 + }, 57 + "indexes": { 58 + "artist_name_unique": { 59 + "name": "artist_name_unique", 60 + "columns": [ 61 + "name" 62 + ], 63 + "isUnique": true 64 + } 65 + }, 66 + "foreignKeys": {}, 67 + "compositePrimaryKeys": {}, 68 + "uniqueConstraints": {}, 69 + "checkConstraints": {} 70 + }, 71 + "artist_to_album": { 72 + "name": "artist_to_album", 73 + "columns": { 74 + "artistId": { 75 + "name": "artistId", 76 + "type": "text", 77 + "primaryKey": false, 78 + "notNull": true, 79 + "autoincrement": false 80 + }, 81 + "albumId": { 82 + "name": "albumId", 83 + "type": "text", 84 + "primaryKey": false, 85 + "notNull": true, 86 + "autoincrement": false 87 + } 88 + }, 89 + "indexes": { 90 + "artist_to_album_artistId_albumId_unique": { 91 + "name": "artist_to_album_artistId_albumId_unique", 92 + "columns": [ 93 + "artistId", 94 + "albumId" 95 + ], 96 + "isUnique": true 97 + } 98 + }, 99 + "foreignKeys": { 100 + "artist_to_album_artistId_artist_id_fk": { 101 + "name": "artist_to_album_artistId_artist_id_fk", 102 + "tableFrom": "artist_to_album", 103 + "tableTo": "artist", 104 + "columnsFrom": [ 105 + "artistId" 106 + ], 107 + "columnsTo": [ 108 + "id" 109 + ], 110 + "onDelete": "cascade", 111 + "onUpdate": "no action" 112 + }, 113 + "artist_to_album_albumId_album_id_fk": { 114 + "name": "artist_to_album_albumId_album_id_fk", 115 + "tableFrom": "artist_to_album", 116 + "tableTo": "album", 117 + "columnsFrom": [ 118 + "albumId" 119 + ], 120 + "columnsTo": [ 121 + "id" 122 + ], 123 + "onDelete": "cascade", 124 + "onUpdate": "no action" 125 + } 126 + }, 127 + "compositePrimaryKeys": {}, 128 + "uniqueConstraints": {}, 129 + "checkConstraints": {} 130 + }, 131 + "file": { 132 + "name": "file", 133 + "columns": { 134 + "id": { 135 + "name": "id", 136 + "type": "text", 137 + "primaryKey": true, 138 + "notNull": true, 139 + "autoincrement": false 140 + }, 141 + "path": { 142 + "name": "path", 143 + "type": "text", 144 + "primaryKey": false, 145 + "notNull": true, 146 + "autoincrement": false 147 + } 148 + }, 149 + "indexes": { 150 + "file_path_unique": { 151 + "name": "file_path_unique", 152 + "columns": [ 153 + "path" 154 + ], 155 + "isUnique": true 156 + } 157 + }, 158 + "foreignKeys": {}, 159 + "compositePrimaryKeys": {}, 160 + "uniqueConstraints": {}, 161 + "checkConstraints": {} 162 + }, 163 + "musicbrainz": { 164 + "name": "musicbrainz", 165 + "columns": { 166 + "id": { 167 + "name": "id", 168 + "type": "text", 169 + "primaryKey": true, 170 + "notNull": true, 171 + "autoincrement": false 172 + }, 173 + "releaseGroupId": { 174 + "name": "releaseGroupId", 175 + "type": "text", 176 + "primaryKey": false, 177 + "notNull": false, 178 + "autoincrement": false 179 + }, 180 + "artistId": { 181 + "name": "artistId", 182 + "type": "text", 183 + "primaryKey": false, 184 + "notNull": false, 185 + "autoincrement": false 186 + }, 187 + "trackId": { 188 + "name": "trackId", 189 + "type": "text", 190 + "primaryKey": false, 191 + "notNull": false, 192 + "autoincrement": false 193 + }, 194 + "albumId": { 195 + "name": "albumId", 196 + "type": "text", 197 + "primaryKey": false, 198 + "notNull": true, 199 + "autoincrement": false 200 + } 201 + }, 202 + "indexes": {}, 203 + "foreignKeys": { 204 + "musicbrainz_albumId_album_id_fk": { 205 + "name": "musicbrainz_albumId_album_id_fk", 206 + "tableFrom": "musicbrainz", 207 + "tableTo": "album", 208 + "columnsFrom": [ 209 + "albumId" 210 + ], 211 + "columnsTo": [ 212 + "id" 213 + ], 214 + "onDelete": "no action", 215 + "onUpdate": "no action" 216 + } 217 + }, 218 + "compositePrimaryKeys": {}, 219 + "uniqueConstraints": {}, 220 + "checkConstraints": {} 221 + }, 222 + "song": { 223 + "name": "song", 224 + "columns": { 225 + "id": { 226 + "name": "id", 227 + "type": "text", 228 + "primaryKey": true, 229 + "notNull": true, 230 + "autoincrement": false 231 + }, 232 + "title": { 233 + "name": "title", 234 + "type": "text", 235 + "primaryKey": false, 236 + "notNull": true, 237 + "autoincrement": false 238 + }, 239 + "trackNumber": { 240 + "name": "trackNumber", 241 + "type": "integer", 242 + "primaryKey": false, 243 + "notNull": false, 244 + "autoincrement": false 245 + }, 246 + "fileId": { 247 + "name": "fileId", 248 + "type": "text", 249 + "primaryKey": false, 250 + "notNull": true, 251 + "autoincrement": false 252 + }, 253 + "albumId": { 254 + "name": "albumId", 255 + "type": "text", 256 + "primaryKey": false, 257 + "notNull": true, 258 + "autoincrement": false 259 + } 260 + }, 261 + "indexes": { 262 + "song_fileId_unique": { 263 + "name": "song_fileId_unique", 264 + "columns": [ 265 + "fileId" 266 + ], 267 + "isUnique": true 268 + } 269 + }, 270 + "foreignKeys": { 271 + "song_fileId_file_id_fk": { 272 + "name": "song_fileId_file_id_fk", 273 + "tableFrom": "song", 274 + "tableTo": "file", 275 + "columnsFrom": [ 276 + "fileId" 277 + ], 278 + "columnsTo": [ 279 + "id" 280 + ], 281 + "onDelete": "cascade", 282 + "onUpdate": "no action" 283 + }, 284 + "song_albumId_album_id_fk": { 285 + "name": "song_albumId_album_id_fk", 286 + "tableFrom": "song", 287 + "tableTo": "album", 288 + "columnsFrom": [ 289 + "albumId" 290 + ], 291 + "columnsTo": [ 292 + "id" 293 + ], 294 + "onDelete": "cascade", 295 + "onUpdate": "no action" 296 + } 297 + }, 298 + "compositePrimaryKeys": {}, 299 + "uniqueConstraints": {}, 300 + "checkConstraints": {} 301 + }, 302 + "song_to_artist": { 303 + "name": "song_to_artist", 304 + "columns": { 305 + "songId": { 306 + "name": "songId", 307 + "type": "text", 308 + "primaryKey": false, 309 + "notNull": true, 310 + "autoincrement": false 311 + }, 312 + "artistId": { 313 + "name": "artistId", 314 + "type": "text", 315 + "primaryKey": false, 316 + "notNull": true, 317 + "autoincrement": false 318 + } 319 + }, 320 + "indexes": { 321 + "song_to_artist_songId_artistId_unique": { 322 + "name": "song_to_artist_songId_artistId_unique", 323 + "columns": [ 324 + "songId", 325 + "artistId" 326 + ], 327 + "isUnique": true 328 + } 329 + }, 330 + "foreignKeys": { 331 + "song_to_artist_songId_song_id_fk": { 332 + "name": "song_to_artist_songId_song_id_fk", 333 + "tableFrom": "song_to_artist", 334 + "tableTo": "song", 335 + "columnsFrom": [ 336 + "songId" 337 + ], 338 + "columnsTo": [ 339 + "id" 340 + ], 341 + "onDelete": "cascade", 342 + "onUpdate": "no action" 343 + }, 344 + "song_to_artist_artistId_artist_id_fk": { 345 + "name": "song_to_artist_artistId_artist_id_fk", 346 + "tableFrom": "song_to_artist", 347 + "tableTo": "artist", 348 + "columnsFrom": [ 349 + "artistId" 350 + ], 351 + "columnsTo": [ 352 + "id" 353 + ], 354 + "onDelete": "cascade", 355 + "onUpdate": "no action" 356 + } 357 + }, 358 + "compositePrimaryKeys": {}, 359 + "uniqueConstraints": {}, 360 + "checkConstraints": {} 361 + } 362 + }, 363 + "views": {}, 364 + "enums": {}, 365 + "_meta": { 366 + "schemas": {}, 367 + "tables": {}, 368 + "columns": {} 369 + }, 370 + "internal": { 371 + "indexes": {} 372 + } 373 + }
+7
backend/drizzle/meta/_journal.json
··· 57 57 "when": 1766333668879, 58 58 "tag": "0007_loud_microbe", 59 59 "breakpoints": true 60 + }, 61 + { 62 + "idx": 8, 63 + "version": "6", 64 + "when": 1766335417147, 65 + "tag": "0008_acoustic_spencer_smythe", 66 + "breakpoints": true 60 67 } 61 68 ] 62 69 }
+26 -24
backend/src/api.ts
··· 1 - import { Console, Data, Effect, Layer, ManagedRuntime, ParseResult, Schema } from "effect"; 1 + import { Data, Effect, Layer, ManagedRuntime, ParseResult, 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"; ··· 8 8 import { openapi } from "@elysiajs/openapi"; 9 9 import { pipe } from "effect"; 10 10 import type { ElysiaCookie } from "elysia/cookies"; 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"; 11 + import { Album, Artist, Musicbrainz, Song } from "./types"; 15 12 16 13 class FileNotFoundError extends Data.TaggedError("FileNotFoundError")<{ 17 14 message: string; ··· 31 28 32 29 const GetAlbumSchema = Schema.Struct({ 33 30 ...Album.fields, 34 - artists: Schema.optional(Artist), 35 - songs: Schema.optional( 36 - Schema.Struct({ 37 - ...Song.fields, 38 - artists: Schema.optional(Artist), 39 - }), 40 - ), 31 + artists: Artist.pipe(Schema.Array, Schema.optional), 32 + songs: Schema.Struct({ 33 + ...Song.fields, 34 + artists: Artist.pipe(Schema.Array, Schema.optional), 35 + }).pipe(Schema.Array, Schema.optional), 36 + musicbrainz: Schema.optional(Musicbrainz), 41 37 }); 42 38 43 39 type AlbumQueryInclude = { 40 + musicbrainz?: true; 44 41 artists?: { 45 42 with: { 46 43 artist: true; ··· 58 55 }; 59 56 }; 60 57 61 - const getAlbumVariants = ["album-artist", "song", "song-artist"] as const; 62 - type GetAlbumVariant = (typeof getAlbumVariants)[number]; 58 + const getAlbumVariants = ["album-artist", "song", "song-artist", "musicbrainz"] as const; 59 + type GetAlbumVariant = (typeof getAlbumVariants)[number] | (string & {}); 63 60 64 61 class ApiService extends Effect.Service<ApiService>()("@boombox/backend/api/ApiService", { 65 62 dependencies: [DatabaseLive.Default], ··· 69 66 70 67 const getAlbumById = Effect.fn("getAlbum")(function* (id: string, relations: Array<GetAlbumVariant> = []) { 71 68 const withArtist = { artists: { with: { artist: true } } } satisfies AlbumQueryInclude; 69 + 72 70 const withSongArtist = { 73 71 songs: { 74 72 with: { ··· 80 78 }, 81 79 }, 82 80 } satisfies AlbumQueryInclude; 81 + 83 82 const withSongs = { 84 83 songs: { 85 84 orderBy: [songTable.trackNumber], ··· 87 86 }, 88 87 } satisfies AlbumQueryInclude; 89 88 89 + const withMusicbrainz = { 90 + musicbrainz: true, 91 + } satisfies AlbumQueryInclude; 92 + 90 93 const include = relations.reduce<AlbumQueryInclude>((acc, rel) => { 91 94 switch (rel) { 92 95 case "album-artist": ··· 95 98 return { ...acc, ...withSongs }; 96 99 case "song-artist": 97 100 return { ...acc, ...withSongArtist }; 101 + case "musicbrainz": 102 + return { ...acc, ...withMusicbrainz }; 98 103 99 104 default: 100 105 return acc; 101 106 } 102 107 }, {}); 108 + 109 + yield* Effect.log(include); 103 110 104 111 const album = yield* db.query.albumTable 105 112 .findMany({ ··· 110 117 .pipe( 111 118 Effect.map((x) => x.at(0)), 112 119 Effect.flatMap(Effect.fromNullable), 120 + Effect.tapError(Effect.logError), 113 121 Effect.mapError( 114 122 (e) => 115 123 new AlbumNotFoundError({ ··· 118 126 }), 119 127 ), 120 128 ); 129 + 130 + yield* Effect.log(album); 121 131 122 132 const DatabaseSchema = Schema.Struct({ 123 133 ...Album.fields, ··· 130 140 artist: Artist, 131 141 }).pipe(Schema.Array, Schema.optional), 132 142 }).pipe(Schema.Array, Schema.optional), 133 - }); 134 - 135 - const ResultSchema = Schema.Struct({ 136 - ...Album.fields, 137 - artists: Artist.pipe(Schema.Array, Schema.optional), 138 - songs: Schema.Struct({ 139 - ...Song.fields, 140 - artists: Artist.pipe(Schema.Array, Schema.optional), 141 - }).pipe(Schema.Array, Schema.optional), 143 + musicbrainz: Schema.optional(Musicbrainz), 142 144 }); 143 145 144 - const transformer = Schema.transformOrFail(DatabaseSchema, ResultSchema, { 146 + const transformer = Schema.transformOrFail(DatabaseSchema, GetAlbumSchema, { 145 147 strict: true, 146 148 decode: (input, _, ast) => 147 149 ParseResult.succeed({
+11 -5
backend/src/db/schema.ts
··· 62 62 ); 63 63 64 64 export const musicbrainzTable = sqliteTable("musicbrainz", { 65 - id: id("mzb"), 65 + id: id("mbz"), 66 66 releaseGroupId: text(), 67 67 artistId: text(), 68 68 trackId: text(), 69 - albumId: text().references(() => albumTable.id), 69 + albumId: text() 70 + .notNull() 71 + .references(() => albumTable.id), 70 72 }); 71 73 72 - export const albumMusicbrainzRelation = relations(albumTable, ({ one }) => ({ 73 - musicbrainz: one(musicbrainzTable), 74 + export const musicbrainzRelations = relations(musicbrainzTable, ({ one }) => ({ 75 + album: one(albumTable, { 76 + fields: [musicbrainzTable.albumId], 77 + references: [albumTable.id], 78 + }), 74 79 })); 75 80 76 81 export const artistRelations = relations(artistTable, ({ many }) => ({ ··· 78 83 albums: many(artistToAlbumTable), 79 84 })); 80 85 81 - export const albumRelations = relations(albumTable, ({ many }) => ({ 86 + export const albumRelations = relations(albumTable, ({ many, one }) => ({ 82 87 songs: many(songTable), 83 88 artists: many(artistToAlbumTable), 89 + musicbrainz: one(musicbrainzTable), 84 90 })); 85 91 86 92 export const songToArtistTable = sqliteTable(
+19 -1
backend/src/types.ts
··· 71 71 identifier: "Album", 72 72 }); 73 73 74 - export { Song, Album, Artist, AudioFile }; 74 + const MusicbrainzId = Schema.NonEmptyString.pipe( 75 + Schema.brand("MusicbrainzId"), 76 + Schema.startsWith("mzb_"), 77 + Schema.filter((str) => { 78 + const [, id] = str.split("_"); 79 + return isValid(id as string) ? true : `Extpected MusicbrainzID to end with valid ulid but recieved (${id})`; 80 + }), 81 + ); 82 + const Musicbrainz = Schema.Struct({ 83 + id: MusicbrainzId, 84 + releaseGroupId: Schema.optional(Schema.String), 85 + trackId: Schema.optional(Schema.String), 86 + artistId: Schema.optional(Schema.String), 87 + }).annotations({ 88 + title: "musicbrainz", 89 + identifier: "Musicbrainz", 90 + }); 91 + 92 + export { Song, Album, Artist, AudioFile, Musicbrainz };
+9 -5
yaak/yaak.rq_KZgY7fLeqp.yaml
··· 2 2 model: http_request 3 3 id: rq_KZgY7fLeqp 4 4 createdAt: 2025-12-21T07:49:27.879149 5 - updatedAt: 2025-12-21T16:07:08.926580555 5 + updatedAt: 2025-12-21T16:48:57.467171027 6 6 workspaceId: wk_ojuTjFeKu7 7 7 folderId: null 8 8 authentication: {} ··· 14 14 method: GET 15 15 name: get-album-by-id 16 16 sortPriority: -1766303145229.0 17 - url: localhost:3003/album/:id?include=song,album-artist,song-artist 17 + url: localhost:3003/album/:id?include=song,album-artist,song-artist,musicbrainz 18 18 urlParameters: 19 19 - enabled: true 20 20 name: :id 21 - value: album_01KD0T5DCWQ857MK16GWK030D3 21 + value: album_01KD0WQBS1MSJ7R6R0YDRCRMW9 22 22 id: U3siCTcZtm 23 - - enabled: true 23 + - enabled: false 24 24 name: :id?include=song,album-artist,song-artist 25 25 value: '' 26 26 id: QH7HgP4I8y 27 + - enabled: false 28 + name: :id?include=song,album-artist,song-artist,musicbrainz 29 + value: '' 30 + id: Herdkw9cse 27 31 - enabled: true 28 32 name: '' 29 33 value: '' 30 - id: 2epsPgMWCS 34 + id: FyTtRkpvVm