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 { 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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user