This commit is contained in:
@@ -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);
|
||||
const token = await this.getSpotifyToken();
|
||||
const trackId = this.extractSpotifyTrackId(trimmed);
|
||||
if (trackId) {
|
||||
const track = await this.fetchSpotifyTrack(trackId, token);
|
||||
if (track) return track;
|
||||
}
|
||||
if (validation === 'so_track') {
|
||||
return { title: trimmed, url: trimmed };
|
||||
}
|
||||
// nur SoundCloud erlaubt, alles andere ignorieren
|
||||
} catch (err) {
|
||||
logger.error('Music resolve error', err);
|
||||
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 [];
|
||||
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 (scSearch && scSearch.length) {
|
||||
const sc = scSearch[0];
|
||||
const url = sc.url || '';
|
||||
if (url && /^https?:\/\//i.test(url)) return { title: sc.title ?? 'Unbekannt', url };
|
||||
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}`;
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user