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

Add publishScrobble and extract getDidAndHandle

Move getDidAndHandle into lib/getDidAndHandle and update sync to import
it. Add apps/cli/src/scrobble.ts with publishScrobble and
getRecentScrobble. Scrobble command now calls publishScrobble and skips
duplicates by checking recent scrobbles in the database.

+86 -21
+2 -1
apps/cli/src/cmd/scrobble.ts
··· 1 1 import { matchTrack } from "lib/matchTrack"; 2 2 import { logger } from "logger"; 3 + import { publishScrobble } from "scrobble"; 3 4 4 5 export async function scrobble(track: string, artist: string, { timestamp }) { 5 6 const match = await matchTrack(track, artist); 6 - logger.info`>> scrobble ${track}, ${artist}, ${timestamp}`; 7 + await publishScrobble(match, timestamp); 7 8 }
+1 -20
apps/cli/src/cmd/sync.ts
··· 19 19 import fs from "node:fs"; 20 20 import os from "node:os"; 21 21 import path from "node:path"; 22 + import { getDidAndHandle } from "lib/getDidAndHandle"; 22 23 23 24 const PAGE_SIZE = 100; 24 25 ··· 74 75 } 75 76 76 77 return `${endpoint}/subscribe`; 77 - }; 78 - 79 - const getDidAndHandle = async (): Promise<[string, string]> => { 80 - let handle = env.ROCKSKY_HANDLE || env.ROCKSKY_IDENTIFIER; 81 - let did = env.ROCKSKY_HANDLE || env.ROCKSKY_IDENTIFIER; 82 - 83 - if (handle.startsWith("did:plc:") || handle.startsWith("did:web:")) { 84 - handle = await ctx.resolver.resolveDidToHandle(handle); 85 - } 86 - 87 - if (!isValidHandle(handle)) { 88 - logger.error`❌ Invalid handle: ${handle}`; 89 - process.exit(1); 90 - } 91 - 92 - if (!did.startsWith("did:plc:") && !did.startsWith("did:web:")) { 93 - did = await ctx.baseIdResolver.handle.resolve(did); 94 - } 95 - 96 - return [did, handle]; 97 78 }; 98 79 99 80 const createUser = async (
+24
apps/cli/src/lib/getDidAndHandle.ts
··· 1 + import { isValidHandle } from "@atproto/syntax"; 2 + import { env } from "./env"; 3 + import { logger } from "logger"; 4 + import { ctx } from "context"; 5 + 6 + export async function getDidAndHandle(): Promise<[string, string]> { 7 + let handle = env.ROCKSKY_HANDLE || env.ROCKSKY_IDENTIFIER; 8 + let did = env.ROCKSKY_HANDLE || env.ROCKSKY_IDENTIFIER; 9 + 10 + if (handle.startsWith("did:plc:") || handle.startsWith("did:web:")) { 11 + handle = await ctx.resolver.resolveDidToHandle(handle); 12 + } 13 + 14 + if (!isValidHandle(handle)) { 15 + logger.error`❌ Invalid handle: ${handle}`; 16 + process.exit(1); 17 + } 18 + 19 + if (!did.startsWith("did:plc:") && !did.startsWith("did:web:")) { 20 + did = await ctx.baseIdResolver.handle.resolve(did); 21 + } 22 + 23 + return [did, handle]; 24 + }
+59
apps/cli/src/scrobble.ts
··· 1 + import { MatchTrackResult } from "lib/matchTrack"; 2 + import { logger } from "logger"; 3 + import dayjs from "dayjs"; 4 + import { createAgent } from "lib/agent"; 5 + import { getDidAndHandle } from "lib/getDidAndHandle"; 6 + import { Agent } from "node:http"; 7 + import { ctx } from "context"; 8 + import schema from "schema"; 9 + import { and, eq, gte, lte } from "drizzle-orm"; 10 + 11 + export async function publishScrobble( 12 + track: MatchTrackResult, 13 + timestamp?: number, 14 + ) { 15 + const [did, handle] = await getDidAndHandle(); 16 + const agent: Agent = await createAgent(did, handle); 17 + const recentScrobble = await getRecentScrobble(did, track, timestamp); 18 + 19 + if (recentScrobble) { 20 + logger.info`${handle} Skipping 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")} (already scrobbled)`; 21 + return; 22 + } 23 + 24 + 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")}`; 25 + } 26 + 27 + async function getRecentScrobble( 28 + did: string, 29 + track: MatchTrackResult, 30 + timestamp?: number, 31 + ) { 32 + const scrobbleTime = dayjs.unix(timestamp || dayjs().unix()); 33 + return ctx.db 34 + .select({ 35 + scrobble: schema.scrobbles, 36 + user: schema.users, 37 + track: schema.tracks, 38 + }) 39 + .from(schema.scrobbles) 40 + .innerJoin(schema.users, eq(schema.scrobbles.userId, schema.users.id)) 41 + .innerJoin(schema.tracks, eq(schema.scrobbles.trackId, schema.tracks.id)) 42 + .where( 43 + and( 44 + eq(schema.users.did, did), 45 + eq(schema.tracks.title, track.title), 46 + eq(schema.tracks.artist, track.artist), 47 + gte( 48 + schema.scrobbles.timestamp, 49 + scrobbleTime.subtract(60, "seconds").toDate(), 50 + ), 51 + lte( 52 + schema.scrobbles.timestamp, 53 + scrobbleTime.add(60, "seconds").toDate(), 54 + ), 55 + ), 56 + ) 57 + .limit(1) 58 + .then((rows) => rows[0]); 59 + }