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

Retry Spotify calls and improve artist matching

+123 -25
+123 -25
apps/api/src/xrpc/app/rocksky/song/matchSong.ts
··· 207 207 })); 208 208 }; 209 209 210 + const MAX_SPOTIFY_RETRIES = 3; 211 + const INITIAL_RETRY_DELAY_MS = 1000; 212 + const SPOTIFY_TIMEOUT_MS = 30000; 213 + 214 + const retrySpotifyCall = async <T>( 215 + fn: () => Promise<T>, 216 + operation: string, 217 + ): Promise<T> => { 218 + let lastError: Error | undefined; 219 + 220 + for (let attempt = 0; attempt < MAX_SPOTIFY_RETRIES; attempt++) { 221 + try { 222 + const controller = new AbortController(); 223 + const timeoutId = setTimeout( 224 + () => controller.abort(), 225 + SPOTIFY_TIMEOUT_MS, 226 + ); 227 + 228 + const result = await Promise.race([ 229 + fn(), 230 + new Promise<never>((_, reject) => { 231 + controller.signal.addEventListener("abort", () => 232 + reject(new Error("Request timeout")), 233 + ); 234 + }), 235 + ]); 236 + 237 + clearTimeout(timeoutId); 238 + return result; 239 + } catch (error) { 240 + const errorMessage = 241 + error instanceof Error ? error.message : String(error); 242 + const isTimeout = 243 + errorMessage.includes("timeout") || 244 + errorMessage.includes("timed out") || 245 + errorMessage.includes("operation timed out") || 246 + errorMessage.includes("ETIMEDOUT"); 247 + 248 + if (isTimeout && attempt < MAX_SPOTIFY_RETRIES - 1) { 249 + const delay = INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt); 250 + consola.warn( 251 + `Spotify API timeout, retrying... attempt=${attempt + 1}, max_attempts=${MAX_SPOTIFY_RETRIES}, delay_ms=${delay}, operation=${operation}`, 252 + ); 253 + await new Promise((resolve) => setTimeout(resolve, delay)); 254 + lastError = error instanceof Error ? error : new Error(String(error)); 255 + } else { 256 + throw error; 257 + } 258 + } 259 + } 260 + 261 + throw lastError || new Error("Max retries exceeded"); 262 + }; 263 + 210 264 const searchOnSpotify = async ( 211 265 ctx: Context, 212 266 title: string, ··· 284 338 q = `q=track:"${encodeURIComponent(title)}" ${artists}&type=track`; 285 339 } 286 340 287 - const response = await fetch(`https://api.spotify.com/v1/search?${q}`, { 288 - method: "GET", 289 - headers: { 290 - Authorization: `Bearer ${access_token}`, 291 - }, 292 - }).then((res) => res.json<SearchResponse>()); 293 - 294 - const track = response.tracks?.items?.[0]; 295 - 296 - if (track) { 297 - const album = await fetch( 298 - `https://api.spotify.com/v1/albums/${track.album.id}`, 299 - { 341 + const response = await retrySpotifyCall( 342 + async () => 343 + fetch(`https://api.spotify.com/v1/search?${q}`, { 300 344 method: "GET", 301 345 headers: { 302 346 Authorization: `Bearer ${access_token}`, 303 347 }, 304 - }, 305 - ).then((res) => res.json<Album>()); 348 + }).then((res) => res.json<SearchResponse>()), 349 + "search", 350 + ); 351 + 352 + const track = response.tracks?.items?.[0]; 353 + 354 + if (track) { 355 + const normalize = (s: string): string => { 356 + return s 357 + .toLowerCase() 358 + .normalize("NFD") 359 + .replace(/[\u0300-\u036f]/g, "") 360 + .replace(/á|à|ä|â|ã|å/g, "a") 361 + .replace(/é|è|ë|ê/g, "e") 362 + .replace(/í|ì|ï|î/g, "i") 363 + .replace(/ó|ò|ö|ô|õ/g, "o") 364 + .replace(/ú|ù|ü|û/g, "u") 365 + .replace(/ñ/g, "n") 366 + .replace(/ç/g, "c"); 367 + }; 368 + 369 + const spotifyArtists = track.artists.map((a) => normalize(a.name)); 370 + 371 + // Check if artists don't contain the scrobble artist (to avoid wrong matches) 372 + // scrobble artist can contain multiple artists separated by ", " 373 + const scrobbleArtists = artist.split(", ").map((a) => normalize(a.trim())); 374 + 375 + // Check for matches with partial matching: 376 + // 1. Check if any scrobble artist is contained in any Spotify artist 377 + // 2. Check if any Spotify artist is contained in any scrobble artist 378 + const hasArtistMatch = scrobbleArtists.some((scrobbleArtist) => 379 + spotifyArtists.some( 380 + (spotifyArtist) => 381 + scrobbleArtist.includes(spotifyArtist) || 382 + spotifyArtist.includes(scrobbleArtist), 383 + ), 384 + ); 385 + 386 + if (!hasArtistMatch) { 387 + consola.warn( 388 + `Artist mismatch, skipping - expected: ${artist}, got: ${track.artists.map((a) => a.name).join(", ")}`, 389 + ); 390 + return undefined; 391 + } 392 + 393 + const album = await retrySpotifyCall( 394 + async () => 395 + fetch(`https://api.spotify.com/v1/albums/${track.album.id}`, { 396 + method: "GET", 397 + headers: { 398 + Authorization: `Bearer ${access_token}`, 399 + }, 400 + }).then((res) => res.json<Album>()), 401 + "get_album", 402 + ); 306 403 307 404 track.album = album; 308 405 309 - const artist = await fetch( 310 - `https://api.spotify.com/v1/artists/${track.artists[0].id}`, 311 - { 312 - method: "GET", 313 - headers: { 314 - Authorization: `Bearer ${access_token}`, 315 - }, 316 - }, 317 - ).then((res) => res.json<Artist>()); 406 + const fetchedArtist = await retrySpotifyCall( 407 + async () => 408 + fetch(`https://api.spotify.com/v1/artists/${track.artists[0].id}`, { 409 + method: "GET", 410 + headers: { 411 + Authorization: `Bearer ${access_token}`, 412 + }, 413 + }).then((res) => res.json<Artist>()), 414 + "get_artist", 415 + ); 318 416 319 - track.artists[0] = artist; 417 + track.artists[0] = fetchedArtist; 320 418 } 321 419 322 420 return track;