Compare commits

..

9 Commits

Author SHA1 Message Date
Myx
445dfafd8e Add jellyfin source 2026-02-13 21:07:54 +01:00
3362c6bf8c Update README.md 2024-10-25 23:28:55 +02:00
a864944318 Update README.md 2024-10-25 22:08:25 +02:00
146455c1bd Add logotype 2024-10-25 22:07:06 +02:00
56eee11fc9 Update README.md 2024-10-25 21:56:21 +02:00
e01746a343 Update README.md 2024-10-25 21:54:44 +02:00
e847c1579a Update readme.md 2024-10-25 21:51:44 +02:00
1ccc31d3d2 Update readme.md 2024-10-25 21:50:41 +02:00
7c4d8c246d Update README.md 2024-10-25 21:49:24 +02:00
8 changed files with 388 additions and 261 deletions

View File

@@ -6,6 +6,7 @@ using Lavalink4NET;
using Lavalink4NET.Events.Players;
using Lavalink4NET.Integrations.SponsorBlock;
using Lavalink4NET.Integrations.SponsorBlock.Extensions;
using Lavalink4NET.Jellyfin;
using Lavalink4NET.Players.Queued;
using Lavalink4NET.Rest.Entities.Tracks;
using Lavalink4NET.Tracks;
@@ -19,8 +20,7 @@ public class PlayHandler : IRequestHandler<PlayCommand>
private readonly MusicEmbed _musicEmbed;
private readonly DiscordSocketClient _client;
private readonly IAudioService _audioService;
private SocketSlashCommand _context;
private const int MaxTrackDuration = 30;
private SocketSlashCommand _context = default!; // ensure initialized before use
private LavalinkTrack? _previousTrack;
private static readonly HashSet<ulong> SubscribedGuilds = new();
@@ -34,15 +34,16 @@ public class PlayHandler : IRequestHandler<PlayCommand>
_audioService = audioService;
}
private async Task OnTrackEnded(object sender, TrackEndedEventArgs eventargs)
private Task OnTrackEnded(object sender, TrackEndedEventArgs eventargs)
{
// Reset the previous track when the track ends.
_previousTrack = null;
return Task.CompletedTask;
}
private async Task OnTrackStarted(object sender, TrackStartedEventArgs eventargs)
{
var player = sender as QueuedLavalinkPlayer;
var player = eventargs.Player as QueuedLavalinkPlayer;
if (player?.CurrentTrack is null)
{
@@ -60,119 +61,132 @@ public class PlayHandler : IRequestHandler<PlayCommand>
// Track has changed, update the previous track and send the embed
_previousTrack = currentTrack;
await _musicEmbed.NowPlayingEmbed(currentTrack, _context, _client);
if (_context != null)
{
await _musicEmbed.NowPlayingEmbed(currentTrack, _context, _client);
}
}
public Task Handle(PlayCommand command, CancellationToken cancellationToken)
public async Task Handle(PlayCommand command, CancellationToken cancellationToken)
{
new Thread(PlayMusic).Start();
return Task.CompletedTask;
async void PlayMusic()
try
{
try
var context = command.Message;
_context = context;
if ((context.User as SocketGuildUser)?.VoiceChannel == null)
{
RegisterTrackStartedEventListerner(command);
await context.SendMessageAsync("You must be in a voice channel to use this command.", _client);
return;
}
await _audioService.StartAsync(cancellationToken);
var searchQuery = context.GetOptionValueByName(Option.Input);
var context = command.Message;
_context = context;
if (string.IsNullOrWhiteSpace(searchQuery))
{
await context.SendMessageAsync("Please provide search terms.", _client);
return;
}
if ((context.User as SocketGuildUser)?.VoiceChannel == null)
RegisterTrackStartedEventListerner(command);
await _audioService.StartAsync(cancellationToken);
await context.SendMessageAsync("📻 Searching...", _client);
var player = await _audioService.GetPlayerAsync(_client, context, connectToVoiceChannel: true);
if (player is null)
return;
await ApplyFilters(cancellationToken, player);
await ConfigureSponsorBlock(cancellationToken, player);
// Parse the query to extract search mode and clean query
// Supports prefixes like jfsearch:, ytsearch:, scsearch:, etc.
// Default: Jellyfin (jfsearch:) when no prefix is specified
var (searchMode, queryToSearch) = SearchQueryParser.Parse(searchQuery, JellyfinSearchMode.Jellyfin);
var trackLoadOptions = new TrackLoadOptions
{
SearchMode = searchMode,
};
var trackCollection = await _audioService.Tracks.LoadTracksAsync(queryToSearch, trackLoadOptions, cancellationToken: cancellationToken);
// Check if the result is a playlist or just a single track from the search result
if (trackCollection.IsPlaylist)
{
// If it's a playlist, check if it's a free-text input.
if (!Uri.IsWellFormedUriString(searchQuery, UriKind.Absolute))
{
await context.SendMessageAsync("You must be in a voice channel to use this command.", _client);
return;
}
var searchQuery = context.GetOptionValueByName(Option.Input);
if (string.IsNullOrWhiteSpace(searchQuery))
{
await context.SendMessageAsync("Please provide search terms.", _client);
return;
}
await context.SendMessageAsync("📻 Searching...", _client);
var player = await _audioService.GetPlayerAsync(_client, context, connectToVoiceChannel: true);
if (player is null)
return;
await ApplyFilters(cancellationToken, player);
await ConfigureSponsorBlock(cancellationToken, player);
var trackLoadOptions = new TrackLoadOptions
{
SearchMode = TrackSearchMode.YouTubeMusic,
};
var trackCollection = await _audioService.Tracks.LoadTracksAsync(searchQuery, trackLoadOptions, cancellationToken: cancellationToken);
// Check if the result is a playlist or just a single track from the search result
if (trackCollection.IsPlaylist)
{
// If it's a playlist, check if it's a free-text input.
if (!Uri.IsWellFormedUriString(searchQuery, UriKind.Absolute))
// Free text was used (not a direct URL to a playlist), let's prevent queueing the whole playlist.
// Queue only the first track of the search result
// var firstTrack = trackCollection.Tracks.FirstOrDefault();
if (trackCollection.Track != null)
{
// Free text was used (not a direct URL to a playlist), let's prevent queueing the whole playlist.
// Queue only the first track of the search result
// var firstTrack = trackCollection.Tracks.FirstOrDefault();
if (trackCollection.Track != null)
{
await player.PlayAsync(trackCollection.Track, cancellationToken: cancellationToken).ConfigureAwait(false);
await _musicEmbed.NowPlayingEmbed(trackCollection.Track, _context, _client);
}
else
{
await context.SendMessageAsync("No tracks found.", _client);
}
}
else
{
// It's a playlist and a URL was used, so we can queue all tracks as usual
var queueTracks = trackCollection.Tracks
.Skip(1)
.Select(t => new TrackQueueItem(t))
.ToList();
await player.PlayAsync(trackCollection.Tracks.First(), cancellationToken: cancellationToken).ConfigureAwait(false);
await player.Queue.AddRangeAsync(queueTracks, cancellationToken);
await context.SendMessageAsync($"Queued playlist {trackCollection.Playlist?.Name} with {queueTracks.Count} tracks.", _client);
}
}
else
{
// It's just a single track or a search result.
var track = trackCollection.Track;
if (track != null)
{
await player.PlayAsync(track, cancellationToken: cancellationToken).ConfigureAwait(false);
await _musicEmbed.NowPlayingEmbed(track, _context, _client);
await player.PlayAsync(trackCollection.Track, cancellationToken: cancellationToken).ConfigureAwait(false);
// rely on TrackStarted event to send Now Playing
}
else
{
await context.SendMessageAsync("No tracks found.", _client);
}
}
else
{
// It's a playlist and a URL was used, so we can queue all tracks as usual
var queueTracks = trackCollection.Tracks
.Skip(1)
.Select(t => new TrackQueueItem(t))
.ToList();
await player.PlayAsync(trackCollection.Tracks.First(), cancellationToken: cancellationToken).ConfigureAwait(false);
await player.Queue.AddRangeAsync(queueTracks, cancellationToken);
await context.SendMessageAsync($"Queued playlist {trackCollection.Playlist?.Name} with {queueTracks.Count} tracks.", _client);
}
}
catch (Exception error)
else
{
throw new Exception("Error occured in the Play handler!", error);
// It's just a single track or a search result.
var track = trackCollection.Track;
if (track != null)
{
await player.PlayAsync(track, cancellationToken: cancellationToken).ConfigureAwait(false);
// rely on TrackStarted event to send Now Playing
}
else
{
await context.SendMessageAsync("No tracks found.", _client);
}
}
}
catch (Exception error)
{
throw new Exception("Error occured in the Play handler!", error);
}
}
private void RegisterTrackStartedEventListerner(PlayCommand command)
{
if (SubscribedGuilds.Contains((ulong)command.Message.GuildId!))
var guildId = command.Message.GuildId;
if (!guildId.HasValue)
{
// Ignore registration for DMs or contexts without a guild.
return;
}
var gid = guildId.Value;
if (SubscribedGuilds.Contains(gid))
return;
// Ensure we don't accidentally double-subscribe.
_audioService.TrackStarted -= OnTrackStarted;
_audioService.TrackEnded -= OnTrackEnded;
_audioService.TrackStarted += OnTrackStarted;
_audioService.TrackEnded += OnTrackEnded;
SubscribedGuilds.Add((ulong)command.Message.GuildId!);
SubscribedGuilds.Add(gid);
}
private static async Task ApplyFilters(CancellationToken cancellationToken, QueuedLavalinkPlayer player)

View File

@@ -9,72 +9,26 @@ flowchart TD
```
```mermaid
classDiagram
class PlayHandler {
-MusicEmbed _musicEmbed
-DiscordSocketClient _client
-IAudioService _audioService
-SocketSlashCommand _context
-const int MaxTrackDuration
-LavalinkTrack? _previousTrack
-static HashSet~ulong~ SubscribedGuilds
+PlayHandler(DiscordSocketClient client, MusicEmbed musicEmbed, IAudioService audioService)
+Task Handle(PlayCommand command, CancellationToken cancellationToken)
-void PlayMusic()
-Task OnTrackEnded(object sender, TrackEndedEventArgs eventargs)
-Task OnTrackStarted(object sender, TrackStartedEventArgs eventargs)
-void RegisterTrackStartedEventListerner(PlayCommand command)
-static Task ApplyFilters(CancellationToken cancellationToken, QueuedLavalinkPlayer player)
-static Task ConfigureSponsorBlock(CancellationToken cancellationToken, QueuedLavalinkPlayer player)
}
sequenceDiagram
participant User
participant Bot
participant DiscordSocketClient
participant IAudioService
participant SocketSlashCommand
participant LavalinkPlayer
class PlayCommand {
+SocketSlashCommand Message
}
class TrackEndedEventArgs {
}
class TrackStartedEventArgs {
}
class QueuedLavalinkPlayer {
+LavalinkTrack? CurrentTrack
+Task PlayAsync(LavalinkTrack track, CancellationToken cancellationToken)
+Task Queue.AddRangeAsync(List~TrackQueueItem~ queueTracks, CancellationToken cancellationToken)
+Task Filters.SetFilter(NormalizationFilter normalizationFilter)
+Task Filters.CommitAsync(CancellationToken cancellationToken)
+Task UpdateSponsorBlockCategoriesAsync(ImmutableArray~SegmentCategory~ categories, CancellationToken cancellationToken)
}
class LavalinkTrack {
+string Identifier
}
class NormalizationFilter {
+NormalizationFilter(double gain, bool enabled)
}
class SegmentCategory {
+static SegmentCategory Intro
+static SegmentCategory Sponsor
+static SegmentCategory SelfPromotion
+static SegmentCategory Outro
+static SegmentCategory Filler
}
class TrackQueueItem {
+TrackQueueItem(LavalinkTrack track)
}
PlayHandler --> PlayCommand
PlayHandler --> TrackEndedEventArgs
PlayHandler --> TrackStartedEventArgs
PlayHandler --> QueuedLavalinkPlayer
PlayHandler --> LavalinkTrack
PlayHandler --> NormalizationFilter
PlayHandler --> SegmentCategory
PlayHandler --> TrackQueueItem
User->>Bot: /play [song]
Bot->>DiscordSocketClient: Get user voice channel
DiscordSocketClient-->>Bot: Voice channel info
Bot->>IAudioService: Get or create player
IAudioService-->>Bot: Player instance
Bot->>SocketSlashCommand: Get search query
SocketSlashCommand-->>Bot: Search query
Bot->>IAudioService: Load tracks
IAudioService-->>Bot: Track collection
Bot->>LavalinkPlayer: Play track
LavalinkPlayer-->>Bot: Track started
Bot->>User: Now playing embed
```
## Steps in the code

View File

@@ -67,73 +67,23 @@ public class MessageReceivedHandler : INotificationHandler<MessageReceivedNotifi
### Class Diagram
```mermaid
classDiagram
class ClearQueueHandler {
+Task Handle(ClearQueueCommand command, CancellationToken cancellationToken)
}
class DisconnectHandler {
+Task Handle(DisconnectCommand command, CancellationToken cancellationToken)
}
class PauseHandler {
+Task Handle(PauseCommand command, CancellationToken cancellationToken)
}
class PlayHandler {
+Task Handle(PlayCommand command, CancellationToken cancellationToken)
}
class ResumeHandler {
+Task Handle(ResumeCommand command, CancellationToken cancellationToken)
}
class SkipHandler {
+Task Handle(SkipCommand command, CancellationToken cancellationToken)
}
class MessageReceivedHandler {
+Task Handle(MessageReceivedNotification notification, CancellationToken cancellationToken)
}
class IAudioService
class DiscordSocketClient
class SocketSlashCommand
class CancellationToken
class Task
class IRequestHandler
class INotificationHandler
sequenceDiagram
participant User as User
participant DiscordSocketClient as DiscordSocketClient
participant MessageReceivedHandler as MessageReceivedHandler
participant MessageReceivedNotification as MessageReceivedNotification
participant EmbedBuilder as EmbedBuilder
participant Channel as Channel
ClearQueueHandler ..|> IRequestHandler
DisconnectHandler ..|> IRequestHandler
PauseHandler ..|> IRequestHandler
PlayHandler ..|> IRequestHandler
ResumeHandler ..|> IRequestHandler
SkipHandler ..|> IRequestHandler
MessageReceivedHandler ..|> INotificationHandler
ClearQueueHandler --> IAudioService
DisconnectHandler --> IAudioService
PauseHandler --> IAudioService
PlayHandler --> IAudioService
ResumeHandler --> IAudioService
SkipHandler --> IAudioService
ClearQueueHandler --> DiscordSocketClient
DisconnectHandler --> DiscordSocketClient
PauseHandler --> DiscordSocketClient
PlayHandler --> DiscordSocketClient
ResumeHandler --> DiscordSocketClient
SkipHandler --> DiscordSocketClient
ClearQueueHandler --> SocketSlashCommand
DisconnectHandler --> SocketSlashCommand
PauseHandler --> SocketSlashCommand
PlayHandler --> SocketSlashCommand
ResumeHandler --> SocketSlashCommand
SkipHandler --> SocketSlashCommand
ClearQueueHandler --> CancellationToken
DisconnectHandler --> CancellationToken
PauseHandler --> CancellationToken
PlayHandler --> CancellationToken
ResumeHandler --> CancellationToken
SkipHandler --> CancellationToken
ClearQueueHandler --> Task
DisconnectHandler --> Task
PauseHandler --> Task
PlayHandler --> Task
ResumeHandler --> Task
SkipHandler --> Task
User->>DiscordSocketClient: Send message "!LunarisStats"
DiscordSocketClient->>MessageReceivedHandler: MessageReceivedNotification
MessageReceivedHandler->>MessageReceivedNotification: Handle(notification, cancellationToken)
MessageReceivedNotification->>MessageReceivedHandler: BotMentioned(notification, cancellationToken)
MessageReceivedHandler->>DiscordSocketClient: Get guilds and voice channels
DiscordSocketClient-->>MessageReceivedHandler: List of guilds and voice channels
MessageReceivedHandler->>EmbedBuilder: Create embed with statistics
EmbedBuilder-->>MessageReceivedHandler: Embed
MessageReceivedHandler->>Channel: Send embed message
```
### Sequence Diagram for PlayHandler
@@ -186,4 +136,104 @@ sequenceDiagram
end
```
This README provides an overview of the handlers and their responsibilities, along with class and sequence diagrams to illustrate the interactions and relationships between the components.
## Extensions.cs
#### Namespaces
- **Discord**: Provides classes for interacting with Discord.
- **Discord.WebSocket**: Provides WebSocket-specific classes for Discord.
- **Lavalink4NET**: Provides classes for interacting with Lavalink.
- **Lavalink4NET.Players**: Provides player-related classes for Lavalink.
- **Lavalink4NET.Players.Queued**: Provides queued player-related classes for Lavalink.
- **Microsoft.Extensions.Options**: Provides classes for handling options and configurations.
#### Class: `Extensions`
This static class contains extension methods for various Discord and Lavalink operations.
##### Method: `GetPlayerAsync`
- **Parameters**:
- `IAudioService audioService`: The audio service to retrieve the player from.
- `DiscordSocketClient client`: The Discord client.
- `SocketSlashCommand context`: The context of the slash command.
- `bool connectToVoiceChannel`: Whether to connect to the voice channel (default is true).
- **Returns**: `ValueTask<QueuedLavalinkPlayer?>`
- **Description**: Retrieves a `QueuedLavalinkPlayer` for the given context. If the retrieval fails, it returns null and sends an appropriate error message.
##### Method: `GetGuild`
- **Parameters**:
- `SocketSlashCommand message`: The slash command message.
- `DiscordSocketClient client`: The Discord client.
- **Returns**: `SocketGuild`
- **Description**: Retrieves the guild associated with the given slash command message. Throws an exception if the guild ID is null.
##### Method: `GetVoiceState`
- **Parameters**:
- `SocketSlashCommand message`: The slash command message.
- **Returns**: `IVoiceState`
- **Description**: Retrieves the voice state of the user who issued the slash command. Throws an exception if the user is not connected to a voice channel.
##### Method: `RespondAsync`
- **Parameters**:
- `SocketSlashCommand message`: The slash command message.
- `string content`: The content of the response.
- **Returns**: `Task`
- **Description**: Sends an ephemeral response to the slash command.
##### Method: `GetOptionValueByName`
- **Parameters**:
- `SocketSlashCommand command`: The slash command.
- `string optionName`: The name of the option to retrieve the value for.
- **Returns**: `string`
- **Description**: Retrieves the value of the specified option from the slash command. Returns an empty string if the option is not found.
# MessageModule
The `MessageModule` class provides utility methods for sending and removing messages in a Discord guild using the Discord.Net library. It maintains a dictionary to keep track of message IDs for each guild, allowing for easy removal of messages when needed.
## Methods
### `SendMessageAsync(SocketSlashCommand context, string message, DiscordSocketClient client)`
Sends a follow-up message with the specified text content in response to a slash command.
- **Parameters:**
- `context`: The `SocketSlashCommand` context in which the command was executed.
- `message`: The text content of the message to be sent.
- `client`: The `DiscordSocketClient` instance.
### `SendMessageAsync(SocketSlashCommand context, Embed message, DiscordSocketClient client)`
Sends a follow-up message with the specified embed content in response to a slash command.
- **Parameters:**
- `context`: The `SocketSlashCommand` context in which the command was executed.
- `message`: The `Embed` content of the message to be sent.
- `client`: The `DiscordSocketClient` instance.
### `RemoveMessages(SocketSlashCommand context, DiscordSocketClient client)`
Removes all tracked messages for the guild in which the command was executed.
- **Parameters:**
- `context`: The `SocketSlashCommand` context in which the command was executed.
- `client`: The `DiscordSocketClient` instance.
### `StoreForRemoval(SocketSlashCommand context, DiscordSocketClient client)`
Stores the message ID for removal and deletes any previously tracked messages for the guild.
- **Parameters:**
- `context`: The `SocketSlashCommand` context in which the command was executed.
- `client`: The `DiscordSocketClient` instance.
- **Returns:**
- The guild ID as a `ulong`.
## Usage
To use the `MessageModule` class, simply call the appropriate method from your command handling logic. For example:
```csharp
await context.SendMessageAsync("Hello, world!", client);
```
This will send a follow-up message with the text "Hello, world!" in response to the slash command.

View File

@@ -21,6 +21,7 @@
<PackageReference Include="Lavalink4NET.Discord.NET" Version="4.0.25" />
<PackageReference Include="Lavalink4NET.Integrations.Lavasrc" Version="4.0.25" />
<PackageReference Include="Lavalink4NET.Integrations.SponsorBlock" Version="4.0.25" />
<PackageReference Include="Lavalink4NET.Jellyfin" Version="1.0.0" />
<PackageReference Include="MediatR" Version="12.4.1" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />

View File

@@ -6,11 +6,14 @@ namespace Lunaris2.Service
public class VoiceChannelMonitorService
{
private readonly DiscordSocketClient _client;
private readonly Dictionary<ulong, Timer> _timers = new();
// Track a cancellation source per voice channel when the bot is alone
private readonly Dictionary<ulong, CancellationTokenSource> _leaveCtsByChannel = new();
public VoiceChannelMonitorService(DiscordSocketClient client)
{
_client = client;
// Subscribe to voice state updates to react immediately
_client.UserVoiceStateUpdated += OnUserVoiceStateUpdated;
}
public void StartMonitoring()
@@ -20,7 +23,7 @@ namespace Lunaris2.Service
while (true)
{
await CheckVoiceChannels();
await Task.Delay(TimeSpan.FromMinutes(1)); // Monitor every minute
await Task.Delay(TimeSpan.FromMinutes(1)); // Status refresh every minute
}
});
}
@@ -28,7 +31,7 @@ namespace Lunaris2.Service
private async Task CheckVoiceChannels()
{
SetStatus();
await LeaveOnAlone();
await EnsureCurrentAloneStatesScheduled();
}
private void SetStatus()
@@ -49,48 +52,137 @@ namespace Lunaris2.Service
_client.SetGameAsync($" in {channels} servers", type: ActivityType.Playing);
}
private async Task LeaveOnAlone()
// Monitor existing alone states during the periodic check to ensure timers exist
private async Task EnsureCurrentAloneStatesScheduled()
{
foreach (var guild in _client.Guilds)
{
// Find voice channels where only the bot is left
var voiceChannel = guild.VoiceChannels.FirstOrDefault(vc =>
vc.ConnectedUsers.Count == 1 &&
vc.Users.Any(u => u.Id == _client.CurrentUser.Id));
foreach (var voiceChannel in guild.VoiceChannels)
{
var botInChannel = voiceChannel.ConnectedUsers.Any(u => u.Id == _client.CurrentUser.Id);
var userCount = voiceChannel.ConnectedUsers.Count;
if (voiceChannel != null)
{
// If timer not set for this channel, start one
if (!_timers.ContainsKey(voiceChannel.Id))
if (botInChannel && userCount == 1)
{
Console.WriteLine($"Bot is alone in channel {voiceChannel.Name}, starting timer...");
_timers[voiceChannel.Id] = new Timer(async _ => await LeaveChannel(voiceChannel), null,
TimeSpan.FromMinutes(3), Timeout.InfiniteTimeSpan); // Set delay before leaving
// Schedule leave if not already scheduled
if (!_leaveCtsByChannel.ContainsKey(voiceChannel.Id))
{
ScheduleLeave(voiceChannel);
}
}
}
else
{
// Clean up timer if channel is no longer active
var timersToDispose = _timers.Where(t => guild.VoiceChannels.All(vc => vc.Id != t.Key)).ToList();
foreach (var timer in timersToDispose)
else
{
await timer.Value.DisposeAsync();
_timers.Remove(timer.Key);
Console.WriteLine($"Disposed timer for inactive voice channel ID: {timer.Key}");
// Cancel if a schedule exists but the bot is not alone anymore
if (_leaveCtsByChannel.TryGetValue(voiceChannel.Id, out var cts))
{
cts.Cancel();
_leaveCtsByChannel.Remove(voiceChannel.Id);
}
}
}
}
await Task.CompletedTask;
}
private async Task LeaveChannel(SocketVoiceChannel voiceChannel)
private Task OnUserVoiceStateUpdated(SocketUser user, SocketVoiceState before, SocketVoiceState after)
{
if (voiceChannel.ConnectedUsers.Count == 1 && voiceChannel.Users.Any(u => u.Id == _client.CurrentUser.Id))
// React only when events relate to the guild(s) and voice channels where the bot might be
var botId = _client.CurrentUser.Id;
// Determine affected channels
var beforeChannelId = before.VoiceChannel?.Id;
var afterChannelId = after.VoiceChannel?.Id;
// If the bot itself moved, we should cancel any old schedule and possibly set a new one
if (user.Id == botId)
{
Console.WriteLine($"Leaving channel {voiceChannel.Name} due to inactivity...");
await voiceChannel.DisconnectAsync();
await _timers[voiceChannel.Id].DisposeAsync();
_timers.Remove(voiceChannel.Id); // Clean up after leaving
if (beforeChannelId.HasValue && _leaveCtsByChannel.TryGetValue(beforeChannelId.Value, out var oldCts))
{
oldCts.Cancel();
_leaveCtsByChannel.Remove(beforeChannelId.Value);
}
if (afterChannelId.HasValue)
{
var channel = after.VoiceChannel!;
var isAlone = channel.ConnectedUsers.Count == 1 && channel.ConnectedUsers.Any(u => u.Id == botId);
if (isAlone && !_leaveCtsByChannel.ContainsKey(channel.Id))
{
ScheduleLeave(channel);
}
}
return Task.CompletedTask;
}
// For other users, if they join the bot's channel, cancel the leave; if they leave and bot becomes alone, schedule leave
if (afterChannelId.HasValue)
{
var channel = after.VoiceChannel!;
var botInChannel = channel.ConnectedUsers.Any(u => u.Id == botId);
var userCount = channel.ConnectedUsers.Count;
if (botInChannel && userCount > 1)
{
// Cancel any pending leave
if (_leaveCtsByChannel.TryGetValue(channel.Id, out var cts))
{
cts.Cancel();
_leaveCtsByChannel.Remove(channel.Id);
}
}
}
if (beforeChannelId.HasValue)
{
var channel = before.VoiceChannel!; // user left this channel
var botInChannel = channel.ConnectedUsers.Any(u => u.Id == botId);
var userCount = channel.ConnectedUsers.Count;
if (botInChannel && userCount == 1)
{
// Bot became alone, schedule leave
if (!_leaveCtsByChannel.ContainsKey(channel.Id))
{
ScheduleLeave(channel);
}
}
}
return Task.CompletedTask;
}
private void ScheduleLeave(SocketVoiceChannel voiceChannel)
{
var cts = new CancellationTokenSource();
_leaveCtsByChannel[voiceChannel.Id] = cts;
_ = Task.Run(async () =>
{
try
{
await Task.Delay(TimeSpan.FromMinutes(3), cts.Token);
// After delay, verify still alone
var botId = _client.CurrentUser.Id;
var isStillAlone = voiceChannel.ConnectedUsers.Count == 1 && voiceChannel.ConnectedUsers.Any(u => u.Id == botId);
if (isStillAlone)
{
Console.WriteLine($"Leaving channel {voiceChannel.Name} due to inactivity...");
await voiceChannel.DisconnectAsync();
}
}
catch (OperationCanceledException)
{
// Cancelled because someone joined or bot moved
}
finally
{
_leaveCtsByChannel.Remove(voiceChannel.Id);
cts.Dispose();
}
});
}
}
}

BIN
LOGOTYPE.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

View File

@@ -1,15 +1,17 @@
# Lunaris2 - Discord Music Bot
![Lunaris Logotype](https://github.com/Myxelium/Lunaris2.0/blob/master/LOGOTYPE.png?raw=true)
# Lunaris - Discord BOT
Lunaris2 is a Discord bot designed to play music in your server's voice channels. It's built using C# and the Discord.Net library, and it uses the LavaLink music client for audio streaming.
## Features
## 🎮Features
- Play music from YouTube directly in your Discord server.
- Skip tracks, pause, and resume playback.
- Skip tracks, pause, resume playback and more music related commands.
- Queue system to line up your favorite tracks.
- Local LLM (AI chatbot) that answers on @mentions in Discord chat. See more about it below.
## Setup
## 🤖 Setup
1. Clone the repo.
2. Extract.
@@ -27,7 +29,8 @@ The LLM is run using Ollama see more about Ollama [here](https://ollama.com/). R
## PM2 Setup
- Install PM2 and configure it following their setup guide
#### Lavalink
#### 🐦‍🔥 Lavalink
* Download Lavalink 4.X.X (.jar)
* Install Java 17
@@ -46,6 +49,11 @@ Register the Lunaris bot with PM2:
- `/play <song>`: Plays the specified song in the voice channel you're currently in.
- `/skip`: Skips the currently playing song.
## Technical Documentations
- [Application Layout](https://github.com/Myxelium/Lunaris2.0/blob/master/Bot/README.md)
* 🤖 [AI CHAT](https://github.com/Myxelium/Lunaris2.0/blob/master/Bot/Handler/ChatCommand/readme.md)
* 🎵 [Music Player](https://github.com/Myxelium/Lunaris2.0/tree/master/Bot/Handler/MusicPlayer)
## Contributing
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.

View File

@@ -2,6 +2,14 @@ server: # REST and WS server
port: 2333
address: 0.0.0.0
plugins:
jellylink:
jellyfin:
baseUrl: "http://192.168.50.244:8096"
username: "Lunaris"
password: "Lunaris"
searchLimit: 5
audioQuality: "ORIGINAL" # ORIGINAL | HIGH | MEDIUM | LOW | custom number (kbps)
audioCodec: "mp3" # mp3 | aac | opus | vorbis | flac (only used when not ORIGINAL)
lavasrc:
providers: # Custom providers for track loading. This is the default
# - "dzisrc:%ISRC%" # Deezer ISRC provider