10 Commits

Author SHA1 Message Date
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
Patryk Koreń
bb2b31bb61 ci11
Some checks failed
Build and Push Docker Image / build-image (push) Failing after 31s
2026-01-07 23:03:22 +01:00
Patryk Koreń
cdbf448291 ci testy 2026-01-07 22:19:12 +01:00
15 changed files with 232 additions and 102 deletions

View File

@@ -6,41 +6,39 @@ on:
- '*' # Trigger on any git tag - '*' # Trigger on any git tag
jobs: jobs:
docker: build-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: docker:rc-dind
steps: steps:
- name: Install dedps
run: |
apk add --no-cache nodejs npm git
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Set up Podman - name: Build container image
env:
IMAGE_NAME: papryk/dj-spangebob
TAG: ${{ github.ref_name }}
run: | run: |
export XDG_RUNTIME_DIR=/tmp/run-user docker build -t "$IMAGE_NAME:$TAG" .
export HOME=/tmp/home
mkdir -p $XDG_RUNTIME_DIR $HOME
# Ensure Podman is installed
if ! command -v podman &> /dev/null; then
echo "Podman not found, installing..."
sudo apt-get update && sudo apt-get install -y podman
fi
podman info || true # info will fail if storage driver is overlay, ignore
- name: Log in to Docker registry - name: Log in to Docker registry
run: |
echo "${REGISTRY_PASSWORD}" | podman login ${GITEA_REGISTRY} \
--username ${REGISTRY_USERNAME} --password-stdin
env: env:
REGISTRY: https://gitea.papryk.com
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
echo "$REGISTRY_PASSWORD" | docker "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin
- name: Build and push Docker image - name: Build and push Docker image
run: |
IMAGE_TAG="Papryk/dj-spangebob:${{ gitea.ref_name }}"
echo "Building image $IMAGE_TAG"
podman build -t "$IMAGE_TAG" .
echo "Pushing image $IMAGE_TAG"
podman push "$IMAGE_TAG"
env: env:
TAG: ${{ gitea.ref_name }} REGISTRY: https://gitea.papryk.com
IMAGE_NAME: Papryk/dj-spangebob
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 WORKDIR /app

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

@@ -32,10 +32,10 @@ async function execute(interaction: ChatInputCommandInteraction<CacheType>) {
} }
await interaction.editReply(msg_downloading(url)); await interaction.editReply(msg_downloading(url));
await forceRequestSong(interaction, url) forceRequestSong(url, { user: interaction.user.username }).then(() => {
// const msg = getPlayMsg(url)
const msg = getPlayMsg(url) interaction.editReply(msg_play());
await interaction.editReply(msg_play()); })
} catch (error) { } catch (error) {
console.error(error); console.error(error);
await interaction.editReply('Coś poszło nie tak :/'); await interaction.editReply('Coś poszło nie tak :/');

View File

@@ -7,6 +7,7 @@ import queue from "./queue"
import resume from "./resume" import resume from "./resume"
import skip from "./skip" import skip from "./skip"
import stop from "./stop" import stop from "./stop"
import loop from "./loop"
export const commands: { [key: string]: Command } = { export const commands: { [key: string]: Command } = {
ping, ping,
@@ -18,5 +19,6 @@ export const commands: { [key: string]: Command } = {
stop, stop,
pause, pause,
skip, skip,
loop,
} }

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

@@ -0,0 +1,34 @@
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.url)}`);
}
else {
await interaction.editReply(`Loop turned off`);
}
} catch (error) {
console.error(error);
await interaction.reply('Coś poszło nie tak :/');
}
}
export default {
name,
register,
execute
}

View File

@@ -36,10 +36,9 @@ async function execute(interaction: ChatInputCommandInteraction<CacheType>) {
} }
await interaction.editReply(msg_downloading(url)); await interaction.editReply(msg_downloading(url));
await requestSong(interaction, url) requestSong(url, { user: interaction.user.username }).then(() => {
interaction.editReply(msg_play());
const msg = getPlayMsg(url) })
await interaction.editReply(msg_play());
} catch (error) { } catch (error) {
console.error(error); console.error(error);
await interaction.editReply('Coś poszło nie tak :/'); await interaction.editReply('Coś poszło nie tak :/');

10
src/global.d.ts vendored
View File

@@ -10,7 +10,13 @@ type AudioFile = {
} }
type Queue = { type Queue = {
songList: AudioFile[], songList: AudioFile[]
current: AudioFile | null, current: AudioFile | null
loop: AudioFile | null
}
type HistoryObject = {
user?: number | string,
url?: string,
} }

View File

@@ -9,6 +9,7 @@ import { updatePlayer } from './playback';
import SpotifyWebApi from 'spotify-web-api-node'; import SpotifyWebApi from 'spotify-web-api-node';
export const DATA_DIR = "./data"; export const DATA_DIR = "./data";
export const HISTORY_DIR_PATH = process.env.HISTORY_DIR_PATH ?? "./history";
// AUDIO // AUDIO
export const player = createAudioPlayer(); export const player = createAudioPlayer();

View File

@@ -3,29 +3,39 @@ import { player } from "./main";
import { CacheType, ChatInputCommandInteraction } from "discord.js"; import { CacheType, ChatInputCommandInteraction } from "discord.js";
import { getAudioFile } from "./util/downloader"; import { getAudioFile } from "./util/downloader";
import { playSong } from "./util/helpers"; import { playSong } from "./util/helpers";
import { add_to_history } from "./util/history";
const queue: Queue = { const queue: Queue = {
songList: [], songList: [],
current: null current: null,
loop: null,
}
export function toggleLoop(): AudioFile | null {
if (queue.loop) queue.loop = null
else queue.loop = queue.current
return queue.loop
} }
export async function requestSong( export async function requestSong(
interaction: ChatInputCommandInteraction<CacheType>, url: string,
url: string) { data: Partial<HistoryObject>
) {
const path = await getAudioFile(url) const path = await getAudioFile(url)
queue.songList.push({ path, url }) queue.songList.push({ path, url })
updatePlayer() add_to_history({ url, user: data.user })
await updatePlayer()
} }
export async function forceRequestSong( export async function forceRequestSong(
interaction: ChatInputCommandInteraction<CacheType>, url: string,
url: string) { data: Partial<HistoryObject>
) {
const path = await getAudioFile(url) const path = await getAudioFile(url)
const audio = { path, url } const audio = { path, url }
queue.songList.push(audio) queue.songList.push(audio)
add_to_history({ url, user: data.user })
queue.current = audio queue.current = audio
playSong(player, path); playSong(player, path);
} }
@@ -33,7 +43,7 @@ export async function forceRequestSong(
export async function updatePlayer() { export async function updatePlayer() {
if (player.state.status === AudioPlayerStatus.Idle) { if (player.state.status === AudioPlayerStatus.Idle) {
const nextSong = queue.songList.shift() const nextSong = queue.loop ?? queue.songList.shift()
if (!nextSong) { if (!nextSong) {
queue.current = null queue.current = null
return return

View File

@@ -18,27 +18,32 @@ export async function getAudioFile(url: string): Promise<string> {
return path return path
} }
function isYTLink(url: string) {
return url.startsWith("https://youtube") ||
url.startsWith("https://youtu.be");
}
export async function extractId(url: string): Promise<string> { export async function extractId(url: string): Promise<string> {
if (url.startsWith("https://soundcloud")) { // soundcloud // if (!isYTLink(url)) {
const ytDlpBin = process.env.YT_DLP_BIN_PATH! ?? "yt-dlp"; const ytDlpBin = process.env.YT_DLP_BIN_PATH! ?? "yt-dlp";
return await new Promise<string>((resolve, reject) => { return await new Promise<string>((resolve, reject) => {
const p = spawn(ytDlpBin, [ const p = spawn(ytDlpBin, [
"--print", "%(id)s", "--print", "%(id)s",
url, url,
]); ]);
let out = "" let out = ""
p.stderr.on("data", d => process.stderr.write(d)); p.stderr.on("data", d => process.stderr.write(d));
p.stdout.on("data", d => out += d.toString()); 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 => code === 0 ? resolve(out.trim()) : reject(new Error("yt-dlp extract id failed")));
}); });
} else { // } else {
const idMatch = /[?&]v=([a-zA-Z0-9_-]{11})/.exec(url); // const idMatch = /[?&]v=([a-zA-Z0-9_-]{11})/.exec(url);
if (!idMatch) throw new Error("Cannot extract video ID"); // if (!idMatch) throw new Error("Cannot extract video ID");
const id = idMatch[1]; // const id = idMatch[1];
return id // return id
} // }
} }
export async function downloadAudio(url: string): Promise<void> { export async function downloadAudio(url: string): Promise<void> {

View File

@@ -14,17 +14,17 @@ import { player } from '../main.js';
export async function connectToChannelByInteraction(interaction: ChatInputCommandInteraction<CacheType>): Promise<void> { export async function connectToChannelByInteraction(interaction: ChatInputCommandInteraction<CacheType>): Promise<void> {
const member = await interaction.guild?.members.fetch(interaction.user.id); const member = await interaction.guild?.members.fetch(interaction.user.id);
if (!member) throw new Error("ale chuj") if (!member) throw new Error("ale chuj")
const voiceChannel = member.voice.channel; const voiceChannel = member.voice.channel;
if (!voiceChannel) throw new Error("ale chuj 2") if (!voiceChannel) throw new Error("ale chuj 2")
const connection = await connectToChannel(voiceChannel); const connection = await connectToChannel(voiceChannel);
connection.subscribe(player); connection.subscribe(player);
} }
export async function connectToChannel(channel: VoiceBasedChannel) { export async function connectToChannel(channel: VoiceBasedChannel) {
/** /**
* Here, we try to establish a connection to a voice channel. If we're already connected * 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! * to this voice channel, \@discordjs/voice will just return the existing connection for us!
*/ */
const connection = joinVoiceChannel({ const connection = joinVoiceChannel({
channelId: channel.id, channelId: channel.id,
guildId: channel.guild.id, guildId: channel.guild.id,
@@ -32,28 +32,28 @@ export async function connectToChannel(channel: VoiceBasedChannel) {
}); });
/** /**
* If we're dealing with a connection that isn't yet Ready, we can set a reasonable * 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 * time limit before giving up. In this example, we give the voice connection 30 seconds
* to enter the ready state before giving up. * to enter the ready state before giving up.
*/ */
try { try {
/** /**
* Allow ourselves 30 seconds to join the voice channel. If we do not join within then, * Allow ourselves 30 seconds to join the voice channel. If we do not join within then,
* an error is thrown. * an error is thrown.
*/ */
await entersState(connection, VoiceConnectionStatus.Ready, 30_000); await entersState(connection, VoiceConnectionStatus.Ready, 30_000);
/** /**
* At this point, the voice connection is ready within 30 seconds! This means we can * 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 * start playing audio in the voice channel. We return the connection so it can be
* used by the caller. * used by the caller.
*/ */
return connection; return connection;
} catch (error) { } catch (error) {
/** /**
* At this point, the voice connection has not entered the Ready state. We should make * 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 * 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. * is aware that we failed to connect to the channel.
*/ */
connection.destroy(); connection.destroy();
throw error; throw error;
@@ -61,27 +61,31 @@ export async function connectToChannel(channel: VoiceBasedChannel) {
} }
export async function playSong(player: AudioPlayer, songUrl: string) { 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) * 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 * We specify an arbitrary inputType. This means that we aren't too sure what the format of
* were using an Ogg or WebM source, then we could change this value. However, for now we * the input is, and that we'd like to have this converted into a format we can use. If we
* will leave this as arbitrary. * 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 }); */
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 * 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 * at least one voice connection is subscribed to it, so it is fine to attach our resource to the
* audio player this early. * audio player this early.
*/ */
player.play(resource); player.play(resource);
/** /**
* Here we are using a helper function. It will resolve if the player enters the Playing * 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. * state within 5 seconds, otherwise it will reject with an error.
*/ */
return entersState(player, AudioPlayerStatus.Playing, 5_000); return entersState(player, AudioPlayerStatus.Playing, 5_000);
} catch (e) {
console.error(e)
}
} }

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

@@ -0,0 +1,30 @@
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);
}
let history = {
timestamp: today.getTime(),
url: data.url,
user: data.user,
};
fs.writeFileSync(file_path, JSON.stringify(history));
} catch (error) {
console.log(error)
}
}