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

Add song matchSong lexicon and handler

+462 -1
+34
apps/api/lexicons/song/matchSong.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.rocksky.song.matchSong", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get a song by its uri", 8 + "parameters": { 9 + "type": "params", 10 + "required": [ 11 + "title", 12 + "artist" 13 + ], 14 + "properties": { 15 + "title": { 16 + "type": "string", 17 + "description": "The title of the song to retrieve" 18 + }, 19 + "artist": { 20 + "type": "string", 21 + "description": "The artist of the song to retrieve" 22 + } 23 + } 24 + }, 25 + "output": { 26 + "encoding": "application/json", 27 + "schema": { 28 + "type": "ref", 29 + "ref": "app.rocksky.song.defs#songViewDetailed" 30 + } 31 + } 32 + } 33 + } 34 + }
+28
apps/api/pkl/defs/song/matchSong.pkl
··· 1 + amends "../../schema/lexicon.pkl" 2 + 3 + lexicon = 1 4 + id = "app.rocksky.song.matchSong" 5 + defs = new Mapping<String, Query> { 6 + ["main"] { 7 + type = "query" 8 + description = "Get a song by its uri" 9 + parameters = new Params { 10 + required = List("title", "artist") 11 + properties { 12 + ["title"] = new StringType { 13 + description = "The title of the song to retrieve" 14 + } 15 + ["artist"] = new StringType { 16 + description = "The artist of the song to retrieve" 17 + } 18 + } 19 + } 20 + output { 21 + encoding = "application/json" 22 + schema = new Ref { 23 + type = "ref" 24 + ref = "app.rocksky.song.defs#songViewDetailed" 25 + } 26 + } 27 + } 28 + }
+12
apps/api/src/lexicon/index.ts
··· 93 93 import type * as AppRockskySongCreateSong from "./types/app/rocksky/song/createSong"; 94 94 import type * as AppRockskySongGetSong from "./types/app/rocksky/song/getSong"; 95 95 import type * as AppRockskySongGetSongs from "./types/app/rocksky/song/getSongs"; 96 + import type * as AppRockskySongMatchSong from "./types/app/rocksky/song/matchSong"; 96 97 import type * as AppRockskySpotifyGetCurrentlyPlaying from "./types/app/rocksky/spotify/getCurrentlyPlaying"; 97 98 import type * as AppRockskySpotifyNext from "./types/app/rocksky/spotify/next"; 98 99 import type * as AppRockskySpotifyPause from "./types/app/rocksky/spotify/pause"; ··· 1261 1262 >, 1262 1263 ) { 1263 1264 const nsid = "app.rocksky.song.getSongs"; // @ts-ignore 1265 + return this._server.xrpc.method(nsid, cfg); 1266 + } 1267 + 1268 + matchSong<AV extends AuthVerifier>( 1269 + cfg: ConfigOf< 1270 + AV, 1271 + AppRockskySongMatchSong.Handler<ExtractAuth<AV>>, 1272 + AppRockskySongMatchSong.HandlerReqCtx<ExtractAuth<AV>> 1273 + >, 1274 + ) { 1275 + const nsid = "app.rocksky.song.matchSong"; // @ts-ignore 1264 1276 return this._server.xrpc.method(nsid, cfg); 1265 1277 } 1266 1278 }
+32
apps/api/src/lexicon/lexicons.ts
··· 5548 5548 }, 5549 5549 }, 5550 5550 }, 5551 + AppRockskySongMatchSong: { 5552 + lexicon: 1, 5553 + id: "app.rocksky.song.matchSong", 5554 + defs: { 5555 + main: { 5556 + type: "query", 5557 + description: "Get a song by its uri", 5558 + parameters: { 5559 + type: "params", 5560 + required: ["title", "artist"], 5561 + properties: { 5562 + title: { 5563 + type: "string", 5564 + description: "The title of the song to retrieve", 5565 + }, 5566 + artist: { 5567 + type: "string", 5568 + description: "The artist of the song to retrieve", 5569 + }, 5570 + }, 5571 + }, 5572 + output: { 5573 + encoding: "application/json", 5574 + schema: { 5575 + type: "ref", 5576 + ref: "lex:app.rocksky.song.defs#songViewDetailed", 5577 + }, 5578 + }, 5579 + }, 5580 + }, 5581 + }, 5551 5582 AppRockskySong: { 5552 5583 lexicon: 1, 5553 5584 id: "app.rocksky.song", ··· 6032 6063 AppRockskySongDefs: "app.rocksky.song.defs", 6033 6064 AppRockskySongGetSong: "app.rocksky.song.getSong", 6034 6065 AppRockskySongGetSongs: "app.rocksky.song.getSongs", 6066 + AppRockskySongMatchSong: "app.rocksky.song.matchSong", 6035 6067 AppRockskySong: "app.rocksky.song", 6036 6068 AppRockskySpotifyDefs: "app.rocksky.spotify.defs", 6037 6069 AppRockskySpotifyGetCurrentlyPlaying:
+45
apps/api/src/lexicon/types/app/rocksky/song/matchSong.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import type express from "express"; 5 + import { ValidationResult, BlobRef } from "@atproto/lexicon"; 6 + import { lexicons } from "../../../../lexicons"; 7 + import { isObj, hasProp } from "../../../../util"; 8 + import { CID } from "multiformats/cid"; 9 + import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 + import type * as AppRockskySongDefs from "./defs"; 11 + 12 + export interface QueryParams { 13 + /** The title of the song to retrieve */ 14 + title: string; 15 + /** The artist of the song to retrieve */ 16 + artist: string; 17 + } 18 + 19 + export type InputSchema = undefined; 20 + export type OutputSchema = AppRockskySongDefs.SongViewDetailed; 21 + export type HandlerInput = undefined; 22 + 23 + export interface HandlerSuccess { 24 + encoding: "application/json"; 25 + body: OutputSchema; 26 + headers?: { [key: string]: string }; 27 + } 28 + 29 + export interface HandlerError { 30 + status: number; 31 + message?: string; 32 + } 33 + 34 + export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough; 35 + export type HandlerReqCtx<HA extends HandlerAuth = never> = { 36 + auth: HA; 37 + params: QueryParams; 38 + input: HandlerInput; 39 + req: express.Request; 40 + res: express.Response; 41 + resetRouteRateLimits: () => Promise<void>; 42 + }; 43 + export type Handler<HA extends HandlerAuth = never> = ( 44 + ctx: HandlerReqCtx<HA>, 45 + ) => Promise<HandlerOutput> | HandlerOutput;
+1 -1
apps/api/src/nowplaying/nowplaying.service.ts
··· 614 614 615 615 let mbTrack; 616 616 try { 617 - let { data } = await ctx.musicbrainz.post<MusicbrainzTrack>("/hydrate", { 617 + const { data } = await ctx.musicbrainz.post<MusicbrainzTrack>("/hydrate", { 618 618 artist: track.artist 619 619 .replaceAll(";", ",") 620 620 .split(",")
+220
apps/api/src/xrpc/app/rocksky/song/matchSong.ts
··· 1 + import type { Context } from "context"; 2 + import { and, count, eq, or } from "drizzle-orm"; 3 + import { Effect, pipe } from "effect"; 4 + import type { Server } from "lexicon"; 5 + import type { SongViewDetailed } from "lexicon/types/app/rocksky/song/defs"; 6 + import type { QueryParams } from "lexicon/types/app/rocksky/song/matchSong"; 7 + import { decrypt } from "lib/crypto"; 8 + import { env } from "lib/env"; 9 + import tables from "schema"; 10 + import type { SelectTrack } from "schema/tracks"; 11 + import { Album, Artist, SearchResponse, Track } from "./types"; 12 + 13 + export default function (server: Server, ctx: Context) { 14 + const matchSong = (params: QueryParams) => 15 + pipe( 16 + { params, ctx }, 17 + retrieve, 18 + Effect.flatMap(presentation), 19 + Effect.retry({ times: 3 }), 20 + Effect.timeout("10 seconds"), 21 + Effect.catchAll((err) => { 22 + console.error(err); 23 + return Effect.succeed({}); 24 + }), 25 + ); 26 + server.app.rocksky.song.matchSong({ 27 + handler: async ({ params }) => { 28 + const result = await Effect.runPromise(matchSong(params)); 29 + return { 30 + encoding: "application/json", 31 + body: result, 32 + }; 33 + }, 34 + }); 35 + } 36 + 37 + const retrieve = ({ params, ctx }: { params: QueryParams; ctx: Context }) => { 38 + return Effect.tryPromise({ 39 + try: async () => { 40 + let track = await ctx.db 41 + .select() 42 + .from(tables.tracks) 43 + .where( 44 + or( 45 + and( 46 + eq(tables.tracks.title, params.title), 47 + eq(tables.tracks.artist, params.artist), 48 + ), 49 + and( 50 + eq(tables.tracks.title, params.title), 51 + eq(tables.tracks.albumArtist, params.artist), 52 + ), 53 + ), 54 + ) 55 + .execute() 56 + .then(([row]) => row); 57 + 58 + if (!track) { 59 + const spotifyTrack = await searchOnSpotify( 60 + ctx, 61 + params.title, 62 + params.artist, 63 + ); 64 + if (spotifyTrack) { 65 + track = { 66 + id: "", 67 + title: spotifyTrack.name, 68 + artist: spotifyTrack.artists 69 + .map((artist) => artist.name) 70 + .join(", "), 71 + albumArtist: spotifyTrack.album.artists[0]?.name, 72 + albumArt: spotifyTrack.album.images[0]?.url || null, 73 + album: spotifyTrack.album.name, 74 + trackNumber: spotifyTrack.track_number, 75 + duration: spotifyTrack.duration_ms, 76 + mbId: null, 77 + youtubeLink: null, 78 + spotifyLink: spotifyTrack.external_urls.spotify, 79 + appleMusicLink: null, 80 + tidalLink: null, 81 + sha256: null, 82 + discNumber: spotifyTrack.disc_number, 83 + lyrics: null, 84 + composer: null, 85 + genre: spotifyTrack.album.genres?.[0] || null, 86 + label: spotifyTrack.album.label || null, 87 + copyrightMessage: spotifyTrack.album.copyrights?.[0]?.text || null, 88 + uri: null, 89 + albumUri: null, 90 + artistUri: null, 91 + createdAt: new Date(), 92 + updatedAt: new Date(), 93 + xataVersion: 0, 94 + }; 95 + } 96 + } 97 + 98 + return Promise.all([ 99 + Promise.resolve(track), 100 + ctx.db 101 + .select({ 102 + count: count(), 103 + }) 104 + .from(tables.userTracks) 105 + .where(eq(tables.userTracks.trackId, track?.id)) 106 + .execute() 107 + .then((rows) => rows[0]?.count || 0), 108 + ctx.db 109 + .select({ count: count() }) 110 + .from(tables.scrobbles) 111 + .where(eq(tables.scrobbles.trackId, track?.id)) 112 + .execute() 113 + .then((rows) => rows[0]?.count || 0), 114 + ]); 115 + }, 116 + catch: (error) => new Error(`Failed to retrieve artist: ${error}`), 117 + }); 118 + }; 119 + 120 + const presentation = ([track, uniqueListeners, playCount]: [ 121 + SelectTrack, 122 + number, 123 + number, 124 + ]): Effect.Effect<SongViewDetailed, never> => { 125 + return Effect.sync(() => ({ 126 + ...track, 127 + playCount, 128 + uniqueListeners, 129 + createdAt: track.createdAt.toISOString(), 130 + updatedAt: track.updatedAt.toISOString(), 131 + })); 132 + }; 133 + 134 + const searchOnSpotify = async ( 135 + ctx: Context, 136 + title: string, 137 + artist: string, 138 + ): Promise<Track | undefined> => { 139 + const spotifyTokens = await ctx.db 140 + .select() 141 + .from(tables.spotifyTokens) 142 + .leftJoin( 143 + tables.spotifyApps, 144 + eq(tables.spotifyTokens.spotifyAppId, tables.spotifyApps.spotifyAppId), 145 + ) 146 + .leftJoin( 147 + tables.spotifyAccounts, 148 + eq(tables.spotifyAccounts.spotifyAppId, tables.spotifyApps.id), 149 + ) 150 + .where(eq(tables.spotifyAccounts.isBetaUser, true)) 151 + .limit(500) 152 + .execute(); 153 + 154 + const { spotify_tokens, spotify_apps } = 155 + spotifyTokens[Math.floor(Math.random() * spotifyTokens.length)]; 156 + 157 + const refreshToken = decrypt( 158 + spotify_tokens.refreshToken, 159 + env.SPOTIFY_ENCRYPTION_KEY, 160 + ); 161 + 162 + // get new access token 163 + const newAccessToken = await fetch("https://accounts.spotify.com/api/token", { 164 + method: "POST", 165 + headers: { 166 + "Content-Type": "application/x-www-form-urlencoded", 167 + }, 168 + body: new URLSearchParams({ 169 + grant_type: "refresh_token", 170 + refresh_token: refreshToken, 171 + client_id: spotify_apps.spotifyAppId, 172 + client_secret: decrypt( 173 + spotify_apps.spotifySecret, 174 + env.SPOTIFY_ENCRYPTION_KEY, 175 + ), 176 + }), 177 + }); 178 + 179 + const { access_token } = (await newAccessToken.json()) as { 180 + access_token: string; 181 + }; 182 + 183 + const q = `q=track:"${encodeURIComponent(title)}"%20artist:"${encodeURIComponent(artist)}"&type=track`; 184 + const response = await fetch(`https://api.spotify.com/v1/search?${q}`, { 185 + method: "GET", 186 + headers: { 187 + Authorization: `Bearer ${access_token}`, 188 + }, 189 + }).then((res) => res.json<SearchResponse>()); 190 + 191 + const track = response.tracks?.items?.[0]; 192 + 193 + if (track) { 194 + const album = await fetch( 195 + `https://api.spotify.com/v1/albums/${track.album.id}`, 196 + { 197 + method: "GET", 198 + headers: { 199 + Authorization: `Bearer ${access_token}`, 200 + }, 201 + }, 202 + ).then((res) => res.json<Album>()); 203 + 204 + track.album = album; 205 + 206 + const artist = await fetch( 207 + `https://api.spotify.com/v1/artists/${track.artists[0].id}`, 208 + { 209 + method: "GET", 210 + headers: { 211 + Authorization: `Bearer ${access_token}`, 212 + }, 213 + }, 214 + ).then((res) => res.json<Artist>()); 215 + 216 + track.artists[0] = artist; 217 + } 218 + 219 + return track; 220 + };
+90
apps/api/src/xrpc/app/rocksky/song/types.ts
··· 1 + export interface SearchResponse { 2 + tracks: Tracks; 3 + } 4 + 5 + export interface Tracks { 6 + href: string; 7 + limit: number; 8 + next: string | null; 9 + offset: number; 10 + previous: string | null; 11 + total: number; 12 + items: Track[]; 13 + } 14 + 15 + export interface Track { 16 + album: Album; 17 + artists: Artist[]; 18 + available_markets: string[]; 19 + disc_number: number; 20 + duration_ms: number; 21 + explicit: boolean; 22 + external_ids: ExternalIds; 23 + external_urls: ExternalUrls; 24 + href: string; 25 + id: string; 26 + is_local: boolean; 27 + is_playable?: boolean; 28 + name: string; 29 + popularity: number; 30 + preview_url: string | null; 31 + track_number: number; 32 + type: string; 33 + uri: string; 34 + } 35 + 36 + export interface Album { 37 + album_type: string; 38 + artists: Artist[]; 39 + available_markets: string[]; 40 + external_urls: ExternalUrls; 41 + href: string; 42 + id: string; 43 + images: Image[]; 44 + name: string; 45 + release_date: string; 46 + release_date_precision: string; 47 + total_tracks: number; 48 + type: string; 49 + uri: string; 50 + label?: string; 51 + genres?: string[]; 52 + copyrights?: Copyright[]; 53 + } 54 + 55 + export interface Copyright { 56 + text: string; 57 + type: string; 58 + } 59 + 60 + export interface Artist { 61 + external_urls: ExternalUrls; 62 + href: string; 63 + id: string; 64 + name: string; 65 + type: string; 66 + uri: string; 67 + images?: Image[]; 68 + genres?: string[]; 69 + } 70 + 71 + export interface ExternalUrls { 72 + spotify: string; 73 + } 74 + 75 + export interface ExternalIds { 76 + isrc: string; 77 + } 78 + 79 + export interface Image { 80 + height: number; 81 + width: number; 82 + url: string; 83 + } 84 + 85 + export interface AccessToken { 86 + access_token: string; 87 + token_type: string; 88 + scope: string; 89 + expires_in: number; 90 + }