diff --git a/package.json b/package.json index 1499b61..a447534 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lucida", - "version": "2.0.0-23", + "version": "2.0.0-40", "description": "A modular music downloader tool", "main": "build/index.js", "type": "module", @@ -36,7 +36,7 @@ "dependencies": { "blowfish-cbc": "^1.0.1", "image-size": "^1.1.1", - "librespot": "^0.2.4", + "librespot": "^0.2.15", "undici": "^6.19.4", "xmldom-qsa": "^1.1.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5855a9..4f49703 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^1.1.1 version: 1.1.1 librespot: - specifier: ^0.2.4 - version: 0.2.4 + specifier: ^0.2.15 + version: 0.2.15 undici: specifier: ^6.19.4 version: 6.19.4 @@ -464,8 +464,8 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - librespot@0.2.4: - resolution: {integrity: sha512-Ufx/8x2lOALfh/BvtuLf8plSrH+t9oEMXxj330YhjzJfvZMibyhKsZUYVPWeb47J/MRtHNAHeaRxZUTVkt+HJg==} + librespot@0.2.15: + resolution: {integrity: sha512-k8/qd7xWZ+eOGUmX4jevRS79+uMjisIeXzfeXc0roxUhJgRDzGYbGVblovy6378NR9OdoQtBhXts7k7uvgmF5g==} locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} @@ -658,6 +658,9 @@ packages: resolution: {integrity: sha512-i3uaEUwNdkRq2qtTRRJb13moW5HWqviu7Vl7oYRYz++uPtGHJj+x7TGjcEuwS5Mt2P4nA0U9dhIX3DdB6JGY0g==} engines: {node: '>=18.17'} + unplayplay@1.0.2: + resolution: {integrity: sha512-93aGwcNPyWpIhro6HCWqtWZ0mlurpcpVU2ODSYZCI1PhWGKrJfe7oOF4AKVuVK74YFLYlW3JVzl6Vw1Bck2Vpg==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1135,12 +1138,13 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - librespot@0.2.4: + librespot@0.2.15: dependencies: fast-xml-parser: 4.3.4 protobufjs: 7.2.6 shannon-bindings: 0.1.0 undici: 5.28.3 + unplayplay: 1.0.2 locate-path@6.0.0: dependencies: @@ -1305,6 +1309,10 @@ snapshots: undici@6.19.4: {} + unplayplay@1.0.2: + dependencies: + node-addon-api: 8.1.0 + uri-js@4.4.1: dependencies: punycode: 2.3.1 diff --git a/src/index.ts b/src/index.ts index 9c4b6fd..be82ae1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -91,6 +91,15 @@ class Lucida { }) ) } + async isrcLookup(isrc: string) { + const results = await Promise.all( + Object.values(this.modules).map(async (e) => { + if (e.isrcLookup) return await e.isrcLookup(isrc) + }) + ) + const moduleNames = Object.keys(this.modules) + return Object.fromEntries(results.map((e, i) => [moduleNames[i], e])) + } } export default Lucida diff --git a/src/streamers/deezer/main.ts b/src/streamers/deezer/main.ts index 9c2a95b..7928c0e 100644 --- a/src/streamers/deezer/main.ts +++ b/src/streamers/deezer/main.ts @@ -1,4 +1,4 @@ -import { fetch } from 'undici' +import { Dispatcher, fetch } from 'undici' import { Album, Artist, @@ -29,6 +29,7 @@ import { Blowfish } from 'blowfish-cbc' interface DeezerOptions { arl?: string + dispatcher?: Dispatcher | undefined } interface APIMethod { @@ -83,19 +84,21 @@ export default class Deezer implements StreamerWithLogin { arl?: string apiToken?: string licenseToken?: string - renewTimestamp?: number country?: string language?: string + dispatcher?: Dispatcher | undefined availableFormats: Set = new Set() constructor(options?: DeezerOptions) { if (options?.arl) this.arl = options.arl + //if (options?.dispatcher) this.dispatcher = options.dispatcher } async #apiCall( method: T, - data: { [key: string]: string | number | string[] } = {} + data: { [key: string]: string | number | string[] } = {}, + doSessionRenewal = true ): Promise { let apiToken = this.apiToken if (method == 'deezer.getUserData' || method == 'user.getArl') apiToken = '' @@ -114,19 +117,34 @@ export default class Deezer implements StreamerWithLogin { } const body = JSON.stringify(data) - const req = await fetch(url, { method: 'POST', body, headers: this.headers }) + const req = await fetch(url, { + method: 'POST', + body, + headers: this.headers, + dispatcher: this.dispatcher + }) const { results, error } = await req.json() if (error.constructor.name == 'Object') { const [type, msg] = Object.entries(error)[0] - if (type == 'VALID_TOKEN_REQUIRED') throw new Error('Invalid ARL') + + // session renewal + if ( + doSessionRenewal && + (type == 'VALID_TOKEN_REQUIRED' || type == 'NEED_USER_AUTH_REQUIRED') + ) { + const userData = await this.#apiCall('deezer.getUserData', {}, false) + if (userData.USER.USER_ID == 0) throw new Error('ARL expired') + return await this.#apiCall(method, data, false) + } + throw new Error(`API Error: ${type}\n${msg}`) } if (method == 'deezer.getUserData') { const setCookie = req.headers.get('Set-Cookie') ?? '' const sid = setCookie.match(/sid=(fr[0-9a-f]+)/)![1] - this.headers['Cookie'] += `arl=${this.arl}; sid=${sid}` + this.headers['Cookie'] = `arl=${this.arl}; sid=${sid}` const res = results @@ -139,8 +157,6 @@ export default class Deezer implements StreamerWithLogin { this.availableFormats = new Set([DeezerFormat.MP3_128]) if (res?.USER?.OPTIONS?.web_hq) this.availableFormats.add(DeezerFormat.MP3_320) if (res?.USER?.OPTIONS?.web_lossless) this.availableFormats.add(DeezerFormat.FLAC) - - this.renewTimestamp = Date.now() } return results @@ -167,7 +183,10 @@ export default class Deezer implements StreamerWithLogin { async login(username: string, password: string): Promise { if (this.arl) await this.#loginViaArl(this.arl) else { - const resp = await fetch('https://www.deezer.com/', { headers: this.headers }) + const resp = await fetch('https://www.deezer.com/', { + headers: this.headers, + dispatcher: this.dispatcher + }) const setCookie = resp.headers.get('Set-Cookie') ?? '' const sid = setCookie.match(/sid=(fr[0-9a-f]+)/)![1] this.headers['Cookie'] = `sid=${sid}` @@ -181,7 +200,7 @@ export default class Deezer implements StreamerWithLogin { password, hash: this.#md5(CLIENT_ID + username + password + CLIENT_SECRET) })}`, - { headers: this.headers } + { headers: this.headers, dispatcher: this.dispatcher } ) const { error } = await loginReq.json() @@ -242,7 +261,7 @@ export default class Deezer implements StreamerWithLogin { /* ---------- GET INFO FROM URL ---------- */ async #unshortenUrl(url: URL): Promise { - const res = await fetch(url, { redirect: 'manual' }) + const res = await fetch(url, { redirect: 'manual', dispatcher: this.dispatcher }) const location = res.headers.get('Location') if (res.status != 302 || !location) throw new Error('URL not supported') @@ -289,7 +308,7 @@ export default class Deezer implements StreamerWithLogin { }) const data = { - metadata: parseAlbum(DATA), + metadata: { ...parseAlbum(DATA), trackCount: SONGS.data.length }, tracks: SONGS.data.map(parseTrack) } @@ -351,7 +370,7 @@ export default class Deezer implements StreamerWithLogin { // download const url = await this.#getTrackUrl(id, trackToken, trackTokenExpiry, format) - const streamResp = await fetch(url) + const streamResp = await fetch(url, { dispatcher: this.dispatcher }) if (!streamResp.ok) throw new Error(`Failed to get track stream. Status code: ${streamResp.status}`) @@ -417,10 +436,6 @@ export default class Deezer implements StreamerWithLogin { trackTokenExpiry: number, format: DeezerFormat ): Promise { - if (Date.now() - (this.renewTimestamp ?? 0) >= 3600 * 1000) - // renew license token - await this.#apiCall('deezer.getUserData') - if (Date.now() / 1000 - trackTokenExpiry >= 0) // renew track token trackToken = ( @@ -441,7 +456,8 @@ export default class Deezer implements StreamerWithLogin { } ], track_tokens: [trackToken] - }) + }), + dispatcher: this.dispatcher }) const res = await req.json() @@ -500,4 +516,14 @@ export default class Deezer implements StreamerWithLogin { explicit: userData.USER.EXPLICIT_CONTENT_LEVEL != 'explicit_hide' } } + + async isrcLookup(isrc: string): Promise { + const resp = <{ type?: 'track'; link?: string }>( + await (await fetch(`https://api.deezer.com/track/isrc:${isrc}`)).json() + ) + if (resp.type == 'track' && resp.link) { + const track = (await this.getByUrl(resp.link)).metadata + return track + } else throw new Error(`Not available on Deezer.`) + } } diff --git a/src/streamers/qobuz/main.ts b/src/streamers/qobuz/main.ts index 11e20fc..aa54b1f 100644 --- a/src/streamers/qobuz/main.ts +++ b/src/streamers/qobuz/main.ts @@ -1,5 +1,5 @@ import crypto from 'crypto' -import { fetch } from 'undici' +import { fetch, Dispatcher } from 'undici' import { ItemType, StreamerWithLogin, @@ -27,6 +27,7 @@ interface QobuzOptions { appSecret: string appId: string token?: string + dispatcher?: Dispatcher } interface LoginResponse { @@ -66,11 +67,13 @@ export default class Qobuz implements StreamerWithLogin { token?: string appSecret: string appId: string + dispatcher: Dispatcher | undefined fetch?(url: URL | RequestInfo, init?: RequestInit): Promise constructor(options: QobuzOptions) { this.appSecret = options.appSecret this.appId = options.appId if (options.token) this.token = options.token + if (options.dispatcher) this.dispatcher = options.dispatcher } async #get(url: string, params: { [key: string]: string | number }) { @@ -83,7 +86,8 @@ export default class Qobuz implements StreamerWithLogin { )}`, { method: 'get', - headers: headers(this.token) + headers: headers(this.token), + dispatcher: this.dispatcher } ) if (!response.ok) { @@ -179,7 +183,7 @@ export default class Qobuz implements StreamerWithLogin { ) if (trackFileResponse.sample == true) throw new Error(`Could not get non-sample file. Make sure the track isn't purchase-only.`) - const streamResponse = await fetch(trackFileResponse.url) + const streamResponse = await fetch(trackFileResponse.url, { dispatcher: this.dispatcher }) return { mimeType: trackFileResponse.mime_type, sizeBytes: parseInt(streamResponse.headers.get('Content-Length')), @@ -203,7 +207,7 @@ export default class Qobuz implements StreamerWithLogin { app_id: this.appId }) return { - metadata: parseAlbum(albumResponse), + metadata: { ...parseAlbum(albumResponse), trackCount: albumResponse.tracks?.items.length }, tracks: albumResponse.tracks?.items.map(parseTrack) } } @@ -296,4 +300,12 @@ export default class Qobuz implements StreamerWithLogin { explicit: true } } + async isrcLookup(isrc: string): Promise { + const isrcUrl = (await this.search(isrc)).tracks?.[0]?.url + if (!isrcUrl) throw new Error(`Not available on Qobuz.`) + else { + const track = (await this.getByUrl(isrcUrl)).metadata + return track + } + } } diff --git a/src/streamers/qobuz/parse.ts b/src/streamers/qobuz/parse.ts index 66adb63..96910ff 100644 --- a/src/streamers/qobuz/parse.ts +++ b/src/streamers/qobuz/parse.ts @@ -111,12 +111,16 @@ export interface RawTrack { } export function parseTrack(raw: RawTrack): Track { + const artists = [] + const artist = raw.performer ?? raw.album?.artist + if (artist) artists.push(parseArtist(artist)) + let track: Track = { title: raw.version ? `${raw.title} (${raw.version})` : raw.title, id: raw.id.toString(), url: `https://play.qobuz.com/track/${raw.id.toString()}`, copyright: raw.copyright, - artists: [parseArtist(raw.performer)], + artists, durationMs: raw.duration * 1000, explicit: raw.parental_warning, isrc: raw.isrc, diff --git a/src/streamers/soundcloud/main.ts b/src/streamers/soundcloud/main.ts index 069cfc9..70d8d29 100644 --- a/src/streamers/soundcloud/main.ts +++ b/src/streamers/soundcloud/main.ts @@ -1,4 +1,4 @@ -import { fetch, HeadersInit } from 'undici' +import { Dispatcher, fetch, HeadersInit } from 'undici' import { DEFAULT_HEADERS } from './constants.js' import { ItemType, @@ -30,6 +30,7 @@ function headers(oauthToken?: string | undefined): HeadersInit { interface SoundcloudOptions { oauthToken?: string + dispatcher: Dispatcher | undefined } interface SoundcloudTranscoding { @@ -56,7 +57,7 @@ interface SoundCloudSubscriptionData { } export default class Soundcloud implements Streamer { - hostnames = ['soundcloud.com', 'm.soundcloud.com', 'www.soundcloud.com'] + hostnames = ['soundcloud.com', 'm.soundcloud.com', 'www.soundcloud.com', 'on.soundcloud.com'] testData = { 'https://soundcloud.com/saoirsedream/charlikartlanparty': { type: 'track', @@ -69,8 +70,10 @@ export default class Soundcloud implements Streamer { } as const oauthToken?: string client?: ScClient + dispatcher: Dispatcher | undefined constructor(options: SoundcloudOptions) { this.oauthToken = options?.oauthToken + this.dispatcher = options?.dispatcher } async search(query: string, limit = 20): Promise { const client = this.client || (await this.#getClient()) @@ -81,7 +84,7 @@ export default class Soundcloud implements Streamer { )}&offset=0&linked_partitioning=1&app_locale=en&limit=${limit}`, client ), - { method: 'get', headers: headers(this.oauthToken) } + { method: 'get', headers: headers(this.oauthToken), dispatcher: this.dispatcher } ) if (!response.ok) { const errMsg = await response.text() @@ -116,7 +119,8 @@ export default class Soundcloud implements Streamer { const response = await ( await fetch(`https://soundcloud.com/`, { method: 'get', - headers: headers() + headers: headers(this.oauthToken), + dispatcher: this.dispatcher }) ).text() @@ -140,6 +144,9 @@ export default class Soundcloud implements Streamer { } async getByUrl(url: string): Promise { + const { hostname } = new URL(url) + + if (hostname == 'on.soundcloud.com') url = await followRedirect(url) return await this.#getMetadata(url) } @@ -163,11 +170,24 @@ export default class Soundcloud implements Streamer { url = url.replace('//m.', '//') // getting the IDs and track authorization - const html = await (await fetch(url, { method: 'get', headers: headers() })).text() + const html = await ( + await fetch(url, { + method: 'get', + headers: headers(this.oauthToken), + dispatcher: this.dispatcher + }) + ).text() switch (type) { case 'track': { - const trackId = html.split(`"soundcloud://sounds:`)?.[1]?.split(`">`)?.[0] + let trackId = html.split('"soundcloud://sounds:')?.[1]?.split('">')?.[0] + if (!trackId) + trackId = html + .split( + 'content="https://w.soundcloud.com/player/?url=https%3A%2F%2Fapi.soundcloud.com%2Ftracks%2F' + )?.[1] + ?.split('%3F')[0] + if (!trackId) throw new Error('Could not extract track ID.') let naked = `https://api-v2.soundcloud.com/tracks/${trackId}` const path = new URL(url).pathname @@ -177,7 +197,8 @@ export default class Soundcloud implements Streamer { await ( await fetch(this.#formatURL(naked, client), { method: 'get', - headers: headers(this.oauthToken) + headers: headers(this.oauthToken), + dispatcher: this.dispatcher }) ).text() ) @@ -240,7 +261,7 @@ export default class Soundcloud implements Streamer { } } default: - throw `Type "${type}" not supported.` + throw new Error(`Type "${type}" not supported.`) } } async #getRawTrackInfo(id: number | string, client: ScClient) { @@ -248,7 +269,8 @@ export default class Soundcloud implements Streamer { await ( await fetch(this.#formatURL(`https://api-v2.soundcloud.com/tracks/${id}`, client), { method: 'get', - headers: headers(this.oauthToken) + headers: headers(this.oauthToken), + dispatcher: this.dispatcher }) ).text() ) @@ -265,7 +287,8 @@ export default class Soundcloud implements Streamer { ), { method: 'get', - headers: headers(this.oauthToken) + headers: headers(this.oauthToken), + dispatcher: this.dispatcher } ) ).json() @@ -315,14 +338,15 @@ async function getStream( if (filter.length == 0) filter = transcodings.filter((x) => x.preset.startsWith('mp3_')) // then mp3 if (filter.length == 0) filter = transcodings.filter((x) => x.preset.startsWith('opus_')) // then opus if (filter.length == 0) throw new Error('Could not find applicable format.') // and this is just in case none of those exist - const transcoding = filter[0] - const streamUrlResp = await fetch( - `${transcoding.url}?client_id=${client.id}&track_authorization=${trackAuth}`, - { - headers: headers(oauthToken) - } - ) + + const streamUrl = new URL(transcoding.url) + if (client?.id) streamUrl.searchParams.append('client_id', client?.id) + streamUrl.searchParams.append('track_authorization', trackAuth) + + const streamUrlResp = await fetch(streamUrl.toString(), { + headers: headers(oauthToken) + }) const json = <{ url: string }>await streamUrlResp.json() if (!json.url) throw new Error('Stream URL could not be retreieved.') @@ -334,7 +358,8 @@ async function getStream( stream: Readable.fromWeb(streamResp.body!) } } else { - const container = transcoding.format.mime_type.split('/')[1].split(';')[0].split('+')[0] + let container = transcoding.format.mime_type.split('/')[1].split(';')[0].split('+')[0] + if (container == 'mpeg') container = 'mp3' return { mimeType: transcoding.format.mime_type, @@ -342,3 +367,12 @@ async function getStream( } } } + +async function followRedirect(url: string) { + const res = await fetch(url, { redirect: 'manual' }) + const location = res.headers.get('Location') + + if (res.status != 302 || !location) throw new Error('URL not supported') + + return location +} diff --git a/src/streamers/spotify/main.ts b/src/streamers/spotify/main.ts index 7c5f517..d9cd8a0 100644 --- a/src/streamers/spotify/main.ts +++ b/src/streamers/spotify/main.ts @@ -5,9 +5,14 @@ import { ItemType, SearchResults, StreamerAccount, - StreamerWithLogin + StreamerWithLogin, + Track } from '../../types.js' +interface SpotifyOptions extends LibrespotOptions { + storedCredential?: boolean +} + class Spotify implements StreamerWithLogin { client: Librespot hostnames = ['open.spotify.com'] @@ -26,11 +31,15 @@ class Spotify implements StreamerWithLogin { } } as const - constructor(options: LibrespotOptions) { + storedCredential: boolean + + constructor(options: SpotifyOptions) { this.client = new Librespot(options) + this.storedCredential = options.storedCredential == true } - login(username: string, password: string) { - return this.client.login(username, password) + async login(username: string, password: string) { + if (this.storedCredential) return await this.client.loginWithStoredCreds(username, password) + else return await this.client.login(username, password) } #getUrlParts(url: string): ['artist' | 'album' | 'track' | 'episode' | 'show', string] { const urlObj = new URL(url) @@ -134,6 +143,11 @@ class Spotify implements StreamerWithLogin { tracks: results.tracks?.map((e) => parseTrack(e)) ?? [] } } + async isrcLookup(isrc: string): Promise { + const results = await this.search(`isrc:${isrc}`) + if (results?.tracks[0]) return (await this.getByUrl(results.tracks?.[0]?.url)).metadata + else throw new Error(`Not available on Spotify.`) + } async getAccountInfo(): Promise { const info = await this.client.get.me() diff --git a/src/streamers/tidal/main.ts b/src/streamers/tidal/main.ts index 2e25808..9ee4b57 100644 --- a/src/streamers/tidal/main.ts +++ b/src/streamers/tidal/main.ts @@ -1,4 +1,4 @@ -import { fetch } from 'undici' +import { Dispatcher, fetch } from 'undici' import { spawn } from 'child_process' import os from 'node:os' import fs from 'node:fs' @@ -34,6 +34,7 @@ interface TidalOptions { refreshToken: string expires: number countryCode: string + dispatcher?: Dispatcher } interface LoginData { @@ -65,6 +66,24 @@ interface SubscriptionData { paymentOverdue: boolean } +interface RawSearchResults { + albums: { + items: RawAlbum[] + } + artists: { + items: RawArtist[] + } + tracks: { + items: RawTrack[] + } +} + +interface RawIsrcLookup { + limit: number + offset: number + items: RawTrack[] +} + export default class Tidal implements Streamer { tvToken: string tvSecret: string @@ -73,6 +92,7 @@ export default class Tidal implements Streamer { expires: number countryCode: string userId: number | undefined + dispatcher: Dispatcher | undefined hostnames = ['tidal.com', 'www.tidal.com', 'listen.tidal.com'] testData = { 'https://tidal.com/browse/artist/3908662': { @@ -98,6 +118,7 @@ export default class Tidal implements Streamer { this.refreshToken = options.refreshToken this.expires = options.expires this.countryCode = options.countryCode + if (options.dispatcher) this.dispatcher = options.dispatcher const getReady = async () => { if (!this.refreshToken) return @@ -115,6 +136,11 @@ export default class Tidal implements Streamer { 'User-Agent': 'TIDAL_ANDROID/1039 okhttp/3.14.9' } } + async isrcLookup(isrc: string): Promise { + const isrcQuery = await this.#get('tracks', { isrc }) + if (isrcQuery?.items?.[0]) return (await this.getByUrl(isrcQuery.items[0].url)).metadata + else throw new Error(`Not available on Tidal.`) + } async #get( url: string, params: { [key: string]: string | number } = {}, @@ -132,7 +158,8 @@ export default class Tidal implements Streamer { const response = await fetch( `${base}${url}?${new URLSearchParams(<{ [key: string]: string }>params)}`, { - headers: this.headers() + headers: this.headers(), + dispatcher: this.dispatcher } ) if (!response.ok) { @@ -158,13 +185,15 @@ export default class Tidal implements Streamer { } async sessionValid() { const resp = await fetch('https://api.tidal.com/v1/sessions', { - headers: this.headers() + headers: this.headers(), + dispatcher: this.dispatcher }) return resp.ok } async getCountryCode() { const sessionResponse = await fetch('https://api.tidal.com/v1/sessions', { - headers: this.headers() + headers: this.headers(), + dispatcher: this.dispatcher }) if (sessionResponse.status != 200) return false const sessionData = await sessionResponse.json() @@ -178,7 +207,8 @@ export default class Tidal implements Streamer { body: new URLSearchParams({ client_id: this.tvToken, scope: 'r_usr w_usr' - }) + }), + dispatcher: this.dispatcher }) if (deviceAuthResponse.status != 200) throw new Error(`Couldn't authorize Tidal`) interface DeviceAuth { @@ -201,7 +231,8 @@ export default class Tidal implements Streamer { await new Promise((r) => setTimeout(r, 1000)) const loginResponse = await fetch(`${TIDAL_AUTH_BASE}oauth2/token`, { method: 'post', - body: new URLSearchParams(params) + body: new URLSearchParams(params), + dispatcher: this.dispatcher }) statusCode = loginResponse.status loginData = await loginResponse.json() @@ -237,7 +268,8 @@ export default class Tidal implements Streamer { client_id: this.tvToken, client_secret: this.tvSecret, grant_type: 'refresh_token' - }) + }), + dispatcher: this.dispatcher }) if (refreshResponse.status == 200) { @@ -250,17 +282,6 @@ export default class Tidal implements Streamer { 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, @@ -366,7 +387,7 @@ export default class Tidal implements Streamer { if (playbackInfoResponse.manifestMimeType != 'application/dash+xml') { const manifest = JSON.parse(manifestStr) - const streamResponse = await fetch(manifest.urls[0]) + const streamResponse = await fetch(manifest.urls[0], { dispatcher: this.dispatcher }) return { mimeType: manifest.mimeType, sizeBytes: parseInt(streamResponse.headers.get('Content-Length')), @@ -505,10 +526,12 @@ export default class Tidal implements Streamer { metadata: await this.#getTrack(id) } case 'album': + // eslint-disable-next-line no-case-declarations + const tracks = await this.#getAlbumTracks(id) return { type, - tracks: await this.#getAlbumTracks(id), - metadata: await this.#getAlbum(id) + tracks: tracks, + metadata: { ...(await this.#getAlbum(id)), trackCount: tracks.length } } case 'artist': return { @@ -527,7 +550,8 @@ export default class Tidal implements Streamer { 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() + headers: this.headers(), + dispatcher: this.dispatcher }) const sessionData = await sessionResponse.json() diff --git a/src/types.ts b/src/types.ts index 8e3547c..254fce9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,18 @@ export interface CoverArtwork { height: number } +export interface Lyrics { + id: Id + source: string + lines?: LyricLine[] +} + +export interface LyricLine { + text: string + startTimeMs?: number + endTimeMs?: number +} + export interface Artist { id: Id url: string @@ -62,10 +74,11 @@ export interface Track { album?: Album durationMs?: number coverArtwork?: CoverArtwork[] + lyrics?: Lyrics genres?: string[] releaseDate?: Date - regions?: string[] description?: string + regions?: string[] } export interface Episode { @@ -164,6 +177,7 @@ export interface Streamer { hostnames: string[] testData?: StreamerTestData search(query: string, limit: number): Promise + isrcLookup?(isrc: string): Promise getTypeFromUrl(url: string): Promise getByUrl: | ((url: string) => Promise)