From ad87d3fbb81df478c8ca66429fa82da92e13242f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20Kore=C5=84?= Date: Sun, 13 Sep 2026 17:03:02 +0200 Subject: [PATCH] Add an overlay soundboard and fix playback command bugs. Stop was advancing the queue, history overwrote the day file, and slash commands often never replied; soundboard clips now mix on top of the current track. Co-authored-by: Cursor --- docker-compose.yml | 1 + src/commands/forceplay.ts | 20 ++-- src/commands/index.ts | 2 + src/commands/loop.ts | 8 +- src/commands/pause.ts | 1 + src/commands/play.ts | 12 +- src/commands/resume.ts | 1 + src/commands/skip.ts | 6 +- src/commands/soundboard.ts | 132 ++++++++++++++++++++++ src/commands/stop.ts | 1 + src/global.d.ts | 9 ++ src/main.ts | 31 +++++- src/messages.ts | 20 +--- src/playback.ts | 96 ++++++++++++---- src/soundboard.ts | 221 +++++++++++++++++++++++++++++++++++++ src/util/downloader.ts | 76 +++++++------ src/util/helpers.ts | 61 +++++----- src/util/history.ts | 20 +++- src/util/pcmMixer.ts | 116 +++++++++++++++++++ 19 files changed, 708 insertions(+), 126 deletions(-) create mode 100644 src/commands/soundboard.ts create mode 100644 src/soundboard.ts create mode 100644 src/util/pcmMixer.ts diff --git a/docker-compose.yml b/docker-compose.yml index dd41232..ef0cc27 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: restart: always volumes: - ./data:/app/data + - ./history:/app/history env_file: - .env environment: diff --git a/src/commands/forceplay.ts b/src/commands/forceplay.ts index 0067912..aecc2ac 100644 --- a/src/commands/forceplay.ts +++ b/src/commands/forceplay.ts @@ -1,8 +1,8 @@ import { CacheType, ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js'; import { connectToChannelByInteraction } from '../util/helpers'; import { forceRequestSong } from '../playback'; -import { getPlayMsg, msg_downloading, msg_play, msg_searching } from '../messages'; -import { search } from '../util/downloader'; +import { msg_downloading, msg_play, msg_searching } from '../messages'; +import { search, spotifyToYouTube } from '../util/downloader'; const name = "forceplay" @@ -18,12 +18,15 @@ function register() { async function execute(interaction: ChatInputCommandInteraction) { try { await interaction.deferReply() - connectToChannelByInteraction(interaction) + await connectToChannelByInteraction(interaction) const input = interaction.options.getString("url")!; let url: string; - if (input.startsWith("http")) { + if (input.startsWith("https://open.spotify.com/")) { + await interaction.editReply(msg_searching(input)); + url = await spotifyToYouTube(input) + } else if (input.startsWith("http")) { url = input } else { await interaction.editReply(msg_searching(input)); @@ -32,13 +35,12 @@ async function execute(interaction: ChatInputCommandInteraction) { } await interaction.editReply(msg_downloading(url)); - forceRequestSong(url, { user: interaction.user.username }).then(() => { - // const msg = getPlayMsg(url) - interaction.editReply(msg_play()); - }) + await forceRequestSong(url, { user: interaction.user.username }) + await interaction.editReply(msg_play()); } catch (error) { console.error(error); - await interaction.editReply('Coś poszło nie tak :/'); + const message = error instanceof Error ? error.message : 'Coś poszło nie tak :/' + await interaction.editReply(message); } } diff --git a/src/commands/index.ts b/src/commands/index.ts index a0b3168..d14565a 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -8,6 +8,7 @@ import resume from "./resume" import skip from "./skip" import stop from "./stop" import loop from "./loop" +import soundboard from "./soundboard" export const commands: { [key: string]: Command } = { ping, @@ -20,5 +21,6 @@ export const commands: { [key: string]: Command } = { pause, skip, loop, + soundboard, } diff --git a/src/commands/loop.ts b/src/commands/loop.ts index 464e809..ced51cb 100644 --- a/src/commands/loop.ts +++ b/src/commands/loop.ts @@ -16,14 +16,18 @@ async function execute(interaction: ChatInputCommandInteraction) { await interaction.deferReply() const audio = toggleLoop() if (audio) { - await interaction.editReply(`looped ${formatFilePath(audio.url)}`); + await interaction.editReply(`looped ${formatFilePath(audio.path)}`); } else { await interaction.editReply(`Loop turned off`); } } catch (error) { console.error(error); - await interaction.reply('Coś poszło nie tak :/'); + if (interaction.deferred || interaction.replied) { + await interaction.editReply('Coś poszło nie tak :/'); + } else { + await interaction.reply('Coś poszło nie tak :/'); + } } } diff --git a/src/commands/pause.ts b/src/commands/pause.ts index f11e710..f037e64 100644 --- a/src/commands/pause.ts +++ b/src/commands/pause.ts @@ -14,6 +14,7 @@ function register() { async function execute(interaction: ChatInputCommandInteraction) { try { pause_playback(player) + await interaction.reply('Pauza') } catch (error) { console.error(error); await interaction.reply('Coś poszło nie tak :/'); diff --git a/src/commands/play.ts b/src/commands/play.ts index 1a2b803..9d439a2 100644 --- a/src/commands/play.ts +++ b/src/commands/play.ts @@ -1,7 +1,7 @@ import { CacheType, ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js'; import { connectToChannelByInteraction } from '../util/helpers'; import { requestSong } from '../playback'; -import { getPlayMsg, msg_downloading, msg_play, msg_searching } from '../messages'; +import { msg_downloading, msg_play, msg_searching } from '../messages'; import { search, spotifyToYouTube } from '../util/downloader'; const name = "play" @@ -18,7 +18,7 @@ function register() { async function execute(interaction: ChatInputCommandInteraction) { try { await interaction.deferReply() - connectToChannelByInteraction(interaction) + await connectToChannelByInteraction(interaction) const input = interaction.options.getString("url")!; let url: string; @@ -36,12 +36,12 @@ async function execute(interaction: ChatInputCommandInteraction) { } await interaction.editReply(msg_downloading(url)); - requestSong(url, { user: interaction.user.username }).then(() => { - interaction.editReply(msg_play()); - }) + await requestSong(url, { user: interaction.user.username }) + await interaction.editReply(msg_play()); } catch (error) { console.error(error); - await interaction.editReply('Coś poszło nie tak :/'); + const message = error instanceof Error ? error.message : 'Coś poszło nie tak :/' + await interaction.editReply(message); } } diff --git a/src/commands/resume.ts b/src/commands/resume.ts index 577006b..b729a7f 100644 --- a/src/commands/resume.ts +++ b/src/commands/resume.ts @@ -14,6 +14,7 @@ function register() { async function execute(interaction: ChatInputCommandInteraction) { try { resume_playback(player) + await interaction.reply('Leci dalej') } catch (error) { console.error(error); await interaction.reply('Coś poszło nie tak :/'); diff --git a/src/commands/skip.ts b/src/commands/skip.ts index 352b55c..ec73265 100644 --- a/src/commands/skip.ts +++ b/src/commands/skip.ts @@ -17,7 +17,11 @@ async function execute(interaction: ChatInputCommandInteraction) { await interaction.editReply(msg_skipped(skipped)); } catch (error) { console.error(error); - await interaction.reply('Coś poszło nie tak :/'); + if (interaction.deferred || interaction.replied) { + await interaction.editReply('Coś poszło nie tak :/'); + } else { + await interaction.reply('Coś poszło nie tak :/'); + } } } diff --git a/src/commands/soundboard.ts b/src/commands/soundboard.ts new file mode 100644 index 0000000..9fcaaff --- /dev/null +++ b/src/commands/soundboard.ts @@ -0,0 +1,132 @@ +import { + AutocompleteInteraction, + CacheType, + ChatInputCommandInteraction, + SlashCommandBuilder, +} from 'discord.js'; +import { + addSound, + buildSoundboardMessage, + listSounds, + playById, + removeSound, +} from '../soundboard'; + +const name = "soundboard" + +function register() { + return new SlashCommandBuilder() + .setName(name) + .setDescription('Soundboard — dźwięki na wierzchu muzyki') + .addSubcommand((sub) => sub + .setName("add") + .setDescription("Dodaj dźwięk (max 10s)") + .addAttachmentOption((option) => option + .setName("file") + .setDescription("Krótki plik audio") + .setRequired(true)) + .addStringOption((option) => option + .setName("description") + .setDescription("Krótki opis na przycisku") + .setRequired(true) + .setMaxLength(40)) + .addStringOption((option) => option + .setName("emoji") + .setDescription("Emoji do przycisku") + .setRequired(true) + .setMaxLength(32))) + .addSubcommand((sub) => sub + .setName("list") + .setDescription("Pokaż soundboard do klikania")) + .addSubcommand((sub) => sub + .setName("play") + .setDescription("Puść dźwięk z listy") + .addStringOption((option) => option + .setName("sound") + .setDescription("Który dźwięk") + .setRequired(true) + .setAutocomplete(true))) + .addSubcommand((sub) => sub + .setName("remove") + .setDescription("Usuń dźwięk") + .addStringOption((option) => option + .setName("sound") + .setDescription("Który dźwięk") + .setRequired(true) + .setAutocomplete(true))) +} + +export async function handleSoundboardAutocomplete(interaction: AutocompleteInteraction) { + const focused = interaction.options.getFocused().toLowerCase(); + const choices = listSounds() + .filter((sound) => + sound.description.toLowerCase().includes(focused) || + sound.emoji.includes(focused) || + sound.id.startsWith(focused) + ) + .slice(0, 25) + .map((sound) => ({ + name: `${sound.emoji} ${sound.description}`.slice(0, 100), + value: sound.id, + })); + await interaction.respond(choices); +} + +async function execute(interaction: ChatInputCommandInteraction) { + const sub = interaction.options.getSubcommand(); + try { + if (sub === "add") { + await interaction.deferReply(); + const file = interaction.options.getAttachment("file", true); + const description = interaction.options.getString("description", true); + const emoji = interaction.options.getString("emoji", true); + const sound = await addSound({ + attachment: file, + description, + emoji, + user: interaction.user.username, + }); + await interaction.editReply(`Dodane: ${sound.emoji} **${sound.description}**`); + return; + } + + if (sub === "list") { + await interaction.reply(buildSoundboardMessage(0)); + return; + } + + if (sub === "play") { + await interaction.deferReply({ ephemeral: true }); + const id = interaction.options.getString("sound", true); + const sound = await playById(id, interaction); + await interaction.editReply(`${sound.emoji} ${sound.description}`); + return; + } + + if (sub === "remove") { + await interaction.deferReply({ ephemeral: true }); + const id = interaction.options.getString("sound", true); + const removed = removeSound(id); + if (!removed) { + await interaction.editReply("Nie ma takiego dźwięku"); + return; + } + await interaction.editReply(`Usunięte: ${removed.emoji} ${removed.description}`); + return; + } + } catch (error) { + console.error(error); + const message = error instanceof Error ? error.message : "Coś poszło nie tak :/"; + if (interaction.deferred || interaction.replied) { + await interaction.editReply(message); + } else { + await interaction.reply({ content: message, ephemeral: true }); + } + } +} + +export default { + name, + register, + execute +} diff --git a/src/commands/stop.ts b/src/commands/stop.ts index 8ce374e..3273106 100644 --- a/src/commands/stop.ts +++ b/src/commands/stop.ts @@ -14,6 +14,7 @@ function register() { async function execute(interaction: ChatInputCommandInteraction) { try { stop_playback(player) + await interaction.reply('Zatrzymane, kolejka wyczyszczona') } catch (error) { console.error(error); await interaction.reply('Coś poszło nie tak :/'); diff --git a/src/global.d.ts b/src/global.d.ts index 3b92b23..dd40cb9 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -20,3 +20,12 @@ type HistoryObject = { url?: string, } +type SoundboardSound = { + id: string + description: string + emoji: string + path: string + user: string + createdAt: number +} + diff --git a/src/main.ts b/src/main.ts index 4f78e28..25bc100 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,8 +3,9 @@ import dotenv from 'dotenv'; dotenv.config() import { Client, Events, GatewayIntentBits, MessageFlags, REST, Routes } from 'discord.js'; import { commands } from "./commands"; -import { AudioPlayerState, createAudioPlayer } from '@discordjs/voice'; -import { connectToChannel, playSong } from './util/helpers'; +import { handleSoundboardAutocomplete } from "./commands/soundboard"; +import { handleSoundboardButton } from "./soundboard"; +import { AudioPlayerStatus, AudioPlayerState, createAudioPlayer } from '@discordjs/voice'; import { updatePlayer } from './playback'; import SpotifyWebApi from 'spotify-web-api-node'; @@ -13,8 +14,10 @@ export const HISTORY_DIR_PATH = process.env.HISTORY_DIR_PATH ?? "./history"; // AUDIO export const player = createAudioPlayer(); -player.on('stateChange', (oldState: AudioPlayerState, newState: AudioPlayerState) => { - updatePlayer() +player.on('stateChange', (_oldState: AudioPlayerState, newState: AudioPlayerState) => { + if (newState.status === AudioPlayerStatus.Idle) { + updatePlayer() + } }); @@ -62,6 +65,26 @@ async function registerCommands() { client.login(process.env.DISCORD_TOKEN!); client.on(Events.InteractionCreate, async (interaction) => { + if (interaction.isButton() && (interaction.customId.startsWith("sbplay:") || interaction.customId.startsWith("sbpage:"))) { + try { + await handleSoundboardButton(interaction); + } catch (error) { + console.error(error); + } + return; + } + + if (interaction.isAutocomplete()) { + if (interaction.commandName === "soundboard") { + try { + await handleSoundboardAutocomplete(interaction); + } catch (error) { + console.error(error); + } + } + return; + } + if (!interaction.isChatInputCommand()) return; const cmd = commands[interaction.commandName]; if (!cmd) { diff --git a/src/messages.ts b/src/messages.ts index 4d5f098..4ae9ded 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -1,22 +1,10 @@ -import { EmbedBuilder, InteractionEditReplyOptions, InteractionReplyOptions } from "discord.js"; +import { EmbedBuilder, InteractionEditReplyOptions } from "discord.js"; import { getQueue } from "./playback"; import { PRIMARY_COLOR } from "./theme"; import { formatFilePath } from "./util/downloader"; const REPO_URL = "https://gitea.papryk.com/Papryk/dj-spangebob" -export function getPlayMsg(url: string): string { - return `TODO-3` -} - -export function getCurrentSongMsg(): string { - return 'TODO-1' -} -export function getQueueMsg(): string { - return `TODO-2` - -} - export function msg_searching(input: string): InteractionEditReplyOptions { return { embeds: [ @@ -64,10 +52,14 @@ export function msg_help() { return { embeds: [ new EmbedBuilder() - .setDescription("") .setTitle("🎵 DJ-SPANDŹBOB 🎵") .setColor(PRIMARY_COLOR) .addFields( + { name: "/play", value: "URL albo wyszukiwanie (YouTube, Spotify track, SoundCloud)" }, + { name: "/forceplay", value: "Przerwij i zagraj teraz" }, + { name: "/queue", value: "Co leci i kolejka" }, + { name: "/skip /pause /resume /stop /loop", value: "Sterowanie odtwarzaniem" }, + { name: "/soundboard", value: "add / list / play / remove — dźwięki na wierzchu utworu" }, { name: "Repo", value: REPO_URL }, ) .setTimestamp() diff --git a/src/playback.ts b/src/playback.ts index 8b8e836..846a180 100644 --- a/src/playback.ts +++ b/src/playback.ts @@ -1,8 +1,7 @@ import { AudioPlayer, AudioPlayerStatus } from "@discordjs/voice"; import { player } from "./main"; -import { CacheType, ChatInputCommandInteraction } from "discord.js"; import { getAudioFile } from "./util/downloader"; -import { playSong } from "./util/helpers"; +import { playOverlay, playSong, destroyMixer } from "./util/helpers"; import { add_to_history } from "./util/history"; const queue: Queue = { @@ -11,6 +10,9 @@ const queue: Queue = { loop: null, } +let advancing = false; +let soundboardSolo = false; + export function toggleLoop(): AudioFile | null { if (queue.loop) queue.loop = null else queue.loop = queue.current @@ -31,42 +33,88 @@ export async function forceRequestSong( url: string, data: Partial ) { - const path = await getAudioFile(url) const audio = { path, url } - queue.songList.push(audio) add_to_history({ url, user: data.user }) - queue.current = audio - playSong(player, path); + advancing = true + try { + queue.current = audio + if (queue.loop) queue.loop = audio + await playSong(player, path) + } finally { + advancing = false + } } +export async function playSoundboardFile(filePath: string): Promise<"overlay" | "solo"> { + const status = player.state.status + if (status === AudioPlayerStatus.Paused) { + throw new Error("Nie da się puścić soundboardu na pauzie — najpierw /resume") + } + if ( + (status === AudioPlayerStatus.Playing || status === AudioPlayerStatus.Buffering) && + playOverlay(filePath) + ) { + return "overlay"; + } + soundboardSolo = true; + advancing = true; + try { + await playSong(player, filePath); + } finally { + advancing = false; + } + return "solo"; +} export async function updatePlayer() { - if (player.state.status === AudioPlayerStatus.Idle) { + if (advancing) return + if (soundboardSolo) { + soundboardSolo = false + } + if (player.state.status !== AudioPlayerStatus.Idle) return + + advancing = true + try { const nextSong = queue.loop ?? queue.songList.shift() if (!nextSong) { queue.current = null return - }; - queue.current = nextSong; - playSong(player, nextSong.path); + } + queue.current = nextSong + await playSong(player, nextSong.path) + } finally { + advancing = false } } export function skip_song(): AudioFile | null { - if (player.state.status === AudioPlayerStatus.Playing) { - const skipped = queue.current - const nextSong = queue.songList.shift() - if (!nextSong) { - queue.current = null - player.stop() - return null - }; - queue.current = nextSong; - playSong(player, nextSong.path); + const skipped = queue.current + const isActive = + player.state.status === AudioPlayerStatus.Playing || + player.state.status === AudioPlayerStatus.Paused || + player.state.status === AudioPlayerStatus.Buffering + + if (!skipped && !isActive) return null + + if (queue.loop && skipped && queue.loop.path === skipped.path) { + queue.loop = null + } + + const nextSong = queue.songList.shift() + if (!nextSong) { + queue.current = null + destroyMixer(); + player.stop() return skipped } - return null + + advancing = true + queue.current = nextSong + playSong(player, nextSong.path).finally(() => { + advancing = false + }) + return skipped } export function pause_playback(player: AudioPlayer) { @@ -74,7 +122,11 @@ export function pause_playback(player: AudioPlayer) { } export function stop_playback(player: AudioPlayer) { - player.stop() + queue.songList = [] + queue.current = null + queue.loop = null + destroyMixer(); + player.stop(true) } export function resume_playback(player: AudioPlayer) { diff --git a/src/soundboard.ts b/src/soundboard.ts new file mode 100644 index 0000000..dd4bb38 --- /dev/null +++ b/src/soundboard.ts @@ -0,0 +1,221 @@ +import fs from "node:fs"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { + ActionRowBuilder, + Attachment, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + CacheType, + ChatInputCommandInteraction, + EmbedBuilder, + MessageFlags, + parseEmoji, +} from "discord.js"; +import { DATA_DIR } from "./main"; +import { connectToChannelByInteraction } from "./util/helpers"; +import { playSoundboardFile } from "./playback"; +import { PRIMARY_COLOR } from "./theme"; + +export const SOUNDBOARD_DIR = path.join(DATA_DIR, "soundboard"); +const INDEX_PATH = path.join(SOUNDBOARD_DIR, "index.json"); +const MAX_SECONDS = 10; +const SOUNDS_PER_PAGE = 20; +const ALLOWED_EXT = new Set(["mp3", "wav", "ogg", "opus", "m4a", "webm", "flac", "aac", "mp4"]); + +function ensureDir() { + if (!fs.existsSync(SOUNDBOARD_DIR)) { + fs.mkdirSync(SOUNDBOARD_DIR, { recursive: true }); + } +} + +export function listSounds(): SoundboardSound[] { + ensureDir(); + if (!fs.existsSync(INDEX_PATH)) return []; + try { + const parsed = JSON.parse(fs.readFileSync(INDEX_PATH, "utf8")); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function saveSounds(sounds: SoundboardSound[]) { + ensureDir(); + fs.writeFileSync(INDEX_PATH, JSON.stringify(sounds, null, 2)); +} + +function probeDuration(filePath: string): Promise { + return new Promise((resolve, reject) => { + const p = spawn("ffprobe", [ + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + filePath, + ]); + let out = ""; + let err = ""; + p.stdout.on("data", (d) => out += d.toString()); + p.stderr.on("data", (d) => err += d.toString()); + p.on("close", (code) => { + const n = parseFloat(out.trim()); + if (code === 0 && Number.isFinite(n)) resolve(n); + else reject(new Error(err.trim() || "Nie udało się odczytać długości pliku")); + }); + }); +} + +export async function addSound(opts: { + attachment: Attachment + description: string + emoji: string + user: string +}): Promise { + const description = opts.description.trim().slice(0, 40); + const emoji = opts.emoji.trim().slice(0, 32); + if (!description) throw new Error("Opis nie może być pusty"); + if (!emoji) throw new Error("Podaj emoji"); + + const urlName = (opts.attachment.name ?? "sound").split("?")[0]; + const ext = (urlName.split(".").pop() ?? "").toLowerCase(); + const contentType = opts.attachment.contentType ?? ""; + if (!ALLOWED_EXT.has(ext) && !contentType.startsWith("audio/") && !contentType.startsWith("video/")) { + throw new Error("Wrzuć plik audio (mp3, wav, ogg, opus, m4a, webm, flac)"); + } + + const id = randomBytes(4).toString("hex"); + const filename = `${id}.${ext || "ogg"}`; + ensureDir(); + const dest = path.join(SOUNDBOARD_DIR, filename); + + const res = await fetch(opts.attachment.url); + if (!res.ok) throw new Error("Nie udało się pobrać załącznika"); + fs.writeFileSync(dest, Buffer.from(await res.arrayBuffer())); + + try { + const duration = await probeDuration(dest); + if (duration > MAX_SECONDS) { + throw new Error(`Soundboard max ${MAX_SECONDS}s (ten plik ma ${duration.toFixed(1)}s)`); + } + } catch (error) { + if (fs.existsSync(dest)) fs.unlinkSync(dest); + throw error; + } + + const sound: SoundboardSound = { + id, + description, + emoji, + path: dest, + user: opts.user, + createdAt: Date.now(), + }; + const sounds = listSounds(); + sounds.push(sound); + saveSounds(sounds); + return sound; +} + +export function removeSound(id: string): SoundboardSound | null { + const sounds = listSounds(); + const index = sounds.findIndex((s) => s.id === id); + if (index < 0) return null; + const [removed] = sounds.splice(index, 1); + saveSounds(sounds); + if (removed && fs.existsSync(removed.path)) { + fs.unlinkSync(removed.path); + } + return removed ?? null; +} + +function buttonEmoji(emoji: string) { + const parsed = parseEmoji(emoji); + if (parsed?.id) return parsed.id; + return parsed?.name || emoji || "🔊"; +} + +export function buildSoundboardMessage(page = 0) { + const sounds = listSounds(); + const maxPage = Math.max(0, Math.ceil(sounds.length / SOUNDS_PER_PAGE) - 1); + const currentPage = Math.min(Math.max(0, page), maxPage); + const slice = sounds.slice(currentPage * SOUNDS_PER_PAGE, (currentPage + 1) * SOUNDS_PER_PAGE); + + const embed = new EmbedBuilder() + .setTitle("🔊 Soundboard") + .setColor(PRIMARY_COLOR) + .setDescription( + sounds.length + ? "Kliknij przycisk — dźwięk leci **na wierzchu** aktualnego utworu." + : "Pusto. Dodaj coś przez `/soundboard add`." + ) + .setFooter({ + text: sounds.length + ? `${sounds.length} dźwięków · strona ${currentPage + 1}/${maxPage + 1}` + : "Brak dźwięków", + }); + + const rows: ActionRowBuilder[] = []; + for (let i = 0; i < slice.length; i += 5) { + const row = new ActionRowBuilder(); + for (const sound of slice.slice(i, i + 5)) { + const button = new ButtonBuilder() + .setCustomId(`sbplay:${sound.id}`) + .setLabel(sound.description) + .setStyle(ButtonStyle.Secondary); + try { + button.setEmoji(buttonEmoji(sound.emoji)); + } catch { + button.setEmoji("🔊"); + } + row.addComponents(button); + } + rows.push(row); + } + + if (sounds.length > SOUNDS_PER_PAGE) { + rows.push(new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`sbpage:${currentPage - 1}`) + .setLabel("◀") + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage <= 0), + new ButtonBuilder() + .setCustomId(`sbpage:${currentPage + 1}`) + .setLabel("▶") + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage >= maxPage), + )); + } + + return { embeds: [embed], components: rows }; +} + +export async function playById(id: string, interaction: ChatInputCommandInteraction | ButtonInteraction) { + const sound = listSounds().find((s) => s.id === id); + if (!sound) throw new Error("Nie ma takiego dźwięku"); + if (!fs.existsSync(sound.path)) throw new Error("Plik dźwięku zniknął z dysku"); + await connectToChannelByInteraction(interaction); + await playSoundboardFile(sound.path); + return sound; +} + +export async function handleSoundboardButton(interaction: ButtonInteraction) { + if (interaction.customId.startsWith("sbpage:")) { + const page = Number(interaction.customId.slice("sbpage:".length)); + await interaction.update(buildSoundboardMessage(Number.isFinite(page) ? page : 0)); + return; + } + if (!interaction.customId.startsWith("sbplay:")) return; + + const id = interaction.customId.slice("sbplay:".length); + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + try { + const sound = await playById(id, interaction); + await interaction.editReply(`${sound.emoji} ${sound.description}`); + } catch (error) { + const message = error instanceof Error ? error.message : "Coś poszło nie tak :/"; + await interaction.editReply(message); + } +} diff --git a/src/util/downloader.ts b/src/util/downloader.ts index fe52669..f0969fd 100644 --- a/src/util/downloader.ts +++ b/src/util/downloader.ts @@ -1,9 +1,17 @@ import { spawn } from "node:child_process"; +import fs from "node:fs"; import { DATA_DIR, spotify } from "../main"; import ytSearch from 'yt-search'; +function ytDlpBin() { + return process.env.YT_DLP_BIN_PATH || "yt-dlp"; +} export async function getAudioFile(url: string): Promise { + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + } + const id = await extractId(url) console.log(`ID: ${id}`); @@ -18,38 +26,27 @@ export async function getAudioFile(url: string): Promise { return path } -function isYTLink(url: string) { - return url.startsWith("https://youtube") || - url.startsWith("https://youtu.be"); -} - export async function extractId(url: string): Promise { - // if (!isYTLink(url)) { - const ytDlpBin = process.env.YT_DLP_BIN_PATH! ?? "yt-dlp"; return await new Promise((resolve, reject) => { - const p = spawn(ytDlpBin, [ + const p = spawn(ytDlpBin(), [ "--print", "%(id)s", + "--no-playlist", url, ]); let out = "" p.stderr.on("data", d => process.stderr.write(d)); p.stdout.on("data", d => out += d.toString()); - p.on("close", code => code === 0 ? resolve(out.trim()) : reject(new Error("yt-dlp extract id failed"))); + p.on("close", code => { + const id = out.trim().split("\n")[0]?.trim(); + code === 0 && id ? resolve(id) : reject(new Error("yt-dlp extract id failed")); + }); }); - - // } else { - // const idMatch = /[?&]v=([a-zA-Z0-9_-]{11})/.exec(url); - // if (!idMatch) throw new Error("Cannot extract video ID"); - // const id = idMatch[1]; - // return id - // } } export async function downloadAudio(url: string): Promise { - const ytDlpBin = process.env.YT_DLP_BIN_PATH! ?? "yt-dlp"; await new Promise((resolve, reject) => { - const p = spawn(ytDlpBin, [ + const p = spawn(ytDlpBin(), [ "-x", // "--audio-format", "opus", "--audio-quality", "0", @@ -83,10 +80,11 @@ export async function findFileById(id: string): Promise { find.stderr.on("data", d => process.stderr.write(d)); find.on("close", code => { - if (code !== 0 || !out.trim()) { + const first = out.trim().split("\n").find(line => line.trim()); + if (code !== 0 || !first) { reject(new Error("Audio file not found")); } else { - resolve(out.trim()); + resolve(first.trim()); } }); }); @@ -94,12 +92,11 @@ export async function findFileById(id: string): Promise { export async function search(input: string) { return await new Promise((resolve, reject) => { - const ytDlpBin = process.env.YT_DLP_BIN_PATH! ?? "yt-dlp"; console.log(`search for ${input}`) - const search_url = spawn(ytDlpBin, [ + const search_url = spawn(ytDlpBin(), [ "--skip-download", "--print", "%(webpage_url)s", - `ytsearch: ${input}` + `ytsearch1:${input}` ]); let out = ""; @@ -108,35 +105,42 @@ export async function search(input: string) { search_url.stderr.on("data", d => process.stderr.write(d)); search_url.on("close", code => { - if (code !== 0 || !out.trim()) { + const found = out.trim().split("\n")[0]?.trim(); + if (code !== 0 || !found) { reject(new Error("Search failed")); } else { - resolve(out.trim()); + resolve(found); } }); }); } -export function formatFilePath(path: string): string { - return path.replace("/app/data/", "") - .replace(/\[.*\].opus/, "") +export function formatFilePath(filePath: string): string { + const base = filePath.split("/").pop() ?? filePath; + return base + .replace(/\s*\[[^\]]*\]\.[^.]+$/, "") + .replace(/\.[^.]+$/, "") .trim(); } export async function spotifyToYouTube(spotifyUrl: string) { const data = await spotify.clientCredentialsGrant() - // console.log(data, { - // clientId: process.env.SPOTIFY_CLIENT_ID, - // clientSecret: process.env.SPOTIFY_CLIENT_SECRET, - // }, spotify) spotify.setAccessToken(data.body.access_token) - - const trackId = spotifyUrl.split('/track/')[1].split('?')[0]; + const trackPart = spotifyUrl.split('/track/')[1]; + if (!trackPart) { + throw new Error("Only Spotify track links are supported"); + } + const trackId = trackPart.split('?')[0]; const track = await spotify.getTrack(trackId); - const query = `${track.body.name} ${track.body.artists[0].name}`; + const artist = track.body.artists[0]?.name ?? ""; + const query = `${track.body.name} ${artist}`.trim(); const result = await ytSearch(query); + const video = result.videos[0]; + if (!video) { + throw new Error(`No YouTube result for ${query}`); + } - return result.videos[0].url; + return video.url; } diff --git a/src/util/helpers.ts b/src/util/helpers.ts index 17da895..c8bb735 100644 --- a/src/util/helpers.ts +++ b/src/util/helpers.ts @@ -8,15 +8,34 @@ import { type AudioPlayer, } from '@discordjs/voice'; import type { CacheType, ChatInputCommandInteraction, VoiceBasedChannel } from 'discord.js'; +import { ButtonInteraction, GuildMember } from 'discord.js'; import { createDiscordJSAdapter } from './adapter.js'; import { player } from '../main.js'; +import { PcmMixer } from './pcmMixer'; -export async function connectToChannelByInteraction(interaction: ChatInputCommandInteraction): Promise { - const member = await interaction.guild?.members.fetch(interaction.user.id); - if (!member) throw new Error("ale chuj") - const voiceChannel = member.voice.channel; - if (!voiceChannel) throw new Error("ale chuj 2") - const connection = await connectToChannel(voiceChannel); +let currentMixer: PcmMixer | null = null; + +export function destroyMixer() { + currentMixer?.destroy(); + currentMixer = null; +} + +export function playOverlay(filePath: string): boolean { + if (!currentMixer?.alive) return false; + currentMixer.addOverlay(filePath); + return true; +} + +export async function connectToChannelByInteraction( + interaction: ChatInputCommandInteraction | ButtonInteraction +): Promise { + const member = interaction.member instanceof GuildMember + ? interaction.member + : await interaction.guild?.members.fetch(interaction.user.id); + if (!member) throw new Error("Nie znaleziono użytkownika na serwerze") + const voiceChannel = member.voice.channel; + if (!voiceChannel) throw new Error("Wejdź na kanał głosowy") + const connection = await connectToChannel(voiceChannel); connection.subscribe(player); } @@ -62,30 +81,16 @@ export async function connectToChannel(channel: VoiceBasedChannel) { export async function playSong(player: AudioPlayer, songUrl: string) { try { - /** - * Here we are creating an audio resource using a sample song freely available online - * (see https://www.soundhelix.com/audio-examples) - * - * We specify an arbitrary inputType. This means that we aren't too sure what the format of - * the input is, and that we'd like to have this converted into a format we can use. If we - * were using an Ogg or WebM source, then we could change this value. However, for now we - * will leave this as arbitrary. - */ - const resource = createAudioResource(songUrl, { inputType: StreamType.Arbitrary }); - - /** - * We will now play this to the audio player. By default, the audio player will not play until - * at least one voice connection is subscribed to it, so it is fine to attach our resource to the - * audio player this early. - */ + const mixer = new PcmMixer(); + mixer.setMain(songUrl); + const resource = createAudioResource(mixer.output, { inputType: StreamType.Raw }); + const previous = currentMixer; + currentMixer = mixer; player.play(resource); - - /** - * Here we are using a helper function. It will resolve if the player enters the Playing - * state within 5 seconds, otherwise it will reject with an error. - */ - return entersState(player, AudioPlayerStatus.Playing, 5_000); + previous?.destroy(); + return entersState(player, AudioPlayerStatus.Playing, 10_000); } catch (e) { console.error(e) + throw e } } diff --git a/src/util/history.ts b/src/util/history.ts index 27ac3f0..b28ecef 100644 --- a/src/util/history.ts +++ b/src/util/history.ts @@ -14,16 +14,28 @@ export async function add_to_history(data: HistoryObject) { const file_path = path.join(HISTORY_DIR_PATH, file_name) if (!fs.existsSync(HISTORY_DIR_PATH)) { - fs.mkdirSync(HISTORY_DIR_PATH); + fs.mkdirSync(HISTORY_DIR_PATH, { recursive: true }); } - let history = { + let entries: Array = []; + if (fs.existsSync(file_path)) { + try { + const parsed = JSON.parse(fs.readFileSync(file_path, "utf8")); + entries = Array.isArray(parsed) ? parsed : [parsed]; + } catch { + entries = []; + } + } + + entries.push({ timestamp: today.getTime(), url: data.url, user: data.user, - }; + }); - fs.writeFileSync(file_path, JSON.stringify(history)); + fs.writeFileSync(file_path, JSON.stringify(entries, (_key, value) => { + return typeof value === "bigint" ? value.toString() : value + }, 2)); } catch (error) { console.log(error) } diff --git a/src/util/pcmMixer.ts b/src/util/pcmMixer.ts new file mode 100644 index 0000000..d0f5350 --- /dev/null +++ b/src/util/pcmMixer.ts @@ -0,0 +1,116 @@ +import { spawn, type ChildProcessByStdio } from "node:child_process"; +import { PassThrough, type Readable } from "node:stream"; + +const RATE = 48000; +const CHANNELS = 2; + +type PcmProcess = ChildProcessByStdio + +type Overlay = { + proc: PcmProcess + buf: Buffer + ended: boolean +} + +function spawnPcm(filePath: string): PcmProcess { + const proc = spawn("ffmpeg", [ + "-hide_banner", + "-loglevel", "error", + "-i", filePath, + "-vn", + "-f", "s16le", + "-ar", String(RATE), + "-ac", String(CHANNELS), + "pipe:1", + ], { stdio: ["ignore", "pipe", "pipe"] }); + + proc.stderr.on("data", (d) => process.stderr.write(d)); + return proc; +} + +export class PcmMixer { + readonly output = new PassThrough({ highWaterMark: RATE * CHANNELS * 2 * 2 }); + private main: PcmProcess | null = null; + private overlays: Overlay[] = []; + private destroyed = false; + + get alive() { + return !this.destroyed && !this.output.destroyed && !this.output.writableEnded; + } + + setMain(filePath: string) { + this.main = spawnPcm(filePath); + this.main.stdout.on("data", (chunk: Buffer) => { + if (!this.alive) return; + this.output.write(this.mix(chunk)); + }); + this.main.stdout.on("end", () => { + for (const overlay of this.overlays) { + overlay.proc.kill("SIGKILL"); + } + this.overlays = []; + if (!this.destroyed && !this.output.writableEnded) { + this.output.end(); + } + }); + this.main.on("error", (err) => { + console.error(err); + if (!this.destroyed && !this.output.writableEnded) { + this.output.end(); + } + }); + } + + addOverlay(filePath: string) { + if (!this.alive) return; + const proc = spawnPcm(filePath); + const overlay: Overlay = { proc, buf: Buffer.alloc(0), ended: false }; + this.overlays.push(overlay); + proc.stdout.on("data", (chunk: Buffer) => { + overlay.buf = Buffer.concat([overlay.buf, chunk]); + }); + proc.stdout.on("end", () => { + overlay.ended = true; + }); + proc.on("error", (err) => { + console.error(err); + overlay.ended = true; + }); + } + + destroy() { + this.destroyed = true; + this.main?.kill("SIGKILL"); + for (const overlay of this.overlays) { + overlay.proc.kill("SIGKILL"); + } + this.overlays = []; + if (!this.output.destroyed && !this.output.writableEnded) { + this.output.end(); + } + } + + private mix(music: Buffer): Buffer { + const out = Buffer.alloc(music.length); + const hasOverlay = this.overlays.some((ov) => ov.buf.length > 0); + const duck = hasOverlay ? 0.65 : 1; + + for (let i = 0; i < music.length; i += 2) { + let sample = Math.round(music.readInt16LE(i) * duck); + for (const overlay of this.overlays) { + if (i < overlay.buf.length) { + sample += overlay.buf.readInt16LE(i); + } + } + out.writeInt16LE(Math.max(-32768, Math.min(32767, sample)), i); + } + + for (const overlay of this.overlays) { + overlay.buf = overlay.buf.length > music.length + ? overlay.buf.subarray(music.length) + : Buffer.alloc(0); + } + this.overlays = this.overlays.filter((ov) => ov.buf.length > 0 || !ov.ended); + return out; + } +}