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.example
/test.js
/test.*
/tmp
/run.js

View File

@ -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'

View File

@ -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(`"</script>`)[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<StreamerAccount> {
const track = <TrackGetByUrlResponse>(
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 = <SoundCloudSubscriptionData>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
}
}
}

View File

@ -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 = <PlaybackInfo>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] {

View File

@ -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 = {