using axXez.TS3.Protocol; using System; using System.Collections.Concurrent; using System.Collections.Generic; namespace axXez.TS3.Bot { /// A music bot instance connected to and playing audio in a specific TeamSpeak channel. internal sealed class MusicBot : IDisposable { private readonly VoiceConnection _connection; private readonly ConcurrentQueue _queue = new(); /// The underlying voice connection. public VoiceConnection Connection => _connection; /// Number of tracks waiting in the queue, not counting the currently playing one. public int QueueLength => _queue.Count; /// Raised when a track successfully begins playing. public event Action TrackStarted; /// Raised when a track fails to load. The bot automatically advances to the next queued track. public event Action TrackFailed; /// Extra arguments forwarded to every yt-dlp invocation (e.g. --cookies). public IReadOnlyList YtDlpArgs { get; set; } = []; public MusicBot(VoiceConnection connection) { _connection = connection; _connection.OnConnected += Voice_OnConnected; _connection.OnDisconnected += Voice_OnDisconnected; _connection.OnTrackEnded += Voice_OnTrackEnded; } /// Starts the voice connection. public void Start() => _connection.Start(); /// /// Adds a URL or file path to the queue. Begins playback immediately if the bot is idle. /// Returns true if playback started immediately, false if the track was queued. /// public bool Enqueue(string url) { bool wasIdle = !_connection.IsPlaying && !_connection.IsPaused; _queue.Enqueue(url); if (wasIdle) PlayNext(); return wasIdle; } /// Adds multiple URLs to the queue. Begins playback immediately if the bot is idle. public void EnqueueAll(IEnumerable urls) { bool wasIdle = !_connection.IsPlaying && !_connection.IsPaused; foreach (var url in urls) _queue.Enqueue(url); if (wasIdle) PlayNext(); } /// Stops the current track and immediately advances to the next one in the queue. public void Skip() { _connection.StopPlayback(); PlayNext(); } /// Stops playback and clears all queued tracks. public void StopAndClear() { while (_queue.TryDequeue(out _)) { } _connection.StopPlayback(); } /// Returns a snapshot of the URLs currently waiting in the queue. public string[] GetQueue() => _queue.ToArray(); private void PlayNext() { if (!_queue.TryDequeue(out string url)) return; if (IsYouTubeUrl(url)) PlayYouTubeHandled(url); else { _connection.PlayStream(url); _connection.SendChannelMessage($"Now playing: [URL]{url}[/URL]"); TrackStarted?.Invoke(url); } } private async void PlayYouTubeHandled(string url) { try { await _connection.PlayYouTubeAsync(url, YtDlpArgs); _connection.SendChannelMessage($"Now playing: [URL]{url}[/URL]"); TrackStarted?.Invoke(url); } catch (Exception ex) { GlobalLogger.LogError("MusicBot", $"Failed to resolve [{url}] for [{_connection.Nickname}]", ex); _connection.SendChannelMessage($"Failed to play [URL]{url}[/URL]: {ex.Message}"); TrackFailed?.Invoke(url, ex.Message); PlayNext(); } } private static bool IsYouTubeUrl(string url) => url.Contains("youtube.com", StringComparison.OrdinalIgnoreCase) || url.Contains("youtu.be", StringComparison.OrdinalIgnoreCase); private void Voice_OnConnected(ITeamspeakClient sender) { GlobalLogger.Log("MusicBot", $"[{_connection.Nickname}] connected."); } private void Voice_OnDisconnected(ITeamspeakClient sender) { GlobalLogger.Log("MusicBot", $"[{_connection.Nickname}] disconnected.", LogType.Warning); } private void Voice_OnTrackEnded(VoiceConnection sender) { PlayNext(); } public void Dispose() { _connection.OnConnected -= Voice_OnConnected; _connection.OnDisconnected -= Voice_OnDisconnected; _connection.OnTrackEnded -= Voice_OnTrackEnded; while (_queue.TryDequeue(out _)) { } } } }