[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 { ChatInputCommandInteraction, GuildMember, TextChannel } from 'discord.js';
import play from 'play-dl'; import { Readable } from 'stream';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { settingsStore } from '../config/state'; import { settingsStore } from '../config/state';
@@ -8,7 +8,8 @@ export type LoopMode = 'off' | 'song' | 'queue';
interface QueueItem { interface QueueItem {
title: string; title: string;
url: string; streamUrl: string;
displayUrl?: string;
requester: string; requester: string;
originalQuery?: string; originalQuery?: string;
} }
@@ -24,6 +25,7 @@ interface QueueState {
export class MusicService { export class MusicService {
private queues = new Map<string, QueueState>(); private queues = new Map<string, QueueState>();
private spotifyToken: { value: string; expiresAt: number } | null = null;
private getQueue(guildId: string) { private getQueue(guildId: string) {
const cfg = settingsStore.get(guildId); const cfg = settingsStore.get(guildId);
@@ -82,7 +84,13 @@ export class MusicService {
await interaction.reply({ content: 'Der gefundene Link ist ungueltig.', ephemeral: true }); await interaction.reply({ content: 'Der gefundene Link ist ungueltig.', ephemeral: true });
return; 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); const queue = this.getQueue(interaction.guildId);
if (!queue) { if (!queue) {
const player = createAudioPlayer(); const player = createAudioPlayer();
@@ -160,7 +168,7 @@ export class MusicService {
next = queue.current; next = queue.current;
} }
if (!next) break; 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)) { if (streamUrlCheck && streamUrlCheck !== 'undefined' && /^https?:\/\//i.test(streamUrlCheck)) {
break; break;
} }
@@ -172,24 +180,16 @@ export class MusicService {
queue.channel.send({ content: 'Queue beendet.' }).catch(() => undefined); queue.channel.send({ content: 'Queue beendet.' }).catch(() => undefined);
return; return;
} }
const streamUrl = (next.url || '').trim(); const streamUrl = (next.streamUrl || '').trim();
queue.current = next; queue.current = next;
try { try {
const kind = await play.validate(streamUrl); if (!/^https?:\/\//i.test(streamUrl)) throw new Error('spotify_stream_url_invalid');
if (kind !== 'so_track') { const res = await fetch(streamUrl);
logger.error('Music stream error', { reason: 'unsupported_url', kind, item: next }); if (!res.ok || !res.body) throw new Error('spotify_stream_fetch_failed');
queue.channel.send({ content: `Nur SoundCloud wird unterstuetzt, ueberspringe: **${next.title ?? 'Unbekannt'}**.` }).catch(() => undefined); const body: any = typeof (res as any).body?.getReader === 'function' ? Readable.fromWeb(res.body as any) : (res as any).body;
queue.current = undefined; const resource: AudioResource = createAudioResource(body, {
this.processQueue(guildId); inputType: StreamType.Arbitrary
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
}); });
queue.player.play(resource); queue.player.play(resource);
queue.connection.subscribe(queue.player); 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> { private async resolveTrack(query: string, opts?: { skipPlaylist?: boolean }): Promise<{ title: string; url: string } | null> {
const trimmed = query.trim(); const trimmed = query.trim();
if (!trimmed) return null; if (!trimmed) return null;
try { const token = await this.getSpotifyToken();
let validation: string | null = null; const trackId = this.extractSpotifyTrackId(trimmed);
try { if (trackId) {
validation = await play.validate(trimmed); const track = await this.fetchSpotifyTrack(trackId, token);
} catch (err) { if (track) return track;
logger.warn('Music validate error', err);
} }
if (validation === 'so_track') { const search = await this.searchSpotifyTrack(trimmed, token);
return { title: trimmed, url: trimmed }; return search;
}
// nur SoundCloud erlaubt, alles andere ignorieren
} catch (err) {
logger.error('Music resolve error', err);
} }
const scSearch = await play.search(trimmed, { source: { soundcloud: 'tracks' }, limit: 1 }).catch((err) => { private async getSpotifyToken(): Promise<string> {
logger.warn('SoundCloud search skipped', err?.message || err); if (this.spotifyToken && this.spotifyToken.expiresAt > Date.now() + 30000) {
return []; 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 (scSearch && scSearch.length) { if (!res.ok) throw new Error('spotify_auth_failed');
const sc = scSearch[0]; const data = (await res.json()) as { access_token: string; expires_in: number };
const url = sc.url || ''; const expiresInMs = Math.max(30_000, (data.expires_in || 3600) * 1000);
if (url && /^https?:\/\//i.test(url)) return { title: sc.title ?? 'Unbekannt', url }; 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; return null;
} }
private buildVideoUrl(details: any): string | null { private async fetchSpotifyTrack(id: string, token: string): Promise<{ title: string; url: string } | null> {
if (!details) return null; const res = await fetch(`https://api.spotify.com/v1/tracks/${id}`, {
const url = details.url || details.permalink; headers: { Authorization: `Bearer ${token}` }
if (typeof url === 'string' && /^https?:\/\//i.test(url)) return url; });
if (details.id) return `https://www.youtube.com/watch?v=${details.id}`; if (!res.ok) {
if (details.videoId) return `https://www.youtube.com/watch?v=${details.videoId}`; logger.warn('Spotify track fetch failed', { status: res.status });
return null; 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 };
}
} }