tidal: chunk playlist adding, fix next-paging handling

This commit is contained in:
2025-07-15 14:22:45 +02:00
parent f309cd87d1
commit a9178b340a

View File

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