321 lines
13 KiB
TypeScript
321 lines
13 KiB
TypeScript
import type {SoundcloudTrack, SoundcloudTranscoding} from "../types"
|
|
import * as fs from "fs"
|
|
import * as path from "path"
|
|
import {API} from "../API"
|
|
import {Tracks, Users, Playlists} from "./index"
|
|
import {Readable} from "stream"
|
|
import {spawnSync} from "child_process"
|
|
|
|
let temp = 0
|
|
const FFMPEG = {checked: false, path: ""}
|
|
const SOURCES: (() => string)[] = [
|
|
() => {
|
|
const ffmpeg = require("ffmpeg-static")
|
|
return ffmpeg?.path ?? ffmpeg
|
|
},
|
|
() => "ffmpeg",
|
|
() => "./ffmpeg",
|
|
]
|
|
|
|
export class Util {
|
|
private readonly tracks = new Tracks(this.api)
|
|
private readonly users = new Users(this.api)
|
|
private readonly playlists = new Playlists(this.api)
|
|
public constructor(private readonly api: API) {}
|
|
|
|
|
|
private readonly resolveTrack = async (trackResolvable: string | SoundcloudTrack) => {
|
|
return typeof trackResolvable === "string" ? await this.tracks.get(trackResolvable) : trackResolvable
|
|
}
|
|
|
|
private readonly sortTranscodings = async (trackResolvable: string | SoundcloudTrack, protocol?: "progressive" | "hls") => {
|
|
const track = await this.resolveTrack(trackResolvable)
|
|
const transcodings = track.media.transcodings.sort(t => (t.quality === "hq" ? -1 : 1))
|
|
if (!protocol) return transcodings
|
|
return transcodings.filter(t => t.format.protocol === protocol)
|
|
}
|
|
|
|
private readonly getStreamLink = async (transcoding: SoundcloudTranscoding) => {
|
|
if (!transcoding?.url) return null
|
|
const url = transcoding.url
|
|
let client_id = await this.api.getClientId()
|
|
const headers = this.api.headers
|
|
let connect = url.includes("?") ? `&client_id=${client_id}` : `?client_id=${client_id}`
|
|
try {
|
|
return fetch(url + connect, {headers}).then(r => r.json()).then(r => r.url) as Promise<string>
|
|
} catch {
|
|
client_id = await this.api.getClientId(true)
|
|
connect = url.includes("?") ? `&client_id=${client_id}` : `?client_id=${client_id}`
|
|
try {
|
|
return fetch(url + connect, {headers}).then(r => r.json()).then(r => r.url) as Promise<string>
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Gets the direct streaming link of a track.
|
|
*/
|
|
public streamLink = async (trackResolvable: string | SoundcloudTrack, protocol?: "progressive" | "hls") => {
|
|
const track = await this.resolveTrack(trackResolvable)
|
|
const transcodings = await this.sortTranscodings(track, protocol)
|
|
if (!transcodings.length) return null
|
|
return this.getStreamLink(transcodings[0])
|
|
}
|
|
|
|
private readonly mergeFiles = async (files: string[], outputFile: string) => {
|
|
const outStream = fs.createWriteStream(outputFile)
|
|
const ret = new Promise<void>((resolve, reject) => {
|
|
outStream.on("finish", resolve)
|
|
outStream.on("error", reject)
|
|
})
|
|
for (const file of files) {
|
|
await new Promise((resolve, reject) => {
|
|
fs.createReadStream(file).on("error", reject).on("end", resolve).pipe(outStream, {end: false})
|
|
})
|
|
}
|
|
outStream.end()
|
|
return ret
|
|
}
|
|
|
|
private readonly checkFFmpeg = () => {
|
|
if (FFMPEG.checked) return true
|
|
for (const fn of SOURCES) {
|
|
try {
|
|
const command = fn()
|
|
const result = spawnSync(command, ["-h"], {windowsHide: true, shell: true, encoding: "utf-8"})
|
|
if (result.error) throw result.error
|
|
if (result.stderr && !result.stdout) throw new Error(result.stderr)
|
|
|
|
const output = result.output.filter(Boolean).join("\n")
|
|
const version = /version (.+) Copyright/im.exec(output)?.[1]
|
|
if (!version) throw new Error(`Malformed FFmpeg command using ${command}`)
|
|
FFMPEG.path = command
|
|
} catch {}
|
|
}
|
|
FFMPEG.checked = true
|
|
if (!FFMPEG.path) {
|
|
console.warn("FFmpeg not found, please install ffmpeg-static or add ffmpeg to your PATH.")
|
|
console.warn("Download m4a (hq) is disabled, use mp3 (sq) instead.")
|
|
}
|
|
return true
|
|
}
|
|
|
|
private readonly spawnFFmpeg = (argss: string[]) => {
|
|
try {
|
|
spawnSync(FFMPEG.path, argss, {windowsHide: true, shell: false})
|
|
} catch (e) {
|
|
console.error(e)
|
|
throw "FFmpeg error"
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Readable stream of m3u playlists.
|
|
*/
|
|
private readonly m3uReadableStream = async (trackResolvable: string | SoundcloudTrack): Promise<{stream: Readable, type: "m4a" | "mp3"}> => {
|
|
const track = await this.resolveTrack(trackResolvable)
|
|
const transcodings = await this.sortTranscodings(track, "hls")
|
|
if (!transcodings.length) throw "No transcodings found"
|
|
let transcoding: {url: string; type: "m4a" | "mp3"}
|
|
for (const t of transcodings) {
|
|
if (t.format.mime_type.startsWith('audio/mp4; codecs="mp4a') && this.checkFFmpeg()) {
|
|
transcoding = {url: t.url, type: "m4a"}
|
|
break
|
|
}
|
|
if (t.format.mime_type.startsWith("audio/mpeg")) {
|
|
transcoding = {url: t.url, type: "mp3"}
|
|
break
|
|
}
|
|
}
|
|
if (!transcoding) {
|
|
console.log(`Support for this track is not yet implemented, please open an issue on GitHub.\n
|
|
URL: ${track.permalink_url}.\n
|
|
Type: ${track.media.transcodings.map(t => t.format.mime_type).join(" | ")}`)
|
|
throw "No supported transcodings found"
|
|
}
|
|
const headers = this.api.headers
|
|
const client_id = await this.api.getClientId()
|
|
const connect = transcoding.url.includes("?") ? `&client_id=${client_id}` : `?client_id=${client_id}`
|
|
const m3uLink = await fetch(transcoding.url + connect, {headers: this.api.headers}).then(r => r.json()).then(r => r.url)
|
|
const destDir = path.join(__dirname, `tmp_${temp++}`)
|
|
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, {recursive: true})
|
|
const output = path.join(destDir, `out.${transcoding.type}`)
|
|
|
|
if (transcoding.type === "m4a") {
|
|
try {
|
|
this.spawnFFmpeg(["-y", "-loglevel", "warning", "-i", m3uLink, "-bsf:a", "aac_adtstoasc", "-vcodec", "copy", "-c", "copy", "-crf", "50", output])
|
|
} catch {
|
|
console.warn("Failed to transmux to m4a (hq), download as mp3 (hq) instead.")
|
|
FFMPEG.path = null
|
|
return this.m3uReadableStream(trackResolvable)
|
|
}
|
|
} else {
|
|
const m3u = await fetch(m3uLink, {headers}).then(r => r.text())
|
|
const urls = m3u.match(/(http).*?(?=\s)/gm)
|
|
const chunks: string[] = []
|
|
for (let i = 0; i < urls.length; i++) {
|
|
const arrayBuffer = await fetch(urls[i], {headers}).then(r => r.arrayBuffer())
|
|
const chunkPath = path.join(destDir, `${i}.${transcoding.type}`)
|
|
fs.writeFileSync(chunkPath, Buffer.from(arrayBuffer))
|
|
chunks.push(chunkPath)
|
|
}
|
|
await this.mergeFiles(chunks, output)
|
|
}
|
|
const stream = Readable.from(fs.readFileSync(output))
|
|
Util.removeDirectory(destDir)
|
|
return {stream, type: transcoding.type}
|
|
}
|
|
|
|
private webToNodeStream = (webStream: ReadableStream<Uint8Array>) => {
|
|
const reader = webStream.getReader()
|
|
return new Readable({
|
|
async read() {
|
|
const { done, value } = await reader.read()
|
|
if (done) {
|
|
this.push(null)
|
|
} else {
|
|
this.push(value)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Downloads the mp3 stream of a track.
|
|
*/
|
|
private readonly downloadTrackStream = async (trackResolvable: string | SoundcloudTrack, title: string, dest: string) => {
|
|
let result: {stream: Readable, type: string}
|
|
const track = await this.resolveTrack(trackResolvable)
|
|
const transcodings = await this.sortTranscodings(track, "progressive")
|
|
if (!transcodings.length) {
|
|
result = await this.m3uReadableStream(trackResolvable)
|
|
} else {
|
|
const transcoding = transcodings[0]
|
|
const url = await this.getStreamLink(transcoding)
|
|
const headers = this.api.headers
|
|
const stream = await fetch(url, {headers}).then((r) => this.webToNodeStream(r.body))
|
|
const type = transcoding.format.mime_type.startsWith('audio/mp4; codecs="mp4a') ? "m4a" : "mp3"
|
|
result = {stream, type}
|
|
}
|
|
|
|
const stream = result.stream
|
|
const fileName = path.extname(dest) ? dest : path.join(dest, `${title}.${result.type}`)
|
|
const writeStream = fs.createWriteStream(fileName)
|
|
stream.pipe(writeStream)
|
|
|
|
await new Promise<void>(resolve => stream.on("end", () => resolve()))
|
|
return fileName
|
|
}
|
|
|
|
/**
|
|
* Downloads a track on Soundcloud.
|
|
*/
|
|
public downloadTrack = async (trackResolvable: string | SoundcloudTrack, dest?: string) => {
|
|
if (!dest) dest = "./"
|
|
const folder = path.extname(dest) ? path.dirname(dest) : dest
|
|
if (!fs.existsSync(folder)) fs.mkdirSync(folder, {recursive: true})
|
|
const track = await this.resolveTrack(trackResolvable)
|
|
if (track.downloadable === true) {
|
|
try {
|
|
const downloadObj = await this.api.getV2(`/tracks/${track.id}/download`) as any
|
|
const result = await fetch(downloadObj.redirectUri)
|
|
dest = path.extname(dest) ? dest : path.join(dest, `${track.title.replace(/[\\/:*?\"<>|]/g, "")}.${result.headers["x-amz-meta-file-type"]}`)
|
|
const arrayBuffer = await result.arrayBuffer() as any
|
|
fs.writeFileSync(dest, Buffer.from(arrayBuffer, "binary"))
|
|
return dest
|
|
} catch {
|
|
return this.downloadTrackStream(track, track.title.replace(/[\\/:*?\"<>|]/g, ""), dest)
|
|
}
|
|
} else {
|
|
return this.downloadTrackStream(track, track.title.replace(/[\\/:*?\"<>|]/g, ""), dest)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Downloads an array of tracks.
|
|
*/
|
|
public downloadTracks = async (tracks: SoundcloudTrack[] | string[], dest?: string, limit?: number) => {
|
|
if (!limit) limit = tracks.length
|
|
const resultArray: string[] = []
|
|
for (let i = 0; i < limit; i++) {
|
|
try {
|
|
const result = await this.downloadTrack(tracks[i], dest)
|
|
resultArray.push(result)
|
|
} catch {
|
|
continue
|
|
}
|
|
}
|
|
return resultArray
|
|
}
|
|
|
|
/**
|
|
* Downloads all the tracks from the search query.
|
|
*/
|
|
public downloadSearch = async (query: string, dest?: string, limit?: number) => {
|
|
const tracks = await this.tracks.search({q: query})
|
|
return this.downloadTracks(tracks.collection, dest, limit)
|
|
}
|
|
|
|
/**
|
|
* Download all liked tracks by a user.
|
|
*/
|
|
public downloadLikes = async (userResolvable: string | number, dest?: string, limit?: number) => {
|
|
const tracks = await this.users.likes(userResolvable, limit)
|
|
return this.downloadTracks(tracks, dest, limit)
|
|
}
|
|
|
|
/**
|
|
* Downloads all the tracks in a playlist.
|
|
*/
|
|
public downloadPlaylist = async (playlistResolvable: string, dest?: string, limit?: number) => {
|
|
const playlist = await this.playlists.get(playlistResolvable)
|
|
return this.downloadTracks(playlist.tracks, dest, limit)
|
|
}
|
|
|
|
/**
|
|
* Returns a readable stream to the track.
|
|
*/
|
|
public streamTrack = async (trackResolvable: string | SoundcloudTrack) => {
|
|
const url = await this.streamLink(trackResolvable, "progressive")
|
|
if (!url) return this.m3uReadableStream(trackResolvable).then(r => r.stream)
|
|
const readable = await fetch(url, {headers: this.api.headers}).then((r) => r.body)
|
|
return this.webToNodeStream(readable) as Readable
|
|
}
|
|
|
|
/**
|
|
* Downloads a track's song cover.
|
|
*/
|
|
public downloadSongCover = async (trackResolvable: string | SoundcloudTrack, dest?: string, noDL?: boolean) => {
|
|
if (!dest) dest = "./"
|
|
const folder = dest
|
|
if (!fs.existsSync(folder)) fs.mkdirSync(folder, {recursive: true})
|
|
const track = await this.resolveTrack(trackResolvable)
|
|
const artwork = (track.artwork_url ? track.artwork_url : track.user.avatar_url).replace(".jpg", ".png").replace("-large", "-t500x500")
|
|
const title = track.title.replace(/[\\/:*?\"<>|]/g, "")
|
|
dest = path.extname(dest) ? dest : path.join(folder, `${title}.png`)
|
|
const client_id = await this.api.getClientId()
|
|
const url = `${artwork}?client_id=${client_id}`
|
|
if (noDL) return url
|
|
const arrayBuffer = await fetch(url).then(r => r.arrayBuffer())
|
|
fs.writeFileSync(dest, Buffer.from(arrayBuffer))
|
|
return dest
|
|
}
|
|
|
|
private static readonly removeDirectory = (dir: string) => {
|
|
if (!fs.existsSync(dir)) return
|
|
fs.readdirSync(dir).forEach(file => {
|
|
const current = path.join(dir, file)
|
|
if (fs.lstatSync(current).isDirectory()) {
|
|
Util.removeDirectory(current)
|
|
} else {
|
|
fs.unlinkSync(current)
|
|
}
|
|
})
|
|
try {
|
|
fs.rmdirSync(dir)
|
|
} catch (error) {
|
|
console.error(error)
|
|
}
|
|
}
|
|
} |