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

Add MusicBrainz lookup and return match in CLI

Fetch MBIDs and artist data in matchSong via a new searchOnMusicBrainz,
add MusicBrainzArtist type, and attach mbId / mbArtists to the song
view. Also make CLI matchTrack return the matched track
(MatchTrackResult) so scrobble can consume the result.

+79 -3
+59 -1
apps/api/src/xrpc/app/rocksky/song/matchSong.ts
··· 8 8 import { env } from "lib/env"; 9 9 import tables from "schema"; 10 10 import type { SelectTrack } from "schema/tracks"; 11 - import { Album, Artist, SearchResponse, Track } from "./types"; 11 + import { 12 + Album, 13 + Artist, 14 + MusicBrainzArtist, 15 + SearchResponse, 16 + Track, 17 + } from "./types"; 18 + import { MusicbrainzTrack } from "types/track"; 12 19 13 20 export default function (server: Server, ctx: Context) { 14 21 const matchSong = (params: QueryParams) => ··· 136 143 year = record.albums.year; 137 144 } 138 145 146 + const mbTrack = await searchOnMusicBrainz(ctx, track); 147 + track.mbId = mbTrack.mbId; 148 + 139 149 return Promise.all([ 140 150 Promise.resolve(track), 141 151 ctx.db ··· 156 166 Promise.resolve(year), 157 167 Promise.resolve(artistPicture), 158 168 Promise.resolve(genres), 169 + Promise.resolve(mbTrack.artists), 159 170 ]); 160 171 }, 161 172 catch: (error) => new Error(`Failed to retrieve artist: ${error}`), ··· 170 181 year, 171 182 artistPicture, 172 183 genres, 184 + mbArtists, 173 185 ]: [ 174 186 SelectTrack, 175 187 number, ··· 178 190 number | null, 179 191 string | null, 180 192 string[] | null, 193 + MusicBrainzArtist[] | null, 181 194 ]): Effect.Effect<SongViewDetailed, never> => { 182 195 return Effect.sync(() => ({ 183 196 ...track, ··· 185 198 year, 186 199 artistPicture, 187 200 genres, 201 + mbArtists, 188 202 playCount, 189 203 uniqueListeners, 190 204 createdAt: track.createdAt.toISOString(), ··· 289 303 290 304 return track; 291 305 }; 306 + 307 + const searchOnMusicBrainz = async (ctx: Context, track: SelectTrack) => { 308 + let mbTrack; 309 + try { 310 + const { data } = await ctx.musicbrainz.post<MusicbrainzTrack>("/hydrate", { 311 + artist: track.artist 312 + .replaceAll(";", ",") 313 + .split(",") 314 + .map((a) => ({ name: a.trim() })), 315 + name: track.title, 316 + album: track.album, 317 + }); 318 + mbTrack = data; 319 + 320 + if (!mbTrack?.trackMBID) { 321 + const response = await ctx.musicbrainz.post<MusicbrainzTrack>( 322 + "/hydrate", 323 + { 324 + artist: track.artist.split(",").map((a) => ({ name: a.trim() })), 325 + name: track.title, 326 + }, 327 + ); 328 + mbTrack = response.data; 329 + } 330 + 331 + const mbId = mbTrack?.trackMBID; 332 + const artists: MusicBrainzArtist[] = mbTrack?.artist?.map((artist) => ({ 333 + mbid: artist.mbid, 334 + name: artist.name, 335 + })); 336 + 337 + return { 338 + mbId, 339 + artists, 340 + }; 341 + } catch (error) { 342 + console.error("Error fetching MusicBrainz data"); 343 + } 344 + 345 + return { 346 + mbId: null, 347 + artists: null, 348 + }; 349 + };
+5
apps/api/src/xrpc/app/rocksky/song/types.ts
··· 88 88 scope: string; 89 89 expires_in: number; 90 90 } 91 + 92 + export interface MusicBrainzArtist { 93 + mbid: string; 94 + name: string; 95 + }
+1 -1
apps/cli/src/cmd/scrobble.ts
··· 2 2 import { logger } from "logger"; 3 3 4 4 export async function scrobble(track: string, artist: string, { timestamp }) { 5 - await matchTrack(track, artist); 5 + const match = await matchTrack(track, artist); 6 6 logger.info`>> scrobble ${track}, ${artist}, ${timestamp}`; 7 7 }
+14 -1
apps/cli/src/lib/matchTrack.ts
··· 3 3 import { eq, and, or } from "drizzle-orm"; 4 4 import { logger } from "logger"; 5 5 import schema from "schema"; 6 + import { SelectTrack } from "schema/tracks"; 6 7 7 - export async function matchTrack(track: string, artist: string) { 8 + export type MatchTrackResult = SelectTrack & { 9 + genres: string[] | null; 10 + artistPicture: string | null; 11 + releaseDate: Date | null; 12 + year: number | null; 13 + }; 14 + 15 + export async function matchTrack( 16 + track: string, 17 + artist: string, 18 + ): Promise<MatchTrackResult | null> { 8 19 const [result] = await ctx.db 9 20 .select() 10 21 .from(schema.tracks) ··· 48 59 } 49 60 logger.info`>> matchTrack ${track}, ${artist}`; 50 61 logger.info`${match}`; 62 + 63 + return match; 51 64 }