WIP. A little custom music server
0
fork

Configure Feed

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

feat: record musicbrainz related stuff into DB too

+463 -18
+8
backend/drizzle/0007_loud_microbe.sql
··· 1 + CREATE TABLE `musicbrainz` ( 2 + `id` text PRIMARY KEY NOT NULL, 3 + `releaseGroupId` text, 4 + `artistId` text, 5 + `trackId` text, 6 + `albumId` text, 7 + FOREIGN KEY (`albumId`) REFERENCES `album`(`id`) ON UPDATE no action ON DELETE no action 8 + );
+373
backend/drizzle/meta/0007_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "6a7b5f7e-becb-45e8-9a78-3668529a4a5e", 5 + "prevId": "ccf9852e-d083-4c6c-bb5c-e7be98473aa0", 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": false, 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
··· 50 50 "when": 1762350364881, 51 51 "tag": "0006_jittery_skreet", 52 52 "breakpoints": true 53 + }, 54 + { 55 + "idx": 7, 56 + "version": "6", 57 + "when": 1766333668879, 58 + "tag": "0007_loud_microbe", 59 + "breakpoints": true 53 60 } 54 61 ] 55 62 }
+12
backend/src/db/schema.ts
··· 61 61 (t) => [unique().on(t.title)], 62 62 ); 63 63 64 + export const musicbrainzTable = sqliteTable("musicbrainz", { 65 + id: id("mzb"), 66 + releaseGroupId: text(), 67 + artistId: text(), 68 + trackId: text(), 69 + albumId: text().references(() => albumTable.id), 70 + }); 71 + 72 + export const albumMusicbrainzRelation = relations(albumTable, ({ one }) => ({ 73 + musicbrainz: one(musicbrainzTable), 74 + })); 75 + 64 76 export const artistRelations = relations(artistTable, ({ many }) => ({ 65 77 songs: many(songToArtistTable), 66 78 albums: many(artistToAlbumTable),
+63 -18
backend/src/sync-library.ts
··· 3 3 import { readDirectory, type ParseResult, type SkippedFile } from "./file-parser"; 4 4 import { inArray } from "drizzle-orm"; 5 5 6 - import { albumTable, artistTable, artistToAlbumTable, fileTable, songTable, songToArtistTable } from "./db/schema"; 6 + import { 7 + albumTable, 8 + artistTable, 9 + artistToAlbumTable, 10 + fileTable, 11 + musicbrainzTable, 12 + songTable, 13 + songToArtistTable, 14 + } from "./db/schema"; 7 15 import type { MetadataWithFilepathSchema } from "./metadata"; 8 16 import { colors } from "./utils/chalk"; 9 17 ··· 17 25 18 26 const stream = yield* readDirectory( 19 27 libraryPath, 20 - alreadyIndexed.map((x) => x.path) 28 + alreadyIndexed.map((x) => x.path), 21 29 ); 22 30 23 31 let successCount = 0; ··· 44 52 "by", 45 53 colors.FgYellow, 46 54 x.artists.join(","), 47 - colors.Reset 48 - ) 55 + colors.Reset, 56 + ), 49 57 ), 50 58 Stream.tap(() => { 51 59 successCount++; ··· 54 62 Stream.grouped(10), 55 63 // Reduce concurrency to 1 to avoid SQLite lock contention 56 64 Stream.mapEffect(saveChunk, { concurrency: 1 }), 57 - Stream.runDrain 65 + Stream.runDrain, 58 66 ); 59 67 60 68 // Print summary ··· 77 85 const saveChunk = Effect.fn("save-chunk")(function* (chunk: Chunk.Chunk<Metadata>) { 78 86 const [files, artists, albums] = yield* Effect.all( 79 87 [createFiles(chunk), createArtists(chunk), createAlbums(chunk)], 80 - { concurrency: 3 } 88 + { concurrency: 3 }, 81 89 ); 82 90 83 91 const songs = yield* createSongs(chunk, { files, albums }); ··· 92 100 artists, 93 101 albums, 94 102 }), 103 + connectMusicbrainz(chunk, { 104 + albums, 105 + }), 95 106 ], 96 - { concurrency: 2 } 107 + { concurrency: 3 }, 97 108 ); 98 109 99 110 return { ··· 103 114 }; 104 115 }); 105 116 117 + const connectMusicbrainz = Effect.fn("createMusicbrainz")(function* ( 118 + chunk: Chunk.Chunk<Metadata>, 119 + lookup: { 120 + albums: Effect.Effect.Success<ReturnType<typeof createAlbums>>; 121 + }, 122 + ) { 123 + const db = yield* DatabaseLive; 124 + const newAlbums = Chunk.toArray(chunk) 125 + .flatMap((entry) => ({ 126 + ...entry, 127 + albumId: lookup.albums.find((x) => x.title === entry.title)?.id ?? "", 128 + })) 129 + .filter( 130 + (x) => 131 + x.albumId && [x.musicBrainzArtistId, x.musicBrainzReleaseGroupId, x.musicBrainzTrackId].some(Boolean), 132 + ); 133 + 134 + if (!newAlbums || !newAlbums.length) { 135 + return; 136 + } 137 + 138 + yield* db 139 + .insert(musicbrainzTable) 140 + .values( 141 + newAlbums.map((x) => ({ 142 + releaseGroupId: x.musicBrainzReleaseGroupId, 143 + artistId: x.musicBrainzArtistId, 144 + trackId: x.musicBrainzTrackId, 145 + albumId: x.albumId, 146 + })), 147 + ) 148 + .onConflictDoNothing(); 149 + }); 150 + 106 151 const connectArtistToSong = Effect.fn("connect-artist-song")(function* ( 107 152 chunk: Chunk.Chunk<Metadata>, 108 153 109 154 lookup: { 110 155 songs: Effect.Effect.Success<ReturnType<typeof createSongs>>; 111 156 artists: Effect.Effect.Success<ReturnType<typeof createArtists>>; 112 - } 157 + }, 113 158 ) { 114 159 const db = yield* DatabaseLive; 115 160 const newSongArtist = Chunk.toArray(chunk) ··· 117 162 entry.artists.map((artist) => ({ 118 163 artistId: lookup.artists.find((x) => x.name === artist)?.id ?? "", 119 164 songId: lookup.songs.find((x) => x.title === entry.title)?.id ?? "", 120 - })) 165 + })), 121 166 ) 122 167 .filter((x) => x.songId && x.artistId); 123 168 ··· 134 179 lookup: { 135 180 albums: Effect.Effect.Success<ReturnType<typeof createAlbums>>; 136 181 artists: Effect.Effect.Success<ReturnType<typeof createArtists>>; 137 - } 182 + }, 138 183 ) { 139 184 const db = yield* DatabaseLive; 140 185 const newAlbumArtists = Chunk.toArray(chunk) ··· 156 201 lookup: { 157 202 files: Effect.Effect.Success<ReturnType<typeof createFiles>>; 158 203 albums: Effect.Effect.Success<ReturnType<typeof createAlbums>>; 159 - } 204 + }, 160 205 ) { 161 206 const db = yield* DatabaseLive; 162 207 ··· 182 227 .where( 183 228 inArray( 184 229 songTable.fileId, 185 - newSongs.map((x) => x.fileId) 186 - ) 230 + newSongs.map((x) => x.fileId), 231 + ), 187 232 ); 188 233 }); 189 234 ··· 209 254 .where( 210 255 inArray( 211 256 fileTable.path, 212 - newFiles.map((x) => x.path) 213 - ) 257 + newFiles.map((x) => x.path), 258 + ), 214 259 ); 215 260 }); 216 261 ··· 228 273 .values( 229 274 newArtists.map((x) => ({ 230 275 name: x, 231 - })) 276 + })), 232 277 ) 233 278 .onConflictDoNothing() 234 279 .returning(); ··· 256 301 .values( 257 302 newAlbums.map((x) => ({ 258 303 title: x, 259 - })) 304 + })), 260 305 ) 261 306 .onConflictDoNothing() 262 307 .returning(); ··· 272 317 273 318 function chunkToUniqueArray<T extends object, U extends string | readonly string[]>( 274 319 chunk: Chunk.Chunk<T>, 275 - map: (x: T) => U 320 + map: (x: T) => U, 276 321 ) { 277 322 const array = Chunk.toArray(chunk); 278 323 const x = array.flatMap(map);