[deploy
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 36s

This commit is contained in:
Pascal Prießnitz
2025-12-03 19:01:36 +01:00
parent e21d9e11b6
commit 336708191b

View File

@@ -1,6 +1,6 @@
import { AudioPlayer, AudioPlayerStatus, AudioResource, VoiceConnection, createAudioPlayer, createAudioResource, entersState, joinVoiceChannel, VoiceConnectionStatus, getVoiceConnection } from '@discordjs/voice';
import { AudioPlayer, AudioPlayerStatus, AudioResource, VoiceConnection, StreamType, createAudioPlayer, createAudioResource, entersState, joinVoiceChannel, VoiceConnectionStatus, getVoiceConnection } from '@discordjs/voice';
import { ChatInputCommandInteraction, GuildMember, TextChannel } from 'discord.js';
import play from 'play-dl';
import { Readable } from 'stream';
import { logger } from '../utils/logger';
import { settingsStore } from '../config/state';
@@ -8,7 +8,8 @@ export type LoopMode = 'off' | 'song' | 'queue';
interface QueueItem {
title: string;
url: string;
streamUrl: string;
displayUrl?: string;
requester: string;
originalQuery?: string;
}
@@ -24,6 +25,7 @@ interface QueueState {
export class MusicService {
private queues = new Map<string, QueueState>();
private spotifyToken: { value: string; expiresAt: number } | null = null;
private getQueue(guildId: string) {
const cfg = settingsStore.get(guildId);
@@ -82,7 +84,13 @@ export class MusicService {
await interaction.reply({ content: 'Der gefundene Link ist ungueltig.', ephemeral: true });
return;
}
const queueItem: QueueItem = { title: track.title ?? 'Unbekannt', url: track.url, requester: interaction.user.tag, originalQuery: trimmedQuery };
const queueItem: QueueItem = {
title: track.title ?? 'Unbekannt',
streamUrl: track.url,
displayUrl: track.url,
requester: interaction.user.tag,
originalQuery: trimmedQuery
};
const queue = this.getQueue(interaction.guildId);
if (!queue) {
const player = createAudioPlayer();
@@ -160,7 +168,7 @@ export class MusicService {
next = queue.current;
}
if (!next) break;
const streamUrlCheck = typeof next.url === 'string' ? next.url.trim() : '';
const streamUrlCheck = typeof next.streamUrl === 'string' ? next.streamUrl.trim() : '';
if (streamUrlCheck && streamUrlCheck !== 'undefined' && /^https?:\/\//i.test(streamUrlCheck)) {
break;
}
@@ -172,24 +180,16 @@ export class MusicService {
queue.channel.send({ content: 'Queue beendet.' }).catch(() => undefined);
return;
}
const streamUrl = (next.url || '').trim();
const streamUrl = (next.streamUrl || '').trim();
queue.current = next;
try {
const kind = await play.validate(streamUrl);
if (kind !== 'so_track') {
logger.error('Music stream error', { reason: 'unsupported_url', kind, item: next });
queue.channel.send({ content: `Nur SoundCloud wird unterstuetzt, ueberspringe: **${next.title ?? 'Unbekannt'}**.` }).catch(() => undefined);
queue.current = undefined;
this.processQueue(guildId);
return;
}
const finalUrl = streamUrl;
if (!finalUrl || !/^https?:\/\//i.test(finalUrl) || finalUrl === 'undefined') throw new Error('soundcloud_url_invalid');
const stream = await play.stream(finalUrl);
if (!stream?.stream) throw new Error('stream_invalid');
const resource: AudioResource = createAudioResource(stream.stream, {
inputType: stream.type
if (!/^https?:\/\//i.test(streamUrl)) throw new Error('spotify_stream_url_invalid');
const res = await fetch(streamUrl);
if (!res.ok || !res.body) throw new Error('spotify_stream_fetch_failed');
const body: any = typeof (res as any).body?.getReader === 'function' ? Readable.fromWeb(res.body as any) : (res as any).body;
const resource: AudioResource = createAudioResource(body, {
inputType: StreamType.Arbitrary
});
queue.player.play(resource);
queue.connection.subscribe(queue.player);
@@ -234,39 +234,79 @@ export class MusicService {
private async resolveTrack(query: string, opts?: { skipPlaylist?: boolean }): Promise<{ title: string; url: string } | null> {
const trimmed = query.trim();
if (!trimmed) return null;
try {
let validation: string | null = null;
try {
validation = await play.validate(trimmed);
} catch (err) {
logger.warn('Music validate error', err);
}
if (validation === 'so_track') {
return { title: trimmed, url: trimmed };
}
// nur SoundCloud erlaubt, alles andere ignorieren
} catch (err) {
logger.error('Music resolve error', err);
const token = await this.getSpotifyToken();
const trackId = this.extractSpotifyTrackId(trimmed);
if (trackId) {
const track = await this.fetchSpotifyTrack(trackId, token);
if (track) return track;
}
const search = await this.searchSpotifyTrack(trimmed, token);
return search;
}
const scSearch = await play.search(trimmed, { source: { soundcloud: 'tracks' }, limit: 1 }).catch((err) => {
logger.warn('SoundCloud search skipped', err?.message || err);
return [];
});
if (scSearch && scSearch.length) {
const sc = scSearch[0];
const url = sc.url || '';
if (url && /^https?:\/\//i.test(url)) return { title: sc.title ?? 'Unbekannt', url };
private async getSpotifyToken(): Promise<string> {
if (this.spotifyToken && this.spotifyToken.expiresAt > Date.now() + 30000) {
return this.spotifyToken.value;
}
const clientId = process.env.SPOTIFY_CLIENT_ID;
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
if (!clientId || !clientSecret) throw new Error('missing_spotify_credentials');
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
const res = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
Authorization: `Basic ${basic}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: 'grant_type=client_credentials'
});
if (!res.ok) throw new Error('spotify_auth_failed');
const data = (await res.json()) as { access_token: string; expires_in: number };
const expiresInMs = Math.max(30_000, (data.expires_in || 3600) * 1000);
this.spotifyToken = { value: data.access_token, expiresAt: Date.now() + expiresInMs };
return data.access_token;
}
private extractSpotifyTrackId(query: string) {
const urlMatch = query.match(/spotify\.com\/track\/([A-Za-z0-9]+)/i);
if (urlMatch?.[1]) return urlMatch[1];
const uriMatch = query.match(/spotify:track:([A-Za-z0-9]+)/i);
if (uriMatch?.[1]) return uriMatch[1];
return null;
}
private buildVideoUrl(details: any): string | null {
if (!details) return null;
const url = details.url || details.permalink;
if (typeof url === 'string' && /^https?:\/\//i.test(url)) return url;
if (details.id) return `https://www.youtube.com/watch?v=${details.id}`;
if (details.videoId) return `https://www.youtube.com/watch?v=${details.videoId}`;
return null;
private async fetchSpotifyTrack(id: string, token: string): Promise<{ title: string; url: string } | null> {
const res = await fetch(`https://api.spotify.com/v1/tracks/${id}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!res.ok) {
logger.warn('Spotify track fetch failed', { status: res.status });
return null;
}
const data: any = await res.json();
if (!data?.preview_url) {
logger.warn('Spotify track has no preview_url', { id });
return null;
}
const artists = Array.isArray(data.artists) ? data.artists.map((a: any) => a.name).filter(Boolean).join(', ') : '';
const title = [data.name, artists].filter(Boolean).join(' - ');
return { title: title || data.name || 'Track', url: data.preview_url };
}
private async searchSpotifyTrack(query: string, token: string): Promise<{ title: string; url: string } | null> {
const market = process.env.SPOTIFY_MARKET || 'DE';
const res = await fetch(`https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=track&limit=1&market=${market}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!res.ok) {
logger.warn('Spotify search failed', { status: res.status });
return null;
}
const data: any = await res.json();
const track = data?.tracks?.items?.[0];
if (!track || !track.preview_url) return null;
const artists = Array.isArray(track.artists) ? track.artists.map((a: any) => a.name).filter(Boolean).join(', ') : '';
const title = [track.name, artists].filter(Boolean).join(' - ');
return { title: title || track.name || 'Track', url: track.preview_url };
}
}