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 {
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) {