Compare commits
12 Commits
0.0.12
...
efca1d2d10
| Author | SHA1 | Date | |
|---|---|---|---|
| efca1d2d10 | |||
|
|
ad87d3fbb8 | ||
|
|
2eb8d25a2d | ||
|
|
e9cd9ffb91 | ||
|
|
a164ca433f | ||
|
|
4fc00493af | ||
|
|
20a36703e6 | ||
|
|
f9f0ba9b1f | ||
|
|
d60cd75786 | ||
|
|
cbf39d2bae | ||
|
|
bb2b31bb61 | ||
|
|
cdbf448291 |
@@ -9,24 +9,22 @@ jobs:
|
||||
build-image:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mgoltzsche/podman:5.7.1-minimal
|
||||
image: docker:rc-dind
|
||||
|
||||
steps:
|
||||
- name: Install Node.js
|
||||
- name: Install dedps
|
||||
run: |
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt-get update && apt-get install -y nodejs
|
||||
apk add --no-cache nodejs npm git
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Build container image
|
||||
env:
|
||||
REGISTRY: https://gitea.papryk.com
|
||||
IMAGE_NAME: Papryk/dj-spangebob
|
||||
TAG: ${{ gitea.ref_name }}
|
||||
IMAGE_NAME: papryk/dj-spangebob
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
podman build "$REGISTRY/$IMAGE_NAME:$TAG" .
|
||||
docker build -t "$IMAGE_NAME:$TAG" .
|
||||
|
||||
- name: Log in to Docker registry
|
||||
env:
|
||||
@@ -34,13 +32,13 @@ jobs:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: |
|
||||
echo "$REGISTRY_PASSWORD" | podman login "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin
|
||||
echo "$REGISTRY_PASSWORD" | docker "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin
|
||||
|
||||
- name: Build and push Docker image
|
||||
env:
|
||||
REGISTRY: https://gitea.papryk.com
|
||||
IMAGE_NAME: Papryk/dj-spangebob
|
||||
TAG: ${{ gitea.ref_name }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
podman push "$REGISTRY/$IMAGE_NAME:$TAG"
|
||||
docker push "$REGISTRY/$IMAGE_NAME:$TAG"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
1
history/06-04-2026.json
Normal 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
1
history/26-02-2026.json
Normal 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
36
history/29-01-2026.json
Normal 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"
|
||||
}
|
||||
]
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
38
src/commands/loop.ts
Normal 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
|
||||
}
|
||||
@@ -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 :/');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 :/');
|
||||
|
||||
@@ -17,7 +17,11 @@ async function execute(interaction: ChatInputCommandInteraction<CacheType>) {
|
||||
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 :/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
132
src/commands/soundboard.ts
Normal file
132
src/commands/soundboard.ts
Normal 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
|
||||
}
|
||||
@@ -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 :/');
|
||||
|
||||
19
src/global.d.ts
vendored
19
src/global.d.ts
vendored
@@ -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
|
||||
}
|
||||
|
||||
|
||||
32
src/main.ts
32
src/main.ts
@@ -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';
|
||||
|
||||
export const DATA_DIR = "./data";
|
||||
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()
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
124
src/playback.ts
124
src/playback.ts
@@ -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)
|
||||
queue.current = audio
|
||||
playSong(player, path);
|
||||
add_to_history({ url, user: data.user })
|
||||
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) {
|
||||
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 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) {
|
||||
@@ -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
221
src/soundboard.ts
Normal 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 "./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<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);
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const id = await extractId(url)
|
||||
console.log(`ID: ${id}`);
|
||||
|
||||
@@ -19,32 +27,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, [
|
||||
"--print", "%(id)s",
|
||||
url,
|
||||
]);
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
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")));
|
||||
let out = ""
|
||||
p.stderr.on("data", d => process.stderr.write(d));
|
||||
p.stdout.on("data", d => out += d.toString());
|
||||
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 +80,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 +92,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 +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;
|
||||
}
|
||||
|
||||
@@ -8,23 +8,42 @@ 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);
|
||||
}
|
||||
|
||||
export async function connectToChannel(channel: VoiceBasedChannel) {
|
||||
/**
|
||||
* Here, we try to establish a connection to a voice channel. If we're already connected
|
||||
* to this voice channel, \@discordjs/voice will just return the existing connection for us!
|
||||
*/
|
||||
* Here, we try to establish a connection to a voice channel. If we're already connected
|
||||
* to this voice channel, \@discordjs/voice will just return the existing connection for us!
|
||||
*/
|
||||
const connection = joinVoiceChannel({
|
||||
channelId: channel.id,
|
||||
guildId: channel.guild.id,
|
||||
@@ -32,28 +51,28 @@ export async function connectToChannel(channel: VoiceBasedChannel) {
|
||||
});
|
||||
|
||||
/**
|
||||
* If we're dealing with a connection that isn't yet Ready, we can set a reasonable
|
||||
* time limit before giving up. In this example, we give the voice connection 30 seconds
|
||||
* to enter the ready state before giving up.
|
||||
*/
|
||||
* If we're dealing with a connection that isn't yet Ready, we can set a reasonable
|
||||
* time limit before giving up. In this example, we give the voice connection 30 seconds
|
||||
* to enter the ready state before giving up.
|
||||
*/
|
||||
try {
|
||||
/**
|
||||
* Allow ourselves 30 seconds to join the voice channel. If we do not join within then,
|
||||
* an error is thrown.
|
||||
*/
|
||||
* Allow ourselves 30 seconds to join the voice channel. If we do not join within then,
|
||||
* an error is thrown.
|
||||
*/
|
||||
await entersState(connection, VoiceConnectionStatus.Ready, 30_000);
|
||||
/**
|
||||
* At this point, the voice connection is ready within 30 seconds! This means we can
|
||||
* start playing audio in the voice channel. We return the connection so it can be
|
||||
* used by the caller.
|
||||
*/
|
||||
* At this point, the voice connection is ready within 30 seconds! This means we can
|
||||
* start playing audio in the voice channel. We return the connection so it can be
|
||||
* used by the caller.
|
||||
*/
|
||||
return connection;
|
||||
} catch (error) {
|
||||
/**
|
||||
* At this point, the voice connection has not entered the Ready state. We should make
|
||||
* sure to destroy it, and propagate the error by throwing it, so that the calling function
|
||||
* is aware that we failed to connect to the channel.
|
||||
*/
|
||||
* At this point, the voice connection has not entered the Ready state. We should make
|
||||
* sure to destroy it, and propagate the error by throwing it, so that the calling function
|
||||
* is aware that we failed to connect to the channel.
|
||||
*/
|
||||
connection.destroy();
|
||||
|
||||
throw error;
|
||||
@@ -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.
|
||||
*/
|
||||
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);
|
||||
try {
|
||||
const mixer = new PcmMixer();
|
||||
mixer.setMain(songUrl);
|
||||
const resource = createAudioResource(mixer.output, { inputType: StreamType.Raw });
|
||||
const previous = currentMixer;
|
||||
currentMixer = mixer;
|
||||
player.play(resource);
|
||||
previous?.destroy();
|
||||
return entersState(player, AudioPlayerStatus.Playing, 10_000);
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
42
src/util/history.ts
Normal file
42
src/util/history.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { HISTORY_DIR_PATH } from "../main";
|
||||
|
||||
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
116
src/util/pcmMixer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user