Compare commits

..

4 Commits

Author SHA1 Message Date
e789da1e7e Update Lunaris2.csproj 2026-02-13 21:15:04 +01:00
85e3145f03 Update Contributing section with emoji 2026-02-13 21:11:00 +01:00
430aad71fb Add jellyfin source (#10) 2026-02-13 21:03:21 +01:00
3362c6bf8c Update README.md 2024-10-25 23:28:55 +02:00
5 changed files with 250 additions and 129 deletions

View File

@@ -6,6 +6,7 @@ using Lavalink4NET;
using Lavalink4NET.Events.Players; using Lavalink4NET.Events.Players;
using Lavalink4NET.Integrations.SponsorBlock; using Lavalink4NET.Integrations.SponsorBlock;
using Lavalink4NET.Integrations.SponsorBlock.Extensions; using Lavalink4NET.Integrations.SponsorBlock.Extensions;
using Lavalink4NET.Jellyfin;
using Lavalink4NET.Players.Queued; using Lavalink4NET.Players.Queued;
using Lavalink4NET.Rest.Entities.Tracks; using Lavalink4NET.Rest.Entities.Tracks;
using Lavalink4NET.Tracks; using Lavalink4NET.Tracks;
@@ -19,8 +20,7 @@ public class PlayHandler : IRequestHandler<PlayCommand>
private readonly MusicEmbed _musicEmbed; private readonly MusicEmbed _musicEmbed;
private readonly DiscordSocketClient _client; private readonly DiscordSocketClient _client;
private readonly IAudioService _audioService; private readonly IAudioService _audioService;
private SocketSlashCommand _context; private SocketSlashCommand _context = default!; // ensure initialized before use
private const int MaxTrackDuration = 30;
private LavalinkTrack? _previousTrack; private LavalinkTrack? _previousTrack;
private static readonly HashSet<ulong> SubscribedGuilds = new(); private static readonly HashSet<ulong> SubscribedGuilds = new();
@@ -34,15 +34,16 @@ public class PlayHandler : IRequestHandler<PlayCommand>
_audioService = audioService; _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. // Reset the previous track when the track ends.
_previousTrack = null; _previousTrack = null;
return Task.CompletedTask;
} }
private async Task OnTrackStarted(object sender, TrackStartedEventArgs eventargs) private async Task OnTrackStarted(object sender, TrackStartedEventArgs eventargs)
{ {
var player = sender as QueuedLavalinkPlayer; var player = eventargs.Player as QueuedLavalinkPlayer;
if (player?.CurrentTrack is null) 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 // Track has changed, update the previous track and send the embed
_previousTrack = currentTrack; _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(); try
return Task.CompletedTask;
async void PlayMusic()
{ {
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 context = command.Message; var searchQuery = context.GetOptionValueByName(Option.Input);
_context = context;
if (string.IsNullOrWhiteSpace(searchQuery))
if ((context.User as SocketGuildUser)?.VoiceChannel == null) {
await context.SendMessageAsync("Please provide search terms.", _client);
return;
}
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); // Free text was used (not a direct URL to a playlist), let's prevent queueing the whole playlist.
return; // Queue only the first track of the search result
} // var firstTrack = trackCollection.Tracks.FirstOrDefault();
if (trackCollection.Track != null)
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. await player.PlayAsync(trackCollection.Track, cancellationToken: cancellationToken).ConfigureAwait(false);
// Queue only the first track of the search result // rely on TrackStarted event to send Now Playing
// 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);
} }
else else
{ {
await context.SendMessageAsync("No tracks found.", _client); 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) 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; 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.TrackStarted += OnTrackStarted;
_audioService.TrackEnded += OnTrackEnded; _audioService.TrackEnded += OnTrackEnded;
SubscribedGuilds.Add((ulong)command.Message.GuildId!); SubscribedGuilds.Add(gid);
} }
private static async Task ApplyFilters(CancellationToken cancellationToken, QueuedLavalinkPlayer player) private static async Task ApplyFilters(CancellationToken cancellationToken, QueuedLavalinkPlayer player)
@@ -193,4 +207,4 @@ public class PlayHandler : IRequestHandler<PlayCommand>
await player.UpdateSponsorBlockCategoriesAsync(categories, cancellationToken: cancellationToken); await player.UpdateSponsorBlockCategoriesAsync(categories, cancellationToken: cancellationToken);
} }
} }

View File

@@ -28,6 +28,7 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="OllamaSharp" Version="1.1.10" /> <PackageReference Include="OllamaSharp" Version="1.1.10" />
<PackageReference Include="Lavalink4NET.Jellyfin" Version="1.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -6,11 +6,14 @@ namespace Lunaris2.Service
public class VoiceChannelMonitorService public class VoiceChannelMonitorService
{ {
private readonly DiscordSocketClient _client; 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) public VoiceChannelMonitorService(DiscordSocketClient client)
{ {
_client = client; _client = client;
// Subscribe to voice state updates to react immediately
_client.UserVoiceStateUpdated += OnUserVoiceStateUpdated;
} }
public void StartMonitoring() public void StartMonitoring()
@@ -20,7 +23,7 @@ namespace Lunaris2.Service
while (true) while (true)
{ {
await CheckVoiceChannels(); 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() private async Task CheckVoiceChannels()
{ {
SetStatus(); SetStatus();
await LeaveOnAlone(); await EnsureCurrentAloneStatesScheduled();
} }
private void SetStatus() private void SetStatus()
@@ -49,48 +52,137 @@ namespace Lunaris2.Service
_client.SetGameAsync($" in {channels} servers", type: ActivityType.Playing); _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) foreach (var guild in _client.Guilds)
{ {
// Find voice channels where only the bot is left foreach (var voiceChannel in guild.VoiceChannels)
var voiceChannel = guild.VoiceChannels.FirstOrDefault(vc => {
vc.ConnectedUsers.Count == 1 && var botInChannel = voiceChannel.ConnectedUsers.Any(u => u.Id == _client.CurrentUser.Id);
vc.Users.Any(u => u.Id == _client.CurrentUser.Id)); var userCount = voiceChannel.ConnectedUsers.Count;
if (voiceChannel != null) if (botInChannel && userCount == 1)
{
// If timer not set for this channel, start one
if (!_timers.ContainsKey(voiceChannel.Id))
{ {
Console.WriteLine($"Bot is alone in channel {voiceChannel.Name}, starting timer..."); // Schedule leave if not already scheduled
_timers[voiceChannel.Id] = new Timer(async _ => await LeaveChannel(voiceChannel), null, if (!_leaveCtsByChannel.ContainsKey(voiceChannel.Id))
TimeSpan.FromMinutes(3), Timeout.InfiniteTimeSpan); // Set delay before leaving {
ScheduleLeave(voiceChannel);
}
} }
} else
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)
{ {
await timer.Value.DisposeAsync(); // Cancel if a schedule exists but the bot is not alone anymore
_timers.Remove(timer.Key); if (_leaveCtsByChannel.TryGetValue(voiceChannel.Id, out var cts))
Console.WriteLine($"Disposed timer for inactive voice channel ID: {timer.Key}"); {
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..."); if (beforeChannelId.HasValue && _leaveCtsByChannel.TryGetValue(beforeChannelId.Value, out var oldCts))
await voiceChannel.DisconnectAsync(); {
await _timers[voiceChannel.Id].DisposeAsync(); oldCts.Cancel();
_timers.Remove(voiceChannel.Id); // Clean up after leaving _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();
}
});
} }
} }
} }

View File

@@ -4,14 +4,14 @@
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. 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. - 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. - Queue system to line up your favorite tracks.
- Local LLM (AI chatbot) that answers on @mentions in Discord chat. See more about it below. - Local LLM (AI chatbot) that answers on @mentions in Discord chat. See more about it below.
## Setup ## 🤖 Setup
1. Clone the repo. 1. Clone the repo.
2. Extract. 2. Extract.
@@ -29,7 +29,8 @@ The LLM is run using Ollama see more about Ollama [here](https://ollama.com/). R
## PM2 Setup ## PM2 Setup
- Install PM2 and configure it following their setup guide - Install PM2 and configure it following their setup guide
#### Lavalink
#### 🐦‍🔥 Lavalink
* Download Lavalink 4.X.X (.jar) * Download Lavalink 4.X.X (.jar)
* Install Java 17 * Install Java 17
@@ -48,6 +49,11 @@ Register the Lunaris bot with PM2:
- `/play <song>`: Plays the specified song in the voice channel you're currently in. - `/play <song>`: Plays the specified song in the voice channel you're currently in.
- `/skip`: Skips the currently playing song. - `/skip`: Skips the currently playing song.
## Contributing ## 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. 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 port: 2333
address: 0.0.0.0 address: 0.0.0.0
plugins: 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: lavasrc:
providers: # Custom providers for track loading. This is the default providers: # Custom providers for track loading. This is the default
# - "dzisrc:%ISRC%" # Deezer ISRC provider # - "dzisrc:%ISRC%" # Deezer ISRC provider