From df35c48e8c52bac54fc83af3546dacb01d2af844 Mon Sep 17 00:00:00 2001 From: Max Nuding Date: Fri, 11 Jul 2025 14:37:57 +0200 Subject: [PATCH] wait before retrying tidal requests --- src/lib/server/playlist/oauthPlaylistAdder.ts | 38 ++++++++-- src/lib/server/playlist/tidalPlaylistAdder.ts | 70 +++++++++++++++++-- 2 files changed, 98 insertions(+), 10 deletions(-) diff --git a/src/lib/server/playlist/oauthPlaylistAdder.ts b/src/lib/server/playlist/oauthPlaylistAdder.ts index 5e3c3e4..76e08fd 100644 --- a/src/lib/server/playlist/oauthPlaylistAdder.ts +++ b/src/lib/server/playlist/oauthPlaylistAdder.ts @@ -145,7 +145,27 @@ export abstract class OauthPlaylistAdder { redirect_uri?: string, client_secret?: string, customHeader?: HeadersInit - ) { + ): Promise { + return ( + await this.requestRefreshTokenWithHeaders( + tokenUrl, + clientId, + refresh_token, + redirect_uri, + client_secret, + customHeader + ) + ).resp; + } + + protected async requestRefreshTokenWithHeaders( + tokenUrl: URL, + clientId: string, + refresh_token: string, + redirect_uri?: string, + client_secret?: string, + customHeader?: HeadersInit + ): Promise<{ resp: OauthResponse | null; headers: Headers }> { const params = new URLSearchParams(); params.append('client_id', clientId); params.append('grant_type', 'refresh_token'); @@ -157,15 +177,20 @@ export abstract class OauthPlaylistAdder { params.append('redirect_uri', redirect_uri); } this.logger.debug('sending token req', params); - const resp: OauthResponse = await fetch(tokenUrl, { + const response = await fetch(tokenUrl, { method: 'POST', body: params, headers: customHeader - }).then((r) => r.json()); + }); + + const resp: OauthResponse = await response.json(); this.logger.verbose('received access token', resp); if (resp.error) { this.logger.error('token resp error', resp); - return null; + return { + resp: null, + headers: response.headers + }; } if (!resp.refresh_token) { resp.refresh_token = refresh_token; @@ -175,6 +200,9 @@ export abstract class OauthPlaylistAdder { expiration.setSeconds(expiration.getSeconds() + resp.expires_in); resp.expires = expiration; await fs.writeFile(this.token_file_name, JSON.stringify(resp)); - return resp; + return { + resp: resp, + headers: response.headers + }; } } diff --git a/src/lib/server/playlist/tidalPlaylistAdder.ts b/src/lib/server/playlist/tidalPlaylistAdder.ts index f834008..6ab4f14 100644 --- a/src/lib/server/playlist/tidalPlaylistAdder.ts +++ b/src/lib/server/playlist/tidalPlaylistAdder.ts @@ -14,6 +14,8 @@ export class TidalPlaylistAdder extends OauthPlaylistAdder implements PlaylistAd super('https://openapi.tidal.com/v2', 'tidal_auth_token'); //super('https://api.tidal.com/v2', 'tidal_auth_token'); this.logger = new Logger('TidalPlaylistAdder'); + // Tidal aggressively rate-limits, so reduce the number of refreshing requests + this.refresh_time = 3; } public constructAuthUrl(redirectUri: URL): URL { @@ -67,7 +69,32 @@ export class TidalPlaylistAdder extends OauthPlaylistAdder implements PlaylistAd } const tokenUrl = new URL('https://auth.tidal.com/v1/oauth2/token'); - return await this.requestRefreshToken(tokenUrl, TIDAL_CLIENT_ID, token.refresh_token); + const response = await this.requestRefreshTokenWithHeaders( + tokenUrl, + TIDAL_CLIENT_ID, + token.refresh_token + ); + this.processTidalHeaders(response.headers); + return response.resp; + } + + private processTidalHeaders(headers: Headers) { + const remainingTokens = headers.get('x-ratelimit-remaining'); + const requiredTokens = headers.get('x-ratelimit-requested-tokens'); + const replenishRate = headers.get('x-ratelimit-replenish-rate'); + if (remainingTokens !== null && replenishRate !== null) { + const remainingTokensValue = parseInt(remainingTokens); + const replenishRateValue = parseInt(replenishRate); + let requiredTokensValue = parseInt(requiredTokens ?? '-1'); + this.logger.debug( + 'Tidal rate limit. Remaining', + remainingTokensValue, + 'reuqired for last request', + requiredTokensValue, + 'replenish rate', + replenishRateValue + ); + } } private async addToPlaylistRetry(song: SongInfo, remaning: number = 3) { @@ -112,7 +139,6 @@ export class TidalPlaylistAdder extends OauthPlaylistAdder implements PlaylistAd }; const apiUrl = new URL(`${this.apiBase}/playlists/${TIDAL_PLAYLIST_ID}/relationships/items`); const request = new Request(apiUrl, options); - this.logger.debug('Adding to playlist request', request); // This would be API v1 (or api v2, but *not* the OpenAPI v2), // but that requires r_usr and w_usr permission scopes which are impossible to request @@ -150,13 +176,17 @@ export class TidalPlaylistAdder extends OauthPlaylistAdder implements PlaylistAd */ 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) { + 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); @@ -167,8 +197,38 @@ export class TidalPlaylistAdder extends OauthPlaylistAdder implements PlaylistAd } this.addToPlaylistRetry(song, remaning--); } - } else if (respObj === null && resp.status === 201) { - this.logger.info('Added to playlist', song.tidalUri, song.title); + } else if (respObj === null) { + switch (resp.status) { + case 201: + this.logger.info('Added to playlist', song.tidalUri, song.title); + break; + case 429: + 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; + const secondsToWait = 1 + needToReplenish / replenishRateValue; + 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); + } else { + this.logger.warn('Could not read headers how long to wait', resp.headers); + } + break; + default: + this.logger.warn('Unknown response', resp.status, respTxt); + break; + } } else { this.logger.info( 'Add to playlist result is neither 201 nor error',