mirror of
https://git.gay/lucida/lucida
synced 2025-12-11 20:15:14 +01:00
improvement to soundcloud's get client function, add aac support to tidal
This commit is contained in:
parent
5b87eebb34
commit
528778f983
2
.gitignore
vendored
2
.gitignore
vendored
@ -7,7 +7,7 @@ node_modules
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
/test.js
|
/test.*
|
||||||
/tmp
|
/tmp
|
||||||
/run.js
|
/run.js
|
||||||
|
|
||||||
|
|||||||
@ -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'
|
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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] {
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user