mirror of
https://codeberg.org/lucida/lucida.git
synced 2026-11-09 13:07:25 -05:00
Merge pull request 'finally. playlists.' (#16) from feat/playlists into main
Reviewed-on: https://git.gay/lucida/lucida/pulls/16
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
export const GW_LIGHT_URL = 'https://www.deezer.com/ajax/gw-light.php'
|
||||
export const CLIENT_ID = '447462'
|
||||
export const CLIENT_SECRET = 'a83bf7f38ad2f137e444727cfc3775cf'
|
||||
export const SIZES = [32, 64, 128, 256, 512, 1024]
|
||||
export const SIZES = [128, 256, 512, 1024]
|
||||
|
||||
export const BLOWFISH_SECRET = 'g4el58wc0zvf9na1'
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
GetByUrlResponse,
|
||||
GetStreamResponse,
|
||||
ItemType,
|
||||
PlaylistGetByUrlResponse,
|
||||
SearchResults,
|
||||
StreamerAccount,
|
||||
StreamerWithLogin,
|
||||
@@ -15,6 +16,7 @@ import { createHash } from 'crypto'
|
||||
import {
|
||||
DeezerAlbum,
|
||||
DeezerArtist,
|
||||
DeezerPlaylistMetadata,
|
||||
DeezerFormat,
|
||||
DeezerLoginResponse,
|
||||
DeezerMediaResponse,
|
||||
@@ -22,7 +24,8 @@ import {
|
||||
DeezerUserData,
|
||||
parseAlbum,
|
||||
parseArtist,
|
||||
parseTrack
|
||||
parseTrack,
|
||||
parsePlaylistMetadata
|
||||
} from './parse.js'
|
||||
import { Readable, Transform } from 'stream'
|
||||
import { Blowfish } from 'blowfish-cbc'
|
||||
@@ -43,9 +46,15 @@ interface APIMethod {
|
||||
DATA: DeezerAlbum
|
||||
SONGS: { data: DeezerTrack[] }
|
||||
}
|
||||
'playlist.getSongs': {
|
||||
data: DeezerTrack[]
|
||||
}
|
||||
'deezer.pageTrack': {
|
||||
DATA: DeezerTrack
|
||||
}
|
||||
'deezer.pagePlaylist': {
|
||||
DATA: DeezerPlaylistMetadata
|
||||
}
|
||||
'user.getArl': string
|
||||
'search.music': { data: (DeezerArtist | DeezerAlbum | DeezerTrack)[] }
|
||||
'song.getListData': { data: DeezerTrack[] }
|
||||
@@ -272,7 +281,7 @@ export default class Deezer implements StreamerWithLogin {
|
||||
async #getInfoFromUrl(url: URL): Promise<{ type: ItemType; id: number }> {
|
||||
if (url.hostname == 'deezer.page.link') url = await this.#unshortenUrl(url)
|
||||
|
||||
const match = url.pathname.match(/^\/(?:[a-z]{2}\/)?(track|album|artist)\/(\d+)\/?$/)
|
||||
const match = url.pathname.match(/^\/(?:[a-z]{2}\/)?(track|album|artist|playlist)\/(\d+)\/?$/)
|
||||
if (!match) throw new Error('URL not supported')
|
||||
|
||||
const [, type, id] = match
|
||||
@@ -317,6 +326,29 @@ export default class Deezer implements StreamerWithLogin {
|
||||
return data
|
||||
}
|
||||
|
||||
// Playlist
|
||||
|
||||
async #getPlaylist(id: number): Promise<PlaylistGetByUrlResponse> {
|
||||
const playlistMetadata = (
|
||||
await this.#apiCall('deezer.pagePlaylist', {
|
||||
playlist_id: id
|
||||
})
|
||||
).DATA
|
||||
const playlistTracks = (
|
||||
await this.#apiCall('playlist.getSongs', {
|
||||
playlist_id: id,
|
||||
nb: 9999,
|
||||
start: 0
|
||||
})
|
||||
).data
|
||||
|
||||
return {
|
||||
type: 'playlist',
|
||||
metadata: parsePlaylistMetadata(playlistMetadata),
|
||||
tracks: playlistTracks.map(parseTrack)
|
||||
}
|
||||
}
|
||||
|
||||
// Track
|
||||
|
||||
async #getTrackData(id: number): Promise<DeezerTrack> {
|
||||
@@ -501,6 +533,9 @@ export default class Deezer implements StreamerWithLogin {
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'playlist': {
|
||||
return await this.#getPlaylist(id)
|
||||
}
|
||||
default:
|
||||
throw new Error('URL unrecognised')
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Album, Artist, CoverArtwork, Track } from '../../types.js'
|
||||
import { Album, Artist, CoverArtwork, Playlist, Track } from '../../types.js'
|
||||
import { SIZES } from './constants.js'
|
||||
|
||||
export enum DeezerFormat {
|
||||
@@ -185,3 +185,22 @@ export function parseArtwork(picture: string): CoverArtwork[] {
|
||||
width: size
|
||||
}))
|
||||
}
|
||||
|
||||
export interface DeezerPlaylistMetadata {
|
||||
PLAYLIST_ID: string
|
||||
TITLE: string
|
||||
DESCRIPTION: string
|
||||
PLAYLIST_PICTURE: string
|
||||
DURATION: number
|
||||
NB_SONG: number
|
||||
}
|
||||
|
||||
export function parsePlaylistMetadata(raw: DeezerPlaylistMetadata) {
|
||||
return <Playlist>{
|
||||
id: raw.PLAYLIST_ID,
|
||||
title: raw.TITLE,
|
||||
url: `https://www.deezer.com/playlist/${raw.PLAYLIST_ID}`,
|
||||
coverArtwork: parseArtwork(raw.PLAYLIST_PICTURE),
|
||||
trackCount: raw.NB_SONG
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,16 @@ import {
|
||||
StreamerAccount
|
||||
} from '../../types.js'
|
||||
import { DEFAULT_HEADERS } from './constants.js'
|
||||
import { parseAlbum, parseTrack, parseArtist, RawAlbum, RawArtist, RawTrack } from './parse.js'
|
||||
import {
|
||||
parseAlbum,
|
||||
parseTrack,
|
||||
parseArtist,
|
||||
RawAlbum,
|
||||
RawArtist,
|
||||
RawTrack,
|
||||
RawPlaylist,
|
||||
parsePlaylist
|
||||
} from './parse.js'
|
||||
import { Readable } from 'stream'
|
||||
|
||||
function headers(token?: string): HeadersInit {
|
||||
@@ -207,6 +216,7 @@ export default class Qobuz implements StreamerWithLogin {
|
||||
app_id: this.appId
|
||||
})
|
||||
return {
|
||||
type: 'album',
|
||||
metadata: { ...parseAlbum(albumResponse), trackCount: albumResponse.tracks?.items.length },
|
||||
tracks: albumResponse.tracks?.items.map(parseTrack)
|
||||
}
|
||||
@@ -224,7 +234,21 @@ export default class Qobuz implements StreamerWithLogin {
|
||||
)
|
||||
}
|
||||
|
||||
#getUrlParts(url: string): ['artist' | 'album' | 'track', string] {
|
||||
async #getPlaylist(id: string) {
|
||||
const init = <RawPlaylist>await this.#get('playlist/get', {
|
||||
playlist_id: id,
|
||||
extra: 'tracks,getSimilarPlaylists',
|
||||
offset: 0,
|
||||
limit: 1000,
|
||||
app_id: this.appId
|
||||
})
|
||||
|
||||
if (init.tracks_count > 1000)
|
||||
throw new Error('To be added, playlists with over a thousand tracks.')
|
||||
else return parsePlaylist(init)
|
||||
}
|
||||
|
||||
#getUrlParts(url: string): ['artist' | 'album' | 'track' | 'playlist', string] {
|
||||
const urlObj = new URL(url)
|
||||
if (urlObj.hostname == 'www.qobuz.com' || urlObj.hostname == 'qobuz.com') {
|
||||
const urlParts = url
|
||||
@@ -239,6 +263,8 @@ export default class Qobuz implements StreamerWithLogin {
|
||||
case 'album':
|
||||
case 'track':
|
||||
return [type, id]
|
||||
case 'playlists':
|
||||
return ['playlist', id]
|
||||
default:
|
||||
throw new Error('URL unrecognised')
|
||||
}
|
||||
@@ -248,7 +274,12 @@ export default class Qobuz implements StreamerWithLogin {
|
||||
?.slice(1, 3)
|
||||
if (!urlParts) throw new Error('URL not supported')
|
||||
urlParts[1] = urlParts[1].replace(/\?.*?$/, '')
|
||||
if (urlParts[0] != 'artist' && urlParts[0] != 'album' && urlParts[0] != 'track') {
|
||||
if (
|
||||
urlParts[0] != 'artist' &&
|
||||
urlParts[0] != 'album' &&
|
||||
urlParts[0] != 'track' &&
|
||||
urlParts[0] != 'playlist'
|
||||
) {
|
||||
throw new Error('URL unrecognised')
|
||||
}
|
||||
return [urlParts[0], urlParts[1]]
|
||||
@@ -282,6 +313,9 @@ export default class Qobuz implements StreamerWithLogin {
|
||||
metadata: await this.#getArtistMetadata(id)
|
||||
}
|
||||
}
|
||||
case 'playlist': {
|
||||
return await this.#getPlaylist(id)
|
||||
}
|
||||
default:
|
||||
throw new Error('URL unrecognised')
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Artist, Album, Track } from '../../types.js'
|
||||
import { Artist, Album, Track, PlaylistGetByUrlResponse } from '../../types.js'
|
||||
|
||||
export interface RawArtist {
|
||||
id: number
|
||||
@@ -154,3 +154,37 @@ function parsePerformers(performers: string, track: Track) {
|
||||
|
||||
return track
|
||||
}
|
||||
|
||||
export interface RawPlaylist {
|
||||
owner: {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
id: number
|
||||
name: string
|
||||
images: string[]
|
||||
is_collaborative: boolean
|
||||
description: string
|
||||
created_at: number
|
||||
duration: number
|
||||
tracks_count: number
|
||||
tracks: {
|
||||
offset?: number
|
||||
limit?: number
|
||||
total?: number
|
||||
items: RawTrack[]
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePlaylist(raw: RawPlaylist) {
|
||||
return <PlaylistGetByUrlResponse>{
|
||||
type: 'playlist',
|
||||
metadata: {
|
||||
id: raw.id,
|
||||
title: raw.name,
|
||||
trackCount: raw?.tracks_count || raw?.tracks?.total,
|
||||
url: `https://open.qobuz.com/playlist/${raw.id}`
|
||||
},
|
||||
tracks: raw.tracks?.items.map(parseTrack)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import Librespot, { LibrespotOptions } from 'librespot'
|
||||
import { parseArtist, parseAlbum, parseTrack, parseEpisode, parsePodcast } from './parse.js'
|
||||
import {
|
||||
parseArtist,
|
||||
parseAlbum,
|
||||
parseTrack,
|
||||
parseEpisode,
|
||||
parsePodcast,
|
||||
parsePlaylist
|
||||
} from './parse.js'
|
||||
import {
|
||||
GetByUrlResponse,
|
||||
ItemType,
|
||||
@@ -41,7 +48,9 @@ class Spotify implements StreamerWithLogin {
|
||||
if (this.storedCredential) return await this.client.loginWithStoredCreds(username, password)
|
||||
else return await this.client.login(username, password)
|
||||
}
|
||||
#getUrlParts(url: string): ['artist' | 'album' | 'track' | 'episode' | 'show', string] {
|
||||
#getUrlParts(
|
||||
url: string
|
||||
): ['artist' | 'album' | 'track' | 'episode' | 'show' | 'playlist', string] {
|
||||
const urlObj = new URL(url)
|
||||
const parts = urlObj.pathname.slice(1).split('/')
|
||||
if (parts.length > 2) throw new Error('Unknown Spotify URL')
|
||||
@@ -50,7 +59,8 @@ class Spotify implements StreamerWithLogin {
|
||||
parts[0] != 'track' &&
|
||||
parts[0] != 'album' &&
|
||||
parts[0] != 'show' &&
|
||||
parts[0] != 'episode'
|
||||
parts[0] != 'episode' &&
|
||||
parts[0] != 'playlist'
|
||||
) {
|
||||
throw new Error(`Spotify type "${parts[0]}" unsupported`)
|
||||
}
|
||||
@@ -132,6 +142,14 @@ class Spotify implements StreamerWithLogin {
|
||||
episodes: metadata.episodes?.map((e) => parseEpisode(e)) ?? []
|
||||
}
|
||||
}
|
||||
case 'playlist': {
|
||||
const metadata = await this.client.get.playlist(id)
|
||||
return {
|
||||
type: 'playlist',
|
||||
metadata: parsePlaylist(metadata),
|
||||
tracks: metadata.tracks?.map((e) => parseTrack(e)) ?? []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
async search(query: string): Promise<SearchResults> {
|
||||
|
||||
@@ -4,9 +4,10 @@ import type {
|
||||
SpotifyThumbnail,
|
||||
SpotifyTrack,
|
||||
SpotifyEpisode,
|
||||
SpotifyPodcast
|
||||
SpotifyPodcast,
|
||||
SpotifyPlaylist
|
||||
} from 'librespot/types'
|
||||
import { Album, Artist, Episode, Podcast, Track } from '../../types.js'
|
||||
import { Album, Artist, Episode, Playlist, Podcast, Track } from '../../types.js'
|
||||
|
||||
function parseThumbnails(raw: SpotifyThumbnail[]) {
|
||||
return raw
|
||||
@@ -92,3 +93,14 @@ export function parsePodcast(raw: SpotifyPodcast) {
|
||||
|
||||
return podcast
|
||||
}
|
||||
|
||||
export function parsePlaylist(raw: SpotifyPlaylist) {
|
||||
const playlist: Playlist = {
|
||||
id: raw.id,
|
||||
title: raw.name,
|
||||
url: raw.externalUrl,
|
||||
trackCount: raw.totalTracks
|
||||
}
|
||||
|
||||
return playlist
|
||||
}
|
||||
|
||||
@@ -16,11 +16,13 @@ import {
|
||||
Contributor,
|
||||
RawAlbum,
|
||||
RawArtist,
|
||||
RawPlaylist,
|
||||
RawTrack,
|
||||
addCredits,
|
||||
parseAlbum,
|
||||
parseArtist,
|
||||
parseMpd,
|
||||
parsePlaylist,
|
||||
parseTrack
|
||||
} from './parse.js'
|
||||
import Stream, { Readable } from 'stream'
|
||||
@@ -327,6 +329,20 @@ export default class Tidal implements Streamer {
|
||||
)
|
||||
return contributorResponse.items.map((item) => parseTrack(addCredits(item.item, item.credits)))
|
||||
}
|
||||
async #getPlaylist(playlistId: number | string) {
|
||||
const playlistResponse = await this.#get(`playlists/${playlistId}`)
|
||||
return parsePlaylist(<RawPlaylist>playlistResponse)
|
||||
}
|
||||
async #getPlaylistTracks(playlistId: number | string) {
|
||||
const tracksResponse = <{ items: RawTrack[] }>await this.#get(
|
||||
`playlists/${playlistId}/tracks`,
|
||||
{
|
||||
offset: 0,
|
||||
limit: 10000
|
||||
}
|
||||
)
|
||||
return tracksResponse.items.map((item) => parseTrack(item))
|
||||
}
|
||||
async #getArtist(artistId: number | string) {
|
||||
const [artistResponse, albumsResponse, tracksResponse] = await Promise.all([
|
||||
<Promise<RawArtist>>this.#get(`artists/${artistId}`),
|
||||
@@ -479,13 +495,18 @@ export default class Tidal implements Streamer {
|
||||
return ['-hide_banner', '-loglevel', 'error', '-i', '-', '-c:a', 'copy', '-f', 'flac', '-']
|
||||
}
|
||||
}
|
||||
#getUrlParts(url: string): ['artist' | 'album' | 'track', string] {
|
||||
#getUrlParts(url: string): ['artist' | 'album' | 'track' | 'playlist', string] {
|
||||
const urlParts = url
|
||||
.match(/^https?:\/\/(?:www\.|listen\.)?tidal\.com\/(?:browse\/)?(.*?)\/(.*?)\/?$/)
|
||||
?.slice(1, 3)
|
||||
if (!urlParts) throw new Error('URL not supported')
|
||||
urlParts[1] = urlParts[1].replace(/\?.*?$/, '')
|
||||
if (urlParts[0] != 'artist' && urlParts[0] != 'album' && urlParts[0] != 'track') {
|
||||
if (
|
||||
urlParts[0] != 'artist' &&
|
||||
urlParts[0] != 'album' &&
|
||||
urlParts[0] != 'track' &&
|
||||
urlParts[0] != 'playlist'
|
||||
) {
|
||||
throw new Error('URL unrecognised')
|
||||
}
|
||||
return [urlParts[0], urlParts[1]]
|
||||
@@ -517,6 +538,12 @@ export default class Tidal implements Streamer {
|
||||
type,
|
||||
metadata: await this.#getArtist(id)
|
||||
}
|
||||
case 'playlist':
|
||||
return {
|
||||
type,
|
||||
tracks: await this.#getPlaylistTracks(id),
|
||||
metadata: await this.#getPlaylist(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
async getAccountInfo(): Promise<StreamerAccount> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Album, Artist, Track } from '../../types.js'
|
||||
import { Album, Artist, Playlist, Track } from '../../types.js'
|
||||
import { DOMParser } from 'xmldom-qsa'
|
||||
|
||||
export interface RawArtist {
|
||||
@@ -78,6 +78,49 @@ export function parseAlbum(raw: RawAlbum): Album {
|
||||
return album
|
||||
}
|
||||
|
||||
export interface RawPlaylist {
|
||||
uuid: string
|
||||
title: string
|
||||
url: string
|
||||
numberOfTracks: number
|
||||
tracks: Track[]
|
||||
cover: string
|
||||
}
|
||||
|
||||
export function parsePlaylist(raw: RawPlaylist): Playlist {
|
||||
let coverPath
|
||||
|
||||
if (raw.cover) coverPath = raw.cover.replace(/-/gm, '/')
|
||||
else coverPath = null
|
||||
|
||||
const playlist: Playlist = {
|
||||
id: raw.uuid,
|
||||
title: raw.title,
|
||||
url: raw.url,
|
||||
trackCount: raw.numberOfTracks
|
||||
}
|
||||
|
||||
playlist.coverArtwork = [
|
||||
{
|
||||
url: `https://resources.tidal.com/images/${coverPath}/160x160.jpg`,
|
||||
width: 160,
|
||||
height: 160
|
||||
},
|
||||
{
|
||||
url: `https://resources.tidal.com/images/${coverPath}/320x320.jpg`,
|
||||
width: 320,
|
||||
height: 320
|
||||
},
|
||||
{
|
||||
url: `https://resources.tidal.com/images/${coverPath}/1280x1280.jpg`,
|
||||
width: 1280,
|
||||
height: 1280
|
||||
}
|
||||
]
|
||||
|
||||
return playlist
|
||||
}
|
||||
|
||||
export interface RawTrack {
|
||||
url: string
|
||||
id: number
|
||||
|
||||
16
src/types.ts
16
src/types.ts
@@ -1,6 +1,6 @@
|
||||
type Id = string | number
|
||||
|
||||
export type ItemType = 'artist' | 'album' | 'track' | 'episode' | 'podcast'
|
||||
export type ItemType = 'artist' | 'album' | 'track' | 'episode' | 'podcast' | 'playlist'
|
||||
export type Region = string
|
||||
|
||||
export interface CoverArtwork {
|
||||
@@ -30,6 +30,14 @@ export interface Artist {
|
||||
tracks?: Track[]
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
id: Id
|
||||
title: string
|
||||
url: string
|
||||
coverArtwork?: CoverArtwork[]
|
||||
trackCount?: number
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
title: string
|
||||
id: Id
|
||||
@@ -118,6 +126,7 @@ export type GetByUrlResponse =
|
||||
| AlbumGetByUrlResponse
|
||||
| EpisodeGetByUrlResponse
|
||||
| PodcastGetByUrlResponse
|
||||
| PlaylistGetByUrlResponse
|
||||
|
||||
export interface TrackGetByUrlResponse {
|
||||
type: 'track'
|
||||
@@ -138,6 +147,11 @@ export interface AlbumGetByUrlResponse {
|
||||
tracks: Track[]
|
||||
metadata: Album
|
||||
}
|
||||
export interface PlaylistGetByUrlResponse {
|
||||
type: 'playlist'
|
||||
tracks: Track[]
|
||||
metadata: Playlist
|
||||
}
|
||||
export interface PodcastGetByUrlResponse {
|
||||
type: 'podcast'
|
||||
episodes: Episode[]
|
||||
|
||||
Reference in New Issue
Block a user