From f2b8cdc812c2522eabe3c2619bb8e815e9129d1e Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:52:07 -0500 Subject: [PATCH 1/4] feat(downloads): add metadata to videos --- js/api.js | 100 +++++++++++++++++++++++++------------------------ js/metadata.js | 6 +-- 2 files changed, 55 insertions(+), 51 deletions(-) diff --git a/js/api.js b/js/api.js index 70b9138..0964358 100644 --- a/js/api.js +++ b/js/api.js @@ -15,7 +15,7 @@ import { APICache } from './cache.js'; import { DashDownloader } from './dash-downloader.ts'; import { HlsDownloader } from './hls-downloader.js'; import { MP3EncodingError } from './mp3-encoder.js'; -import { loadFfmpeg, FfmpegError } from './ffmpeg.js'; +import { loadFfmpeg, FfmpegError, ffmpeg } from './ffmpeg.js'; import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts'; import { isCustomFormat } from './ffmpegFormats.ts'; import { DownloadProgress } from './progressEvents.js'; @@ -1504,59 +1504,63 @@ export class LosslessAPI { if (!isVideo) { blob = await applyAudioPostProcessing(blob, quality, onProgress, options.signal); + } - // Add metadata if track information is provided - if (track) { - onProgress?.({ - stage: 'processing', - message: 'Adding metadata...', - }); + // Add metadata if track information is provided + if (track) { + onProgress?.({ + stage: 'processing', + message: 'Adding metadata...', + }); - const enrichedTrack = { ...track }; - if (lookup.info) { - enrichedTrack.replayGain = { - trackReplayGain: lookup.info.trackReplayGain, - trackPeakAmplitude: lookup.info.trackPeakAmplitude, - albumReplayGain: lookup.info.albumReplayGain, - albumPeakAmplitude: lookup.info.albumPeakAmplitude, - }; - } + const enrichedTrack = { ...track }; + if (lookup.info) { + enrichedTrack.replayGain = { + trackReplayGain: lookup.info.trackReplayGain, + trackPeakAmplitude: lookup.info.trackPeakAmplitude, + albumReplayGain: lookup.info.albumReplayGain, + albumPeakAmplitude: lookup.info.albumPeakAmplitude, + }; + } - if ( - track.album?.id && - (track.album?.totalDiscs == null || track.album?.numberOfTracksOnDisc == null) - ) { - try { - const albumData = await this.getAlbum(track.album.id); - if (albumData.tracks?.length > 0) { - const discTrackCounts = new Map(); - let maxDiscNumber = 0; - for (const t of albumData.tracks) { - const dn = getTrackDiscNumber(t); - discTrackCounts.set(dn, (discTrackCounts.get(dn) || 0) + 1); - if (dn > maxDiscNumber) maxDiscNumber = dn; - } - const totalDiscs = maxDiscNumber || 1; - const discNumber = getTrackDiscNumber(track); - enrichedTrack.album = { - ...(enrichedTrack.album || {}), - totalDiscs: track.album?.totalDiscs ?? totalDiscs, - numberOfTracksOnDisc: - track.album?.numberOfTracksOnDisc ?? discTrackCounts.get(discNumber), - }; - } - } catch (e) { - console.warn('Failed to fetch album for disc info:', e); - } - } - - onProgress?.(new DownloadProgress('Adding metadata')); + if (track.album?.id && (track.album?.totalDiscs == null || track.album?.numberOfTracksOnDisc == null)) { try { - blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, prefetchPromises); - } catch (err) { - console.error(err); + const albumData = await this.getAlbum(track.album.id); + if (albumData.tracks?.length > 0) { + const discTrackCounts = new Map(); + let maxDiscNumber = 0; + for (const t of albumData.tracks) { + const dn = getTrackDiscNumber(t); + discTrackCounts.set(dn, (discTrackCounts.get(dn) || 0) + 1); + if (dn > maxDiscNumber) maxDiscNumber = dn; + } + const totalDiscs = maxDiscNumber || 1; + const discNumber = getTrackDiscNumber(track); + enrichedTrack.album = { + ...(enrichedTrack.album || {}), + totalDiscs: track.album?.totalDiscs ?? totalDiscs, + numberOfTracksOnDisc: + track.album?.numberOfTracksOnDisc ?? discTrackCounts.get(discNumber), + }; + } + } catch (e) { + console.warn('Failed to fetch album for disc info:', e); } } + + onProgress?.(new DownloadProgress('Adding metadata')); + try { + if (isVideo) { + blob = new File( + [await ffmpeg(blob, ['-c', 'copy'], 'output.mp4', 'video/mp4', onProgress, options.signal)], + 'output.mp4', + { type: 'video/mp4' } + ); + } + blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, prefetchPromises); + } catch (err) { + console.error(err); + } } if (options.triggerDownload ?? true) { diff --git a/js/metadata.js b/js/metadata.js index c296c7c..336030c 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -34,12 +34,12 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet try { data.title = getTrackTitle(track); data.artist = getFullArtistString(track); - data.albumTitle = track.album.title; + data.albumTitle = track.album?.title; data.albumArtist = track.album?.artist?.name || track.artist?.name; data.trackNumber = track.trackNumber; data.discNumber = track.volumeNumber ?? track.discNumber; - data.totalTracks = track.album.numberOfTracksOnDisc ?? track.album.numberOfTracks; - data.totalDiscs = track.album.totalDiscs; + data.totalTracks = track.album?.numberOfTracksOnDisc ?? track.album?.numberOfTracks; + data.totalDiscs = track.album?.totalDiscs; data.copyright = track.copyright; data.isrc = track.isrc; data.explicit = Boolean(track.explicit); From a385cb558aad570f6c0be59eadaf4e20a0f3a747 Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:02:48 -0500 Subject: [PATCH 2/4] fix(api): use an instance for `/recommendations` --- js/api.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/js/api.js b/js/api.js index 0964358..4d906cf 100644 --- a/js/api.js +++ b/js/api.js @@ -56,11 +56,14 @@ export class LosslessAPI { async fetchWithRetry(relativePath, options = {}) { const type = options.type || 'api'; - const instanceRoutes = ['/track', '/album/similar', '/artist/similar', '/video']; + const instanceRoutes = ['/track', '/album/similar', '/artist/similar', '/video', '/recommendations']; if (window.allTidal == true || !instanceRoutes.some((route) => relativePath.startsWith(route))) { try { - console.log(relativePath); + if (import.meta.env.DEV) { + console.log(relativePath); + } + return await client.queryResponse(relativePath); } catch (err) { console.warn( From 5ac4d23199c3297e269d177e5ce583ded1cff000 Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:12:40 -0500 Subject: [PATCH 3/4] 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 --- js/HiFi.ts | 61 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/js/HiFi.ts b/js/HiFi.ts index 0cc222a..1a52e79 100644 --- a/js/HiFi.ts +++ b/js/HiFi.ts @@ -20,8 +20,9 @@ export class TidalResponse extends Response { export class HiFiClient { private static token: string | null; - private countryCode: string; private static appTokenExpiry = 0; + private static tokenPromise: Promise | null = null; + private countryCode: string; private static albumTracksMax = 20; private static albumTracksActive = 0; private static albumTracksQueue: Array<() => void> = []; @@ -35,7 +36,7 @@ export class HiFiClient { return u.toString(); } - private encodeBasic(id: string, secret: string) { + private static encodeBasic(id: string, secret: string) { if (typeof window !== 'undefined' && typeof window.btoa === 'function') { return window.btoa(`${id}:${secret}`); } @@ -43,35 +44,41 @@ export class HiFiClient { return Buffer.from(`${id}:${secret}`).toString('base64'); } - private async fetchAppToken(signal: AbortSignal = new AbortController().signal): Promise { + private static async fetchAppToken(signal: AbortSignal = new AbortController().signal) { const now = Date.now(); if (HiFiClient.token && now < HiFiClient.appTokenExpiry) return HiFiClient.token; - const res = await fetch('https://auth.tidal.com/v1/oauth2/token', { - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - authorization: `Basic ${this.encodeBasic(CLIENT_ID, CLIENT_SECRET)}`, - }, - body: new URLSearchParams({ - grant_type: 'client_credentials', - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, - }), - signal, - }); + return await (HiFiClient.tokenPromise ??= (async () => { + try { + const res = await fetch('https://auth.tidal.com/v1/oauth2/token', { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + authorization: `Basic ${this.encodeBasic(CLIENT_ID, CLIENT_SECRET)}`, + }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + }), + signal, + }); - if (!res.ok) { - const txt = await res.text().catch(() => ''); - throw new Error(`Failed to obtain app token: ${res.status} ${txt}`); - } + if (!res.ok) { + const txt = await res.text().catch(() => ''); + throw new Error(`Failed to obtain app token: ${res.status} ${txt}`); + } - const json = await res.json(); - const token = json.access_token; - const expires_in = json.expires_in ?? 3600; - HiFiClient.token = token; - HiFiClient.appTokenExpiry = Date.now() + expires_in * 1000 - 60_000; - return token; + const json = await res.json(); + const token = json.access_token; + const expires_in = json.expires_in ?? 3600; + HiFiClient.token = token; + HiFiClient.appTokenExpiry = Date.now() + expires_in * 1000 - 60_000; + return token; + } finally { + HiFiClient.tokenPromise = null; + } + })()); } constructor(countryCode = 'US') { @@ -81,7 +88,7 @@ export class HiFiClient { private async fetchJson(url: string, params?: Params, signal: AbortSignal = new AbortController().signal) { const final = HiFiClient.buildUrl(url, params); const res = await fetch(final, { - headers: { authorization: `Bearer ${await this.fetchAppToken(signal)}` }, + headers: { authorization: `Bearer ${await HiFiClient.fetchAppToken(signal)}` }, signal, }); From 5d697760d09079c40da99ee244fbe7a9a16083e7 Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:19:15 -0500 Subject: [PATCH 4/4] fix(HiFi.ts): cache token --- js/HiFi.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/js/HiFi.ts b/js/HiFi.ts index 1a52e79..a34dc4a 100644 --- a/js/HiFi.ts +++ b/js/HiFi.ts @@ -46,6 +46,9 @@ export class HiFiClient { private static async fetchAppToken(signal: AbortSignal = new AbortController().signal) { const now = Date.now(); + HiFiClient.token ??= localStorage.getItem('hifi_token') || null; + HiFiClient.appTokenExpiry = Number(localStorage.getItem('hifi_token_expiry') || '0'); + if (HiFiClient.token && now < HiFiClient.appTokenExpiry) return HiFiClient.token; return await (HiFiClient.tokenPromise ??= (async () => { @@ -74,6 +77,9 @@ export class HiFiClient { const expires_in = json.expires_in ?? 3600; HiFiClient.token = token; HiFiClient.appTokenExpiry = Date.now() + expires_in * 1000 - 60_000; + localStorage.setItem('hifi_token', token); + localStorage.setItem('hifi_token_expiry', HiFiClient.appTokenExpiry.toString()); + return token; } finally { HiFiClient.tokenPromise = null;