mirror of
https://codeberg.org/lucida/lucida.git
synced 2026-11-12 14:47:24 -05:00
Initial commit
This commit is contained in:
58
src/index.ts
Normal file
58
src/index.ts
Normal 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
|
||||
9
src/streamers/qobuz/constants.ts
Normal file
9
src/streamers/qobuz/constants.ts
Normal 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
246
src/streamers/qobuz/main.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
101
src/streamers/qobuz/parse.ts
Normal file
101
src/streamers/qobuz/parse.ts
Normal 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
|
||||
}
|
||||
14
src/streamers/soundcloud/constants.ts
Normal file
14
src/streamers/soundcloud/constants.ts
Normal 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'
|
||||
255
src/streamers/soundcloud/main.ts
Normal file
255
src/streamers/soundcloud/main.ts
Normal 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
|
||||
}
|
||||
}
|
||||
111
src/streamers/soundcloud/parse.ts
Normal file
111
src/streamers/soundcloud/parse.ts
Normal 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
|
||||
}
|
||||
2
src/streamers/tidal/constants.ts
Normal file
2
src/streamers/tidal/constants.ts
Normal 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
363
src/streamers/tidal/main.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
191
src/streamers/tidal/parse.ts
Normal file
191
src/streamers/tidal/parse.ts
Normal 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
93
src/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user