From 336708191bc972f10d32ff13f0ff18d0fe78ba2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Prie=C3=9Fnitz?= Date: Wed, 3 Dec 2025 19:01:36 +0100 Subject: [PATCH] [deploy --- src/services/musicService.ts | 136 ++++++++++++++++++++++------------- 1 file changed, 88 insertions(+), 48 deletions(-) diff --git a/src/services/musicService.ts b/src/services/musicService.ts index a6d7350..421da66 100644 --- a/src/services/musicService.ts +++ b/src/services/musicService.ts @@ -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(); + 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 { + 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 }; } }