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

Publish scrobble and avoid duplicate metadata

Add DB existence checks for tracks, artists, and albums before creating
records (match on title/artist/albumArtist as appropriate). Implement
putScrobbleRecord to construct, validate, and publish a scrobble record
(handles timestamps, optional fields, rkey). Replace direct console logs
with structured logger calls and improve error messages. Also import
'or' from drizzle-orm for compound queries.

+124 -9
+124 -9
apps/cli/src/scrobble.ts
··· 5 5 import { getDidAndHandle } from "lib/getDidAndHandle"; 6 6 import { ctx } from "context"; 7 7 import schema from "schema"; 8 - import { and, eq, gte, lte, sql } from "drizzle-orm"; 8 + import { and, eq, gte, lte, or, sql } from "drizzle-orm"; 9 9 import os from "node:os"; 10 10 import path from "node:path"; 11 11 import fs from "node:fs"; ··· 49 49 50 50 logger.info`${handle} Publishing scrobble for ${track.title} by ${track.artist} at ${timestamp ? dayjs.unix(timestamp).format("YYYY-MM-DD HH:mm:ss") : dayjs().format("YYYY-MM-DD HH:mm:ss")}`; 51 51 52 - // putSongRecord 53 - // putArtistRecord 54 - // putAlbumRecord 55 - // putScrobbleRecord 52 + const existingTrack = await ctx.db 53 + .select() 54 + .from(schema.tracks) 55 + .where( 56 + or( 57 + and( 58 + eq(schema.tracks.title, track.title), 59 + eq(schema.tracks.artist, track.artist), 60 + ), 61 + and( 62 + eq(schema.tracks.title, track.title), 63 + eq(schema.tracks.albumArtist, track.artist), 64 + ), 65 + and( 66 + eq(schema.tracks.title, track.title), 67 + eq(schema.tracks.albumArtist, track.albumArtist), 68 + ), 69 + ), 70 + ) 71 + .limit(1) 72 + .execute() 73 + .then((rows) => rows[0]); 74 + 75 + let songUri = existingTrack?.uri; 76 + if (!existingTrack) { 77 + songUri = await putSongRecord(agent, track); 78 + } 79 + 80 + const existingArtist = await ctx.db 81 + .select() 82 + .from(schema.artists) 83 + .where( 84 + or( 85 + eq(schema.artists.name, track.artist), 86 + eq(schema.artists.name, track.albumArtist), 87 + ), 88 + ) 89 + .limit(1) 90 + .execute() 91 + .then((rows) => rows[0]); 92 + 93 + if (!existingArtist) { 94 + await putArtistRecord(agent, track); 95 + } 96 + 97 + const existingAlbum = await ctx.db 98 + .select() 99 + .from(schema.albums) 100 + .where( 101 + and( 102 + eq(schema.albums.title, track.album), 103 + eq(schema.albums.artist, track.albumArtist), 104 + ), 105 + ) 106 + .limit(1) 107 + .execute() 108 + .then((rows) => rows[0]); 109 + 110 + if (!existingAlbum) { 111 + await putAlbumRecord(agent, track); 112 + } 113 + 114 + await putScrobbleRecord(agent, track, timestamp); 56 115 57 116 return true; 58 117 } ··· 164 223 165 224 if (!Artist.validateRecord(record).success) { 166 225 logger.info`${Artist.validateRecord(record)}`; 167 - logger.info`${JSON.stringify(record, null, 2)}`; 226 + logger.info`${record}`; 168 227 throw new Error("Invalid Artist record"); 169 228 } 170 229 ··· 177 236 validate: false, 178 237 }); 179 238 const uri = res.data.uri; 180 - console.log(`Artist record created at ${uri}`); 239 + logger.info`Artist record created at ${uri}`; 181 240 return uri; 182 241 } catch (e) { 183 - console.error("Error creating artist record", e); 242 + logger.error`Error creating artist record: ${e}`; 184 243 return null; 185 244 } 186 245 } ··· 223 282 } 224 283 } 225 284 226 - async function putScrobbleRecord(agent: Agent, track: MatchTrackResult) {} 285 + async function putScrobbleRecord( 286 + agent: Agent, 287 + track: MatchTrackResult, 288 + timestamp?: number, 289 + ) { 290 + const rkey = TID.nextStr(); 291 + 292 + const record: Scrobble.Record = { 293 + $type: "app.rocksky.scrobble", 294 + title: track.title, 295 + albumArtist: track.albumArtist, 296 + albumArtUrl: track.albumArt, 297 + artist: track.artist, 298 + artists: track.mbArtists === null ? undefined : track.mbArtists, 299 + album: track.album, 300 + duration: track.duration, 301 + trackNumber: track.trackNumber, 302 + discNumber: track.discNumber === 0 ? 1 : track.discNumber, 303 + releaseDate: track.releaseDate 304 + ? new Date(track.releaseDate).toISOString() 305 + : undefined, 306 + year: track.year === null ? undefined : track.year, 307 + composer: track.composer ? track.composer : undefined, 308 + lyrics: track.lyrics ? track.lyrics : undefined, 309 + copyrightMessage: track.copyrightMessage 310 + ? track.copyrightMessage 311 + : undefined, 312 + createdAt: timestamp 313 + ? dayjs.unix(timestamp).toISOString() 314 + : new Date().toISOString(), 315 + spotifyLink: track.spotifyLink ? track.spotifyLink : undefined, 316 + tags: track.genres || [], 317 + mbid: track.mbId, 318 + }; 319 + 320 + if (!Scrobble.validateRecord(record).success) { 321 + logger.info`${Scrobble.validateRecord(record)}`; 322 + logger.info`${record}`; 323 + throw new Error("Invalid Scrobble record"); 324 + } 325 + 326 + try { 327 + const res = await agent.com.atproto.repo.putRecord({ 328 + repo: agent.assertDid, 329 + collection: "app.rocksky.scrobble", 330 + rkey, 331 + record, 332 + validate: false, 333 + }); 334 + const uri = res.data.uri; 335 + logger.info`Scrobble record created at ${uri}`; 336 + return uri; 337 + } catch (e) { 338 + logger.error`Error creating scrobble record: ${e}`; 339 + return null; 340 + } 341 + }