mirror of
https://codeberg.org/lucida/lucida.git
synced 2026-11-09 13:07:25 -05:00
Merge branch 'main' into feat/playlists
This commit is contained in:
@@ -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
18
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
16
src/types.ts
16
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<SearchResults>
|
||||
isrcLookup?(isrc: string): Promise<Track>
|
||||
getTypeFromUrl(url: string): Promise<ItemType>
|
||||
getByUrl:
|
||||
| ((url: string) => Promise<GetByUrlResponse>)
|
||||
|
||||
Reference in New Issue
Block a user