diff --git a/src/lib/server/playlist/tidalPlaylistAdder.ts b/src/lib/server/playlist/tidalPlaylistAdder.ts index d1dfaa6..09d2fa2 100644 --- a/src/lib/server/playlist/tidalPlaylistAdder.ts +++ b/src/lib/server/playlist/tidalPlaylistAdder.ts @@ -121,8 +121,13 @@ export class TidalPlaylistAdder extends OauthPlaylistAdder implements PlaylistAd try { let albumUrl: URL; if (nextLink) { + this.logger.debug('getAlbumItems nextPage', nextLink); albumUrl = new URL(nextLink); } else { + this.logger.debug( + 'getAlbumItems albumUrl', + `${this.apiBase}/albums/${albumId}/relationships/items` + ); albumUrl = new URL(`${this.apiBase}/albums/${albumId}/relationships/items`); albumUrl.searchParams.append('countryCode', 'DE'); } @@ -144,7 +149,12 @@ export class TidalPlaylistAdder extends OauthPlaylistAdder implements PlaylistAd return { id: x.id, type: x.type }; }); if (respData.links?.next) { - const nextPage = await this.getAlbumItems(albumId, remaning, respData.links.next); + this.logger.debug('getAlbumItems requesting next page', respData.links.next); + const nextPage = await this.getAlbumItems( + albumId, + remaning, + this.apiBase + respData.links.next + ); tracks = tracks.concat(nextPage); } return tracks; @@ -243,96 +253,108 @@ export class TidalPlaylistAdder extends OauthPlaylistAdder implements PlaylistAd this.logger.error('Could not check for tidal dupes', dbe); } - // This would be API v2, but that's still in beta and only allows adding an item *before* another one - const options: RequestInit = { - method: 'POST', - headers: { - Authorization: `${token.token_type} ${token.access_token}`, - 'Content-Type': 'application/vnd.api+json', - Accept: 'application/vnd.api+json' - }, - body: JSON.stringify({ - data: tracks, - meta: { - positionBefore: 'ffb6286e-237a-4dfc-bbf1-2fb0eb004ed5' // Hardcoded last element of list - } - }) - }; - const apiUrl = new URL(`${this.apiBase}/playlists/${TIDAL_PLAYLIST_ID}/relationships/items`); - const request = new Request(apiUrl, options); + // Tidal can only handle max. 20 items to be added at once + // This isn't documented, but the API helpfully provides a useful error message + let chunkSize = 20; + const chunkedTracks: { id: string; type: string }[][] = []; + let chunkIndex = 0; + while (chunkIndex < tracks.length) { + chunkedTracks.push(tracks.slice(chunkIndex, chunkIndex + chunkSize)); + chunkIndex += chunkSize; + } + const apiUrl = new URL(`${this.apiBase}/playlists/${TIDAL_PLAYLIST_ID}/relationships/items`); + let options: RequestInit; + let request: Request; let resp: Response | null = null; let respTxt: string | null = null; - try { - resp = await fetch(request); - this.processTidalHeaders(resp.headers); - let respObj: TidalAddToPlaylistResponse | null = null; - // If the request was successful, a 201 with no content is received - // Errors will have content and a different status code - if (resp.status !== 201 && resp.status !== 429) { - respObj = await resp.json(); - } else { - respTxt = await resp.text(); - } - if (respObj !== null && respObj.errors) { - this.logger.error('Add to playlist failed', song.tidalUri, resp.status, respObj.errors); - if (resp.status === 401 || respObj.errors.some((x) => x.code === 'UNAUTHORIZED')) { - const token = await this.refreshToken(true); - if (token == null) { - return; + for (let chunk of chunkedTracks) { + options = { + method: 'POST', + headers: { + Authorization: `${token.token_type} ${token.access_token}`, + 'Content-Type': 'application/vnd.api+json', + Accept: 'application/vnd.api+json' + }, + body: JSON.stringify({ + data: chunk, + meta: { + positionBefore: 'ffb6286e-237a-4dfc-bbf1-2fb0eb004ed5' // Hardcoded last element of list } - this.addToPlaylistRetry(song, remaning--); + }) + }; + + request = new Request(apiUrl, options); + try { + resp = await fetch(request); + this.processTidalHeaders(resp.headers); + let respObj: TidalAddToPlaylistResponse | null = null; + // If the request was successful, a 201 with no content is received + // Errors will have content and a different status code + if (resp.status !== 201 && resp.status !== 429) { + respObj = await resp.json(); + } else { + respTxt = await resp.text(); } - } else if (respObj === null) { - switch (resp.status) { - case 201: - this.logger.info('Added to playlist', song.tidalUri, song.title); - break; - case 429: - let secondsToWait = -1; - const retryAfterHeader = resp.headers.get('Retry-After'); - if (retryAfterHeader) { - secondsToWait = parseInt(retryAfterHeader); - } else { - const remainingTokens = resp.headers.get('x-ratelimit-remaining'); - const requiredTokens = resp.headers.get('x-ratelimit-requested-tokens'); - const replenishRate = resp.headers.get('x-ratelimit-replenish-rate'); - if (remainingTokens !== null && requiredTokens !== null && replenishRate !== null) { - const remainingTokensValue = parseInt(remainingTokens); - const requiredTokensValue = parseInt(requiredTokens); - const replenishRateValue = parseInt(replenishRate); - const needToReplenish = requiredTokensValue - remainingTokensValue; - secondsToWait = 1 + needToReplenish / replenishRateValue; + if (respObj !== null && respObj.errors) { + this.logger.error('Add to playlist failed', song.tidalUri, resp.status, respObj.errors); + if (resp.status === 401 || respObj.errors.some((x) => x.code === 'UNAUTHORIZED')) { + const token = await this.refreshToken(true); + if (token == null) { + return; + } + await this.addToPlaylistRetry(song, remaning--); + } + } else if (respObj === null) { + switch (resp.status) { + case 201: + this.logger.info('Added to playlist', song.tidalUri, song.title); + break; + case 429: + let secondsToWait = -1; + const retryAfterHeader = resp.headers.get('Retry-After'); + if (retryAfterHeader) { + secondsToWait = parseInt(retryAfterHeader); + } else { + const remainingTokens = resp.headers.get('x-ratelimit-remaining'); + const requiredTokens = resp.headers.get('x-ratelimit-requested-tokens'); + const replenishRate = resp.headers.get('x-ratelimit-replenish-rate'); + if (remainingTokens !== null && requiredTokens !== null && replenishRate !== null) { + const remainingTokensValue = parseInt(remainingTokens); + const requiredTokensValue = parseInt(requiredTokens); + const replenishRateValue = parseInt(replenishRate); + const needToReplenish = requiredTokensValue - remainingTokensValue; + secondsToWait = 1 + needToReplenish / replenishRateValue; + } } - } - if (secondsToWait === -1) { - this.logger.warn('Could not read headers how long to wait', resp.headers); - } else { - this.logger.warn( - 'Received HTTP 429 Too Many Requests. Retrying in', - secondsToWait, - 'sec' - ); - // Try again secondsToWait sec later, just to be safe one additional second - setTimeout(() => { - this.addToPlaylistRetry(song, remaning--); - }, secondsToWait * 1000); - } - break; - default: - this.logger.warn('Unknown response', resp.status, respTxt); - break; + if (secondsToWait === -1) { + this.logger.warn('Could not read headers how long to wait', resp.headers); + } else { + this.logger.warn( + 'Received HTTP 429 Too Many Requests. Retrying in', + secondsToWait, + 'sec' + ); + // Try again secondsToWait sec later, just to be safe one additional second + await sleep(secondsToWait * 1000); + await this.addToPlaylistRetry(song, remaning--); + } + break; + default: + this.logger.warn('Unknown response', resp.status, respTxt); + break; + } + } else { + this.logger.info( + 'Add to playlist result is neither 201 nor error', + song.tidalUri, + song.title, + respObj + ); } - } else { - this.logger.info( - 'Add to playlist result is neither 201 nor error', - song.tidalUri, - song.title, - respObj - ); + } catch (e) { + this.logger.error('Add to playlist request failed', resp?.status, e); } - } catch (e) { - this.logger.error('Add to playlist request failed', resp?.status, e); } } public async addToPlaylist(song: SongInfo) {