diff --git a/README.md b/README.md index 411b1b3..5ec7fdb 100644 --- a/README.md +++ b/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 - getByUrl(url: string): Promise -} -``` - -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. diff --git a/package.json b/package.json index 3723997..3c91beb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b945a1..3285226 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/scripts/spotify-get-stored-creds.js b/scripts/spotify-get-stored-creds.js deleted file mode 100644 index 027ca55..0000000 --- a/scripts/spotify-get-stored-creds.js +++ /dev/null @@ -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) diff --git a/scripts/tidal-get-tokens.js b/scripts/tidal-get-tokens.js deleted file mode 100644 index 96f75ef..0000000 --- a/scripts/tidal-get-tokens.js +++ /dev/null @@ -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() diff --git a/src/streamers/deezer/constants.ts b/src/streamers/deezer/constants.ts index f9d9ce9..260a1ca 100644 --- a/src/streamers/deezer/constants.ts +++ b/src/streamers/deezer/constants.ts @@ -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 + } +} diff --git a/src/streamers/deezer/main.ts b/src/streamers/deezer/main.ts index 3837f99..bd50e7f 100644 --- a/src/streamers/deezer/main.ts +++ b/src/streamers/deezer/main.ts @@ -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 = new Set() - - constructor(options?: DeezerOptions) { - if (options?.arl) this.arl = options.arl - //if (options?.dispatcher) this.dispatcher = options.dispatcher - } - - async #apiCall( - method: T, - data: { [key: string]: string | number | string[] } = {}, - doSessionRenewal = true - ): Promise { - 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 } = 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 = 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 { - 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 } = 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 { - 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 { - 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 { - 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 { - 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 { - 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: type, id: parseInt(id) } - } - - async getTypeFromUrl(url: string): Promise { - const urlObj = new URL(url) - const { type } = await this.#getInfoFromUrl(urlObj) - return type - } - - /* ---------- GET BY URL ---------- */ - - // Artist - - async #getArtist(id: number): Promise { - 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 { - 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 { - return await this.#apiCall('deezer.pageTrack', { - sng_id: id - }) - } - - async #getStream(track: DeezerTrack): Promise { - 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 = 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 { - 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 = 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 { - 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 { - 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 { - 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 = (await this.getByUrl(resp.link)).metadata - return track - } else throw new Error(`Not available on Deezer.`) - } -} +export async function getAlbum(tag: string, type: LookupType) {} diff --git a/src/streamers/deezer/parse.ts b/src/streamers/deezer/parse.ts index 65af73c..a1164a1 100644 --- a/src/streamers/deezer/parse.ts +++ b/src/streamers/deezer/parse.ts @@ -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 { - 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.' } diff --git a/src/streamers/qobuz/constants.ts b/src/streamers/qobuz/constants.ts deleted file mode 100644 index 62b698f..0000000 --- a/src/streamers/qobuz/constants.ts +++ /dev/null @@ -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' -} diff --git a/src/streamers/qobuz/main.ts b/src/streamers/qobuz/main.ts deleted file mode 100644 index d3c837f..0000000 --- a/src/streamers/qobuz/main.ts +++ /dev/null @@ -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 - 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 = await this.#getSigned('user/login', params) - this.token = loginResponse.user_auth_token - } - } - - async search(query: string, limit = 10): Promise { - if (!this.token) throw new Error('Not logged in.') - - interface RawSearchResults { - query: string - albums: { items: RawAlbum[] } - tracks: { items: RawTrack[] } - artists: { items: RawArtist[] } - } - const resultResponse = ( - 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 { - 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 = ( - 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(streamResponse.headers.get('Content-Length')), - stream: Readable.fromWeb(streamResponse.body!) - } - } - - async #getTrackMetadata(trackId: string) { - return parseTrack( - await this.#get('track/get', { - track_id: trackId, - app_id: this.appId - }) - ) - } - - async #getAlbum(albumId: string) { - const albumResponse = 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( - 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 = 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 { - return this.#getUrlParts(url)[0] - } - async getByUrl(url: string): Promise { - 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: 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 { - const 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 { - const isrcUrl = (await this.search(isrc)).tracks?.[0]?.url - if (!isrcUrl) throw new Error(`Not available on Qobuz.`) - else { - const track = (await this.getByUrl(isrcUrl)).metadata - return track - } - } -} diff --git a/src/streamers/qobuz/parse.ts b/src/streamers/qobuz/parse.ts deleted file mode 100644 index b56c420..0000000 --- a/src/streamers/qobuz/parse.ts +++ /dev/null @@ -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 { - 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) - } -} diff --git a/src/streamers/soundcloud/constants.ts b/src/streamers/soundcloud/constants.ts deleted file mode 100644 index 187d7a2..0000000 --- a/src/streamers/soundcloud/constants.ts +++ /dev/null @@ -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' -} diff --git a/src/streamers/soundcloud/main.ts b/src/streamers/soundcloud/main.ts deleted file mode 100644 index 1de2b6d..0000000 --- a/src/streamers/soundcloud/main.ts +++ /dev/null @@ -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 { - 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 = 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(resultResponse.collection[i])) - else if (resultResponse.collection[i].kind == 'playlist') - items.albums.push(await parseAlbum(resultResponse.collection[i])) - else if (resultResponse.collection[i].kind == 'user') - items.artists.push(await parseArtist(resultResponse.collection[i])) - } - - return items - } - - async #getClient(): Promise { - 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(`"`)[0], - anonId: response.split(`[{"hydratable":"anonymousId","data":"`)[1].split(`"`)[0], - id: await fetchKey(response) - } - - this.client = client - return client - } - - async getTypeFromUrl(url: string): Promise { - 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 { - 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 { - // 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 { - 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 { - 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 = 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(`