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:
aria
2024-09-05 23:19:03 -04:00
10 changed files with 253 additions and 17 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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[]