add spotify support

This commit is contained in:
hazycora 2023-09-16 22:39:32 -05:00
parent 8af3b9f926
commit c321cfab05
No known key found for this signature in database
GPG Key ID: 215AF1F81F86940E
7 changed files with 294 additions and 2 deletions

View File

@ -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.

View File

@ -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"
},

130
pnpm-lock.yaml generated
View File

@ -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'}

View File

@ -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

View File

@ -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<GetByUrlResponse> {
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(<SpotifyArtist>data)
return {
type,
metadata
}
case 'album':
metadata = parseAlbum(<SpotifyAlbum>data)
if ((<SpotifyAlbum>data).tracks) {
return {
type,
metadata,
tracks: (<SpotifyAlbum>data).tracks?.map((e) => parseTrack(e)) ?? []
}
}
return {
type,
metadata,
tracks: []
}
}
}
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)) ?? []
}
}
disconnect() {
return this.client.disconnect()
}
}
export default Spotify

View File

@ -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))
}
}

View File

@ -86,6 +86,7 @@ export interface Streamer {
search(query: string, limit: number): Promise<SearchResults>
getTypeFromUrl(url: string): ItemType
getByUrl(url: string): Promise<GetByUrlResponse>
disconnect?(): Promise<void>
}
export interface StreamerWithLogin extends Streamer {