@getsolara/solara.voice
Version:
Optional voice functionality for @getsolara/solara.js using @discordjs/voice
189 lines (167 loc) • 11.4 kB
JavaScript
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}]`;
}
}
};