diff --git a/src/index.ts b/src/index.ts index 8a7d360..26427ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { ItemType, GetByUrlResponse, SearchResults, Streamer, StreamerWithLogin } from './types.js' +import { ItemType, GetByUrlResponse, SearchResults, Streamer, StreamerWithLogin, StreamerAccount } from './types.js' interface LucidaOptions { modules: { [key: string]: Streamer | StreamerWithLogin } @@ -38,6 +38,16 @@ class Lucida { const moduleNames = Object.keys(this.modules) return Object.fromEntries(results.map((e, i) => [moduleNames[i], e])) } + async checkAccounts(): Promise<{ [key: string]: StreamerAccount }> { + const results = await Promise.all( + Object.values(this.modules).map(async (e) => { + if (e.getAccountInfo) return (await e.getAccountInfo()) + else return {valid: false} + }) + ) + const moduleNames = Object.keys(this.modules) + return Object.fromEntries(results.map((e, i) => [moduleNames[i], e])) + } async getTypeFromUrl(url: string): Promise { const urlObj = new URL(url) for (const i in this.modules) { diff --git a/src/streamers/qobuz/main.ts b/src/streamers/qobuz/main.ts index ad85809..c67f8d6 100644 --- a/src/streamers/qobuz/main.ts +++ b/src/streamers/qobuz/main.ts @@ -6,7 +6,8 @@ import { SearchResults, GetByUrlResponse, GetStreamResponse, - Track + Track, + StreamerAccount } from '../../types.js' import { DEFAULT_HEADERS } from './constants.js' import { parseAlbum, parseTrack, parseArtist, RawAlbum, RawArtist, RawTrack } from './parse.js' diff --git a/src/streamers/qobuz/parse.ts b/src/streamers/qobuz/parse.ts index c6f9a70..0a6865e 100644 --- a/src/streamers/qobuz/parse.ts +++ b/src/streamers/qobuz/parse.ts @@ -46,10 +46,23 @@ export interface RawAlbum { } 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): Album { - return { +export function parseAlbum(raw: RawAlbum) { + console.log(raw) + const album: Album = { title: raw.title, id: raw.id, url: raw.url ?? `https://play.qobuz.com/album/${raw.id}`, @@ -70,8 +83,16 @@ export function parseAlbum(raw: RawAlbum): Album { height: 600 } ], - artists: raw.artists?.map(parseArtist) ?? [parseArtist(raw.artist)] + 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 { @@ -82,20 +103,49 @@ export interface RawTrack { album?: RawAlbum track_number?: number media_number?: number - duration: number + duration: number, + parental_warning: boolean + isrc: string, + performers?: string } export function parseTrack(raw: RawTrack): Track { - const track: Track = { + let track: Track = { title: raw.title, id: raw.id.toString(), url: `https://play.qobuz.com/track/${raw.id.toString()}`, copyright: raw.copyright, artists: [parseArtist(raw.performer)], - durationMs: raw.duration * 1000 + 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')) track.performers.push(name) + if (credits.toLowerCase().includes('engineer')) track.engineers.push(name) + } + + return track +} \ No newline at end of file diff --git a/src/streamers/soundcloud/main.ts b/src/streamers/soundcloud/main.ts index 1237104..000590c 100644 --- a/src/streamers/soundcloud/main.ts +++ b/src/streamers/soundcloud/main.ts @@ -141,18 +141,21 @@ export default class Soundcloud implements Streamer { switch (type) { case 'track': { const trackId = html - .split(`{"hydratable":"sound",`)?.[1] - ?.split(`"id":`)?.[1] - ?.split(',')?.[0] + .split(``)?.[0] + + let naked = `https://api-v2.soundcloud.com/tracks/${trackId}` + let 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(`https://api-v2.soundcloud.com/tracks?ids=${trackId}`, client), + this.#formatURL(naked, client), { method: 'get', headers: headers(this.oauthToken) } ) ).text() - )[0] + ) return { type: 'track', @@ -215,6 +218,8 @@ export default class Soundcloud implements Streamer { metadata: parseArtist(data) } } + default: + throw(`Type "${type}" not supported.`) } } async #getRawTrackInfo(id: number | string, client: ScClient) { diff --git a/src/streamers/soundcloud/parse.ts b/src/streamers/soundcloud/parse.ts index bf6d80f..d3fb9b3 100644 --- a/src/streamers/soundcloud/parse.ts +++ b/src/streamers/soundcloud/parse.ts @@ -81,10 +81,14 @@ export interface RawTrack { kind: 'track' id: number title: string + duration: number, + created_at: string, + full_duration: number, permalink_url: string - artwork_url?: string - full_duration: number - user: RawArtist + artwork_url?: string, + user: RawArtist, + last_modified: string, + description: string } export async function parseTrack(raw: RawTrack): Promise { @@ -93,12 +97,12 @@ export async function parseTrack(raw: RawTrack): Promise { title: raw.title, url: raw.permalink_url, artists: [parseArtist(raw.user)], - durationMs: raw.media?.transcodings?.[0]?.duration + 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)] - } + if (raw?.artwork_url != undefined) track.coverArtwork = [await parseCoverArtwork(raw?.artwork_url)] return track } diff --git a/src/streamers/spotify/main.ts b/src/streamers/spotify/main.ts index ad7f6e3..0fd7cb0 100644 --- a/src/streamers/spotify/main.ts +++ b/src/streamers/spotify/main.ts @@ -1,6 +1,6 @@ import Librespot, { LibrespotOptions } from 'librespot' -import { parseArtist, parseAlbum, parseTrack } from './parse.js' -import { GetByUrlResponse, SearchResults, StreamerWithLogin } from '../../types.js' +import { parseArtist, parseAlbum, parseTrack, parseEpisode, parsePodcast } from './parse.js' +import { GetByUrlResponse, ItemType, SearchResults, StreamerAccount, StreamerWithLogin } from '../../types.js' class Spotify implements StreamerWithLogin { client: Librespot @@ -11,18 +11,22 @@ class Spotify implements StreamerWithLogin { login(username: string, password: string) { return this.client.login(username, password) } - #getUrlParts(url: string): ['artist' | 'album' | 'track', string] { + #getUrlParts(url: string): ['artist' | 'album' | 'track' | 'episode' | 'show', 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') { + if (parts[0] != 'artist' && parts[0] != 'track' && parts[0] != 'album' && parts[0] != 'show' && parts[0] != 'episode') { 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) { - return this.#getUrlParts(url)[0] + let type: ItemType | 'show' = this.#getUrlParts(url)[0] + + if (type == 'show') type = 'podcast' + + return type } async getByUrl(url: string, limit = 0): Promise { const [type, id] = this.#getUrlParts(url) @@ -59,7 +63,7 @@ class Spotify implements StreamerWithLogin { if (tracks) { return { type, - metadata: parseAlbum(metadata), + metadata: {...parseAlbum(metadata), trackCount: tracks.length}, tracks: tracks?.map((e) => parseTrack(e)) ?? [] } } @@ -69,6 +73,29 @@ class Spotify implements StreamerWithLogin { 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))) ?? [] + } + } } } async search(query: string): Promise { @@ -80,6 +107,12 @@ class Spotify implements StreamerWithLogin { tracks: results.tracks?.map((e) => parseTrack(e)) ?? [] } } + async getAccountInfo(): Promise { + return { + valid: true, + premium: (await this.client.isPremium()) + } + } disconnect() { return this.client.disconnect() } diff --git a/src/streamers/spotify/parse.ts b/src/streamers/spotify/parse.ts index 8f54424..eea9a7d 100644 --- a/src/streamers/spotify/parse.ts +++ b/src/streamers/spotify/parse.ts @@ -1,5 +1,5 @@ -import type { SpotifyAlbum, SpotifyArtist, SpotifyThumbnail, SpotifyTrack } from 'librespot/types' -import { Album, Artist, Track } from '../../types.js' +import type { SpotifyAlbum, SpotifyArtist, SpotifyThumbnail, SpotifyTrack, SpotifyEpisode, SpotifyPodcast } from 'librespot/types' +import { Album, Artist, Episode, Podcast, Track } from '../../types.js' function parseThumbnails(raw: SpotifyThumbnail[]) { return raw @@ -47,6 +47,34 @@ export function parseAlbum(raw: SpotifyAlbum): Album { trackCount: raw.totalTracks, releaseDate: raw.releaseDate, coverArtwork: parseThumbnails(raw.coverArtwork), - artists: raw.artists.map((e) => parseArtist(e)) + artists: raw.artists.map((e) => parseArtist(e)), } } + +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 + + return podcast +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 68dd2e7..2f92faf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ type Id = string | number -export type ItemType = 'artist' | 'album' | 'track' +export type ItemType = 'artist' | 'album' | 'track' | 'episode' | 'podcast' +export type Region = string export interface CoverArtwork { url: string @@ -27,6 +28,10 @@ export interface Album { releaseDate?: Date coverArtwork?: CoverArtwork[] artists?: Artist[] + description?: string + copyright?: string, + label?: string, + genre?: string[] } export interface Track { @@ -42,10 +47,40 @@ export interface Track { producers?: string[] composers?: string[] lyricists?: string[] + performers?: string[] + engineers?: string[] album?: Album durationMs?: number coverArtwork?: CoverArtwork[] regions?: Region[] + genres?: string[] + releaseDate?: Date + description?: 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[] } export interface SearchResults { @@ -65,13 +100,20 @@ export interface GetStreamResponse { export type GetByUrlResponse = | TrackGetByUrlResponse | ArtistGetByUrlResponse - | AlbumGetByUrlResponse + | AlbumGetByUrlResponse + | EpisodeGetByUrlResponse + | PodcastGetByUrlResponse export interface TrackGetByUrlResponse { type: 'track' getStream(): Promise metadata: Track } +export interface EpisodeGetByUrlResponse { + type: 'episode' + getStream(): Promise + metadata: Episode +} export interface ArtistGetByUrlResponse { type: 'artist' metadata: Artist @@ -81,6 +123,17 @@ export interface AlbumGetByUrlResponse { tracks: Track[] metadata: Album } +export interface PodcastGetByUrlResponse { + type: 'podcast' + episodes: Episode[] + metadata: Podcast +} + +export interface StreamerAccount { + valid: boolean + premium?: boolean + country?: string +} export interface Streamer { hostnames: string[] @@ -90,8 +143,9 @@ export interface Streamer { | ((url: string) => Promise) | ((url: string, limit?: number) => Promise) disconnect?(): Promise + getAccountInfo?(): Promise } export interface StreamerWithLogin extends Streamer { login(username: string, password: string): Promise -} +} \ No newline at end of file