start deezer work

This commit is contained in:
lucida
2025-07-20 18:28:43 -04:00
parent b4f4eb259a
commit 324b137aee
21 changed files with 230 additions and 3615 deletions

View File

@@ -4,98 +4,6 @@ A modular downloading tool. Includes code for a small collection of streaming se
Lucida is made to use few NodeJS dependencies and no system dependencies (...besides `ffmpeg`)
## Usage
```ts
import Lucida from 'lucida'
import Tidal from 'lucida/streamers/tidal/main.js'
import Qobuz from 'lucida/streamers/qobuz/main.js'
import Spotify from 'lucida/streamers/spotify/main.js'
const lucida = new Lucida({
modules: {
tidal: new Tidal({
// tokens
}),
qobuz: new Qobuz({
// tokens
}),
spotify: new Spotify({
// options
})
// Any other modules
},
logins: {
qobuz: {
username: '',
password: ''
},
spotify: {
username: '',
password: ''
}
}
})
// only needed if using modules which use the logins configuration rather than tokens
await lucida.login()
const track = await lucida.getByUrl('https://tidal.com/browse/track/255207223')
await fs.promises.writeFile('test.flac', (await track.getStream()).stream)
// only needed for modules which create persistent connections (of the built-in modules, this is just Spotify)
await lucida.disconnect()
```
For using a specific module, you can just use the functions built into the `Streamer` interface.
## Project Structure
### src/streamers/{app-name}
#### main.ts
Default export is a class which implements the `Streamer` interface:
```ts
interface Streamer {
hostnames: string[]
search(query: string, limit: number): Promise<SearchResults>
getByUrl(url: string): Promise<GetByUrlResponse>
}
```
They can optionally include a login function in this class which takes a username and password (if supported):
```ts
async login(username: string, password: string): void
```
Options for the app, including tokens (if supported by the given app), are passed to the class's constructor:
```ts
new StreamerApp({ token: 'secret!' })
```
The classes can also include their own custom functions. Any function used by Lucida's app-agnostic code should be defined in the `Streamer` interface for compatibility across multiple apps.
#### parse.ts
Functions for parsing the app's API into the types defined in `src/types.ts`.
#### constants.ts
Constants used by `main.ts`. Secrets should not be defined here (or anywhere else in the project).
### src/index.ts
Wraps all the `Streamer`s using a module system. See the usage section.
### src/types.ts
Types used across the project. The purpose of many of these is to make sure all apps' functions return the same types so the rest of the logic can work across all apps the same.
## Acknowledgements
Lucida is partially inspired by [OrpheusDL](https://github.com/yarrm80s/orpheusdl), a Python program for music archival which can be used similarly to Lucida. Some scripts inside Lucida are modeled after OrpheusDL modules.

View File

@@ -1,6 +1,6 @@
{
"name": "lucida",
"version": "2.0.0-54",
"version": "3.0.0",
"description": "A modular music downloader tool",
"main": "build/index.js",
"type": "module",
@@ -34,11 +34,7 @@
},
"license": "OQL",
"dependencies": {
"blowfish-cbc": "^1.0.1",
"image-size": "^1.1.1",
"librespot": "^0.2.21",
"undici": "^6.19.4",
"xmldom-qsa": "^1.1.3"
"undici": "^6.19.4"
},
"devDependencies": {
"@types/node": "^20.11.17",

146
pnpm-lock.yaml generated
View File

@@ -8,21 +8,9 @@ importers:
.:
dependencies:
blowfish-cbc:
specifier: ^1.0.1
version: 1.0.1
image-size:
specifier: ^1.1.1
version: 1.1.1
librespot:
specifier: ^0.2.21
version: 0.2.21
undici:
specifier: ^6.19.4
version: 6.19.4
xmldom-qsa:
specifier: ^1.1.3
version: 1.1.3
devDependencies:
'@types/node':
specifier: ^20.11.17
@@ -73,10 +61,6 @@ packages:
resolution: {integrity: sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
'@fastify/busboy@2.1.0':
resolution: {integrity: sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==}
engines: {node: '>=14'}
'@humanwhocodes/config-array@0.11.14':
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
engines: {node: '>=10.10.0'}
@@ -100,36 +84,6 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@protobufjs/aspromise@1.1.2':
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
'@protobufjs/base64@1.1.2':
resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
'@protobufjs/codegen@2.0.4':
resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==}
'@protobufjs/eventemitter@1.1.0':
resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==}
'@protobufjs/fetch@1.1.0':
resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==}
'@protobufjs/float@1.0.2':
resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
'@protobufjs/inquire@1.1.0':
resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==}
'@protobufjs/path@1.1.2':
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
'@protobufjs/pool@1.1.0':
resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
'@protobufjs/utf8@1.1.0':
resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -231,9 +185,6 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
blowfish-cbc@1.0.1:
resolution: {integrity: sha512-o1JN6g6+ATW/4k7q1BZzy14VqLxwYa1mCii47qT4kmAaYD0NAfdM6/pz6uizbhTra/xunsPQI27LZt08OQS4sA==}
brace-expansion@1.1.11:
resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
@@ -402,11 +353,6 @@ packages:
resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==}
engines: {node: '>= 4'}
image-size@1.1.1:
resolution: {integrity: sha512-541xKlUw6jr/6gGuk92F+mYM5zaFAc5ahphvkqvNe2bQ6gVBkd6bfrmVJ2t4KDAfikAYZyIqTnktX3i6/aQDrQ==}
engines: {node: '>=16.x'}
hasBin: true
import-fresh@3.3.0:
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
engines: {node: '>=6'}
@@ -460,9 +406,6 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
librespot@0.2.21:
resolution: {integrity: sha512-oBAMqsI6UBfwhyNnt+XKtopWVXU+18zzhGgB6RFaRya8rTHOoNdZ1JkfYoRCoKWRsydoLAEIqC17ouZB1pwn3w==}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -470,9 +413,6 @@ packages:
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
long@5.2.3:
resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==}
lru-cache@6.0.0:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
@@ -498,10 +438,6 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
node-addon-api@8.1.0:
resolution: {integrity: sha512-yBY+qqWSv3dWKGODD6OGE6GnTX7Q2r+4+DfpqxHSHh8x0B4EKP9+wVGLS6U/AM1vxSNNmUEuIV5EGhYwPpfOwQ==}
engines: {node: ^18 || ^20 || >= 21}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@@ -550,10 +486,6 @@ packages:
engines: {node: '>=14'}
hasBin: true
protobufjs@7.2.6:
resolution: {integrity: sha512-dgJaEDDL6x8ASUZ1YqWciTRrdOuYNzoOf27oHNfdyvKqHr5i0FV7FSLU+aIeFjyFgVxrpTOtQUi0BLLBymZaBw==}
engines: {node: '>=12.0.0'}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -561,9 +493,6 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
queue@6.0.2:
resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -637,10 +566,6 @@ packages:
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici@5.28.3:
resolution: {integrity: sha512-3ItfzbrhDlINjaP0duwnNsKpDQk3acHI3gVJ1z4fmwMK31k5G9OVIAMLSIaP6w4FaGkaAkN6zaQO9LUvZ1t7VA==}
engines: {node: '>=14.0'}
undici@6.19.4:
resolution: {integrity: sha512-i3uaEUwNdkRq2qtTRRJb13moW5HWqviu7Vl7oYRYz++uPtGHJj+x7TGjcEuwS5Mt2P4nA0U9dhIX3DdB6JGY0g==}
engines: {node: '>=18.17'}
@@ -656,10 +581,6 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
xmldom-qsa@1.1.3:
resolution: {integrity: sha512-IJBOczBpAYrIBJFFsmCBwfBhwe4zdMR3Xz0ZBX0OFtgO49rLy/BWbhkegOwsthdBWb1gUtFK6ZZnGdT8ZqPRBA==}
engines: {node: '>=8.0.0'}
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
@@ -694,8 +615,6 @@ snapshots:
'@eslint/js@8.56.0': {}
'@fastify/busboy@2.1.0': {}
'@humanwhocodes/config-array@0.11.14':
dependencies:
'@humanwhocodes/object-schema': 2.0.2
@@ -720,29 +639,6 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.17.1
'@protobufjs/aspromise@1.1.2': {}
'@protobufjs/base64@1.1.2': {}
'@protobufjs/codegen@2.0.4': {}
'@protobufjs/eventemitter@1.1.0': {}
'@protobufjs/fetch@1.1.0':
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/inquire': 1.1.0
'@protobufjs/float@1.0.2': {}
'@protobufjs/inquire@1.1.0': {}
'@protobufjs/path@1.1.2': {}
'@protobufjs/pool@1.1.0': {}
'@protobufjs/utf8@1.1.0': {}
'@types/json-schema@7.0.15': {}
'@types/node@20.11.17':
@@ -864,10 +760,6 @@ snapshots:
balanced-match@1.0.2: {}
blowfish-cbc@1.0.1:
dependencies:
node-addon-api: 8.1.0
brace-expansion@1.1.11:
dependencies:
balanced-match: 1.0.2
@@ -1069,10 +961,6 @@ snapshots:
ignore@5.3.1: {}
image-size@1.1.1:
dependencies:
queue: 6.0.2
import-fresh@3.3.0:
dependencies:
parent-module: 1.0.1
@@ -1118,19 +1006,12 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
librespot@0.2.21:
dependencies:
protobufjs: 7.2.6
undici: 5.28.3
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
lodash.merge@4.6.2: {}
long@5.2.3: {}
lru-cache@6.0.0:
dependencies:
yallist: 4.0.0
@@ -1154,8 +1035,6 @@ snapshots:
natural-compare@1.4.0: {}
node-addon-api@8.1.0: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
@@ -1195,29 +1074,10 @@ snapshots:
prettier@3.2.5: {}
protobufjs@7.2.6:
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/base64': 1.1.2
'@protobufjs/codegen': 2.0.4
'@protobufjs/eventemitter': 1.1.0
'@protobufjs/fetch': 1.1.0
'@protobufjs/float': 1.0.2
'@protobufjs/inquire': 1.1.0
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.0
'@types/node': 20.11.17
long: 5.2.3
punycode@2.3.1: {}
queue-microtask@1.2.3: {}
queue@6.0.2:
dependencies:
inherits: 2.0.4
resolve-from@4.0.0: {}
reusify@1.0.4: {}
@@ -1272,10 +1132,6 @@ snapshots:
undici-types@5.26.5: {}
undici@5.28.3:
dependencies:
'@fastify/busboy': 2.1.0
undici@6.19.4: {}
uri-js@4.4.1:
@@ -1288,8 +1144,6 @@ snapshots:
wrappy@1.0.2: {}
xmldom-qsa@1.1.3: {}
yallist@4.0.0: {}
yocto-queue@0.1.0: {}

View File

@@ -1,7 +0,0 @@
import Spotify from './../build/streamers/spotify/main.js'
const client = new Spotify({ clientId: '9a8d2f0ce77a4e248bb71fefcb557637' })
await client.login(process.env.SPOTIFY_USERNAME, process.env.SPOTIFY_PASSWORD)
const storedCreds = client.getStoredCredentials()
console.log('[spotify] New config:', storedCreds)

View File

@@ -1,7 +0,0 @@
import Tidal from './../build/streamers/tidal/main.js'
const client = new Tidal({
tvToken: process.env.TV_TOKEN,
tvSecret: process.env.TV_SECRET
})
client.getTokens()

View File

@@ -1,6 +1,172 @@
export const GW_LIGHT_URL = 'https://www.deezer.com/ajax/gw-light.php'
export const CLIENT_ID = '447462'
export const CLIENT_SECRET = 'a83bf7f38ad2f137e444727cfc3775cf'
export const SIZES = [256, 512, 1024, 1200]
export const AcceptableAlbumTypes = ['upc']
export const AcceptableTrackTypes = ['isrc']
export const BLOWFISH_SECRET = 'g4el58wc0zvf9na1'
export const UPC = `https://api.deezer.com/album/upc:{upc}?limit=100`
export const ISRC = `https://api.deezer.com/track/isrc:{upc}`
export type RawUPCAlbum = {
id: number
title: string
upc: string
link: string
share: string
cover: string
cover_small: string
cover_medium: string
cover_big: string
cover_xl: string
md5_image: string
genre_id: number
genres: {
data: Genre[]
}
label: string
nb_tracks: number
duration: number
fans: number
release_date: string
available: boolean
tracklist: string
explicit_lyrics: boolean
explicit_contents_lyrics: number
explicit_contents_cover: number
contributors: Contributor[]
artist: ArtistFromAlbumUPC
type: 'album'
tracks: { data: TrackWithinUPCAlbum[] }
}
type Genre = {
id: number
name: string
picture: string
type: 'genre'
}
type Contributor = {
id: number
name: string
link: string
share: string
picture: string
picture_small: string
picture_medium: string
picture_big: string
picture_xl: string
radio: boolean
tracklist: string
type: 'artist'
role: 'Main'
}
type ArtistFromAlbumUPC = {
id: number
name: string
picture: string
picture_small: string
picture_medium: string
picture_big: string
picture_xl: string
tracklist: string
type: 'artist'
}
type TrackWithinUPCAlbum = {
id: number
readable: boolean
title: string
title_short: string
title_version: ''
link: string
duration: number
rank: number
explicit_lyrics: boolean
explicit_content_lyrics: number
explicit_content_cover: number
preview: string
md5_image: string
artist: {
id: number
name: string
tracklist: string
type: 'artist'
}
album: {
id: number
title: string
over: string
cover_small: string
cover_medium: string
cover_big: string
cover_xl: string
md5_image: string
tracklist: string
type: 'album'
}
type: 'track'
}
export type RawISRCTrack = {
id: number
readable: boolean
title: string
title_short: string
title_version: string
isrc: string
link: string
share: string
duration: number
track_position: number
disk_number: number
rank: number
release_date: string
explicit_lyrics: boolean
explicit_content_lyrics: number
explicit_content_cover: number
preview: string
bpm: number
gain: number
available_countries: string[] // is there a better way to do this?
contributors: Contributor[]
md5_image: string
track_token: string
artist: ArtistFromTrackISRC
album: AlbumFromTrackISRC
type: 'track'
}
type ArtistFromTrackISRC = {
id: number
name: string
picture: string
picture_small: string
picture_medium: string
picture_big: string
picture_xl: string
radio: boolean
tracklist: string
type: 'artist'
}
type AlbumFromTrackISRC = {
id: number
title: string
link: string
cover: string
cover_small: string
cover_medium: string
cover_big: string
cover_xl: string
md5_image: string
release_date: string
tracklist: string
type: 'album'
}
export type RawError = {
error: {
type: string
message: string
code: number
}
}

View File

@@ -1,571 +1,19 @@
import { Dispatcher, fetch } from 'undici'
import {
Album,
Artist,
GetByUrlResponse,
GetStreamResponse,
ItemType,
PlaylistGetByUrlResponse,
SearchResults,
StreamerAccount,
StreamerWithLogin,
Track
} from '../../types.js'
import { BLOWFISH_SECRET, CLIENT_ID, CLIENT_SECRET, GW_LIGHT_URL } from './constants.js'
import { createHash } from 'crypto'
import {
DeezerAlbum,
DeezerArtist,
DeezerPlaylistMetadata,
DeezerFormat,
DeezerLoginResponse,
DeezerMediaResponse,
DeezerTrack,
DeezerUserData,
parseAlbum,
parseArtist,
parseTrack,
parsePlaylistMetadata,
DeezerLyrics
} from './parse.js'
import { Readable, Transform } from 'stream'
import { Blowfish } from 'blowfish-cbc'
import { fetch } from 'undici'
import { BarcodeAlbumType, LookupType } from '../../types.js'
import { AcceptableAlbumTypes, RawError, RawUPCAlbum, UPC } from './constants.js'
import { parseAlbum, parseError } from './parse.js'
interface DeezerOptions {
arl?: string
dispatcher?: Dispatcher | undefined
export async function getTrack(tag: string, type: BarcodeAlbumType) {
if (!AcceptableAlbumTypes.includes(type)) throw new Error('Cannot use this type with Deezer.')
switch (type) {
case 'upc': {
const data = (await (await fetch(UPC.replace('{upc}', tag))).json()) as any
if (data?.id) return parseAlbum(data as RawUPCAlbum)
else throw new Error(parseError(data as RawError))
}
}
}
interface APIMethod {
'deezer.getUserData': DeezerUserData
'deezer.pageArtist': {
DATA: DeezerArtist
TOP: { data?: DeezerTrack[] }
ALBUMS: { data?: DeezerAlbum[] }
}
'deezer.pageAlbum': {
DATA: DeezerAlbum
SONGS: { data: DeezerTrack[] }
}
'playlist.getSongs': {
data: DeezerTrack[]
}
'deezer.pageTrack': {
DATA: DeezerTrack
LYRICS?: DeezerLyrics
}
'deezer.pagePlaylist': {
DATA: DeezerPlaylistMetadata
}
'user.getArl': string
'search.music': { data: (DeezerArtist | DeezerAlbum | DeezerTrack)[] }
'song.getListData': { data: DeezerTrack[] }
'song.getData': DeezerTrack
}
export default class Deezer implements StreamerWithLogin {
hostnames = ['deezer.com', 'www.deezer.com', 'deezer.page.link']
testData = {
'https://www.deezer.com/us/artist/1194083': {
type: 'artist',
title: 'Tyler, The Creator'
},
'https://www.deezer.com/us/track/559711712': {
type: 'track',
title: 'Potato Salad'
},
'https://www.deezer.com/us/album/97140952': {
type: 'album',
title: 'IGOR'
}
} as const
headers: { [header: string]: string } = {
Accept: '*/*',
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36',
'Content-Type': 'text/plain;charset=UTF-8',
Origin: 'https://www.deezer.com',
'Sec-Fetch-Site': 'same-origin',
'Sec-Fetch-Mode': 'same-origin',
'Sec-Fetch-Dest': 'empty',
Referer: 'https://www.deezer.com/',
'Accept-Language': 'en-US,en;q=0.9'
}
arl?: string
apiToken?: string
licenseToken?: string
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[] } = {},
doSessionRenewal = true
): Promise<APIMethod[T]> {
let apiToken = this.apiToken
if (method == 'deezer.getUserData' || method == 'user.getArl') apiToken = ''
const url = `${GW_LIGHT_URL}?${new URLSearchParams({
method,
input: '3',
api_version: '1.0',
api_token: apiToken ?? '',
cid: Math.floor(Math.random() * 1e9).toString()
})}`
interface DeezerResponse {
results: APIMethod[T]
error: number[] | { [key: string]: string }
}
const body = JSON.stringify(data)
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]
// 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}`
const res = <APIMethod['deezer.getUserData']>results
this.apiToken = res.checkForm
this.licenseToken = res.USER?.OPTIONS?.license_token
this.country = res?.COUNTRY
this.language = res?.USER?.SETTING?.global?.language
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)
}
return results
}
async #loginViaArl(arl: string) {
this.arl = arl
this.headers['Cookie'] = `arl=${arl}`
const userData = await this.#apiCall('deezer.getUserData')
if (userData.USER.USER_ID == 0) {
delete this.headers['Cookie']
this.arl = undefined
throw new Error('Invalid ARL')
}
return userData
}
#md5(str: string) {
return createHash('md5').update(str).digest('hex')
}
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,
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}`
password = this.#md5(password)
const loginReq = await fetch(
`https://connect.deezer.com/oauth/user_auth.php?${new URLSearchParams({
app_id: CLIENT_ID,
login: username,
password,
hash: this.#md5(CLIENT_ID + username + password + CLIENT_SECRET)
})}`,
{ headers: this.headers, dispatcher: this.dispatcher }
)
const { error } = <DeezerLoginResponse>await loginReq.json()
if (error) throw new Error('Error while getting access token, check your credentials')
const arl = await this.#apiCall('user.getArl')
await this.#loginViaArl(arl)
}
}
/* ---------- SEARCH ---------- */
async search(query: string, limit: number): Promise<SearchResults> {
const results = await Promise.all([
this.#searchArtists(query, limit),
this.#searchAlbums(query, limit),
this.#searchTracks(query, limit)
])
const [artists, albums, tracks] = results
return { query, albums, tracks, artists }
}
async #searchArtists(query: string, limit: number): Promise<Artist[]> {
const { data } = await this.#apiCall('search.music', {
query,
start: 0,
nb: limit,
filter: 'ALL',
output: 'ARTIST'
})
return data.map((a) => parseArtist(a as DeezerArtist))
}
async #searchAlbums(query: string, limit: number): Promise<Album[]> {
const { data } = await this.#apiCall('search.music', {
query,
start: 0,
nb: limit,
filter: 'ALL',
output: 'ALBUM'
})
return data.map((a) => parseAlbum(a as DeezerAlbum))
}
async #searchTracks(query: string, limit: number): Promise<Track[]> {
const { data } = await this.#apiCall('search.music', {
query,
start: 0,
nb: limit,
filter: 'ALL',
output: 'TRACK'
})
return data.map((t) => parseTrack(t as DeezerTrack))
}
/* ---------- GET INFO FROM URL ---------- */
async #unshortenUrl(url: URL): Promise<URL> {
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')
return new URL(location)
}
async #getInfoFromUrl(url: URL): Promise<{ type: ItemType; id: number }> {
if (url.hostname == 'deezer.page.link') url = await this.#unshortenUrl(url)
const match = url.pathname.match(/^\/(?:[a-z]{2}\/)?(track|album|artist|playlist)\/(\d+)\/?$/)
if (!match) throw new Error('URL not supported')
const [, type, id] = match
return { type: <ItemType>type, id: parseInt(id) }
}
async getTypeFromUrl(url: string): Promise<ItemType> {
const urlObj = new URL(url)
const { type } = await this.#getInfoFromUrl(urlObj)
return type
}
/* ---------- GET BY URL ---------- */
// Artist
async #getArtist(id: number): Promise<Artist> {
const { DATA, TOP, ALBUMS } = await this.#apiCall('deezer.pageArtist', {
art_id: id,
lang: 'en'
})
return parseArtist(DATA, TOP.data, ALBUMS.data)
}
// Album
async #getAlbum(id: number): Promise<{ metadata: Album; tracks: Track[] }> {
const { DATA, SONGS } = await this.#apiCall('deezer.pageAlbum', {
alb_id: id,
lang: 'en'
})
const data = {
metadata: { ...parseAlbum(DATA), trackCount: SONGS.data.length },
tracks: SONGS.data.map((t) => parseTrack(t))
}
if (!data.metadata.trackCount) data.metadata.trackCount = data.tracks.length
return data
}
// Playlist
async #getPlaylist(id: number): Promise<PlaylistGetByUrlResponse> {
const playlistMetadata = (
await this.#apiCall('deezer.pagePlaylist', {
playlist_id: id
})
).DATA
const playlistTracks = (
await this.#apiCall('playlist.getSongs', {
playlist_id: id,
nb: 9999,
start: 0
})
).data
return {
type: 'playlist',
metadata: parsePlaylistMetadata(playlistMetadata),
tracks: playlistTracks.map((t) => parseTrack(t))
}
}
// Track
async #getTrackPage(id: number): Promise<APIMethod['deezer.pageTrack']> {
return await this.#apiCall('deezer.pageTrack', {
sng_id: id
})
}
async #getStream(track: DeezerTrack): Promise<GetStreamResponse> {
if ('FALLBACK' in track) track = track.FALLBACK!
const countries = track?.AVAILABLE_COUNTRIES?.STREAM_ADS
if (!countries?.length) throw new Error('Track not available in any country')
if (!countries.includes(this.country!))
throw new Error("Track not available in the account's country")
let format: DeezerFormat = DeezerFormat.MP3_128
const formatsToCheck = [
{
format: DeezerFormat.FLAC,
filesize: track.FILESIZE_FLAC
},
{
format: DeezerFormat.MP3_320,
filesize: track.FILESIZE_MP3_320
}
]
for (const f of formatsToCheck) {
if (f.filesize != '0' && this.availableFormats.has(f.format)) {
format = f.format
break
}
}
const id = track.SNG_ID
const trackToken = track.TRACK_TOKEN
const trackTokenExpiry = track.TRACK_TOKEN_EXPIRE
let mimeType = ''
switch (format) {
case DeezerFormat.MP3_128:
case DeezerFormat.MP3_320:
mimeType = 'audio/mpeg'
break
case DeezerFormat.FLAC:
mimeType = 'audio/flac'
}
// download
const url = await this.#getTrackUrl(id, trackToken, trackTokenExpiry, format)
const streamResp = await fetch(url, { dispatcher: this.dispatcher })
if (!streamResp.ok)
throw new Error(`Failed to get track stream. Status code: ${streamResp.status}`)
const decryptionKey = this.#getTrackDecryptionKey(id)
const blowfish = new Blowfish(decryptionKey)
const chunkSize = 2048 * 3
const buf = Buffer.alloc(chunkSize)
let bufSize = 0
const decryption = new Transform({
transform(c, _, callback) {
const chunk = <Buffer>c
let chunkBytesRead = 0
while (chunkBytesRead != chunk.length) {
const slice = chunk.subarray(
chunkBytesRead,
chunkBytesRead + (chunkSize - (bufSize % chunkSize))
)
chunkBytesRead += slice.length
slice.copy(buf, bufSize)
bufSize += slice.length
if (bufSize == chunkSize) {
bufSize = 0
const copy = Buffer.alloc(chunkSize)
buf.copy(copy, 0)
if (copy.length >= 2048) {
const encryptedChunk = copy.subarray(0, 2048)
blowfish.decryptChunk(encryptedChunk)
}
this.push(copy)
}
}
callback()
},
flush(callback) {
if (bufSize != 0) {
const final = buf.subarray(0, bufSize)
if (final.length >= 2048) {
const encryptedChunk = final.subarray(0, 2048)
blowfish.decryptChunk(encryptedChunk)
}
this.push(final)
}
callback()
}
})
return {
stream: Readable.fromWeb(streamResp.body!)
.on('error', function (e) {
throw new Error('Error while downloading track stream:' + e)
})
.pipe(decryption)
.on('error', function (e) {
throw new Error('Error while decrypting track stream:' + e)
}),
mimeType
}
}
async #getTrackUrl(
id: string,
trackToken: string,
trackTokenExpiry: number,
format: DeezerFormat
): Promise<string> {
if (Date.now() / 1000 - trackTokenExpiry >= 0)
// renew track token
trackToken = (
await this.#apiCall('song.getData', {
sng_id: id,
array_default: ['TRACK_TOKEN']
})
).TRACK_TOKEN
const req = await fetch('https://media.deezer.com/v1/get_url', {
method: 'POST',
body: JSON.stringify({
license_token: this.licenseToken,
media: [
{
type: 'FULL',
formats: [{ cipher: 'BF_CBC_STRIPE', format: DeezerFormat[format] }]
}
],
track_tokens: [trackToken]
}),
dispatcher: this.dispatcher
})
const res = <DeezerMediaResponse>await req.json()
return res.data[0].media[0].sources[0].url
}
#getTrackDecryptionKey(id: string): Uint8Array {
const hash = this.#md5(id)
const key = new Uint8Array(16)
for (let i = 0; i < 16; i++) {
key[i] = hash.charCodeAt(i) ^ hash.charCodeAt(i + 16) ^ BLOWFISH_SECRET.charCodeAt(i)
}
return key
}
async getByUrl(url: string): Promise<GetByUrlResponse> {
const { type, id } = await this.#getInfoFromUrl(new URL(url))
switch (type) {
case 'artist': {
return {
type: 'artist',
metadata: await this.#getArtist(id)
}
}
case 'album': {
return {
type: 'album',
...(await this.#getAlbum(id))
}
}
case 'track': {
const trackPage = await this.#getTrackPage(id)
return {
type: 'track',
metadata: parseTrack(trackPage.DATA, trackPage.LYRICS),
getStream: () => {
return this.#getStream(trackPage.DATA)
}
}
}
case 'playlist': {
return await this.#getPlaylist(id)
}
default:
throw new Error('URL unrecognised')
}
}
async getAccountInfo(): Promise<StreamerAccount> {
const userData = await this.#apiCall('deezer.getUserData')
return {
valid: userData.USER.USER_ID != 0,
premium: userData.OFFER_ID != 0,
country: userData.COUNTRY,
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.`)
}
}
export async function getAlbum(tag: string, type: LookupType) {}

View File

@@ -1,253 +1,20 @@
import { Album, Artist, CoverArtwork, LyricLine, Playlist, Track } from '../../types.js'
import { SIZES } from './constants.js'
export enum DeezerFormat {
MP3_128 = 1,
MP3_320 = 3,
FLAC = 9
}
export interface DeezerLoginResponse {
error?: {
type: string
message: string
}
}
export interface DeezerMediaResponse {
data: [
{
media: [
{
sources: [
{
url: string
}
]
}
]
}
]
}
export interface DeezerUserData {
USER: {
USER_ID: number
EXPLICIT_CONTENT_LEVEL: string
OPTIONS: {
license_token: string
web_hq: boolean
web_lossless: boolean
}
SETTING: {
global: {
language: string
}
}
}
OFFER_ID: number
COUNTRY: string
checkForm: string
}
export interface DeezerArtist {
ART_ID: string
ART_NAME: string
ART_PICTURE: string
}
export function parseArtist(
artist: DeezerArtist,
tracks?: DeezerTrack[],
albums?: DeezerAlbum[]
): Artist {
let pictures
if (artist.ART_PICTURE)
pictures = SIZES.map(
(s) =>
`https://e-cdns-images.dzcdn.net/images/artist/${artist.ART_PICTURE}/${s}x${s}-000000-80-0-0.jpg`
)
let parsedTracks
if (tracks) parsedTracks = tracks.map((t) => parseTrack(t))
let parsedAlbums
if (albums) parsedAlbums = albums.map(parseAlbum)
import { RawError, RawUPCAlbum } from './constants.js'
import { Album } from '../../types.js'
export function parseAlbum(data: RawUPCAlbum): Album {
return {
id: artist.ART_ID,
url: `https://www.deezer.com/artist/${artist.ART_ID}`,
pictures,
name: artist.ART_NAME,
tracks: parsedTracks,
albums: parsedAlbums
}
}
export interface DeezerAlbum {
ALB_ID: string
ALB_TITLE: string
ALB_PICTURE: string
ARTISTS?: DeezerArtist[]
ORIGINAL_RELEASE_DATE?: string
NUMBER_TRACK?: string
COPYRIGHT?: string
UPC?: string
LABEL_NAME?: string
}
export function parseAlbum(album: DeezerAlbum): Album {
const data: Album = {
title: album.ALB_TITLE,
id: album.ALB_ID,
url: `https://www.deezer.com/album/${album.ALB_ID}`,
trackCount: album.NUMBER_TRACK ? parseInt(album.NUMBER_TRACK) : undefined,
releaseDate: album.ORIGINAL_RELEASE_DATE ? new Date(album.ORIGINAL_RELEASE_DATE) : undefined,
coverArtwork: parseArtwork(album.ALB_PICTURE),
artists: album.ARTISTS ? album.ARTISTS.map((a) => parseArtist(a)) : undefined,
label: album.LABEL_NAME
}
if (album.COPYRIGHT) data.copyright = album.COPYRIGHT
if (album.UPC) data.upc = album.UPC
return data
}
export interface DeezerTrack {
SNG_ID: string
SNG_TITLE: string
VERSION: string
EXPLICIT_LYRICS: '0' | '1'
TRACK_NUMBER: string
DISK_NUMBER: string
ARTISTS: DeezerArtist[]
ISRC: string
SNG_CONTRIBUTORS: { [role: string]: string[] }
ALB_ID: string
ALB_TITLE: string
ALB_PICTURE: string
DURATION: string
AVAILABLE_COUNTRIES?: { STREAM_ADS?: string }
COPYRIGHT: string
TRACK_TOKEN: string
TRACK_TOKEN_EXPIRE: number
MD5_ORIGIN: string
MEDIA_VERSION: string
FILESIZE_MP3_320: string
FILESIZE_FLAC: string
FALLBACK?: DeezerTrack
}
export interface DeezerLyrics {
LYRICS_ID: string
LYRICS_SYNC_JSON: DeezerSyncedLine[]
LYRICS_TEXT: string
}
export interface DeezerSyncedLine {
lrc_timestamp?: string
milliseconds?: string
duration?: string
line: string
}
export function parseTrack(track: DeezerTrack, lyricsData?: DeezerLyrics): Track {
const addt: {
regions?: string[]
copyright?: string
producers?: string[]
composers?: string[]
lyricists?: string[]
} = {}
if (track?.AVAILABLE_COUNTRIES?.STREAM_ADS)
addt.regions = [...track.AVAILABLE_COUNTRIES.STREAM_ADS]
if (track?.COPYRIGHT) addt.copyright = track.COPYRIGHT
if (track.SNG_CONTRIBUTORS?.producer) addt.producers = track.SNG_CONTRIBUTORS?.producer
if (track.SNG_CONTRIBUTORS?.composer) addt.composers = track.SNG_CONTRIBUTORS?.composer
if (track.SNG_CONTRIBUTORS?.composer) addt.lyricists = track.SNG_CONTRIBUTORS?.lyricist
let lyrics
if (lyricsData) {
const lines: LyricLine[] = []
const syncedLyrics = lyricsData.LYRICS_SYNC_JSON
if (syncedLyrics) {
let lastLineEnd = 0
for (const line of syncedLyrics) {
if (!line.line) {
const lastLine = lines[lines.length - 1]
if (lastLine) lastLine.endTimeMs = lastLineEnd
continue
}
lines.push({
text: line.line,
startTimeMs: parseInt(line.milliseconds!)
})
lastLineEnd = parseInt(line.milliseconds!) + parseInt(line.duration!)
type: 'album',
duration: data.duration,
title: data.title,
barcode: [
{
type: 'upc',
data: data.upc
}
} else {
lines.push({
text: lyricsData.LYRICS_TEXT
})
}
lyrics = {
id: lyricsData.LYRICS_ID,
source: 'deezer',
lines
}
}
return {
title: track.VERSION ? `${track.SNG_TITLE} ${track.VERSION}` : track.SNG_TITLE,
id: track.SNG_ID,
url: `https://www.deezer.com/track/${track.SNG_ID}`,
explicit: track.EXPLICIT_LYRICS == '1',
trackNumber: parseInt(track.TRACK_NUMBER),
discNumber: parseInt(track.DISK_NUMBER),
artists: track.ARTISTS.map((a) => parseArtist(a)),
isrc: track.ISRC,
album: parseAlbum({
ALB_ID: track.ALB_ID,
ALB_PICTURE: track.ALB_PICTURE,
ALB_TITLE: track.ALB_TITLE
}),
durationMs: parseInt(track.DURATION) * 1e3,
coverArtwork: parseArtwork(track.ALB_PICTURE),
lyrics,
...addt
]
}
}
export function parseArtwork(picture: string): CoverArtwork[] {
return SIZES.map((size) => ({
url: `https://e-cdns-images.dzcdn.net/images/cover/${picture}/${size}x${size}-000000-80-0-0.jpg`,
height: size,
width: size
}))
}
export interface DeezerPlaylistMetadata {
PLAYLIST_ID: string
TITLE: string
DESCRIPTION: string
PLAYLIST_PICTURE: string
DURATION: number
NB_SONG: number
}
export function parsePlaylistMetadata(raw: DeezerPlaylistMetadata) {
return <Playlist>{
id: raw.PLAYLIST_ID,
title: raw.TITLE,
url: `https://www.deezer.com/playlist/${raw.PLAYLIST_ID}`,
coverArtwork: parseArtwork(raw.PLAYLIST_PICTURE),
trackCount: raw.NB_SONG
}
export function parseError(data: RawError) {
return data?.error?.message || 'Deezer reported no error data.'
}

View File

@@ -1,9 +0,0 @@
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'
}

View File

@@ -1,350 +0,0 @@
import crypto from 'crypto'
import { fetch, Dispatcher } from 'undici'
import {
ItemType,
StreamerWithLogin,
SearchResults,
GetByUrlResponse,
GetStreamResponse,
Track,
StreamerAccount
} from '../../types.js'
import { DEFAULT_HEADERS } from './constants.js'
import {
parseAlbum,
parseTrack,
parseArtist,
RawAlbum,
RawArtist,
RawTrack,
RawPlaylist,
parsePlaylist
} from './parse.js'
import { Readable } from 'stream'
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
token?: string
dispatcher?: Dispatcher
}
interface LoginResponse {
user: {
id: number
display_name: string
language_code: string
zone: string
store: string
country: string
creation_date: string
credential: {
parameters: {
lossy_streaming: boolean
lossless_streaming: boolean
hires_streaming: boolean
}
label: string
source: string
}
subscription: {
offer: string
}
}
user_auth_token: string
}
export default class Qobuz implements StreamerWithLogin {
hostnames = ['play.qobuz.com', 'open.qobuz.com', 'www.qobuz.com', 'qobuz.com']
testData = {
'https://www.qobuz.com/us-en/interpreter/tyler-the-creator/589771': {
title: 'Tyler, The Creator',
type: 'artist'
},
'https://www.qobuz.com/us-en/album/igor-tyler-the-creator/qtz65tw2of0ha': {
title: 'IGOR',
type: 'album'
}
} as const
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 }) {
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),
dispatcher: this.dispatcher
}
)
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) {
if (!this.token) {
const params: { [key: string]: string } = {
username,
password: md5(password),
extra: 'partner',
app_id: this.appId
}
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
sample: boolean
}
const trackFileResponse = <RawTrackFileResponse>(
await this.#getSigned('track/getFileUrl', params)
)
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, { dispatcher: this.dispatcher })
return {
mimeType: trackFileResponse.mime_type,
sizeBytes: parseInt(<string>streamResponse.headers.get('Content-Length')),
stream: Readable.fromWeb(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 {
type: 'album',
metadata: { ...parseAlbum(albumResponse), trackCount: albumResponse.tracks?.items.length },
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
})
)
}
async #getPlaylist(id: string) {
const init = <RawPlaylist>await this.#get('playlist/get', {
playlist_id: id,
extra: 'tracks,getSimilarPlaylists',
offset: 0,
limit: 1000,
app_id: this.appId
})
if (init.tracks_count > 1000)
throw new Error('To be added, playlists with over a thousand tracks.')
else return parsePlaylist(init)
}
#getUrlParts(url: string): ['artist' | 'album' | 'track' | 'playlist', 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]
case 'playlists':
return ['playlist', 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' &&
urlParts[0] != 'playlist'
) {
throw new Error('URL unrecognised')
}
return [urlParts[0], urlParts[1]]
}
async getTypeFromUrl(url: string): Promise<ItemType> {
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)
}
}
case 'playlist': {
return await this.#getPlaylist(id)
}
default:
throw new Error('URL unrecognised')
}
}
async getAccountInfo(): Promise<StreamerAccount> {
const loginResponse = <LoginResponse>await this.#getSigned('user/login', {
extra: 'partner',
device_manufacturer_id: 'undefined',
app_id: this.appId
})
return {
valid: true,
premium:
loginResponse.user?.credential?.parameters?.hires_streaming ||
loginResponse.user.subscription.offer == 'studio',
country: loginResponse.user.country,
explicit: true
}
}
async isrcLookup(isrc: string): Promise<Track> {
const isrcUrl = (await this.search(isrc)).tracks?.[0]?.url
if (!isrcUrl) throw new Error(`Not available on Qobuz.`)
else {
const track = <Track>(await this.getByUrl(isrcUrl)).metadata
return track
}
}
}

View File

@@ -1,194 +0,0 @@
import { Artist, Album, Track, PlaylistGetByUrlResponse } 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
version?: string
id: string
url: string
image: {
thumbnail: string
small: string
large: string
}
tracks?: {
items: RawTrack[]
}
artists?: RawArtist[]
artist: RawArtist
upc: string
released_at: number
label?: {
name: string
id: number
}
genre?: {
name: string
id: number
slug: string
}
copyright: string
}
export function parseAlbum(raw: RawAlbum) {
const album: Album = {
title: raw.version ? `${raw.title} (${raw.version})` : 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)],
upc: raw.upc,
releaseDate: new Date(raw.released_at * 1000),
copyright: raw.copyright
}
if (raw.label?.name) album.label = raw.label.name
if (raw.genre?.name) album.genre = [raw.genre.name]
return album
}
export interface RawTrack {
title: string
version?: string
id: number
copyright?: string
performer: RawArtist
album?: RawAlbum
track_number?: number
media_number?: number
duration: number
parental_warning: boolean
isrc: string
performers?: string
}
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,
durationMs: raw.duration * 1000,
explicit: raw.parental_warning,
isrc: raw.isrc,
genres: []
}
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
if (raw.performers) track = parsePerformers(raw.performers, track)
return track
}
function parsePerformers(performers: string, track: Track) {
const pre = performers.split(' - ')
track.producers = []
track.composers = []
track.lyricists = []
track.performers = []
track.engineers = []
for (const i in pre) {
const name = pre[i].split(', ')[0]
const credits = pre[i].split(', ').slice(1).join(', ')
if (credits.toLowerCase().includes('producer')) track.producers.push(name)
if (credits.toLowerCase().includes('lyricist')) track.lyricists.push(name)
if (credits.toLowerCase().includes('composer')) track.composers.push(name)
if (
credits.toLowerCase().includes('performer') ||
credits.toLowerCase().includes('featuredartist')
)
track.performers.push(name)
if (credits.toLowerCase().includes('engineer')) track.engineers.push(name)
}
return track
}
export interface RawPlaylist {
owner: {
id: number
name: string
}
id: number
name: string
images: string[]
is_collaborative: boolean
description: string
created_at: number
duration: number
tracks_count: number
tracks: {
offset?: number
limit?: number
total?: number
items: RawTrack[]
}
}
export function parsePlaylist(raw: RawPlaylist) {
return <PlaylistGetByUrlResponse>{
type: 'playlist',
metadata: {
id: raw.id,
title: raw.name,
trackCount: raw?.tracks_count || raw?.tracks?.total,
url: `https://open.qobuz.com/playlist/${raw.id}`
},
tracks: raw.tracks?.items.map(parseTrack)
}
}

View File

@@ -1,14 +0,0 @@
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 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36'
}

View File

@@ -1,381 +0,0 @@
import { Dispatcher, fetch, HeadersInit } from 'undici'
import { DEFAULT_HEADERS } from './constants.js'
import {
ItemType,
Streamer,
SearchResults,
GetByUrlResponse,
GetStreamResponse,
StreamerAccount,
TrackGetByUrlResponse
} from '../../types.js'
import {
parseAlbum,
parseTrack,
parseArtist,
parseHls,
RawSearchResults,
RawTrack,
RawAlbum,
RawArtist,
ScClient
} from './parse.js'
import { Readable } from 'stream'
function headers(oauthToken?: string | undefined): HeadersInit {
const headers: HeadersInit = DEFAULT_HEADERS
if (oauthToken) headers['Authorization'] = 'OAuth ' + oauthToken
return headers
}
interface SoundcloudOptions {
oauthToken?: string
dispatcher: Dispatcher | undefined
}
interface SoundcloudTranscoding {
url: string
preset: string
duration: number
snipped: boolean
format: {
protocol: string
mime_type: string
}
quality: string
}
interface SoundCloudSubscriptionData {
active_subscription: {
state: string
subscription_period_started_at: string
expires_at: string
recurring: boolean
trial: boolean
is_eligible: boolean
} | null
}
export default class Soundcloud implements Streamer {
hostnames = ['soundcloud.com', 'm.soundcloud.com', 'www.soundcloud.com', 'on.soundcloud.com']
testData = {
'https://soundcloud.com/saoirsedream/charlikartlanparty': {
type: 'track',
title: 'Charli Kart LAN Party'
},
'https://soundcloud.com/saoirsedream/sets/star': {
type: 'album',
title: 'star★☆'
}
} 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())
const response = await fetch(
this.#formatURL(
`https://api-v2.soundcloud.com/search?q=${encodeURIComponent(
query
)}&offset=0&linked_partitioning=1&app_locale=en&limit=${limit}`,
client
),
{ method: 'get', headers: headers(this.oauthToken), dispatcher: this.dispatcher }
)
if (!response.ok) {
const errMsg = await response.text()
try {
throw new Error(JSON.parse(errMsg))
} catch (error) {
if (errMsg) throw new Error(errMsg)
else throw new Error('Soundcloud request failed. Try removing the OAuth token, if added.')
}
}
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(await parseArtist(<RawArtist>resultResponse.collection[i]))
}
return items
}
async #getClient(): Promise<ScClient> {
const response = await (
await fetch(`https://soundcloud.com/`, {
method: 'get',
headers: headers(this.oauthToken),
dispatcher: this.dispatcher
})
).text()
const client = {
version: response.split(`__sc_version="`)[1].split(`"</script>`)[0],
anonId: response.split(`[{"hydratable":"anonymousId","data":"`)[1].split(`"`)[0],
id: await fetchKey(response)
}
this.client = client
return client
}
async getTypeFromUrl(url: string): Promise<ItemType> {
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> {
const { hostname } = new URL(url)
if (hostname == 'on.soundcloud.com') url = await followRedirect(url)
return await this.#getMetadata(url)
}
#formatURL(og: string, client: ScClient): string {
const parsed = new URL(og)
if (client.anonId && !this.oauthToken) parsed.searchParams.append('user_id', client.anonId)
if (client.id) parsed.searchParams.append('client_id', client.id)
if (client.version) parsed.searchParams.append('app_version', client.version)
if (!parsed.searchParams.get('app_locale')) parsed.searchParams.append('app_locale', 'en')
return parsed.href
}
async #getMetadata(url: string): Promise<GetByUrlResponse> {
// loosely based off: https://github.com/wukko/cobalt/blob/92c0e1d7b7df262fcd82ea7f5cf8c58c6d2ad744/src/modules/processing/services/soundcloud.js
const type = await this.getTypeFromUrl(url)
const client = this.client || (await this.#getClient())
url = url.replace('//m.', '//')
// getting the IDs and track authorization
const html = await (
await fetch(url, {
method: 'get',
headers: headers(this.oauthToken),
dispatcher: this.dispatcher
})
).text()
switch (type) {
case 'track': {
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
if (path.split('/').length == 4) naked = `${naked}?secret_token=${path.split('/')[3]}`
const api = JSON.parse(
await (
await fetch(this.#formatURL(naked, client), {
method: 'get',
headers: headers(this.oauthToken),
dispatcher: this.dispatcher
})
).text()
)
return <TrackGetByUrlResponse>{
type: 'track',
getStream: async (hq?: boolean) => {
if (!hq) hq = false
return await getStream(
hq,
api.media.transcodings,
api.track_authorization,
client,
this.oauthToken
)
},
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: []
}
for (const i in data.tracks) {
let track = data.tracks[i]
if (!track.title) track = await this.#getRawTrackInfo(track.id, client)
const parsedTrack = {
type: 'track',
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: await parseArtist(data)
}
}
default:
throw new Error(`Type "${type}" not supported.`)
}
}
async #getRawTrackInfo(id: number | string, client: ScClient) {
const api = JSON.parse(
await (
await fetch(this.#formatURL(`https://api-v2.soundcloud.com/tracks/${id}`, client), {
method: 'get',
headers: headers(this.oauthToken),
dispatcher: this.dispatcher
})
).text()
)
return { ...api, id }
}
async getAccountInfo(): Promise<StreamerAccount> {
if (!this.oauthToken) return { valid: false }
const client = this.client || (await this.#getClient())
const response = await fetch(
this.#formatURL(
`https://api-v2.soundcloud.com/payments/quotations/consumer-subscription`,
client
),
{
method: 'get',
headers: headers(this.oauthToken),
dispatcher: this.dispatcher
}
)
if (response.status != 200) return { valid: false }
const subscriptionQuery = <SoundCloudSubscriptionData>await response.json()
return {
valid: true,
premium: subscriptionQuery?.active_subscription?.state == 'active',
explicit: true
}
}
}
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]
const keyregex =
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/
if (!keyregex.test(key)) 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(
hq: boolean,
transcodings: Array<SoundcloudTranscoding>,
trackAuth: string,
client: ScClient,
oauthToken?: string | undefined
): Promise<GetStreamResponse> {
let filter = transcodings.filter((x) => x.quality == 'hq')
if (hq == true && filter.length == 0) throw new Error('Could not find HQ format.')
if (filter.length == 0) filter = transcodings.filter((x) => x.preset.startsWith('aac_')) // prioritize aac (go+)
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 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.')
if (transcoding.format.protocol == 'progressive') {
const streamResp = await fetch(json.url)
return {
mimeType: transcoding.format.mime_type,
sizeBytes: parseInt(streamResp.headers.get('Content-Length')!),
stream: Readable.fromWeb(streamResp.body!)
}
} else {
let container = transcoding.format.mime_type.split('/')[1].split(';')[0].split('+')[0]
if (container == 'mpeg') container = 'mp3'
return {
mimeType: transcoding.format.mime_type,
stream: await parseHls(json.url, container)
}
}
}
async function followRedirect(url: string) {
const res = await fetch(url, { redirect: 'manual' })
const location = res.headers.get('Location')
if (res.status != 302 || !location) throw new Error('URL not supported')
return location
}

View File

@@ -1,152 +0,0 @@
import { Artist, Album, Track } from '../../types.js'
import { spawn } from 'child_process'
import { fetch } from 'undici'
import { imageSize as sizeOf } from 'image-size'
import os from 'node:os'
import fs from 'node:fs'
import path from 'node:path'
async function parseCoverArtwork(url: string) {
const resp = await fetch(url)
if (!resp.body) throw new Error('No body on image')
const body = await resp.arrayBuffer()
const dimensions = sizeOf(new Uint8Array(body))
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 interface Headers {
Authorization?: string
'User-Agent': string
}
export async function parseArtist(raw: RawArtist): Promise<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
user_id: number | string
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: [await parseArtist(raw.user)]
}
if (raw.tracks?.[0]?.artwork_url != undefined) {
album.coverArtwork = [await parseCoverArtwork(raw?.tracks?.[0]?.artwork_url)]
}
return album
}
export interface RawTrack {
media?: {
transcodings?: { duration: number }[]
}
kind: 'track'
id: number | string
title: string
duration: number
created_at: string
full_duration: number
permalink_url: string
artwork_url?: string
user: RawArtist
last_modified: string
description: string
user_id: number | string
}
export async function parseTrack(raw: RawTrack): Promise<Track> {
const track: Track = {
id: raw.id,
title: raw.title,
url: raw.permalink_url,
artists: [await parseArtist(raw.user)],
durationMs: raw.full_duration || raw.media?.transcodings?.[0]?.duration,
releaseDate: new Date(raw.created_at),
description: raw.description
}
if (raw?.artwork_url != undefined)
track.coverArtwork = [await parseCoverArtwork(raw?.artwork_url)]
return track
}
export async function parseHls(url: string, container: string): Promise<NodeJS.ReadableStream> {
const folder = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'lucida'))
return new Promise(function (resolve, reject) {
const ffmpegProc = spawn('ffmpeg', [
'-hide_banner',
'-loglevel',
'error',
'-i',
url,
'-c:a',
'copy',
'-f',
container,
`${folder}/hls.${container}`
])
let err: string
ffmpegProc.stderr.on('data', function (data) {
err = data.toString()
})
ffmpegProc.once('exit', function (code) {
if (code == 0) resolve(fs.createReadStream(`${folder}/hls.${container}`))
else reject(`FFMPEG HLS error: ${err}` || 'FFMPEG could not parse the HLS.')
})
})
}
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

@@ -1,194 +0,0 @@
import Librespot, { LibrespotOptions } from 'librespot'
import {
parseArtist,
parseAlbum,
parseTrack,
parseEpisode,
parsePodcast,
parsePlaylist
} from './parse.js'
import {
GetByUrlResponse,
ItemType,
SearchResults,
StreamerAccount,
StreamerWithLogin,
Track
} from '../../types.js'
interface SpotifyOptions extends LibrespotOptions {
username?: string
storedCredential?: string
}
class Spotify implements StreamerWithLogin {
client: Librespot
hostnames = ['open.spotify.com']
testData = {
'https://open.spotify.com/track/1jzIJcHCXneHw7ojC6LXiF': {
type: 'track',
title: 'Potato Salad'
},
'https://open.spotify.com/album/5zi7WsKlIiUXv09tbGLKsE': {
type: 'album',
title: 'IGOR'
},
'https://open.spotify.com/artist/4V8LLVI7PbaPR0K2TGSxFF': {
type: 'artist',
title: 'Tyler, The Creator'
}
} as const
username?: string
storedCredential?: string
constructor(options: SpotifyOptions) {
this.client = new Librespot(options)
const { username, storedCredential } = options
this.username = username
this.storedCredential = storedCredential
}
async login(username: string, password: string) {
if (this.username && this.storedCredential)
return await this.client.loginWithStoredCreds(this.username, this.storedCredential)
else return await this.client.login(username, password)
}
getStoredCredentials() {
return this.client.getStoredCredentials()
}
#getUrlParts(
url: string
): ['artist' | 'album' | 'track' | 'episode' | 'show' | 'playlist', string] {
const urlObj = new URL(url)
const parts = urlObj.pathname.slice(1).split('/')
if (parts.length > 2) throw new Error('Unknown Spotify URL')
if (
parts[0] != 'artist' &&
parts[0] != 'track' &&
parts[0] != 'album' &&
parts[0] != 'show' &&
parts[0] != 'episode' &&
parts[0] != 'playlist'
) {
throw new Error(`Spotify type "${parts[0]}" unsupported`)
}
if (!parts[1]) throw new Error('Unknown Spotify URL')
return [parts[0], parts[1]]
}
async getTypeFromUrl(url: string) {
let type: ItemType | 'show' = this.#getUrlParts(url)[0]
if (type == 'show') type = 'podcast'
return type
}
async getByUrl(url: string, limit = 0): Promise<GetByUrlResponse> {
const [type, id] = this.#getUrlParts(url)
switch (type) {
case 'track': {
const metadata = await this.client.get.trackMetadata(id)
return {
type,
getStream: async () => {
const streamData = await this.client.get.trackStream(id)
return {
mimeType: 'audio/ogg',
sizeBytes: streamData.sizeBytes,
stream: streamData.stream
}
},
metadata: parseTrack(metadata)
}
}
case 'artist': {
const metadata = await this.client.get.artistMetadata(id)
const albums = await this.client.get.artistAlbums(id, limit)
return {
type,
metadata: {
...parseArtist(metadata),
albums: albums.map((e) => parseAlbum(e))
}
}
}
case 'album': {
const metadata = await this.client.get.albumMetadata(id)
const tracks = await this.client.get.albumTracks(id)
if (tracks) {
return {
type,
metadata: { ...parseAlbum(metadata), trackCount: tracks.length },
tracks: tracks?.map((e) => parseTrack(e)) ?? []
}
}
return {
type,
metadata: parseAlbum(metadata),
tracks: []
}
}
case 'episode': {
const metadata = await this.client.get.episodeMetadata(id)
return {
type,
getStream: async () => {
const streamData = await this.client.get.episodeStream(id)
return {
mimeType: 'audio/ogg',
sizeBytes: streamData.sizeBytes,
stream: streamData.stream
}
},
metadata: parseEpisode(metadata)
}
}
case 'show': {
const metadata = await this.client.get.podcastMetadata(id)
return {
type: 'podcast',
metadata: parsePodcast(metadata),
episodes: metadata.episodes?.map((e) => parseEpisode(e)) ?? []
}
}
case 'playlist': {
const metadata = await this.client.get.playlist(id)
return {
type: 'playlist',
metadata: parsePlaylist(metadata),
tracks: metadata.tracks?.map((e) => parseTrack(e)) ?? []
}
}
}
}
async search(query: string): Promise<SearchResults> {
const results = await this.client.browse.search(query)
return {
query,
albums: results.albums?.map((e) => parseAlbum(e)) ?? [],
artists: results.artists?.map((e) => parseArtist(e)) ?? [],
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()
let premium
if (info.plan == 'premium') premium = true
else premium = false
return {
valid: true,
premium,
country: info.country,
explicit: info.allowExplicit
}
}
}
export default Spotify

View File

@@ -1,107 +0,0 @@
import type {
SpotifyAlbum,
SpotifyArtist,
SpotifyThumbnail,
SpotifyTrack,
SpotifyEpisode,
SpotifyPodcast,
SpotifyPlaylist
} from 'librespot/types'
import { Album, Artist, Episode, Playlist, Podcast, Track } from '../../types.js'
function parseThumbnails(raw: SpotifyThumbnail[]) {
return raw
.sort((a, b) => (a.width ?? 0) - (b.width ?? 0))
.map((e) => {
return {
width: e.width ?? 0,
height: e.height ?? 0,
url: e.url
}
})
}
export function parseArtist(raw: SpotifyArtist) {
const artist: Artist = {
id: raw.id,
url: raw.externalUrl,
name: raw.name
}
if (raw.avatar) artist.pictures = parseThumbnails(raw.avatar).map((e) => e.url)
if (raw.albums) artist.albums = raw.albums.map((e) => parseAlbum(e))
return artist
}
export function parseTrack(raw: SpotifyTrack) {
const track: Track = {
title: raw.name,
id: raw.id,
url: raw.externalUrl,
explicit: raw.explicit,
trackNumber: raw.trackNumber,
discNumber: raw.discNumber,
artists: raw.artists?.map((e) => parseArtist(e)) ?? [],
durationMs: raw.durationMs
}
if (raw.album) track.album = parseAlbum(raw.album)
if (raw?.isrc) track.isrc = raw.isrc
if (raw?.album?.availableMarkets) track.regions = raw.album.availableMarkets
return track
}
export function parseAlbum(raw: SpotifyAlbum) {
const album: Album = {
title: raw.name,
id: raw.id,
url: raw.externalUrl,
trackCount: raw.totalTracks,
releaseDate: raw.releaseDate,
coverArtwork: parseThumbnails(raw.coverArtwork),
artists: raw.artists.map((e) => parseArtist(e))
}
if (raw.availableMarkets) album.regions = raw.availableMarkets
return album
}
export function parseEpisode(raw: SpotifyEpisode) {
const episode: Episode = {
title: raw.name,
id: raw.id,
url: raw.externalUrl,
explicit: raw.explicit,
description: raw.description,
coverArtwork: parseThumbnails(raw.coverArtwork),
releaseDate: raw.releaseDate,
durationMs: raw.durationMs
}
if (raw.podcast) episode.podcast = parsePodcast(raw.podcast)
return episode
}
export function parsePodcast(raw: SpotifyPodcast) {
const podcast: Podcast = {
title: raw.name,
id: raw.id,
url: raw.externalUrl,
description: raw.description,
coverArtwork: parseThumbnails(raw.coverArtwork)
}
if (typeof raw.explicit == 'boolean') podcast.explicit = raw.explicit
if (raw.episodes) podcast.episodes = raw.episodes.map((e) => parseEpisode(e))
return podcast
}
export function parsePlaylist(raw: SpotifyPlaylist) {
const playlist: Playlist = {
id: raw.id,
title: raw.name,
url: raw.externalUrl,
trackCount: raw.totalTracks,
coverArtwork: parseThumbnails(raw.coverArtwork)
}
return playlist
}

View File

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

View File

@@ -1,564 +0,0 @@
import { Dispatcher, fetch } from 'undici'
import { spawn } from 'child_process'
import os from 'node:os'
import fs from 'node:fs'
import {
ItemType,
GetByUrlResponse,
GetStreamResponse,
SearchResults,
Streamer,
Track,
StreamerAccount
} from '../../types.js'
import { TIDAL_AUTH_BASE, TIDAL_API_BASE, TIDAL_SUBSCRIPTION_BASE } from './constants.js'
import {
Contributor,
RawAlbum,
RawArtist,
RawPlaylist,
RawTrack,
addCredits,
parseAlbum,
parseArtist,
parseMpd,
parsePlaylist,
parseTrack
} from './parse.js'
import Stream, { Readable } from 'stream'
interface TidalOptions {
tvToken: string
tvSecret: string
accessToken: string
refreshToken: string
expires: number
countryCode: string
dispatcher?: Dispatcher
}
interface LoginData {
error: string
access_token: string
refresh_token: string
expires_in: number
}
interface SessionData {
countryCode: string
userId: number
status?: number
userMessage?: string
}
interface SubscriptionData {
startDate: string
validUntil: string
status: string
subscription: {
type: string
offlineGracePeriod: number
}
highestSoundQuality: string
premiumAccess: boolean
canGetTrial: boolean
paymentType: string
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
accessToken: string
refreshToken: string
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': {
type: 'artist',
title: 'Tyler, The Creator'
},
'https://tidal.com/browse/album/109485854': {
type: 'album',
title: 'IGOR'
},
'https://tidal.com/browse/track/95691774': {
type: 'track',
title: 'Potato Salad'
}
} as const
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
this.countryCode = options.countryCode
if (options.dispatcher) this.dispatcher = options.dispatcher
}
headers() {
return {
'X-Tidal-Token': this.tvToken,
Authorization: `Bearer ${this.accessToken}`,
'Accept-Encoding': 'gzip',
'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 } = {},
base: string = TIDAL_API_BASE
): Promise<unknown> {
if (this.failedAuth) throw new Error(`Last request failed to authorize, get new tokens`)
if (Date.now() > this.expires) await this.refresh()
if (!this.countryCode) await this.getCountryCode()
params.countryCode = params.countryCode ?? this.countryCode
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(
`${base}${url}?${new URLSearchParams(<{ [key: string]: string }>params)}`,
{
headers: this.headers(),
dispatcher: this.dispatcher
}
)
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('[tidal] Refreshed tokens')
if (this.failedAuth) {
throw new Error('Auth failed. Try getting new tokens.')
}
return this.#get(url, params)
}
console.error('[tidal] Tidal error response:', json)
} catch (error) {
console.error('[tidal] 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(),
dispatcher: this.dispatcher
})
return resp.ok
}
async getCountryCode() {
const sessionResponse = await fetch('https://api.tidal.com/v1/sessions', {
headers: this.headers(),
dispatcher: this.dispatcher
})
if (sessionResponse.status != 200) return false
const sessionData = <SessionData>await sessionResponse.json()
this.countryCode = sessionData.countryCode
this.userId = sessionData.userId
return true
}
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'
}),
dispatcher: this.dispatcher
})
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),
dispatcher: this.dispatcher
})
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
await this.getCountryCode()
console.log('[tidal] Using the following new config:', this.getCurrentConfig())
}
console.log(`[tidal] Log in at ${linkUrl}`)
checkToken()
return linkUrl
}
getCurrentConfig() {
return {
tvToken: this.tvToken,
tvSecret: this.tvSecret,
accessToken: this.accessToken,
refreshToken: this.refreshToken,
expires: this.expires,
countryCode: this.countryCode
}
}
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'
}),
dispatcher: this.dispatcher
})
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> {
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',
limit: 100
}
)
return contributorResponse.items.map((item) => parseTrack(addCredits(item.item, item.credits)))
}
async #getPlaylist(playlistId: number | string) {
const playlistResponse = await this.#get(`playlists/${playlistId}`)
return parsePlaylist(<RawPlaylist>playlistResponse)
}
async #getPlaylistTracks(playlistId: number | string) {
const tracksResponse = <{ items: RawTrack[] }>await this.#get(
`playlists/${playlistId}/tracks`,
{
offset: 0,
limit: 10000
}
)
return tracksResponse.items.map((item) => parseTrack(item))
}
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_LOSSLESS'
): Promise<GetStreamResponse> {
interface PlaybackInfo {
manifest: string
manifestMimeType: string
audioQuality: 'LOW' | 'HIGH' | 'LOSSLESS' | 'HI_RES' | 'HI_RES_LOSSLESS'
}
const playbackInfoResponse = <PlaybackInfo>await this.#get(
`tracks/${trackId}/playbackinfopostpaywall/v4`,
{
playbackmode: 'STREAM',
assetpresentation: 'FULL',
audioquality: quality,
prefetch: 'false'
}
)
const manifestStr = Buffer.from(playbackInfoResponse.manifest, 'base64').toString('utf-8')
interface Manifest {
mimeType: string
urls: string[]
}
if (playbackInfoResponse.manifestMimeType != 'application/dash+xml') {
const manifest = <Manifest>JSON.parse(manifestStr)
const streamResponse = await fetch(manifest.urls[0], { dispatcher: this.dispatcher })
return {
mimeType: manifest.mimeType,
sizeBytes: parseInt(<string>streamResponse.headers.get('Content-Length')),
stream: Readable.fromWeb(streamResponse.body!)
}
}
const trackUrls = parseMpd(manifestStr)
const args = await this.#getFfArgs(playbackInfoResponse.audioQuality)
const ffmpegProc = spawn('ffmpeg', args)
switch (playbackInfoResponse.audioQuality) {
case 'LOW':
case 'HIGH': {
return new Promise(function (resolve, reject) {
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)
let err: string
ffmpegProc.stderr.on('data', function (data) {
err = data.toString()
})
load()
ffmpegProc.once('exit', async function (code) {
const folder = args[args.length - 1]
.split('/')
.slice(0, args[args.length - 1].split('/').length - 1)
.join('/')
if (code == 0)
resolve({
mimeType: 'audio/mp4',
stream: fs.createReadStream(args[args.length - 1]).once('end', async function () {
await fs.promises.rm(folder, { recursive: true })
})
})
else {
reject(`FFMPEG error: ${err}` || 'FFMPEG could not handle the file.')
await fs.promises.rm(folder, { recursive: true })
}
})
})
}
default: {
const stream = new Stream.Readable({
// eslint-disable-next-line @typescript-eslint/no-empty-function
read() {}
})
// eslint-disable-next-line no-inner-declarations
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)
load()
return {
mimeType: 'audio/flac',
stream: ffmpegProc.stdout
}
}
}
}
async #getFfArgs(audioQuality: 'LOW' | 'HIGH' | 'LOSSLESS' | 'HI_RES' | 'HI_RES_LOSSLESS') {
switch (audioQuality) {
case 'LOW':
case 'HIGH':
// eslint-disable-next-line no-case-declarations
const folder = await fs.promises.mkdtemp(`${os.tmpdir()}/lucida`)
return [
'-hide_banner',
'-loglevel',
'error',
'-i',
'-',
'-c:a',
'copy',
'-y',
`${folder}/data.m4a`
]
default:
return ['-hide_banner', '-loglevel', 'error', '-i', '-', '-c:a', 'copy', '-f', 'flac', '-']
}
}
#getUrlParts(url: string): ['artist' | 'album' | 'track' | 'playlist', 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' &&
urlParts[0] != 'playlist'
) {
throw new Error('URL unrecognised')
}
return [urlParts[0], urlParts[1]]
}
async getTypeFromUrl(url: string): Promise<ItemType> {
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':
// eslint-disable-next-line no-case-declarations
const tracks = await this.#getAlbumTracks(id)
return {
type,
tracks: tracks,
metadata: { ...(await this.#getAlbum(id)), trackCount: tracks.length }
}
case 'artist':
return {
type,
metadata: await this.#getArtist(id)
}
case 'playlist':
return {
type,
tracks: await this.#getPlaylistTracks(id),
metadata: await this.#getPlaylist(id)
}
}
}
async getAccountInfo(): Promise<StreamerAccount> {
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(),
dispatcher: this.dispatcher
})
const sessionData = <SessionData>await sessionResponse.json()
this.userId = sessionData.userId
this.countryCode = sessionData.countryCode
}
const subscription = <SubscriptionData>(
await this.#get(`users/${this.userId}/subscription`, {}, TIDAL_SUBSCRIPTION_BASE)
)
return {
valid: true,
premium: subscription.premiumAccess,
country: this.countryCode,
explicit: true
}
}
}

View File

@@ -1,245 +0,0 @@
import { Album, Artist, Playlist, 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 {
let picturePath
if (raw?.picture != null) picturePath = raw?.picture?.replace(/-/gm, '/')
else picturePath = null
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 {
let coverPath
if (raw.cover) coverPath = raw.cover.replace(/-/gm, '/')
else coverPath = null
const album: Album = {
id: raw.id,
url: raw.url ?? `https://tidal.com/browse/album/${raw.id}`,
title: raw.title,
coverArtwork: []
}
if (coverPath)
album.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 RawPlaylist {
uuid: string
title: string
url: string
numberOfTracks: number
tracks: Track[]
cover: string
}
export function parsePlaylist(raw: RawPlaylist): Playlist {
let coverPath
if (raw.cover) coverPath = raw.cover.replace(/-/gm, '/')
else coverPath = null
const playlist: Playlist = {
id: raw.uuid,
title: raw.title,
url: raw.url,
trackCount: raw.numberOfTracks
}
playlist.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
}
]
return playlist
}
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
version?: string
album: RawAlbum
}
export function parseTrack(raw: RawTrack): Track {
const track: Track = {
url: raw.url,
id: raw.id,
title: raw.version ? `${raw.title} (${raw.version})` : 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 && raw.composers[0] != 'Not Documented') 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 = [initializationUrl]
const timeline = segTemplate.querySelector('SegmentTimeline')
if (timeline) {
let numSegments = 0
// 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')
numSegments += r
// for (let i = 0; i < r; i++) {
// currentTime += parseInt(<string>s.getAttribute('d'))
// }
}
for (let i = 1; i <= numSegments; i++) {
trackUrls.push(mediaUrl.replace('$Number$', i.toString()))
}
}
tracks.push(trackUrls)
}
}
return tracks[0]
}

View File

@@ -1,150 +0,0 @@
import Lucida, { LucidaOptions } from './index.js'
import { GetByUrlResponse, Streamer } from './types.js'
import fs from 'node:fs'
let testOptions: {
[module: string]: {
username?: string
password?: string
[key: string]: string | number | boolean | undefined
}
}
if (!process.env.TEST_OPTIONS) {
console.error(
'No test options found in environment variables. Trying to read from .test-options.json'
)
try {
const testOptionsFile = fs.readFileSync('.test-options.json', 'utf-8')
testOptions = JSON.parse(testOptionsFile)
} catch (error) {
console.error(
'No test options found in environment variables or .test-options.json. Exiting...'
)
process.exit(1)
}
} else testOptions = JSON.parse(process.env.TEST_OPTIONS)
async function start() {
const lucidaOptions: LucidaOptions = {
modules: {}
}
for (const [module, options] of Object.entries(testOptions)) {
const username = options.username
const password = options.password
delete options.username
delete options.password
const { default: Streamer } = await import(`lucida/streamers/${module}`)
lucidaOptions.modules[module] = <Streamer>new Streamer(options)
if (username && password) {
lucidaOptions.logins ??= {}
lucidaOptions.logins[module] = { username, password }
}
}
const lucida = new Lucida(lucidaOptions)
await lucida.login(true)
const testResults: {
[module: string]: {
loginSuccess: boolean
searchSuccess: boolean
urlTestsSuccess?: number
}
} = {}
for (const moduleName of Object.keys(testOptions)) {
const module = lucida.modules[moduleName]
console.log(`Testing ${moduleName}...`)
testResults[moduleName] ??= {
loginSuccess: module ? true : false,
searchSuccess: false
}
if (!module) {
console.error(`Module ${moduleName} not found in Lucida instance. Skipping...`)
continue
}
try {
await Promise.race([
module.search('test', 5),
new Promise((_, reject) => setTimeout(() => reject('Search timed out'), 10000))
])
console.log(`Searching with the ${moduleName} streamer succeeded`)
testResults[moduleName].searchSuccess = true
} catch (error) {
console.error(`Searching with the ${moduleName} streamer failed:`, error)
}
let urlTestsSuccess = 0
if (module.testData) {
for (const url in module.testData) {
const expected = module.testData[url]
let getResult: GetByUrlResponse
try {
getResult = <GetByUrlResponse>(
await Promise.race([
module.getByUrl(url),
new Promise((_, reject) => setTimeout(() => reject('Search timed out'), 10000))
])
)
} catch (error) {
console.error(`Getting data from ${url} with the ${moduleName} streamer failed:`, error)
continue
}
const actual = {
title: getResult.type != 'artist' ? getResult.metadata.title : getResult.metadata.name,
type: getResult.type
}
const success = {
title: actual.title.toLowerCase() == expected.title.toLowerCase(),
type: actual.type == expected.type
}
if (!success.type) {
console.error(
`Type mismatch for ${url} with the ${moduleName} streamer. Expected: ${expected.title}, got: ${actual.title}`
)
}
if (!success.title) {
console.error(
`Title mismatch for ${url} with the ${moduleName} streamer. Expected: ${expected.title}, got: ${actual.title}`
)
}
if (success.title && success.type) {
console.log(`getByUrl test succeeded`)
urlTestsSuccess++
}
}
testResults[moduleName].urlTestsSuccess =
urlTestsSuccess / Object.keys(module.testData).length
} else {
console.error(`No test data found for ${moduleName}. Skipping URL tests...`)
}
}
lucida.disconnect()
console.log('Testing complete. Results:', testResults)
const readableResults = Object.entries(testResults).map((result) => {
const moduleName = result[0]
const moduleResults = result[1]
const score = Object.values(moduleResults).filter(
(result) => result === 1 || result != false
).length
const percentage = (score / Object.keys(moduleResults).length) * 100
return `- ${moduleName}: ${percentage.toFixed(2)}%`
})
fs.writeFileSync(
'.test-results.md',
`## Testing complete\n\n${readableResults.join('\n')}\n\n<details><summary>Results</summary>\n\n\`\`\`json\n` +
JSON.stringify(testResults, null, '\t') +
'\n```\n\n</details>'
)
}
start()

View File

@@ -1,190 +1,43 @@
type Id = string | number
export type LookupType = 'barcode' | 'id'
export type BarcodeAlbumType = 'upc' | 'ean' | 'gtin' | 'musicbrainz'
export type BarcodeTrackType = 'isrc' | 'musicbrainz'
export type ItemType = 'artist' | 'album' | 'track' | 'episode' | 'podcast' | 'playlist'
export type Region = string
export interface CoverArtwork {
export type Photo = {
url: string
width: number
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
pictures?: string[]
export type Artist = {
type: 'artist'
name: string
albums?: Album[]
tracks?: Track[]
}
export interface Playlist {
id: Id
title: string
url: string
coverArtwork?: CoverArtwork[]
trackCount?: number
}
export interface Album {
title: string
id: Id
url: string
upc?: string
trackCount?: number
discCount?: number
releaseDate?: Date
coverArtwork?: CoverArtwork[]
artists?: Artist[]
description?: string
copyright?: string
label?: string
genre?: string[]
regions?: string[]
}
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[]
performers?: string[]
engineers?: string[]
album?: Album
durationMs?: number
coverArtwork?: CoverArtwork[]
lyrics?: Lyrics
genres?: string[]
releaseDate?: Date
description?: string
regions?: string[]
}
export interface Episode {
title: string
id: Id
url: string
explicit?: boolean
episodeNumber?: number
copyright?: string
description?: string
producers?: string[]
composers?: string[]
podcast?: Podcast
durationMs?: number
coverArtwork?: CoverArtwork[]
releaseDate?: Date
}
export interface Podcast {
title: string
id: Id
url: string
explicit?: boolean
description?: string
coverArtwork?: CoverArtwork[]
episodes?: Episode[]
}
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
| EpisodeGetByUrlResponse
| PodcastGetByUrlResponse
| PlaylistGetByUrlResponse
export interface TrackGetByUrlResponse {
type: 'track'
getStream(): Promise<GetStreamResponse>
metadata: Track
}
export interface EpisodeGetByUrlResponse {
type: 'episode'
getStream(): Promise<GetStreamResponse>
metadata: Episode
}
export interface ArtistGetByUrlResponse {
type: 'artist'
metadata: Artist
}
export interface AlbumGetByUrlResponse {
export type Album = {
type: 'album'
tracks: Track[]
metadata: Album
}
export interface PlaylistGetByUrlResponse {
type: 'playlist'
tracks: Track[]
metadata: Playlist
}
export interface PodcastGetByUrlResponse {
type: 'podcast'
episodes: Episode[]
metadata: Podcast
title: string
artists?: Artist[]
tracklist?: Track[]
duration: number
barcode: [
{
type: BarcodeAlbumType
data: string
}
]
}
export interface StreamerAccount {
valid: boolean
premium?: boolean
country?: string
explicit?: boolean
}
export interface StreamerTestData {
[url: string]: {
title: string
type: ItemType
}
}
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>)
| ((url: string, limit?: number) => Promise<GetByUrlResponse>)
disconnect?(): Promise<void>
getAccountInfo(): Promise<StreamerAccount>
}
export interface StreamerWithLogin extends Streamer {
login(username: string, password: string): Promise<void>
export type Track = {
type: 'track'
title: string
alternativeTitles?: string[]
artists?: Artist[]
barcode: [
{
type: BarcodeTrackType
data: string
}
]
}