add episodes/podcasts to spotify, fix soundcloud bugs

This commit is contained in:
aria
2024-07-17 04:12:16 -04:00
parent 25f7babb3e
commit 12a04b2a7b
8 changed files with 217 additions and 32 deletions

View File

@@ -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 { interface LucidaOptions {
modules: { [key: string]: Streamer | StreamerWithLogin } modules: { [key: string]: Streamer | StreamerWithLogin }
@@ -38,6 +38,16 @@ class Lucida {
const moduleNames = Object.keys(this.modules) const moduleNames = Object.keys(this.modules)
return Object.fromEntries(results.map((e, i) => [moduleNames[i], e])) 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<ItemType> { async getTypeFromUrl(url: string): Promise<ItemType> {
const urlObj = new URL(url) const urlObj = new URL(url)
for (const i in this.modules) { for (const i in this.modules) {

View File

@@ -6,7 +6,8 @@ import {
SearchResults, SearchResults,
GetByUrlResponse, GetByUrlResponse,
GetStreamResponse, GetStreamResponse,
Track Track,
StreamerAccount
} from '../../types.js' } from '../../types.js'
import { DEFAULT_HEADERS } from './constants.js' import { DEFAULT_HEADERS } from './constants.js'
import { parseAlbum, parseTrack, parseArtist, RawAlbum, RawArtist, RawTrack } from './parse.js' import { parseAlbum, parseTrack, parseArtist, RawAlbum, RawArtist, RawTrack } from './parse.js'

View File

@@ -46,10 +46,23 @@ export interface RawAlbum {
} }
artists?: RawArtist[] artists?: RawArtist[]
artist: 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 { export function parseAlbum(raw: RawAlbum) {
return { console.log(raw)
const album: Album = {
title: raw.title, title: raw.title,
id: raw.id, id: raw.id,
url: raw.url ?? `https://play.qobuz.com/album/${raw.id}`, url: raw.url ?? `https://play.qobuz.com/album/${raw.id}`,
@@ -70,8 +83,16 @@ export function parseAlbum(raw: RawAlbum): Album {
height: 600 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 { export interface RawTrack {
@@ -82,20 +103,49 @@ export interface RawTrack {
album?: RawAlbum album?: RawAlbum
track_number?: number track_number?: number
media_number?: number media_number?: number
duration: number duration: number,
parental_warning: boolean
isrc: string,
performers?: string
} }
export function parseTrack(raw: RawTrack): Track { export function parseTrack(raw: RawTrack): Track {
const track: Track = { let track: Track = {
title: raw.title, title: raw.title,
id: raw.id.toString(), id: raw.id.toString(),
url: `https://play.qobuz.com/track/${raw.id.toString()}`, url: `https://play.qobuz.com/track/${raw.id.toString()}`,
copyright: raw.copyright, copyright: raw.copyright,
artists: [parseArtist(raw.performer)], 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.album) track.album = parseAlbum(raw.album)
if (raw.track_number) track.trackNumber = raw.track_number if (raw.track_number) track.trackNumber = raw.track_number
if (raw.media_number) track.discNumber = raw.media_number if (raw.media_number) track.discNumber = raw.media_number
if (raw.performers) track = parsePerformers(raw.performers, track)
return 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
}

View File

@@ -141,18 +141,21 @@ export default class Soundcloud implements Streamer {
switch (type) { switch (type) {
case 'track': { case 'track': {
const trackId = html const trackId = html
.split(`{"hydratable":"sound",`)?.[1] .split(`<link rel="alternate" href="android-app://com.soundcloud.android/soundcloud/sounds:`)?.[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( const api = JSON.parse(
await ( await (
await fetch( await fetch(
this.#formatURL(`https://api-v2.soundcloud.com/tracks?ids=${trackId}`, client), this.#formatURL(naked, client),
{ method: 'get', headers: headers(this.oauthToken) } { method: 'get', headers: headers(this.oauthToken) }
) )
).text() ).text()
)[0] )
return { return {
type: 'track', type: 'track',
@@ -215,6 +218,8 @@ export default class Soundcloud implements Streamer {
metadata: parseArtist(data) metadata: parseArtist(data)
} }
} }
default:
throw(`Type "${type}" not supported.`)
} }
} }
async #getRawTrackInfo(id: number | string, client: ScClient) { async #getRawTrackInfo(id: number | string, client: ScClient) {

View File

@@ -81,10 +81,14 @@ export interface RawTrack {
kind: 'track' kind: 'track'
id: number id: number
title: string title: string
duration: number,
created_at: string,
full_duration: number,
permalink_url: string permalink_url: string
artwork_url?: string artwork_url?: string,
full_duration: number user: RawArtist,
user: RawArtist last_modified: string,
description: string
} }
export async function parseTrack(raw: RawTrack): Promise<Track> { export async function parseTrack(raw: RawTrack): Promise<Track> {
@@ -93,12 +97,12 @@ export async function parseTrack(raw: RawTrack): Promise<Track> {
title: raw.title, title: raw.title,
url: raw.permalink_url, url: raw.permalink_url,
artists: [parseArtist(raw.user)], 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) { if (raw?.artwork_url != undefined) track.coverArtwork = [await parseCoverArtwork(raw?.artwork_url)]
track.coverArtwork = [await parseCoverArtwork(raw?.artwork_url)]
}
return track return track
} }

View File

@@ -1,6 +1,6 @@
import Librespot, { LibrespotOptions } from 'librespot' import Librespot, { LibrespotOptions } from 'librespot'
import { parseArtist, parseAlbum, parseTrack } from './parse.js' import { parseArtist, parseAlbum, parseTrack, parseEpisode, parsePodcast } from './parse.js'
import { GetByUrlResponse, SearchResults, StreamerWithLogin } from '../../types.js' import { GetByUrlResponse, ItemType, SearchResults, StreamerAccount, StreamerWithLogin } from '../../types.js'
class Spotify implements StreamerWithLogin { class Spotify implements StreamerWithLogin {
client: Librespot client: Librespot
@@ -11,18 +11,22 @@ class Spotify implements StreamerWithLogin {
login(username: string, password: string) { login(username: string, password: string) {
return this.client.login(username, password) 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 urlObj = new URL(url)
const parts = urlObj.pathname.slice(1).split('/') const parts = urlObj.pathname.slice(1).split('/')
if (parts.length > 2) throw new Error('Unknown Spotify URL') 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`) throw new Error(`Spotify type "${parts[0]}" unsupported`)
} }
if (!parts[1]) throw new Error('Unknown Spotify URL') if (!parts[1]) throw new Error('Unknown Spotify URL')
return [parts[0], parts[1]] return [parts[0], parts[1]]
} }
async getTypeFromUrl(url: string) { 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<GetByUrlResponse> { async getByUrl(url: string, limit = 0): Promise<GetByUrlResponse> {
const [type, id] = this.#getUrlParts(url) const [type, id] = this.#getUrlParts(url)
@@ -59,7 +63,7 @@ class Spotify implements StreamerWithLogin {
if (tracks) { if (tracks) {
return { return {
type, type,
metadata: parseAlbum(metadata), metadata: {...parseAlbum(metadata), trackCount: tracks.length},
tracks: tracks?.map((e) => parseTrack(e)) ?? [] tracks: tracks?.map((e) => parseTrack(e)) ?? []
} }
} }
@@ -69,6 +73,29 @@ class Spotify implements StreamerWithLogin {
tracks: [] 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<SearchResults> { async search(query: string): Promise<SearchResults> {
@@ -80,6 +107,12 @@ class Spotify implements StreamerWithLogin {
tracks: results.tracks?.map((e) => parseTrack(e)) ?? [] tracks: results.tracks?.map((e) => parseTrack(e)) ?? []
} }
} }
async getAccountInfo(): Promise<StreamerAccount> {
return {
valid: true,
premium: (await this.client.isPremium())
}
}
disconnect() { disconnect() {
return this.client.disconnect() return this.client.disconnect()
} }

View File

@@ -1,5 +1,5 @@
import type { SpotifyAlbum, SpotifyArtist, SpotifyThumbnail, SpotifyTrack } from 'librespot/types' import type { SpotifyAlbum, SpotifyArtist, SpotifyThumbnail, SpotifyTrack, SpotifyEpisode, SpotifyPodcast } from 'librespot/types'
import { Album, Artist, Track } from '../../types.js' import { Album, Artist, Episode, Podcast, Track } from '../../types.js'
function parseThumbnails(raw: SpotifyThumbnail[]) { function parseThumbnails(raw: SpotifyThumbnail[]) {
return raw return raw
@@ -47,6 +47,34 @@ export function parseAlbum(raw: SpotifyAlbum): Album {
trackCount: raw.totalTracks, trackCount: raw.totalTracks,
releaseDate: raw.releaseDate, releaseDate: raw.releaseDate,
coverArtwork: parseThumbnails(raw.coverArtwork), 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
}

View File

@@ -1,6 +1,7 @@
type Id = string | number 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 { export interface CoverArtwork {
url: string url: string
@@ -27,6 +28,10 @@ export interface Album {
releaseDate?: Date releaseDate?: Date
coverArtwork?: CoverArtwork[] coverArtwork?: CoverArtwork[]
artists?: Artist[] artists?: Artist[]
description?: string
copyright?: string,
label?: string,
genre?: string[]
} }
export interface Track { export interface Track {
@@ -42,10 +47,40 @@ export interface Track {
producers?: string[] producers?: string[]
composers?: string[] composers?: string[]
lyricists?: string[] lyricists?: string[]
performers?: string[]
engineers?: string[]
album?: Album album?: Album
durationMs?: number durationMs?: number
coverArtwork?: CoverArtwork[] coverArtwork?: CoverArtwork[]
regions?: Region[] 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 { export interface SearchResults {
@@ -65,13 +100,20 @@ export interface GetStreamResponse {
export type GetByUrlResponse = export type GetByUrlResponse =
| TrackGetByUrlResponse | TrackGetByUrlResponse
| ArtistGetByUrlResponse | ArtistGetByUrlResponse
| AlbumGetByUrlResponse | AlbumGetByUrlResponse
| EpisodeGetByUrlResponse
| PodcastGetByUrlResponse
export interface TrackGetByUrlResponse { export interface TrackGetByUrlResponse {
type: 'track' type: 'track'
getStream(): Promise<GetStreamResponse> getStream(): Promise<GetStreamResponse>
metadata: Track metadata: Track
} }
export interface EpisodeGetByUrlResponse {
type: 'episode'
getStream(): Promise<GetStreamResponse>
metadata: Episode
}
export interface ArtistGetByUrlResponse { export interface ArtistGetByUrlResponse {
type: 'artist' type: 'artist'
metadata: Artist metadata: Artist
@@ -81,6 +123,17 @@ export interface AlbumGetByUrlResponse {
tracks: Track[] tracks: Track[]
metadata: Album metadata: Album
} }
export interface PodcastGetByUrlResponse {
type: 'podcast'
episodes: Episode[]
metadata: Podcast
}
export interface StreamerAccount {
valid: boolean
premium?: boolean
country?: string
}
export interface Streamer { export interface Streamer {
hostnames: string[] hostnames: string[]
@@ -90,8 +143,9 @@ export interface Streamer {
| ((url: string) => Promise<GetByUrlResponse>) | ((url: string) => Promise<GetByUrlResponse>)
| ((url: string, limit?: number) => Promise<GetByUrlResponse>) | ((url: string, limit?: number) => Promise<GetByUrlResponse>)
disconnect?(): Promise<void> disconnect?(): Promise<void>
getAccountInfo?(): Promise<StreamerAccount>
} }
export interface StreamerWithLogin extends Streamer { export interface StreamerWithLogin extends Streamer {
login(username: string, password: string): Promise<void> login(username: string, password: string): Promise<void>
} }