improvement to soundcloud's get client function, add aac support to tidal

This commit is contained in:
aria 2024-08-01 02:32:53 -04:00
parent 5b87eebb34
commit 528778f983
5 changed files with 130 additions and 49 deletions

2
.gitignore vendored
View File

@ -7,7 +7,7 @@ node_modules
.env .env
.env.* .env.*
!.env.example !.env.example
/test.js /test.*
/tmp /tmp
/run.js /run.js

View File

@ -12,4 +12,3 @@ export const DEFAULT_HEADERS = {
'User-Agent': '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' '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'

View File

@ -1,5 +1,5 @@
import { fetch, HeadersInit } from 'undici' import { fetch, HeadersInit } from 'undici'
import { DEFAULT_HEADERS, SC_VERSION } from './constants.js' import { DEFAULT_HEADERS } from './constants.js'
import { import {
ItemType, ItemType,
Streamer, Streamer,
@ -44,6 +44,17 @@ interface SoundcloudTranscoding {
quality: string 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 { export default class Soundcloud implements Streamer {
hostnames = ['soundcloud.com', 'm.soundcloud.com', 'www.soundcloud.com'] hostnames = ['soundcloud.com', 'm.soundcloud.com', 'www.soundcloud.com']
testData = { testData = {
@ -110,7 +121,7 @@ export default class Soundcloud implements Streamer {
).text() ).text()
const client = { const client = {
version: SC_VERSION, version: response.split(`__sc_version="`)[1].split(`"</script>`)[0],
anonId: response.split(`[{"hydratable":"anonymousId","data":"`)[1].split(`"`)[0], anonId: response.split(`[{"hydratable":"anonymousId","data":"`)[1].split(`"`)[0],
id: await fetchKey(response) id: await fetchKey(response)
} }
@ -135,7 +146,7 @@ export default class Soundcloud implements Streamer {
#formatURL(og: string, client: ScClient): string { #formatURL(og: string, client: ScClient): string {
const parsed = new URL(og) 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.id) parsed.searchParams.append('client_id', client.id)
if (client.version) parsed.searchParams.append('app_version', client.version) if (client.version) parsed.searchParams.append('app_version', client.version)
if (!parsed.searchParams.get('app_locale')) parsed.searchParams.append('app_locale', 'en') 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 } return { ...api, id }
} }
async getAccountInfo(): Promise<StreamerAccount> { async getAccountInfo(): Promise<StreamerAccount> {
const track = <TrackGetByUrlResponse>( const client = this.client || (await this.#getClient())
await this.getByUrl('https://soundcloud.com/ween/polka-dot-tail') const subscriptionQuery = <SoundCloudSubscriptionData>await (
) await fetch(
const stream = await track.getStream() this.#formatURL(
if (stream.mimeType.startsWith('audio/mp4')) { `https://api-v2.soundcloud.com/payments/quotations/consumer-subscription`,
stream.stream.unpipe() client
return { valid: true, premium: true, explicit: true } ),
} else return { valid: true, premium: false, explicit: true } {
method: 'get',
headers: headers(this.oauthToken)
}
)
).json()
return {
valid: true,
premium: subscriptionQuery?.active_subscription?.state == 'active',
explicit: true
}
} }
} }

View File

@ -1,5 +1,7 @@
import { fetch } from 'undici' import { fetch } from 'undici'
import { spawn } from 'child_process' import { spawn } from 'child_process'
import os from 'node:os'
import fs from 'node:fs'
import { import {
ItemType, ItemType,
GetByUrlResponse, GetByUrlResponse,
@ -329,6 +331,7 @@ export default class Tidal implements Streamer {
manifestMimeType: string manifestMimeType: string
audioQuality: 'LOW' | 'HIGH' | 'LOSSLESS' | 'HI_RES' | 'HI_RES_LOSSLESS' audioQuality: 'LOW' | 'HIGH' | 'LOSSLESS' | 'HI_RES' | 'HI_RES_LOSSLESS'
} }
const playbackInfoResponse = <PlaybackInfo>await this.#get( const playbackInfoResponse = <PlaybackInfo>await this.#get(
`tracks/${trackId}/playbackinfopostpaywall/v4`, `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') const manifestStr = Buffer.from(playbackInfoResponse.manifest, 'base64').toString('utf-8')
interface Manifest { interface Manifest {
mimeType: string mimeType: string
@ -359,41 +359,103 @@ export default class Tidal implements Streamer {
} }
const trackUrls = parseMpd(manifestStr) const trackUrls = parseMpd(manifestStr)
const args = await this.#getFfArgs(playbackInfoResponse.audioQuality)
const ffmpegProc = spawn('ffmpeg', [ const ffmpegProc = spawn('ffmpeg', args)
'-hide_banner', switch (playbackInfoResponse.audioQuality) {
'-loglevel', case 'LOW':
'error', case 'HIGH': {
'-i', return new Promise(function (resolve, reject) {
'-', const stream = new Stream.Readable({
'-c:a', // eslint-disable-next-line @typescript-eslint/no-empty-function
'copy', read() {}
'-f', })
'flac', 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({ let err: string
// eslint-disable-next-line @typescript-eslint/no-empty-function
read() {} ffmpegProc.stderr.on('data', function (data) {
}) err = data.toString()
async function load() { })
for (const url of trackUrls) {
const resp = await fetch(url) load()
if (!resp.body) throw new Error('Response has no body')
for await (const chunk of resp.body) { ffmpegProc.once('exit', async function (code) {
stream.push(chunk) 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) async #getFfArgs(audioQuality: 'LOW' | 'HIGH' | 'LOSSLESS' | 'HI_RES' | 'HI_RES_LOSSLESS') {
load() switch (audioQuality) {
case 'LOW':
return { case 'HIGH':
mimeType: 'audio/flac', // eslint-disable-next-line no-case-declarations
stream: ffmpegProc.stdout 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] { #getUrlParts(url: string): ['artist' | 'album' | 'track', string] {

View File

@ -24,9 +24,7 @@ if (!process.env.TEST_OPTIONS) {
) )
process.exit(1) process.exit(1)
} }
} else { } else testOptions = JSON.parse(process.env.TEST_OPTIONS)
testOptions = JSON.parse(process.env.TEST_OPTIONS)
}
async function start() { async function start() {
const lucidaOptions: LucidaOptions = { const lucidaOptions: LucidaOptions = {