spotdl/index.js
2023-05-25 13:10:41 -05:00

334 lines
8.3 KiB
JavaScript

import fs from 'fs'
import {
ApplicationCommandTypes,
ApplicationCommandOptionTypes,
InteractionTypes,
Client
} from 'oceanic.js'
import Librespot from 'librespot'
import archiver from 'archiver'
import * as dotenv from 'dotenv'
import path from 'path'
import { spawn } from 'child_process'
import { formatPath, sanitiseFilename } from './utils.js'
import Filesdotgay from './filesdotgay.js'
dotenv.config()
const LOADING_EMOJI = process.env.LOADING_EMOJI ?? ''
function generateRandomId(length = 50) {
let chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let str = ''
for (let i = 0; i < length; i++) {
str += chars.charAt(Math.floor(Math.random() * chars.length))
}
return str
}
if (!fs.existsSync('rips')) fs.mkdirSync('rips')
const ripsPath = fs.realpathSync('rips')
const bot = new Client({
auth: 'Bot ' + process.env.DISCORD_TOKEN,
gateway: {
intents: ['GUILD_MESSAGES', 'DIRECT_MESSAGES', 'MESSAGE_CONTENT']
}
})
const filesdotgay = new Filesdotgay()
const spotify = new Librespot({
sessionOptions: {
handshakeOptions: {
product: 0
}
}
})
async function writeWithMetadata(track, downloadPathFormat, extension = 'mp3') {
let filepath = formatPath(downloadPathFormat, track.metadata, extension)
fs.mkdirSync(path.dirname(filepath), {
recursive: true
})
if (extension != 'mp3') {
return new Promise(resolve => {
let writeStream = fs.createWriteStream(filepath)
track.stream.pipe(writeStream)
track.stream.once('end', resolve)
})
}
let args = [
'-hide_banner',
'-loglevel',
'error',
'-thread_queue_size',
'1024',
'-i',
'-',
'-i',
track.metadata.album.coverArtwork[0].url,
'-map',
'0:a',
'-map',
'1:0',
'-c:1',
'copy',
// metadata
'-id3v2_version',
'3',
'-metadata:s:v',
'title=Album cover',
'-metadata:s:v',
'comment=Cover (front)',
'-metadata',
`title=${track.metadata.name}`,
'-metadata',
`artist=${track.metadata.artists[0].name}`,
'-metadata',
`album=${track.metadata.album.name}`,
'-metadata',
`album_artist=${track.metadata.album.artists[0].name}`,
'-metadata',
`track=${track.metadata.trackNumber}`,
'-metadata',
`disc=${track.metadata.discNumber}`,
// output
filepath
]
return new Promise((resolve, reject) => {
console.log('ffmpeg', ...args.map(e => `'${e}'`))
const ffmpegProc = spawn('ffmpeg', args)
track.stream.pipe(ffmpegProc.stdin)
ffmpegProc.stdout.pipe(process.stdout)
ffmpegProc.stderr.pipe(process.stderr)
ffmpegProc.once('exit', code => {
if (code == 1) reject()
resolve()
})
})
}
async function downloadAndUpdateMessage(
url,
interaction,
originalMessage,
ogg
) {
const item = await spotify.get.byUrl(url)
let metadata = {
title: item.name ?? item.title ?? item.metadata.name,
author:
item.owner?.name ??
item.publisher ??
(item.artists ?? item.metadata.artists).map(e => e.name).join(', ')
}
let itemName = `*${metadata.title}* by *${metadata.author}*`
let messageStartText = `Downloading ${itemName} ${LOADING_EMOJI}`
let randomId = generateRandomId()
const itemFolderPath = `${ripsPath}/${randomId}`
await fs.promises.mkdir(itemFolderPath)
await interaction.editFollowup(originalMessage.id, {
content: messageStartText
})
let outputPath
let friendlyFilename
const extension = ogg ? 'ogg' : 'mp3'
if (item.tracks) {
const hasMultipleDiscs =
(item.tracks[item.tracks.length - 1].discNumber ?? 1) != 1
const downloadFormat =
itemFolderPath +
(hasMultipleDiscs
? '/%disc %track %title - %artist.%ext'
: '/%track %title - %artist.%ext')
for (let i = 0; i < item.tracks.length; i++) {
await interaction.editFollowup(originalMessage.id, {
content: `${messageStartText}\n${item.tracks[i].name} by ${
item.tracks[i].artists[0].name
} [${i + 1}/${item.tracks.length}]`
})
await writeWithMetadata(
await spotify.get.track(item.tracks[i].id),
downloadFormat,
extension
)
await new Promise(r => setTimeout(r, 500))
}
await interaction.editFollowup(originalMessage.id, {
content: `Downloaded ${itemName}.\nCompressing to .zip file ${LOADING_EMOJI}`
})
outputPath = `${ripsPath}/${randomId}.zip`
friendlyFilename =
metadata.title.replace(/[^a-zA-z0-9 \-_]/gm, '-') + '.zip'
const output = fs.createWriteStream(outputPath)
const archive = archiver('zip', {
zlib: { level: 9 } // Sets the compression level.
})
archive.directory(itemFolderPath, false)
archive.pipe(output)
archive.finalize()
await new Promise((resolve, reject) => {
output.on('close', resolve)
archive.on('error', reject)
})
fs.promises.rm(itemFolderPath, { recursive: true })
} else {
const downloadFormat = itemFolderPath + '/%title - %artist.%ext'
await writeWithMetadata(item, downloadFormat, extension)
friendlyFilename = (await fs.promises.readdir(itemFolderPath))[0]
outputPath = itemFolderPath + '/' + friendlyFilename
}
const fileSizeMib = (await fs.promises.stat(outputPath)).size / 1048576
const humanFileSize = `${new Intl.NumberFormat().format(
Math.round(fileSizeMib * 10) / 10
)} MiB`
const useDiscordUpload = fileSizeMib <= 25
if (useDiscordUpload) {
await interaction.editFollowup(originalMessage.id, {
content: `Uploading ${itemName} (${humanFileSize}) ${LOADING_EMOJI}`
})
await interaction.editFollowup(originalMessage.id, {
content: itemName,
attachments: [
{
id: '0',
filename: friendlyFilename,
description: `${metadata.title} by ${metadata.author}`
}
],
files: [
{
name: 'file[0]',
filename: friendlyFilename,
contents: await fs.promises.readFile(outputPath)
}
]
})
} else {
interaction.editFollowup(originalMessage.id, {
content: `Uploading ${itemName} to files.gay (${humanFileSize}) ${LOADING_EMOJI}`
})
let uploadUrl
try {
uploadUrl = await filesdotgay.upload(
await fs.promises.readFile(outputPath),
sanitiseFilename(`${metadata.title} - ${metadata.author}.zip`)
)
} catch (err) {
interaction.editFollowup(originalMessage.id, {
content: `An error occurred while uploading: \`\`\`${err.message.replaceAll(
'`',
'\\`'
)}\`\`\``
})
}
interaction.editFollowup(originalMessage.id, {
content: `${itemName}\n${uploadUrl}`
})
}
await fs.promises.rm(outputPath)
}
bot.on('interactionCreate', async interaction => {
if (interaction.type !== InteractionTypes.APPLICATION_COMMAND) return
if (interaction.data.type !== ApplicationCommandTypes.CHAT_INPUT) return
await interaction.defer()
if (interaction.data.name === 'dl') {
try {
let url = interaction.data.options.getString('url', true)
let ogg = interaction.data.options.getBoolean('ogg') ?? false
let urlObj
try {
urlObj = new URL(url)
} catch (error) {
await interaction.createFollowup({
content: 'Your second argument is not a valid URL!'
})
return
}
if (!url.host === 'open.spotify.com') {
await interaction.createFollowup({
content: 'Your second argument is not a Spotify URL!'
})
return
}
urlObj.search = ''
url = urlObj.href
let originalMessage = await interaction.createFollowup({
content: 'Loading...'
})
try {
await downloadAndUpdateMessage(
urlObj.href,
interaction,
originalMessage,
ogg
)
} catch (err) {
console.log(err)
await interaction.editFollowup(originalMessage.id, {
content: `Error occured: ${err.message}`
})
}
} catch (error) {
console.log(error)
}
}
})
bot.connect()
bot.on('ready', async () => {
console.log('Logged into Discord')
await spotify.login(
process.env.SPOTIFY_USERNAME,
process.env.SPOTIFY_PASSWORD
)
console.log('Logged into Spotify')
await filesdotgay.login(
process.env.FILES_GAY_USER,
process.env.FILES_GAY_PASS
)
console.log('Logged into files.gay')
await bot.application.bulkEditGlobalCommands([
{
type: ApplicationCommandTypes.CHAT_INPUT,
name: 'dl',
dmPermission: true,
description: 'Download',
defaultMemberPermissions: '2048',
options: [
{
type: ApplicationCommandOptionTypes.STRING,
name: 'url',
description: 'The URL of the Spotify resource',
required: true
},
{
type: ApplicationCommandOptionTypes.BOOLEAN,
name: 'ogg',
description: 'Use OGG format'
}
]
}
])
console.log('Ready as', bot.user.tag)
})
bot.on('error', err => {
console.error(err)
})