using axXez.TS3.Audio;
using axXez.TS3.Protocol;
using System;
using System.Collections.Generic;
namespace axXez.TS3.Bot.Modules
{
[Module(Namespace = "axXez", DisplayName = "Music")]
internal class MusicModule : Module
{
/// Default TS3 identity shared by all bots with no per-bot identity. Auto-generated and saved on first load if null.
public string Identity { get; set; } = null;
/// Minimum security level for the default identity and any per-bot identity without an explicit override.
public int IdentitySecurityLevel { get; set; } = 8;
/// Persisted list of music bot configurations, recreated on every connect.
public List Bots { get; set; } = new();
/// Minimum role required to enqueue and start tracks.
public CommandRole PlayRole { get; set; } = CommandRole.Guest;
/// Minimum role required to skip the current track.
public CommandRole SkipRole { get; set; } = CommandRole.Moderator;
/// Minimum role required to pause or resume playback.
public CommandRole PauseRole { get; set; } = CommandRole.Moderator;
/// Minimum role required to stop playback and clear the queue.
public CommandRole StopRole { get; set; } = CommandRole.Moderator;
/// Minimum role required to change the volume.
public CommandRole VolRole { get; set; } = CommandRole.Moderator;
/// Extra arguments passed to every yt-dlp invocation. Use for cookies, rate limits, etc. (e.g. ["--cookies", "/app/cookies.txt"]).
public List YtDlpArgs { get; set; } = new();
private readonly Dictionary _bots = new(StringComparer.OrdinalIgnoreCase);
private VoiceIdentity _defaultIdentity;
protected override void OnLoad()
{
AudioPlayer.EnsureDependencies();
SetCommandRole("play", PlayRole);
SetCommandRole("skip", SkipRole);
SetCommandRole("pause", PauseRole);
SetCommandRole("resume", PauseRole);
SetCommandRole("stop", StopRole);
SetCommandRole("vol", VolRole);
}
protected override void OnStart()
{
bool dirty = false;
if (IdentitySecurityLevel < Server.Configuration.NeededIdentitySecurityLevel)
{
IdentitySecurityLevel = Server.Configuration.NeededIdentitySecurityLevel;
dirty = true;
}
if (Identity != null)
{
_defaultIdentity = VoiceIdentity.FromTsIdentity(Identity);
if (_defaultIdentity.SecurityLevel < IdentitySecurityLevel)
{
Log($"Improving default identity security level to {IdentitySecurityLevel} (currently {_defaultIdentity.SecurityLevel})...");
_defaultIdentity.ImproveSecurity(IdentitySecurityLevel);
dirty = true;
}
}
else
{
_defaultIdentity = VoiceIdentity.Generate(IdentitySecurityLevel);
dirty = true;
}
if (dirty)
Identity = _defaultIdentity.ToTsIdentity();
foreach (var cfg in Bots)
{
bool identityImproved = false;
if (cfg.Identity != null)
{
using var id = VoiceIdentity.FromTsIdentity(cfg.Identity);
if (id.SecurityLevel < IdentitySecurityLevel)
{
Log($"Improving identity for bot in '{cfg.Channel}' to security level {IdentitySecurityLevel} (currently {id.SecurityLevel})...");
id.ImproveSecurity(IdentitySecurityLevel);
cfg.Identity = id.ToTsIdentity();
dirty = true;
identityImproved = true;
}
}
if (!Server.FindChannel(cfg.Channel, out var ch))
{
Log($"Saved music bot channel '{cfg.Channel}' not found, skipping.", LogType.Warning);
continue;
}
if (_bots.TryGetValue(cfg.Nickname, out var existing))
{
if (!identityImproved)
continue;
_bots.Remove(cfg.Nickname);
existing.Dispose();
RemoveVoiceConnection(existing.Connection);
}
cfg.Channel = ch.Name;
CreateBotInternal(cfg);
}
if (dirty)
Save();
}
protected override void OnUnload()
{
foreach (var bot in _bots.Values)
bot.Dispose();
_bots.Clear();
_defaultIdentity?.Dispose();
_defaultIdentity = null;
//base.OnUnload(); // don't auto-save; config is only written on explicit create/remove
}
private void CreateBotInternal(MusicBotConfig cfg)
{
VoiceIdentity identity = cfg.Identity != null ? VoiceIdentity.FromTsIdentity(cfg.Identity) : _defaultIdentity;
var connection = CreateVoiceConnection(cfg.Nickname, identity, new VoiceClientOptions { DefaultChannel = cfg.Channel });
connection.Volume = cfg.Volume;
connection.OnChannelChanged += (_, channelId) =>
{
if (Channels.TryGetValue(channelId, out var ch))
{
cfg.Channel = ch.Name;
Save();
}
};
var bot = new MusicBot(connection) { YtDlpArgs = YtDlpArgs };
_bots[cfg.Nickname] = bot;
bot.Start();
}
#region management commands
[Command("music create", Description = "Creates a music bot with the given nickname in the given channel, or your current channel if omitted.", Role = CommandRole.Admin, Scope = CommandScope.Any)]
public CommandResponse CreateBot(CommandContext ctx, [CommandParam("name")] string nickname, [CommandParam("channel")] string channelNameParam = null, bool ownIdentity = false, [CommandParam("security")] int securityLevel = 0)
{
if (_bots.ContainsKey(nickname))
return ctx.Error($"A music bot named [B]{nickname}[/B] already exists.");
string channelName;
if (channelNameParam != null)
{
if (!Server.FindChannel(channelNameParam, out var ch))
return ctx.Error($"Channel [B]{channelNameParam}[/B] not found.");
channelName = ch.Name;
}
else
{
int channelId = ctx.Invoker.ChannelID;
channelName = Channels.TryGetValue(channelId, out var ch) ? ch.Name : channelId.ToString();
}
string identityStr = null;
int targetLevel = securityLevel > 0 ? securityLevel : IdentitySecurityLevel;
if (ownIdentity)
{
var id = VoiceIdentity.Generate(targetLevel);
identityStr = id.ToTsIdentity();
}
var cfg = new MusicBotConfig(channelName, nickname, identityStr);
Bots.Add(cfg);
Save();
CreateBotInternal(cfg);
return ctx.Ok($"Music bot [B]{nickname}[/B] created in [B]{channelName}[/B]{(ownIdentity ? $" with dedicated identity (level {targetLevel})" : "")}.");
}
[Command("music name", Description = "Renames a music bot. Takes effect on next reconnect.", Role = CommandRole.Admin, Scope = CommandScope.Any)]
public CommandResponse SetBotName(CommandContext ctx, [CommandParam("bot")] string currentNickname, [CommandParam("name")] string newNickname)
{
var cfg = Bots.Find(c => c.Nickname.Equals(currentNickname, StringComparison.OrdinalIgnoreCase));
if (cfg == null || !_bots.ContainsKey(currentNickname))
return ctx.Error($"No music bot named [B]{currentNickname}[/B].");
cfg.Nickname = newNickname;
Save();
return ctx.Ok($"Bot [B]{currentNickname}[/B] renamed to [B]{newNickname}[/B]. Takes effect on next reconnect.");
}
[Command("music remove", Description = "Removes a music bot by nickname.", Role = CommandRole.Admin, Scope = CommandScope.Any)]
public CommandResponse RemoveBot(CommandContext ctx, [CommandParam("name")] string nickname)
{
if (!_bots.Remove(nickname, out var bot))
return ctx.Error($"No music bot named [B]{nickname}[/B].");
var cfg = Bots.Find(c => c.Nickname.Equals(nickname, StringComparison.OrdinalIgnoreCase));
bot.Dispose();
RemoveVoiceConnection(bot.Connection);
Bots.Remove(cfg);
Save();
return ctx.Ok($"Music bot [B]{nickname}[/B] removed.");
}
[Command("music list", Description = "Lists all active music bots and their current channel.", Role = CommandRole.Admin, Scope = CommandScope.Any)]
public CommandResponse ListBots(CommandContext ctx)
{
if (_bots.Count == 0)
return ctx.Ok("No music bots active.");
ctx.Reply($"[B]{_bots.Count}[/B] music bot(s) active:");
foreach (var (nick, bot) in _bots)
{
var cfg = Bots.Find(c => c.Nickname.Equals(nick, StringComparison.OrdinalIgnoreCase));
ctx.Reply($" [B]{nick}[/B] in [B]{cfg?.Channel ?? "?"}[/B] — {bot.QueueLength} track(s) queued");
}
return ctx.Ok();
}
#endregion
#region playback commands
private bool TryGetBot(CommandContext ctx, out MusicBot bot)
{
if (ctx.Source is not VoiceConnection vc)
{
bot = null;
return false;
}
return _bots.TryGetValue(vc.Nickname, out bot);
}
[Command("play", Description = "Adds a track or playlist URL to the queue. Use list=true to enqueue an entire playlist.", Role = CommandRole.Guest, Scope = CommandScope.Channel | CommandScope.Private)]
public CommandResponse Play(CommandContext ctx, [CommandParam("url")] string url, [CommandParam("list")] bool list = false)
{
if (!TryGetBot(ctx, out var bot))
return ctx.Ok(); //silently ignore
// TS3 clients auto-wrap URLs in [URL]...[/URL] BBCode
if (url.StartsWith("[URL]", StringComparison.OrdinalIgnoreCase) && url.EndsWith("[/URL]", StringComparison.OrdinalIgnoreCase))
url = url[5..^6];
if (list)
{
PlayPlaylistAsync(bot, url);
return ctx.Ok("Resolving playlist...");
}
bool startedNow = bot.Enqueue(url);
return ctx.Ok(startedNow ? "Now playing." : "Added to queue.");
}
private async void PlayPlaylistAsync(MusicBot bot, string url)
{
try
{
string[] urls = await YtDlp.GetPlaylistUrlsAsync(url, YtDlpArgs);
bot.EnqueueAll(urls);
bot.Connection.SendChannelMessage($"Added [B]{urls.Length}[/B] track(s) from playlist to queue.");
}
catch (Exception ex)
{
GlobalLogger.LogError("MusicModule", $"Failed to resolve playlist [{url}]", ex);
bot.Connection.SendChannelMessage($"Failed to load playlist: {ex.Message}");
}
}
[Command("skip", Description = "Skips the current track", Role = CommandRole.Guest, Scope = CommandScope.Channel | CommandScope.Private)]
public CommandResponse Skip(CommandContext ctx)
{
if (!TryGetBot(ctx, out var bot))
return ctx.Ok(); //silently ignore
bot.Skip();
return ctx.Ok("Skipped.");
}
[Command("stop", Description = "Stops playback and clears the queue", Role = CommandRole.Moderator, Scope = CommandScope.Channel | CommandScope.Private)]
public CommandResponse Stop(CommandContext ctx)
{
if (!TryGetBot(ctx, out var bot))
return ctx.Ok(); //silently ignore
bot.StopAndClear();
return ctx.Ok("Stopped.");
}
[Command("pause", Description = "Pauses playback", Role = CommandRole.Guest, Scope = CommandScope.Channel | CommandScope.Private)]
public CommandResponse Pause(CommandContext ctx)
{
if (!TryGetBot(ctx, out var bot))
return ctx.Ok(); //silently ignore
bot.Connection.PausePlayback();
return ctx.Ok("Paused.");
}
[Command("resume", Description = "Resumes paused playback", Role = CommandRole.Guest, Scope = CommandScope.Channel | CommandScope.Private)]
public CommandResponse Resume(CommandContext ctx)
{
if (!TryGetBot(ctx, out var bot))
return ctx.Ok(); //silently ignore
bot.Connection.ResumePlayback();
return ctx.Ok("Resumed.");
}
[Command("playlist", Description = "Shows the tracks currently waiting in the queue", Role = CommandRole.Guest, Scope = CommandScope.Channel | CommandScope.Private)]
public CommandResponse Playlist(CommandContext ctx)
{
if (!TryGetBot(ctx, out var bot))
return ctx.Ok(); //silently ignore
var tracks = bot.GetQueue();
if (tracks.Length == 0)
return ctx.Ok("Queue is empty.");
ctx.Reply($"[B]{tracks.Length}[/B] track(s) in queue:");
for (int i = 0; i < tracks.Length; i++)
ctx.Reply($" [B]{i + 1}.[/B] {tracks[i]}");
return ctx.Ok();
}
[Command("vol", Description = "Gets or sets the playback volume (0–200%). Omit to show current.", Role = CommandRole.Guest, Scope = CommandScope.Channel | CommandScope.Private)]
public CommandResponse Vol(CommandContext ctx, [CommandParam("volume")] string percent = null)
{
if (!TryGetBot(ctx, out var bot))
return ctx.Ok(); //silently ignore
if (percent == null)
return ctx.Ok($"Volume: [B]{(int)(bot.Connection.Volume * 100)}%[/B]");
if (!int.TryParse(percent, out int val) || val < 0 || val > 200)
return ctx.Error("Volume must be a number between 0 and 200.");
bot.Connection.Volume = val / 100f;
var cfg = Bots.Find(c => c.Nickname.Equals(bot.Connection.Nickname, StringComparison.OrdinalIgnoreCase));
if (cfg != null)
{
cfg.Volume = bot.Connection.Volume;
Save();
}
return ctx.Ok($"Volume set to [B]{val}%[/B].");
}
#endregion
public class MusicBotConfig
{
/// Channel name or numeric ID string used to locate the channel on connect.
public string Channel { get; set; }
/// Nickname for this bot. Null falls back to the module-level .
public string Nickname { get; set; }
/// TS3 identity string ({KeyOffset}V{base64}). Null uses the module-level default identity.
public string Identity { get; set; }
/// Playback volume multiplier (0.0–2.0). Persisted across restarts.
public float Volume { get; set; } = 0.5f;
public MusicBotConfig(string channel, string nickname, string identity)
{
Channel = channel;
Nickname = nickname;
Identity = identity;
}
}
}
}