A decentralized music tracking and discovery platform built on AT Protocol 🎵

implement createSong xrpc method

+1184 -1
+80
rockskyapi/rocksky-auth/lexicons/song/createSong.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.rocksky.song.createSong", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a new song", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "title", 14 + "artist", 15 + "album", 16 + "albumArtist" 17 + ], 18 + "properties": { 19 + "title": { 20 + "type": "string", 21 + "description": "The title of the song" 22 + }, 23 + "artist": { 24 + "type": "string", 25 + "description": "The artist of the song" 26 + }, 27 + "albumArtist": { 28 + "type": "string", 29 + "description": "The album artist of the song, if different from the main artist" 30 + }, 31 + "album": { 32 + "type": "string", 33 + "description": "The album of the song, if applicable" 34 + }, 35 + "duration": { 36 + "type": "integer", 37 + "description": "The duration of the song in seconds" 38 + }, 39 + "mbId": { 40 + "type": "string", 41 + "description": "The MusicBrainz ID of the song, if available" 42 + }, 43 + "albumArt": { 44 + "type": "string", 45 + "description": "The URL of the album art for the song", 46 + "format": "uri" 47 + }, 48 + "trackNumber": { 49 + "type": "integer", 50 + "description": "The track number of the song in the album, if applicable" 51 + }, 52 + "releaseDate": { 53 + "type": "string", 54 + "description": "The release date of the song, formatted as YYYY-MM-DD" 55 + }, 56 + "year": { 57 + "type": "integer", 58 + "description": "The year the song was released" 59 + }, 60 + "discNumber": { 61 + "type": "integer", 62 + "description": "The disc number of the song in the album, if applicable" 63 + }, 64 + "lyrics": { 65 + "type": "string", 66 + "description": "The lyrics of the song, if available" 67 + } 68 + } 69 + } 70 + }, 71 + "output": { 72 + "encoding": "application/json", 73 + "schema": { 74 + "type": "ref", 75 + "ref": "app.rocksky.song.defs#songViewDetailed" 76 + } 77 + } 78 + } 79 + } 80 + }
+75
rockskyapi/rocksky-auth/pkl/defs/song/createSong.pkl
··· 1 + amends "../../schema/lexicon.pkl" 2 + 3 + lexicon = 1 4 + id = "app.rocksky.song.createSong" 5 + defs = new Mapping<String, Procedure> { 6 + ["main"] { 7 + type = "procedure" 8 + description = "Create a new song" 9 + input { 10 + encoding = "application/json" 11 + schema { 12 + type = "object" 13 + required = List("title", "artist", "album", "albumArtist") 14 + properties { 15 + ["title"] = new StringType { 16 + type = "string" 17 + description = "The title of the song" 18 + } 19 + ["artist"] = new StringType { 20 + type = "string" 21 + description = "The artist of the song" 22 + } 23 + ["albumArtist"] = new StringType { 24 + type = "string" 25 + description = "The album artist of the song, if different from the main artist" 26 + } 27 + ["album"] = new StringType { 28 + type = "string" 29 + description = "The album of the song, if applicable" 30 + } 31 + ["duration"] = new IntegerType { 32 + type = "integer" 33 + description = "The duration of the song in seconds" 34 + } 35 + ["mbId"] = new StringType { 36 + type = "string" 37 + description = "The MusicBrainz ID of the song, if available" 38 + } 39 + ["albumArt"] = new StringType { 40 + type = "string" 41 + description = "The URL of the album art for the song" 42 + format = "uri" 43 + } 44 + ["trackNumber"] = new IntegerType { 45 + type = "integer" 46 + description = "The track number of the song in the album, if applicable" 47 + } 48 + ["releaseDate"] = new StringType { 49 + type = "string" 50 + description = "The release date of the song, formatted as YYYY-MM-DD" 51 + } 52 + ["year"] = new IntegerType { 53 + type = "integer" 54 + description = "The year the song was released" 55 + } 56 + ["discNumber"] = new IntegerType { 57 + type = "integer" 58 + description = "The disc number of the song in the album, if applicable" 59 + } 60 + ["lyrics"] = new StringType { 61 + type = "string" 62 + description = "The lyrics of the song, if available" 63 + } 64 + } 65 + } 66 + } 67 + output { 68 + encoding = "application/json" 69 + schema = new Ref { 70 + type = "ref" 71 + ref = "app.rocksky.song.defs#songViewDetailed" 72 + } 73 + } 74 + } 75 + }
+12
rockskyapi/rocksky-auth/src/lexicon/index.ts
··· 61 61 import * as AppRockskyShoutRemoveShout from './types/app/rocksky/shout/removeShout' 62 62 import * as AppRockskyShoutReplyShout from './types/app/rocksky/shout/replyShout' 63 63 import * as AppRockskyShoutReportShout from './types/app/rocksky/shout/reportShout' 64 + import * as AppRockskySongCreateSong from './types/app/rocksky/song/createSong' 64 65 import * as AppRockskySongGetSong from './types/app/rocksky/song/getSong' 65 66 import * as AppRockskySongGetSongs from './types/app/rocksky/song/getSongs' 66 67 import * as AppRockskySpotifyGetCurrentlyPlaying from './types/app/rocksky/spotify/getCurrentlyPlaying' ··· 820 821 821 822 constructor(server: Server) { 822 823 this._server = server 824 + } 825 + 826 + createSong<AV extends AuthVerifier>( 827 + cfg: ConfigOf< 828 + AV, 829 + AppRockskySongCreateSong.Handler<ExtractAuth<AV>>, 830 + AppRockskySongCreateSong.HandlerReqCtx<ExtractAuth<AV>> 831 + >, 832 + ) { 833 + const nsid = 'app.rocksky.song.createSong' // @ts-ignore 834 + return this._server.xrpc.method(nsid, cfg) 823 835 } 824 836 825 837 getSong<AV extends AuthVerifier>(
+80
rockskyapi/rocksky-auth/src/lexicon/lexicons.ts
··· 3275 3275 }, 3276 3276 }, 3277 3277 }, 3278 + AppRockskySongCreateSong: { 3279 + lexicon: 1, 3280 + id: 'app.rocksky.song.createSong', 3281 + defs: { 3282 + main: { 3283 + type: 'procedure', 3284 + description: 'Create a new song', 3285 + input: { 3286 + encoding: 'application/json', 3287 + schema: { 3288 + type: 'object', 3289 + required: ['title', 'artist', 'album', 'albumArtist'], 3290 + properties: { 3291 + title: { 3292 + type: 'string', 3293 + description: 'The title of the song', 3294 + }, 3295 + artist: { 3296 + type: 'string', 3297 + description: 'The artist of the song', 3298 + }, 3299 + albumArtist: { 3300 + type: 'string', 3301 + description: 3302 + 'The album artist of the song, if different from the main artist', 3303 + }, 3304 + album: { 3305 + type: 'string', 3306 + description: 'The album of the song, if applicable', 3307 + }, 3308 + duration: { 3309 + type: 'integer', 3310 + description: 'The duration of the song in seconds', 3311 + }, 3312 + mbId: { 3313 + type: 'string', 3314 + description: 'The MusicBrainz ID of the song, if available', 3315 + }, 3316 + albumArt: { 3317 + type: 'string', 3318 + description: 'The URL of the album art for the song', 3319 + format: 'uri', 3320 + }, 3321 + trackNumber: { 3322 + type: 'integer', 3323 + description: 3324 + 'The track number of the song in the album, if applicable', 3325 + }, 3326 + releaseDate: { 3327 + type: 'string', 3328 + description: 3329 + 'The release date of the song, formatted as YYYY-MM-DD', 3330 + }, 3331 + year: { 3332 + type: 'integer', 3333 + description: 'The year the song was released', 3334 + }, 3335 + discNumber: { 3336 + type: 'integer', 3337 + description: 3338 + 'The disc number of the song in the album, if applicable', 3339 + }, 3340 + lyrics: { 3341 + type: 'string', 3342 + description: 'The lyrics of the song, if available', 3343 + }, 3344 + }, 3345 + }, 3346 + }, 3347 + output: { 3348 + encoding: 'application/json', 3349 + schema: { 3350 + type: 'ref', 3351 + ref: 'lex:app.rocksky.song.defs#songViewDetailed', 3352 + }, 3353 + }, 3354 + }, 3355 + }, 3356 + }, 3278 3357 AppRockskySongDefs: { 3279 3358 lexicon: 1, 3280 3359 id: 'app.rocksky.song.defs', ··· 4063 4142 AppRockskyShoutReplyShout: 'app.rocksky.shout.replyShout', 4064 4143 AppRockskyShoutReportShout: 'app.rocksky.shout.reportShout', 4065 4144 AppRockskyShout: 'app.rocksky.shout', 4145 + AppRockskySongCreateSong: 'app.rocksky.song.createSong', 4066 4146 AppRockskySongDefs: 'app.rocksky.song.defs', 4067 4147 AppRockskySongGetSong: 'app.rocksky.song.getSong', 4068 4148 AppRockskySongGetSongs: 'app.rocksky.song.getSongs',
+71
rockskyapi/rocksky-auth/src/lexicon/types/app/rocksky/song/createSong.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import express from 'express' 5 + import { ValidationResult, BlobRef } from '@atproto/lexicon' 6 + import { lexicons } from '../../../../lexicons' 7 + import { isObj, hasProp } from '../../../../util' 8 + import { CID } from 'multiformats/cid' 9 + import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' 10 + import * as AppRockskySongDefs from './defs' 11 + 12 + export interface QueryParams {} 13 + 14 + export interface InputSchema { 15 + /** The title of the song */ 16 + title: string 17 + /** The artist of the song */ 18 + artist: string 19 + /** The album artist of the song, if different from the main artist */ 20 + albumArtist: string 21 + /** The album of the song, if applicable */ 22 + album: string 23 + /** The duration of the song in seconds */ 24 + duration?: number 25 + /** The MusicBrainz ID of the song, if available */ 26 + mbId?: string 27 + /** The URL of the album art for the song */ 28 + albumArt?: string 29 + /** The track number of the song in the album, if applicable */ 30 + trackNumber?: number 31 + /** The release date of the song, formatted as YYYY-MM-DD */ 32 + releaseDate?: string 33 + /** The year the song was released */ 34 + year?: number 35 + /** The disc number of the song in the album, if applicable */ 36 + discNumber?: number 37 + /** The lyrics of the song, if available */ 38 + lyrics?: string 39 + [k: string]: unknown 40 + } 41 + 42 + export type OutputSchema = AppRockskySongDefs.SongViewDetailed 43 + 44 + export interface HandlerInput { 45 + encoding: 'application/json' 46 + body: InputSchema 47 + } 48 + 49 + export interface HandlerSuccess { 50 + encoding: 'application/json' 51 + body: OutputSchema 52 + headers?: { [key: string]: string } 53 + } 54 + 55 + export interface HandlerError { 56 + status: number 57 + message?: string 58 + } 59 + 60 + export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough 61 + export type HandlerReqCtx<HA extends HandlerAuth = never> = { 62 + auth: HA 63 + params: QueryParams 64 + input: HandlerInput 65 + req: express.Request 66 + res: express.Response 67 + resetRouteRateLimits: () => Promise<void> 68 + } 69 + export type Handler<HA extends HandlerAuth = never> = ( 70 + ctx: HandlerReqCtx<HA>, 71 + ) => Promise<HandlerOutput> | HandlerOutput
+865
rockskyapi/rocksky-auth/src/xrpc/app/rocksky/song/createSong.ts
··· 1 + import { Agent, BlobRef } from "@atproto/api"; 2 + import { TID } from "@atproto/common"; 3 + import { HandlerAuth } from "@atproto/xrpc-server"; 4 + import chalk from "chalk"; 5 + import { Context } from "context"; 6 + import { and, eq } from "drizzle-orm"; 7 + import { Effect, Match, Option, pipe } from "effect"; 8 + import { NoSuchElementException, UnknownException } from "effect/Cause"; 9 + import { Server } from "lexicon"; 10 + import * as Album from "lexicon/types/app/rocksky/album"; 11 + import * as Artist from "lexicon/types/app/rocksky/artist"; 12 + import * as Song from "lexicon/types/app/rocksky/song"; 13 + import { InputSchema } from "lexicon/types/app/rocksky/song/createSong"; 14 + import { SongViewDetailed } from "lexicon/types/app/rocksky/song/defs"; 15 + import { deepSnakeCaseKeys } from "lib"; 16 + import { createAgent } from "lib/agent"; 17 + import downloadImage from "lib/downloadImage"; 18 + import { createHash } from "node:crypto"; 19 + import tables from "schema"; 20 + import { InsertAlbumTrack, SelectAlbumTrack } from "schema/album-tracks"; 21 + import { SelectAlbum } from "schema/albums"; 22 + import { InsertArtistAlbum, SelectArtistAlbum } from "schema/artist-albums"; 23 + import { InsertArtistTrack, SelectArtistTrack } from "schema/artist-tracks"; 24 + import { SelectArtist } from "schema/artists"; 25 + import { SelectTrack } from "schema/tracks"; 26 + import { Track, trackSchema } from "types/track"; 27 + 28 + export default function (server: Server, ctx: Context) { 29 + const createSong = (input: InputSchema, auth: HandlerAuth) => 30 + pipe( 31 + { input, ctx, did: auth.credentials?.did }, 32 + withAgent, 33 + Effect.flatMap(validateInput), 34 + Effect.flatMap(create), 35 + Effect.flatMap(presentation), 36 + Effect.retry({ times: 3 }), 37 + Effect.timeout("120 seconds"), 38 + Effect.catchAll((err) => { 39 + console.error(err); 40 + return Effect.succeed({}); 41 + }) 42 + ); 43 + server.app.rocksky.song.createSong({ 44 + auth: ctx.authVerifier, 45 + handler: async ({ input, auth }) => { 46 + const result = await Effect.runPromise(createSong(input.body, auth)); 47 + return { 48 + encoding: "application/json", 49 + body: result, 50 + }; 51 + }, 52 + }); 53 + } 54 + 55 + const withAgent = ({ 56 + input, 57 + ctx, 58 + did, 59 + }: { 60 + input: InputSchema; 61 + ctx: Context; 62 + did: string; 63 + }): Effect.Effect<InputWithAgent, Error> => { 64 + return Effect.tryPromise({ 65 + try: async () => 66 + Match.value(did).pipe( 67 + Match.when( 68 + (value) => !!value, 69 + () => 70 + createAgent(ctx.oauthClient, did).then((agent) => ({ 71 + agent, 72 + ctx, 73 + did, 74 + input, 75 + })) 76 + ), 77 + Match.orElse(() => { 78 + throw new Error("Authentication required to create a song"); 79 + }) 80 + ), 81 + catch: (error) => new Error(`Failed to create agent: ${error}`), 82 + }); 83 + }; 84 + 85 + const validateInput = ({ 86 + input, 87 + ...params 88 + }: InputWithAgent): Effect.Effect<ValidatedInput, Error> => 89 + Effect.try(() => ({ 90 + ...params, 91 + track: trackSchema.safeParse(input).data, 92 + })); 93 + 94 + const create = ({ 95 + ctx, 96 + agent, 97 + track, 98 + }: ValidatedInput): Effect.Effect< 99 + { 100 + tries: number; 101 + track: SelectTrack; 102 + album: SelectAlbum; 103 + artist: SelectArtist; 104 + albumTrack: SelectAlbumTrack; 105 + artistTrack: SelectArtistTrack; 106 + artistAlbum: SelectArtistAlbum; 107 + }, 108 + Error | UnknownException | NoSuchElementException, 109 + never 110 + > => { 111 + return saveTrack(ctx, track, agent); 112 + }; 113 + 114 + const presentation = ({ 115 + track, 116 + }: { 117 + tries: number; 118 + track: SelectTrack; 119 + album: SelectAlbum; 120 + artist: SelectArtist; 121 + albumTrack: SelectAlbumTrack; 122 + artistTrack: SelectArtistTrack; 123 + artistAlbum: SelectArtistAlbum; 124 + }): Effect.Effect<SongViewDetailed, never> => { 125 + return Effect.sync(() => ({ 126 + ...track, 127 + createdAt: track.createdAt.toISOString(), 128 + })); 129 + }; 130 + 131 + type InputWithAgent = { 132 + agent: Agent; 133 + ctx: Context; 134 + input: InputSchema; 135 + }; 136 + 137 + type ValidatedInput = { 138 + ctx: Context; 139 + agent: Agent; 140 + track: Track; 141 + }; 142 + 143 + const computeTrackHash = (track: Track): Effect.Effect<string, never> => 144 + Effect.succeed( 145 + createHash("sha256") 146 + .update(`${track.title} - ${track.artist} - ${track.album}`.toLowerCase()) 147 + .digest("hex") 148 + ); 149 + 150 + const computeAlbumHash = (track: Track): Effect.Effect<string, never> => 151 + Effect.succeed( 152 + createHash("sha256") 153 + .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 154 + .digest("hex") 155 + ); 156 + 157 + const computeArtistHash = (track: Track): Effect.Effect<string, never> => 158 + Effect.succeed( 159 + createHash("sha256").update(track.albumArtist.toLowerCase()).digest("hex") 160 + ); 161 + 162 + const fetchExistingTrack = ( 163 + ctx: Context, 164 + trackHash: string 165 + ): Effect.Effect<SelectTrack | undefined, Error> => 166 + Effect.tryPromise(() => 167 + ctx.db 168 + .select() 169 + .from(tables.tracks) 170 + .where(eq(tables.tracks.sha256, trackHash)) 171 + .execute() 172 + .then(([row]) => row) 173 + ); 174 + 175 + const uploadImage = (url: string, agent: Agent) => 176 + pipe( 177 + Effect.tryPromise(() => downloadImage(url)), 178 + Effect.map< 179 + Buffer<ArrayBufferLike>, 180 + [Buffer<ArrayBufferLike>, { encoding: string } | undefined] 181 + >((imageBuffer) => { 182 + if (url.endsWith(".jpeg") || url.endsWith(".jpg")) { 183 + return [imageBuffer, { encoding: "image/jpeg" }]; 184 + } else if (url.endsWith(".png")) { 185 + return [imageBuffer, { encoding: "image/png" }]; 186 + } 187 + return [imageBuffer, undefined]; 188 + }), 189 + Effect.flatMap(([imageBuffer, options]) => 190 + pipe( 191 + Effect.tryPromise(() => agent.uploadBlob(imageBuffer, options)), 192 + Effect.map((uploadResponse) => uploadResponse.data.blob) 193 + ) 194 + ), 195 + Effect.catchAll(() => Effect.succeed(undefined as BlobRef | undefined)) 196 + ); 197 + 198 + const generateRkey = Effect.succeed(TID.nextStr()); 199 + 200 + const putRecord = <T>( 201 + agent: Agent, 202 + collection: string, 203 + record: T, 204 + validate: (record: T) => { success: boolean } 205 + ): Effect.Effect<string, Error> => 206 + pipe( 207 + Effect.succeed(record), 208 + Effect.filterOrFail( 209 + (rec) => validate(rec).success, 210 + () => new Error("Invalid record") 211 + ), 212 + Effect.flatMap(() => 213 + pipe( 214 + generateRkey, 215 + Effect.flatMap((rkey) => 216 + Effect.tryPromise(() => 217 + agent.com.atproto.repo.putRecord({ 218 + repo: agent.assertDid, 219 + collection, 220 + rkey, 221 + record, 222 + validate: false, 223 + }) 224 + ) 225 + ), 226 + Effect.tap((res) => 227 + Effect.logInfo(`Record created at ${res.data.uri}`) 228 + ), 229 + Effect.map((res) => res.data.uri) 230 + ) 231 + ), 232 + Effect.catchAll((error) => { 233 + console.error(`Error creating ${collection} record`, error); 234 + return Effect.fail(error); 235 + }) 236 + ); 237 + 238 + const putArtistRecord = (track: Track, agent: Agent) => 239 + pipe( 240 + track.artistPicture 241 + ? uploadImage(track.artistPicture, agent) 242 + : Effect.succeed(undefined), 243 + Effect.map((picture) => ({ 244 + $type: "app.rocksky.artist", 245 + name: track.albumArtist, 246 + createdAt: new Date().toISOString(), 247 + picture, 248 + })), 249 + Effect.flatMap((record) => 250 + putRecord(agent, "app.rocksky.artist", record, Artist.validateRecord) 251 + ) 252 + ); 253 + 254 + const putAlbumRecord = (track: Track, agent: Agent) => 255 + pipe( 256 + Match.value(track.albumArt).pipe( 257 + Match.when( 258 + (url) => !!url, 259 + (url) => uploadImage(url, agent) 260 + ), 261 + Match.orElse(() => Effect.succeed(undefined as BlobRef | undefined)) 262 + ), 263 + Effect.map((albumArt) => ({ 264 + $type: "app.rocksky.album", 265 + title: track.album, 266 + artist: track.albumArtist, 267 + year: track.year, 268 + releaseDate: track.releaseDate 269 + ? track.releaseDate.toISOString() 270 + : undefined, 271 + createdAt: new Date().toISOString(), 272 + albumArt, 273 + })), 274 + Effect.flatMap((record) => 275 + putRecord(agent, "app.rocksky.album", record, Album.validateRecord) 276 + ) 277 + ); 278 + 279 + const putSongRecord = (track: Track, agent: Agent) => 280 + pipe( 281 + Match.value(track.albumArt).pipe( 282 + Match.when( 283 + (url) => !!url, 284 + (url) => uploadImage(url, agent) 285 + ), 286 + Match.orElse(() => Effect.succeed(undefined as BlobRef | undefined)) 287 + ), 288 + Effect.map((albumArt) => ({ 289 + $type: "app.rocksky.song", 290 + title: track.title, 291 + artist: track.artist, 292 + album: track.album, 293 + albumArtist: track.albumArtist, 294 + duration: track.duration, 295 + releaseDate: track.releaseDate 296 + ? track.releaseDate.toISOString() 297 + : undefined, 298 + year: track.year, 299 + albumArt, 300 + composer: track.composer ?? undefined, 301 + lyrics: track.lyrics ?? undefined, 302 + trackNumber: track.trackNumber, 303 + discNumber: track.discNumber === 0 ? 1 : track.discNumber, 304 + copyrightMessage: track.copyrightMessage ?? undefined, 305 + createdAt: new Date().toISOString(), 306 + spotifyLink: track.spotifyLink ?? undefined, 307 + })), 308 + Effect.flatMap((record) => 309 + putRecord(agent, "app.rocksky.song", record, Song.validateRecord) 310 + ) 311 + ); 312 + 313 + const ensureTrack = (ctx: Context, track: Track, agent: Agent) => 314 + pipe( 315 + computeTrackHash(track), 316 + Effect.flatMap((trackHash) => 317 + pipe( 318 + fetchExistingTrack(ctx, trackHash), 319 + Effect.flatMap((existingTrack) => 320 + pipe( 321 + Option.fromNullable(existingTrack), 322 + Effect.tap((trackOpt) => 323 + trackOpt 324 + ? updateTrackMetadata(ctx, track, trackOpt) 325 + : Effect.succeed(undefined) 326 + ), 327 + Effect.flatMap((trackOpt) => 328 + trackOpt.uri 329 + ? Effect.succeed(trackOpt.uri) 330 + : putSongRecord(track, agent) 331 + ) 332 + ) 333 + ) 334 + ) 335 + ) 336 + ); 337 + 338 + // Update track metadata (album_uri and artist_uri) 339 + const updateTrackMetadata = ( 340 + ctx: Context, 341 + track: Track, 342 + trackRecord: SelectTrack 343 + ) => 344 + pipe( 345 + Effect.succeed(trackRecord), 346 + Effect.tap((trackRecord) => 347 + !trackRecord.albumUri 348 + ? pipe( 349 + computeAlbumHash(track), 350 + Effect.flatMap((albumHash) => 351 + Effect.tryPromise(() => 352 + ctx.db 353 + .select() 354 + .from(tables.albums) 355 + .where(eq(tables.albums.sha256, albumHash)) 356 + .execute() 357 + .then(([row]) => row) 358 + ) 359 + ), 360 + Effect.flatMap((album) => 361 + Option.fromNullable(album).pipe( 362 + Effect.flatMap((album) => 363 + Effect.tryPromise(() => 364 + ctx.db 365 + .update(tables.tracks) 366 + .set({ albumUri: album.uri }) 367 + .where(eq(tables.tracks.id, trackRecord.id)) 368 + .execute() 369 + ) 370 + ), 371 + Effect.catchAll(() => Effect.succeed(undefined)) 372 + ) 373 + ) 374 + ) 375 + : Effect.succeed(undefined) 376 + ), 377 + Effect.tap((trackRecord) => 378 + !trackRecord.artistUri 379 + ? pipe( 380 + computeArtistHash(track), 381 + Effect.flatMap((artistHash) => 382 + Effect.tryPromise(() => 383 + ctx.db 384 + .select() 385 + .from(tables.artists) 386 + .where(eq(tables.artists.sha256, artistHash)) 387 + .execute() 388 + .then(([row]) => row) 389 + ) 390 + ), 391 + Effect.flatMap((artist) => 392 + Option.fromNullable(artist).pipe( 393 + Effect.flatMap((artist) => 394 + Effect.tryPromise(() => 395 + ctx.db 396 + .update(tables.tracks) 397 + .set({ artistUri: artist.uri }) 398 + .where(eq(tables.tracks.id, trackRecord.id)) 399 + .execute() 400 + ) 401 + ), 402 + Effect.catchAll(() => Effect.succeed(undefined)) 403 + ) 404 + ) 405 + ) 406 + : Effect.succeed(undefined) 407 + ) 408 + ); 409 + 410 + // Ensure artist exists or create it 411 + const ensureArtist = (ctx: Context, track: Track, agent: Agent) => 412 + pipe( 413 + computeArtistHash(track), 414 + Effect.flatMap((artistHash) => 415 + pipe( 416 + Effect.tryPromise(() => 417 + ctx.db 418 + .select() 419 + .from(tables.artists) 420 + .where(eq(tables.artists.sha256, artistHash)) 421 + .execute() 422 + .then(([row]) => row) 423 + ), 424 + Effect.flatMap((existingArtist) => 425 + pipe( 426 + Option.fromNullable(existingArtist), 427 + Effect.flatMap((artistOpt) => 428 + artistOpt.uri 429 + ? Effect.succeed(artistOpt.uri) 430 + : putArtistRecord(track, agent) 431 + ) 432 + ) 433 + ) 434 + ) 435 + ) 436 + ); 437 + 438 + // Ensure album exists or create it 439 + const ensureAlbum = (ctx: Context, track: Track, agent: Agent) => 440 + pipe( 441 + computeAlbumHash(track), 442 + Effect.flatMap((albumHash) => 443 + pipe( 444 + Effect.tryPromise(() => 445 + ctx.db 446 + .select() 447 + .from(tables.albums) 448 + .where(eq(tables.albums.sha256, albumHash)) 449 + .execute() 450 + .then(([row]) => row) 451 + ), 452 + Effect.flatMap((existingAlbum) => 453 + pipe( 454 + Option.fromNullable(existingAlbum), 455 + Effect.flatMap((albumOpt) => 456 + albumOpt.uri 457 + ? Effect.succeed(albumOpt.uri) 458 + : putAlbumRecord(track, agent) 459 + ) 460 + ) 461 + ) 462 + ) 463 + ) 464 + ); 465 + 466 + // Fetch track, album, and artist by URIs 467 + const fetchRecordsByUris = ( 468 + ctx: Context, 469 + trackUri: string, 470 + albumUri: string, 471 + artistUri: string 472 + ): Effect.Effect< 473 + { 474 + track: SelectTrack | null; 475 + album: SelectAlbum | null; 476 + artist: SelectArtist | null; 477 + }, 478 + Error 479 + > => 480 + Effect.all({ 481 + track: Effect.tryPromise(() => 482 + ctx.db 483 + .select() 484 + .from(tables.tracks) 485 + .where(eq(tables.tracks.uri, trackUri)) 486 + .execute() 487 + .then(([row]) => row) 488 + ), 489 + album: Effect.tryPromise(() => 490 + ctx.db 491 + .select() 492 + .from(tables.albums) 493 + .where(eq(tables.albums.uri, albumUri)) 494 + .execute() 495 + .then(([row]) => row) 496 + ), 497 + artist: Effect.tryPromise(() => 498 + ctx.db 499 + .select() 500 + .from(tables.artists) 501 + .where(eq(tables.artists.uri, artistUri)) 502 + .execute() 503 + .then(([row]) => row) 504 + ), 505 + }); 506 + 507 + // Ensure relationships (album_track, artist_track, artist_album) 508 + const ensureRelationships = ( 509 + ctx: Context, 510 + track: SelectTrack, 511 + album: SelectAlbum, 512 + artist: SelectArtist 513 + ) => 514 + pipe( 515 + Effect.all({ 516 + albumTrack: Effect.tryPromise(() => 517 + ctx.db 518 + .select() 519 + .from(tables.albumTracks) 520 + .where( 521 + and( 522 + eq(tables.albumTracks.albumId, album.id), 523 + eq(tables.albumTracks.trackId, track.id) 524 + ) 525 + ) 526 + .execute() 527 + .then(([row]) => row) 528 + ), 529 + 530 + artistTrack: Effect.tryPromise(() => 531 + ctx.db 532 + .select() 533 + .from(tables.artistTracks) 534 + .where( 535 + and( 536 + eq(tables.artistTracks.artistId, artist.id), 537 + eq(tables.artistTracks.trackId, track.id) 538 + ) 539 + ) 540 + .execute() 541 + .then(([row]) => row) 542 + ), 543 + artistAlbum: Effect.tryPromise(() => 544 + ctx.db 545 + .select() 546 + .from(tables.artistAlbums) 547 + .where( 548 + and( 549 + eq(tables.artistAlbums.artistId, artist.id), 550 + eq(tables.artistAlbums.albumId, album.id) 551 + ) 552 + ) 553 + .execute() 554 + .then(([row]) => row) 555 + ), 556 + }), 557 + Effect.flatMap(({ albumTrack, artistTrack, artistAlbum }) => 558 + pipe( 559 + Effect.all([ 560 + pipe( 561 + Option.fromNullable(albumTrack), 562 + Effect.orElse(() => 563 + Effect.tryPromise(() => 564 + ctx.db 565 + .insert(tables.albumTracks) 566 + .values({ 567 + albumId: album.id, 568 + trackId: track.id, 569 + } as InsertAlbumTrack) 570 + .returning() 571 + .execute() 572 + .then(([row]) => row) 573 + ) 574 + ) 575 + ), 576 + pipe( 577 + Option.fromNullable(artistTrack), 578 + Effect.orElse(() => 579 + Effect.tryPromise(() => 580 + ctx.db 581 + .insert(tables.artistTracks) 582 + .values({ 583 + artistId: artist.id, 584 + trackId: track.id, 585 + } as InsertArtistTrack) 586 + .returning() 587 + .execute() 588 + .then(([row]) => row) 589 + ) 590 + ) 591 + ), 592 + pipe( 593 + Option.fromNullable(artistAlbum), 594 + Effect.orElse(() => 595 + Effect.tryPromise(() => 596 + ctx.db 597 + .insert(tables.artistAlbums) 598 + .values({ 599 + artistId: artist.id, 600 + albumId: album.id, 601 + } as InsertArtistAlbum) 602 + .returning() 603 + .execute() 604 + .then(([row]) => row) 605 + ) 606 + ) 607 + ), 608 + ]), 609 + Effect.map(([albumTrack, artistTrack, artistAlbum]) => ({ 610 + albumTrack, 611 + artistTrack, 612 + artistAlbum, 613 + })) 614 + ) 615 + ) 616 + ); 617 + 618 + // Update track with album and artist URIs if missing 619 + const updateTrackUris = ( 620 + ctx: Context, 621 + track: SelectTrack, 622 + album: SelectAlbum, 623 + artist: SelectArtist 624 + ) => 625 + pipe( 626 + Effect.succeed(track), 627 + Effect.tap((trackRecord) => 628 + !trackRecord.albumUri 629 + ? Effect.tryPromise(() => 630 + ctx.db 631 + .update(tables.tracks) 632 + .set({ 633 + albumUri: album.uri, 634 + }) 635 + .where(eq(tables.tracks.id, trackRecord.id)) 636 + .execute() 637 + ) 638 + : Effect.succeed(undefined) 639 + ), 640 + Effect.tap((trackRecord) => 641 + !trackRecord.artistUri 642 + ? Effect.tryPromise(() => 643 + ctx.db 644 + .update(tables.tracks) 645 + .set({ 646 + artistUri: artist.uri, 647 + }) 648 + .where(eq(tables.tracks.id, trackRecord.id)) 649 + .execute() 650 + ) 651 + : Effect.succeed(undefined) 652 + ) 653 + ); 654 + 655 + const publishTrack = ( 656 + ctx: Context, 657 + track: SelectTrack, 658 + albumTrack: SelectAlbumTrack, 659 + artistTrack: SelectArtistTrack, 660 + artistAlbum: SelectArtistAlbum 661 + ) => 662 + pipe( 663 + Effect.succeed( 664 + deepSnakeCaseKeys({ 665 + track: { 666 + ...track, 667 + sha256: track.sha256, 668 + uri: track.uri, 669 + xata_createdat: track.createdAt.toISOString(), 670 + xata_id: track.id, 671 + xata_updatedat: track.updatedAt.toISOString(), 672 + xata_version: track.xataVersion, 673 + }, 674 + album_track: { 675 + xata_id: albumTrack.id, 676 + album_id: { 677 + xata_id: albumTrack.albumId, 678 + }, 679 + track_id: { 680 + xata_id: albumTrack.trackId, 681 + }, 682 + xata_createdat: albumTrack.createdAt.toISOString(), 683 + xata_updatedat: albumTrack.updatedAt.toISOString(), 684 + xata_version: albumTrack.xataVersion, 685 + }, 686 + artist_track: { 687 + xata_id: artistTrack.id, 688 + artist_id: { 689 + xata_id: artistTrack.artistId, 690 + }, 691 + track_id: { 692 + xata_id: artistTrack.trackId, 693 + }, 694 + xata_createdat: artistTrack.createdAt.toISOString(), 695 + xata_updatedat: artistTrack.updatedAt.toISOString(), 696 + xata_version: artistTrack.xataVersion, 697 + }, 698 + artist_album: { 699 + xata_id: artistAlbum.id, 700 + artist_id: { 701 + xata_id: artistAlbum.artistId, 702 + }, 703 + album_id: { 704 + xata_id: artistAlbum.albumId, 705 + }, 706 + xata_createdat: artistAlbum.createdAt.toISOString(), 707 + xata_updatedat: artistAlbum.updatedAt.toISOString(), 708 + xata_version: artistAlbum.xataVersion, 709 + }, 710 + }) 711 + ), 712 + Effect.flatMap((message) => 713 + Effect.try(() => 714 + ctx.nc.publish("rocksky.track", Buffer.from(JSON.stringify(message))) 715 + ) 716 + ) 717 + ); 718 + 719 + export const saveTrack = (ctx: Context, track: Track, agent: Agent) => 720 + pipe( 721 + Effect.all({ 722 + trackUri: ensureTrack(ctx, track, agent), 723 + albumUri: ensureAlbum(ctx, track, agent), 724 + artistUri: ensureArtist(ctx, track, agent), 725 + }), 726 + Effect.flatMap(({ trackUri, albumUri, artistUri }) => 727 + pipe( 728 + Effect.iterate( 729 + { 730 + tries: 0, 731 + track: null, 732 + album: null, 733 + artist: null, 734 + albumTrack: null, 735 + artistTrack: null, 736 + artistAlbum: null, 737 + }, 738 + { 739 + while: ({ 740 + tries, 741 + track, 742 + album, 743 + artist, 744 + albumTrack, 745 + artistTrack, 746 + artistAlbum, 747 + }) => 748 + tries < 15 && 749 + !( 750 + track && 751 + album && 752 + artist && 753 + albumTrack && 754 + artistTrack && 755 + artistAlbum && 756 + track.albumUri && 757 + track.artistUri 758 + ), 759 + body: ({ tries }) => 760 + pipe( 761 + fetchRecordsByUris(ctx, trackUri, albumUri, artistUri), 762 + Effect.flatMap(({ track, album, artist }) => 763 + pipe( 764 + Effect.all([ 765 + Option.fromNullable(track).pipe( 766 + Effect.filterOrFail( 767 + () => !!track, 768 + () => 769 + new Error(`Track not found for uri: ${trackUri}`) 770 + ) 771 + ), 772 + Option.fromNullable(album).pipe( 773 + Effect.filterOrFail( 774 + () => !!album, 775 + () => 776 + new Error(`Album not found for uri: ${albumUri}`) 777 + ) 778 + ), 779 + Option.fromNullable(artist).pipe( 780 + Effect.filterOrFail( 781 + () => !!artist, 782 + () => 783 + new Error(`Artist not found for uri: ${artistUri}`) 784 + ) 785 + ), 786 + ]), 787 + Effect.flatMap(([track, album, artist]) => 788 + pipe( 789 + updateTrackUris(ctx, track, album, artist), 790 + Effect.flatMap(() => 791 + ensureRelationships(ctx, track, album, artist) 792 + ), 793 + Effect.map( 794 + ({ albumTrack, artistTrack, artistAlbum }) => ({ 795 + tries: tries + 1, 796 + track, 797 + album, 798 + artist, 799 + albumTrack, 800 + artistTrack, 801 + artistAlbum, 802 + }) 803 + ) 804 + ) 805 + ) 806 + ) 807 + ), 808 + Effect.tap( 809 + ({ 810 + tries, 811 + track, 812 + album, 813 + artist, 814 + albumTrack, 815 + artistTrack, 816 + artistAlbum, 817 + }) => 818 + Effect.logInfo( 819 + track && 820 + album && 821 + artist && 822 + albumTrack && 823 + artistTrack && 824 + artistAlbum && 825 + track.albumUri && 826 + track.artistUri 827 + ? `Track saved successfully after ${chalk.magenta(tries + 1)} tries` 828 + : `Track not yet saved, retrying... ${chalk.magenta(tries + 1)}` 829 + ) 830 + ), 831 + Effect.tap( 832 + ({ 833 + tries, 834 + track, 835 + album, 836 + artist, 837 + albumTrack, 838 + artistTrack, 839 + artistAlbum, 840 + }) => 841 + tries === 15 842 + ? pipe( 843 + Effect.logError( 844 + "Failed to save track after 15 tries" 845 + ), 846 + Effect.tap(() => 847 + Effect.logDebug( 848 + `Debug info: track=${JSON.stringify(track)}, album=${JSON.stringify(album)}, artist=${JSON.stringify(artist)}, albumTrack=${JSON.stringify(albumTrack)}, artistTrack=${JSON.stringify(artistTrack)}, artistAlbum=${JSON.stringify(artistAlbum)}` 849 + ) 850 + ) 851 + ) 852 + : Effect.succeed(undefined) 853 + ), 854 + Effect.delay("1 second") 855 + ), 856 + } 857 + ), 858 + Effect.tap(({ tries, track, albumTrack, artistTrack, artistAlbum }) => 859 + tries < 15 && track && albumTrack && artistTrack && artistAlbum 860 + ? publishTrack(ctx, track, albumTrack, artistTrack, artistAlbum) 861 + : Effect.succeed(undefined) 862 + ) 863 + ) 864 + ) 865 + );
+1 -1
rockskyweb/src/layouts/Navbar/Navbar.tsx
··· 331 331 </LabelMedium> 332 332 <div className="mt-[20px]"> 333 333 <a 334 - href="https://web-scrobbler.com/" 334 + href="https://github.com/web-scrobbler/web-scrobbler" 335 335 target="_blank" 336 336 rel="noopener noreferrer" 337 337 className="text-[var(--color-primary)]"