Flatlogic Bot ca3a32f23e V 4
2026-02-16 06:55:36 +00:00

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)
}
}
}