mirror of
https://git.gay/lucida/lucida
synced 2025-12-11 20:15:14 +01:00
add episodes/podcasts to spotify, fix soundcloud bugs
This commit is contained in:
parent
25f7babb3e
commit
12a04b2a7b
12
src/index.ts
12
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<ItemType> {
|
||||
const urlObj = new URL(url)
|
||||
for (const i in this.modules) {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
60
src/types.ts
60
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<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>
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user