import fetch from 'node-fetch' import { spawn } from 'child_process' import { ItemType, GetByUrlResponse, GetStreamResponse, SearchResults, Streamer, Track, StreamerAccount } from '../../types.js' import { TIDAL_AUTH_BASE, TIDAL_API_BASE, TIDAL_SUBSCRIPTION_BASE } from './constants.js' import { Contributor, RawAlbum, RawArtist, RawTrack, addCredits, parseAlbum, parseArtist, parseMpd, parseTrack } from './parse.js' import Stream from 'stream' interface TidalOptions { tvToken: string tvSecret: string accessToken: string refreshToken: string expires: number countryCode: string } interface LoginData { error: string access_token: string refresh_token: string expires_in: number } interface SessionData { countryCode: string userId: number status?: number userMessage?: string } interface SubscriptionData { startDate: string validUntil: string status: string subscription: { type: string offlineGracePeriod: number } highestSoundQuality: string premiumAccess: boolean canGetTrial: boolean paymentType: string paymentOverdue: boolean } export default class Tidal implements Streamer { tvToken: string tvSecret: string accessToken: string refreshToken: string expires: number countryCode: string userId: number | undefined hostnames = ['tidal.com', 'www.tidal.com', 'listen.tidal.com'] testData = { 'https://tidal.com/browse/artist/3908662': { type: 'artist', title: 'Tyler, The Creator' }, 'https://tidal.com/browse/album/109485854': { type: 'album', title: 'IGOR' }, 'https://tidal.com/browse/track/95691774': { type: 'track', title: 'Potato Salad' } } as const failedAuth = false constructor(options: TidalOptions) { this.tvToken = options.tvToken this.tvSecret = options.tvSecret // TODO: ideally this should be stored in a .config file this.accessToken = options.accessToken this.refreshToken = options.refreshToken this.expires = options.expires this.countryCode = options.countryCode const getReady = async () => { if (!this.refreshToken) return if (await this.sessionValid()) return const success = await this.refresh() if (!success) console.log(`[tidal] Failed to refresh tokens, this could be a bad sign`) } getReady() } headers() { return { 'X-Tidal-Token': this.tvToken, Authorization: `Bearer ${this.accessToken}`, 'Accept-Encoding': 'gzip', 'User-Agent': 'TIDAL_ANDROID/1039 okhttp/3.14.9' } } async #get( url: string, params: { [key: string]: string | number } = {}, base: string = TIDAL_API_BASE ): Promise { if (this.failedAuth) throw new Error(`Last request failed to authorize, get new tokens`) if (Date.now() > this.expires) await this.refresh() if (!this.countryCode) await this.getCountryCode() params.countryCode = params.countryCode ?? this.countryCode params.locale = params.locale ?? 'en_US' params.deviceType = params.deviceType ?? 'TV' for (const key in params) { if (typeof params[key] == 'number') params[key] = params[key].toString() } const response = await fetch( `${base}${url}?${new URLSearchParams(<{ [key: string]: string }>params)}`, { headers: this.headers() } ) if (!response.ok) { const errMsg = await response.text() try { const json = JSON.parse(errMsg) const sessionValid = await this.sessionValid() if (json.status == 401 && !sessionValid) { this.failedAuth = !(await this.refresh()) console.log('[tidal] Refreshed tokens') if (this.failedAuth) { throw new Error('Auth failed. Try getting new tokens.') } return this.#get(url, params) } console.error('[tidal] Tidal error response:', json) } catch (error) { console.error('[tidal] Tidal error response:', errMsg) } throw new Error(`Fetching ${url} from Tidal failed with status code ${response.status}.`) } return await response.json() } async sessionValid() { const resp = await fetch('https://api.tidal.com/v1/sessions', { headers: this.headers() }) return resp.ok } async getCountryCode() { const sessionResponse = await fetch('https://api.tidal.com/v1/sessions', { headers: this.headers() }) if (sessionResponse.status != 200) return false const sessionData = await sessionResponse.json() this.countryCode = sessionData.countryCode this.userId = sessionData.userId return true } async getTokens() { const deviceAuthResponse = await fetch(`${TIDAL_AUTH_BASE}oauth2/device_authorization`, { method: 'post', body: new URLSearchParams({ client_id: this.tvToken, scope: 'r_usr w_usr' }) }) if (deviceAuthResponse.status != 200) throw new Error(`Couldn't authorize Tidal`) interface DeviceAuth { deviceCode: string userCode: string } const deviceAuth = await deviceAuthResponse.json() const linkUrl = `https://link.tidal.com/${deviceAuth.userCode}` const checkToken = async () => { const params = { client_id: this.tvToken, client_secret: this.tvSecret, device_code: deviceAuth.deviceCode, grant_type: 'urn:ietf:params:oauth:grant-type:device_code', scope: 'r_usr w_usr' } let loginData: LoginData | null = null let statusCode = 400 while (statusCode == 400) { await new Promise((r) => setTimeout(r, 1000)) const loginResponse = await fetch(`${TIDAL_AUTH_BASE}oauth2/token`, { method: 'post', body: new URLSearchParams(params) }) statusCode = loginResponse.status loginData = await loginResponse.json() } if (statusCode != 200 || !loginData) throw new Error(`Failed to log in. ${loginData?.error}`) this.accessToken = loginData.access_token this.refreshToken = loginData.refresh_token this.expires = Date.now() + loginData.expires_in * 1000 await this.getCountryCode() console.log('[tidal] Using the following new config:', this.getCurrentConfig()) } console.log(`[tidal] Log in at ${linkUrl}`) checkToken() return linkUrl } getCurrentConfig() { return { tvToken: this.tvToken, tvSecret: this.tvSecret, accessToken: this.accessToken, refreshToken: this.refreshToken, expires: this.expires, countryCode: this.countryCode } } async refresh() { const refreshResponse = await fetch(`${TIDAL_AUTH_BASE}oauth2/token`, { method: 'post', body: new URLSearchParams({ refresh_token: this.refreshToken, client_id: this.tvToken, client_secret: this.tvSecret, grant_type: 'refresh_token' }) }) if (refreshResponse.status == 200) { const refreshData: LoginData = await refreshResponse.json() this.expires = Date.now() + refreshData.expires_in * 1000 this.accessToken = refreshData.access_token if (refreshData.refresh_token) this.refreshToken = refreshData.refresh_token return true } return false } async search(query: string, limit = 20): Promise { interface RawSearchResults { albums: { items: RawAlbum[] } artists: { items: RawArtist[] } tracks: { items: RawTrack[] } } const results = await this.#get('search/top-hits', { query: query, limit: limit, offset: 0, types: 'ARTISTS,ALBUMS,TRACKS', includeContributors: 'true', includeUserPlaylists: 'true', supportsUserData: 'true' }) return { query, albums: results.albums.items.map((raw) => parseAlbum(raw)), artists: results.artists.items.map(parseArtist), tracks: results.tracks.items.map(parseTrack) } } async #getTrack(trackId: number | string) { const trackResponse = await this.#get(`tracks/${trackId}`) const contributorResponse = (<{ items: Contributor[] }>( await this.#get(`tracks/${trackId}/contributors`) )).items trackResponse.album = await this.#get(`albums/${trackResponse.album.id}`) return parseTrack(addCredits(trackResponse, contributorResponse)) } async #getAlbum(albumId: number | string) { const albumResponse = await this.#get(`albums/${albumId}`) return parseAlbum(albumResponse) } async #getAlbumTracks(albumId: number | string): Promise { interface TracksContributors { items: { item: RawTrack credits: Contributor[] }[] } const contributorResponse = await this.#get( `albums/${albumId}/items/credits`, { replace: 'true', offset: 0, includeContributors: 'true', limit: 100 } ) return contributorResponse.items.map((item) => parseTrack(addCredits(item.item, item.credits))) } async #getArtist(artistId: number | string) { const [artistResponse, albumsResponse, tracksResponse] = await Promise.all([ >this.#get(`artists/${artistId}`), >this.#get(`artists/${artistId}/albums`, { limit: 20 }), >this.#get(`artists/${artistId}/toptracks`, { limit: 20 }) ]) return { ...parseArtist(artistResponse), albums: albumsResponse.items.map(parseAlbum), tracks: tracksResponse.items.map(parseTrack) } } async #getFileUrl( trackId: number | string, quality = 'HI_RES_LOSSLESS' ): Promise { interface PlaybackInfo { manifest: string manifestMimeType: string audioQuality: 'LOW' | 'HIGH' | 'LOSSLESS' | 'HI_RES' | 'HI_RES_LOSSLESS' } const playbackInfoResponse = await this.#get( `tracks/${trackId}/playbackinfopostpaywall/v4`, { playbackmode: 'STREAM', assetpresentation: 'FULL', audioquality: quality, prefetch: 'false' } ) if (playbackInfoResponse.audioQuality == 'HIGH' || playbackInfoResponse.audioQuality == 'LOW') throw new Error('This ripper is incompatible with AAC codecs formats at the moment.') const manifestStr = Buffer.from(playbackInfoResponse.manifest, 'base64').toString('utf-8') interface Manifest { mimeType: string urls: string[] } if (playbackInfoResponse.manifestMimeType != 'application/dash+xml') { const manifest = JSON.parse(manifestStr) const streamResponse = await fetch(manifest.urls[0]) return { mimeType: manifest.mimeType, sizeBytes: parseInt(streamResponse.headers.get('Content-Length')), stream: streamResponse.body } } const trackUrls = parseMpd(manifestStr) const ffmpegProc = spawn('ffmpeg', [ '-hide_banner', '-loglevel', 'error', '-i', '-', '-c:a', 'copy', '-f', 'flac', '-' ]) const stream = new Stream.Readable({ // eslint-disable-next-line @typescript-eslint/no-empty-function read() {} }) async function load() { for (const url of trackUrls) { const resp = await fetch(url) if (!resp.body) throw new Error('Response has no body') for await (const chunk of resp.body) { stream.push(chunk) } } stream.push(null) } stream.pipe(ffmpegProc.stdin) ffmpegProc.stderr.pipe(process.stderr) load() return { mimeType: 'audio/flac', stream: ffmpegProc.stdout } } #getUrlParts(url: string): ['artist' | 'album' | 'track', string] { const urlParts = url .match(/^https?:\/\/(?:www\.|listen\.)?tidal\.com\/(?:browse\/)?(.*?)\/(.*?)\/?$/) ?.slice(1, 3) if (!urlParts) throw new Error('URL not supported') urlParts[1] = urlParts[1].replace(/\?.*?$/, '') if (urlParts[0] != 'artist' && urlParts[0] != 'album' && urlParts[0] != 'track') { throw new Error('URL unrecognised') } return [urlParts[0], urlParts[1]] } async getTypeFromUrl(url: string): Promise { return this.#getUrlParts(url)[0] } async getByUrl(url: string): Promise { const [type, id] = this.#getUrlParts(url) switch (type) { case 'track': return { type, getStream: () => { return this.#getFileUrl(id) }, metadata: await this.#getTrack(id) } case 'album': return { type, tracks: await this.#getAlbumTracks(id), metadata: await this.#getAlbum(id) } case 'artist': return { type, metadata: await this.#getArtist(id) } } } async getAccountInfo(): Promise { if (this.userId == undefined || this.countryCode == undefined) { if (Date.now() > this.expires) await this.refresh() const sessionResponse = await fetch('https://api.tidal.com/v1/sessions', { headers: this.headers() }) const sessionData = await sessionResponse.json() this.userId = sessionData.userId this.countryCode = sessionData.countryCode } const subscription = ( await this.#get(`users/${this.userId}/subscription`, {}, TIDAL_SUBSCRIPTION_BASE) ) return { valid: true, premium: subscription.premiumAccess, country: this.countryCode, explicit: true } } }