mirror of
https://git.stupid.fish/teidesu/scripts.git
synced 2025-11-27 16:31:25 +11:00
375 lines
11 KiB
TypeScript
375 lines
11 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
import { mkdir, rm, writeFile } from 'node:fs/promises'
|
|
import { dirname, join } from 'node:path'
|
|
import { asyncPool, base64, todo, unknownToError, utf8 } from '@fuman/utils'
|
|
import Spinnies from 'spinnies'
|
|
import { z } from 'zod'
|
|
import { $, question } from 'zx'
|
|
import { ffetch as ffetchBase } from '../../utils/fetch.ts'
|
|
import { sanitizeFilename } from '../../utils/fs.ts'
|
|
import { pipeIntoProc, runMetaflac, writeIntoProc } from '../../utils/media-metadata.ts'
|
|
import { getEnv } from '../../utils/misc.ts'
|
|
import { concatMpdSegments, parseSimpleMpd } from '../../utils/mpd.ts'
|
|
import { createLibcurlFetch } from '../../utils/temkakit/libcurl.ts'
|
|
|
|
const oauthResponse = await ffetchBase('https://auth.tidal.com/v1/oauth2/token', {
|
|
form: {
|
|
client_id: '49YxDN9a2aFV6RTG',
|
|
grant_type: 'refresh_token',
|
|
scope: 'r_usr w_usr',
|
|
refresh_token: getEnv('TIDAL_REFRESH_TOKEN'),
|
|
},
|
|
}).parsedJson(z.object({
|
|
access_token: z.string(),
|
|
user: z.object({
|
|
username: z.string(),
|
|
countryCode: z.string(),
|
|
}),
|
|
}))
|
|
|
|
console.log('Logged in as %s', oauthResponse.user.username)
|
|
|
|
const ffetch = ffetchBase.extend({
|
|
headers: {
|
|
'accept': '*/*',
|
|
'Authorization': `Bearer ${oauthResponse.access_token}`,
|
|
'accept-language': 'en-US,en;q=0.5',
|
|
'accept-encoding': 'gzip, deflate, br',
|
|
'referer': 'https://tidal.com/',
|
|
'origin': 'https://tidal.com',
|
|
'sec-fetch-dest': 'empty',
|
|
'sec-fetch-mode': 'cors',
|
|
'sec-fetch-site': 'same-origin',
|
|
},
|
|
// for some reason the request sometimes hangs indefinitely, so we need to timeout
|
|
timeout: 5000,
|
|
retry: {
|
|
maxRetries: 10,
|
|
// onError: (err, req) => {
|
|
// console.log('%s: error: %s', req.url, err)
|
|
// return true
|
|
// },
|
|
},
|
|
})
|
|
|
|
const PlaybackInfoResult = z.object({
|
|
albumPeakAmplitude: z.number(),
|
|
albumReplayGain: z.number(),
|
|
assetPresentation: z.string(),
|
|
audioMode: z.string(),
|
|
audioQuality: z.enum(['HIGH', 'LOSSLESS', 'HI_RES_LOSSLESS']),
|
|
bitDepth: z.number(),
|
|
manifest: z.string(),
|
|
manifestHash: z.string(),
|
|
manifestMimeType: z.literal('application/dash+xml'),
|
|
sampleRate: z.number(),
|
|
streamingSessionId: z.string(),
|
|
trackId: z.number(),
|
|
trackPeakAmplitude: z.number(),
|
|
trackReplayGain: z.number(),
|
|
})
|
|
|
|
const streamingSessionId = randomUUID()
|
|
|
|
const TidalTrack = z.object({
|
|
id: z.number(),
|
|
album: z.object({
|
|
id: z.number(),
|
|
cover: z.string(),
|
|
}),
|
|
artists: z.array(z.object({
|
|
id: z.number(),
|
|
name: z.string(),
|
|
})),
|
|
isrc: z.string().nullable(),
|
|
trackNumber: z.number(),
|
|
volumeNumber: z.number(),
|
|
title: z.string(),
|
|
copyright: z.string().nullable(),
|
|
version: z.string().nullable(),
|
|
bpm: z.number().nullable(),
|
|
})
|
|
type TidalTrack = z.infer<typeof TidalTrack>
|
|
|
|
function getTrackName(track: TidalTrack) {
|
|
let name = track.title
|
|
if (track.version) {
|
|
name += ` ${track.version}`
|
|
}
|
|
return name
|
|
}
|
|
|
|
function getTrackArtistString(track: TidalTrack | TidalAlbum) {
|
|
return track.artists.map(it => it.name).join(', ')
|
|
}
|
|
|
|
function getAlbumCoverUrl(uuid: string) {
|
|
return `https://resources.tidal.com/images/${uuid.replace(/-/g, '/')}/1280x1280.jpg`
|
|
}
|
|
|
|
const TidalAlbum = z.object({
|
|
id: z.number(),
|
|
title: z.string(),
|
|
cover: z.string(),
|
|
releaseDate: z.string(),
|
|
artists: z.array(z.object({
|
|
id: z.number(),
|
|
name: z.string(),
|
|
})),
|
|
})
|
|
type TidalAlbum = z.infer<typeof TidalAlbum>
|
|
|
|
const COMMON_QUERY = {
|
|
countryCode: oauthResponse.user.countryCode,
|
|
locale: 'en_US',
|
|
deviceType: 'BROWSER',
|
|
}
|
|
|
|
async function downloadTrack(options: {
|
|
track: TidalTrack
|
|
album: TidalAlbum
|
|
albumCoverPath?: string
|
|
destination: string
|
|
}) {
|
|
const { track, album, albumCoverPath, destination } = options
|
|
const [playbackRes, lyricsRes, creditsRes] = [
|
|
await ffetch(`https://tidal.com/v1/tracks/${track.id}/playbackinfo`, {
|
|
query: {
|
|
audioquality: 'HI_RES_LOSSLESS',
|
|
playbackmode: 'STREAM',
|
|
assetpresentation: 'FULL',
|
|
},
|
|
headers: {
|
|
'x-tidal-streamingsessionid': streamingSessionId,
|
|
'x-tidal-token': '49YxDN9a2aFV6RTG',
|
|
},
|
|
}).parsedJson(PlaybackInfoResult),
|
|
await ffetch(`https://tidal.com/v1/tracks/${track.id}/lyrics`, {
|
|
query: {
|
|
...COMMON_QUERY,
|
|
},
|
|
}).parsedJson(z.object({
|
|
lyrics: z.string(),
|
|
// subtitles = timestamped lyrics
|
|
subtitles: z.string().nullable(),
|
|
})).catch(() => null),
|
|
await ffetch(`https://tidal.com/v1/tracks/${track.id}/credits`, {
|
|
query: {
|
|
limit: 100,
|
|
includeContributors: true,
|
|
...COMMON_QUERY,
|
|
},
|
|
}).parsedJson(z.array(z.object({
|
|
type: z.string(),
|
|
contributors: z.array(z.object({
|
|
id: z.number(),
|
|
name: z.string(),
|
|
})),
|
|
}))),
|
|
]
|
|
|
|
const manifest = base64.decode(playbackRes.manifest)
|
|
|
|
const ext = playbackRes.audioQuality === 'HIGH' ? 'm4a' : 'flac'
|
|
const destFile = `${destination}.${ext}`
|
|
|
|
await mkdir(dirname(destFile), { recursive: true })
|
|
|
|
const lyricsLrc = lyricsRes ? lyricsRes.subtitles ?? lyricsRes.lyrics : undefined
|
|
const keyedCredits = creditsRes
|
|
? Object.fromEntries(creditsRes.map(it => [it.type, it.contributors.map(it => it.name)]))
|
|
: undefined
|
|
|
|
const params: string[] = [
|
|
'-y',
|
|
'-i',
|
|
'pipe:0',
|
|
'-c',
|
|
'copy',
|
|
'-loglevel',
|
|
'error',
|
|
'-hide_banner',
|
|
destFile,
|
|
]
|
|
|
|
const proc = $`ffmpeg ${params}`
|
|
await pipeIntoProc(proc, concatMpdSegments({
|
|
mpd: parseSimpleMpd(utf8.decoder.decode(manifest)),
|
|
fetch: async url => new Uint8Array(await ffetch(url).arrayBuffer()),
|
|
}))
|
|
await proc
|
|
|
|
if (ext === 'flac') {
|
|
await runMetaflac({
|
|
path: destFile,
|
|
tags: {
|
|
TITLE: getTrackName(track),
|
|
ALBUM: album.title,
|
|
DATE: album.releaseDate,
|
|
DISCNUMBER: track.volumeNumber,
|
|
TRACKNUMBER: track.trackNumber,
|
|
COMMENT: `ripped from tidal (id: ${track.id})`,
|
|
ARTIST: track.artists.map(it => it.name),
|
|
COPYRIGHT: track.copyright,
|
|
LYRICS: lyricsLrc,
|
|
REPLAYGAIN_ALBUM_GAIN: playbackRes.albumReplayGain,
|
|
REPLAYGAIN_ALBUM_PEAK: playbackRes.albumPeakAmplitude,
|
|
REPLAYGAIN_TRACK_GAIN: playbackRes.trackReplayGain,
|
|
REPLAYGAIN_TRACK_PEAK: playbackRes.trackPeakAmplitude,
|
|
PRODUCER: keyedCredits?.Producer,
|
|
COMPOSER: keyedCredits?.Composer,
|
|
LYRICIST: keyedCredits?.Lyricist,
|
|
PERFORMER: keyedCredits?.['Vocal accompaniment']?.map(it => `${it} (Vocal)`),
|
|
ISRC: track.isrc,
|
|
BPM: track.bpm,
|
|
},
|
|
coverPath: albumCoverPath,
|
|
})
|
|
} else {
|
|
console.log('warn: m4a tagging not yet implemented')
|
|
}
|
|
}
|
|
|
|
async function fetchAlbumTracks(albumId: number) {
|
|
let offset = 0
|
|
const tracks: TidalTrack[] = []
|
|
while (true) {
|
|
const res = await ffetch(`https://tidal.com/v1/albums/${albumId}/items`, { query: {
|
|
...COMMON_QUERY,
|
|
replace: true,
|
|
offset,
|
|
limit: 100,
|
|
} }).parsedJson(z.object({
|
|
items: z.array(z.object({
|
|
item: TidalTrack,
|
|
type: z.literal('track'),
|
|
})),
|
|
totalNumberOfItems: z.number(),
|
|
}))
|
|
|
|
for (const item of res.items) {
|
|
tracks.push(item.item)
|
|
}
|
|
if (tracks.length >= res.totalNumberOfItems) break
|
|
offset += 100
|
|
}
|
|
|
|
return tracks
|
|
}
|
|
|
|
async function downloadTrackList(opts: {
|
|
tracks: TidalTrack[]
|
|
albums: Map<number, TidalAlbum>
|
|
albumCoverPaths: Map<number, string>
|
|
destination: string
|
|
includeTrackNumber?: boolean
|
|
onDownloadStart?: (track: TidalTrack) => void
|
|
onDownloadEnd?: (track: TidalTrack, error: Error | null) => void
|
|
}) {
|
|
await mkdir(opts.destination, { recursive: true })
|
|
|
|
const isMultiDisc = opts.tracks.some(it => it.volumeNumber !== 1)
|
|
const firstTrackArtistString = getTrackArtistString(opts.tracks[0])
|
|
const isDifferentArtists = opts.tracks.some(it => getTrackArtistString(it) !== firstTrackArtistString)
|
|
|
|
await asyncPool(opts.tracks, async (track) => {
|
|
let filename = ''
|
|
if (opts.includeTrackNumber) {
|
|
if (isMultiDisc) {
|
|
filename = `${track.volumeNumber}-`
|
|
}
|
|
filename = `${track.trackNumber.toString().padStart(2, '0')}. `
|
|
}
|
|
if (isDifferentArtists) {
|
|
filename += `${getTrackArtistString(track)} - `
|
|
}
|
|
filename += `${getTrackName(track)}`
|
|
|
|
const filenamePath = join(opts.destination, sanitizeFilename(filename))
|
|
|
|
try {
|
|
opts.onDownloadStart?.(track)
|
|
await downloadTrack({
|
|
track,
|
|
album: opts.albums.get(track.album.id)!,
|
|
albumCoverPath: opts.albumCoverPaths.get(track.album.id)!,
|
|
destination: filenamePath,
|
|
})
|
|
opts.onDownloadEnd?.(track, null)
|
|
} catch (e) {
|
|
opts.onDownloadEnd?.(track, unknownToError(e))
|
|
}
|
|
}, { limit: 8 })
|
|
}
|
|
|
|
const url = process.argv[2] ?? await question('url or search > ')
|
|
|
|
/* eslint-disable no-cond-assign */
|
|
|
|
let m
|
|
if ((m = url.match(/\/track\/(\d+)/))) {
|
|
const track = await ffetch(`https://tidal.com/v1/tracks/${m[1]}`, { query: COMMON_QUERY })
|
|
.parsedJson(TidalTrack)
|
|
const [albumRes, albumCoverRes] = await Promise.all([
|
|
ffetch(`https://tidal.com/v1/albums/${track.album.id}`, { query: COMMON_QUERY }).parsedJson(TidalAlbum),
|
|
ffetch(getAlbumCoverUrl(track.album.cover)).arrayBuffer(),
|
|
])
|
|
|
|
const tmpAlbumCoverPath = join(`assets/tidal-${track.album.cover}.jpg`)
|
|
await writeFile(tmpAlbumCoverPath, new Uint8Array(albumCoverRes))
|
|
|
|
await downloadTrack({
|
|
track,
|
|
album: albumRes,
|
|
albumCoverPath: tmpAlbumCoverPath,
|
|
destination: join('assets/tidal-dl', sanitizeFilename(`${getTrackArtistString(track)} - ${getTrackName(track)}`)),
|
|
})
|
|
|
|
await rm(tmpAlbumCoverPath)
|
|
} else if ((m = url.match(/\/album\/(\d+)/))) {
|
|
const [albumRes, albumTracks] = await Promise.all([
|
|
ffetch(`https://tidal.com/v1/albums/${m[1]}`, { query: COMMON_QUERY }).parsedJson(TidalAlbum),
|
|
fetchAlbumTracks(m[1]),
|
|
])
|
|
|
|
console.log(`downloading album ${albumRes.title} with ${albumTracks.length} tracks`)
|
|
|
|
const outDir = join('assets/tidal-dl', `${getTrackArtistString(albumRes)} - ${sanitizeFilename(albumRes.title)}`)
|
|
await mkdir(outDir, { recursive: true })
|
|
|
|
const albumCoverRes = await ffetch(getAlbumCoverUrl(albumRes.cover)).arrayBuffer()
|
|
await writeFile(join(outDir, 'cover.jpg'), new Uint8Array(albumCoverRes))
|
|
|
|
const spinnies = new Spinnies()
|
|
spinnies.add('download', { text: 'downloading album...' })
|
|
|
|
const errors = new Map<number, Error>()
|
|
await downloadTrackList({
|
|
tracks: albumTracks,
|
|
albums: new Map([[albumRes.id, albumRes]]),
|
|
albumCoverPaths: new Map([[albumRes.id, join(outDir, 'cover.jpg')]]),
|
|
destination: outDir,
|
|
includeTrackNumber: true,
|
|
onDownloadStart(track) {
|
|
spinnies.add(`${track.id}`, { text: getTrackName(track) })
|
|
},
|
|
onDownloadEnd(track, error) {
|
|
spinnies.remove(`${track.id}`)
|
|
if (error) {
|
|
errors.set(track.id, error)
|
|
}
|
|
spinnies.remove(`${track.id}`)
|
|
},
|
|
})
|
|
|
|
spinnies.succeed('download', { text: 'downloaded album' })
|
|
|
|
if (errors.size) {
|
|
console.error('errors:')
|
|
for (const [id, error] of errors) {
|
|
console.error(` ${id}: ${error.message}`)
|
|
}
|
|
}
|
|
} else {
|
|
todo('unsupported url')
|
|
}
|