import { createHash } from 'node:crypto' import { mkdir, open, rm, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { Readable } from 'node:stream' import { TransformStream } from 'node:stream/web' import { Bytes, read, write } from '@fuman/io' import { asNonNull, assert, asyncPool, base64, hex, iter, unknownToError, utf8 } from '@fuman/utils' import { Blowfish } from 'egoroof-blowfish' import Spinnies from 'spinnies' import { CookieJar } from 'tough-cookie' import { FileCookieStore } from 'tough-cookie-file-store' import { z } from 'zod' import { $, question } from 'zx' import { ffetch as ffetchBase } from '../../utils/fetch.ts' import { sanitizeFilename, writeWebStreamToFile } from '../../utils/fs.ts' import { generateFfmpegMetadataFlags, pipeIntoProc, runMetaflac } from '../../utils/media-metadata.ts' import { getEnv } from '../../utils/misc.ts' const jar = new CookieJar(new FileCookieStore('./assets/deezer-cookies.json')) await jar.setCookie(`arl=${getEnv('DEEZER_ARL')}; path=/; domain=.deezer.com;`, 'https://www.deezer.com') await jar.setCookie('comeback=1; path=/; domain=.deezer.com;', 'https://www.deezer.com') const ffetch = ffetchBase.extend({ cookies: jar, headers: { 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:137.0) Gecko/20100101 Firefox/137.0', 'accept': '*/*', 'accept-language': 'en-US,en;q=0.5', 'accept-encoding': 'gzip, deflate, br', 'referer': 'https://www.deezer.com/', 'origin': 'https://www.deezer.com', 'sec-fetch-dest': 'empty', 'sec-fetch-mode': 'cors', 'sec-fetch-site': 'same-origin', }, }) const GwLightEnvelope = z.object({ error: z.union([ z.array(z.unknown()), z.record(z.unknown()), ]), results: z.unknown(), }) async function gwLightApi(params: { method: string token?: string options?: unknown result: T }): Promise> { const { method, token, options, result } = params const res = await ffetch.post('https://www.deezer.com/ajax/gw-light.php', { query: { method, input: '3', api_version: '1.0', api_token: token ?? '', cid: Math.floor(1000000000 * Math.random()), }, json: options ?? {}, }).parsedJson(GwLightEnvelope) if (res.error.length || (typeof res.error === 'object' && !Array.isArray(res.error))) { throw new Error(JSON.stringify(res.error)) } return result.parse(res.results) } let _cachedToken: [string, number] | undefined async function getGraphqlToken() { if (_cachedToken && Date.now() < _cachedToken[1]) return _cachedToken[0] const hasRefreshToken = (await jar.getCookieString('https://auth.deezer.com')).includes('refresh-token=') const { jwt } = await ffetch.post(hasRefreshToken ? 'https://auth.deezer.com/login/renew' : 'https://auth.deezer.com/login/arl', { // i have no idea what these mean query: { jo: 'p', rto: 'c', i: 'c', }, }).parsedJson(z.object({ jwt: z.string(), })) const expires = JSON.parse(utf8.decoder.decode(base64.decode(jwt.split('.')[1]))).exp * 1000 _cachedToken = [jwt, expires] return jwt } async function graphqlApi(params: { query: string variables?: unknown result: T }): Promise> { const { query, variables, result } = params const operationName = query.match(/(?:query|mutation)\s+(\w+)/)?.[1] const res = await ffetchBase.post('https://pipe.deezer.com/api', { json: { operationName, query, variables, }, headers: { Authorization: `Bearer ${await getGraphqlToken()}`, }, }).json() if (!res.data) { throw new Error(JSON.stringify(res)) } return result.parse(res.data) } const GwTrack = z.object({ ALB_ID: z.string(), ALB_PICTURE: z.string(), ALB_TITLE: z.string(), ARTISTS: z.array( z.object({ ART_ID: z.string(), ROLE_ID: z.string(), ARTISTS_SONGS_ORDER: z.string(), ART_NAME: z.string(), ARTIST_IS_DUMMY: z.boolean().optional(), ART_PICTURE: z.string(), RANK: z.string(), }), ), ART_ID: z.string(), ART_NAME: z.string(), ARTIST_IS_DUMMY: z.boolean().optional(), DIGITAL_RELEASE_DATE: z.string().optional(), DISK_NUMBER: z.string(), DURATION: z.string(), EXPLICIT_LYRICS: z.string(), EXPLICIT_TRACK_CONTENT: z.object({ EXPLICIT_LYRICS_STATUS: z.number(), EXPLICIT_COVER_STATUS: z.number(), }), ISRC: z.string().optional(), LYRICS_ID: z.number(), PROVIDER_ID: z.string(), SNG_CONTRIBUTORS: z.object({ main_artist: z.array(z.string()), author: z.array(z.string()), composer: z.array(z.string()), featuring: z.array(z.string()), }).partial().optional(), SNG_ID: z.string(), SNG_TITLE: z.string(), TRACK_NUMBER: z.string(), VERSION: z.string().optional(), MD5_ORIGIN: z.string(), FILESIZE_AAC_64: z.coerce.number(), FILESIZE_MP3_64: z.coerce.number(), FILESIZE_MP3_128: z.coerce.number(), FILESIZE_MP3_256: z.coerce.number(), FILESIZE_MP3_320: z.coerce.number(), FILESIZE_MP4_RA1: z.coerce.number(), FILESIZE_MP4_RA2: z.coerce.number(), FILESIZE_MP4_RA3: z.coerce.number(), FILESIZE_FLAC: z.coerce.number(), FILESIZE: z.coerce.number(), MEDIA_VERSION: z.string(), TRACK_TOKEN: z.string(), TRACK_TOKEN_EXPIRE: z.number(), RIGHTS: z.object({ STREAM_ADS_AVAILABLE: z.boolean(), STREAM_ADS: z.string(), STREAM_SUB_AVAILABLE: z.boolean(), STREAM_SUB: z.string(), }), }) type GwTrack = z.infer const GwAlbum = z.object({ ALB_ID: z.string(), ALB_TITLE: z.string(), ARTISTS: z.array(z.object({ ART_ID: z.string(), ART_NAME: z.string(), })), TYPE: z.string().optional(), // "0" = single, "1" = normal album, "3" = ep ROLE_ID: z.number().optional(), // 0 = own album, 5 = "featured on" album COPYRIGHT: z.string(), PRODUCER_LINE: z.string(), DIGITAL_RELEASE_DATE: z.string(), SONGS: z.object({ total: z.number(), }).optional(), }) type GwAlbum = z.infer const userData = await gwLightApi({ method: 'deezer.getUserData', result: z.object({ USER: z.object({ OPTIONS: z.object({ license_token: z.string(), }), BLOG_NAME: z.string(), }), checkForm: z.string(), }), }) const GetUrlResult = z.object({ data: z.array( z.object({ media: z.array( z.object({ media_type: z.string(), cipher: z.object({ type: z.string() }), format: z.string(), sources: z.array(z.object({ url: z.string(), provider: z.string() })), nbf: z.number(), exp: z.number(), }), ), }), ), }) const GetLyricsResult = z.object({ track: z.object({ lyrics: z.object({ text: z.string(), synchronizedLines: z.array(z.object({ lrcTimestamp: z.string(), line: z.string(), duration: z.number(), })).nullable(), }).nullable(), }), }) const BLOWFISH_SALT_1 = [97, 57, 118, 48, 119, 53, 101, 103] const BLOWFISH_SALT_2 = [49, 110, 102, 122, 99, 56, 108, 52] const BLOWFISH_CHUNK_SIZE = 61440 const BLOWFISH_BLOCK_SIZE = 2048 class BlowfishDecryptTransform implements Transformer { cipher: Blowfish constructor(readonly trackId: string) { const trackIdMd5 = createHash('md5').update(trackId).digest('hex') const bfKey = new Uint8Array(16) for (let i = 0; i < 16; i++) { bfKey[i] = trackIdMd5[i].charCodeAt(0) ^ trackIdMd5[i + 16].charCodeAt(0) ^ (i % 2 ? BLOWFISH_SALT_2 : BLOWFISH_SALT_1)[ 7 - Math.floor(i / 2) ] } this.cipher = new Blowfish(bfKey, Blowfish.MODE.CBC, Blowfish.PADDING.NULL) this.cipher.setIv(hex.decode('0001020304050607')) } #processChunk(chunk: Uint8Array) { const res = new Uint8Array(chunk.length) const size = chunk.length for ( let pos = 0, blockIdx = 0; pos < size && pos + BLOWFISH_BLOCK_SIZE <= size; pos += BLOWFISH_BLOCK_SIZE, blockIdx++ ) { const block = chunk.subarray(pos, pos + BLOWFISH_BLOCK_SIZE) if (blockIdx % 3 === 0) { res.set(this.cipher.decode(block, Blowfish.TYPE.UINT8_ARRAY), pos) } else { res.set(block, pos) } } return res } #buffer = Bytes.alloc() transform(chunk: Uint8Array, ctx: TransformStreamDefaultController) { write.bytes(this.#buffer, chunk) if (this.#buffer.available > BLOWFISH_CHUNK_SIZE) { const chunk = read.exactly(this.#buffer, BLOWFISH_CHUNK_SIZE) ctx.enqueue(this.#processChunk(chunk)) this.#buffer.reclaim() } return Promise.resolve(undefined) } flush(ctx: TransformStreamDefaultController) { const remaining = this.#buffer.result() if (remaining.length) { ctx.enqueue(remaining) } } } function getTrackArtistString(track: GwTrack) { if (track.ARTISTS) return track.ARTISTS.map(it => it.ART_NAME).join(', ').slice(0, 100) return track.ART_NAME } function getTrackName(track: GwTrack) { let name = track.SNG_TITLE if (track.VERSION) { name += ` ${track.VERSION}` } return name } // todo // async function resolveMusicbrainzIds(albumId: number) { // const deezerUrl = `https://www.deezer.com/album/${albumId}` // // try odesli api to fetch extra links // const odesliRes = await ffetch('https://api.song.link/v1-alpha.1/links', { // query: { // url: deezerUrl, // key: '71d7be8a-3a76-459b-b21e-8f0350374984', // }, // }).parsedJson(z.object({ // linksByPlatform: z.record(z.string(), z.object({ // url: z.string(), // })), // })).catch(() => null) // const urls = [deezerUrl] // if (odesliRes) { // for (const { url } of Object.values(odesliRes.linksByPlatform)) { // urls.push(url) // } // } // // try to resolve musicbrainz album id // const mbRes1 = await ffetch('https://musicbrainz.org/ws/2/url', { // query: { // resource: urls, // inc: 'release-rels', // }, // }).parsedJson(z.object({ // urls: z.array(z.object({ // relations: z.array(z.any()), // })), // })) // const uniqueMbIds = new Set() // for (const { relations } of mbRes1.urls) { // for (const rel of relations) { // if (rel['target-type'] !== 'release') continue // uniqueMbIds.add(rel.release.id) // } // } // if (uniqueMbIds.size === 0) return null // const releaseMbId = uniqueMbIds.values().next().value // // resolve the rest of the ids from the release // const releaseRes = await ffetch(`https://musicbrainz.org/ws/2/release/${releaseMbId}`, { // query: { // inc: 'artists recordings', // }, // }).parsedJson(z.object({ // 'artist-credit': z.array(z.object({ // artist: z.object({ // id: z.string(), // }), // })).optional(), // 'media': z.array(z.object({ // id: z.string(), // tracks: z.array(z.object({ // position: z.number(), // title: z.string(), // id: z.string(), // recording: z.object({ // id: z.string(), // }), // })), // })).optional(), // })) // return { // release: releaseMbId, // artists: releaseRes['artist-credit']?.map(it => it.artist.id) ?? [], // tracks: releaseRes['media']?.[0] // } // } async function downloadTrack(track: GwTrack, opts: { destination: string album?: GwAlbum }) { const albumUrl = `https://cdn-images.dzcdn.net/images/cover/${track.ALB_PICTURE}/1500x1500-000000-80-0-0.jpg` const [ getUrlRes, albumAb, lyricsRes, ] = await Promise.all([ ffetch.post('https://media.deezer.com/v1/get_url', { json: { license_token: userData.USER.OPTIONS.license_token, media: [{ type: 'FULL', formats: [ { cipher: 'BF_CBC_STRIPE', format: 'FLAC' }, { cipher: 'BF_CBC_STRIPE', format: 'MP3_320' }, { cipher: 'BF_CBC_STRIPE', format: 'MP3_128' }, { cipher: 'BF_CBC_STRIPE', format: 'MP3_64' }, { cipher: 'BF_CBC_STRIPE', format: 'MP3_MISC' }, ], }], track_tokens: [track.TRACK_TOKEN], }, }).parsedJson(GetUrlResult), ffetch.get(albumUrl).arrayBuffer(), graphqlApi({ query: 'query GetLyrics($trackId: String!) {\n track(trackId: $trackId) {\n id\n lyrics {\n id\n text\n ...SynchronizedWordByWordLines\n ...SynchronizedLines\n copyright\n writers\n __typename\n }\n __typename\n }\n}\n\nfragment SynchronizedWordByWordLines on Lyrics {\n id\n synchronizedWordByWordLines {\n start\n end\n words {\n start\n end\n word\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment SynchronizedLines on Lyrics {\n id\n synchronizedLines {\n lrcTimestamp\n line\n lineTranslated\n milliseconds\n duration\n __typename\n }\n __typename\n}', variables: { trackId: track.SNG_ID, }, result: GetLyricsResult, }), ]) // console.dir(getUrlRes, { depth: null }) const albumCoverPath = join(`assets/deezer-tmp-${track.SNG_ID}.jpg`) await writeFile(albumCoverPath, new Uint8Array(albumAb)) const media = getUrlRes.data[0].media[0] const stream = await ffetch.get(media.sources[0].url).stream() const decStream = stream.pipeThrough( new TransformStream( new BlowfishDecryptTransform(track.SNG_ID), ) as any, ) const ext = media.format === 'FLAC' ? 'flac' : 'mp3' const filename = `${opts.destination}.${ext}` await mkdir(dirname(filename), { recursive: true }) let lyricsLrc: string | undefined if (lyricsRes.track.lyrics) { if (lyricsRes.track.lyrics.synchronizedLines) { lyricsLrc = lyricsRes.track.lyrics.synchronizedLines.map(it => `${it.lrcTimestamp}${it.line}`).join('\n') } else { lyricsLrc = lyricsRes.track.lyrics.text } } if (ext === 'mp3') { const params: string[] = [ '-y', '-i', 'pipe:0', '-i', albumCoverPath, '-map', '1:v:0', '-id3v2_version', '3', '-metadata:s:v', 'title=Album cover', '-metadata:s:v', 'comment=Cover (front)', '-map', '0:a', '-c', 'copy', ...generateFfmpegMetadataFlags({ title: getTrackName(track), album: opts.album?.ALB_TITLE ?? track.ALB_TITLE, year: track.DIGITAL_RELEASE_DATE, comment: `ripped from deezer (id: ${track.SNG_ID})`, track: track.TRACK_NUMBER, disc: track.DISK_NUMBER, composer: track.SNG_CONTRIBUTORS?.composer, artist: track.ARTISTS?.map(it => it.ART_NAME) ?? track.ART_NAME, }), filename, ] if (lyricsLrc) { await writeFile(`${opts.destination}.lrc`, lyricsLrc) } const proc = $`ffmpeg ${params}` await pipeIntoProc(proc, decStream) await proc } else { await writeWebStreamToFile(decStream, filename) await runMetaflac({ path: filename, tags: { TITLE: getTrackName(track), ALBUM: track.ALB_TITLE, DATE: track.DIGITAL_RELEASE_DATE ?? asNonNull(opts.album?.DIGITAL_RELEASE_DATE), DISCNUMBER: track.DISK_NUMBER, TRACKNUMBER: track.TRACK_NUMBER, COMMENT: `ripped from deezer (id: ${track.SNG_ID})`, ARTIST: track.ARTISTS?.map(it => it.ART_NAME) ?? track.ART_NAME, COMPOSER: track.SNG_CONTRIBUTORS?.composer, MAIN_ARTIST: track.SNG_CONTRIBUTORS?.main_artist, ISRC: track.ISRC, PRODUCER: opts.album?.PRODUCER_LINE, COPYRIGHT: opts.album?.COPYRIGHT, LYRICS: lyricsLrc, }, coverPath: albumCoverPath, }) } await rm(albumCoverPath, { force: true }) } async function downloadTrackList(tracks: GwTrack[], opts: { album?: GwAlbum poolLimit?: number destination: string includeTrackNumber?: boolean onDownloadStart?: (track: GwTrack) => void onDownloadEnd?: (track: GwTrack, error: Error | null) => void }) { await mkdir(opts.destination, { recursive: true }) const isMultiDisc = tracks.some(it => it.DISK_NUMBER !== '1') const firstTrackArtistString = getTrackArtistString(tracks[0]) const isDifferentArtists = tracks.some(it => getTrackArtistString(it) !== firstTrackArtistString) await asyncPool(tracks, async (track) => { let filename = '' if (opts.includeTrackNumber) { if (isMultiDisc) { filename = `${track.DISK_NUMBER}-` } filename = `${track.TRACK_NUMBER.padStart(2, '0')}. ` } if (isDifferentArtists) { filename += `${getTrackArtistString(track)} - ` } filename += `${getTrackName(track)}` const filenamePath = join(opts.destination, sanitizeFilename(filename)) opts.onDownloadStart?.(track) try { await downloadTrack(track, { destination: filenamePath, album: opts.album, }) opts.onDownloadEnd?.(track, null) } catch (e) { opts.onDownloadEnd?.(track, unknownToError(e)) } }, { limit: opts.poolLimit }) } const GwPageArtist = z.object({ DATA: z.object({ ART_NAME: z.string(), }), ALBUMS: z.object({ data: z.array(GwAlbum), total: z.number(), }), }) async function downloadArtist(options: { artistId: string includeFeaturedAlbums?: boolean }) { const { artistId, includeFeaturedAlbums = false } = options const artistInfo = await gwLightApi({ method: 'deezer.pageArtist', token: userData.checkForm, options: { art_id: artistId, lang: 'us', }, result: z.object({ DATA: z.object({ ART_NAME: z.string(), }), ALBUMS: z.object({ data: z.array(GwAlbum), total: z.number(), }), }), }) const albums: GwAlbum[] = artistInfo.ALBUMS.data let trackCount = 0 const spinnies = new Spinnies() if (artistInfo.ALBUMS.total > albums.length) { // fetch the rest spinnies.add('collect', { text: 'collecting albums...' }) let offset = albums.length while (true) { const res = await gwLightApi({ method: 'album.getDiscography', token: userData.checkForm, options: { art_id: artistId, nb: 25, nb_songs: 0, start: offset, }, result: z.object({ data: z.array(GwAlbum), total: z.number(), }), }) for (const alb of res.data) { if (!includeFeaturedAlbums && alb.ROLE_ID === 5) continue albums.push(alb) trackCount += asNonNull(alb.SONGS).total } if (res.total <= offset) break offset += 25 } spinnies.succeed('collect', { text: `collected ${albums.length} albums with a total of ${trackCount} tracks` }) } // fixme: singles should be saved in artist root dir // todo: automatic musicbrainz matching // todo: automatic genius/musixmatch matching for lyrics if unavailable directly from deezer await asyncPool(albums, async (alb) => { const tracks = await gwLightApi({ method: 'song.getListByAlbum', token: userData.checkForm, options: { alb_id: alb.ALB_ID, nb: -1, }, result: z.object({ data: z.array(GwTrack), total: z.number(), }), }) assert(tracks.total === asNonNull(alb.SONGS).total) assert(tracks.data.length === asNonNull(alb.SONGS).total) let folderName = alb.ALB_TITLE if (alb.ROLE_ID === 5) { folderName = `${artistInfo.DATA.ART_NAME} - ${folderName}` } await downloadTrackList(tracks.data, { destination: join( 'assets/deezer-dl', sanitizeFilename(artistInfo.DATA.ART_NAME), sanitizeFilename(folderName), ), album: alb, poolLimit: 4, includeTrackNumber: true, onDownloadStart(track) { spinnies.add(`${track.SNG_ID}`, { text: track.SNG_TITLE }) }, onDownloadEnd(track, error) { if (error) { spinnies.fail(`${track.SNG_ID}`, { text: error.message }) } else { spinnies.remove(`${track.SNG_ID}`) } }, }) }, { limit: 4 }) } async function downloadByUri(uri: string) { const [type, id] = uri.split(':') if (type === 'track') { const res = await gwLightApi({ method: 'song.getListData', token: userData.checkForm, options: { sng_ids: [id], }, result: z.object({ data: z.array(GwTrack), }), }) const track = res.data[0] const filename = `${track.ART_NAME} - ${track.SNG_TITLE}` console.log('downloading track:', filename) await downloadTrack(track, { destination: join('assets/deezer-dl', sanitizeFilename(filename)), }) } if (type === 'album') { const album = await gwLightApi({ method: 'deezer.pageAlbum', token: userData.checkForm, options: { alb_id: id, lang: 'us', }, result: z.object({ DATA: GwAlbum, SONGS: z.object({ data: z.array(GwTrack), total: z.number(), }), }), }) const tracks = album.SONGS.data if (tracks.length < album.SONGS.total) { // fetch the rest const res = await gwLightApi({ method: 'song.getListByAlbum', token: userData.checkForm, options: { alb_id: id, nb: -1, start: tracks.length, }, result: z.object({ data: z.array(GwTrack), total: z.number(), }), }) tracks.push(...res.data) assert(tracks.length === album.SONGS.total) } const spinnies = new Spinnies() spinnies.add('download', { text: 'downloading album...' }) await downloadTrackList(tracks, { destination: join( 'assets/deezer-dl', sanitizeFilename( `${album.DATA.ARTISTS.map(it => it.ART_NAME).join(', ').slice(0, 100)} - ${album.DATA.ALB_TITLE}`, ), ), includeTrackNumber: true, poolLimit: 8, album: album.DATA, onDownloadStart(track) { spinnies.add(`${track.SNG_ID}`, { text: track.SNG_TITLE }) }, onDownloadEnd(track, error) { if (error) { spinnies.fail(`${track.SNG_ID}`, { text: error.stack }) } else { spinnies.remove(`${track.SNG_ID}`) } }, }) spinnies.succeed('download', { text: 'downloaded album' }) } if (type === 'artist') { const includeFeaturedAlbums = await question('include featured albums? (y/N) > ') await downloadArtist({ artistId: id, includeFeaturedAlbums: includeFeaturedAlbums.toLowerCase() === 'y', }) } } console.log('logged in as %s', userData.USER.BLOG_NAME) const url = process.argv[2] ?? await question('url or search > ') if (url.match(/^(artist|album|track):(\d+)$/)) { await downloadByUri(url) } else if (url.startsWith('https://www.deezer.com/')) { // todo } else { // search query const searchResult = await graphqlApi({ query: 'query SearchFull($query: String!, $firstGrid: Int!, $firstList: Int!) {\n instantSearch(query: $query) {\n bestResult {\n __typename\n ... on InstantSearchAlbumBestResult {\n album {\n ...SearchAlbum\n __typename\n }\n __typename\n }\n ... on InstantSearchArtistBestResult {\n artist {\n ...BestResultArtist\n __typename\n }\n __typename\n }\n ... on InstantSearchPlaylistBestResult {\n playlist {\n ...SearchPlaylist\n __typename\n }\n __typename\n }\n ... on InstantSearchPodcastBestResult {\n podcast {\n ...SearchPodcast\n __typename\n }\n __typename\n }\n ... on InstantSearchLivestreamBestResult {\n livestream {\n ...SearchLivestream\n __typename\n }\n __typename\n }\n ... on InstantSearchTrackBestResult {\n track {\n ...TableTrack\n __typename\n }\n __typename\n }\n ... on InstantSearchPodcastEpisodeBestResult {\n podcastEpisode {\n ...SearchPodcastEpisode\n __typename\n }\n __typename\n }\n }\n results {\n artists(first: $firstGrid) {\n edges {\n node {\n ...SearchArtist\n __typename\n }\n __typename\n }\n pageInfo {\n endCursor\n __typename\n }\n priority\n __typename\n }\n albums(first: $firstGrid) {\n edges {\n node {\n ...SearchAlbum\n __typename\n }\n __typename\n }\n pageInfo {\n endCursor\n __typename\n }\n priority\n __typename\n }\n channels(first: $firstGrid) {\n edges {\n node {\n ...SearchChannel\n __typename\n }\n __typename\n }\n pageInfo {\n endCursor\n __typename\n }\n priority\n __typename\n }\n flowConfigs(first: $firstGrid) {\n edges {\n node {\n ...SearchFlowConfig\n __typename\n }\n __typename\n }\n pageInfo {\n endCursor\n __typename\n }\n priority\n __typename\n }\n livestreams(first: $firstGrid) {\n edges {\n node {\n ...SearchLivestream\n __typename\n }\n __typename\n }\n pageInfo {\n endCursor\n __typename\n }\n priority\n __typename\n }\n playlists(first: $firstGrid) {\n edges {\n node {\n ...SearchPlaylist\n __typename\n }\n __typename\n }\n pageInfo {\n endCursor\n __typename\n }\n priority\n __typename\n }\n podcasts(first: $firstGrid) {\n edges {\n node {\n ...SearchPodcast\n __typename\n }\n __typename\n }\n pageInfo {\n endCursor\n __typename\n }\n priority\n __typename\n }\n tracks(first: $firstList) {\n edges {\n node {\n ...TableTrack\n __typename\n }\n __typename\n }\n pageInfo {\n endCursor\n __typename\n }\n priority\n __typename\n }\n users(first: $firstGrid) {\n edges {\n node {\n ...SearchUser\n __typename\n }\n __typename\n }\n pageInfo {\n endCursor\n __typename\n }\n priority\n __typename\n }\n podcastEpisodes(first: $firstList) {\n edges {\n node {\n ...SearchPodcastEpisode\n __typename\n }\n __typename\n }\n pageInfo {\n endCursor\n __typename\n }\n priority\n __typename\n }\n __typename\n }\n __typename\n }\n}\n\nfragment SearchAlbum on Album {\n id\n displayTitle\n isFavorite\n releaseDateAlbum: releaseDate\n isExplicitAlbum: isExplicit\n cover {\n ...PictureLarge\n __typename\n }\n contributors {\n edges {\n roles\n node {\n ... on Artist {\n id\n name\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n tracksCount\n __typename\n}\n\nfragment PictureLarge on Picture {\n id\n large: urls(pictureRequest: {width: 500, height: 500})\n explicitStatus\n __typename\n}\n\nfragment BestResultArtist on Artist {\n ...SearchArtist\n hasSmartRadio\n hasTopTracks\n __typename\n}\n\nfragment SearchArtist on Artist {\n id\n isFavorite\n name\n fansCount\n picture {\n ...PictureLarge\n __typename\n }\n __typename\n}\n\nfragment SearchPlaylist on Playlist {\n id\n title\n isFavorite\n estimatedTracksCount\n fansCount\n picture {\n ...PictureLarge\n __typename\n }\n owner {\n id\n name\n __typename\n }\n __typename\n}\n\nfragment SearchPodcast on Podcast {\n id\n displayTitle\n isPodcastFavorite: isFavorite\n cover {\n ...PictureLarge\n __typename\n }\n isExplicit\n rawEpisodes\n __typename\n}\n\nfragment SearchLivestream on Livestream {\n id\n name\n cover {\n ...PictureLarge\n __typename\n }\n __typename\n}\n\nfragment TableTrack on Track {\n id\n title\n duration\n popularity\n isExplicit\n lyrics {\n id\n __typename\n }\n media {\n id\n rights {\n ads {\n available\n availableAfter\n __typename\n }\n sub {\n available\n availableAfter\n __typename\n }\n __typename\n }\n __typename\n }\n album {\n id\n displayTitle\n cover {\n ...PictureXSmall\n ...PictureLarge\n __typename\n }\n __typename\n }\n contributors {\n edges {\n node {\n ... on Artist {\n id\n name\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment PictureXSmall on Picture {\n id\n xxx_small: urls(pictureRequest: {width: 40, height: 40})\n explicitStatus\n __typename\n}\n\nfragment SearchPodcastEpisode on PodcastEpisode {\n id\n title\n description\n duration\n releaseDate\n media {\n url\n __typename\n }\n podcast {\n id\n displayTitle\n isExplicit\n cover {\n ...PictureSmall\n ...PictureLarge\n __typename\n }\n rights {\n ads {\n available\n __typename\n }\n sub {\n available\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment PictureSmall on Picture {\n id\n small: urls(pictureRequest: {height: 100, width: 100})\n explicitStatus\n __typename\n}\n\nfragment SearchChannel on Channel {\n id\n picture {\n ...PictureLarge\n __typename\n }\n logoAsset {\n id\n large: urls(uiAssetRequest: {width: 500, height: 0})\n __typename\n }\n name\n slug\n backgroundColor\n __typename\n}\n\nfragment SearchFlowConfig on FlowConfig {\n id\n title\n visuals {\n dynamicPageIcon {\n id\n large: urls(uiAssetRequest: {width: 500, height: 500})\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment SearchUser on User {\n id\n name\n picture {\n ...PictureLarge\n __typename\n }\n __typename\n}', variables: { query: url, firstGrid: 10, firstList: 10, }, result: z.object({ instantSearch: z.object({ results: z.object({ artists: z.object({ edges: z.array(z.object({ node: z.object({ id: z.string(), name: z.string(), }), })), }), albums: z.object({ edges: z.array(z.object({ node: z.object({ id: z.string(), displayTitle: z.string(), }), })), }), tracks: z.object({ edges: z.array(z.object({ node: z.object({ id: z.string(), title: z.string(), contributors: z.object({ edges: z.array(z.object({ node: z.object({ id: z.string(), name: z.string(), }), })), }), }), })), }), }), }), }), }) for (const [i, { node }] of iter.enumerate(searchResult.instantSearch.results.artists.edges)) { console.log(`artist:${node.id}: ${node.name}`) } for (const [i, { node }] of iter.enumerate(searchResult.instantSearch.results.albums.edges)) { console.log(`album:${node.id}: ${node.displayTitle}`) } for (const [i, { node }] of iter.enumerate(searchResult.instantSearch.results.tracks.edges)) { console.log(`track:${node.id}: ${node.contributors.edges.map(it => it.node.name).join(', ')} - ${node.title}`) } const uri = await question('option > ') await downloadByUri(uri) }