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 {
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<ItemType> {
const urlObj = new URL(url)
for (const i in this.modules) {

View File

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

View File

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

View File

@ -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(`<link rel="alternate" href="android-app://com.soundcloud.android/soundcloud/sounds:`)?.[1]
?.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) {

View File

@ -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<Track> {
@ -93,12 +97,12 @@ export async function parseTrack(raw: RawTrack): Promise<Track> {
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
}

View File

@ -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<GetByUrlResponse> {
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<SearchResults> {
@ -80,6 +107,12 @@ class Spotify implements StreamerWithLogin {
tracks: results.tracks?.map((e) => parseTrack(e)) ?? []
}
}
async getAccountInfo(): Promise<StreamerAccount> {
return {
valid: true,
premium: (await this.client.isPremium())
}
}
disconnect() {
return this.client.disconnect()
}

View File

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

View File

@ -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<GetStreamResponse>
metadata: Track
}
export interface EpisodeGetByUrlResponse {
type: 'episode'
getStream(): Promise<GetStreamResponse>
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<GetByUrlResponse>)
| ((url: string, limit?: number) => Promise<GetByUrlResponse>)
disconnect?(): Promise<void>
getAccountInfo?(): Promise<StreamerAccount>
}
export interface StreamerWithLogin extends Streamer {
login(username: string, password: string): Promise<void>
}
}