From 528778f98319a7aacf843b05ea0c10c4f8fd0a1f Mon Sep 17 00:00:00 2001 From: aria Date: Thu, 1 Aug 2024 02:32:53 -0400 Subject: [PATCH] improvement to soundcloud's get client function, add aac support to tidal --- .gitignore | 2 +- src/streamers/soundcloud/constants.ts | 1 - src/streamers/soundcloud/main.ts | 44 ++++++--- src/streamers/tidal/main.ts | 128 +++++++++++++++++++------- src/test.ts | 4 +- 5 files changed, 130 insertions(+), 49 deletions(-) diff --git a/.gitignore b/.gitignore index 564c870..516a95c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ node_modules .env .env.* !.env.example -/test.js +/test.* /tmp /run.js diff --git a/src/streamers/soundcloud/constants.ts b/src/streamers/soundcloud/constants.ts index 3392121..187d7a2 100644 --- a/src/streamers/soundcloud/constants.ts +++ b/src/streamers/soundcloud/constants.ts @@ -12,4 +12,3 @@ export const DEFAULT_HEADERS = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36' } -export const SC_VERSION = '1712837702' diff --git a/src/streamers/soundcloud/main.ts b/src/streamers/soundcloud/main.ts index faae745..069cfc9 100644 --- a/src/streamers/soundcloud/main.ts +++ b/src/streamers/soundcloud/main.ts @@ -1,5 +1,5 @@ import { fetch, HeadersInit } from 'undici' -import { DEFAULT_HEADERS, SC_VERSION } from './constants.js' +import { DEFAULT_HEADERS } from './constants.js' import { ItemType, Streamer, @@ -44,6 +44,17 @@ interface SoundcloudTranscoding { quality: string } +interface SoundCloudSubscriptionData { + active_subscription: { + state: string + subscription_period_started_at: string + expires_at: string + recurring: boolean + trial: boolean + is_eligible: boolean + } | null +} + export default class Soundcloud implements Streamer { hostnames = ['soundcloud.com', 'm.soundcloud.com', 'www.soundcloud.com'] testData = { @@ -110,7 +121,7 @@ export default class Soundcloud implements Streamer { ).text() const client = { - version: SC_VERSION, + version: response.split(`__sc_version="`)[1].split(`"`)[0], anonId: response.split(`[{"hydratable":"anonymousId","data":"`)[1].split(`"`)[0], id: await fetchKey(response) } @@ -135,7 +146,7 @@ export default class Soundcloud implements Streamer { #formatURL(og: string, client: ScClient): string { const parsed = new URL(og) - if (client.anonId) parsed.searchParams.append('user_id', client.anonId) + if (client.anonId && !this.oauthToken) parsed.searchParams.append('user_id', client.anonId) if (client.id) parsed.searchParams.append('client_id', client.id) if (client.version) parsed.searchParams.append('app_version', client.version) if (!parsed.searchParams.get('app_locale')) parsed.searchParams.append('app_locale', 'en') @@ -245,14 +256,25 @@ export default class Soundcloud implements Streamer { return { ...api, id } } async getAccountInfo(): Promise { - const track = ( - await this.getByUrl('https://soundcloud.com/ween/polka-dot-tail') - ) - const stream = await track.getStream() - if (stream.mimeType.startsWith('audio/mp4')) { - stream.stream.unpipe() - return { valid: true, premium: true, explicit: true } - } else return { valid: true, premium: false, explicit: true } + const client = this.client || (await this.#getClient()) + const subscriptionQuery = await ( + await fetch( + this.#formatURL( + `https://api-v2.soundcloud.com/payments/quotations/consumer-subscription`, + client + ), + { + method: 'get', + headers: headers(this.oauthToken) + } + ) + ).json() + + return { + valid: true, + premium: subscriptionQuery?.active_subscription?.state == 'active', + explicit: true + } } } diff --git a/src/streamers/tidal/main.ts b/src/streamers/tidal/main.ts index 6cc010f..53d83a2 100644 --- a/src/streamers/tidal/main.ts +++ b/src/streamers/tidal/main.ts @@ -1,5 +1,7 @@ import { fetch } from 'undici' import { spawn } from 'child_process' +import os from 'node:os' +import fs from 'node:fs' import { ItemType, GetByUrlResponse, @@ -329,6 +331,7 @@ export default class Tidal implements Streamer { manifestMimeType: string audioQuality: 'LOW' | 'HIGH' | 'LOSSLESS' | 'HI_RES' | 'HI_RES_LOSSLESS' } + const playbackInfoResponse = await this.#get( `tracks/${trackId}/playbackinfopostpaywall/v4`, { @@ -339,9 +342,6 @@ export default class Tidal implements Streamer { } ) - if (playbackInfoResponse.audioQuality == 'HIGH' || playbackInfoResponse.audioQuality == 'LOW') - throw new Error('This ripper is incompatible with AAC codecs formats at the moment.') - const manifestStr = Buffer.from(playbackInfoResponse.manifest, 'base64').toString('utf-8') interface Manifest { mimeType: string @@ -359,41 +359,103 @@ export default class Tidal implements Streamer { } const trackUrls = parseMpd(manifestStr) + const args = await this.#getFfArgs(playbackInfoResponse.audioQuality) - const ffmpegProc = spawn('ffmpeg', [ - '-hide_banner', - '-loglevel', - 'error', - '-i', - '-', - '-c:a', - 'copy', - '-f', - 'flac', - '-' - ]) + const ffmpegProc = spawn('ffmpeg', args) + switch (playbackInfoResponse.audioQuality) { + case 'LOW': + case 'HIGH': { + return new Promise(function (resolve, reject) { + const stream = new Stream.Readable({ + // eslint-disable-next-line @typescript-eslint/no-empty-function + read() {} + }) + async function load() { + for (const url of trackUrls) { + const resp = await fetch(url) + if (!resp.body) throw new Error('Response has no body') + for await (const chunk of resp.body) { + stream.push(chunk) + } + } + stream.push(null) + } + stream.pipe(ffmpegProc.stdin) - const stream = new Stream.Readable({ - // eslint-disable-next-line @typescript-eslint/no-empty-function - read() {} - }) - async function load() { - for (const url of trackUrls) { - const resp = await fetch(url) - if (!resp.body) throw new Error('Response has no body') - for await (const chunk of resp.body) { - stream.push(chunk) + let err: string + + ffmpegProc.stderr.on('data', function (data) { + err = data.toString() + }) + + load() + + ffmpegProc.once('exit', async function (code) { + const folder = args[args.length - 1] + .split('/') + .slice(0, args[args.length - 1].split('/').length - 1) + .join('/') + if (code == 0) + resolve({ + mimeType: 'audio/mp4', + stream: fs.createReadStream(args[args.length - 1]).once('end', async function () { + await fs.promises.rm(folder, { recursive: true }) + }) + }) + else { + reject(`FFMPEG error: ${err}` || 'FFMPEG could not handle the file.') + await fs.promises.rm(folder, { recursive: true }) + } + }) + }) + } + + default: { + const stream = new Stream.Readable({ + // eslint-disable-next-line @typescript-eslint/no-empty-function + read() {} + }) + + // eslint-disable-next-line no-inner-declarations + async function load() { + for (const url of trackUrls) { + const resp = await fetch(url) + if (!resp.body) throw new Error('Response has no body') + for await (const chunk of resp.body) { + stream.push(chunk) + } + } + stream.push(null) + } + stream.pipe(ffmpegProc.stdin) + load() + + return { + mimeType: 'audio/flac', + stream: ffmpegProc.stdout } } - stream.push(null) } - stream.pipe(ffmpegProc.stdin) - ffmpegProc.stderr.pipe(process.stderr) - load() - - return { - mimeType: 'audio/flac', - stream: ffmpegProc.stdout + } + async #getFfArgs(audioQuality: 'LOW' | 'HIGH' | 'LOSSLESS' | 'HI_RES' | 'HI_RES_LOSSLESS') { + switch (audioQuality) { + case 'LOW': + case 'HIGH': + // eslint-disable-next-line no-case-declarations + const folder = await fs.promises.mkdtemp(`${os.tmpdir()}/lucida`) + return [ + '-hide_banner', + '-loglevel', + 'error', + '-i', + '-', + '-c:a', + 'copy', + '-y', + `${folder}/data.m4a` + ] + default: + return ['-hide_banner', '-loglevel', 'error', '-i', '-', '-c:a', 'copy', '-f', 'flac', '-'] } } #getUrlParts(url: string): ['artist' | 'album' | 'track', string] { diff --git a/src/test.ts b/src/test.ts index 3490cee..224aed2 100644 --- a/src/test.ts +++ b/src/test.ts @@ -24,9 +24,7 @@ if (!process.env.TEST_OPTIONS) { ) process.exit(1) } -} else { - testOptions = JSON.parse(process.env.TEST_OPTIONS) -} +} else testOptions = JSON.parse(process.env.TEST_OPTIONS) async function start() { const lucidaOptions: LucidaOptions = {