import { randomUUID } from 'node:crypto' import { mkdir, rm, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { asyncPool, AsyncQueue, base64, todo, unknownToError, utf8 } from '@fuman/utils' import Spinnies from 'spinnies' import { z } from 'zod' import { $, question } from 'zx' import { ffetch as ffetchBase } from '../../utils/fetch.ts' import { sanitizeFilename } from '../../utils/fs.ts' import { pipeIntoProc, runMetaflac, writeIntoProc } from '../../utils/media-metadata.ts' import { getEnv } from '../../utils/misc.ts' import { concatMpdSegments, parseSimpleMpd } from '../../utils/mpd.ts' import { createLibcurlFetch } from '../../utils/temkakit/libcurl.ts' const oauthResponse = await ffetchBase('https://auth.tidal.com/v1/oauth2/token', { form: { client_id: '49YxDN9a2aFV6RTG', grant_type: 'refresh_token', scope: 'r_usr w_usr', refresh_token: getEnv('TIDAL_REFRESH_TOKEN'), }, }).parsedJson(z.object({ access_token: z.string(), user: z.object({ username: z.string(), countryCode: z.string(), }), })) console.log('Logged in as %s', oauthResponse.user.username) const ffetch = ffetchBase.extend({ headers: { 'accept': '*/*', 'Authorization': `Bearer ${oauthResponse.access_token}`, 'accept-language': 'en-US,en;q=0.5', 'accept-encoding': 'gzip, deflate, br', 'referer': 'https://tidal.com/', 'origin': 'https://tidal.com', 'sec-fetch-dest': 'empty', 'sec-fetch-mode': 'cors', 'sec-fetch-site': 'same-origin', }, // for some reason the request sometimes hangs indefinitely, so we need to timeout timeout: 5000, retry: { maxRetries: 3, // onError: (err, req) => { // console.log('%s: error: %s', req.url, err) // return true // }, }, }) const PlaybackInfoResult = z.object({ albumPeakAmplitude: z.number(), albumReplayGain: z.number(), assetPresentation: z.string(), audioMode: z.string(), audioQuality: z.enum(['HIGH', 'LOSSLESS', 'HI_RES_LOSSLESS']), bitDepth: z.number(), manifest: z.string(), manifestHash: z.string(), manifestMimeType: z.literal('application/dash+xml'), sampleRate: z.number(), streamingSessionId: z.string(), trackId: z.number(), trackPeakAmplitude: z.number(), trackReplayGain: z.number(), }) const streamingSessionId = randomUUID() const TidalTrack = z.object({ id: z.number(), album: z.object({ id: z.number(), cover: z.string(), }), artists: z.array(z.object({ id: z.number(), name: z.string(), })), isrc: z.string().nullable(), trackNumber: z.number(), volumeNumber: z.number(), title: z.string(), copyright: z.string().nullable(), version: z.string().nullable(), bpm: z.number().nullable(), }) type TidalTrack = z.infer function getTrackName(track: TidalTrack) { let name = track.title if (track.version) { name += ` ${track.version}` } return name } function getTrackArtistString(track: TidalTrack | TidalAlbum) { return track.artists.map(it => it.name).join(', ') } function getAlbumCoverUrl(uuid: string) { return `https://resources.tidal.com/images/${uuid.replace(/-/g, '/')}/1280x1280.jpg` } const TidalAlbum = z.object({ id: z.number(), title: z.string(), cover: z.string(), releaseDate: z.string(), artists: z.array(z.object({ id: z.number(), name: z.string(), })), }) type TidalAlbum = z.infer const COMMON_QUERY = { countryCode: oauthResponse.user.countryCode, locale: 'en_US', deviceType: 'BROWSER', } async function downloadTrack(options: { track: TidalTrack album: TidalAlbum albumCoverPath?: string destination: string }) { const { track, album, albumCoverPath, destination } = options const [playbackRes, lyricsRes, creditsRes] = [ await ffetch(`https://tidal.com/v1/tracks/${track.id}/playbackinfo`, { query: { audioquality: 'HI_RES_LOSSLESS', playbackmode: 'STREAM', assetpresentation: 'FULL', }, headers: { 'x-tidal-streamingsessionid': streamingSessionId, 'x-tidal-token': '49YxDN9a2aFV6RTG', }, }).parsedJson(PlaybackInfoResult), await ffetch(`https://tidal.com/v1/tracks/${track.id}/lyrics`, { query: { ...COMMON_QUERY, }, }).parsedJson(z.object({ lyrics: z.string(), // subtitles = timestamped lyrics subtitles: z.string().nullable(), })).catch(() => null), await ffetch(`https://tidal.com/v1/tracks/${track.id}/credits`, { query: { limit: 100, includeContributors: true, ...COMMON_QUERY, }, }).parsedJson(z.array(z.object({ type: z.string(), contributors: z.array(z.object({ name: z.string(), })), }))), ] const manifest = base64.decode(playbackRes.manifest) const ext = playbackRes.audioQuality === 'HIGH' ? 'm4a' : 'flac' const destFile = `${destination}.${ext}` await mkdir(dirname(destFile), { recursive: true }) const lyricsLrc = lyricsRes ? lyricsRes.subtitles ?? lyricsRes.lyrics : undefined const keyedCredits = creditsRes ? Object.fromEntries(creditsRes.map(it => [it.type, it.contributors.map(it => it.name)])) : undefined const params: string[] = [ '-y', '-i', 'pipe:0', '-c', 'copy', '-loglevel', 'error', '-hide_banner', destFile, ] const proc = $`ffmpeg ${params}` await pipeIntoProc(proc, concatMpdSegments({ mpd: parseSimpleMpd(utf8.decoder.decode(manifest)), fetch: async url => new Uint8Array(await ffetch(url).arrayBuffer()), })) await proc if (ext === 'flac') { await runMetaflac({ path: destFile, tags: { TITLE: getTrackName(track), ALBUM: album.title, DATE: album.releaseDate, DISCNUMBER: track.volumeNumber, TRACKNUMBER: track.trackNumber, COMMENT: `ripped from tidal (id: ${track.id})`, ARTIST: track.artists.map(it => it.name), COPYRIGHT: track.copyright, LYRICS: lyricsLrc, REPLAYGAIN_ALBUM_GAIN: playbackRes.albumReplayGain, REPLAYGAIN_ALBUM_PEAK: playbackRes.albumPeakAmplitude, REPLAYGAIN_TRACK_GAIN: playbackRes.trackReplayGain, REPLAYGAIN_TRACK_PEAK: playbackRes.trackPeakAmplitude, PRODUCER: keyedCredits?.Producer, COMPOSER: keyedCredits?.Composer, LYRICIST: keyedCredits?.Lyricist, PERFORMER: keyedCredits?.['Vocal accompaniment']?.map(it => `${it} (Vocal)`), ISRC: track.isrc, BPM: track.bpm, }, coverPath: albumCoverPath, }) } else { console.log('warn: m4a tagging not yet implemented') } } async function fetchPaginated(params: { initialOffset?: number fetch: (offset: number) => Promise<{ items: T[], hasMore: boolean }> }): Promise { let offset = params.initialOffset ?? 0 const items: T[] = [] while (true) { const res = await params.fetch(offset) for (const item of res.items) { items.push(item) } if (!res.hasMore) break offset += res.items.length } return items } async function fetchAlbumTracks(albumId: number) { return fetchPaginated({ fetch: async (offset) => { const res = await ffetch(`https://tidal.com/v1/albums/${albumId}/items`, { query: { ...COMMON_QUERY, replace: true, offset, limit: 100, } }).parsedJson(z.object({ items: z.array(z.object({ item: TidalTrack, type: z.literal('track'), })), totalNumberOfItems: z.number(), })) return { items: res.items.map(it => it.item), hasMore: res.totalNumberOfItems > offset + res.items.length, } }, }) } async function downloadTrackList(opts: { tracks: TidalTrack[] albums: Map albumCoverPaths: Map destination: string includeTrackNumber?: boolean onDownloadStart?: (track: TidalTrack) => void onDownloadEnd?: (track: TidalTrack, error: Error | null) => void }) { await mkdir(opts.destination, { recursive: true }) const isMultiDisc = opts.tracks.some(it => it.volumeNumber !== 1) const firstTrackArtistString = getTrackArtistString(opts.tracks[0]) const isDifferentArtists = opts.tracks.some(it => getTrackArtistString(it) !== firstTrackArtistString) const retries = new Map() const queue = new AsyncQueue(opts.tracks) let finished = 0 await asyncPool(queue, async (track, idx) => { let filename = '' if (opts.includeTrackNumber) { if (isMultiDisc) { filename = `${track.volumeNumber}-` } filename = `${track.trackNumber.toString().padStart(2, '0')}. ` } if (isDifferentArtists) { filename += `${getTrackArtistString(track)} - ` } filename += `${getTrackName(track)}` const filenamePath = join(opts.destination, sanitizeFilename(filename)) try { opts.onDownloadStart?.(track) await downloadTrack({ track, album: opts.albums.get(track.album.id)!, albumCoverPath: opts.albumCoverPaths.get(track.album.id)!, destination: filenamePath, }) opts.onDownloadEnd?.(track, null) } catch (e) { opts.onDownloadEnd?.(track, unknownToError(e)) const n = retries.get(track.id) ?? 0 if (n < 3) { retries.set(track.id, n + 1) queue.enqueue(track) return } } finished += 1 if (finished === opts.tracks.length) { queue.end() } }, { limit: 8 }) } async function downloadAlbum(album: TidalAlbum | number) { const [albumRes, albumTracks] = await Promise.all([ typeof album === 'number' ? ffetch(`https://tidal.com/v1/albums/${album}`, { query: COMMON_QUERY }).parsedJson(TidalAlbum) : Promise.resolve(album), fetchAlbumTracks(typeof album === 'number' ? album : album.id), ]) console.log(`downloading album ${albumRes.title} with ${albumTracks.length} tracks`) const outDir = join('assets/tidal-dl', `${getTrackArtistString(albumRes)} - ${sanitizeFilename(albumRes.title)}`) await mkdir(outDir, { recursive: true }) const albumCoverRes = await ffetch(getAlbumCoverUrl(albumRes.cover)).arrayBuffer() await writeFile(join(outDir, 'cover.jpg'), new Uint8Array(albumCoverRes)) const spinnies = new Spinnies() spinnies.add('download', { text: 'downloading album...' }) const errors = new Map() await downloadTrackList({ tracks: albumTracks, albums: new Map([[albumRes.id, albumRes]]), albumCoverPaths: new Map([[albumRes.id, join(outDir, 'cover.jpg')]]), destination: outDir, includeTrackNumber: true, onDownloadStart(track) { spinnies.add(`${track.id}`, { text: getTrackName(track) }) errors.delete(track.id) }, onDownloadEnd(track, error) { spinnies.remove(`${track.id}`) if (error) { errors.set(track.id, error) } spinnies.remove(`${track.id}`) }, }) spinnies.succeed('download', { text: 'downloaded album' }) if (errors.size) { console.error('errors:') for (const [id, error] of errors) { console.error(` ${id}: ${error.message}`) } } } const url = process.argv[2] ?? await question('url or search > ') /* eslint-disable no-cond-assign */ let m if ((m = url.match(/\/track\/(\d+)/))) { const track = await ffetch(`https://tidal.com/v1/tracks/${m[1]}`, { query: COMMON_QUERY }) .parsedJson(TidalTrack) const [albumRes, albumCoverRes] = await Promise.all([ ffetch(`https://tidal.com/v1/albums/${track.album.id}`, { query: COMMON_QUERY }).parsedJson(TidalAlbum), ffetch(getAlbumCoverUrl(track.album.cover)).arrayBuffer(), ]) const tmpAlbumCoverPath = join(`assets/tidal-${track.album.cover}.jpg`) await writeFile(tmpAlbumCoverPath, new Uint8Array(albumCoverRes)) await downloadTrack({ track, album: albumRes, albumCoverPath: tmpAlbumCoverPath, destination: join('assets/tidal-dl', sanitizeFilename(`${getTrackArtistString(track)} - ${getTrackName(track)}`)), }) await rm(tmpAlbumCoverPath) } else if ((m = url.match(/\/album\/(\d+)/))) { await downloadAlbum(m[1]) } else if ((m = url.match(/\/artist\/(\d+)/))) { const withAppearsOn = (await question('include appears on albums? (y/N) > ')).toLowerCase() === 'y' function fetchAlbumList(type: string): Promise { return fetchPaginated({ fetch: async (offset) => { const r = await ffetch(`https://tidal.com/v2/artist/${type}/view-all`, { query: { itemId: m[1], ...COMMON_QUERY, platform: 'WEB', limit: 50, offset, }, headers: { 'x-tidal-client-version': '2025.10.29', }, }).parsedJson(z.object({ items: z.array(z.object({ type: z.literal('ALBUM'), data: TidalAlbum, })), })) return { items: r.items.map(it => it.data), hasMore: r.items.length === 50, } }, }) } const [albums, singles, appearsOn] = await Promise.all([ fetchAlbumList('ARTIST_ALBUMS'), fetchAlbumList('ARTIST_TOP_SINGLES'), withAppearsOn ? fetchAlbumList('ARTIST_APPEARS_ON') : Promise.resolve([]), ]) // concat and dedupe const seenIds = new Set() const allAlbums: TidalAlbum[] = [] for (const album of [...albums, ...singles, ...appearsOn]) { if (seenIds.has(album.id)) continue seenIds.add(album.id) allAlbums.push(album) } console.log('found %d albums', allAlbums.length) for (const album of allAlbums) { await downloadAlbum(album) } } else { todo('unsupported url') }