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

Refactor code for consistency and readability

- Removed unnecessary spaces and added missing commas in various files to ensure consistent formatting.
- Updated logging and error handling to maintain uniformity across the codebase.
- Improved the structure of function calls and pipe operations for better clarity.
- Ensured all promises resolve correctly and consistently handle errors.

+404 -404
+1 -1
apps/api/src/lexicon/types/app/rocksky/apikey/createApikey.ts
··· 9 import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 import type * as AppRockskyApikeyDefs from "./defs"; 11 12 - export type QueryParams = {} 13 14 export interface InputSchema { 15 /** The name of the API key. */
··· 9 import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 import type * as AppRockskyApikeyDefs from "./defs"; 11 12 + export type QueryParams = {}; 13 14 export interface InputSchema { 15 /** The name of the API key. */
+1 -1
apps/api/src/lexicon/types/app/rocksky/apikey/updateApikey.ts
··· 9 import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 import type * as AppRockskyApikeyDefs from "./defs"; 11 12 - export type QueryParams = {} 13 14 export interface InputSchema { 15 /** The ID of the API key to update. */
··· 9 import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 import type * as AppRockskyApikeyDefs from "./defs"; 11 12 + export type QueryParams = {}; 13 14 export interface InputSchema { 15 /** The ID of the API key to update. */
+1 -1
apps/api/src/lexicon/types/app/rocksky/like/dislikeShout.ts
··· 9 import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 import type * as AppRockskyShoutDefs from "../shout/defs"; 11 12 - export type QueryParams = {} 13 14 export interface InputSchema { 15 /** The unique identifier of the shout to dislike */
··· 9 import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 import type * as AppRockskyShoutDefs from "../shout/defs"; 11 12 + export type QueryParams = {}; 13 14 export interface InputSchema { 15 /** The unique identifier of the shout to dislike */
+1 -1
apps/api/src/lexicon/types/app/rocksky/like/dislikeSong.ts
··· 9 import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 import type * as AppRockskySongDefs from "../song/defs"; 11 12 - export type QueryParams = {} 13 14 export interface InputSchema { 15 /** The unique identifier of the song to dislike */
··· 9 import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 import type * as AppRockskySongDefs from "../song/defs"; 11 12 + export type QueryParams = {}; 13 14 export interface InputSchema { 15 /** The unique identifier of the song to dislike */
+1 -1
apps/api/src/lexicon/types/app/rocksky/like/likeShout.ts
··· 9 import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 import type * as AppRockskyShoutDefs from "../shout/defs"; 11 12 - export type QueryParams = {} 13 14 export interface InputSchema { 15 /** The unique identifier of the shout to like */
··· 9 import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 import type * as AppRockskyShoutDefs from "../shout/defs"; 11 12 + export type QueryParams = {}; 13 14 export interface InputSchema { 15 /** The unique identifier of the shout to like */
+1 -1
apps/api/src/lexicon/types/app/rocksky/like/likeSong.ts
··· 9 import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 import type * as AppRockskySongDefs from "../song/defs"; 11 12 - export type QueryParams = {} 13 14 export interface InputSchema { 15 /** The unique identifier of the song to like */
··· 9 import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 import type * as AppRockskySongDefs from "../song/defs"; 11 12 + export type QueryParams = {}; 13 14 export interface InputSchema { 15 /** The unique identifier of the song to like */
+1 -1
apps/api/src/lexicon/types/app/rocksky/scrobble/createScrobble.ts
··· 9 import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 import type * as AppRockskyScrobbleDefs from "./defs"; 11 12 - export type QueryParams = {} 13 14 export interface InputSchema { 15 /** The title of the track being scrobbled */
··· 9 import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 import type * as AppRockskyScrobbleDefs from "./defs"; 11 12 + export type QueryParams = {}; 13 14 export interface InputSchema { 15 /** The title of the track being scrobbled */
+1 -1
apps/api/src/lexicon/types/app/rocksky/shout/createShout.ts
··· 9 import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 import type * as AppRockskyShoutDefs from "./defs"; 11 12 - export type QueryParams = {} 13 14 export interface InputSchema { 15 /** The content of the shout */
··· 9 import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 import type * as AppRockskyShoutDefs from "./defs"; 11 12 + export type QueryParams = {}; 13 14 export interface InputSchema { 15 /** The content of the shout */
+1 -1
apps/api/src/lexicon/types/app/rocksky/shout/replyShout.ts
··· 9 import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 import type * as AppRockskyShoutDefs from "./defs"; 11 12 - export type QueryParams = {} 13 14 export interface InputSchema { 15 /** The unique identifier of the shout to reply to */
··· 9 import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 import type * as AppRockskyShoutDefs from "./defs"; 11 12 + export type QueryParams = {}; 13 14 export interface InputSchema { 15 /** The unique identifier of the shout to reply to */
+1 -1
apps/api/src/lexicon/types/app/rocksky/shout/reportShout.ts
··· 9 import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 import type * as AppRockskyShoutDefs from "./defs"; 11 12 - export type QueryParams = {} 13 14 export interface InputSchema { 15 /** The unique identifier of the shout to report */
··· 9 import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 import type * as AppRockskyShoutDefs from "./defs"; 11 12 + export type QueryParams = {}; 13 14 export interface InputSchema { 15 /** The unique identifier of the shout to report */
+1 -1
apps/api/src/lexicon/types/app/rocksky/song/createSong.ts
··· 9 import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 import type * as AppRockskySongDefs from "./defs"; 11 12 - export type QueryParams = {} 13 14 export interface InputSchema { 15 /** The title of the song */
··· 9 import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 import type * as AppRockskySongDefs from "./defs"; 11 12 + export type QueryParams = {}; 13 14 export interface InputSchema { 15 /** The title of the song */
+1 -1
apps/api/src/lexicon/types/app/rocksky/spotify/next.ts
··· 8 import { CID } from "multiformats/cid"; 9 import { type HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 11 - export type QueryParams = {} 12 13 export type InputSchema = undefined; 14 export type HandlerInput = undefined;
··· 8 import { CID } from "multiformats/cid"; 9 import { type HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 11 + export type QueryParams = {}; 12 13 export type InputSchema = undefined; 14 export type HandlerInput = undefined;
+1 -1
apps/api/src/lexicon/types/app/rocksky/spotify/pause.ts
··· 8 import { CID } from "multiformats/cid"; 9 import { type HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 11 - export type QueryParams = {} 12 13 export type InputSchema = undefined; 14 export type HandlerInput = undefined;
··· 8 import { CID } from "multiformats/cid"; 9 import { type HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 11 + export type QueryParams = {}; 12 13 export type InputSchema = undefined; 14 export type HandlerInput = undefined;
+1 -1
apps/api/src/lexicon/types/app/rocksky/spotify/play.ts
··· 8 import { CID } from "multiformats/cid"; 9 import { type HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 11 - export type QueryParams = {} 12 13 export type InputSchema = undefined; 14 export type HandlerInput = undefined;
··· 8 import { CID } from "multiformats/cid"; 9 import { type HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 11 + export type QueryParams = {}; 12 13 export type InputSchema = undefined; 14 export type HandlerInput = undefined;
+1 -1
apps/api/src/lexicon/types/app/rocksky/spotify/previous.ts
··· 8 import { CID } from "multiformats/cid"; 9 import { type HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 11 - export type QueryParams = {} 12 13 export type InputSchema = undefined; 14 export type HandlerInput = undefined;
··· 8 import { CID } from "multiformats/cid"; 9 import { type HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 11 + export type QueryParams = {}; 12 13 export type InputSchema = undefined; 14 export type HandlerInput = undefined;
+2 -2
apps/api/src/lib/crypto.ts
··· 6 const cipher = crypto.createCipheriv( 7 "aes-256-ctr", 8 Buffer.from(key, "hex"), 9 - iv 10 ); 11 const encrypted = Buffer.concat([ 12 cipher.update(text, "utf8"), ··· 21 const decipher = crypto.createDecipheriv( 22 "aes-256-ctr", 23 Buffer.from(key, "hex"), 24 - iv 25 ); 26 const decrypted = Buffer.concat([decipher.update(content), decipher.final()]); 27 return decrypted.toString("utf8");
··· 6 const cipher = crypto.createCipheriv( 7 "aes-256-ctr", 8 Buffer.from(key, "hex"), 9 + iv, 10 ); 11 const encrypted = Buffer.concat([ 12 cipher.update(text, "utf8"), ··· 21 const decipher = crypto.createDecipheriv( 22 "aes-256-ctr", 23 Buffer.from(key, "hex"), 24 + iv, 25 ); 26 const decrypted = Buffer.concat([decipher.update(content), decipher.final()]); 27 return decrypted.toString("utf8");
+17 -17
apps/api/src/lovedtracks/lovedtracks.service.ts
··· 11 ctx: Context, 12 track: Track, 13 user, 14 - agent: Agent 15 ) { 16 const existingTrack = await ctx.client.db.tracks 17 .filter( ··· 19 equals( 20 createHash("sha256") 21 .update( 22 - `${track.title} - ${track.artist} - ${track.album}`.toLowerCase() 23 ) 24 - .digest("hex") 25 - ) 26 ) 27 .getFirst(); 28 ··· 43 // compute sha256 (lowercase(title + artist + album)) 44 sha256: createHash("sha256") 45 .update( 46 - `${track.title} - ${track.artist} - ${track.album}`.toLowerCase() 47 ) 48 .digest("hex"), 49 - } 50 ); 51 52 const existingArtist = await ctx.client.db.artists ··· 55 equals( 56 createHash("sha256") 57 .update(track.albumArtist.toLocaleLowerCase()) 58 - .digest("hex") 59 - ) 60 ) 61 .getFirst(); 62 const { xata_id: artist_id } = await ctx.client.db.artists.createOrUpdate( ··· 67 sha256: createHash("sha256") 68 .update(track.albumArtist.toLowerCase()) 69 .digest("hex"), 70 - } 71 ); 72 73 const existingAlbum = await ctx.client.db.albums ··· 76 equals( 77 createHash("sha256") 78 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 79 - .digest("hex") 80 - ) 81 ) 82 .getFirst(); 83 ··· 95 sha256: createHash("sha256") 96 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 97 .digest("hex"), 98 - } 99 ); 100 101 const existingAlbumTrack = await ctx.client.db.album_tracks ··· 118 { 119 artist_id, 120 track_id, 121 - } 122 ); 123 124 const existingArtistAlbum = await ctx.client.db.artist_albums ··· 131 { 132 artist_id, 133 album_id, 134 - } 135 ); 136 137 const lovedTrack = await ctx.client.db.loved_tracks ··· 144 { 145 user_id: user.xata_id, 146 track_id, 147 - } 148 ); 149 150 if (existingTrack.uri) { ··· 206 ctx: Context, 207 trackSha256: string, 208 user, 209 - agent: Agent 210 ) { 211 const track = await ctx.client.db.tracks 212 .filter("sha256", equals(trackSha256)) ··· 244 ctx: Context, 245 user, 246 size = 10, 247 - offset = 0 248 ) { 249 const lovedTracks = await ctx.client.db.loved_tracks 250 .select(["track_id.*"])
··· 11 ctx: Context, 12 track: Track, 13 user, 14 + agent: Agent, 15 ) { 16 const existingTrack = await ctx.client.db.tracks 17 .filter( ··· 19 equals( 20 createHash("sha256") 21 .update( 22 + `${track.title} - ${track.artist} - ${track.album}`.toLowerCase(), 23 ) 24 + .digest("hex"), 25 + ), 26 ) 27 .getFirst(); 28 ··· 43 // compute sha256 (lowercase(title + artist + album)) 44 sha256: createHash("sha256") 45 .update( 46 + `${track.title} - ${track.artist} - ${track.album}`.toLowerCase(), 47 ) 48 .digest("hex"), 49 + }, 50 ); 51 52 const existingArtist = await ctx.client.db.artists ··· 55 equals( 56 createHash("sha256") 57 .update(track.albumArtist.toLocaleLowerCase()) 58 + .digest("hex"), 59 + ), 60 ) 61 .getFirst(); 62 const { xata_id: artist_id } = await ctx.client.db.artists.createOrUpdate( ··· 67 sha256: createHash("sha256") 68 .update(track.albumArtist.toLowerCase()) 69 .digest("hex"), 70 + }, 71 ); 72 73 const existingAlbum = await ctx.client.db.albums ··· 76 equals( 77 createHash("sha256") 78 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 79 + .digest("hex"), 80 + ), 81 ) 82 .getFirst(); 83 ··· 95 sha256: createHash("sha256") 96 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 97 .digest("hex"), 98 + }, 99 ); 100 101 const existingAlbumTrack = await ctx.client.db.album_tracks ··· 118 { 119 artist_id, 120 track_id, 121 + }, 122 ); 123 124 const existingArtistAlbum = await ctx.client.db.artist_albums ··· 131 { 132 artist_id, 133 album_id, 134 + }, 135 ); 136 137 const lovedTrack = await ctx.client.db.loved_tracks ··· 144 { 145 user_id: user.xata_id, 146 track_id, 147 + }, 148 ); 149 150 if (existingTrack.uri) { ··· 206 ctx: Context, 207 trackSha256: string, 208 user, 209 + agent: Agent, 210 ) { 211 const track = await ctx.client.db.tracks 212 .filter("sha256", equals(trackSha256)) ··· 244 ctx: Context, 245 user, 246 size = 10, 247 + offset = 0, 248 ) { 249 const lovedTracks = await ctx.client.db.loved_tracks 250 .select(["track_id.*"])
+32 -32
apps/api/src/nowplaying/nowplaying.service.ts
··· 13 14 export async function putArtistRecord( 15 track: Track, 16 - agent: Agent 17 ): Promise<string | null> { 18 const rkey = TID.nextStr(); 19 const record: { ··· 54 55 export async function putAlbumRecord( 56 track: Track, 57 - agent: Agent 58 ): Promise<string | null> { 59 const rkey = TID.nextStr(); 60 ··· 94 95 export async function putSongRecord( 96 track: Track, 97 - agent: Agent 98 ): Promise<string | null> { 99 const rkey = TID.nextStr(); 100 ··· 146 147 async function putScrobbleRecord( 148 track: Track, 149 - agent: Agent 150 ): Promise<string | null> { 151 const rkey = TID.nextStr(); 152 ··· 308 ctx: Context, 309 track: Track, 310 agent: Agent, 311 - userDid: string 312 ): Promise<void> { 313 // check if scrobble already exists (user did + timestamp) 314 const scrobbleTime = dayjs.unix(track.timestamp); ··· 332 if (existingScrobble) { 333 console.log( 334 `Scrobble already exists for ${chalk.cyan(track.title)} at ${chalk.cyan( 335 - dayjs.unix(track.timestamp).format("YYYY-MM-DD HH:mm:ss") 336 - )}` 337 ); 338 return; 339 } ··· 345 equals( 346 createHash("sha256") 347 .update( 348 - `${track.title} - ${track.artist} - ${track.album}`.toLowerCase() 349 ) 350 - .digest("hex") 351 - ) 352 ) 353 .getFirst(); 354 ··· 359 equals( 360 createHash("sha256") 361 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 362 - .digest("hex") 363 - ) 364 ) 365 .getFirst(); 366 if (album) { ··· 377 equals( 378 createHash("sha256") 379 .update(track.albumArtist.toLowerCase()) 380 - .digest("hex") 381 - ) 382 ) 383 .getFirst(); 384 if (artist) { ··· 405 equals( 406 createHash("sha256") 407 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 408 - .digest("hex") 409 - ) 410 ) 411 .getFirst(); 412 ··· 419 equals( 420 createHash("sha256") 421 .update( 422 - `${track.title} - ${track.artist} - ${track.album}`.toLowerCase() 423 ) 424 - .digest("hex") 425 - ) 426 ) 427 .getFirst(); 428 await new Promise((resolve) => setTimeout(resolve, 1000)); ··· 435 436 if (existingTrack) { 437 console.log( 438 - `Song found: ${chalk.cyan(existingTrack.xata_id)} - ${track.title}, after ${chalk.magenta(tries)} tries` 439 ); 440 } 441 ··· 485 equals( 486 createHash("sha256") 487 .update( 488 - `${track.title} - ${track.artist} - ${track.album}`.toLowerCase() 489 ) 490 - .digest("hex") 491 - ) 492 ) 493 .getFirst(); 494 ··· 498 tries < 30 499 ) { 500 console.log( 501 - `Artist uri not ready, trying again: ${chalk.magenta(tries + 1)}` 502 ); 503 existingTrack = await ctx.client.db.tracks 504 .filter( ··· 506 equals( 507 createHash("sha256") 508 .update( 509 - `${track.title} - ${track.artist} - ${track.album}`.toLowerCase() 510 ) 511 - .digest("hex") 512 - ) 513 ) 514 .getFirst(); 515 ··· 521 equals( 522 createHash("sha256") 523 .update(track.albumArtist.toLowerCase()) 524 - .digest("hex") 525 - ) 526 ) 527 .getFirst(); 528 if (artist) { ··· 541 equals( 542 createHash("sha256") 543 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 544 - .digest("hex") 545 - ) 546 ) 547 .getFirst(); 548 if (album) { ··· 569 570 if (existingTrack?.artist_uri) { 571 console.log( 572 - `Artist uri ready: ${chalk.cyan(existingTrack.xata_id)} - ${track.title}, after ${chalk.magenta(tries)} tries` 573 ); 574 } 575
··· 13 14 export async function putArtistRecord( 15 track: Track, 16 + agent: Agent, 17 ): Promise<string | null> { 18 const rkey = TID.nextStr(); 19 const record: { ··· 54 55 export async function putAlbumRecord( 56 track: Track, 57 + agent: Agent, 58 ): Promise<string | null> { 59 const rkey = TID.nextStr(); 60 ··· 94 95 export async function putSongRecord( 96 track: Track, 97 + agent: Agent, 98 ): Promise<string | null> { 99 const rkey = TID.nextStr(); 100 ··· 146 147 async function putScrobbleRecord( 148 track: Track, 149 + agent: Agent, 150 ): Promise<string | null> { 151 const rkey = TID.nextStr(); 152 ··· 308 ctx: Context, 309 track: Track, 310 agent: Agent, 311 + userDid: string, 312 ): Promise<void> { 313 // check if scrobble already exists (user did + timestamp) 314 const scrobbleTime = dayjs.unix(track.timestamp); ··· 332 if (existingScrobble) { 333 console.log( 334 `Scrobble already exists for ${chalk.cyan(track.title)} at ${chalk.cyan( 335 + dayjs.unix(track.timestamp).format("YYYY-MM-DD HH:mm:ss"), 336 + )}`, 337 ); 338 return; 339 } ··· 345 equals( 346 createHash("sha256") 347 .update( 348 + `${track.title} - ${track.artist} - ${track.album}`.toLowerCase(), 349 ) 350 + .digest("hex"), 351 + ), 352 ) 353 .getFirst(); 354 ··· 359 equals( 360 createHash("sha256") 361 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 362 + .digest("hex"), 363 + ), 364 ) 365 .getFirst(); 366 if (album) { ··· 377 equals( 378 createHash("sha256") 379 .update(track.albumArtist.toLowerCase()) 380 + .digest("hex"), 381 + ), 382 ) 383 .getFirst(); 384 if (artist) { ··· 405 equals( 406 createHash("sha256") 407 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 408 + .digest("hex"), 409 + ), 410 ) 411 .getFirst(); 412 ··· 419 equals( 420 createHash("sha256") 421 .update( 422 + `${track.title} - ${track.artist} - ${track.album}`.toLowerCase(), 423 ) 424 + .digest("hex"), 425 + ), 426 ) 427 .getFirst(); 428 await new Promise((resolve) => setTimeout(resolve, 1000)); ··· 435 436 if (existingTrack) { 437 console.log( 438 + `Song found: ${chalk.cyan(existingTrack.xata_id)} - ${track.title}, after ${chalk.magenta(tries)} tries`, 439 ); 440 } 441 ··· 485 equals( 486 createHash("sha256") 487 .update( 488 + `${track.title} - ${track.artist} - ${track.album}`.toLowerCase(), 489 ) 490 + .digest("hex"), 491 + ), 492 ) 493 .getFirst(); 494 ··· 498 tries < 30 499 ) { 500 console.log( 501 + `Artist uri not ready, trying again: ${chalk.magenta(tries + 1)}`, 502 ); 503 existingTrack = await ctx.client.db.tracks 504 .filter( ··· 506 equals( 507 createHash("sha256") 508 .update( 509 + `${track.title} - ${track.artist} - ${track.album}`.toLowerCase(), 510 ) 511 + .digest("hex"), 512 + ), 513 ) 514 .getFirst(); 515 ··· 521 equals( 522 createHash("sha256") 523 .update(track.albumArtist.toLowerCase()) 524 + .digest("hex"), 525 + ), 526 ) 527 .getFirst(); 528 if (artist) { ··· 541 equals( 542 createHash("sha256") 543 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 544 + .digest("hex"), 545 + ), 546 ) 547 .getFirst(); 548 if (album) { ··· 569 570 if (existingTrack?.artist_uri) { 571 console.log( 572 + `Artist uri ready: ${chalk.cyan(existingTrack.xata_id)} - ${track.title}, after ${chalk.magenta(tries)} tries`, 573 ); 574 } 575
+10 -10
apps/api/src/spotify/app.ts
··· 17 limit: 10, // max Spotify API calls 18 window: 15, // per 10 seconds 19 keyPrefix: "spotify-ratelimit", 20 - }) 21 ); 22 23 app.get("/login", async (c) => { ··· 44 const redirectUrl = `https://accounts.spotify.com/en/authorize?client_id=${env.SPOTIFY_CLIENT_ID}&response_type=code&redirect_uri=${env.SPOTIFY_REDIRECT_URI}&scope=user-read-private%20user-read-email%20user-read-playback-state%20user-read-currently-playing%20user-modify-playback-state%20playlist-modify-public%20playlist-modify-private%20playlist-read-private%20playlist-read-collaborative&state=${state}`; 45 c.header( 46 "Set-Cookie", 47 - `session-id=${state}; Path=/; HttpOnly; SameSite=Strict; Secure` 48 ); 49 return c.json({ redirectUrl }); 50 }); ··· 210 211 const sha256 = createHash("sha256") 212 .update( 213 - `${track.item.name} - ${track.item.artists.map((x) => x.name).join(", ")} - ${track.item.album.name}`.toLowerCase() 214 ) 215 .digest("hex"); 216 ··· 264 265 const refreshToken = decrypt( 266 spotifyToken.refresh_token, 267 - env.SPOTIFY_ENCRYPTION_KEY 268 ); 269 270 // get new access token ··· 330 331 const refreshToken = decrypt( 332 spotifyToken.refresh_token, 333 - env.SPOTIFY_ENCRYPTION_KEY 334 ); 335 336 // get new access token ··· 396 397 const refreshToken = decrypt( 398 spotifyToken.refresh_token, 399 - env.SPOTIFY_ENCRYPTION_KEY 400 ); 401 402 // get new access token ··· 462 463 const refreshToken = decrypt( 464 spotifyToken.refresh_token, 465 - env.SPOTIFY_ENCRYPTION_KEY 466 ); 467 468 // get new access token ··· 488 headers: { 489 Authorization: `Bearer ${access_token}`, 490 }, 491 - } 492 ); 493 494 if (response.status === 403) { ··· 531 532 const refreshToken = decrypt( 533 spotifyToken.refresh_token, 534 - env.SPOTIFY_ENCRYPTION_KEY 535 ); 536 537 // get new access token ··· 558 headers: { 559 Authorization: `Bearer ${access_token}`, 560 }, 561 - } 562 ); 563 564 if (response.status === 403) {
··· 17 limit: 10, // max Spotify API calls 18 window: 15, // per 10 seconds 19 keyPrefix: "spotify-ratelimit", 20 + }), 21 ); 22 23 app.get("/login", async (c) => { ··· 44 const redirectUrl = `https://accounts.spotify.com/en/authorize?client_id=${env.SPOTIFY_CLIENT_ID}&response_type=code&redirect_uri=${env.SPOTIFY_REDIRECT_URI}&scope=user-read-private%20user-read-email%20user-read-playback-state%20user-read-currently-playing%20user-modify-playback-state%20playlist-modify-public%20playlist-modify-private%20playlist-read-private%20playlist-read-collaborative&state=${state}`; 45 c.header( 46 "Set-Cookie", 47 + `session-id=${state}; Path=/; HttpOnly; SameSite=Strict; Secure`, 48 ); 49 return c.json({ redirectUrl }); 50 }); ··· 210 211 const sha256 = createHash("sha256") 212 .update( 213 + `${track.item.name} - ${track.item.artists.map((x) => x.name).join(", ")} - ${track.item.album.name}`.toLowerCase(), 214 ) 215 .digest("hex"); 216 ··· 264 265 const refreshToken = decrypt( 266 spotifyToken.refresh_token, 267 + env.SPOTIFY_ENCRYPTION_KEY, 268 ); 269 270 // get new access token ··· 330 331 const refreshToken = decrypt( 332 spotifyToken.refresh_token, 333 + env.SPOTIFY_ENCRYPTION_KEY, 334 ); 335 336 // get new access token ··· 396 397 const refreshToken = decrypt( 398 spotifyToken.refresh_token, 399 + env.SPOTIFY_ENCRYPTION_KEY, 400 ); 401 402 // get new access token ··· 462 463 const refreshToken = decrypt( 464 spotifyToken.refresh_token, 465 + env.SPOTIFY_ENCRYPTION_KEY, 466 ); 467 468 // get new access token ··· 488 headers: { 489 Authorization: `Bearer ${access_token}`, 490 }, 491 + }, 492 ); 493 494 if (response.status === 403) { ··· 531 532 const refreshToken = decrypt( 533 spotifyToken.refresh_token, 534 + env.SPOTIFY_ENCRYPTION_KEY, 535 ); 536 537 // get new access token ··· 558 headers: { 559 Authorization: `Bearer ${access_token}`, 560 }, 561 + }, 562 ); 563 564 if (response.status === 403) {
+4 -4
apps/api/src/subscribers/playlist.ts
··· 17 did: string; 18 } = JSON.parse(sc.decode(m.data)); 19 console.log( 20 - `New playlist: ${chalk.cyan(payload.did)} - ${chalk.greenBright(payload.id)}` 21 ); 22 await putPlaylistRecord(ctx, payload); 23 } ··· 26 27 async function putPlaylistRecord( 28 ctx: Context, 29 - payload: { id: string; did: string } 30 ) { 31 const agent = await createAgent(ctx.oauthClient, payload.did); 32 33 if (!agent) { 34 console.error( 35 - `Failed to create agent, skipping playlist: ${chalk.cyan(payload.id)} for ${chalk.greenBright(payload.did)}` 36 ); 37 return; 38 } ··· 100 101 await ctx.meilisearch.post( 102 `indexes/playlists/documents?primaryKey=id`, 103 - updatedPlaylist 104 ); 105 }
··· 17 did: string; 18 } = JSON.parse(sc.decode(m.data)); 19 console.log( 20 + `New playlist: ${chalk.cyan(payload.did)} - ${chalk.greenBright(payload.id)}`, 21 ); 22 await putPlaylistRecord(ctx, payload); 23 } ··· 26 27 async function putPlaylistRecord( 28 ctx: Context, 29 + payload: { id: string; did: string }, 30 ) { 31 const agent = await createAgent(ctx.oauthClient, payload.did); 32 33 if (!agent) { 34 console.error( 35 + `Failed to create agent, skipping playlist: ${chalk.cyan(payload.id)} for ${chalk.greenBright(payload.did)}`, 36 ); 37 return; 38 } ··· 100 101 await ctx.meilisearch.post( 102 `indexes/playlists/documents?primaryKey=id`, 103 + updatedPlaylist, 104 ); 105 }
+12 -12
apps/api/src/tracks/tracks.service.ts
··· 16 equals( 17 createHash("sha256") 18 .update( 19 - `${track.title} - ${track.artist} - ${track.album}`.toLowerCase() 20 ) 21 - .digest("hex") 22 - ) 23 ) 24 .getFirst(); 25 ··· 36 equals( 37 createHash("sha256") 38 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 39 - .digest("hex") 40 - ) 41 ) 42 .getFirst(); 43 if (album) { ··· 54 equals( 55 createHash("sha256") 56 .update(track.albumArtist.toLowerCase()) 57 - .digest("hex") 58 - ) 59 ) 60 .getFirst(); 61 if (artist) { ··· 72 equals( 73 createHash("sha256") 74 .update(track.albumArtist.toLocaleLowerCase()) 75 - .digest("hex") 76 - ) 77 ) 78 .getFirst(); 79 ··· 88 equals( 89 createHash("sha256") 90 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 91 - .digest("hex") 92 - ) 93 ) 94 .getFirst(); 95 ··· 116 if (!track_id || !album_id || !artist_id) { 117 console.log( 118 "Track not yet saved (uri not saved), retrying...", 119 - tries + 1 120 ); 121 await new Promise((resolve) => setTimeout(resolve, 1000)); 122 tries += 1;
··· 16 equals( 17 createHash("sha256") 18 .update( 19 + `${track.title} - ${track.artist} - ${track.album}`.toLowerCase(), 20 ) 21 + .digest("hex"), 22 + ), 23 ) 24 .getFirst(); 25 ··· 36 equals( 37 createHash("sha256") 38 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 39 + .digest("hex"), 40 + ), 41 ) 42 .getFirst(); 43 if (album) { ··· 54 equals( 55 createHash("sha256") 56 .update(track.albumArtist.toLowerCase()) 57 + .digest("hex"), 58 + ), 59 ) 60 .getFirst(); 61 if (artist) { ··· 72 equals( 73 createHash("sha256") 74 .update(track.albumArtist.toLocaleLowerCase()) 75 + .digest("hex"), 76 + ), 77 ) 78 .getFirst(); 79 ··· 88 equals( 89 createHash("sha256") 90 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 91 + .digest("hex"), 92 + ), 93 ) 94 .getFirst(); 95 ··· 116 if (!track_id || !album_id || !artist_id) { 117 console.log( 118 "Track not yet saved (uri not saved), retrying...", 119 + tries + 1, 120 ); 121 await new Promise((resolve) => setTimeout(resolve, 1000)); 122 tries += 1;
+14 -14
apps/api/src/websocket/handler.ts
··· 70 if (data.type === "track") { 71 const sha256 = createHash("sha256") 72 .update( 73 - `${data.title} - ${data.artist} - ${data.album}`.toLowerCase() 74 ) 75 .digest("hex"); 76 const [cachedTrack, cachedLikes] = await Promise.all([ ··· 93 await ctx.redis.setEx( 94 `likes:${did}:${sha256}`, 95 2, 96 - JSON.stringify({ liked: data.liked }) 97 ); 98 } 99 ··· 113 ...data, 114 sha256, 115 liked: data.liked, 116 - }) 117 ); 118 } else { 119 const [track] = await ctx.db ··· 136 albumUri: track.albumUri, 137 artistUri: track.artistUri, 138 liked: data.liked, 139 - }) 140 ), 141 ctx.redis.setEx( 142 `nowplaying:${did}`, ··· 145 ...data, 146 sha256, 147 liked: data.liked, 148 - }) 149 ), 150 ]); 151 } ··· 154 await ctx.redis.setEx( 155 `nowplaying:${did}:status`, 156 3, 157 - `${data.status}` 158 ); 159 } 160 ··· 163 type: "message", 164 data, 165 device_id, 166 - }) 167 ); 168 } 169 }); ··· 175 ignoreExpiration: true, 176 }); 177 console.log( 178 - `Control message: ${chalk.greenBright(type)}, ${chalk.greenBright(target)}, ${chalk.greenBright(action)}, ${chalk.greenBright(args)}, ${chalk.greenBright("***")}` 179 ); 180 // Handle control message 181 const deviceId = userDevices[did]?.find((id) => id === target); ··· 184 if (targetDevice) { 185 targetDevice.send(JSON.stringify({ type, action, args })); 186 console.log( 187 - `Control message sent to device: ${chalk.greenBright(deviceId)}, ${chalk.greenBright(target)}` 188 ); 189 return; 190 } ··· 196 if (targetDevice) { 197 targetDevice.send(JSON.stringify({ type, action, args })); 198 console.log( 199 - `Control message sent to all devices: ${chalk.greenBright(id)}, ${chalk.greenBright(target)}` 200 ); 201 } 202 }); ··· 208 if (registerMessage.success) { 209 const { type, clientName, token } = registerMessage.data; 210 console.log( 211 - `Register message: ${chalk.greenBright(type)}, ${chalk.greenBright(clientName)}, ${chalk.greenBright("****")}` 212 ); 213 // Handle register Message 214 const { did } = jwt.verify(token, env.JWT_SECRET, { ··· 221 deviceNames[deviceId] = clientName; 222 userDevices[did] = [...(userDevices[did] || []), deviceId]; 223 console.log( 224 - `Device registered: ${chalk.greenBright(deviceId)}, ${chalk.greenBright(clientName)}` 225 ); 226 227 // broadcast to all devices ··· 235 type: "device_registered", 236 deviceId, 237 clientName, 238 - }) 239 ); 240 } 241 }); ··· 266 const clientName = deviceNames[deviceId]; 267 delete deviceNames[deviceId]; 268 console.log( 269 - `Device name removed: ${chalk.redBright(deviceId)}, ${chalk.redBright(clientName)}` 270 ); 271 } 272 },
··· 70 if (data.type === "track") { 71 const sha256 = createHash("sha256") 72 .update( 73 + `${data.title} - ${data.artist} - ${data.album}`.toLowerCase(), 74 ) 75 .digest("hex"); 76 const [cachedTrack, cachedLikes] = await Promise.all([ ··· 93 await ctx.redis.setEx( 94 `likes:${did}:${sha256}`, 95 2, 96 + JSON.stringify({ liked: data.liked }), 97 ); 98 } 99 ··· 113 ...data, 114 sha256, 115 liked: data.liked, 116 + }), 117 ); 118 } else { 119 const [track] = await ctx.db ··· 136 albumUri: track.albumUri, 137 artistUri: track.artistUri, 138 liked: data.liked, 139 + }), 140 ), 141 ctx.redis.setEx( 142 `nowplaying:${did}`, ··· 145 ...data, 146 sha256, 147 liked: data.liked, 148 + }), 149 ), 150 ]); 151 } ··· 154 await ctx.redis.setEx( 155 `nowplaying:${did}:status`, 156 3, 157 + `${data.status}`, 158 ); 159 } 160 ··· 163 type: "message", 164 data, 165 device_id, 166 + }), 167 ); 168 } 169 }); ··· 175 ignoreExpiration: true, 176 }); 177 console.log( 178 + `Control message: ${chalk.greenBright(type)}, ${chalk.greenBright(target)}, ${chalk.greenBright(action)}, ${chalk.greenBright(args)}, ${chalk.greenBright("***")}`, 179 ); 180 // Handle control message 181 const deviceId = userDevices[did]?.find((id) => id === target); ··· 184 if (targetDevice) { 185 targetDevice.send(JSON.stringify({ type, action, args })); 186 console.log( 187 + `Control message sent to device: ${chalk.greenBright(deviceId)}, ${chalk.greenBright(target)}`, 188 ); 189 return; 190 } ··· 196 if (targetDevice) { 197 targetDevice.send(JSON.stringify({ type, action, args })); 198 console.log( 199 + `Control message sent to all devices: ${chalk.greenBright(id)}, ${chalk.greenBright(target)}`, 200 ); 201 } 202 }); ··· 208 if (registerMessage.success) { 209 const { type, clientName, token } = registerMessage.data; 210 console.log( 211 + `Register message: ${chalk.greenBright(type)}, ${chalk.greenBright(clientName)}, ${chalk.greenBright("****")}`, 212 ); 213 // Handle register Message 214 const { did } = jwt.verify(token, env.JWT_SECRET, { ··· 221 deviceNames[deviceId] = clientName; 222 userDevices[did] = [...(userDevices[did] || []), deviceId]; 223 console.log( 224 + `Device registered: ${chalk.greenBright(deviceId)}, ${chalk.greenBright(clientName)}`, 225 ); 226 227 // broadcast to all devices ··· 235 type: "device_registered", 236 deviceId, 237 clientName, 238 + }), 239 ); 240 } 241 }); ··· 266 const clientName = deviceNames[deviceId]; 267 delete deviceNames[deviceId]; 268 console.log( 269 + `Device name removed: ${chalk.redBright(deviceId)}, ${chalk.redBright(clientName)}`, 270 ); 271 } 272 },
+1 -1
apps/api/src/xata.ts
··· 5255 } 5256 } 5257 5258 - let instance: XataClient | undefined ; 5259 5260 export const getXataClient = () => { 5261 if (instance) return instance;
··· 5255 } 5256 } 5257 5258 + let instance: XataClient | undefined; 5259 5260 export const getXataClient = () => { 5261 if (instance) return instance;
+173 -173
apps/api/src/xrpc/app/rocksky/scrobble/createScrobble.ts
··· 37 pipe( 38 scrobbleTrack(ctx, track, agent, did), 39 Effect.tap(() => 40 - Effect.logInfo(`Scrobble created for ${chalk.cyan(track.title)}`) 41 - ) 42 - ) 43 ), 44 Effect.flatMap(presentation), 45 Effect.retry({ times: 3 }), ··· 47 Effect.catchAll((err) => { 48 console.error(err); 49 return Effect.succeed({}); 50 - }) 51 ); 52 server.app.rocksky.scrobble.createScrobble({ 53 auth: ctx.authVerifier, ··· 81 ctx, 82 did, 83 input, 84 - })) 85 ), 86 Match.orElse(() => { 87 throw new Error("Authentication required to create a scrobble"); 88 - }) 89 ), 90 catch: (error) => new Error(`Failed to create agent: ${error}`), 91 }); ··· 124 agent: Agent, 125 collection: string, 126 record: T, 127 - validate: (record: T) => { success: boolean } 128 ) => 129 pipe( 130 Effect.succeed(record), 131 Effect.filterOrFail( 132 (rec) => validate(rec).success, 133 - () => new Error("Invalid record") 134 ), 135 Effect.flatMap(() => 136 pipe( ··· 143 rkey, 144 record, 145 validate: false, 146 - }) 147 - ) 148 ), 149 Effect.tap((res) => 150 - Effect.logInfo(`Record created at ${res.data.uri}`) 151 ), 152 - Effect.map((res) => res.data.uri) 153 - ) 154 ), 155 Effect.catchAll((error) => { 156 console.error(`Error creating ${collection} record`, error); 157 return Effect.succeed(null); 158 - }) 159 ); 160 161 const putArtistRecord = (track: Track, agent: Agent) => ··· 168 tags: track.genres, 169 }), 170 Effect.flatMap((record) => 171 - putRecord(agent, "app.rocksky.artist", record, Artist.validateRecord) 172 - ) 173 ); 174 175 const putAlbumRecord = (track: Track, agent: Agent) => ··· 186 albumArtUrl: track.albumArt, 187 }), 188 Effect.flatMap((record) => 189 - putRecord(agent, "app.rocksky.album", record, Album.validateRecord) 190 - ) 191 ); 192 193 const putSongRecord = (track: Track, agent: Agent) => ··· 213 spotifyLink: track.spotifyLink ?? undefined, 214 }), 215 Effect.flatMap((record) => 216 - putRecord(agent, "app.rocksky.song", record, Song.validateRecord) 217 - ) 218 ); 219 220 const putScrobbleRecord = (track: Track, agent: Agent) => ··· 242 spotifyLink: track.spotifyLink ?? undefined, 243 }), 244 Effect.flatMap((record) => 245 - putRecord(agent, "app.rocksky.scrobble", record, Scrobble.validateRecord) 246 - ) 247 ); 248 249 const getScrobble = ({ ctx, id }: { ctx: Context; id: string }) => ··· 255 .leftJoin(tables.albums, eq(tables.albums.id, tables.scrobbles.albumId)) 256 .leftJoin( 257 tables.artists, 258 - eq(tables.artists.id, tables.scrobbles.artistId) 259 ) 260 .leftJoin(tables.users, eq(tables.users.id, tables.scrobbles.userId)) 261 .where(eq(tables.scrobbles.id, id)) 262 .execute() 263 - .then(([row]) => row) 264 ); 265 266 const getUserAlbum = ( ··· 270 artists: SelectArtist; 271 users: SelectUser; 272 tracks: SelectTrack; 273 - } 274 ) => 275 Effect.tryPromise(() => 276 ctx.db ··· 278 .from(tables.userAlbums) 279 .where(eq(tables.userAlbums.albumId, scrobble.albums.id)) 280 .execute() 281 - .then(([row]) => row) 282 ); 283 284 const getUserArtist = ( ··· 288 artists: SelectArtist; 289 users: SelectUser; 290 tracks: SelectTrack; 291 - } 292 ) => 293 Effect.tryPromise(() => 294 ctx.db ··· 296 .from(tables.userArtists) 297 .where(eq(tables.userArtists.id, scrobble.artists.id)) 298 .execute() 299 - .then(([row]) => row) 300 ); 301 302 const getUserTrack = ( ··· 306 artists: SelectArtist; 307 users: SelectUser; 308 tracks: SelectTrack; 309 - } 310 ) => 311 Effect.tryPromise(() => 312 ctx.db ··· 314 .from(tables.userTracks) 315 .where(eq(tables.userTracks.id, scrobble.tracks.id)) 316 .execute() 317 - .then(([row]) => row) 318 ); 319 320 const getAlbumTrack = ( ··· 324 artists: SelectArtist; 325 users: SelectUser; 326 tracks: SelectTrack; 327 - } 328 ) => 329 Effect.tryPromise(() => 330 ctx.db ··· 332 .from(tables.albumTracks) 333 .where(eq(tables.albumTracks.trackId, scrobble.tracks.id)) 334 .execute() 335 - .then(([row]) => row) 336 ); 337 338 const getArtistTrack = ( ··· 342 artists: SelectArtist; 343 users: SelectUser; 344 tracks: SelectTrack; 345 - } 346 ) => 347 Effect.tryPromise(() => 348 ctx.db ··· 350 .from(tables.artistTracks) 351 .where(eq(tables.artistTracks.trackId, scrobble.tracks.id)) 352 .execute() 353 - .then(([row]) => row) 354 ); 355 356 const getArtistAlbum = ( ··· 360 artists: SelectArtist; 361 users: SelectUser; 362 tracks: SelectTrack; 363 - } 364 ) => 365 Effect.tryPromise(() => 366 ctx.db ··· 369 .where( 370 and( 371 eq(tables.artistAlbums.albumId, scrobble.albums.id), 372 - eq(tables.artistAlbums.artistId, scrobble.artists.id) 373 - ) 374 ) 375 - .then(([row]) => row) 376 ); 377 378 const createUserArtist = ( ··· 382 artists: SelectArtist; 383 users: SelectUser; 384 tracks: SelectTrack; 385 - } 386 ) => 387 pipe( 388 Effect.tryPromise(() => ··· 394 uri: scrobble.artists.uri, 395 scrobbles: 1, 396 } as InsertUserArtist) 397 - .execute() 398 ), 399 Effect.flatMap(() => 400 Effect.tryPromise(() => ··· 403 .from(tables.userArtists) 404 .where(eq(tables.userArtists.artistId, scrobble.artists.id)) 405 .execute() 406 - .then(([row]) => row) 407 - ) 408 - ) 409 ); 410 411 const createUserAlbum = ( ··· 415 artists: SelectArtist; 416 users: SelectUser; 417 tracks: SelectTrack; 418 - } 419 ) => 420 pipe( 421 Effect.tryPromise(() => ··· 427 uri: scrobble.albums.uri, 428 scrobbles: 1, 429 } as InsertUserAlbum) 430 - .execute() 431 ), 432 Effect.flatMap(() => 433 Effect.tryPromise(() => ··· 436 .from(tables.userAlbums) 437 .where(eq(tables.userAlbums.albumId, scrobble.albums.id)) 438 .execute() 439 - .then(([row]) => row) 440 - ) 441 - ) 442 ); 443 444 const createUserTrack = ( ··· 448 artists: SelectArtist; 449 users: SelectUser; 450 tracks: SelectTrack; 451 - } 452 ) => 453 pipe( 454 Effect.tryPromise(() => ··· 460 uri: scrobble.tracks.uri, 461 scrobbles: 1, 462 } as InsertUserTrack) 463 - .execute() 464 ), 465 Effect.flatMap(() => 466 Effect.tryPromise(() => ··· 468 .select() 469 .from(tables.userTracks) 470 .where(eq(tables.userTracks.trackId, scrobble.tracks.id)) 471 - .then(([row]) => row) 472 - ) 473 - ) 474 ); 475 476 const publishScrobble = (ctx: Context, id: string) => ··· 641 xata_updatedat: artistAlbum.updatedAt.toISOString(), 642 xata_version: artistAlbum.xataVersion, 643 }, 644 - }) 645 - ) 646 - ) 647 - ) 648 - ) 649 ), 650 Effect.flatMap((data) => 651 Effect.try(() => 652 ctx.nc.publish( 653 "rocksky.scrobble", 654 Buffer.from( 655 - JSON.stringify(data).replaceAll("sha_256", "sha256") 656 - ) 657 - ) 658 - ) 659 - ) 660 - ) 661 - ) 662 - ) 663 - ) 664 ); 665 666 const computeTrackHash = (track: Track): Effect.Effect<string, never> => 667 Effect.succeed( 668 createHash("sha256") 669 .update(`${track.title} - ${track.artist} - ${track.album}`.toLowerCase()) 670 - .digest("hex") 671 ); 672 673 const computeAlbumHash = (track: Track): Effect.Effect<string, never> => 674 Effect.succeed( 675 createHash("sha256") 676 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 677 - .digest("hex") 678 ); 679 680 const computeArtistHash = (track: Track): Effect.Effect<string, never> => 681 Effect.succeed( 682 - createHash("sha256").update(track.albumArtist.toLowerCase()).digest("hex") 683 ); 684 685 const fetchExistingTrack = ( 686 ctx: Context, 687 - trackHash: string 688 ): Effect.Effect<SelectTrack | undefined, Error> => 689 Effect.tryPromise(() => 690 ctx.db ··· 692 .from(tables.tracks) 693 .where(eq(tables.tracks.sha256, trackHash)) 694 .execute() 695 - .then(([row]) => row) 696 ); 697 698 // Update track metadata (album_uri and artist_uri) 699 const updateTrackMetadata = ( 700 ctx: Context, 701 track: Track, 702 - trackRecord: SelectTrack 703 ) => 704 pipe( 705 Effect.succeed(trackRecord), ··· 714 .from(tables.albums) 715 .where(eq(tables.albums.sha256, albumHash)) 716 .execute() 717 - .then(([row]) => row) 718 - ) 719 ), 720 Effect.flatMap((album) => 721 album ··· 726 albumUri: album.uri, 727 }) 728 .where(eq(tables.tracks.id, trackRecord.id)) 729 - .execute() 730 ) 731 - : Effect.succeed(undefined) 732 - ) 733 ) 734 - : Effect.succeed(undefined) 735 ), 736 Effect.tap((trackRecord) => 737 !trackRecord.artistUri ··· 744 .from(tables.artists) 745 .where(eq(tables.artists.sha256, artistHash)) 746 .execute() 747 - .then(([row]) => row) 748 - ) 749 ), 750 Effect.flatMap((artist) => 751 artist ··· 756 artistUri: artist.uri, 757 }) 758 .where(eq(tables.tracks.id, trackRecord.id)) 759 - .execute() 760 ) 761 - : Effect.succeed(undefined) 762 - ) 763 ) 764 - : Effect.succeed(undefined) 765 - ) 766 ); 767 768 // Ensure track exists or create it ··· 771 track: Track, 772 agent: Agent, 773 userDid: string, 774 - existingTrack: SelectTrack | undefined 775 ) => 776 pipe( 777 Effect.succeed(existingTrack), ··· 779 Match.value(trackOpt).pipe( 780 Match.when( 781 (value) => !!value, 782 - () => updateTrackMetadata(ctx, track, trackOpt) 783 ), 784 - Match.orElse(() => Effect.succeed(undefined)) 785 - ) 786 ), 787 Effect.flatMap((trackOpt) => 788 pipe( ··· 792 .from(tables.userTracks) 793 .leftJoin( 794 tables.tracks, 795 - eq(tables.userTracks.trackId, tables.tracks.id) 796 ) 797 .leftJoin( 798 tables.users, 799 - eq(tables.userTracks.userId, tables.users.id) 800 ) 801 .where( 802 and( 803 eq(tables.tracks.id, trackOpt?.id), 804 - eq(tables.users.did, userDid) 805 - ) 806 ) 807 .execute() 808 - .then(([row]) => row.user_tracks) 809 ), 810 Effect.flatMap((userTrack) => 811 Option.isNone(Option.fromNullable(userTrack)) || 812 !userTrack?.uri?.includes(userDid) 813 ? putSongRecord(track, agent) 814 - : Effect.succeed(null) 815 - ) 816 - ) 817 - ) 818 ); 819 820 // Ensure album exists or create it ··· 822 ctx: Context, 823 track: Track, 824 agent: Agent, 825 - userDid: string 826 ) => 827 pipe( 828 computeAlbumHash(track), ··· 833 .from(tables.albums) 834 .where(eq(tables.albums.sha256, albumHash)) 835 .execute() 836 - .then(([row]) => row) 837 - ) 838 ), 839 Effect.flatMap((existingAlbum) => 840 pipe( ··· 846 .from(tables.userAlbums) 847 .leftJoin( 848 tables.albums, 849 - eq(tables.userAlbums.albumId, tables.albums.id) 850 ) 851 .leftJoin( 852 tables.users, 853 - eq(tables.userAlbums.userId, tables.users.id) 854 ) 855 .where( 856 and( 857 eq(tables.albums.id, album.id), 858 - eq(tables.users.did, userDid) 859 - ) 860 ) 861 .execute() 862 - .then(([row]) => row.user_albums) 863 - ) 864 ), 865 Effect.flatMap((userAlbum) => 866 Option.isNone(Option.fromNullable(existingAlbum)) || 867 Option.isNone(Option.fromNullable(userAlbum)) || 868 !userAlbum?.uri?.includes(userDid) 869 ? putAlbumRecord(track, agent) 870 - : Effect.succeed(null) 871 - ) 872 - ) 873 - ) 874 ); 875 876 // Ensure artist exists or create it ··· 878 ctx: Context, 879 track: Track, 880 agent: Agent, 881 - userDid: string 882 ) => 883 pipe( 884 computeArtistHash(track), ··· 889 .from(tables.artists) 890 .where(eq(tables.artists.sha256, artistHash)) 891 .execute() 892 - .then(([row]) => row) 893 - ) 894 ), 895 Effect.flatMap((existingArtist) => 896 pipe( ··· 902 .from(tables.userArtists) 903 .leftJoin( 904 tables.artists, 905 - eq(tables.userArtists.artistId, tables.artists.id) 906 ) 907 .leftJoin( 908 tables.users, 909 - eq(tables.userArtists.userId, tables.users.id) 910 ) 911 .where( 912 and( 913 eq(tables.artists.id, artist.id), 914 - eq(tables.users.did, userDid) 915 - ) 916 ) 917 .execute() 918 - .then(([row]) => row.user_artists) 919 - ) 920 ), 921 Effect.flatMap((userArtist) => 922 Effect.if( ··· 926 { 927 onTrue: () => putArtistRecord(track, agent), 928 onFalse: () => Effect.succeed(null), 929 - } 930 - ) 931 - ) 932 - ) 933 - ) 934 ); 935 936 // Retry fetching track until metadata is ready 937 const retryFetchTrack = ( 938 ctx: Context, 939 trackHash: string, 940 - initialTrack: SelectTrack | undefined 941 ) => 942 pipe( 943 Effect.iterate( ··· 953 .from(tables.tracks) 954 .where(eq(tables.tracks.sha256, trackHash)) 955 .execute() 956 - .then(([row]) => row) 957 ), 958 Effect.flatMap((trackRecord) => 959 Option.fromNullable(trackRecord).pipe( 960 Effect.flatMap((track) => 961 - updateTrackMetadata(ctx, track, trackRecord) 962 - ) 963 - ) 964 ), 965 Effect.tap((trackRecord) => 966 Effect.logInfo( 967 trackRecord 968 ? `Track metadata ready: ${chalk.cyan(trackRecord.id)} - ${track.title}, after ${chalk.magenta(tries + 1)} tries` 969 - : `Retrying track fetch: ${chalk.magenta(tries + 1)}` 970 - ) 971 ), 972 Effect.map((trackRecord) => ({ 973 tries: tries + 1, 974 track: trackRecord, 975 })), 976 - Effect.delay("1 second") 977 ), 978 - } 979 ), 980 Effect.tap(({ tries, track }) => 981 tries >= 30 && !(track?.artistUri && track?.albumUri) 982 ? Effect.logError( 983 - `Track metadata not ready after ${chalk.magenta("30 tries")}` 984 ) 985 - : Effect.succeed(undefined) 986 ), 987 - Effect.map(({ track }) => track) 988 ); 989 990 // Retry fetching scrobble until complete ··· 1022 .from(tables.scrobbles) 1023 .leftJoin( 1024 tables.tracks, 1025 - eq(tables.scrobbles.trackId, tables.tracks.id) 1026 ) 1027 .leftJoin( 1028 tables.albums, 1029 - eq(tables.scrobbles.albumId, tables.albums.id) 1030 ) 1031 .leftJoin( 1032 tables.artists, 1033 - eq(tables.scrobbles.artistId, tables.artists.id) 1034 ) 1035 .leftJoin( 1036 tables.users, 1037 - eq(tables.scrobbles.userId, tables.users.id) 1038 ) 1039 .where(eq(tables.scrobbles.uri, scrobbleUri)) 1040 .execute() 1041 - .then(([row]) => row) 1042 ), 1043 Effect.tap((scrobble) => 1044 Effect.if( ··· 1055 artistUri: scrobble.artists.uri, 1056 }) 1057 .where(eq(tables.albums.id, scrobble.albums.id)) 1058 - .execute() 1059 ), 1060 onFalse: () => Effect.succeed(undefined), 1061 - } 1062 - ) 1063 ), 1064 Effect.flatMap(() => 1065 Effect.tryPromise(() => ··· 1068 .from(tables.scrobbles) 1069 .leftJoin( 1070 tables.tracks, 1071 - eq(tables.scrobbles.trackId, tables.tracks.id) 1072 ) 1073 .leftJoin( 1074 tables.albums, 1075 - eq(tables.scrobbles.albumId, tables.albums.id) 1076 ) 1077 .leftJoin( 1078 tables.artists, 1079 - eq(tables.scrobbles.artistId, tables.artists.id) 1080 ) 1081 .leftJoin( 1082 tables.users, 1083 - eq(tables.scrobbles.userId, tables.users.id) 1084 ) 1085 .where(eq(tables.scrobbles.uri, scrobbleUri)) 1086 .execute() 1087 - .then(([row]) => row) 1088 - ) 1089 ), 1090 Effect.map((scrobble) => ({ 1091 tries: tries + 1, ··· 1102 scrobble.tracks.albumUri && 1103 scrobble.scrobbles 1104 ? `Scrobble found after ${chalk.magenta(tries + 1)} tries` 1105 - : `Scrobble not found, trying again: ${chalk.magenta(tries + 1)}` 1106 - ) 1107 ), 1108 - Effect.delay("1 second") 1109 ), 1110 - } 1111 ), 1112 Effect.tap(({ tries, scrobble }) => 1113 tries >= 30 && ··· 1121 scrobble.tracks.albumUri 1122 ) 1123 ? Effect.logError( 1124 - `Scrobble not found after ${chalk.magenta("30 tries")}` 1125 ) 1126 - : Effect.succeed(undefined) 1127 ), 1128 - Effect.map(({ scrobble }) => scrobble) 1129 ); 1130 1131 export const scrobbleTrack = ( 1132 ctx: Context, 1133 track: Track, 1134 agent: Agent, 1135 - userDid: string 1136 ) => 1137 pipe( 1138 computeTrackHash(track), ··· 1145 Effect.flatMap(() => ensureAlbum(ctx, track, agent, userDid)), 1146 Effect.flatMap(() => ensureArtist(ctx, track, agent, userDid)), 1147 Effect.flatMap(() => 1148 - retryFetchTrack(ctx, trackHash, existingTrack) 1149 ), 1150 Effect.flatMap(() => 1151 pipe( ··· 1165 ? pipe( 1166 publishScrobble(ctx, scrobble.scrobbles.id), 1167 Effect.tap(() => 1168 - Effect.logInfo("Scrobble published") 1169 - ) 1170 ) 1171 - : Effect.succeed(undefined) 1172 - ) 1173 - ) 1174 - ) 1175 - ) 1176 - ) 1177 - ) 1178 - ) 1179 - ) 1180 - ) 1181 );
··· 37 pipe( 38 scrobbleTrack(ctx, track, agent, did), 39 Effect.tap(() => 40 + Effect.logInfo(`Scrobble created for ${chalk.cyan(track.title)}`), 41 + ), 42 + ), 43 ), 44 Effect.flatMap(presentation), 45 Effect.retry({ times: 3 }), ··· 47 Effect.catchAll((err) => { 48 console.error(err); 49 return Effect.succeed({}); 50 + }), 51 ); 52 server.app.rocksky.scrobble.createScrobble({ 53 auth: ctx.authVerifier, ··· 81 ctx, 82 did, 83 input, 84 + })), 85 ), 86 Match.orElse(() => { 87 throw new Error("Authentication required to create a scrobble"); 88 + }), 89 ), 90 catch: (error) => new Error(`Failed to create agent: ${error}`), 91 }); ··· 124 agent: Agent, 125 collection: string, 126 record: T, 127 + validate: (record: T) => { success: boolean }, 128 ) => 129 pipe( 130 Effect.succeed(record), 131 Effect.filterOrFail( 132 (rec) => validate(rec).success, 133 + () => new Error("Invalid record"), 134 ), 135 Effect.flatMap(() => 136 pipe( ··· 143 rkey, 144 record, 145 validate: false, 146 + }), 147 + ), 148 ), 149 Effect.tap((res) => 150 + Effect.logInfo(`Record created at ${res.data.uri}`), 151 ), 152 + Effect.map((res) => res.data.uri), 153 + ), 154 ), 155 Effect.catchAll((error) => { 156 console.error(`Error creating ${collection} record`, error); 157 return Effect.succeed(null); 158 + }), 159 ); 160 161 const putArtistRecord = (track: Track, agent: Agent) => ··· 168 tags: track.genres, 169 }), 170 Effect.flatMap((record) => 171 + putRecord(agent, "app.rocksky.artist", record, Artist.validateRecord), 172 + ), 173 ); 174 175 const putAlbumRecord = (track: Track, agent: Agent) => ··· 186 albumArtUrl: track.albumArt, 187 }), 188 Effect.flatMap((record) => 189 + putRecord(agent, "app.rocksky.album", record, Album.validateRecord), 190 + ), 191 ); 192 193 const putSongRecord = (track: Track, agent: Agent) => ··· 213 spotifyLink: track.spotifyLink ?? undefined, 214 }), 215 Effect.flatMap((record) => 216 + putRecord(agent, "app.rocksky.song", record, Song.validateRecord), 217 + ), 218 ); 219 220 const putScrobbleRecord = (track: Track, agent: Agent) => ··· 242 spotifyLink: track.spotifyLink ?? undefined, 243 }), 244 Effect.flatMap((record) => 245 + putRecord(agent, "app.rocksky.scrobble", record, Scrobble.validateRecord), 246 + ), 247 ); 248 249 const getScrobble = ({ ctx, id }: { ctx: Context; id: string }) => ··· 255 .leftJoin(tables.albums, eq(tables.albums.id, tables.scrobbles.albumId)) 256 .leftJoin( 257 tables.artists, 258 + eq(tables.artists.id, tables.scrobbles.artistId), 259 ) 260 .leftJoin(tables.users, eq(tables.users.id, tables.scrobbles.userId)) 261 .where(eq(tables.scrobbles.id, id)) 262 .execute() 263 + .then(([row]) => row), 264 ); 265 266 const getUserAlbum = ( ··· 270 artists: SelectArtist; 271 users: SelectUser; 272 tracks: SelectTrack; 273 + }, 274 ) => 275 Effect.tryPromise(() => 276 ctx.db ··· 278 .from(tables.userAlbums) 279 .where(eq(tables.userAlbums.albumId, scrobble.albums.id)) 280 .execute() 281 + .then(([row]) => row), 282 ); 283 284 const getUserArtist = ( ··· 288 artists: SelectArtist; 289 users: SelectUser; 290 tracks: SelectTrack; 291 + }, 292 ) => 293 Effect.tryPromise(() => 294 ctx.db ··· 296 .from(tables.userArtists) 297 .where(eq(tables.userArtists.id, scrobble.artists.id)) 298 .execute() 299 + .then(([row]) => row), 300 ); 301 302 const getUserTrack = ( ··· 306 artists: SelectArtist; 307 users: SelectUser; 308 tracks: SelectTrack; 309 + }, 310 ) => 311 Effect.tryPromise(() => 312 ctx.db ··· 314 .from(tables.userTracks) 315 .where(eq(tables.userTracks.id, scrobble.tracks.id)) 316 .execute() 317 + .then(([row]) => row), 318 ); 319 320 const getAlbumTrack = ( ··· 324 artists: SelectArtist; 325 users: SelectUser; 326 tracks: SelectTrack; 327 + }, 328 ) => 329 Effect.tryPromise(() => 330 ctx.db ··· 332 .from(tables.albumTracks) 333 .where(eq(tables.albumTracks.trackId, scrobble.tracks.id)) 334 .execute() 335 + .then(([row]) => row), 336 ); 337 338 const getArtistTrack = ( ··· 342 artists: SelectArtist; 343 users: SelectUser; 344 tracks: SelectTrack; 345 + }, 346 ) => 347 Effect.tryPromise(() => 348 ctx.db ··· 350 .from(tables.artistTracks) 351 .where(eq(tables.artistTracks.trackId, scrobble.tracks.id)) 352 .execute() 353 + .then(([row]) => row), 354 ); 355 356 const getArtistAlbum = ( ··· 360 artists: SelectArtist; 361 users: SelectUser; 362 tracks: SelectTrack; 363 + }, 364 ) => 365 Effect.tryPromise(() => 366 ctx.db ··· 369 .where( 370 and( 371 eq(tables.artistAlbums.albumId, scrobble.albums.id), 372 + eq(tables.artistAlbums.artistId, scrobble.artists.id), 373 + ), 374 ) 375 + .then(([row]) => row), 376 ); 377 378 const createUserArtist = ( ··· 382 artists: SelectArtist; 383 users: SelectUser; 384 tracks: SelectTrack; 385 + }, 386 ) => 387 pipe( 388 Effect.tryPromise(() => ··· 394 uri: scrobble.artists.uri, 395 scrobbles: 1, 396 } as InsertUserArtist) 397 + .execute(), 398 ), 399 Effect.flatMap(() => 400 Effect.tryPromise(() => ··· 403 .from(tables.userArtists) 404 .where(eq(tables.userArtists.artistId, scrobble.artists.id)) 405 .execute() 406 + .then(([row]) => row), 407 + ), 408 + ), 409 ); 410 411 const createUserAlbum = ( ··· 415 artists: SelectArtist; 416 users: SelectUser; 417 tracks: SelectTrack; 418 + }, 419 ) => 420 pipe( 421 Effect.tryPromise(() => ··· 427 uri: scrobble.albums.uri, 428 scrobbles: 1, 429 } as InsertUserAlbum) 430 + .execute(), 431 ), 432 Effect.flatMap(() => 433 Effect.tryPromise(() => ··· 436 .from(tables.userAlbums) 437 .where(eq(tables.userAlbums.albumId, scrobble.albums.id)) 438 .execute() 439 + .then(([row]) => row), 440 + ), 441 + ), 442 ); 443 444 const createUserTrack = ( ··· 448 artists: SelectArtist; 449 users: SelectUser; 450 tracks: SelectTrack; 451 + }, 452 ) => 453 pipe( 454 Effect.tryPromise(() => ··· 460 uri: scrobble.tracks.uri, 461 scrobbles: 1, 462 } as InsertUserTrack) 463 + .execute(), 464 ), 465 Effect.flatMap(() => 466 Effect.tryPromise(() => ··· 468 .select() 469 .from(tables.userTracks) 470 .where(eq(tables.userTracks.trackId, scrobble.tracks.id)) 471 + .then(([row]) => row), 472 + ), 473 + ), 474 ); 475 476 const publishScrobble = (ctx: Context, id: string) => ··· 641 xata_updatedat: artistAlbum.updatedAt.toISOString(), 642 xata_version: artistAlbum.xataVersion, 643 }, 644 + }), 645 + ), 646 + ), 647 + ), 648 + ), 649 ), 650 Effect.flatMap((data) => 651 Effect.try(() => 652 ctx.nc.publish( 653 "rocksky.scrobble", 654 Buffer.from( 655 + JSON.stringify(data).replaceAll("sha_256", "sha256"), 656 + ), 657 + ), 658 + ), 659 + ), 660 + ), 661 + ), 662 + ), 663 + ), 664 ); 665 666 const computeTrackHash = (track: Track): Effect.Effect<string, never> => 667 Effect.succeed( 668 createHash("sha256") 669 .update(`${track.title} - ${track.artist} - ${track.album}`.toLowerCase()) 670 + .digest("hex"), 671 ); 672 673 const computeAlbumHash = (track: Track): Effect.Effect<string, never> => 674 Effect.succeed( 675 createHash("sha256") 676 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 677 + .digest("hex"), 678 ); 679 680 const computeArtistHash = (track: Track): Effect.Effect<string, never> => 681 Effect.succeed( 682 + createHash("sha256").update(track.albumArtist.toLowerCase()).digest("hex"), 683 ); 684 685 const fetchExistingTrack = ( 686 ctx: Context, 687 + trackHash: string, 688 ): Effect.Effect<SelectTrack | undefined, Error> => 689 Effect.tryPromise(() => 690 ctx.db ··· 692 .from(tables.tracks) 693 .where(eq(tables.tracks.sha256, trackHash)) 694 .execute() 695 + .then(([row]) => row), 696 ); 697 698 // Update track metadata (album_uri and artist_uri) 699 const updateTrackMetadata = ( 700 ctx: Context, 701 track: Track, 702 + trackRecord: SelectTrack, 703 ) => 704 pipe( 705 Effect.succeed(trackRecord), ··· 714 .from(tables.albums) 715 .where(eq(tables.albums.sha256, albumHash)) 716 .execute() 717 + .then(([row]) => row), 718 + ), 719 ), 720 Effect.flatMap((album) => 721 album ··· 726 albumUri: album.uri, 727 }) 728 .where(eq(tables.tracks.id, trackRecord.id)) 729 + .execute(), 730 ) 731 + : Effect.succeed(undefined), 732 + ), 733 ) 734 + : Effect.succeed(undefined), 735 ), 736 Effect.tap((trackRecord) => 737 !trackRecord.artistUri ··· 744 .from(tables.artists) 745 .where(eq(tables.artists.sha256, artistHash)) 746 .execute() 747 + .then(([row]) => row), 748 + ), 749 ), 750 Effect.flatMap((artist) => 751 artist ··· 756 artistUri: artist.uri, 757 }) 758 .where(eq(tables.tracks.id, trackRecord.id)) 759 + .execute(), 760 ) 761 + : Effect.succeed(undefined), 762 + ), 763 ) 764 + : Effect.succeed(undefined), 765 + ), 766 ); 767 768 // Ensure track exists or create it ··· 771 track: Track, 772 agent: Agent, 773 userDid: string, 774 + existingTrack: SelectTrack | undefined, 775 ) => 776 pipe( 777 Effect.succeed(existingTrack), ··· 779 Match.value(trackOpt).pipe( 780 Match.when( 781 (value) => !!value, 782 + () => updateTrackMetadata(ctx, track, trackOpt), 783 ), 784 + Match.orElse(() => Effect.succeed(undefined)), 785 + ), 786 ), 787 Effect.flatMap((trackOpt) => 788 pipe( ··· 792 .from(tables.userTracks) 793 .leftJoin( 794 tables.tracks, 795 + eq(tables.userTracks.trackId, tables.tracks.id), 796 ) 797 .leftJoin( 798 tables.users, 799 + eq(tables.userTracks.userId, tables.users.id), 800 ) 801 .where( 802 and( 803 eq(tables.tracks.id, trackOpt?.id), 804 + eq(tables.users.did, userDid), 805 + ), 806 ) 807 .execute() 808 + .then(([row]) => row.user_tracks), 809 ), 810 Effect.flatMap((userTrack) => 811 Option.isNone(Option.fromNullable(userTrack)) || 812 !userTrack?.uri?.includes(userDid) 813 ? putSongRecord(track, agent) 814 + : Effect.succeed(null), 815 + ), 816 + ), 817 + ), 818 ); 819 820 // Ensure album exists or create it ··· 822 ctx: Context, 823 track: Track, 824 agent: Agent, 825 + userDid: string, 826 ) => 827 pipe( 828 computeAlbumHash(track), ··· 833 .from(tables.albums) 834 .where(eq(tables.albums.sha256, albumHash)) 835 .execute() 836 + .then(([row]) => row), 837 + ), 838 ), 839 Effect.flatMap((existingAlbum) => 840 pipe( ··· 846 .from(tables.userAlbums) 847 .leftJoin( 848 tables.albums, 849 + eq(tables.userAlbums.albumId, tables.albums.id), 850 ) 851 .leftJoin( 852 tables.users, 853 + eq(tables.userAlbums.userId, tables.users.id), 854 ) 855 .where( 856 and( 857 eq(tables.albums.id, album.id), 858 + eq(tables.users.did, userDid), 859 + ), 860 ) 861 .execute() 862 + .then(([row]) => row.user_albums), 863 + ), 864 ), 865 Effect.flatMap((userAlbum) => 866 Option.isNone(Option.fromNullable(existingAlbum)) || 867 Option.isNone(Option.fromNullable(userAlbum)) || 868 !userAlbum?.uri?.includes(userDid) 869 ? putAlbumRecord(track, agent) 870 + : Effect.succeed(null), 871 + ), 872 + ), 873 + ), 874 ); 875 876 // Ensure artist exists or create it ··· 878 ctx: Context, 879 track: Track, 880 agent: Agent, 881 + userDid: string, 882 ) => 883 pipe( 884 computeArtistHash(track), ··· 889 .from(tables.artists) 890 .where(eq(tables.artists.sha256, artistHash)) 891 .execute() 892 + .then(([row]) => row), 893 + ), 894 ), 895 Effect.flatMap((existingArtist) => 896 pipe( ··· 902 .from(tables.userArtists) 903 .leftJoin( 904 tables.artists, 905 + eq(tables.userArtists.artistId, tables.artists.id), 906 ) 907 .leftJoin( 908 tables.users, 909 + eq(tables.userArtists.userId, tables.users.id), 910 ) 911 .where( 912 and( 913 eq(tables.artists.id, artist.id), 914 + eq(tables.users.did, userDid), 915 + ), 916 ) 917 .execute() 918 + .then(([row]) => row.user_artists), 919 + ), 920 ), 921 Effect.flatMap((userArtist) => 922 Effect.if( ··· 926 { 927 onTrue: () => putArtistRecord(track, agent), 928 onFalse: () => Effect.succeed(null), 929 + }, 930 + ), 931 + ), 932 + ), 933 + ), 934 ); 935 936 // Retry fetching track until metadata is ready 937 const retryFetchTrack = ( 938 ctx: Context, 939 trackHash: string, 940 + initialTrack: SelectTrack | undefined, 941 ) => 942 pipe( 943 Effect.iterate( ··· 953 .from(tables.tracks) 954 .where(eq(tables.tracks.sha256, trackHash)) 955 .execute() 956 + .then(([row]) => row), 957 ), 958 Effect.flatMap((trackRecord) => 959 Option.fromNullable(trackRecord).pipe( 960 Effect.flatMap((track) => 961 + updateTrackMetadata(ctx, track, trackRecord), 962 + ), 963 + ), 964 ), 965 Effect.tap((trackRecord) => 966 Effect.logInfo( 967 trackRecord 968 ? `Track metadata ready: ${chalk.cyan(trackRecord.id)} - ${track.title}, after ${chalk.magenta(tries + 1)} tries` 969 + : `Retrying track fetch: ${chalk.magenta(tries + 1)}`, 970 + ), 971 ), 972 Effect.map((trackRecord) => ({ 973 tries: tries + 1, 974 track: trackRecord, 975 })), 976 + Effect.delay("1 second"), 977 ), 978 + }, 979 ), 980 Effect.tap(({ tries, track }) => 981 tries >= 30 && !(track?.artistUri && track?.albumUri) 982 ? Effect.logError( 983 + `Track metadata not ready after ${chalk.magenta("30 tries")}`, 984 ) 985 + : Effect.succeed(undefined), 986 ), 987 + Effect.map(({ track }) => track), 988 ); 989 990 // Retry fetching scrobble until complete ··· 1022 .from(tables.scrobbles) 1023 .leftJoin( 1024 tables.tracks, 1025 + eq(tables.scrobbles.trackId, tables.tracks.id), 1026 ) 1027 .leftJoin( 1028 tables.albums, 1029 + eq(tables.scrobbles.albumId, tables.albums.id), 1030 ) 1031 .leftJoin( 1032 tables.artists, 1033 + eq(tables.scrobbles.artistId, tables.artists.id), 1034 ) 1035 .leftJoin( 1036 tables.users, 1037 + eq(tables.scrobbles.userId, tables.users.id), 1038 ) 1039 .where(eq(tables.scrobbles.uri, scrobbleUri)) 1040 .execute() 1041 + .then(([row]) => row), 1042 ), 1043 Effect.tap((scrobble) => 1044 Effect.if( ··· 1055 artistUri: scrobble.artists.uri, 1056 }) 1057 .where(eq(tables.albums.id, scrobble.albums.id)) 1058 + .execute(), 1059 ), 1060 onFalse: () => Effect.succeed(undefined), 1061 + }, 1062 + ), 1063 ), 1064 Effect.flatMap(() => 1065 Effect.tryPromise(() => ··· 1068 .from(tables.scrobbles) 1069 .leftJoin( 1070 tables.tracks, 1071 + eq(tables.scrobbles.trackId, tables.tracks.id), 1072 ) 1073 .leftJoin( 1074 tables.albums, 1075 + eq(tables.scrobbles.albumId, tables.albums.id), 1076 ) 1077 .leftJoin( 1078 tables.artists, 1079 + eq(tables.scrobbles.artistId, tables.artists.id), 1080 ) 1081 .leftJoin( 1082 tables.users, 1083 + eq(tables.scrobbles.userId, tables.users.id), 1084 ) 1085 .where(eq(tables.scrobbles.uri, scrobbleUri)) 1086 .execute() 1087 + .then(([row]) => row), 1088 + ), 1089 ), 1090 Effect.map((scrobble) => ({ 1091 tries: tries + 1, ··· 1102 scrobble.tracks.albumUri && 1103 scrobble.scrobbles 1104 ? `Scrobble found after ${chalk.magenta(tries + 1)} tries` 1105 + : `Scrobble not found, trying again: ${chalk.magenta(tries + 1)}`, 1106 + ), 1107 ), 1108 + Effect.delay("1 second"), 1109 ), 1110 + }, 1111 ), 1112 Effect.tap(({ tries, scrobble }) => 1113 tries >= 30 && ··· 1121 scrobble.tracks.albumUri 1122 ) 1123 ? Effect.logError( 1124 + `Scrobble not found after ${chalk.magenta("30 tries")}`, 1125 ) 1126 + : Effect.succeed(undefined), 1127 ), 1128 + Effect.map(({ scrobble }) => scrobble), 1129 ); 1130 1131 export const scrobbleTrack = ( 1132 ctx: Context, 1133 track: Track, 1134 agent: Agent, 1135 + userDid: string, 1136 ) => 1137 pipe( 1138 computeTrackHash(track), ··· 1145 Effect.flatMap(() => ensureAlbum(ctx, track, agent, userDid)), 1146 Effect.flatMap(() => ensureArtist(ctx, track, agent, userDid)), 1147 Effect.flatMap(() => 1148 + retryFetchTrack(ctx, trackHash, existingTrack), 1149 ), 1150 Effect.flatMap(() => 1151 pipe( ··· 1165 ? pipe( 1166 publishScrobble(ctx, scrobble.scrobbles.id), 1167 Effect.tap(() => 1168 + Effect.logInfo("Scrobble published"), 1169 + ), 1170 ) 1171 + : Effect.succeed(undefined), 1172 + ), 1173 + ), 1174 + ), 1175 + ), 1176 + ), 1177 + ), 1178 + ), 1179 + ), 1180 + ), 1181 );
+124 -124
apps/api/src/xrpc/app/rocksky/song/createSong.ts
··· 43 Effect.catchAll((err) => { 44 console.error(err); 45 return Effect.succeed({}); 46 - }) 47 ); 48 server.app.rocksky.song.createSong({ 49 auth: ctx.authVerifier, ··· 77 ctx, 78 did, 79 input, 80 - })) 81 ), 82 Match.orElse(() => { 83 throw new Error("Authentication required to create a song"); 84 - }) 85 ), 86 catch: (error) => new Error(`Failed to create agent: ${error}`), 87 }); ··· 150 Effect.succeed( 151 createHash("sha256") 152 .update(`${track.title} - ${track.artist} - ${track.album}`.toLowerCase()) 153 - .digest("hex") 154 ); 155 156 const computeAlbumHash = (track: Track): Effect.Effect<string, never> => 157 Effect.succeed( 158 createHash("sha256") 159 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 160 - .digest("hex") 161 ); 162 163 const computeArtistHash = (track: Track): Effect.Effect<string, never> => 164 Effect.succeed( 165 - createHash("sha256").update(track.albumArtist.toLowerCase()).digest("hex") 166 ); 167 168 const fetchExistingTrack = ( 169 ctx: Context, 170 - trackHash: string 171 ): Effect.Effect<SelectTrack | undefined, Error> => 172 Effect.tryPromise(() => 173 ctx.db ··· 175 .from(tables.tracks) 176 .where(eq(tables.tracks.sha256, trackHash)) 177 .execute() 178 - .then(([row]) => row) 179 ); 180 181 const generateRkey = Effect.succeed(TID.nextStr()); ··· 184 agent: Agent, 185 collection: string, 186 record: T, 187 - validate: (record: T) => { success: boolean } 188 ): Effect.Effect<string, Error> => 189 pipe( 190 Effect.succeed(record), 191 Effect.filterOrFail( 192 (rec) => validate(rec).success, 193 - () => new Error("Invalid record") 194 ), 195 Effect.flatMap(() => 196 pipe( ··· 203 rkey, 204 record, 205 validate: false, 206 - }) 207 - ) 208 ), 209 Effect.tap((res) => 210 - Effect.logInfo(`Record created at ${res.data.uri}`) 211 ), 212 - Effect.map((res) => res.data.uri) 213 - ) 214 ), 215 Effect.catchAll((error) => { 216 console.error(`Error creating ${collection} record`, error); 217 return Effect.fail(error); 218 - }) 219 ); 220 221 const putArtistRecord = (track: Track, agent: Agent) => ··· 227 pictureUrl: track.artistPicture, 228 }), 229 Effect.flatMap((record) => 230 - putRecord(agent, "app.rocksky.artist", record, Artist.validateRecord) 231 - ) 232 ); 233 234 const putAlbumRecord = (track: Track, agent: Agent) => ··· 245 albumArtUrl: track.albumArt, 246 }), 247 Effect.flatMap((record) => 248 - putRecord(agent, "app.rocksky.album", record, Album.validateRecord) 249 - ) 250 ); 251 252 const putSongRecord = (track: Track, agent: Agent) => ··· 272 spotifyLink: track.spotifyLink ?? undefined, 273 }), 274 Effect.flatMap((record) => 275 - putRecord(agent, "app.rocksky.song", record, Song.validateRecord) 276 - ) 277 ); 278 279 const ensureTrack = (ctx: Context, track: Track, agent: Agent) => ··· 288 Effect.tap((trackOpt) => 289 trackOpt 290 ? updateTrackMetadata(ctx, track, trackOpt) 291 - : Effect.succeed(undefined) 292 ), 293 Effect.flatMap((trackOpt) => 294 trackOpt.uri 295 ? Effect.succeed(trackOpt.uri) 296 - : putSongRecord(track, agent) 297 - ) 298 - ) 299 - ) 300 - ) 301 - ) 302 ); 303 304 // Update track metadata (album_uri and artist_uri) 305 const updateTrackMetadata = ( 306 ctx: Context, 307 track: Track, 308 - trackRecord: SelectTrack 309 ) => 310 pipe( 311 Effect.succeed(trackRecord), ··· 320 .from(tables.albums) 321 .where(eq(tables.albums.sha256, albumHash)) 322 .execute() 323 - .then(([row]) => row) 324 - ) 325 ), 326 Effect.flatMap((album) => 327 Option.fromNullable(album).pipe( ··· 331 .update(tables.tracks) 332 .set({ albumUri: album.uri }) 333 .where(eq(tables.tracks.id, trackRecord.id)) 334 - .execute() 335 - ) 336 ), 337 - Effect.catchAll(() => Effect.succeed(undefined)) 338 - ) 339 - ) 340 ) 341 - : Effect.succeed(undefined) 342 ), 343 Effect.tap((trackRecord) => 344 !trackRecord.artistUri ··· 351 .from(tables.artists) 352 .where(eq(tables.artists.sha256, artistHash)) 353 .execute() 354 - .then(([row]) => row) 355 - ) 356 ), 357 Effect.flatMap((artist) => 358 Option.fromNullable(artist).pipe( ··· 362 .update(tables.tracks) 363 .set({ artistUri: artist.uri }) 364 .where(eq(tables.tracks.id, trackRecord.id)) 365 - .execute() 366 - ) 367 ), 368 - Effect.catchAll(() => Effect.succeed(undefined)) 369 - ) 370 - ) 371 ) 372 - : Effect.succeed(undefined) 373 - ) 374 ); 375 376 // Ensure artist exists or create it ··· 385 .from(tables.artists) 386 .where(eq(tables.artists.sha256, artistHash)) 387 .execute() 388 - .then(([row]) => row) 389 ), 390 Effect.flatMap((existingArtist) => 391 pipe( ··· 393 Effect.flatMap((artistOpt) => 394 artistOpt.uri 395 ? Effect.succeed(artistOpt.uri) 396 - : putArtistRecord(track, agent) 397 - ) 398 - ) 399 - ) 400 - ) 401 - ) 402 ); 403 404 // Ensure album exists or create it ··· 413 .from(tables.albums) 414 .where(eq(tables.albums.sha256, albumHash)) 415 .execute() 416 - .then(([row]) => row) 417 ), 418 Effect.flatMap((existingAlbum) => 419 pipe( ··· 421 Effect.flatMap((albumOpt) => 422 albumOpt.uri 423 ? Effect.succeed(albumOpt.uri) 424 - : putAlbumRecord(track, agent) 425 - ) 426 - ) 427 - ) 428 - ) 429 - ) 430 ); 431 432 // Fetch track, album, and artist by URIs ··· 434 ctx: Context, 435 trackUri: string, 436 albumUri: string, 437 - artistUri: string 438 ): Effect.Effect< 439 { 440 track: SelectTrack | null; ··· 450 .from(tables.tracks) 451 .where(eq(tables.tracks.uri, trackUri)) 452 .execute() 453 - .then(([row]) => row) 454 ), 455 album: Effect.tryPromise(() => 456 ctx.db ··· 458 .from(tables.albums) 459 .where(eq(tables.albums.uri, albumUri)) 460 .execute() 461 - .then(([row]) => row) 462 ), 463 artist: Effect.tryPromise(() => 464 ctx.db ··· 466 .from(tables.artists) 467 .where(eq(tables.artists.uri, artistUri)) 468 .execute() 469 - .then(([row]) => row) 470 ), 471 }); 472 ··· 475 ctx: Context, 476 track: SelectTrack, 477 album: SelectAlbum, 478 - artist: SelectArtist 479 ) => 480 pipe( 481 Effect.all({ ··· 486 .where( 487 and( 488 eq(tables.albumTracks.albumId, album.id), 489 - eq(tables.albumTracks.trackId, track.id) 490 - ) 491 ) 492 .execute() 493 - .then(([row]) => row) 494 ), 495 496 artistTrack: Effect.tryPromise(() => ··· 500 .where( 501 and( 502 eq(tables.artistTracks.artistId, artist.id), 503 - eq(tables.artistTracks.trackId, track.id) 504 - ) 505 ) 506 .execute() 507 - .then(([row]) => row) 508 ), 509 artistAlbum: Effect.tryPromise(() => 510 ctx.db ··· 513 .where( 514 and( 515 eq(tables.artistAlbums.artistId, artist.id), 516 - eq(tables.artistAlbums.albumId, album.id) 517 - ) 518 ) 519 .execute() 520 - .then(([row]) => row) 521 ), 522 }), 523 Effect.flatMap(({ albumTrack, artistTrack, artistAlbum }) => ··· 535 } as InsertAlbumTrack) 536 .returning() 537 .execute() 538 - .then(([row]) => row) 539 - ) 540 - ) 541 ), 542 pipe( 543 Option.fromNullable(artistTrack), ··· 551 } as InsertArtistTrack) 552 .returning() 553 .execute() 554 - .then(([row]) => row) 555 - ) 556 - ) 557 ), 558 pipe( 559 Option.fromNullable(artistAlbum), ··· 567 } as InsertArtistAlbum) 568 .returning() 569 .execute() 570 - .then(([row]) => row) 571 - ) 572 - ) 573 ), 574 ]), 575 Effect.map(([albumTrack, artistTrack, artistAlbum]) => ({ 576 albumTrack, 577 artistTrack, 578 artistAlbum, 579 - })) 580 - ) 581 - ) 582 ); 583 584 // Update track with album and artist URIs if missing ··· 586 ctx: Context, 587 track: SelectTrack, 588 album: SelectAlbum, 589 - artist: SelectArtist 590 ) => 591 pipe( 592 Effect.succeed(track), ··· 599 albumUri: album.uri, 600 }) 601 .where(eq(tables.tracks.id, trackRecord.id)) 602 - .execute() 603 ) 604 - : Effect.succeed(undefined) 605 ), 606 Effect.tap((trackRecord) => 607 !trackRecord.artistUri ··· 612 artistUri: artist.uri, 613 }) 614 .where(eq(tables.tracks.id, trackRecord.id)) 615 - .execute() 616 ) 617 - : Effect.succeed(undefined) 618 - ) 619 ); 620 621 const publishTrack = ( ··· 623 track: SelectTrack, 624 albumTrack: SelectAlbumTrack, 625 artistTrack: SelectArtistTrack, 626 - artistAlbum: SelectArtistAlbum 627 ) => 628 pipe( 629 Effect.succeed( ··· 673 xata_updatedat: artistAlbum.updatedAt.toISOString(), 674 xata_version: artistAlbum.xataVersion, 675 }, 676 - }) 677 ), 678 Effect.flatMap((message) => 679 Effect.try(() => 680 ctx.nc.publish( 681 "rocksky.track", 682 - Buffer.from(JSON.stringify(message).replaceAll("sha_256", "sha256")) 683 - ) 684 - ) 685 - ) 686 ); 687 688 export const saveTrack = (ctx: Context, track: Track, agent: Agent) => ··· 735 Effect.filterOrFail( 736 () => !!track, 737 () => 738 - new Error(`Track not found for uri: ${trackUri}`) 739 - ) 740 ), 741 Option.fromNullable(album).pipe( 742 Effect.filterOrFail( 743 () => !!album, 744 () => 745 - new Error(`Album not found for uri: ${albumUri}`) 746 - ) 747 ), 748 Option.fromNullable(artist).pipe( 749 Effect.filterOrFail( 750 () => !!artist, 751 () => 752 - new Error(`Artist not found for uri: ${artistUri}`) 753 - ) 754 ), 755 ]), 756 Effect.flatMap(([track, album, artist]) => 757 pipe( 758 updateTrackUris(ctx, track, album, artist), 759 Effect.flatMap(() => 760 - ensureRelationships(ctx, track, album, artist) 761 ), 762 Effect.map( 763 ({ albumTrack, artistTrack, artistAlbum }) => ({ ··· 768 albumTrack, 769 artistTrack, 770 artistAlbum, 771 - }) 772 - ) 773 - ) 774 - ) 775 - ) 776 ), 777 Effect.tap( 778 ({ ··· 794 track.albumUri && 795 track.artistUri 796 ? `Track saved successfully after ${chalk.magenta(tries + 1)} tries` 797 - : `Track not yet saved, retrying... ${chalk.magenta(tries + 1)}` 798 - ) 799 ), 800 Effect.tap( 801 ({ ··· 810 tries === 15 811 ? pipe( 812 Effect.logError( 813 - "Failed to save track after 15 tries" 814 ), 815 Effect.tap(() => 816 Effect.logDebug( 817 - `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)}` 818 - ) 819 - ) 820 ) 821 - : Effect.succeed(undefined) 822 ), 823 - Effect.delay("1 second") 824 ), 825 - } 826 ), 827 Effect.tap(({ tries, track, albumTrack, artistTrack, artistAlbum }) => 828 tries < 15 && track && albumTrack && artistTrack && artistAlbum 829 ? publishTrack(ctx, track, albumTrack, artistTrack, artistAlbum) 830 - : Effect.succeed(undefined) 831 - ) 832 - ) 833 - ) 834 );
··· 43 Effect.catchAll((err) => { 44 console.error(err); 45 return Effect.succeed({}); 46 + }), 47 ); 48 server.app.rocksky.song.createSong({ 49 auth: ctx.authVerifier, ··· 77 ctx, 78 did, 79 input, 80 + })), 81 ), 82 Match.orElse(() => { 83 throw new Error("Authentication required to create a song"); 84 + }), 85 ), 86 catch: (error) => new Error(`Failed to create agent: ${error}`), 87 }); ··· 150 Effect.succeed( 151 createHash("sha256") 152 .update(`${track.title} - ${track.artist} - ${track.album}`.toLowerCase()) 153 + .digest("hex"), 154 ); 155 156 const computeAlbumHash = (track: Track): Effect.Effect<string, never> => 157 Effect.succeed( 158 createHash("sha256") 159 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 160 + .digest("hex"), 161 ); 162 163 const computeArtistHash = (track: Track): Effect.Effect<string, never> => 164 Effect.succeed( 165 + createHash("sha256").update(track.albumArtist.toLowerCase()).digest("hex"), 166 ); 167 168 const fetchExistingTrack = ( 169 ctx: Context, 170 + trackHash: string, 171 ): Effect.Effect<SelectTrack | undefined, Error> => 172 Effect.tryPromise(() => 173 ctx.db ··· 175 .from(tables.tracks) 176 .where(eq(tables.tracks.sha256, trackHash)) 177 .execute() 178 + .then(([row]) => row), 179 ); 180 181 const generateRkey = Effect.succeed(TID.nextStr()); ··· 184 agent: Agent, 185 collection: string, 186 record: T, 187 + validate: (record: T) => { success: boolean }, 188 ): Effect.Effect<string, Error> => 189 pipe( 190 Effect.succeed(record), 191 Effect.filterOrFail( 192 (rec) => validate(rec).success, 193 + () => new Error("Invalid record"), 194 ), 195 Effect.flatMap(() => 196 pipe( ··· 203 rkey, 204 record, 205 validate: false, 206 + }), 207 + ), 208 ), 209 Effect.tap((res) => 210 + Effect.logInfo(`Record created at ${res.data.uri}`), 211 ), 212 + Effect.map((res) => res.data.uri), 213 + ), 214 ), 215 Effect.catchAll((error) => { 216 console.error(`Error creating ${collection} record`, error); 217 return Effect.fail(error); 218 + }), 219 ); 220 221 const putArtistRecord = (track: Track, agent: Agent) => ··· 227 pictureUrl: track.artistPicture, 228 }), 229 Effect.flatMap((record) => 230 + putRecord(agent, "app.rocksky.artist", record, Artist.validateRecord), 231 + ), 232 ); 233 234 const putAlbumRecord = (track: Track, agent: Agent) => ··· 245 albumArtUrl: track.albumArt, 246 }), 247 Effect.flatMap((record) => 248 + putRecord(agent, "app.rocksky.album", record, Album.validateRecord), 249 + ), 250 ); 251 252 const putSongRecord = (track: Track, agent: Agent) => ··· 272 spotifyLink: track.spotifyLink ?? undefined, 273 }), 274 Effect.flatMap((record) => 275 + putRecord(agent, "app.rocksky.song", record, Song.validateRecord), 276 + ), 277 ); 278 279 const ensureTrack = (ctx: Context, track: Track, agent: Agent) => ··· 288 Effect.tap((trackOpt) => 289 trackOpt 290 ? updateTrackMetadata(ctx, track, trackOpt) 291 + : Effect.succeed(undefined), 292 ), 293 Effect.flatMap((trackOpt) => 294 trackOpt.uri 295 ? Effect.succeed(trackOpt.uri) 296 + : putSongRecord(track, agent), 297 + ), 298 + ), 299 + ), 300 + ), 301 + ), 302 ); 303 304 // Update track metadata (album_uri and artist_uri) 305 const updateTrackMetadata = ( 306 ctx: Context, 307 track: Track, 308 + trackRecord: SelectTrack, 309 ) => 310 pipe( 311 Effect.succeed(trackRecord), ··· 320 .from(tables.albums) 321 .where(eq(tables.albums.sha256, albumHash)) 322 .execute() 323 + .then(([row]) => row), 324 + ), 325 ), 326 Effect.flatMap((album) => 327 Option.fromNullable(album).pipe( ··· 331 .update(tables.tracks) 332 .set({ albumUri: album.uri }) 333 .where(eq(tables.tracks.id, trackRecord.id)) 334 + .execute(), 335 + ), 336 ), 337 + Effect.catchAll(() => Effect.succeed(undefined)), 338 + ), 339 + ), 340 ) 341 + : Effect.succeed(undefined), 342 ), 343 Effect.tap((trackRecord) => 344 !trackRecord.artistUri ··· 351 .from(tables.artists) 352 .where(eq(tables.artists.sha256, artistHash)) 353 .execute() 354 + .then(([row]) => row), 355 + ), 356 ), 357 Effect.flatMap((artist) => 358 Option.fromNullable(artist).pipe( ··· 362 .update(tables.tracks) 363 .set({ artistUri: artist.uri }) 364 .where(eq(tables.tracks.id, trackRecord.id)) 365 + .execute(), 366 + ), 367 ), 368 + Effect.catchAll(() => Effect.succeed(undefined)), 369 + ), 370 + ), 371 ) 372 + : Effect.succeed(undefined), 373 + ), 374 ); 375 376 // Ensure artist exists or create it ··· 385 .from(tables.artists) 386 .where(eq(tables.artists.sha256, artistHash)) 387 .execute() 388 + .then(([row]) => row), 389 ), 390 Effect.flatMap((existingArtist) => 391 pipe( ··· 393 Effect.flatMap((artistOpt) => 394 artistOpt.uri 395 ? Effect.succeed(artistOpt.uri) 396 + : putArtistRecord(track, agent), 397 + ), 398 + ), 399 + ), 400 + ), 401 + ), 402 ); 403 404 // Ensure album exists or create it ··· 413 .from(tables.albums) 414 .where(eq(tables.albums.sha256, albumHash)) 415 .execute() 416 + .then(([row]) => row), 417 ), 418 Effect.flatMap((existingAlbum) => 419 pipe( ··· 421 Effect.flatMap((albumOpt) => 422 albumOpt.uri 423 ? Effect.succeed(albumOpt.uri) 424 + : putAlbumRecord(track, agent), 425 + ), 426 + ), 427 + ), 428 + ), 429 + ), 430 ); 431 432 // Fetch track, album, and artist by URIs ··· 434 ctx: Context, 435 trackUri: string, 436 albumUri: string, 437 + artistUri: string, 438 ): Effect.Effect< 439 { 440 track: SelectTrack | null; ··· 450 .from(tables.tracks) 451 .where(eq(tables.tracks.uri, trackUri)) 452 .execute() 453 + .then(([row]) => row), 454 ), 455 album: Effect.tryPromise(() => 456 ctx.db ··· 458 .from(tables.albums) 459 .where(eq(tables.albums.uri, albumUri)) 460 .execute() 461 + .then(([row]) => row), 462 ), 463 artist: Effect.tryPromise(() => 464 ctx.db ··· 466 .from(tables.artists) 467 .where(eq(tables.artists.uri, artistUri)) 468 .execute() 469 + .then(([row]) => row), 470 ), 471 }); 472 ··· 475 ctx: Context, 476 track: SelectTrack, 477 album: SelectAlbum, 478 + artist: SelectArtist, 479 ) => 480 pipe( 481 Effect.all({ ··· 486 .where( 487 and( 488 eq(tables.albumTracks.albumId, album.id), 489 + eq(tables.albumTracks.trackId, track.id), 490 + ), 491 ) 492 .execute() 493 + .then(([row]) => row), 494 ), 495 496 artistTrack: Effect.tryPromise(() => ··· 500 .where( 501 and( 502 eq(tables.artistTracks.artistId, artist.id), 503 + eq(tables.artistTracks.trackId, track.id), 504 + ), 505 ) 506 .execute() 507 + .then(([row]) => row), 508 ), 509 artistAlbum: Effect.tryPromise(() => 510 ctx.db ··· 513 .where( 514 and( 515 eq(tables.artistAlbums.artistId, artist.id), 516 + eq(tables.artistAlbums.albumId, album.id), 517 + ), 518 ) 519 .execute() 520 + .then(([row]) => row), 521 ), 522 }), 523 Effect.flatMap(({ albumTrack, artistTrack, artistAlbum }) => ··· 535 } as InsertAlbumTrack) 536 .returning() 537 .execute() 538 + .then(([row]) => row), 539 + ), 540 + ), 541 ), 542 pipe( 543 Option.fromNullable(artistTrack), ··· 551 } as InsertArtistTrack) 552 .returning() 553 .execute() 554 + .then(([row]) => row), 555 + ), 556 + ), 557 ), 558 pipe( 559 Option.fromNullable(artistAlbum), ··· 567 } as InsertArtistAlbum) 568 .returning() 569 .execute() 570 + .then(([row]) => row), 571 + ), 572 + ), 573 ), 574 ]), 575 Effect.map(([albumTrack, artistTrack, artistAlbum]) => ({ 576 albumTrack, 577 artistTrack, 578 artistAlbum, 579 + })), 580 + ), 581 + ), 582 ); 583 584 // Update track with album and artist URIs if missing ··· 586 ctx: Context, 587 track: SelectTrack, 588 album: SelectAlbum, 589 + artist: SelectArtist, 590 ) => 591 pipe( 592 Effect.succeed(track), ··· 599 albumUri: album.uri, 600 }) 601 .where(eq(tables.tracks.id, trackRecord.id)) 602 + .execute(), 603 ) 604 + : Effect.succeed(undefined), 605 ), 606 Effect.tap((trackRecord) => 607 !trackRecord.artistUri ··· 612 artistUri: artist.uri, 613 }) 614 .where(eq(tables.tracks.id, trackRecord.id)) 615 + .execute(), 616 ) 617 + : Effect.succeed(undefined), 618 + ), 619 ); 620 621 const publishTrack = ( ··· 623 track: SelectTrack, 624 albumTrack: SelectAlbumTrack, 625 artistTrack: SelectArtistTrack, 626 + artistAlbum: SelectArtistAlbum, 627 ) => 628 pipe( 629 Effect.succeed( ··· 673 xata_updatedat: artistAlbum.updatedAt.toISOString(), 674 xata_version: artistAlbum.xataVersion, 675 }, 676 + }), 677 ), 678 Effect.flatMap((message) => 679 Effect.try(() => 680 ctx.nc.publish( 681 "rocksky.track", 682 + Buffer.from(JSON.stringify(message).replaceAll("sha_256", "sha256")), 683 + ), 684 + ), 685 + ), 686 ); 687 688 export const saveTrack = (ctx: Context, track: Track, agent: Agent) => ··· 735 Effect.filterOrFail( 736 () => !!track, 737 () => 738 + new Error(`Track not found for uri: ${trackUri}`), 739 + ), 740 ), 741 Option.fromNullable(album).pipe( 742 Effect.filterOrFail( 743 () => !!album, 744 () => 745 + new Error(`Album not found for uri: ${albumUri}`), 746 + ), 747 ), 748 Option.fromNullable(artist).pipe( 749 Effect.filterOrFail( 750 () => !!artist, 751 () => 752 + new Error(`Artist not found for uri: ${artistUri}`), 753 + ), 754 ), 755 ]), 756 Effect.flatMap(([track, album, artist]) => 757 pipe( 758 updateTrackUris(ctx, track, album, artist), 759 Effect.flatMap(() => 760 + ensureRelationships(ctx, track, album, artist), 761 ), 762 Effect.map( 763 ({ albumTrack, artistTrack, artistAlbum }) => ({ ··· 768 albumTrack, 769 artistTrack, 770 artistAlbum, 771 + }), 772 + ), 773 + ), 774 + ), 775 + ), 776 ), 777 Effect.tap( 778 ({ ··· 794 track.albumUri && 795 track.artistUri 796 ? `Track saved successfully after ${chalk.magenta(tries + 1)} tries` 797 + : `Track not yet saved, retrying... ${chalk.magenta(tries + 1)}`, 798 + ), 799 ), 800 Effect.tap( 801 ({ ··· 810 tries === 15 811 ? pipe( 812 Effect.logError( 813 + "Failed to save track after 15 tries", 814 ), 815 Effect.tap(() => 816 Effect.logDebug( 817 + `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)}`, 818 + ), 819 + ), 820 ) 821 + : Effect.succeed(undefined), 822 ), 823 + Effect.delay("1 second"), 824 ), 825 + }, 826 ), 827 Effect.tap(({ tries, track, albumTrack, artistTrack, artistAlbum }) => 828 tries < 15 && track && albumTrack && artistTrack && artistAlbum 829 ? publishTrack(ctx, track, albumTrack, artistTrack, artistAlbum) 830 + : Effect.succeed(undefined), 831 + ), 832 + ), 833 + ), 834 );