From c321cfab05fae493a23633f690c4e5370e65dfd6 Mon Sep 17 00:00:00 2001 From: hazycora Date: Sat, 16 Sep 2023 22:39:32 -0500 Subject: [PATCH] add spotify support --- README.md | 14 ++++ package.json | 1 + pnpm-lock.yaml | 130 ++++++++++++++++++++++++++++++++- src/index.ts | 9 ++- src/streamers/spotify/main.ts | 84 +++++++++++++++++++++ src/streamers/spotify/parse.ts | 57 +++++++++++++++ src/types.ts | 1 + 7 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 src/streamers/spotify/main.ts create mode 100644 src/streamers/spotify/parse.ts diff --git a/README.md b/README.md index 440116e..6e50902 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Lucida is made to use few NodeJS dependencies and no system dependencies (...bes 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: { @@ -18,6 +19,9 @@ const lucida = new Lucida({ }), qobuz: new Qobuz({ // tokens + }), + spotify: new Spotify({ + // options }) // Any other modules }, @@ -25,13 +29,23 @@ const lucida = new Lucida({ 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. diff --git a/package.json b/package.json index cbc34a2..0f9ff58 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "dependencies": { "dotenv": "^16.1.4", "image-size": "^1.0.2", + "librespot": "^0.1.3", "node-fetch": "^3.3.1", "xmldom-qsa": "^1.1.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b7f9e3..3c7db29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ dependencies: image-size: specifier: ^1.0.2 version: 1.0.2 + librespot: + specifier: ^0.1.3 + version: 0.1.3 node-fetch: specifier: ^3.3.1 version: 3.3.1 @@ -126,13 +129,55 @@ packages: fastq: 1.15.0 dev: true + /@protobufjs/aspromise@1.1.2: + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + dev: false + + /@protobufjs/base64@1.1.2: + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + dev: false + + /@protobufjs/codegen@2.0.4: + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + dev: false + + /@protobufjs/eventemitter@1.1.0: + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + dev: false + + /@protobufjs/fetch@1.1.0: + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + dev: false + + /@protobufjs/float@1.0.2: + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + dev: false + + /@protobufjs/inquire@1.1.0: + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + dev: false + + /@protobufjs/path@1.1.2: + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + dev: false + + /@protobufjs/pool@1.1.0: + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + dev: false + + /@protobufjs/utf8@1.1.0: + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + dev: false + /@types/json-schema@7.0.13: resolution: {integrity: sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ==} dev: true /@types/node@20.2.5: resolution: {integrity: sha512-JJulVEQXmiY9Px5axXHeYGLSjhkZEnD+MDPDGbCbIAbMslkKwmygtZFy1X6s/075Yo94sf8GuSlFfPzysQrWZQ==} - dev: true /@types/semver@7.5.2: resolution: {integrity: sha512-7aqorHYgdNO4DM36stTiGO3DvKoex9TQRwsJU6vMaFGyqpBA1MNZkz+PG3gaNUPpTAOYhT1WR7M1JyA3fbS9Cw==} @@ -551,6 +596,13 @@ packages: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} dev: true + /fast-xml-parser@4.2.7: + resolution: {integrity: sha512-J8r6BriSLO1uj2miOk1NW0YVm8AGOOu3Si2HQp/cSmo6EA4m3fcwu2WKjJ4RK9wMLBtg69y1kS8baDiQBR41Ig==} + hasBin: true + dependencies: + strnum: 1.0.5 + dev: false + /fastq@1.15.0: resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} dependencies: @@ -763,6 +815,17 @@ packages: type-check: 0.4.0 dev: true + /librespot@0.1.3: + resolution: {integrity: sha512-G9AGVUunh6zK8tTJE1kwod4gYge2nFJw8yyhCWBzK36HkZBXiHLMEKU1Yq8ANmXMPPPV7ffUFNOJFzJWqWFMwA==} + dependencies: + fast-xml-parser: 4.2.7 + node-fetch: 2.7.0 + protobufjs: 7.2.5 + shannon-bindings: 0.1.0 + transitivePeerDependencies: + - encoding + dev: false + /locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -774,6 +837,10 @@ packages: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true + /long@5.2.3: + resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==} + dev: false + /lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} @@ -812,11 +879,27 @@ packages: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true + /node-addon-api@1.7.2: + resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==} + dev: false + /node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} dev: false + /node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + dependencies: + whatwg-url: 5.0.0 + dev: false + /node-fetch@3.3.1: resolution: {integrity: sha512-cRVc/kyto/7E5shrWca1Wsea4y6tL9iYJE5FBCius3JQfb/4P4I295PfhgbJQBLTx6lATE4z+wK0rPM4VS2uow==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -901,6 +984,25 @@ packages: hasBin: true dev: true + /protobufjs@7.2.5: + resolution: {integrity: sha512-gGXRSXvxQ7UiPgfw8gevrfRWcTlSbOFg+p/N+JVJEK5VhueL2miT6qTymqAmjr1Q5WbOCyJbyrk6JfWKwlFn6A==} + engines: {node: '>=12.0.0'} + requiresBuild: true + 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.2.5 + long: 5.2.3 + dev: false + /punycode@2.3.0: resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} engines: {node: '>=6'} @@ -947,6 +1049,13 @@ packages: lru-cache: 6.0.0 dev: true + /shannon-bindings@0.1.0: + resolution: {integrity: sha512-svz5ewuAGfrxibOe5GVfPfvjs8FwHEixTW2K3cUEDDt6tCnxTsFzqi0JoQ//45EGYoxxjX2uES3msWE41XVaIQ==} + requiresBuild: true + dependencies: + node-addon-api: 1.7.2 + dev: false + /shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -976,6 +1085,10 @@ packages: engines: {node: '>=8'} dev: true + /strnum@1.0.5: + resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} + dev: false + /supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -994,6 +1107,10 @@ packages: is-number: 7.0.0 dev: true + /tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + dev: false + /tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} dev: true @@ -1037,6 +1154,17 @@ packages: engines: {node: '>= 8'} dev: false + /webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + dev: false + + /whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + dev: false + /which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} diff --git a/src/index.ts b/src/index.ts index 850ac2d..c814dd1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,7 +25,7 @@ class Lucida { if (!this.logins) throw new Error('No logins specified') for (const i in this.logins) { const credentials = this.logins[i] - await this.modules[i].login?.(credentials.username, credentials.password) + await this.modules[i]?.login?.(credentials.username, credentials.password) } } async search(query: string, limit: number): Promise<{ [key: string]: SearchResults }> { @@ -53,6 +53,13 @@ class Lucida { } throw new Error(`Couldn't find module for hostname ${urlObj.hostname}`) } + disconnect() { + return Promise.all( + Object.values(this.modules).map((e) => { + return e.disconnect?.() + }) + ) + } } export default Lucida diff --git a/src/streamers/spotify/main.ts b/src/streamers/spotify/main.ts new file mode 100644 index 0000000..844b6d9 --- /dev/null +++ b/src/streamers/spotify/main.ts @@ -0,0 +1,84 @@ +import Librespot from 'librespot' +import { parseArtist, parseAlbum, parseTrack } from './parse.js' +import { GetByUrlResponse, SearchResults, StreamerWithLogin } from '../../types.js' +import { SpotifyAlbum, SpotifyArtist } from 'librespot/build/utils/types' + +class Spotify implements StreamerWithLogin { + client: Librespot + hostnames = ['open.spotify.com'] + constructor(options: never) { + this.client = new Librespot(options) + } + login(username: string, password: string) { + return this.client.login(username, password) + } + #getUrlParts(url: string): ['artist' | 'album' | 'track', 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') { + throw new Error(`Spotify type "${parts[0]}" unsupported`) + } + if (!parts[1]) throw new Error('Unknown Spotify URL') + return [parts[0], parts[1]] + } + getTypeFromUrl(url: string) { + return this.#getUrlParts(url)[0] + } + async getByUrl(url: string): Promise { + const [type, id] = this.#getUrlParts(url) + if (type == '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) + } + } + const data = await this.client.get.byUrl(url) + let metadata + switch (type) { + case 'artist': + metadata = parseArtist(data) + return { + type, + metadata + } + case 'album': + metadata = parseAlbum(data) + if ((data).tracks) { + return { + type, + metadata, + tracks: (data).tracks?.map((e) => parseTrack(e)) ?? [] + } + } + return { + type, + metadata, + tracks: [] + } + } + } + async search(query: string): Promise { + 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)) ?? [] + } + } + disconnect() { + return this.client.disconnect() + } +} + +export default Spotify diff --git a/src/streamers/spotify/parse.ts b/src/streamers/spotify/parse.ts new file mode 100644 index 0000000..f996b43 --- /dev/null +++ b/src/streamers/spotify/parse.ts @@ -0,0 +1,57 @@ +import type { + SpotifyAlbum, + SpotifyArtist, + SpotifyThumbnail, + SpotifyTrack +} from 'librespot/build/utils/types' +import { Album, Artist, Track } from '../../types' + +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) + return track +} + +export function parseAlbum(raw: SpotifyAlbum): Album { + return { + 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)) + } +} diff --git a/src/types.ts b/src/types.ts index 958767f..bd8f717 100644 --- a/src/types.ts +++ b/src/types.ts @@ -86,6 +86,7 @@ export interface Streamer { search(query: string, limit: number): Promise getTypeFromUrl(url: string): ItemType getByUrl(url: string): Promise + disconnect?(): Promise } export interface StreamerWithLogin extends Streamer {