mirror of
https://codeberg.org/lucida/lucida.git
synced 2026-11-12 14:47:24 -05:00
start deezer work
This commit is contained in:
92
README.md
92
README.md
@@ -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.
|
||||
|
||||
@@ -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
146
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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/'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
150
src/test.ts
150
src/test.ts
@@ -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()
|
||||
203
src/types.ts
203
src/types.ts
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user