UNPKG

@getsolara/solara.voice

Version:

Optional voice functionality for @getsolara/solara.js using @discordjs/voice

189 lines (167 loc) 11.4 kB
const { createAudioPlayer, createAudioResource, StreamType, AudioPlayerStatus, VoiceConnectionStatus, entersState } = require('@discordjs/voice'); async function playNextTrackInQueue(guildId, client, providedCookie) { if (!client.voiceInitialized) return; const connection = client.solaraVoiceConnections?.get(guildId); let player = client.solaraAudioPlayers?.get(guildId); const queue = client.solaraVoiceQueues?.get(guildId); if (!connection || connection.state.status === VoiceConnectionStatus.Destroyed || !player || !queue) { if (client.solaraOptions?.verboseStartupLogging) console.log(`Solara.voice [${guildId}]: Playback stopped - invalid voice state (conn/player/queue missing or conn destroyed).`); if (connection && connection.state.status !== VoiceConnectionStatus.Destroyed) connection.destroy(); return; } player.removeAllListeners('stateChange'); player.removeAllListeners('error'); player.removeAllListeners(AudioPlayerStatus.Idle); if (queue.length === 0) { if (client.solaraOptions?.verboseStartupLogging) console.log(`Solara.voice [${guildId}]: Queue is empty. Player stopped.`); player.stop(true); return; } const trackToPlay = queue.shift(); client.solaraNowPlaying = client.solaraNowPlaying || new Map(); client.solaraNowPlaying.set(guildId, trackToPlay); let streamData; try { const play = require('play-dl'); const streamOptions = { discordPlayerCompatibility: true, requestOptions: {} }; if (trackToPlay.youtubeCookie) { streamOptions.requestOptions = { headers: { cookie: trackToPlay.youtubeCookie } }; } streamData = await play.stream(trackToPlay.url, streamOptions); if (!streamData || !streamData.stream) throw new Error("play.dl returned invalid stream object."); } catch (streamError) { console.error(`Solara.voice [${guildId}]: Error fetching stream for "${trackToPlay.title}":`, streamError); client.solaraNowPlaying.delete(guildId); if(trackToPlay.sourceContext && trackToPlay.sourceContext.channel) { trackToPlay.sourceContext.channel.send(`❌ Error streaming **${trackToPlay.title}**: ${streamError.message.length > 100 ? streamError.message.substring(0,97)+'...' : streamError.message}`).catch(()=>{}); } playNextTrackInQueue(guildId, client, providedCookie); return; } const resource = createAudioResource(streamData.stream, { inputType: streamData.type }); player.play(resource); player.on('stateChange', (oldState, newState) => { if (client.solaraOptions?.verboseStartupLogging) console.log(`Solara.voice [${guildId}]: Player state: ${oldState.status} -> ${newState.status} for "${client.solaraNowPlaying.get(guildId)?.title || 'Unknown'}"`); if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) { client.solaraNowPlaying.delete(guildId); playNextTrackInQueue(guildId, client, providedCookie); } else if (newState.status === AudioPlayerStatus.Playing && oldState.status === AudioPlayerStatus.Buffering) { if (client.solaraOptions?.verboseStartupLogging && trackToPlay.sourceContext && trackToPlay.sourceContext.channel) { // Avoid sending "Now Playing" for every buffer, usually done when track starts } } }); player.once('error', (error) => { console.error(`Solara.voice [${guildId}]: AudioPlayer Error for "${client.solaraNowPlaying.get(guildId)?.title || 'Unknown'}":`, error); client.solaraNowPlaying.delete(guildId); if(trackToPlay.sourceContext && trackToPlay.sourceContext.channel) { trackToPlay.sourceContext.channel.send(`❌ Player error for **${client.solaraNowPlaying.get(guildId)?.title || 'current track'}**`).catch(()=>{}); } playNextTrackInQueue(guildId, client, providedCookie); }); } module.exports = { name: "$voicePlay", description: "Plays or queues audio. Args: queryOrURL;[youtubeCookie?]", takesBrackets: true, execute: async (context, args) => { if (context.client.voiceInitialized === false) { return "[Error: $voicePlay requires voice features to be enabled. Ensure @getsolara/solara.voice is installed and configured correctly.]"; } if (!args[0]) return "[Error: $voicePlay requires a search query or URL.]"; const query = args[0].trim(); const youtubeCookie = args[1]?.trim(); if (!context.guild) return "[Error: $voicePlay can only be used in a server.]"; const guildId = context.guild.id; let connection = context.client.solaraVoiceConnections?.get(guildId); let player = context.client.solaraAudioPlayers?.get(guildId); let queue = context.client.solaraVoiceQueues?.get(guildId); if (!connection || connection.state.status === VoiceConnectionStatus.Destroyed || connection.state.status === VoiceConnectionStatus.Disconnected) { const voiceChannelId = context.member?.voice?.channelId; if (!voiceChannelId) return "[Error: You need to be in a voice channel for me to join, or $voiceJoin first.]"; try { const joinResult = await context.client.functionParser.parse(`$voiceJoin[${voiceChannelId}]`, context); if (joinResult.startsWith("[Error:")) return joinResult; connection = context.client.solaraVoiceConnections?.get(guildId); if (!connection) return "[Error: Failed to establish voice connection via $voiceJoin for $voicePlay.]"; } catch (e) { return `[Error: Failed to auto-join for $voicePlay: ${e.message}]`; } } if (!player) { player = createAudioPlayer(); player.on('error', error => { console.error(`Solara.voice [${guildId}]: Global AudioPlayer Error:`, error.message); const np = context.client.solaraNowPlaying?.get(guildId); if(context.client.solaraOptions?.verboseStartupLogging) console.log(`Solara.voice [${guildId}]: Global player error occurred. Now Playing: ${np?.title}. Attempting to play next.`); context.client.solaraNowPlaying?.delete(guildId); playNextTrackInQueue(guildId, context.client, youtubeCookie); }); connection.subscribe(player); context.client.solaraAudioPlayers?.set(guildId, player); } if (!queue) { queue = []; context.client.solaraVoiceQueues?.set(guildId, queue); } try { const play = require('play-dl'); const validation = await play.validate(query).catch(() => 'search'); // Default to search on validation error let trackInfos = []; const createTrackInfo = (details, cookie) => ({ url: details.url, title: details.title || 'Unknown Title', duration: details.durationRaw || 'N/A', requestedBy: context.user?.id, thumbnail: details.thumbnails?.[0]?.url, youtubeCookie: cookie || youtubeCookie, // Prioritize track-specific if available sourceContext: { channel: context.channel, guild: context.guild } }); if (validation === 'yt_video' || validation === 'so_track' || validation === 'sp_track' || (validation === 'yt_livestream' && context.client.solaraOptions?.allowLivestreams)) { const trackData = await play.video_basic_info(query); if (!trackData?.video_details) throw new Error("Could not fetch track details."); trackInfos.push(createTrackInfo(trackData.video_details)); } else if (validation === 'yt_playlist' || validation === 'so_playlist' || validation === 'sp_album' || validation === 'sp_playlist') { const playlist = await play.playlist_info(query, { incomplete: true }); if (!playlist) throw new Error(`Could not fetch playlist info.`); const allVideos = await playlist.all_videos(); if (!allVideos || allVideos.length === 0) return `[Error: Playlist/Album seems empty: ${query}]`; let addedCount = 0; for (const video of allVideos.slice(0, context.client.solaraOptions?.playlistImportLimit || 50)) { // Limit playlist import if (video.url && video.title) { trackInfos.push(createTrackInfo(video)); addedCount++; } } if (addedCount === 0) return `[Error: No valid tracks in playlist/album: ${query}]`; if(context.channel) context.channel.send(`☑️ Added **${addedCount}** tracks from the playlist/album.`).catch(()=>{}); } else { // Default to search let searchResults = await play.search(query, { limit: 1, source: { youtube: 'video' } }); if (!searchResults || searchResults.length === 0) return `[Error: No results found for "${query}"]`; trackInfos.push(createTrackInfo(searchResults[0])); } if (trackInfos.length === 0) return `[Error: No playable tracks from "${query}"]`; const wasPlayerIdleOrEmptyQueue = player.state.status === AudioPlayerStatus.Idle || queue.length === 0; trackInfos.forEach(track => queue.push(track)); let responseMessage = ""; if (trackInfos.length === 1) { responseMessage = `☑️ Queued: **${trackInfos[0].title}** (Position: ${queue.indexOf(trackInfos[0]) + 1})`; } else { responseMessage = `☑️ Queued **${trackInfos.length}** tracks.`; } if (wasPlayerIdleOrEmptyQueue && queue.length > 0) { playNextTrackInQueue(guildId, context.client, youtubeCookie); const firstTrack = context.client.solaraNowPlaying?.get(guildId) || queue[0]; // Check nowPlaying first if (firstTrack && trackInfos.includes(firstTrack)) { // If the first track played was among those just added responseMessage = `▶️ Now Playing: **${firstTrack.title}**`; } } return responseMessage; } catch (error) { if (error.code === 'MODULE_NOT_FOUND' && error.message.includes('play-dl')) { return "[Error: $voicePlay - play-dl module not found. Please install it.]"; } console.error(`Solara.voice Error ($voicePlay) for query "${query}" in guild ${guildId}:`, error); const errorMsg = error.message?.includes("Sign in") ? "Age-restricted/private video." : error.message?.includes("confirm your age") ? "Age-restricted video." : error.message?.includes("Premiere") || error.message?.includes("live stream") ? "Livestreams not directly supported for queuing this way." : error.message || "Unknown error."; return `[Error processing "${query}": ${errorMsg}]`; } } };