mirror of
https://git.stupid.fish/teidesu/scripts.git
synced 2025-10-12 15:51:23 +11:00
859 lines
32 KiB
TypeScript
859 lines
32 KiB
TypeScript
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<T extends z.ZodTypeAny>(params: {
|
|
method: string
|
|
token?: string
|
|
options?: unknown
|
|
result: T
|
|
}): Promise<z.infer<T>> {
|
|
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<T extends z.ZodTypeAny>(params: {
|
|
query: string
|
|
variables?: unknown
|
|
result: T
|
|
}): Promise<z.infer<T>> {
|
|
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<any>()
|
|
|
|
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<typeof GwTrack>
|
|
|
|
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<typeof GwAlbum>
|
|
|
|
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<Uint8Array, Uint8Array> {
|
|
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<Uint8Array>) {
|
|
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<Uint8Array>) {
|
|
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<string>()
|
|
// 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<Uint8Array, Uint8Array>(
|
|
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)
|
|
}
|