diff --git a/src/lib/server/playlist/tidalPlaylistAdder.ts b/src/lib/server/playlist/tidalPlaylistAdder.ts index 7818073..9b46d79 100644 --- a/src/lib/server/playlist/tidalPlaylistAdder.ts +++ b/src/lib/server/playlist/tidalPlaylistAdder.ts @@ -5,8 +5,9 @@ import type { SongInfo } from '$lib/odesliResponse'; import { createHash } from 'crypto'; import { OauthPlaylistAdder } from './oauthPlaylistAdder'; import type { PlaylistAdder } from './playlistAdder'; -import type { TidalAddToPlaylistResponse } from './tidalResponse'; +import type { TidalAddToPlaylistResponse, TidalAlbumResponse } from './tidalResponse'; import { doesTidalSongExist } from '$lib/server/db'; +import { sleep } from '$lib/sleep'; export class TidalPlaylistAdder extends OauthPlaylistAdder implements PlaylistAdder { private static code_verifier?: string; @@ -98,6 +99,101 @@ export class TidalPlaylistAdder extends OauthPlaylistAdder implements PlaylistAd } } + private async getAlbumItems( + albumId: string, + remaning: number = 3, + nextLink?: string + ): Promise<{ id: string; type: string }[]> { + this.logger.debug('getAlbumItems', albumId, remaning, nextLink); + if (remaning <= 0) { + return []; + } + + const token = await this.refreshToken(); + if (token == null) { + return []; + } + try { + let albumUrl: URL; + if (nextLink) { + albumUrl = new URL(nextLink); + } else { + albumUrl = new URL(`${this.apiBase}/albums/${albumId}/relationships/items`); + albumUrl.searchParams.append('countryCode', 'DE'); + } + + const options: RequestInit = { + method: 'GET', + headers: { + Authorization: `${token.token_type} ${token.access_token}`, + Accept: 'application/vnd.api+json' + } + }; + const request = new Request(albumUrl, options); + const resp = await fetch(request); + this.processTidalHeaders(resp.headers); + if (resp.ok) { + const respData: TidalAlbumResponse = await resp.json(); + if (respData.data !== undefined) { + let tracks = respData.data.map((x) => { + return { id: x.id, type: x.type }; + }); + if (respData.links?.next) { + const nextPage = await this.getAlbumItems(albumId, remaning, respData.links.next); + tracks = tracks.concat(nextPage); + } + return tracks; + } else { + this.logger.error('Error response for album', respData.errors, respData); + return []; + } + } else if (resp.status === 429) { + // Tidal docs say, this endpoint uses Retry-After header, + // but other endpoints use x-rate-limit headers. Check both + let secondsToWait = 0; + 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 === 0) { + // Try again secondsToWait sec later, just to be safe one additional second + this.logger.warn( + 'Received HTTP 429 Too Many Requests. Retrying in', + secondsToWait, + 'sec' + ); + await sleep(secondsToWait * 1000); + return await this.getAlbumItems(albumId, remaning - 1, nextLink); + } else { + this.logger.warn( + 'Received HTTP 429 Too Many Requests, but no instructions on how long to wait. Aborting', + secondsToWait, + 'sec' + ); + return []; + } + } else { + const respText = await resp.text(); + this.logger.error('Cannot check album contents', resp.status, respText); + return []; + } + } catch (e) { + this.logger.error('Error checking album contents', e); + return []; + } + } + private async addToPlaylistRetry(song: SongInfo, remaning: number = 3) { if (remaning < 0) { this.logger.error('max retries reached, song will not be added to playlist'); @@ -118,6 +214,20 @@ export class TidalPlaylistAdder extends OauthPlaylistAdder implements PlaylistAd return; } + let tracks = [ + { + id: song.tidalUri, + type: 'tracks' + } + ]; + if (song.type === 'album') { + tracks = await this.getAlbumItems(song.tidalUri); + this.logger.debug('received tracks', tracks); + if (tracks.length === 0) { + return; + } + } + const alreadyExists = await doesTidalSongExist(song); try { if (alreadyExists) { @@ -137,12 +247,7 @@ export class TidalPlaylistAdder extends OauthPlaylistAdder implements PlaylistAd Accept: 'application/vnd.api+json' }, body: JSON.stringify({ - data: [ - { - id: song.tidalUri, - type: 'tracks' - } - ], + data: tracks, meta: { positionBefore: 'ffb6286e-237a-4dfc-bbf1-2fb0eb004ed5' // Hardcoded last element of list } diff --git a/src/lib/server/playlist/tidalResponse.ts b/src/lib/server/playlist/tidalResponse.ts index ff95457..d31ae77 100644 --- a/src/lib/server/playlist/tidalResponse.ts +++ b/src/lib/server/playlist/tidalResponse.ts @@ -2,21 +2,23 @@ export type TidalAddToPlaylistResponse = { errors: TidalAddToPlaylistError[]; }; -export type TidalAddToPlaylistError = { +export type TidalAddToPlaylistError = TidalError & { id: string; status: number; - code: TidalErrorCode; - detail: string; source: TidalAddToPlaylistErrorSource; - meta: TidalAddToPlaylistErrorMeta; }; export type TidalAddToPlaylistErrorSource = { parameter: string; }; -export type TidalAddToPlaylistErrorMeta = { +export type TidalErrorMeta = { category: string; }; +export type TidalError = { + code: string; + detail: string; + meta: TidalErrorMeta; +}; export type TidalErrorCode = | 'INVALID_ENUM_VALUE' | 'VALUE_REGEX_MISMATCH' @@ -27,3 +29,22 @@ export type TidalErrorCode = | 'UNAVAILABLE_FOR_LEGAL_REASONS_RESPONSE' | 'INTERNAL_SERVER_ERROR' | 'UNAUTHORIZED'; + +export type TidalAlbumResponse = { + data?: TidalAlbumTrack[]; + links?: { + next?: string; + self: string; + }; + errors?: TidalError[]; +}; +export type TidalAlbumTrack = { + id: string; + type: 'tracks' | string; + meta: TidalAlbumTrackMeta; +}; + +export type TidalAlbumTrackMeta = { + trackNumber: number; + volumeNumber: number; +};