From 2d46db9c7708f69ed429ddc407328e6a266839dc Mon Sep 17 00:00:00 2001 From: desu-bot Date: Sat, 10 May 2025 21:06:34 +0000 Subject: [PATCH] chore: update public repo --- package.json | 8 +- pnpm-lock.yaml | 33 ++- scripts/auth/mtcute-login.ts | 3 + scripts/media/deezer-dl.ts | 516 +++++++++++++++++++++++++++++++++++ 4 files changed, 541 insertions(+), 19 deletions(-) create mode 100644 scripts/media/deezer-dl.ts diff --git a/package.json b/package.json index 1ecf24e..3269071 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,6 @@ "name": "teidesu-scripts", "type": "module", "packageManager": "pnpm@9.5.0", - "peerDependencies": { - "typescript": "^5.0.0" - }, "dependencies": { "@faker-js/faker": "^9.3.0", "@fuman/io": "^0.0.4", @@ -17,6 +14,7 @@ "better-sqlite3": "^11.8.1", "canvas": "^3.1.0", "cheerio": "^1.0.0", + "egoroof-blowfish": "4.0.1", "es-main": "^1.3.0", "filesize": "^10.1.6", "json5": "^2.2.3", @@ -33,8 +31,8 @@ }, "devDependencies": { "@antfu/eslint-config": "3.10.0", - "@fuman/fetch": "0.0.10", - "@fuman/utils": "0.0.10", + "@fuman/fetch": "0.1.0", + "@fuman/utils": "0.0.14", "@types/node": "22.10.0", "domhandler": "^5.0.3", "dotenv": "16.4.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef97f05..871673a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: cheerio: specifier: ^1.0.0 version: 1.0.0 + egoroof-blowfish: + specifier: 4.0.1 + version: 4.0.1 es-main: specifier: ^1.3.0 version: 1.3.0 @@ -74,9 +77,6 @@ importers: tsx: specifier: ^4.19.2 version: 4.19.2 - typescript: - specifier: ^5.0.0 - version: 5.7.2 undici: specifier: ^7.2.0 version: 7.2.0 @@ -88,11 +88,11 @@ importers: specifier: 3.10.0 version: 3.10.0(@typescript-eslint/utils@8.16.0(eslint@9.15.0)(typescript@5.7.2))(@vue/compiler-sfc@3.5.13)(eslint@9.15.0)(typescript@5.7.2) '@fuman/fetch': - specifier: 0.0.10 - version: 0.0.10(@badrap/valita@0.4.2)(tough-cookie@5.0.0)(zod@3.23.8) + specifier: 0.1.0 + version: 0.1.0(@badrap/valita@0.4.2)(tough-cookie@5.0.0)(zod@3.23.8) '@fuman/utils': - specifier: 0.0.10 - version: 0.0.10 + specifier: 0.0.14 + version: 0.0.14 '@types/node': specifier: 22.10.0 version: 22.10.0 @@ -406,8 +406,8 @@ packages: resolution: {integrity: sha512-r0tJ3ZOkMd9xsu3VRfqlFR6cz0V/jFYRswAIpC+m/DIfAUXq7g8N7wTAlhSANySXYGKzGryfDXwtwsY8TxEIDw==} engines: {node: '>=18.0.0', npm: '>=9.0.0'} - '@fuman/fetch@0.0.10': - resolution: {integrity: sha512-Zy1GKZ8/NGP2adH0JXVaP91TZPpXngZDTGOFrb38pEp6RYAVSywt0yOPO2kwZGUpordfCj96CMNv+e6zUc7qqw==} + '@fuman/fetch@0.1.0': + resolution: {integrity: sha512-q3CWQJ939Kcmrp/9/dUOCRqQi2og4uweSy7QrzeIxKRsz3jYSca1q2mwTQtTnA+9AAq2WNMlcZtgOA9qVJkXJw==} peerDependencies: '@badrap/valita': '>=0.4.0' tough-cookie: ^5.0.0 || ^4.0.0 @@ -441,8 +441,8 @@ packages: '@fuman/node@0.0.4': resolution: {integrity: sha512-tgwbIceUHWuwh4RTwJRQ1sLjzuIGrWx0SeCrqYhGF+IkI/B7DY0FP2SZykWImkVDtW8IzmdZskPZqiDINRGcNg==} - '@fuman/utils@0.0.10': - resolution: {integrity: sha512-KVlDx0S1Og7IWcPi93f1T45WPfCSUV6/A4dQb36zZRtb8KECl1BK2u9WkNVI+sjrjKCb3xijjY5gq4lS3PqH5g==} + '@fuman/utils@0.0.14': + resolution: {integrity: sha512-HmQo6DXoYBtkN12rZsMSZRTUynByioOHpMZjfrePwUwXuKBYm9F1Rm4R7tGLTNJTG1zMXBJw1Xwy/cRqwzrujw==} '@fuman/utils@0.0.4': resolution: {integrity: sha512-YBZIlGDbM8s9G85pWFZJ9wQrDY4511XLHZ06/uxZfXBY0eSStwje8JFNmRMNF0SjRk4D3iRmPl9wirHKTkg89w==} @@ -906,6 +906,9 @@ packages: doublearray@0.0.2: resolution: {integrity: sha512-aw55FtZzT6AmiamEj2kvmR6BuFqvYgKZUkfQ7teqVRNqD5UE0rw8IeW/3gieHNKQ5sPuDKlljWEn4bzv5+1bHw==} + egoroof-blowfish@4.0.1: + resolution: {integrity: sha512-e2gdfyLZrDvyuHX2NkRPFxEuYCIddp+W3MucqjBw9h9nineZUWynuuqQh+R5sNT9IVopPFBHwcnBYHqTcB1Vdw==} + electron-to-chromium@1.5.65: resolution: {integrity: sha512-PWVzBjghx7/wop6n22vS2MLU8tKGd4Q91aCEGhG/TYmW6PP5OcSXcdnxTe1NNt0T66N8D6jxh4kC8UsdzOGaIw==} @@ -2357,9 +2360,9 @@ snapshots: '@faker-js/faker@9.3.0': {} - '@fuman/fetch@0.0.10(@badrap/valita@0.4.2)(tough-cookie@5.0.0)(zod@3.23.8)': + '@fuman/fetch@0.1.0(@badrap/valita@0.4.2)(tough-cookie@5.0.0)(zod@3.23.8)': dependencies: - '@fuman/utils': 0.0.10 + '@fuman/utils': 0.0.14 optionalDependencies: '@badrap/valita': 0.4.2 tough-cookie: 5.0.0 @@ -2389,7 +2392,7 @@ snapshots: '@fuman/net': 0.0.4 '@fuman/utils': 0.0.4 - '@fuman/utils@0.0.10': {} + '@fuman/utils@0.0.14': {} '@fuman/utils@0.0.4': {} @@ -2920,6 +2923,8 @@ snapshots: doublearray@0.0.2: {} + egoroof-blowfish@4.0.1: {} + electron-to-chromium@1.5.65: {} emoji-regex@8.0.0: {} diff --git a/scripts/auth/mtcute-login.ts b/scripts/auth/mtcute-login.ts index 0bf4af9..7946774 100644 --- a/scripts/auth/mtcute-login.ts +++ b/scripts/auth/mtcute-login.ts @@ -9,6 +9,9 @@ if (!sessionName) { const tg = createTg(sessionName) +await tg.prepare() +await tg.storage.clear(true) + const self = await tg.start({ qrCodeHandler(url, expires) { console.log(qrTerminal.generate(url, { small: true })) diff --git a/scripts/media/deezer-dl.ts b/scripts/media/deezer-dl.ts new file mode 100644 index 0000000..c30dee7 --- /dev/null +++ b/scripts/media/deezer-dl.ts @@ -0,0 +1,516 @@ +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 { base64, hex, iter, utf8 } from '@fuman/utils' +import { Blowfish } from 'egoroof-blowfish' +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 } from '../../utils/fs.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(), + 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(), + }), + GENRE_ID: z.string(), + HIERARCHICAL_TITLE: z.string().optional(), + ISRC: z.string(), + LYRICS_ID: z.number(), + PHYSICAL_RELEASE_DATE: z.string(), + PROVIDER_ID: z.string(), + RANK: z.string().optional(), + SMARTRADIO: z.number(), + SNG_CONTRIBUTORS: z.object({ + main_artist: z.array(z.string()), + author: z.array(z.string()), + composer: z.array(z.string()), + }).partial().optional(), + SNG_ID: z.string(), + SNG_TITLE: z.string(), + STATUS: z.number(), + TRACK_NUMBER: z.string(), + USER_ID: z.number(), + VERSION: z.string(), + 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(), + GAIN: z.nullable(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 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) + } + } +} + +async function downloadTrack(track: GwTrack, opts: { + destination: string +}) { + 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, + }), + ]) + + 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', + '-metadata', + `title=${track.SNG_TITLE}`, + '-metadata', + `artist=${track.ART_NAME}`, + '-metadata', + `album=${track.ALB_TITLE}`, + '-metadata', + `year=${track.DIGITAL_RELEASE_DATE}`, + '-metadata', + `comment=ripped from deezer (id: ${track.SNG_ID})`, + filename, + ] + + if (lyricsLrc) { + params.push( + '-metadata', + `lyrics=${lyricsLrc}`, + ) + } + + const proc = $`ffmpeg ${params}` + const pipe = Readable.fromWeb(decStream as any).pipe(proc.stdin) + await new Promise((resolve, reject) => { + pipe.on('error', reject) + pipe.on('finish', resolve) + }) + await proc + } else { + const fd = await open(filename, 'w+') + const writer = fd.createWriteStream() + + for await (const chunk of decStream as any) { + writer.write(chunk) + } + + writer.end() + + await new Promise((resolve, reject) => { + writer.on('error', reject) + writer.on('finish', resolve) + }) + + const params: string[] = [ + '--remove-all-tags', + `--set-tag=TITLE=${track.SNG_TITLE}`, + `--set-tag=ARTIST=${track.ART_NAME}`, + `--set-tag=ALBUM=${track.ALB_TITLE}`, + `--set-tag=DATE=${track.DIGITAL_RELEASE_DATE}`, + `--set-tag=COMMENT=ripped from deezer (id: ${track.SNG_ID})`, + `--import-picture-from=${albumCoverPath}`, + ] + + params.push(filename) + + await $`metaflac ${params}` + } + + await rm(albumCoverPath, { force: true }) +} + +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)), + }) + } +} + +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(), + }), + })), + }), + }), + }), + }), + }) + + 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.title}`) + } + + const uri = await question('option > ') + + await downloadByUri(uri) +}