teidesu-scripts/scripts/media/deezer-dl.ts
2025-05-10 21:06:34 +00:00

516 lines
22 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 { 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<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(),
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<typeof GwTrack>
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)
}
}
}
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<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',
'-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<void>((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<void>((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)
}