fix(HiFi.ts): ensure only one token is fetched

If multiple calls to the HiFi methods were called at once, you could potentially have ended up with multiple simultaneous token api calls
This commit is contained in:
Daniel 2026-03-20 13:12:40 -05:00
parent a385cb558a
commit 5ac4d23199

View file

@ -20,8 +20,9 @@ export class TidalResponse extends Response {
export class HiFiClient { export class HiFiClient {
private static token: string | null; private static token: string | null;
private countryCode: string;
private static appTokenExpiry = 0; private static appTokenExpiry = 0;
private static tokenPromise: Promise<string> | null = null;
private countryCode: string;
private static albumTracksMax = 20; private static albumTracksMax = 20;
private static albumTracksActive = 0; private static albumTracksActive = 0;
private static albumTracksQueue: Array<() => void> = []; private static albumTracksQueue: Array<() => void> = [];
@ -35,7 +36,7 @@ export class HiFiClient {
return u.toString(); return u.toString();
} }
private encodeBasic(id: string, secret: string) { private static encodeBasic(id: string, secret: string) {
if (typeof window !== 'undefined' && typeof window.btoa === 'function') { if (typeof window !== 'undefined' && typeof window.btoa === 'function') {
return window.btoa(`${id}:${secret}`); return window.btoa(`${id}:${secret}`);
} }
@ -43,35 +44,41 @@ export class HiFiClient {
return Buffer.from(`${id}:${secret}`).toString('base64'); return Buffer.from(`${id}:${secret}`).toString('base64');
} }
private async fetchAppToken(signal: AbortSignal = new AbortController().signal): Promise<string> { private static async fetchAppToken(signal: AbortSignal = new AbortController().signal) {
const now = Date.now(); const now = Date.now();
if (HiFiClient.token && now < HiFiClient.appTokenExpiry) return HiFiClient.token; if (HiFiClient.token && now < HiFiClient.appTokenExpiry) return HiFiClient.token;
const res = await fetch('https://auth.tidal.com/v1/oauth2/token', { return await (HiFiClient.tokenPromise ??= (async () => {
method: 'POST', try {
headers: { const res = await fetch('https://auth.tidal.com/v1/oauth2/token', {
'content-type': 'application/x-www-form-urlencoded', method: 'POST',
authorization: `Basic ${this.encodeBasic(CLIENT_ID, CLIENT_SECRET)}`, headers: {
}, 'content-type': 'application/x-www-form-urlencoded',
body: new URLSearchParams({ authorization: `Basic ${this.encodeBasic(CLIENT_ID, CLIENT_SECRET)}`,
grant_type: 'client_credentials', },
client_id: CLIENT_ID, body: new URLSearchParams({
client_secret: CLIENT_SECRET, grant_type: 'client_credentials',
}), client_id: CLIENT_ID,
signal, client_secret: CLIENT_SECRET,
}); }),
signal,
});
if (!res.ok) { if (!res.ok) {
const txt = await res.text().catch(() => ''); const txt = await res.text().catch(() => '');
throw new Error(`Failed to obtain app token: ${res.status} ${txt}`); throw new Error(`Failed to obtain app token: ${res.status} ${txt}`);
} }
const json = await res.json(); const json = await res.json();
const token = json.access_token; const token = json.access_token;
const expires_in = json.expires_in ?? 3600; const expires_in = json.expires_in ?? 3600;
HiFiClient.token = token; HiFiClient.token = token;
HiFiClient.appTokenExpiry = Date.now() + expires_in * 1000 - 60_000; HiFiClient.appTokenExpiry = Date.now() + expires_in * 1000 - 60_000;
return token; return token;
} finally {
HiFiClient.tokenPromise = null;
}
})());
} }
constructor(countryCode = 'US') { constructor(countryCode = 'US') {
@ -81,7 +88,7 @@ export class HiFiClient {
private async fetchJson(url: string, params?: Params, signal: AbortSignal = new AbortController().signal) { private async fetchJson(url: string, params?: Params, signal: AbortSignal = new AbortController().signal) {
const final = HiFiClient.buildUrl(url, params); const final = HiFiClient.buildUrl(url, params);
const res = await fetch(final, { const res = await fetch(final, {
headers: { authorization: `Bearer ${await this.fetchAppToken(signal)}` }, headers: { authorization: `Bearer ${await HiFiClient.fetchAppToken(signal)}` },
signal, signal,
}); });