10 Commits

Author SHA1 Message Date
Patryk Koreń
a3d9669b28 Fix startup crash from a circular DATA_DIR import.
Soundboard loaded main before DATA_DIR was initialized, so path.join got undefined and the container looped.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 18:08:37 +02:00
Patryk Koreń
ad87d3fbb8 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 <cursoragent@cursor.com>
2026-09-13 17:03:02 +02:00
Patryk Koreń
2eb8d25a2d działaj ty kurwo 2026-05-12 20:02:35 +02:00
Patryk Koreń
e9cd9ffb91 bump ytdlp version 2026-05-11 21:07:10 +02:00
Patryk Koreń
a164ca433f fix message and added other links support 2026-05-11 21:02:09 +02:00
Patryk Koreń
4fc00493af fix: rozpierdalał sie przy dodawaniu do kolejki kiedy coś grało 2026-04-06 22:51:18 +02:00
Patryk Koreń
20a36703e6 added loop 2026-02-12 17:51:08 +01:00
Patryk Koreń
f9f0ba9b1f Manualne pobieranie yt-dlp bo yt rozjebał pobieranie we wcześniejszej wersji 2026-02-01 14:18:18 +01:00
Patryk Koreń
d60cd75786 fix history 2026-01-29 17:43:43 +01:00
Patryk Koreń
cbf39d2bae added history 2026-01-27 21:27:36 +01:00
25 changed files with 875 additions and 155 deletions

View File

@@ -22,7 +22,7 @@ jobs:
- name: Build container image
env:
IMAGE_NAME: papryk/dj-spangebob
TAG: ${{ gitea.ref_name }}
TAG: ${{ github.ref_name }}
run: |
docker build -t "$IMAGE_NAME:$TAG" .
@@ -38,7 +38,7 @@ jobs:
env:
REGISTRY: https://gitea.papryk.com
IMAGE_NAME: Papryk/dj-spangebob
TAG: ${{ gitea.ref_name }}
TAG: ${{ github.ref_name }}
run: |
docker push "$REGISTRY/$IMAGE_NAME:$TAG"

View File

@@ -1,6 +1,9 @@
FROM node:20-alpine
FROM node:22-alpine
RUN apk add --no-cache ffmpeg yt-dlp
RUN apk add --no-cache wget ffmpeg #yt-dlp
RUN wget -P /tmp https://github.com/yt-dlp/yt-dlp/releases/download/2026.03.17/yt-dlp_musllinux
RUN mv /tmp/yt-dlp_musllinux /usr/bin/yt-dlp
RUN chmod 777 /usr/bin/yt-dlp
WORKDIR /app

View File

@@ -7,6 +7,7 @@ services:
restart: always
volumes:
- ./data:/app/data
- ./history:/app/history
env_file:
- .env
environment:

1
history/06-04-2026.json Normal file
View File

@@ -0,0 +1 @@
{"timestamp":1775508648856,"url":"https://www.youtube.com/watch?v=xTemcPZw8Eo","user":"kapitan.papryk"}

1
history/26-02-2026.json Normal file
View File

@@ -0,0 +1 @@
{"timestamp":1772133865649,"url":"https://www.youtube.com/watch?v=TWiOB4lUZrQ","user":"kapitan.papryk"}

36
history/29-01-2026.json Normal file
View File

@@ -0,0 +1,36 @@
[
{
"interaction": {
"type": 2,
"id": "1466473802148872486",
"applicationId": "887789346277691402",
"channelId": "470712099753164811",
"guildId": "343827205878710273",
"user": "264739894641950720",
"member": "264739894641950720",
"version": 1,
"appPermissions": "2230881414020816",
"memberPermissions": "2230883322429171",
"locale": "pl",
"guildLocale": "en-US",
"entitlements": [],
"authorizingIntegrationOwners": {
"0": "343827205878710273"
},
"context": 0,
"attachmentSizeLimit": 10485760,
"commandId": "1452614990078021710",
"commandName": "play",
"commandType": 1,
"commandGuildId": null,
"deferred": true,
"replied": true,
"ephemeral": false,
"webhook": {
"id": "887789346277691402"
},
"options": {}
},
"url": "https://soundcloud.com/musicbymoonlght/4th-of-july-rock-x-house-mix-by-moonlght-vol-2?si=93777764ee5c404db6f3fb3c6dc347d8&utm_source=clipboard&utm_medium=text&utm_campaign=social_sharing"
}
]

View File

@@ -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<CacheType>) {
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<CacheType>) {
}
await interaction.editReply(msg_downloading(url));
await forceRequestSong(interaction, url)
const msg = getPlayMsg(url)
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);
}
}

View File

@@ -7,6 +7,8 @@ import queue from "./queue"
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,
@@ -18,5 +20,7 @@ export const commands: { [key: string]: Command } = {
stop,
pause,
skip,
loop,
soundboard,
}

38
src/commands/loop.ts Normal file
View File

@@ -0,0 +1,38 @@
import { CacheType, ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js';
import { toggleLoop } from '../playback';
import { formatFilePath } from '../util/downloader'
const name = "loop"
function register() {
return new SlashCommandBuilder()
.setName(name)
.setDescription('loop current song')
}
async function execute(interaction: ChatInputCommandInteraction<CacheType>) {
try {
await interaction.deferReply()
const audio = toggleLoop()
if (audio) {
await interaction.editReply(`looped ${formatFilePath(audio.path)}`);
}
else {
await interaction.editReply(`Loop turned off`);
}
} catch (error) {
console.error(error);
if (interaction.deferred || interaction.replied) {
await interaction.editReply('Coś poszło nie tak :/');
} else {
await interaction.reply('Coś poszło nie tak :/');
}
}
}
export default {
name,
register,
execute
}

View File

@@ -14,6 +14,7 @@ function register() {
async function execute(interaction: ChatInputCommandInteraction<CacheType>) {
try {
pause_playback(player)
await interaction.reply('Pauza')
} catch (error) {
console.error(error);
await interaction.reply('Coś poszło nie tak :/');

View File

@@ -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<CacheType>) {
try {
await interaction.deferReply()
connectToChannelByInteraction(interaction)
await connectToChannelByInteraction(interaction)
const input = interaction.options.getString("url")!;
let url: string;
@@ -36,13 +36,12 @@ async function execute(interaction: ChatInputCommandInteraction<CacheType>) {
}
await interaction.editReply(msg_downloading(url));
await requestSong(interaction, url)
const msg = getPlayMsg(url)
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);
}
}

View File

@@ -14,6 +14,7 @@ function register() {
async function execute(interaction: ChatInputCommandInteraction<CacheType>) {
try {
resume_playback(player)
await interaction.reply('Leci dalej')
} catch (error) {
console.error(error);
await interaction.reply('Coś poszło nie tak :/');

View File

@@ -17,8 +17,12 @@ async function execute(interaction: ChatInputCommandInteraction<CacheType>) {
await interaction.editReply(msg_skipped(skipped));
} catch (error) {
console.error(error);
if (interaction.deferred || interaction.replied) {
await interaction.editReply('Coś poszło nie tak :/');
} else {
await interaction.reply('Coś poszło nie tak :/');
}
}
}
export default {

132
src/commands/soundboard.ts Normal file
View File

@@ -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<CacheType>) {
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<CacheType>) {
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
}

View File

@@ -14,6 +14,7 @@ function register() {
async function execute(interaction: ChatInputCommandInteraction<CacheType>) {
try {
stop_playback(player)
await interaction.reply('Zatrzymane, kolejka wyczyszczona')
} catch (error) {
console.error(error);
await interaction.reply('Coś poszło nie tak :/');

5
src/config.ts Normal file
View File

@@ -0,0 +1,5 @@
import dotenv from "dotenv";
dotenv.config();
export const DATA_DIR = process.env.DATA_DIR ?? "./data";
export const HISTORY_DIR_PATH = process.env.HISTORY_DIR_PATH ?? "./history";

19
src/global.d.ts vendored
View File

@@ -10,7 +10,22 @@ type AudioFile = {
}
type Queue = {
songList: AudioFile[],
current: AudioFile | null,
songList: AudioFile[]
current: AudioFile | null
loop: AudioFile | null
}
type HistoryObject = {
user?: number | string,
url?: string,
}
type SoundboardSound = {
id: string
description: string
emoji: string
path: string
user: string
createdAt: number
}

View File

@@ -3,17 +3,21 @@ 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';
import { DATA_DIR, HISTORY_DIR_PATH } from './config';
export const DATA_DIR = "./data";
export { DATA_DIR, HISTORY_DIR_PATH };
// AUDIO
export const player = createAudioPlayer();
player.on('stateChange', (oldState: AudioPlayerState, newState: AudioPlayerState) => {
player.on('stateChange', (_oldState: AudioPlayerState, newState: AudioPlayerState) => {
if (newState.status === AudioPlayerStatus.Idle) {
updatePlayer()
}
});
@@ -61,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) {

View File

@@ -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()

View File

@@ -1,62 +1,120 @@
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 = {
songList: [],
current: null
current: null,
loop: null,
}
let advancing = false;
let soundboardSolo = false;
export function toggleLoop(): AudioFile | null {
if (queue.loop) queue.loop = null
else queue.loop = queue.current
return queue.loop
}
export async function requestSong(
interaction: ChatInputCommandInteraction<CacheType>,
url: string) {
url: string,
data: Partial<HistoryObject>
) {
const path = await getAudioFile(url)
queue.songList.push({ path, url })
updatePlayer()
add_to_history({ url, user: data.user })
await updatePlayer()
}
export async function forceRequestSong(
interaction: ChatInputCommandInteraction<CacheType>,
url: string) {
url: string,
data: Partial<HistoryObject>
) {
const path = await getAudioFile(url)
const audio = { path, url }
queue.songList.push(audio)
add_to_history({ url, user: data.user })
advancing = true
try {
queue.current = audio
playSong(player, path);
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) {
const nextSong = queue.songList.shift()
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 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 null
};
queue.current = nextSong;
playSong(player, nextSong.path);
return skipped
}
return null
advancing = true
queue.current = nextSong
playSong(player, nextSong.path).finally(() => {
advancing = false
})
return skipped
}
export function pause_playback(player: AudioPlayer) {
@@ -64,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) {

221
src/soundboard.ts Normal file
View File

@@ -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 "./config";
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<number> {
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<SoundboardSound> {
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<ButtonBuilder>[] = [];
for (let i = 0; i < slice.length; i += 5) {
const row = new ActionRowBuilder<ButtonBuilder>();
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<ButtonBuilder>().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<CacheType> | ButtonInteraction<CacheType>) {
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<CacheType>) {
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);
}
}

View File

@@ -1,9 +1,18 @@
import { spawn } from "node:child_process";
import { DATA_DIR, spotify } from "../main";
import fs from "node:fs";
import { DATA_DIR } from "../config";
import { 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<string> {
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
const id = await extractId(url)
console.log(`ID: ${id}`);
@@ -19,32 +28,26 @@ export async function getAudioFile(url: string): Promise<string> {
}
export async function extractId(url: string): Promise<string> {
if (url.startsWith("https://soundcloud")) { // soundcloud
const ytDlpBin = process.env.YT_DLP_BIN_PATH! ?? "yt-dlp";
return await new Promise<string>((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<void> {
const ytDlpBin = process.env.YT_DLP_BIN_PATH! ?? "yt-dlp";
await new Promise<void>((resolve, reject) => {
const p = spawn(ytDlpBin, [
const p = spawn(ytDlpBin(), [
"-x",
// "--audio-format", "opus",
"--audio-quality", "0",
@@ -78,10 +81,11 @@ export async function findFileById(id: string): Promise<string> {
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());
}
});
});
@@ -89,12 +93,11 @@ export async function findFileById(id: string): Promise<string> {
export async function search(input: string) {
return await new Promise<string>((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 = "";
@@ -103,35 +106,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;
}

View File

@@ -8,14 +8,33 @@ 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<CacheType>): Promise<void> {
const member = await interaction.guild?.members.fetch(interaction.user.id);
if (!member) throw new Error("ale chuj")
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<CacheType> | ButtonInteraction<CacheType>
): Promise<void> {
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("ale chuj 2")
if (!voiceChannel) throw new Error("Wejdź na kanał głosowy")
const connection = await connectToChannel(voiceChannel);
connection.subscribe(player);
}
@@ -61,27 +80,17 @@ export async function connectToChannel(channel: VoiceBasedChannel) {
}
export async function playSong(player: AudioPlayer, songUrl: string) {
/**
* 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.
*/
try {
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
}
}

42
src/util/history.ts Normal file
View File

@@ -0,0 +1,42 @@
import path from "node:path";
import fs from "node:fs";
import { HISTORY_DIR_PATH } from "../config";
export async function add_to_history(data: HistoryObject) {
try {
const today = new Date();
const day = String(today.getDate()).padStart(2, '0');
const month = String(today.getMonth() + 1).padStart(2, '0');
const year = today.getFullYear();
const file_name = `${day}-${month}-${year}.json`;
const file_path = path.join(HISTORY_DIR_PATH, file_name)
if (!fs.existsSync(HISTORY_DIR_PATH)) {
fs.mkdirSync(HISTORY_DIR_PATH, { recursive: true });
}
let entries: Array<HistoryObject & { timestamp: number }> = [];
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(entries, (_key, value) => {
return typeof value === "bigint" ? value.toString() : value
}, 2));
} catch (error) {
console.log(error)
}
}

116
src/util/pcmMixer.ts Normal file
View File

@@ -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<null, Readable, Readable>
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;
}
}