Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .copilotignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
docker-compose.yml
14 changes: 14 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Set default behavior for all files
* text=auto

# Force LF line endings for shell scripts
*.sh text eol=lf

# Force LF for other Unix-specific files
Dockerfile text eol=lf
*.yml text eol=lf
*.yaml text eol=lf

# Force CRLF for Windows-specific files
*.bat text eol=crlf
*.cmd text eol=crlf
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,8 @@ dist

# TernJS port file
.tern-port

docker-compose.yml
/muse

.vscode
11 changes: 9 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ FROM node:22-bookworm-slim AS base

# openssl will be a required package if base is updated to 18.16+ due to node:*-slim base distro change
# https://github.com/prisma/prisma/issues/19729#issuecomment-1591270599
# Install ffmpeg
# Install ffmpeg and yt-dlp (will be updated on container start)
RUN apt-get update \
&& apt-get install --no-install-recommends -y \
ffmpeg \
tini \
openssl \
ca-certificates \
python3 \
python3-pip \
&& pip3 install --no-cache-dir --break-system-packages yt-dlp \
&& apt-get autoclean \
&& apt-get autoremove \
&& rm -rf /var/lib/apt/lists/*
Expand Down Expand Up @@ -52,9 +55,13 @@ WORKDIR /usr/app
COPY --from=builder /usr/app/dist ./dist
COPY --from=dependencies /usr/app/prod_node_modules node_modules
COPY --from=builder /usr/app/node_modules/.prisma/client ./node_modules/.prisma/client
COPY --from=builder /usr/app/scripts ./scripts

COPY . .

# Make the startup script executable
RUN chmod +x scripts/start-with-ytdlp-update.sh

ARG COMMIT_HASH=unknown
ARG BUILD_DATE=unknown

Expand All @@ -64,4 +71,4 @@ ENV COMMIT_HASH=$COMMIT_HASH
ENV BUILD_DATE=$BUILD_DATE
ENV ENV_FILE=/config

CMD ["tini", "--", "node", "--enable-source-maps", "dist/scripts/migrate-and-start.js"]
CMD ["tini", "--", "./scripts/start-with-ytdlp-update.sh"]
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,16 @@ You can configure the bot to automatically turn down the volume when people are
- `/config set-reduce-vol-when-voice false` - Disable automatic volume reduction
- `/config set-reduce-vol-when-voice-target <volume>` - Set the target volume percentage when people speak (0-100, default is 70)

### Building from Source

To build and run Muse locally using Docker Compose:

1. Copy the example configuration: `cp docker-compose.example.yml docker-compose.yml`
2. Edit `docker-compose.yml` and populate the environment variables with your API keys and tokens
3. Build and start the container:

```bash
docker-compose up --build
```

This will build the latest version from source and start Muse with your local changes.
11 changes: 11 additions & 0 deletions docker-compose.example.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
services:
muse:
image: muse-local:latest
restart: always
volumes:
- ./muse:/data
environment:
- DISCORD_TOKEN=
- YOUTUBE_API_KEY=
- SPOTIFY_CLIENT_ID=
- SPOTIFY_CLIENT_SECRET=
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@
"@discordjs/opus": "^0.10.0",
"@discordjs/rest": "1.0.1",
"@discordjs/voice": "0.18.0",
"@distube/ytdl-core": "^4.16.10",
"@distube/ytsr": "^2.0.4",
"@prisma/client": "4.16.0",
"@types/libsodium-wrappers": "^0.7.9",
Expand Down
12 changes: 12 additions & 0 deletions scripts/start-with-ytdlp-update.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/bin/bash

# Update yt-dlp to latest version on startup
echo "🔄 Updating yt-dlp to latest version..."
pip3 install --no-cache-dir --break-system-packages --upgrade yt-dlp

# Check yt-dlp version
echo "📦 yt-dlp version: $(yt-dlp --version)"

# Start the bot
echo "🚀 Starting Muse bot..."
exec node --enable-source-maps dist/scripts/migrate-and-start.js
1 change: 0 additions & 1 deletion src/services/add-query-to-queue.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* eslint-disable complexity */
import {ChatInputCommandInteraction, GuildMember} from 'discord.js';
import {inject, injectable} from 'inversify';
import shuffle from 'array-shuffle';
Expand Down
123 changes: 113 additions & 10 deletions src/services/player.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {VoiceChannel, Snowflake} from 'discord.js';
import {Readable} from 'stream';
import hasha from 'hasha';
import ytdl, {videoFormat} from '@distube/ytdl-core';
import {WriteStream} from 'fs-capacitor';
import ffmpeg from 'fluent-ffmpeg';
import shuffle from 'array-shuffle';
import {spawn} from 'child_process';
import {
AudioPlayer,
AudioPlayerState,
Expand Down Expand Up @@ -58,7 +58,36 @@ export interface PlayerEvents {
statusChange: (oldStatus: STATUS, newStatus: STATUS) => void;
}

type YTDLVideoFormat = videoFormat & {loudnessDb?: number};
interface VideoFormat {
url: string;
itag: string | number;
codecs?: string;
container?: string;
audioSampleRate?: string;
averageBitrate?: number;
bitrate?: string | number;
isLive?: boolean;
loudnessDb?: number;
}

interface YtDlpFormat {
url?: string;
format_id?: string;
acodec?: string;
vcodec?: string;
ext?: string;
asr?: number;
abr?: number;
tbr?: number;
}

interface YtDlpResponse {
formats?: YtDlpFormat[];
is_live?: boolean;
duration?: number;
}

type YTDLVideoFormat = VideoFormat;

export const DEFAULT_VOLUME = 100;

Expand Down Expand Up @@ -99,7 +128,7 @@ export default class {
this.voiceConnection = joinVoiceChannel({
channelId: channel.id,
guildId: channel.guild.id,
selfDeaf: false,
selfDeaf: true,
adapterCreator: channel.guild.voiceAdapterCreator as DiscordGatewayAdapterCreator,
});

Expand Down Expand Up @@ -490,6 +519,79 @@ export default class {
return this.volume ?? this.defaultVolume;
}

private async getVideoInfoWithYtDlp(url: string): Promise<YtDlpResponse> {
return new Promise((resolve, reject) => {
const ytDlp = spawn('yt-dlp', ['--dump-json', '--no-warnings', url]);

let stdout = '';
let stderr = '';

ytDlp.stdout.on('data', (data: Buffer) => {
stdout += data.toString();
});

ytDlp.stderr.on('data', (data: Buffer) => {
stderr += data.toString();
});

ytDlp.on('close', (code: number) => {
if (code === 0) {
try {
const info = JSON.parse(stdout) as YtDlpResponse;
resolve(info);
} catch (parseError: unknown) {
reject(new Error(`Failed to parse yt-dlp JSON output: ${String(parseError)}`));
}
} else {
reject(new Error(`yt-dlp failed with code ${code}: ${stderr}`));
}
});

ytDlp.on('error', (error: Error) => {
reject(new Error(`Failed to spawn yt-dlp: ${error.message}`));
});
});
}

private extractVideoId(url: string): string {
const regex = /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/;
const match = regex.exec(url);
return match?.[1] ?? url;
}

private async getYouTubeInfo(url: string): Promise<{
formats: VideoFormat[];
isLive: boolean;
lengthSeconds: string;
}> {
const videoId = this.extractVideoId(url);

// Construct full YouTube URL if we only have a video ID
const fullUrl = url.includes('youtube.com') || url.includes('youtu.be') ? url : `https://www.youtube.com/watch?v=${videoId}`;

const info = await this.getVideoInfoWithYtDlp(fullUrl);

const formats: VideoFormat[] = (info.formats ?? []).map((format: YtDlpFormat) => ({
url: format.url ?? '',
itag: format.format_id ?? '',
codecs:
format.acodec && format.acodec !== 'none'
? format.acodec
: format.vcodec ?? '',
container: format.ext ?? '',
audioSampleRate: format.asr?.toString(),
averageBitrate: format.abr,
bitrate: format.tbr,
isLive: info.is_live ?? false,
}));

return {
formats,
isLive: info.is_live ?? false,
lengthSeconds: info.duration?.toString() ?? '0',
};
}

private getHashForCache(url: string): string {
return hasha(url);
}
Expand All @@ -515,23 +617,24 @@ export default class {

if (!ffmpegInput) {
// Not yet cached, must download
const info = await ytdl.getInfo(song.url);
const info = await this.getYouTubeInfo(song.url);

const formats = info.formats as YTDLVideoFormat[];
const {formats} = info;

const filter = (format: ytdl.videoFormat): boolean => format.codecs === 'opus' && format.container === 'webm' && format.audioSampleRate !== undefined && parseInt(format.audioSampleRate, 10) === 48000;
// Look for the ideal format (opus codec, webm container, 48kHz)
const filter = (format: VideoFormat): boolean => format.codecs === 'opus' && format.container === 'webm' && format.audioSampleRate !== undefined && parseInt(format.audioSampleRate, 10) === 48000 && Boolean(format.url);

format = formats.find(filter);

const nextBestFormat = (formats: ytdl.videoFormat[]): ytdl.videoFormat | undefined => {
const nextBestFormat = (formats: VideoFormat[]): VideoFormat | undefined => {
if (formats.length < 1) {
return undefined;
}

if (formats[0].isLive) {
formats = formats.sort((a, b) => (b as unknown as {audioBitrate: number}).audioBitrate - (a as unknown as {audioBitrate: number}).audioBitrate); // Bad typings
formats = formats.sort((a, b) => (b as unknown as {audioBitrate: number}).audioBitrate - (a as unknown as {audioBitrate: number}).audioBitrate);

return formats.find(format => [128, 127, 120, 96, 95, 94, 93].includes(parseInt(format.itag as unknown as string, 10))); // Bad typings
return formats.find(format => [128, 127, 120, 96, 95, 94, 93].includes(parseInt(format.itag as unknown as string, 10)));
}

formats = formats
Expand Down Expand Up @@ -561,7 +664,7 @@ export default class {

// Don't cache livestreams or long videos
const MAX_CACHE_LENGTH_SECONDS = 30 * 60; // 30 minutes
shouldCacheVideo = !info.player_response.videoDetails.isLiveContent && parseInt(info.videoDetails.lengthSeconds, 10) < MAX_CACHE_LENGTH_SECONDS && !options.seek;
shouldCacheVideo = !info.isLive && parseInt(info.lengthSeconds, 10) < MAX_CACHE_LENGTH_SECONDS && !options.seek;

debug(shouldCacheVideo ? 'Caching video' : 'Not caching video');

Expand Down
Loading
Loading