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

Adapt CLI to new search API and improve UX

Update client search endpoint and adapt to the new response shape (using
hits and _federation.indexUid). Remap result fields and fix
links/display names. Add matchTrack helper and use logger in scrobble.
Enhance CLI help styling and messages.

+77 -41
+14 -14
apps/cli/src/client.ts
··· 35 35 Authorization: this.token ? `Bearer ${this.token}` : undefined, 36 36 "Content-Type": "application/json", 37 37 }, 38 - } 38 + }, 39 39 ); 40 40 41 41 if (!response.ok) { 42 42 throw new Error( 43 - `Failed to fetch now playing data: ${response.statusText}` 43 + `Failed to fetch now playing data: ${response.statusText}`, 44 44 ); 45 45 } 46 46 ··· 56 56 Authorization: this.token ? `Bearer ${this.token}` : undefined, 57 57 "Content-Type": "application/json", 58 58 }, 59 - } 59 + }, 60 60 ); 61 61 62 62 if (!response.ok) { 63 63 throw new Error( 64 - `Failed to fetch now playing data: ${response.statusText}` 64 + `Failed to fetch now playing data: ${response.statusText}`, 65 65 ); 66 66 } 67 67 ··· 78 78 Authorization: this.token ? `Bearer ${this.token}` : undefined, 79 79 "Content-Type": "application/json", 80 80 }, 81 - } 81 + }, 82 82 ); 83 83 if (!response.ok) { 84 84 throw new Error( 85 - `Failed to fetch scrobbles data: ${response.statusText}` 85 + `Failed to fetch scrobbles data: ${response.statusText}`, 86 86 ); 87 87 } 88 88 return response.json(); ··· 96 96 Authorization: this.token ? `Bearer ${this.token}` : undefined, 97 97 "Content-Type": "application/json", 98 98 }, 99 - } 99 + }, 100 100 ); 101 101 if (!response.ok) { 102 102 throw new Error(`Failed to fetch scrobbles data: ${response.statusText}`); ··· 107 107 108 108 async search(query: string, { size }) { 109 109 const response = await fetch( 110 - `${ROCKSKY_API_URL}/search?q=${query}&size=${size}`, 110 + `${ROCKSKY_API_URL}/xrpc/app.rocksky.feed.search?query=${query}&size=${size}`, 111 111 { 112 112 method: "GET", 113 113 headers: { 114 114 Authorization: this.token ? `Bearer ${this.token}` : undefined, 115 115 "Content-Type": "application/json", 116 116 }, 117 - } 117 + }, 118 118 ); 119 119 120 120 if (!response.ok) { ··· 176 176 Authorization: this.token ? `Bearer ${this.token}` : undefined, 177 177 "Content-Type": "application/json", 178 178 }, 179 - } 179 + }, 180 180 ); 181 181 if (!response.ok) { 182 182 throw new Error(`Failed to fetch artists data: ${response.statusText}`); ··· 207 207 Authorization: this.token ? `Bearer ${this.token}` : undefined, 208 208 "Content-Type": "application/json", 209 209 }, 210 - } 210 + }, 211 211 ); 212 212 if (!response.ok) { 213 213 throw new Error(`Failed to fetch albums data: ${response.statusText}`); ··· 238 238 Authorization: this.token ? `Bearer ${this.token}` : undefined, 239 239 "Content-Type": "application/json", 240 240 }, 241 - } 241 + }, 242 242 ); 243 243 if (!response.ok) { 244 244 throw new Error(`Failed to fetch tracks data: ${response.statusText}`); ··· 252 252 await fs.promises.access(tokenPath); 253 253 } catch (err) { 254 254 console.error( 255 - `You are not logged in. Please run the login command first.` 255 + `You are not logged in. Please run the login command first.`, 256 256 ); 257 257 return; 258 258 } ··· 279 279 throw new Error( 280 280 `Failed to scrobble track: ${ 281 281 response.statusText 282 - } ${await response.text()}` 282 + } ${await response.text()}`, 283 283 ); 284 284 } 285 285
+6 -2
apps/cli/src/cmd/scrobble.ts
··· 1 - export async function scrobble(track, artist, { timestamp }) { 2 - console.log(">> scrobble", track, artist, timestamp); 1 + import { matchTrack } from "lib/matchTrack"; 2 + import { logger } from "logger"; 3 + 4 + export async function scrobble(track: string, artist: string, { timestamp }) { 5 + await matchTrack(track, artist); 6 + logger.info`>> scrobble ${track}, ${artist}, ${timestamp}`; 3 7 }
+27 -25
apps/cli/src/cmd/search.ts
··· 1 1 import chalk from "chalk"; 2 2 import { RockskyClient } from "client"; 3 + import _ from "lodash"; 3 4 4 5 export async function search( 5 6 query: string, 6 - { limit = 20, albums = false, artists = false, tracks = false, users = false } 7 + { 8 + limit = 20, 9 + albums = false, 10 + artists = false, 11 + tracks = false, 12 + users = false, 13 + }, 7 14 ) { 8 15 const client = new RockskyClient(); 9 16 const results = await client.search(query, { size: limit }); 10 - if (results.records.length === 0) { 17 + if (results.hits.length === 0) { 11 18 console.log(`No results found for ${chalk.magenta(query)}.`); 12 19 return; 13 20 } 14 21 15 - // merge all results into one array with type and sort by xata_scrore 16 - let mergedResults = results.records.map((record) => ({ 22 + let mergedResults = results.hits.map((record) => ({ 17 23 ...record, 18 - type: record.table, 24 + type: _.get(record, "_federation.indexUid"), 19 25 })); 20 26 21 27 if (albums) { 22 - mergedResults = mergedResults.filter((record) => record.table === "albums"); 28 + mergedResults = mergedResults.filter((record) => record.type === "albums"); 23 29 } 24 30 25 31 if (artists) { 26 - mergedResults = mergedResults.filter( 27 - (record) => record.table === "artists" 28 - ); 32 + mergedResults = mergedResults.filter((record) => record.type === "artists"); 29 33 } 30 34 31 35 if (tracks) { 32 - mergedResults = mergedResults.filter(({ table }) => table === "tracks"); 36 + mergedResults = mergedResults.filter(({ type }) => type === "tracks"); 33 37 } 34 38 35 39 if (users) { 36 - mergedResults = mergedResults.filter(({ table }) => table === "users"); 40 + mergedResults = mergedResults.filter(({ type }) => type === "users"); 37 41 } 38 42 39 - mergedResults.sort((a, b) => b.xata_score - a.xata_score); 40 - 41 - for (const { table, record } of mergedResults) { 42 - if (table === "users") { 43 + for (const { type, ...record } of mergedResults) { 44 + if (type === "users") { 43 45 console.log( 44 46 `${chalk.bold.magenta(record.handle)} ${ 45 - record.display_name 46 - } ${chalk.yellow(`https://rocksky.app/profile/${record.did}`)}` 47 + record.displayName 48 + } ${chalk.yellow(`https://rocksky.app/profile/${record.did}`)}`, 47 49 ); 48 50 } 49 51 50 - if (table === "albums") { 52 + if (type === "albums") { 51 53 const link = record.uri 52 - ? `https://rocksky.app/${record.uri?.split("at://")[1]}` 54 + ? `https://rocksky.app/${record.uri?.split("at://")[1]?.replace("app.rocksky.", "")}` 53 55 : ""; 54 56 console.log( 55 57 `${chalk.bold.magenta(record.title)} ${record.artist} ${chalk.yellow( 56 - link 57 - )}` 58 + link, 59 + )}`, 58 60 ); 59 61 } 60 62 61 - if (table === "tracks") { 63 + if (type === "tracks") { 62 64 const link = record.uri 63 - ? `https://rocksky.app/${record.uri?.split("at://")[1]}` 65 + ? `https://rocksky.app/${record.uri?.split("at://")[1]?.replace("app.rocksky.", "")}` 64 66 : ""; 65 67 console.log( 66 68 `${chalk.bold.magenta(record.title)} ${record.artist} ${chalk.yellow( 67 - link 68 - )}` 69 + link, 70 + )}`, 69 71 ); 70 72 } 71 73 }
+18
apps/cli/src/index.ts
··· 28 28 ) 29 29 .version(version.version); 30 30 31 + program.configureHelp({ 32 + styleTitle: (str) => chalk.bold.cyan(str), 33 + styleCommandText: (str) => chalk.yellow(str), 34 + styleCommandDescription: (str) => chalk.gray(str), 35 + styleDescriptionText: (str) => chalk.white(str), 36 + styleOptionText: (str) => chalk.green(str), 37 + styleArgumentText: (str) => chalk.magenta(str), 38 + styleSubcommandText: (str) => chalk.blue(str), 39 + }); 40 + 41 + program.addHelpText( 42 + "after", 43 + ` 44 + ${chalk.bold("\nLearn more about Rocksky:")} ${chalk.underline("https://docs.rocksky.app")} 45 + ${chalk.bold("Join our Discord community:")} ${chalk.underline(chalk.blueBright("https://discord.gg/EVcBy2fVa3"))} 46 + `, 47 + ); 48 + 31 49 program 32 50 .command("login") 33 51 .argument("<handle>", "your BlueSky handle (e.g., <username>.bsky.social)")
+12
apps/cli/src/lib/matchTrack.ts
··· 1 + import { ctx } from "context"; 2 + import { logger } from "logger"; 3 + import schema from "schema"; 4 + 5 + export async function matchTrack(track: string, artist: string) { 6 + await ctx.db 7 + .select() 8 + .from(schema.tracks) 9 + 10 + .execute(); 11 + logger.info`>> matchTrack ${track}, ${artist}`; 12 + }