Initial commit

This commit is contained in:
hazycora
2023-09-16 22:05:49 -04:00
commit fc4c8ceb40
22 changed files with 3476 additions and 0 deletions

58
src/index.ts Normal file
View File

@@ -0,0 +1,58 @@
import { GetByUrlResponse, SearchResults, StreamerWithLogin } from './types.js'
interface LucidaOptions {
modules: { [key: string]: StreamerWithLogin }
logins?: {
[key: string]: {
username: string
password: string
}
}
}
class Lucida {
modules: { [key: string]: StreamerWithLogin }
hostnames: string[]
logins?: { [key: string]: { username: string; password: string } }
constructor(options: LucidaOptions) {
this.modules = options.modules
this.hostnames = Object.values(this.modules)
.map((e) => e.hostnames)
.flat()
if (options.logins) this.logins = options.logins
}
async login() {
if (!this.logins) throw new Error('No logins specified')
for (const i in this.logins) {
const credentials = this.logins[i]
await this.modules[i].login?.(credentials.username, credentials.password)
}
}
async search(query: string, limit: number): Promise<{ [key: string]: SearchResults }> {
const results = await Promise.all(
Object.values(this.modules).map((e) => e.search(query, limit))
)
const moduleNames = Object.keys(this.modules)
return Object.fromEntries(results.map((e, i) => [moduleNames[i], e]))
}
getTypeFromUrl(url: string): 'artist' | 'album' | 'track' {
const urlObj = new URL(url)
for (const i in this.modules) {
const matches = this.modules[i].hostnames.includes(urlObj.hostname)
if (!matches) continue
return this.modules[i].getTypeFromUrl(url)
}
throw new Error(`Couldn't find module for hostname ${urlObj.hostname}`)
}
getByUrl(url: string): Promise<GetByUrlResponse> {
const urlObj = new URL(url)
for (const i in this.modules) {
const matches = this.modules[i].hostnames.includes(urlObj.hostname)
if (!matches) continue
return this.modules[i].getByUrl(url)
}
throw new Error(`Couldn't find module for hostname ${urlObj.hostname}`)
}
}
export default Lucida

View File

@@ -0,0 +1,9 @@
export const DEFAULT_HEADERS = {
'X-Device-Platform': 'android',
'X-Device-Model': 'Pixel 3',
'X-Device-Os-Version': '10',
'X-Device-Manufacturer-Id': 'ffffffff-5783-1f51-ffff-ffffef05ac4a',
'X-App-Version': '5.16.1.5',
'User-Agent':
'Dalvik/2.1.0 (Linux; U; Android 10; Pixel 3 Build/QP1A.190711.020)) QobuzMobileAndroid/5.16.1.5-b21041415'
}

246
src/streamers/qobuz/main.ts Normal file
View File

@@ -0,0 +1,246 @@
import crypto from 'crypto'
import fetch from 'node-fetch'
import {
StreamerWithLogin,
SearchResults,
GetByUrlResponse,
GetStreamResponse,
Track
} from '../../types.js'
import { DEFAULT_HEADERS } from './constants.js'
import { parseAlbum, parseTrack, parseArtist, RawAlbum, RawArtist, RawTrack } from './parse.js'
function headers(token?: string): HeadersInit {
const headers: HeadersInit = DEFAULT_HEADERS
if (token) headers['X-User-Auth-Token'] = token
return headers
}
function md5(str: string): string {
return crypto.createHash('md5').update(str).digest('hex')
}
interface QobuzOptions {
appSecret: string
appId: string
}
export default class Qobuz implements StreamerWithLogin {
hostnames = ['play.qobuz.com', 'open.qobuz.com', 'www.qobuz.com', 'qobuz.com']
token?: string
appSecret: string
appId: string
fetch?(url: URL | RequestInfo, init?: RequestInit): Promise<Response>
constructor(options: QobuzOptions) {
this.appSecret = options.appSecret
this.appId = options.appId
}
async #get(url: string, params: { [key: string]: string | number }) {
for (const key in params) {
if (typeof params[key] == 'number') params[key] = params[key].toString()
}
const response = await fetch(
`https://www.qobuz.com/api.json/0.2/${url}?${new URLSearchParams(
<{ [key: string]: string }>params
)}`,
{
method: 'get',
headers: headers(this.token)
}
)
if (!response.ok) {
const errMsg = await response.text()
try {
console.error('Qobuz error response:', JSON.parse(errMsg))
} catch (error) {
console.error('Qobuz error response:', errMsg)
}
throw new Error(`Fetching ${url} from Qobuz failed with status code ${response.status}.`)
}
return await response.json()
}
#createSignature(path: string, params: { [key: string]: string | number }) {
if (!this.appSecret) throw new Error('appSecret not specified')
const timestamp = Math.floor(Date.now() / 1000)
let toHash = path.replace(/\//g, '')
for (const key of Object.keys(params).sort()) {
if (key != 'app_id' && key != 'user_auth_token') {
toHash += key + params[key]
}
}
toHash += timestamp + this.appSecret
return {
timestamp,
hash: md5(toHash)
}
}
async #getSigned(url: string, params: { [key: string]: string | number }) {
const signature = this.#createSignature(url, params)
params.request_ts = signature.timestamp.toString()
params.request_sig = signature.hash
return await this.#get(url, params)
}
async login(username: string, password: string) {
const params: { [key: string]: string } = {
username,
password: md5(password),
extra: 'partner',
app_id: this.appId
}
interface LoginResponse {
user_auth_token: string
}
const loginResponse = <LoginResponse>await this.#getSigned('user/login', params)
this.token = loginResponse.user_auth_token
}
async search(query: string, limit = 10): Promise<SearchResults> {
if (!this.token) throw new Error('Not logged in.')
interface RawSearchResults {
query: string
albums: { items: RawAlbum[] }
tracks: { items: RawTrack[] }
artists: { items: RawArtist[] }
}
const resultResponse = <RawSearchResults>(
await this.#get('catalog/search', { query, limit, app_id: this.appId })
)
return {
query: resultResponse.query,
albums: resultResponse.albums.items.map(parseAlbum),
tracks: resultResponse.tracks.items.map(parseTrack),
artists: resultResponse.artists.items.map(parseArtist)
}
}
async #getFileUrl(trackId: string, qualityId = 27): Promise<GetStreamResponse> {
if (!this.token) throw new Error('Not logged in.')
const params: { [key: string]: string } = {
track_id: trackId.toString(),
format_id: qualityId.toString(),
intent: 'stream',
sample: 'false',
app_id: this.appId,
user_auth_token: this.token
}
interface RawTrackFileResponse {
url: string
mime_type: string
}
const trackFileResponse = <RawTrackFileResponse>(
await this.#getSigned('track/getFileUrl', params)
)
const streamResponse = await fetch(trackFileResponse.url)
return {
mimeType: trackFileResponse.mime_type,
sizeBytes: parseInt(<string>streamResponse.headers.get('Content-Length')),
stream: <NodeJS.ReadableStream>streamResponse.body
}
}
async #getTrackMetadata(trackId: string) {
return parseTrack(
<RawTrack>await this.#get('track/get', {
track_id: trackId,
app_id: this.appId
})
)
}
async #getAlbum(albumId: string) {
const albumResponse = <RawAlbum>await this.#get('album/get', {
album_id: albumId,
extra: 'albumsFromSameArtist,focusAll',
app_id: this.appId
})
return {
metadata: parseAlbum(albumResponse),
tracks: albumResponse.tracks?.items.map(parseTrack)
}
}
async #getArtistMetadata(artistId: string) {
return parseArtist(
<RawArtist>await this.#get('artist/get', {
artist_id: artistId,
extra: 'albums,playlists,tracks_appears_on,albums_with_last_release,focusAll',
limit: 100,
offset: 0,
app_id: this.appId
})
)
}
#getUrlParts(url: string): ['artist' | 'album' | 'track', string] {
const urlObj = new URL(url)
if (urlObj.hostname == 'www.qobuz.com' || urlObj.hostname == 'qobuz.com') {
const urlParts = url
.match(/^https?:\/\/(?:www\.)?qobuz\.com\/[a-z]{2}-[a-z]{2}\/(.*?)\/.*?\/(.*?)$/)
?.slice(1, 3)
if (!urlParts) throw new Error('URL not supported')
urlParts[1] = urlParts[1].replace(/\?.*?$/, '')
const [type, id] = urlParts
switch (type) {
case 'interpreter':
return ['artist', id]
case 'album':
case 'track':
return [type, id]
default:
throw new Error('URL unrecognised')
}
}
const urlParts = url
.match(/^https:\/\/(?:play|open)\.qobuz\.com\/(.*?)\/([^/]*?)\/?$/)
?.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]]
}
getTypeFromUrl(url: string): 'artist' | 'album' | 'track' {
return this.#getUrlParts(url)[0]
}
async getByUrl(url: string): Promise<GetByUrlResponse> {
const [type, id] = this.#getUrlParts(url)
switch (type) {
case 'track': {
return {
type,
getStream: () => {
return this.#getFileUrl(id)
},
metadata: await this.#getTrackMetadata(id)
}
}
case 'album': {
const album = await this.#getAlbum(id)
return {
type,
tracks: <Track[]>album.tracks,
metadata: album.metadata
}
}
case 'artist': {
return {
type,
metadata: await this.#getArtistMetadata(id)
}
}
default:
throw new Error('URL unrecognised')
}
}
}

View File

@@ -0,0 +1,101 @@
import { Artist, Album, Track } from '../../types.js'
export interface RawArtist {
id: number
name: string
picture?: string
image?: {
small: string
medium: string
large: string
}
albums?: {
items: RawAlbum[]
}
tracks_appears_on?: {
items: RawTrack[]
}
}
export function parseArtist(raw: RawArtist): Artist {
const artist: Artist = {
id: raw.id.toString(),
url: `https://play.qobuz.com/artist/${raw.id.toString()}`,
name: raw.name
}
if (raw.picture) artist.pictures = [raw.picture]
else if (raw.image) artist.pictures = [raw.image.small, raw.image.medium, raw.image.large]
if (raw.albums) artist.albums = raw.albums.items.map(parseAlbum)
if (raw.tracks_appears_on) artist.tracks = raw.tracks_appears_on.items.map(parseTrack)
return artist
}
export interface RawAlbum {
title: string
id: string
url: string
image: {
thumbnail: string
small: string
large: string
}
tracks?: {
items: RawTrack[]
}
artists?: RawArtist[]
artist: RawArtist
}
export function parseAlbum(raw: RawAlbum): Album {
return {
title: raw.title,
id: raw.id,
url: raw.url ?? `https://play.qobuz.com/album/${raw.id}`,
coverArtwork: [
{
url: raw.image.thumbnail,
width: 50,
height: 50
},
{
url: raw.image.small,
width: 230,
height: 230
},
{
url: raw.image.large,
width: 600,
height: 600
}
],
artists: raw.artists?.map(parseArtist) ?? [parseArtist(raw.artist)]
}
}
export interface RawTrack {
title: string
id: number
copyright?: string
performer: RawArtist
album?: RawAlbum
track_number?: number
media_number?: number
duration: number
}
export function parseTrack(raw: RawTrack): Track {
const track: Track = {
title: raw.title,
id: raw.id.toString(),
url: `https://play.qobuz.com/track/${raw.id.toString()}`,
copyright: raw.copyright,
artists: [parseArtist(raw.performer)],
durationMs: raw.duration * 1000
}
if (raw.album) track.album = parseAlbum(raw.album)
if (raw.track_number) track.trackNumber = raw.track_number
if (raw.media_number) track.discNumber = raw.media_number
return track
}

View File

@@ -0,0 +1,14 @@
export const DEFAULT_HEADERS = {
Accept: 'application/json, text/javascript, */*',
'Accept-Language': 'en-US',
Connection: 'keep-alive',
DNT: '1',
Origin: 'https://soundcloud.com',
Referer: 'https://soundcloud.com/',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-site',
'Sec-GPC': '1',
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/113.0'
}
export const SC_VERSION = '1686222762'

View File

@@ -0,0 +1,255 @@
import fetch from 'node-fetch'
import { DEFAULT_HEADERS, SC_VERSION } from './constants.js'
import { Streamer, SearchResults, GetByUrlResponse, GetStreamResponse } from '../../types.js'
import {
parseAlbum,
parseTrack,
parseArtist,
RawSearchResults,
RawTrack,
RawAlbum,
RawArtist,
ScClient
} from './parse.js'
function headers(token?: string): HeadersInit {
const headers: HeadersInit = DEFAULT_HEADERS
if (token) headers['Authentication'] = 'OAuth' + token
return headers
}
interface SoundcloudTranscoding {
url: string
preset: string
duration: number
snipped: boolean
format: {
protocol: string
mime_type: string
}
quality: string
}
export default class Soundcloud implements Streamer {
hostnames = ['soundcloud.com', 'm.soundcloud.com', 'www.soundcloud.com']
oauthToken?: string
token?: string
async search(query: string, limit = 20): Promise<SearchResults> {
const client = await this.#getClient()
if (this.oauthToken) client.id = this.oauthToken
const response = await fetch(
this.#formatURL(
`https://api-v2.soundcloud.com/search?q=${encodeURIComponent(
query
)}&app_locale=en&limit=${limit}`,
client
),
{ method: 'get', headers: headers(this.token) }
)
if (!response.ok) {
const errMsg = await response.text()
try {
console.error('Soundcloud error response:', JSON.parse(errMsg))
} catch (error) {
console.error('Soundcloud error response:', errMsg)
}
throw new Error(`Searching Soundcloud failed with status code ${response.status}.`)
}
const resultResponse = <RawSearchResults>await response.json()
const items: SearchResults = {
query,
albums: [],
tracks: [],
artists: []
}
for (const i in resultResponse.collection) {
if (resultResponse.collection[i].kind == 'track')
items.tracks.push(await parseTrack(<RawTrack>resultResponse.collection[i]))
else if (resultResponse.collection[i].kind == 'playlist')
items.albums.push(await parseAlbum(<RawAlbum>resultResponse.collection[i]))
else if (resultResponse.collection[i].kind == 'user')
items.artists.push(parseArtist(<RawArtist>resultResponse.collection[i]))
}
return items
}
async #getClient(): Promise<ScClient> {
const response = await (
await fetch(`https://soundcloud.com/`, {
method: 'get',
headers: headers(this.token)
})
).text()
const client = {
version: SC_VERSION,
anonId: response.split(`[{"hydratable":"anonymousId","data":"`)[1].split(`"`)[0],
id: await fetchKey(response)
}
return client
}
getTypeFromUrl(url: string): 'artist' | 'album' | 'track' {
const { pathname } = new URL(url)
if (pathname.split('/').slice(1).length == 1) return 'artist'
else {
if (pathname.split('/')?.[2] == 'sets') return 'album'
else return 'track'
}
}
async getByUrl(url: string): Promise<GetByUrlResponse> {
return await this.#getMetadata(url)
}
#formatURL(og: string, client: ScClient): string {
let url = og
const parsed = new URL(og)
if (!parsed.searchParams.get('user_id')) url = url + `&user_id=${client.anonId}`
if (!parsed.searchParams.get('client_id')) url = url + `&client_id=${client.id}`
if (!parsed.searchParams.get('app_version')) url = url + `&app_version=${client.version}`
if (!parsed.searchParams.get('app_locale')) url = url + `&app_locale=en`
return url
}
async #getMetadata(url: string): Promise<GetByUrlResponse> {
// loosely based off: https://github.com/wukko/cobalt/blob/92c0e1d7b7df262fcd82ea7f5cf8c58c6d2ad744/src/modules/processing/services/soundcloud.js
const type = this.getTypeFromUrl(url)
const client = await this.#getClient()
if (this.oauthToken) client.id = this.oauthToken
// getting the IDs and track authorization
const html = await (await fetch(url, { method: 'get', headers: headers(this.token) })).text()
switch (type) {
case 'track': {
const trackId = html
.split(`{"hydratable":"sound",`)?.[1]
?.split(`"id":`)?.[1]
?.split(',')?.[0]
const trackAuthorization = html.split(`"track_authorization":"`)[1].split(`"`)[0]
const api = JSON.parse(
await (
await fetch(
this.#formatURL(`https://api-v2.soundcloud.com/tracks?ids=${trackId}`, client),
{ method: 'get', headers: headers(this.token) }
)
).text()
)[0]
return {
type: 'track',
getStream: async () => {
return await getStream(api.media.transcodings, trackAuthorization, client)
},
metadata: await parseTrack(api)
}
}
case 'album': {
const data = JSON.parse(html.split(`"hydratable":"playlist","data":`)[1].split(`}];`)[0])
const parsed: GetByUrlResponse = {
type: 'album',
metadata: await parseAlbum(data),
tracks: await Promise.all(
data.tracks.map(async (track: RawTrack) => {
if (!track.title) track = await this.#getRawTrackInfo(track.id, client)
track.user = data.user
return parseTrack(track)
})
)
}
for (const i in data.tracks) {
let track = data.tracks[i]
const parsedTrack = {
id: track.id,
title: track.title,
url: track.permalink_url,
artists: [
{
id: data.user.id,
name: data.user.username,
url: data.user.permalink_url,
pictures: [data.user.avatar_url.replace('-large', '-original')]
}
],
durationMs: track.media?.transcodings?.[0]?.duration,
coverArtwork: track.artwork_url?.replace('-large', '-original')
}
parsed.tracks.push(parsedTrack)
}
return parsed
}
case 'artist': {
const data = JSON.parse(html.split(`{"hydratable":"user","data":`)[1].split(`}];`)[0])
return {
type: 'artist',
metadata: parseArtist(data)
}
}
}
}
async #getRawTrackInfo(id: number | string, client: ScClient) {
const api = JSON.parse(
await (
await fetch(this.#formatURL(`https://api-v2.soundcloud.com/tracks?ids=${id}`, client), {
method: 'get',
headers: headers(this.token)
})
).text()
)[0]
return api
}
}
async function fetchKey(response: string) {
// loosely based on https://gitlab.com/sylviiu/soundcloud-key-fetch/-/blob/master/index.js
const keys = response.split(`<script crossorigin src="`)
let streamKey
for (const i in keys) {
if (typeof streamKey == 'string') continue
const key = keys[i].split(`"`)[0]
if (!key.startsWith('https://a-v2.sndcdn.com/assets/50-')) continue
const script = await (await fetch(key)).text()
if (script.split(`,client_id:"`).length > 1 && !streamKey) {
streamKey = script.split(`,client_id:"`)?.[1]?.split(`"`)?.[0]
} else continue
}
return streamKey
}
async function getStream(
transcodings: Array<SoundcloudTranscoding>,
trackAuth: string,
client: ScClient
): Promise<GetStreamResponse> {
const progressive = transcodings.filter((x) => x.format?.protocol == 'progressive')[0]
const streamUrlResp = await fetch(
`${progressive.url}?client_id=${client.id}&track_authorization=${trackAuth}`
)
const { url } = <{ url: string }>await streamUrlResp.json()
const streamResp = await fetch(url)
return {
mimeType: progressive.format.mime_type,
sizeBytes: parseInt(<string>streamResp.headers.get('Content-Length')),
stream: <NodeJS.ReadableStream>streamResp.body
}
}

View File

@@ -0,0 +1,111 @@
import { Artist, Album, Track } from '../../types.js'
import fetch from 'node-fetch'
import sizeOf from 'image-size'
async function parseCoverArtwork(url: string) {
const resp = await fetch(url)
if (!resp.body) throw new Error('No body on image')
const chunks = []
for await (const chunk of resp.body) {
chunks.push(Buffer.from(chunk))
}
const dimensions = sizeOf(Buffer.concat(chunks))
if (!dimensions.width || !dimensions.height) throw new Error(`Couldn't get dimensions`)
return {
url,
width: dimensions.width,
height: dimensions.height
}
}
export interface RawArtist {
id: number
username: string
full_name: string
avatar_url?: string
permalink_url: string
kind: 'user'
}
export function parseArtist(raw: RawArtist): Artist {
const artist: Artist = {
id: raw.id,
url: raw.permalink_url,
name: raw.username
}
if (raw.avatar_url) {
artist.pictures = [raw.avatar_url, raw.avatar_url?.replace('-large', '-original')]
}
return artist
}
export interface RawAlbum {
title: string
id: number
permalink_url: string
tracks: RawTrack[]
track_count: number
release_date: string
user: RawArtist
kind: 'playlist'
}
export async function parseAlbum(raw: RawAlbum): Promise<Album> {
const album: Album = {
id: raw.id,
title: raw.title,
url: raw.permalink_url,
trackCount: raw.track_count,
releaseDate: new Date(raw.release_date),
artists: [parseArtist(raw.user)]
}
if (raw.tracks?.[0]?.artwork_url) {
album.coverArtwork = [
await parseCoverArtwork(raw.tracks[0].artwork_url.replace('-large', '-original'))
]
}
return album
}
export interface RawTrack {
media?: {
transcodings?: { duration: number }[]
}
kind: 'track'
id: number
title: string
permalink_url: string
artwork_url?: string
full_duration: number
user: RawArtist
}
export async function parseTrack(raw: RawTrack): Promise<Track> {
const track: Track = {
id: raw.id,
title: raw.title,
url: raw.permalink_url,
artists: [parseArtist(raw.user)],
durationMs: raw.media?.transcodings?.[0]?.duration
}
if (raw.artwork_url) {
track.coverArtwork = [await parseCoverArtwork(raw.artwork_url?.replace('-large', '-original'))]
}
return track
}
export interface RawSearchResults {
collection: (RawAlbum | RawTrack | RawArtist)[]
total_results: number
facets: []
next_href: string
query_urn: string
}
export interface ScClient {
anonId: string
version: string
id: string | undefined
}

View File

@@ -0,0 +1,2 @@
export const TIDAL_AUTH_BASE = 'https://auth.tidal.com/v1/'
export const TIDAL_API_BASE = 'https://listen.tidal.com/v1/'

363
src/streamers/tidal/main.ts Normal file
View File

@@ -0,0 +1,363 @@
import fetch from 'node-fetch'
import { spawn } from 'child_process'
import { GetByUrlResponse, GetStreamResponse, SearchResults, Streamer, Track } from '../../types.js'
import { TIDAL_AUTH_BASE, TIDAL_API_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
}
interface LoginData {
error: string
access_token: string
refresh_token: string
expires_in: number
}
export default class Tidal implements Streamer {
tvToken: string
tvSecret: string
accessToken: string
refreshToken: string
expires: number
hostnames = ['tidal.com', 'www.tidal.com', 'listen.tidal.com']
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
const getReady = async () => {
if (!this.refreshToken) return
if (await this.sessionValid()) return
const success = await this.refresh()
if (success) console.log(`Refreshed tokens successfully`)
else console.log(`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 } = {}): Promise<unknown> {
if (Date.now() > this.expires) await this.refresh()
if (this.failedAuth) throw new Error(`Last request failed to authorize, get new tokens`)
params.countryCode = params.countryCode ?? 'US'
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(
`${TIDAL_API_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('Refreshed tokens')
if (this.failedAuth) {
throw new Error('Auth failed. Try getting new tokens.')
}
return this.#get(url, params)
}
console.error('Tidal error response:', json)
} catch (error) {
console.error('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 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 = <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 = <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
console.log('Using the following new config:', this.getCurrentConfig())
}
console.log(`Log in at ${linkUrl}`)
checkToken()
return linkUrl
}
getCurrentConfig() {
return {
tvToken: this.tvToken,
tvSecret: this.tvSecret,
accessToken: this.accessToken,
refreshToken: this.refreshToken,
expires: this.expires
}
}
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 = <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<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,
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 = <RawTrack>await this.#get(`tracks/${trackId}`)
const contributorResponse = (<{ items: Contributor[] }>(
await this.#get(`tracks/${trackId}/contributors`)
)).items
trackResponse.album = <RawAlbum>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(<RawAlbum>albumResponse)
}
async #getAlbumTracks(albumId: number | string): Promise<Track[]> {
interface TracksContributors {
items: {
item: RawTrack
credits: Contributor[]
}[]
}
const contributorResponse = <TracksContributors>await this.#get(
`albums/${albumId}/items/credits`,
{
replace: 'true',
offset: 0,
includeContributors: 'true'
}
)
return contributorResponse.items.map((item) => parseTrack(addCredits(item.item, item.credits)))
}
async #getArtist(artistId: number | string) {
const [artistResponse, albumsResponse, tracksResponse] = await Promise.all([
<Promise<RawArtist>>this.#get(`artists/${artistId}`),
<Promise<{ items: RawAlbum[] }>>this.#get(`artists/${artistId}/albums`, {
limit: 20
}),
<Promise<{ items: RawTrack[] }>>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'): Promise<GetStreamResponse> {
interface PlaybackInfo {
manifest: string
manifestMimeType: string
}
const playbackInfoResponse = <PlaybackInfo>await this.#get(
`tracks/${trackId}/playbackinfopostpaywall`,
{
playbackmode: 'STREAM',
assetpresentation: 'FULL',
audioquality: quality,
prefetch: 'false'
}
)
const manifestStr = Buffer.from(playbackInfoResponse.manifest, 'base64').toString('utf-8')
if (playbackInfoResponse.manifestMimeType != 'application/dash+xml') {
interface Manifest {
mimeType: string
urls: string[]
}
const manifest = <Manifest>JSON.parse(manifestStr)
const streamResponse = await fetch(manifest.urls[0])
return {
mimeType: manifest.mimeType,
sizeBytes: parseInt(<string>streamResponse.headers.get('Content-Length')),
stream: <NodeJS.ReadableStream>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: playbackInfoResponse.manifestMimeType,
sizeBytes: manifestStr.length,
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]]
}
getTypeFromUrl(url: string): 'artist' | 'album' | 'track' {
return this.#getUrlParts(url)[0]
}
async getByUrl(url: string): Promise<GetByUrlResponse> {
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)
}
}
}
}

View File

@@ -0,0 +1,191 @@
import { Album, Artist, Track } from '../../types.js'
import { DOMParser } from 'xmldom-qsa'
export interface RawArtist {
id: string
url?: string
name: string
picture?: string
}
export function parseArtist(raw: RawArtist): Artist {
const picturePath = raw.picture?.replace(/-/gm, '/')
const artist: Artist = {
id: raw.id,
url: raw.url ?? `https://www.tidal.com/artist/${raw.id}`,
name: raw.name
}
if (picturePath)
artist.pictures = [
`https://resources.tidal.com/images/${picturePath}/160x160.jpg`,
`https://resources.tidal.com/images/${picturePath}/320x320.jpg`,
`https://resources.tidal.com/images/${picturePath}/750x750.jpg`
]
return artist
}
export interface RawAlbum {
cover: string
id: number
url: string
numberOfTracks?: number
numberOfVolumes?: number
title: string
artists?: RawArtist[]
upc?: string
releaseDate?: string
}
export function parseAlbum(raw: RawAlbum): Album {
const coverPath = raw.cover.replace(/-/gm, '/')
const album: Album = {
id: raw.id,
url: raw.url ?? `https://tidal.com/browse/album/${raw.id}`,
title: raw.title,
coverArtwork: [
{
url: `https://resources.tidal.com/images/${coverPath}/160x160.jpg`,
width: 160,
height: 160
},
{
url: `https://resources.tidal.com/images/${coverPath}/320x320.jpg`,
width: 320,
height: 320
},
{
url: `https://resources.tidal.com/images/${coverPath}/1280x1280.jpg`,
width: 1280,
height: 1280
}
]
}
if (raw.upc) album.upc = raw.upc
if (raw.artists) album.artists = raw.artists.map(parseArtist)
if (raw.numberOfTracks) album.trackCount = raw.numberOfTracks
if (raw.numberOfVolumes) album.discCount = raw.numberOfVolumes
if (raw.releaseDate) album.releaseDate = new Date(raw.releaseDate)
return album
}
export interface RawTrack {
url: string
id: number
artists: RawArtist[]
duration: number
copyright: string
isrc?: string
producers?: string[]
composers?: string[]
lyricists?: string[]
explicit?: boolean
trackNumber?: number
volumeNumber?: number
title: string
album: RawAlbum
}
export function parseTrack(raw: RawTrack): Track {
const track: Track = {
url: raw.url,
id: raw.id,
title: raw.title,
durationMs: raw.duration * 1000,
artists: raw.artists.map(parseArtist),
album: parseAlbum(raw.album)
}
if (raw.producers) track.producers = raw.producers
if (raw.composers) track.composers = raw.composers
if (raw.lyricists) track.lyricists = raw.lyricists
if (raw.isrc) track.isrc = raw.isrc
if (raw.copyright) track.copyright = raw.copyright
if (raw.explicit) track.explicit = raw.explicit
if (raw.trackNumber) track.trackNumber = raw.trackNumber
if (raw.volumeNumber) track.discNumber = raw.volumeNumber
return track
}
export interface Contributor {
name: string
role: string
}
export interface ContributorsByType {
type: string
contributors: { name: string; id: number }[]
}
export function addCredits(raw: RawTrack, credits: Contributor[] | ContributorsByType[]): RawTrack {
if (credits.length > 0 && 'type' in credits[0]) {
credits = (<ContributorsByType[]>credits)
.map((group) => {
return group.contributors.map((contributor) => {
return {
name: contributor.name,
role: group.type
}
})
})
.flat()
}
for (const contributor of <Contributor[]>credits) {
switch (contributor.role) {
case 'Producer':
if (!raw.producers) raw.producers = []
raw.producers.push(contributor.name)
break
case 'Composer':
if (!raw.composers) raw.composers = []
raw.composers.push(contributor.name)
break
case 'Lyricist':
if (!raw.lyricists) raw.lyricists = []
raw.lyricists.push(contributor.name)
break
default:
break
}
}
return raw
}
export function parseMpd(mpdString: string): string[] {
const tracks: string[][] = []
const { documentElement: doc } = new DOMParser().parseFromString(mpdString, 'application/xml')
for (const adaptationSet of [...doc.querySelectorAll('AdaptationSet')]) {
const contentType = adaptationSet.getAttribute('contentType')
if (contentType != 'audio') throw new Error('Lucida only supports audio MPDs')
for (const rep of [...doc.querySelectorAll('Representation')]) {
let codec = rep.getAttribute('codecs')?.toLowerCase()
if (codec?.startsWith('mp4a')) codec = 'aac'
const segTemplate = rep.querySelector('SegmentTemplate')
if (!segTemplate) throw new Error('No SegmentTemplate found')
const initializationUrl = segTemplate.getAttribute('initialization')
if (!initializationUrl) throw new Error('No initialization url')
const mediaUrl = segTemplate.getAttribute('media')
if (!mediaUrl) throw new Error('No media url')
const trackUrls = []
const timeline = segTemplate.querySelector('SegmentTimeline')
if (timeline) {
const timeList = []
// let currentTime = 0
for (const s of [...timeline.querySelectorAll('S')]) {
if (s.getAttribute('t')) {
// currentTime = parseInt(<string>s.getAttribute('t'))
}
const r = parseInt(s.getAttribute('r') || '0') + 1
if (!s.getAttribute('d')) throw new Error('No d property on SegmentTimeline')
for (let i = 0; i < r; i++) {
timeList.push(0)
// currentTime += parseInt(<string>s.getAttribute('d'))
}
}
for (const i in timeList) {
trackUrls.push(mediaUrl.replace('$Number$', i))
}
}
tracks.push(trackUrls)
}
}
return tracks[0]
}

93
src/types.ts Normal file
View File

@@ -0,0 +1,93 @@
type Id = string | number
type ItemType = 'artist' | 'album' | 'track'
export interface CoverArtwork {
url: string
width: number
height: number
}
export interface Artist {
id: Id
url: string
pictures?: string[]
name: string
albums?: Album[]
tracks?: Track[]
}
export interface Album {
title: string
id: Id
url: string
upc?: string
trackCount?: number
discCount?: number
releaseDate?: Date
coverArtwork?: CoverArtwork[]
artists?: Artist[]
}
export interface Track {
title: string
id: Id
url: string
explicit?: boolean
trackNumber?: number
discNumber?: number
copyright?: string
artists: Artist[]
isrc?: string
producers?: string[]
composers?: string[]
lyricists?: string[]
album?: Album
durationMs?: number
coverArtwork?: CoverArtwork[]
}
export interface SearchResults {
query: string
albums: Album[]
tracks: Track[]
artists: Artist[]
}
export interface GetStreamResponse {
sizeBytes: number
stream: NodeJS.ReadableStream
mimeType: string
}
// got a better name for this?
export type GetByUrlResponse =
| TrackGetByUrlResponse
| ArtistGetByUrlResponse
| AlbumGetByUrlResponse
export interface TrackGetByUrlResponse {
type: 'track'
getStream(): Promise<GetStreamResponse>
metadata: Track
}
export interface ArtistGetByUrlResponse {
type: 'artist'
metadata: Artist
}
export interface AlbumGetByUrlResponse {
type: 'album'
tracks: Track[]
metadata: Album
}
export interface Streamer {
hostnames: string[]
search(query: string, limit: number): Promise<SearchResults>
getTypeFromUrl(url: string): ItemType
getByUrl(url: string): Promise<GetByUrlResponse>
}
export interface StreamerWithLogin extends Streamer {
login(username: string, password: string): void
}