Add whole albums to playlist for tidal

This commit is contained in:
2025-07-14 14:24:21 +02:00
parent 68a139f287
commit 4c3689016f
2 changed files with 138 additions and 12 deletions

View File

@ -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
}

View File

@ -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;
};