Merge branch 'main' into feat/playlists

This commit is contained in:
aria
2024-09-05 21:52:05 -04:00
10 changed files with 220 additions and 75 deletions

View File

@@ -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"
},

18
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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

View File

@@ -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<DeezerFormat> = new Set()
constructor(options?: DeezerOptions) {
if (options?.arl) this.arl = options.arl
//if (options?.dispatcher) this.dispatcher = options.dispatcher
}
async #apiCall<T extends keyof APIMethod>(
method: T,
data: { [key: string]: string | number | string[] } = {}
data: { [key: string]: string | number | string[] } = {},
doSessionRenewal = true
): Promise<APIMethod[T]> {
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 } = <DeezerResponse>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 = <APIMethod['deezer.getUserData']>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<void> {
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 } = <DeezerLoginResponse>await loginReq.json()
@@ -242,7 +261,7 @@ export default class Deezer implements StreamerWithLogin {
/* ---------- GET INFO FROM URL ---------- */
async #unshortenUrl(url: URL): Promise<URL> {
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<string> {
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 = <DeezerMediaResponse>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<Track> {
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 = <Track>(await this.getByUrl(resp.link)).metadata
return track
} else throw new Error(`Not available on Deezer.`)
}
}

View File

@@ -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<Response>
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(<string>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<Track> {
const isrcUrl = (await this.search(isrc)).tracks?.[0]?.url
if (!isrcUrl) throw new Error(`Not available on Qobuz.`)
else {
const track = <Track>(await this.getByUrl(isrcUrl)).metadata
return track
}
}
}

View File

@@ -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,

View File

@@ -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<SearchResults> {
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<GetByUrlResponse> {
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
}

View File

@@ -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<Track> {
const results = await this.search(`isrc:${isrc}`)
if (results?.tracks[0]) return <Track>(await this.getByUrl(results.tracks?.[0]?.url)).metadata
else throw new Error(`Not available on Spotify.`)
}
async getAccountInfo(): Promise<StreamerAccount> {
const info = await this.client.get.me()

View File

@@ -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<Track> {
const isrcQuery = <RawIsrcLookup>await this.#get('tracks', { isrc })
if (isrcQuery?.items?.[0]) return <Track>(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 = <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 = <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<SearchResults> {
interface RawSearchResults {
albums: {
items: RawAlbum[]
}
artists: {
items: RawArtist[]
}
tracks: {
items: RawTrack[]
}
}
const results = <RawSearchResults>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 = <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(<string>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 = <SessionData>await sessionResponse.json()

View File

@@ -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<SearchResults>
isrcLookup?(isrc: string): Promise<Track>
getTypeFromUrl(url: string): Promise<ItemType>
getByUrl:
| ((url: string) => Promise<GetByUrlResponse>)