Restructured the project structure back to the way it was, there's no reasonable way to split the modules

This commit is contained in:
Kwoth
2024-04-26 22:26:24 +00:00
parent 6c9c8bf63e
commit e0819f760c
768 changed files with 192 additions and 1047 deletions

View File

@@ -0,0 +1,12 @@
#nullable disable
namespace NadekoBot.Modules.Music;
public interface ICachableTrackData
{
string Id { get; set; }
string Url { get; set; }
string Thumbnail { get; set; }
public TimeSpan Duration { get; }
MusicPlatform Platform { get; set; }
string Title { get; set; }
}

View File

@@ -0,0 +1,7 @@
#nullable disable
namespace NadekoBot.Modules.Music;
public interface ILocalTrackResolver : IPlatformQueryResolver
{
IAsyncEnumerable<ITrackInfo> ResolveDirectoryAsync(string dirPath);
}

View File

@@ -0,0 +1,41 @@
using Nadeko.Bot.Db.Models;
namespace NadekoBot.Modules.Music;
public interface IMusicPlayer : IDisposable
{
float Volume { get; }
bool IsPaused { get; }
bool IsStopped { get; }
bool IsKilled { get; }
int CurrentIndex { get; }
public PlayerRepeatType Repeat { get; }
bool AutoPlay { get; set; }
void Stop();
void Clear();
IReadOnlyCollection<IQueuedTrackInfo> GetQueuedTracks();
IQueuedTrackInfo? GetCurrentTrack(out int index);
void Next();
bool MoveTo(int index);
void SetVolume(int newVolume);
void Kill();
bool TryRemoveTrackAt(int index, out IQueuedTrackInfo? trackInfo);
Task<(IQueuedTrackInfo? QueuedTrack, int Index)> TryEnqueueTrackAsync(
string query,
string queuer,
bool asNext,
MusicPlatform? forcePlatform = null);
Task EnqueueManyAsync(IEnumerable<(string Query, MusicPlatform Platform)> queries, string queuer);
bool TogglePause();
IQueuedTrackInfo? MoveTrack(int from, int to);
void EnqueueTrack(ITrackInfo track, string queuer);
void EnqueueTracks(IEnumerable<ITrackInfo> tracks, string queuer);
void SetRepeat(PlayerRepeatType type);
void ShuffleQueue();
void SetFairplay();
}

View File

@@ -0,0 +1,23 @@
namespace NadekoBot.Modules.Music;
public interface IMusicQueue
{
int Index { get; }
int Count { get; }
IQueuedTrackInfo Enqueue(ITrackInfo trackInfo, string queuer, out int index);
IQueuedTrackInfo EnqueueNext(ITrackInfo song, string queuer, out int index);
void EnqueueMany(IEnumerable<ITrackInfo> tracks, string queuer);
public IReadOnlyCollection<IQueuedTrackInfo> List();
IQueuedTrackInfo? GetCurrent(out int index);
void Advance();
void Clear();
bool SetIndex(int index);
bool TryRemoveAt(int index, out IQueuedTrackInfo? trackInfo, out bool isCurrent);
void RemoveCurrent();
IQueuedTrackInfo? MoveTrack(int from, int to);
void Shuffle(Random rng);
bool IsLast();
void ReorderFairly();
}

View File

@@ -0,0 +1,6 @@
namespace NadekoBot.Modules.Music;
public interface IPlatformQueryResolver
{
Task<ITrackInfo?> ResolveByQueryAsync(string query);
}

View File

@@ -0,0 +1,9 @@
#nullable disable
namespace NadekoBot.Modules.Music;
public interface IQueuedTrackInfo : ITrackInfo
{
public ITrackInfo TrackInfo { get; }
public string Queuer { get; }
}

View File

@@ -0,0 +1,6 @@
#nullable disable
namespace NadekoBot.Modules.Music;
public interface IRadioResolver : IPlatformQueryResolver
{
}

View File

@@ -0,0 +1,25 @@
namespace NadekoBot.Modules.Music;
public interface ITrackCacher
{
Task<string?> GetOrCreateStreamLink(
string id,
MusicPlatform platform,
Func<Task<(string StreamUrl, TimeSpan Expiry)>> streamUrlFactory);
Task CacheTrackDataAsync(ICachableTrackData data);
Task<ICachableTrackData?> GetCachedDataByIdAsync(string id, MusicPlatform platform);
Task<ICachableTrackData?> GetCachedDataByQueryAsync(string query, MusicPlatform platform);
Task CacheTrackDataByQueryAsync(string query, ICachableTrackData data);
Task CacheStreamUrlAsync(
string id,
MusicPlatform platform,
string url,
TimeSpan expiry);
Task<IReadOnlyCollection<string>> GetPlaylistTrackIdsAsync(string playlistId, MusicPlatform platform);
Task CachePlaylistTrackIdsAsync(string playlistId, MusicPlatform platform, IEnumerable<string> ids);
Task CachePlaylistIdByQueryAsync(string query, MusicPlatform platform, string playlistId);
Task<string?> GetPlaylistIdByQueryAsync(string query, MusicPlatform platform);
}

View File

@@ -0,0 +1,12 @@
namespace NadekoBot.Modules.Music;
public interface ITrackInfo
{
public string Id => string.Empty;
public string Title { get; }
public string Url { get; }
public string Thumbnail { get; }
public TimeSpan Duration { get; }
public MusicPlatform Platform { get; }
public ValueTask<string?> GetStreamUrl();
}

View File

@@ -0,0 +1,6 @@
namespace NadekoBot.Modules.Music;
public interface ITrackResolveProvider
{
Task<ITrackInfo?> QuerySongAsync(string query, MusicPlatform? forcePlatform);
}

View File

@@ -0,0 +1,15 @@
#nullable disable
using Ayu.Discord.Voice;
namespace NadekoBot.Modules.Music;
public interface IVoiceProxy
{
VoiceProxy.VoiceProxyState State { get; }
public bool SendPcmFrame(VoiceClient vc, Span<byte> data, int length);
public void SetGateway(VoiceGateway gateway);
Task StartSpeakingAsync();
Task StopSpeakingAsync();
public Task StartGateway();
Task StopGateway();
}

View File

@@ -0,0 +1,11 @@
using System.Text.RegularExpressions;
namespace NadekoBot.Modules.Music;
public interface IYoutubeResolver : IPlatformQueryResolver
{
public Regex YtVideoIdRegex { get; }
public Task<ITrackInfo?> ResolveByIdAsync(string id);
IAsyncEnumerable<ITrackInfo> ResolveTracksFromPlaylistAsync(string query);
Task<ITrackInfo?> ResolveByQueryAsync(string query, bool tryExtractingId);
}

View File

@@ -0,0 +1,19 @@
#nullable disable
using System.Text.Json.Serialization;
namespace NadekoBot.Modules.Music;
public sealed class CachableTrackData : ICachableTrackData
{
public string Title { get; set; } = string.Empty;
public string Id { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
public string Thumbnail { get; set; } = string.Empty;
public double TotalDurationMs { get; set; }
[JsonIgnore]
public TimeSpan Duration
=> TimeSpan.FromMilliseconds(TotalDurationMs);
public MusicPlatform Platform { get; set; }
}

View File

@@ -0,0 +1,95 @@
#nullable disable
using System.Runtime.InteropServices;
namespace NadekoBot.Modules.Music.Common;
public sealed class MultimediaTimer : IDisposable
{
private LpTimeProcDelegate lpTimeProc;
private readonly uint _eventId;
private readonly Action<object> _callback;
private readonly object _state;
public MultimediaTimer(Action<object> callback, object state, int period)
{
if (period <= 0)
throw new ArgumentOutOfRangeException(nameof(period), "Period must be greater than 0");
_callback = callback;
_state = state;
lpTimeProc = CallbackInternal;
_eventId = timeSetEvent((uint)period, 1, lpTimeProc, 0, TimerMode.Periodic);
}
/// <summary>
/// The timeSetEvent function starts a specified timer event. The multimedia timer runs in its own thread.
/// After the event is activated, it calls the specified callback function or sets or pulses the specified
/// event object.
/// </summary>
/// <param name="uDelay">
/// Event delay, in milliseconds. If this value is not in the range of the minimum and
/// maximum event delays supported by the timer, the function returns an error.
/// </param>
/// <param name="uResolution">
/// Resolution of the timer event, in milliseconds. The resolution increases with
/// smaller values; a resolution of 0 indicates periodic events should occur with the greatest possible accuracy.
/// To reduce system overhead, however, you should use the maximum value appropriate for your application.
/// </param>
/// <param name="lpTimeProc">
/// Pointer to a callback function that is called once upon expiration of a single event or periodically upon
/// expiration of periodic events. If fuEvent specifies the TIME_CALLBACK_EVENT_SET or TIME_CALLBACK_EVENT_PULSE
/// flag, then the lpTimeProc parameter is interpreted as a handle to an event object. The event will be set or
/// pulsed upon completion of a single event or periodically upon completion of periodic events.
/// For any other value of fuEvent, the lpTimeProc parameter is a pointer to a callback function of type
/// LPTIMECALLBACK.
/// </param>
/// <param name="dwUser">User-supplied callback data.</param>
/// <param name="fuEvent"></param>
/// <returns>Timer event type. This parameter may include one of the following values.</returns>
[DllImport("Winmm.dll")]
private static extern uint timeSetEvent(
uint uDelay,
uint uResolution,
LpTimeProcDelegate lpTimeProc,
int dwUser,
TimerMode fuEvent);
/// <summary>
/// The timeKillEvent function cancels a specified timer event.
/// </summary>
/// <param name="uTimerId">
/// Identifier of the timer event to cancel.
/// This identifier was returned by the timeSetEvent function when the timer event was set up.
/// </param>
/// <returns>Returns TIMERR_NOERROR if successful or MMSYSERR_INVALPARAM if the specified timer event does not exist.</returns>
[DllImport("Winmm.dll")]
private static extern int timeKillEvent(uint uTimerId);
private void CallbackInternal(
uint uTimerId,
uint uMsg,
int dwUser,
int dw1,
int dw2)
=> _callback(_state);
public void Dispose()
{
lpTimeProc = default;
timeKillEvent(_eventId);
}
private delegate void LpTimeProcDelegate(
uint uTimerId,
uint uMsg,
int dwUser,
int dw1,
int dw2);
private enum TimerMode
{
OneShot,
Periodic
}
}

View File

@@ -0,0 +1,57 @@
#nullable disable
namespace NadekoBot.Modules.Music;
public static class MusicExtensions
{
public static string PrettyTotalTime(this IMusicPlayer mp)
{
long sum = 0;
foreach (var track in mp.GetQueuedTracks())
{
if (track.Duration == TimeSpan.MaxValue)
return "∞";
sum += track.Duration.Ticks;
}
var total = new TimeSpan(sum);
return total.ToString(@"hh\:mm\:ss");
}
public static string PrettyVolume(this IMusicPlayer mp)
=> $"🔉 {(int)(mp.Volume * 100)}%";
public static string PrettyName(this ITrackInfo trackInfo)
=> $"**[{trackInfo.Title.TrimTo(60).Replace("[", "\\[").Replace("]", "\\]")}]({trackInfo.Url.TrimTo(50, true)})**";
public static string PrettyInfo(this IQueuedTrackInfo trackInfo)
=> $"{trackInfo.PrettyTotalTime()} | {trackInfo.Platform} | {trackInfo.Queuer}";
public static string PrettyFullName(this IQueuedTrackInfo trackInfo)
=> $@"{trackInfo.PrettyName()}
`{trackInfo.PrettyTotalTime()} | {trackInfo.Platform} | {Format.Sanitize(trackInfo.Queuer.TrimTo(15))}`";
public static string PrettyTotalTime(this ITrackInfo trackInfo)
{
if (trackInfo.Duration == TimeSpan.Zero)
return "(?)";
if (trackInfo.Duration == TimeSpan.MaxValue)
return "∞";
if (trackInfo.Duration.TotalHours >= 1)
return trackInfo.Duration.ToString("""hh\:mm\:ss""");
return trackInfo.Duration.ToString("""mm\:ss""");
}
public static ICachableTrackData ToCachedData(this ITrackInfo trackInfo, string id)
=> new CachableTrackData
{
TotalDurationMs = trackInfo.Duration.TotalMilliseconds,
Id = id,
Thumbnail = trackInfo.Thumbnail,
Url = trackInfo.Url,
Platform = trackInfo.Platform,
Title = trackInfo.Title
};
}

View File

@@ -0,0 +1,9 @@
#nullable disable
namespace NadekoBot.Modules.Music;
public enum MusicPlatform
{
Radio,
Youtube,
Local,
}

View File

@@ -0,0 +1,531 @@
using Ayu.Discord.Voice;
using Nadeko.Bot.Db.Models;
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace NadekoBot.Modules.Music;
public sealed class MusicPlayer : IMusicPlayer
{
public event Func<IMusicPlayer, IQueuedTrackInfo, Task>? OnCompleted;
public event Func<IMusicPlayer, IQueuedTrackInfo, int, Task>? OnStarted;
public event Func<IMusicPlayer, Task>? OnQueueStopped;
public bool IsKilled { get; private set; }
public bool IsStopped { get; private set; }
public bool IsPaused { get; private set; }
public PlayerRepeatType Repeat { get; private set; }
public int CurrentIndex
=> _queue.Index;
public float Volume { get; private set; } = 1.0f;
private readonly AdjustVolumeDelegate _adjustVolume;
private readonly VoiceClient _vc;
private readonly IMusicQueue _queue;
private readonly ITrackResolveProvider _trackResolveProvider;
private readonly IVoiceProxy _proxy;
private readonly IGoogleApiService _googleApiService;
private readonly ISongBuffer _songBuffer;
private bool skipped;
private int? forceIndex;
private readonly Thread _thread;
private readonly Random _rng;
public bool AutoPlay { get; set; }
public MusicPlayer(
IMusicQueue queue,
ITrackResolveProvider trackResolveProvider,
IVoiceProxy proxy,
IGoogleApiService googleApiService,
QualityPreset qualityPreset,
bool autoPlay)
{
_queue = queue;
_trackResolveProvider = trackResolveProvider;
_proxy = proxy;
_googleApiService = googleApiService;
AutoPlay = autoPlay;
_rng = new NadekoRandom();
_vc = GetVoiceClient(qualityPreset);
if (_vc.BitDepth == 16)
_adjustVolume = AdjustVolumeInt16;
else
_adjustVolume = AdjustVolumeFloat32;
_songBuffer = new PoopyBufferImmortalized(_vc.InputLength);
_thread = new(async () => { await PlayLoop(); });
_thread.Start();
}
private static VoiceClient GetVoiceClient(QualityPreset qualityPreset)
=> qualityPreset switch
{
QualityPreset.Highest => new(),
QualityPreset.High => new(SampleRate._48k, Bitrate._128k, Channels.Two, FrameDelay.Delay40),
QualityPreset.Medium => new(SampleRate._48k,
Bitrate._96k,
Channels.Two,
FrameDelay.Delay40,
BitDepthEnum.UInt16),
QualityPreset.Low => new(SampleRate._48k,
Bitrate._64k,
Channels.Two,
FrameDelay.Delay40,
BitDepthEnum.UInt16),
_ => throw new ArgumentOutOfRangeException(nameof(qualityPreset), qualityPreset, null)
};
private async Task PlayLoop()
{
var sw = new Stopwatch();
while (!IsKilled)
{
// wait until a song is available in the queue
// or until the queue is resumed
var track = _queue.GetCurrent(out var index);
if (track is null || IsStopped)
{
await Task.Delay(500);
continue;
}
if (skipped)
{
skipped = false;
_queue.Advance();
continue;
}
using var cancellationTokenSource = new CancellationTokenSource();
var token = cancellationTokenSource.Token;
try
{
// light up green in vc
_ = _proxy.StartSpeakingAsync();
_ = OnStarted?.Invoke(this, track, index);
// make sure song buffer is ready to be (re)used
_songBuffer.Reset();
var streamUrl = await track.GetStreamUrl();
// start up the data source
using var source = FfmpegTrackDataSource.CreateAsync(
_vc.BitDepth,
streamUrl,
track.Platform == MusicPlatform.Local);
// start moving data from the source into the buffer
// this method will return once the sufficient prebuffering is done
await _songBuffer.BufferAsync(source, token);
// // Implemenation with multimedia timer. Works but a hassle because no support for switching
// // vcs, as any error in copying will cancel the song. Also no idea how to use this as an option
// // for selfhosters.
// if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
// {
// var cancelSource = new CancellationTokenSource();
// var cancelToken = cancelSource.Token;
// using var timer = new MultimediaTimer(_ =>
// {
// if (IsStopped || IsKilled)
// {
// cancelSource.Cancel();
// return;
// }
//
// if (_skipped)
// {
// _skipped = false;
// cancelSource.Cancel();
// return;
// }
//
// if (IsPaused)
// return;
//
// try
// {
// // this should tolerate certain number of errors
// var result = CopyChunkToOutput(_songBuffer, _vc);
// if (!result)
// cancelSource.Cancel();
//
// }
// catch (Exception ex)
// {
// Log.Warning(ex, "Something went wrong sending voice data: {ErrorMessage}", ex.Message);
// cancelSource.Cancel();
// }
//
// }, null, 20);
//
// while(true)
// await Task.Delay(1000, cancelToken);
// }
// start sending data
var ticksPerMs = 1000f / Stopwatch.Frequency;
sw.Start();
Thread.Sleep(2);
var delay = sw.ElapsedTicks * ticksPerMs > 3f ? _vc.Delay - 16 : _vc.Delay - 3;
var errorCount = 0;
while (!IsStopped && !IsKilled)
{
// doing the skip this way instead of in the condition
// ensures that a song will for sure be skipped
if (skipped)
{
skipped = false;
break;
}
if (IsPaused)
{
await Task.Delay(200);
continue;
}
sw.Restart();
var ticks = sw.ElapsedTicks;
try
{
var result = CopyChunkToOutput(_songBuffer, _vc);
// if song is finished
if (result is null)
break;
if (result is true)
{
if (errorCount > 0)
{
_ = _proxy.StartSpeakingAsync();
errorCount = 0;
}
// FUTURE windows multimedia api
// wait for slightly less than the latency
Thread.Sleep(delay);
// and then spin out the rest
while ((sw.ElapsedTicks - ticks) * ticksPerMs <= _vc.Delay - 0.1f)
Thread.SpinWait(100);
}
else
{
// result is false is either when the gateway is being swapped
// or if the bot is reconnecting, or just disconnected for whatever reason
// tolerate up to 15x200ms of failures (3 seconds)
if (++errorCount <= 15)
{
await Task.Delay(200);
continue;
}
Log.Warning("Can't send data to voice channel");
IsStopped = true;
// if errors are happening for more than 3 seconds
// Stop the player
break;
}
}
catch (Exception ex)
{
Log.Warning(ex, "Something went wrong sending voice data: {ErrorMessage}", ex.Message);
}
}
}
catch (Win32Exception)
{
IsStopped = true;
Log.Error("Please install ffmpeg and make sure it's added to your "
+ "PATH environment variable before trying again");
}
catch (OperationCanceledException)
{
Log.Information("Song skipped");
}
catch (Exception ex)
{
Log.Error(ex, "Unknown error in music loop: {ErrorMessage}", ex.Message);
}
finally
{
cancellationTokenSource.Cancel();
// turn off green in vc
_ = OnCompleted?.Invoke(this, track);
if (AutoPlay && track.Platform == MusicPlatform.Youtube)
{
try
{
var relatedSongs = await _googleApiService.GetRelatedVideosAsync(track.TrackInfo.Id, 5);
var related = relatedSongs.Shuffle().FirstOrDefault();
if (related is not null)
{
var relatedTrack =
await _trackResolveProvider.QuerySongAsync(related, MusicPlatform.Youtube);
if (relatedTrack is not null)
EnqueueTrack(relatedTrack, "Autoplay");
}
}
catch (Exception ex)
{
Log.Warning(ex, "Failed queueing a related song via autoplay");
}
}
HandleQueuePostTrack();
skipped = false;
_ = _proxy.StopSpeakingAsync();
await Task.Delay(100);
}
}
}
private bool? CopyChunkToOutput(ISongBuffer sb, VoiceClient vc)
{
var data = sb.Read(vc.InputLength, out var length);
// if nothing is read from the buffer, song is finished
if (data.Length == 0)
return null;
_adjustVolume(data, Volume);
return _proxy.SendPcmFrame(vc, data, length);
}
private void HandleQueuePostTrack()
{
if (forceIndex is { } index)
{
_queue.SetIndex(index);
forceIndex = null;
return;
}
var (repeat, isStopped) = (Repeat, IsStopped);
if (repeat == PlayerRepeatType.Track || isStopped)
return;
// if queue is being repeated, advance no matter what
if (repeat == PlayerRepeatType.None)
{
// if this is the last song,
// stop the queue
if (_queue.IsLast())
{
IsStopped = true;
OnQueueStopped?.Invoke(this);
return;
}
_queue.Advance();
return;
}
_queue.Advance();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void AdjustVolumeInt16(Span<byte> audioSamples, float volume)
{
if (Math.Abs(volume - 1f) < 0.0001f)
return;
var samples = MemoryMarshal.Cast<byte, short>(audioSamples);
for (var i = 0; i < samples.Length; i++)
{
ref var sample = ref samples[i];
sample = (short)(sample * volume);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void AdjustVolumeFloat32(Span<byte> audioSamples, float volume)
{
if (Math.Abs(volume - 1f) < 0.0001f)
return;
var samples = MemoryMarshal.Cast<byte, float>(audioSamples);
for (var i = 0; i < samples.Length; i++)
{
ref var sample = ref samples[i];
sample *= volume;
}
}
public async Task<(IQueuedTrackInfo? QueuedTrack, int Index)> TryEnqueueTrackAsync(
string query,
string queuer,
bool asNext,
MusicPlatform? forcePlatform = null)
{
var song = await _trackResolveProvider.QuerySongAsync(query, forcePlatform);
if (song is null)
return default;
int index;
if (asNext)
return (_queue.EnqueueNext(song, queuer, out index), index);
return (_queue.Enqueue(song, queuer, out index), index);
}
public async Task EnqueueManyAsync(IEnumerable<(string Query, MusicPlatform Platform)> queries, string queuer)
{
var errorCount = 0;
foreach (var chunk in queries.Chunk(5))
{
if (IsKilled)
break;
await chunk.Select(async data =>
{
var (query, platform) = data;
try
{
await TryEnqueueTrackAsync(query, queuer, false, platform);
errorCount = 0;
}
catch (Exception ex)
{
Log.Warning(ex, "Error resolving {MusicPlatform} Track {TrackQuery}", platform, query);
++errorCount;
}
})
.WhenAll();
await Task.Delay(1000);
// > 10 errors in a row = kill
if (errorCount > 10)
break;
}
}
public void EnqueueTrack(ITrackInfo track, string queuer)
=> _queue.Enqueue(track, queuer, out _);
public void EnqueueTracks(IEnumerable<ITrackInfo> tracks, string queuer)
=> _queue.EnqueueMany(tracks, queuer);
public void SetRepeat(PlayerRepeatType type)
=> Repeat = type;
public void ShuffleQueue()
=> _queue.Shuffle(_rng);
public void Stop()
=> IsStopped = true;
public void Clear()
{
_queue.Clear();
skipped = true;
}
public IReadOnlyCollection<IQueuedTrackInfo> GetQueuedTracks()
=> _queue.List();
public IQueuedTrackInfo? GetCurrentTrack(out int index)
=> _queue.GetCurrent(out index);
public void Next()
{
skipped = true;
IsStopped = false;
IsPaused = false;
}
public bool MoveTo(int index)
{
if (_queue.SetIndex(index))
{
forceIndex = index;
skipped = true;
IsStopped = false;
IsPaused = false;
return true;
}
return false;
}
public void SetVolume(int newVolume)
{
var normalizedVolume = newVolume / 100f;
if (normalizedVolume is < 0f or > 1f)
throw new ArgumentOutOfRangeException(nameof(newVolume), "Volume must be in range 0-100");
Volume = normalizedVolume;
}
public void Kill()
{
IsKilled = true;
IsStopped = true;
IsPaused = false;
skipped = true;
}
public bool TryRemoveTrackAt(int index, out IQueuedTrackInfo? trackInfo)
{
if (!_queue.TryRemoveAt(index, out trackInfo, out var isCurrent))
return false;
if (isCurrent)
skipped = true;
return true;
}
public bool TogglePause()
=> IsPaused = !IsPaused;
public IQueuedTrackInfo? MoveTrack(int from, int to)
=> _queue.MoveTrack(from, to);
public void Dispose()
{
IsKilled = true;
OnCompleted = null;
OnStarted = null;
OnQueueStopped = null;
_queue.Clear();
_songBuffer.Dispose();
_vc.Dispose();
}
private delegate void AdjustVolumeDelegate(Span<byte> data, float volume);
public void SetFairplay()
{
_queue.ReorderFairly();
}
}

View File

@@ -0,0 +1,362 @@
using Microsoft.EntityFrameworkCore.ChangeTracking;
namespace NadekoBot.Modules.Music;
public sealed partial class MusicQueue
{
private sealed class QueuedTrackInfo : IQueuedTrackInfo
{
public ITrackInfo TrackInfo { get; }
public string Queuer { get; }
public string Title
=> TrackInfo.Title;
public string Url
=> TrackInfo.Url;
public string Thumbnail
=> TrackInfo.Thumbnail;
public TimeSpan Duration
=> TrackInfo.Duration;
public MusicPlatform Platform
=> TrackInfo.Platform;
public QueuedTrackInfo(ITrackInfo trackInfo, string queuer)
{
TrackInfo = trackInfo;
Queuer = queuer;
}
public ValueTask<string?> GetStreamUrl()
=> TrackInfo.GetStreamUrl();
}
}
public sealed partial class MusicQueue : IMusicQueue
{
public int Index
{
get
{
// just make sure the internal logic runs first
// to make sure that some potential intermediate value is not returned
lock (_locker)
{
return index;
}
}
}
public int Count
{
get
{
lock (_locker)
{
return tracks.Count;
}
}
}
private LinkedList<QueuedTrackInfo> tracks;
private int index;
private readonly object _locker = new();
public MusicQueue()
{
index = 0;
tracks = new();
}
public IQueuedTrackInfo Enqueue(ITrackInfo trackInfo, string queuer, out int enqueuedAt)
{
lock (_locker)
{
var added = new QueuedTrackInfo(trackInfo, queuer);
enqueuedAt = tracks.Count;
tracks.AddLast(added);
return added;
}
}
public IQueuedTrackInfo EnqueueNext(ITrackInfo trackInfo, string queuer, out int trackIndex)
{
lock (_locker)
{
if (tracks.Count == 0)
return Enqueue(trackInfo, queuer, out trackIndex);
var currentNode = tracks.First!;
int i;
for (i = 1; i <= index; i++)
currentNode = currentNode.Next!; // can't be null because index is always in range of the count
var added = new QueuedTrackInfo(trackInfo, queuer);
trackIndex = i;
tracks.AddAfter(currentNode, added);
return added;
}
}
public void EnqueueMany(IEnumerable<ITrackInfo> toEnqueue, string queuer)
{
lock (_locker)
{
foreach (var track in toEnqueue)
{
var added = new QueuedTrackInfo(track, queuer);
tracks.AddLast(added);
}
}
}
public IReadOnlyCollection<IQueuedTrackInfo> List()
{
lock (_locker)
{
return tracks.ToList();
}
}
public IQueuedTrackInfo? GetCurrent(out int currentIndex)
{
lock (_locker)
{
currentIndex = index;
return tracks.ElementAtOrDefault(index);
}
}
public void Advance()
{
lock (_locker)
{
if (++index >= tracks.Count)
index = 0;
}
}
public void Clear()
{
lock (_locker)
{
tracks.Clear();
}
}
public bool SetIndex(int newIndex)
{
lock (_locker)
{
if (newIndex < 0 || newIndex >= tracks.Count)
return false;
index = newIndex;
return true;
}
}
private void RemoveAtInternal(int remoteAtIndex, out IQueuedTrackInfo trackInfo)
{
var removedNode = tracks.First!;
int i;
for (i = 0; i < remoteAtIndex; i++)
removedNode = removedNode.Next!;
trackInfo = removedNode.Value;
tracks.Remove(removedNode);
if (i <= index)
--index;
if (index < 0)
index = Count;
// if it was the last song in the queue
// // wrap back to start
// if (_index == Count)
// _index = 0;
// else if (i <= _index)
// if (_index == 0)
// _index = Count;
// else --_index;
}
public void RemoveCurrent()
{
lock (_locker)
{
if (index < tracks.Count)
RemoveAtInternal(index, out _);
}
}
public IQueuedTrackInfo? MoveTrack(int from, int to)
{
if (from < 0)
throw new ArgumentOutOfRangeException(nameof(from));
if (to < 0)
throw new ArgumentOutOfRangeException(nameof(to));
if (to == from)
throw new ArgumentException($"{nameof(from)} and {nameof(to)} must be different");
lock (_locker)
{
if (from >= Count || to >= Count)
return null;
// update current track index
if (from == index)
{
// if the song being moved is the current track
// it means that it will for sure end up on the destination
index = to;
}
else
{
// moving a track from below the current track means
// means it will drop down
if (from < index)
index--;
// moving a track to below the current track
// means it will rise up
if (to <= index)
index++;
// if both from and to are below _index - net change is + 1 - 1 = 0
// if from is below and to is above - net change is -1 (as the track is taken and put above)
// if from is above and to is below - net change is 1 (as the track is inserted under)
// if from is above and to is above - net change is 0
}
// get the node which needs to be moved
var fromNode = tracks.First!;
for (var i = 0; i < from; i++)
fromNode = fromNode.Next!;
// remove it from the queue
tracks.Remove(fromNode);
// if it needs to be added as a first node,
// add it directly and return
if (to == 0)
{
tracks.AddFirst(fromNode);
return fromNode.Value;
}
// else find the node at the index before the specified target
var addAfterNode = tracks.First!;
for (var i = 1; i < to; i++)
addAfterNode = addAfterNode.Next!;
// and add after it
tracks.AddAfter(addAfterNode, fromNode);
return fromNode.Value;
}
}
public void Shuffle(Random rng)
{
lock (_locker)
{
var list = tracks.ToList();
for (var i = 0; i < list.Count; i++)
{
var struck = rng.Next(i, list.Count);
(list[struck], list[i]) = (list[i], list[struck]);
// could preserving the index during shuffling be done better?
if (i == index)
index = struck;
else if (struck == index)
index = i;
}
tracks = new(list);
}
}
public bool IsLast()
{
lock (_locker)
{
return index == tracks.Count // if there are no tracks
|| index == tracks.Count - 1;
}
}
public void ReorderFairly()
{
lock (_locker)
{
var groups = new Dictionary<string, int>();
var queuers = new List<Queue<QueuedTrackInfo>>();
foreach (var track in tracks.Skip(index).Concat(tracks.Take(index)))
{
if (!groups.TryGetValue(track.Queuer, out var qIndex))
{
queuers.Add(new Queue<QueuedTrackInfo>());
qIndex = queuers.Count - 1;
groups.Add(track.Queuer, qIndex);
}
queuers[qIndex].Enqueue(track);
}
tracks = new LinkedList<QueuedTrackInfo>();
index = 0;
while (true)
{
for (var i = 0; i < queuers.Count; i++)
{
var queue = queuers[i];
tracks.AddLast(queue.Dequeue());
if (queue.Count == 0)
{
queuers.RemoveAt(i);
i--;
}
}
if (queuers.Count == 0)
break;
}
}
}
public bool TryRemoveAt(int remoteAt, out IQueuedTrackInfo? trackInfo, out bool isCurrent)
{
lock (_locker)
{
isCurrent = false;
trackInfo = null;
if (remoteAt < 0 || remoteAt >= tracks.Count)
return false;
if (remoteAt == index)
isCurrent = true;
RemoveAtInternal(remoteAt, out trackInfo);
return true;
}
}
}

View File

@@ -0,0 +1,16 @@
namespace NadekoBot.Modules.Music;
public sealed record RemoteTrackInfo(
string Id,
string Title,
string Url,
string Thumbnail,
TimeSpan Duration,
MusicPlatform Platform,
Func<Task<string?>> _streamFactory) : ITrackInfo
{
private readonly Func<Task<string?>> _streamFactory = _streamFactory;
public async ValueTask<string?> GetStreamUrl()
=> await _streamFactory();
}

View File

@@ -0,0 +1,30 @@
namespace NadekoBot.Modules.Music;
public sealed class SimpleTrackInfo : ITrackInfo
{
public string Title { get; }
public string Url { get; }
public string Thumbnail { get; }
public TimeSpan Duration { get; }
public MusicPlatform Platform { get; }
public string? StreamUrl { get; }
public SimpleTrackInfo(
string title,
string url,
string thumbnail,
TimeSpan duration,
MusicPlatform platform,
string streamUrl)
{
Title = title;
Url = url;
Thumbnail = thumbnail;
Duration = duration;
Platform = platform;
StreamUrl = streamUrl;
}
public ValueTask<string?> GetStreamUrl()
=> new(StreamUrl);
}

View File

@@ -0,0 +1,105 @@
namespace NadekoBot.Modules.Music;
public sealed class TrackCacher : ITrackCacher
{
private readonly IBotCache _cache;
public TrackCacher(IBotCache cache)
=> _cache = cache;
private TypedKey<string> GetStreamLinkKey(MusicPlatform platform, string id)
=> new($"music:stream:{platform}:{id}");
public async Task<string?> GetOrCreateStreamLink(
string id,
MusicPlatform platform,
Func<Task<(string StreamUrl, TimeSpan Expiry)>> streamUrlFactory)
{
var key = GetStreamLinkKey(platform, id);
var streamUrl = await _cache.GetOrDefaultAsync(key);
await _cache.RemoveAsync(key);
if (streamUrl == default)
{
(streamUrl, _) = await streamUrlFactory();
}
// make a new one for later use
_ = Task.Run(async () =>
{
(streamUrl, var expiry) = await streamUrlFactory();
await CacheStreamUrlAsync(id, platform, streamUrl, expiry);
});
return streamUrl;
}
public async Task CacheStreamUrlAsync(
string id,
MusicPlatform platform,
string url,
TimeSpan expiry)
=> await _cache.AddAsync(GetStreamLinkKey(platform, id), url, expiry);
// track data by id
private TypedKey<CachableTrackData> GetTrackDataKey(MusicPlatform platform, string id)
=> new($"music:track:{platform}:{id}");
public async Task CacheTrackDataAsync(ICachableTrackData data)
=> await _cache.AddAsync(GetTrackDataKey(data.Platform, data.Id), ToCachableTrackData(data));
private CachableTrackData ToCachableTrackData(ICachableTrackData data)
=> new CachableTrackData()
{
Id = data.Id,
Platform = data.Platform,
Thumbnail = data.Thumbnail,
Title = data.Title,
Url = data.Url,
};
public async Task<ICachableTrackData?> GetCachedDataByIdAsync(string id, MusicPlatform platform)
=> await _cache.GetOrDefaultAsync(GetTrackDataKey(platform, id));
// track data by query
private TypedKey<CachableTrackData> GetTrackDataQueryKey(MusicPlatform platform, string query)
=> new($"music:track:{platform}:q:{query}");
public async Task CacheTrackDataByQueryAsync(string query, ICachableTrackData data)
=> await Task.WhenAll(
_cache.AddAsync(GetTrackDataQueryKey(data.Platform, query), ToCachableTrackData(data)).AsTask(),
_cache.AddAsync(GetTrackDataKey(data.Platform, data.Id), ToCachableTrackData(data)).AsTask());
public async Task<ICachableTrackData?> GetCachedDataByQueryAsync(string query, MusicPlatform platform)
=> await _cache.GetOrDefaultAsync(GetTrackDataQueryKey(platform, query));
// playlist track ids by playlist id
private TypedKey<List<string>> GetPlaylistTracksCacheKey(string playlist, MusicPlatform platform)
=> new($"music:playlist_tracks:{platform}:{playlist}");
public async Task CachePlaylistTrackIdsAsync(string playlistId, MusicPlatform platform, IEnumerable<string> ids)
=> await _cache.AddAsync(GetPlaylistTracksCacheKey(playlistId, platform), ids.ToList());
public async Task<IReadOnlyCollection<string>> GetPlaylistTrackIdsAsync(string playlistId, MusicPlatform platform)
{
var result = await _cache.GetAsync(GetPlaylistTracksCacheKey(playlistId, platform));
if (result.TryGetValue(out var val))
return val;
return Array.Empty<string>();
}
// playlist id by query
private TypedKey<string> GetPlaylistCacheKey(string query, MusicPlatform platform)
=> new($"music:playlist_id:{platform}:{query}");
public async Task CachePlaylistIdByQueryAsync(string query, MusicPlatform platform, string playlistId)
=> await _cache.AddAsync(GetPlaylistCacheKey(query, platform), playlistId);
public async Task<string?> GetPlaylistIdByQueryAsync(string query, MusicPlatform platform)
=> await _cache.GetOrDefaultAsync(GetPlaylistCacheKey(query, platform));
}

View File

@@ -0,0 +1,102 @@
#nullable disable
using Ayu.Discord.Voice;
using Ayu.Discord.Voice.Models;
namespace NadekoBot.Modules.Music;
public sealed class VoiceProxy : IVoiceProxy
{
public enum VoiceProxyState
{
Created,
Started,
Stopped
}
private const int MAX_ERROR_COUNT = 20;
private const int DELAY_ON_ERROR_MILISECONDS = 200;
public VoiceProxyState State
=> gateway switch
{
{ Started: true, Stopped: false } => VoiceProxyState.Started,
{ Stopped: false } => VoiceProxyState.Created,
_ => VoiceProxyState.Stopped
};
private VoiceGateway gateway;
public VoiceProxy(VoiceGateway initial)
=> gateway = initial;
public bool SendPcmFrame(VoiceClient vc, Span<byte> data, int length)
{
try
{
var gw = gateway;
if (gw is null || gw.Stopped || !gw.Started)
return false;
vc.SendPcmFrame(gw, data, 0, length);
return true;
}
catch (Exception)
{
return false;
}
}
public async Task<bool> RunGatewayAction(Func<VoiceGateway, Task> action)
{
var errorCount = 0;
do
{
if (State == VoiceProxyState.Stopped)
break;
try
{
var gw = gateway;
if (gw is null || !gw.ConnectingFinished.Task.IsCompleted)
{
++errorCount;
await Task.Delay(DELAY_ON_ERROR_MILISECONDS);
Log.Debug("Gateway is not ready");
continue;
}
await action(gw);
errorCount = 0;
}
catch (Exception ex)
{
++errorCount;
await Task.Delay(DELAY_ON_ERROR_MILISECONDS);
Log.Debug(ex, "Error performing proxy gateway action");
}
} while (errorCount is > 0 and <= MAX_ERROR_COUNT);
return State != VoiceProxyState.Stopped && errorCount <= MAX_ERROR_COUNT;
}
public void SetGateway(VoiceGateway newGateway)
=> gateway = newGateway;
public Task StartSpeakingAsync()
=> RunGatewayAction(gw => gw.SendSpeakingAsync(VoiceSpeaking.State.Microphone));
public Task StopSpeakingAsync()
=> RunGatewayAction(gw => gw.SendSpeakingAsync(VoiceSpeaking.State.None));
public async Task StartGateway()
=> await gateway.Start();
public Task StopGateway()
{
if (gateway is { } gw)
return gw.StopAsync();
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,122 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Text;
namespace NadekoBot.Modules.Music.Resolvers;
public sealed class LocalTrackResolver : ILocalTrackResolver
{
private static readonly HashSet<string> _musicExtensions = new[]
{
".MP4", ".MP3", ".FLAC", ".OGG", ".WAV", ".WMA", ".WMV", ".AAC", ".MKV", ".WEBM", ".M4A", ".AA", ".AAX",
".ALAC", ".AIFF", ".MOV", ".FLV", ".OGG", ".M4V"
}.ToHashSet();
public async Task<ITrackInfo?> ResolveByQueryAsync(string query)
{
if (!File.Exists(query))
return null;
var trackDuration = await Ffprobe.GetTrackDurationAsync(query);
return new SimpleTrackInfo(Path.GetFileNameWithoutExtension(query),
$"https://google.com?q={Uri.EscapeDataString(Path.GetFileNameWithoutExtension(query))}",
"https://cdn.discordapp.com/attachments/155726317222887425/261850914783100928/1482522077_music.png",
trackDuration,
MusicPlatform.Local,
$"\"{Path.GetFullPath(query)}\"");
}
public async IAsyncEnumerable<ITrackInfo> ResolveDirectoryAsync(string dirPath)
{
DirectoryInfo dir;
try
{
dir = new(dirPath);
}
catch (Exception ex)
{
Log.Error(ex, "Specified directory {DirectoryPath} could not be opened", dirPath);
yield break;
}
var files = dir.EnumerateFiles()
.Where(x =>
{
if (!x.Attributes.HasFlag(FileAttributes.Hidden | FileAttributes.System)
&& _musicExtensions.Contains(x.Extension.ToUpperInvariant()))
return true;
return false;
})
.ToList();
var firstFile = files.FirstOrDefault()?.FullName;
if (firstFile is null)
yield break;
var firstData = await ResolveByQueryAsync(firstFile);
if (firstData is not null)
yield return firstData;
var fileChunks = files.Skip(1).Chunk(10);
foreach (var chunk in fileChunks)
{
var part = await chunk.Select(x => ResolveByQueryAsync(x.FullName)).WhenAll();
// nullable reference types being annoying
foreach (var p in part)
{
if (p is null)
continue;
yield return p;
}
}
}
}
public static class Ffprobe
{
public static async Task<TimeSpan> GetTrackDurationAsync(string query)
{
query = query.Replace("\"", "");
try
{
using var p = Process.Start(new ProcessStartInfo
{
FileName = "ffprobe",
Arguments =
$"-v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 -- \"{query}\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
CreateNoWindow = true
});
if (p is null)
return TimeSpan.Zero;
var data = await p.StandardOutput.ReadToEndAsync();
if (double.TryParse(data, out var seconds))
return TimeSpan.FromSeconds(seconds);
var errorData = await p.StandardError.ReadToEndAsync();
if (!string.IsNullOrWhiteSpace(errorData))
Log.Warning("Ffprobe warning for file {FileName}: {ErrorMessage}", query, errorData);
return TimeSpan.Zero;
}
catch (Win32Exception)
{
Log.Warning("Ffprobe was likely not installed. Local song durations will show as (?)");
}
catch (Exception ex)
{
Log.Error(ex, "Unknown exception running ffprobe; {ErrorMessage}", ex.Message);
}
return TimeSpan.Zero;
}
}

View File

@@ -0,0 +1,106 @@
#nullable disable
using System.Text.RegularExpressions;
namespace NadekoBot.Modules.Music.Resolvers;
public class RadioResolver : IRadioResolver
{
private readonly Regex _plsRegex = new(@"File1=(?<url>.*?)\n", RegexOptions.Compiled);
private readonly Regex _m3URegex = new(@"(?<url>^[^#].*)", RegexOptions.Compiled | RegexOptions.Multiline);
private readonly Regex _asxRegex = new(@"<ref href=""(?<url>.*?)""", RegexOptions.Compiled);
private readonly Regex _xspfRegex = new(@"<location>(?<url>.*?)</location>", RegexOptions.Compiled);
public async Task<ITrackInfo> ResolveByQueryAsync(string query)
{
if (IsRadioLink(query))
query = await HandleStreamContainers(query);
return new SimpleTrackInfo(query.TrimTo(50),
query,
"https://cdn.discordapp.com/attachments/155726317222887425/261850925063340032/1482522097_radio.png",
TimeSpan.MaxValue,
MusicPlatform.Radio,
query);
}
public static bool IsRadioLink(string query)
=> (query.StartsWith("http", StringComparison.InvariantCulture)
|| query.StartsWith("ww", StringComparison.InvariantCulture))
&& (query.Contains(".pls") || query.Contains(".m3u") || query.Contains(".asx") || query.Contains(".xspf"));
private async Task<string> HandleStreamContainers(string query)
{
string file = null;
try
{
using var http = new HttpClient();
file = await http.GetStringAsync(query);
}
catch
{
return query;
}
if (query.Contains(".pls"))
{
try
{
var m = _plsRegex.Match(file);
var res = m.Groups["url"]?.ToString();
return res?.Trim();
}
catch
{
Log.Warning("Failed reading .pls:\n{PlsFile}", file);
return null;
}
}
if (query.Contains(".m3u"))
{
try
{
var m = _m3URegex.Match(file);
var res = m.Groups["url"]?.ToString();
return res?.Trim();
}
catch
{
Log.Warning("Failed reading .m3u:\n{M3uFile}", file);
return null;
}
}
if (query.Contains(".asx"))
{
try
{
var m = _asxRegex.Match(file);
var res = m.Groups["url"]?.ToString();
return res?.Trim();
}
catch
{
Log.Warning("Failed reading .asx:\n{AsxFile}", file);
return null;
}
}
if (query.Contains(".xspf"))
{
try
{
var m = _xspfRegex.Match(file);
var res = m.Groups["url"]?.ToString();
return res?.Trim();
}
catch
{
Log.Warning("Failed reading .xspf:\n{XspfFile}", file);
return null;
}
}
return query;
}
}

View File

@@ -0,0 +1,49 @@
namespace NadekoBot.Modules.Music;
public sealed class TrackResolveProvider : ITrackResolveProvider
{
private readonly IYoutubeResolver _ytResolver;
private readonly ILocalTrackResolver _localResolver;
private readonly IRadioResolver _radioResolver;
public TrackResolveProvider(
IYoutubeResolver ytResolver,
ILocalTrackResolver localResolver,
IRadioResolver radioResolver)
{
_ytResolver = ytResolver;
_localResolver = localResolver;
_radioResolver = radioResolver;
}
public Task<ITrackInfo?> QuerySongAsync(string query, MusicPlatform? forcePlatform)
{
switch (forcePlatform)
{
case MusicPlatform.Radio:
return _radioResolver.ResolveByQueryAsync(query);
case MusicPlatform.Youtube:
return _ytResolver.ResolveByQueryAsync(query);
case MusicPlatform.Local:
return _localResolver.ResolveByQueryAsync(query);
case null:
var match = _ytResolver.YtVideoIdRegex.Match(query);
if (match.Success)
return _ytResolver.ResolveByIdAsync(match.Groups["id"].Value);
else if (Uri.TryCreate(query, UriKind.Absolute, out var uri) && uri.IsFile)
return _localResolver.ResolveByQueryAsync(uri.AbsolutePath);
else if (IsRadioLink(query))
return _radioResolver.ResolveByQueryAsync(query);
else
return _ytResolver.ResolveByQueryAsync(query, false);
default:
Log.Error("Unsupported platform: {MusicPlatform}", forcePlatform);
return Task.FromResult<ITrackInfo?>(null);
}
}
public static bool IsRadioLink(string query)
=> (query.StartsWith("http", StringComparison.InvariantCulture)
|| query.StartsWith("ww", StringComparison.InvariantCulture))
&& (query.Contains(".pls") || query.Contains(".m3u") || query.Contains(".asx") || query.Contains(".xspf"));
}

View File

@@ -0,0 +1,315 @@
using System.Globalization;
using System.Text.RegularExpressions;
using NadekoBot.Modules.Searches;
namespace NadekoBot.Modules.Music;
public sealed class YtdlYoutubeResolver : IYoutubeResolver
{
private static readonly string[] _durationFormats =
{
"ss", "m\\:ss", "mm\\:ss", "h\\:mm\\:ss", "hh\\:mm\\:ss", "hhh\\:mm\\:ss"
};
private static readonly Regex _expiryRegex = new(@"(?:[\?\&]expire\=(?<timestamp>\d+))");
private static readonly Regex _simplePlaylistRegex = new(@"&list=(?<id>[\w\-]{12,})", RegexOptions.Compiled);
public Regex YtVideoIdRegex { get; } =
new(@"(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\?(?:\S*?&?v\=))|youtu\.be\/)(?<id>[a-zA-Z0-9_-]{6,11})",
RegexOptions.Compiled);
private readonly ITrackCacher _trackCacher;
private readonly YtdlOperation _ytdlPlaylistOperation;
private readonly YtdlOperation _ytdlIdOperation;
private readonly YtdlOperation _ytdlSearchOperation;
private readonly IGoogleApiService _google;
public YtdlYoutubeResolver(ITrackCacher trackCacher, IGoogleApiService google, SearchesConfigService scs)
{
_trackCacher = trackCacher;
_google = google;
_ytdlPlaylistOperation = new("-4 "
+ "--geo-bypass "
+ "--encoding UTF8 "
+ "-f bestaudio "
+ "-e "
+ "--get-url "
+ "--get-id "
+ "--get-thumbnail "
+ "--get-duration "
+ "--no-check-certificate "
+ "-i "
+ "--yes-playlist "
+ "-- \"{0}\"", scs.Data.YtProvider != YoutubeSearcher.Ytdl);
_ytdlIdOperation = new("-4 "
+ "--geo-bypass "
+ "--encoding UTF8 "
+ "-f bestaudio "
+ "-e "
+ "--get-url "
+ "--get-id "
+ "--get-thumbnail "
+ "--get-duration "
+ "--no-check-certificate "
+ "-- \"{0}\"", scs.Data.YtProvider != YoutubeSearcher.Ytdl);
_ytdlSearchOperation = new("-4 "
+ "--geo-bypass "
+ "--encoding UTF8 "
+ "-f bestaudio "
+ "-e "
+ "--get-url "
+ "--get-id "
+ "--get-thumbnail "
+ "--get-duration "
+ "--no-check-certificate "
+ "--default-search "
+ "\"ytsearch:\" -- \"{0}\"", scs.Data.YtProvider != YoutubeSearcher.Ytdl);
}
private YtTrackData ResolveYtdlData(string ytdlOutputString)
{
if (string.IsNullOrWhiteSpace(ytdlOutputString))
return default;
var dataArray = ytdlOutputString.Trim().Split('\n');
if (dataArray.Length < 5)
{
Log.Information("Not enough data received: {YtdlData}", ytdlOutputString);
return default;
}
if (!TimeSpan.TryParseExact(dataArray[4], _durationFormats, CultureInfo.InvariantCulture, out var time))
time = TimeSpan.Zero;
var thumbnail = Uri.IsWellFormedUriString(dataArray[3], UriKind.Absolute) ? dataArray[3].Trim() : string.Empty;
return new(dataArray[0], dataArray[1], thumbnail, dataArray[2], time);
}
private ITrackInfo DataToInfo(in YtTrackData trackData)
=> new RemoteTrackInfo(
trackData.Id,
trackData.Title,
$"https://youtube.com/watch?v={trackData.Id}",
trackData.Thumbnail,
trackData.Duration,
MusicPlatform.Youtube,
CreateCacherFactory(trackData.Id));
private Func<Task<string?>> CreateCacherFactory(string id)
=> () => _trackCacher.GetOrCreateStreamLink(id,
MusicPlatform.Youtube,
async () => await ExtractNewStreamUrlAsync(id));
private static TimeSpan GetExpiry(string streamUrl)
{
var match = _expiryRegex.Match(streamUrl);
if (match.Success && double.TryParse(match.Groups["timestamp"].ToString(), out var timestamp))
{
var realExpiry = timestamp.ToUnixTimestamp() - DateTime.UtcNow;
if (realExpiry > TimeSpan.FromMinutes(60))
return realExpiry.Subtract(TimeSpan.FromMinutes(30));
return realExpiry;
}
return TimeSpan.FromHours(1);
}
private async Task<(string StreamUrl, TimeSpan Expiry)> ExtractNewStreamUrlAsync(string id)
{
var data = await _ytdlIdOperation.GetDataAsync(id);
var trackInfo = ResolveYtdlData(data);
if (string.IsNullOrWhiteSpace(trackInfo.StreamUrl))
return default;
return (trackInfo.StreamUrl!, GetExpiry(trackInfo.StreamUrl!));
}
public async Task<ITrackInfo?> ResolveByIdAsync(string id)
{
id = id.Trim();
var cachedData = await _trackCacher.GetCachedDataByIdAsync(id, MusicPlatform.Youtube);
if (cachedData is null)
{
Log.Information("Resolving youtube track by Id: {YoutubeId}", id);
var data = await _ytdlIdOperation.GetDataAsync(id);
var trackInfo = ResolveYtdlData(data);
if (string.IsNullOrWhiteSpace(trackInfo.Title))
return default;
var toReturn = DataToInfo(in trackInfo);
await Task.WhenAll(_trackCacher.CacheTrackDataAsync(toReturn.ToCachedData(id)),
CacheStreamUrlAsync(trackInfo));
return toReturn;
}
return DataToInfo(new(cachedData.Title, cachedData.Id, cachedData.Thumbnail, null, cachedData.Duration));
}
private Task CacheStreamUrlAsync(YtTrackData trackInfo)
=> _trackCacher.CacheStreamUrlAsync(trackInfo.Id,
MusicPlatform.Youtube,
trackInfo.StreamUrl!,
GetExpiry(trackInfo.StreamUrl!));
public async IAsyncEnumerable<ITrackInfo> ResolveTracksByPlaylistIdAsync(string playlistId)
{
Log.Information("Resolving youtube tracks from playlist: {PlaylistId}", playlistId);
var count = 0;
var ids = await _trackCacher.GetPlaylistTrackIdsAsync(playlistId, MusicPlatform.Youtube);
if (ids.Count > 0)
{
foreach (var id in ids)
{
var trackInfo = await ResolveByIdAsync(id);
if (trackInfo is null)
continue;
yield return trackInfo;
}
yield break;
}
var data = string.Empty;
var trackIds = new List<string>();
await foreach (var line in _ytdlPlaylistOperation.EnumerateDataAsync(playlistId))
{
data += line;
if (++count == 5)
{
var trackData = ResolveYtdlData(data);
data = string.Empty;
count = 0;
if (string.IsNullOrWhiteSpace(trackData.Id))
continue;
var info = DataToInfo(in trackData);
await Task.WhenAll(_trackCacher.CacheTrackDataAsync(info.ToCachedData(trackData.Id)),
CacheStreamUrlAsync(trackData));
trackIds.Add(trackData.Id);
yield return info;
}
else
data += Environment.NewLine;
}
await _trackCacher.CachePlaylistTrackIdsAsync(playlistId, MusicPlatform.Youtube, trackIds);
}
public async IAsyncEnumerable<ITrackInfo> ResolveTracksFromPlaylistAsync(string query)
{
string? playlistId;
// try to match playlist id inside the query, if a playlist url has been queried
var match = _simplePlaylistRegex.Match(query);
if (match.Success)
{
// if it's a success, just return from that playlist using the id
playlistId = match.Groups["id"].ToString();
await foreach (var track in ResolveTracksByPlaylistIdAsync(playlistId))
yield return track;
yield break;
}
// if a query is a search term, try the cache
playlistId = await _trackCacher.GetPlaylistIdByQueryAsync(query, MusicPlatform.Youtube);
if (playlistId is null)
{
// if it's not in the cache
// find playlist id by keyword using google api
try
{
var playlistIds = await _google.GetPlaylistIdsByKeywordsAsync(query);
playlistId = playlistIds.FirstOrDefault();
}
catch (Exception ex)
{
Log.Warning(ex, "Error Getting playlist id via GoogleApi");
}
// if query is not a playlist url
// and query result is not in the cache
// and api returns no values
// it means invalid input has been used,
// or google api key is not provided
if (playlistId is null)
yield break;
}
// cache the query -> playlist id for fast future lookup
await _trackCacher.CachePlaylistIdByQueryAsync(query, MusicPlatform.Youtube, playlistId);
await foreach (var track in ResolveTracksByPlaylistIdAsync(playlistId))
yield return track;
}
public Task<ITrackInfo?> ResolveByQueryAsync(string query)
=> ResolveByQueryAsync(query, true);
public async Task<ITrackInfo?> ResolveByQueryAsync(string query, bool tryResolving)
{
if (tryResolving)
{
var match = YtVideoIdRegex.Match(query);
if (match.Success)
return await ResolveByIdAsync(match.Groups["id"].Value);
}
Log.Information("Resolving youtube song by search term: {YoutubeQuery}", query);
var cachedData = await _trackCacher.GetCachedDataByQueryAsync(query, MusicPlatform.Youtube);
if (cachedData is null || string.IsNullOrWhiteSpace(cachedData.Title))
{
var stringData = await _ytdlSearchOperation.GetDataAsync(query);
var trackData = ResolveYtdlData(stringData);
var trackInfo = DataToInfo(trackData);
await Task.WhenAll(_trackCacher.CacheTrackDataByQueryAsync(query, trackInfo.ToCachedData(trackData.Id)),
CacheStreamUrlAsync(trackData));
return trackInfo;
}
return DataToInfo(new(cachedData.Title, cachedData.Id, cachedData.Thumbnail, null, cachedData.Duration));
}
private readonly struct YtTrackData
{
public readonly string Title;
public readonly string Id;
public readonly string Thumbnail;
public readonly string? StreamUrl;
public readonly TimeSpan Duration;
public YtTrackData(
string title,
string id,
string thumbnail,
string? streamUrl,
TimeSpan duration)
{
Title = title.Trim();
Id = id.Trim();
Thumbnail = thumbnail;
StreamUrl = streamUrl;
Duration = duration;
}
}
}

View File

@@ -0,0 +1,27 @@
#nullable disable
using Microsoft.EntityFrameworkCore;
using Nadeko.Bot.Db.Models;
namespace NadekoBot.Db;
public static class MusicPlayerSettingsExtensions
{
public static async Task<MusicPlayerSettings> ForGuildAsync(this DbSet<MusicPlayerSettings> settings, ulong guildId)
{
var toReturn = await settings.AsQueryable().FirstOrDefaultAsync(x => x.GuildId == guildId);
if (toReturn is null)
{
var newSettings = new MusicPlayerSettings
{
GuildId = guildId,
PlayerRepeat = PlayerRepeatType.Queue
};
await settings.AddAsync(newSettings);
return newSettings;
}
return toReturn;
}
}

View File

@@ -0,0 +1,10 @@
#nullable disable
namespace Nadeko.Bot.Db.Models;
public class MusicPlaylist : DbEntity
{
public string Name { get; set; }
public string Author { get; set; }
public ulong AuthorId { get; set; }
public List<PlaylistSong> Songs { get; set; } = new();
}

View File

@@ -0,0 +1,19 @@
#nullable disable
using Microsoft.EntityFrameworkCore;
using Nadeko.Bot.Db.Models;
namespace NadekoBot.Db;
public static class MusicPlaylistExtensions
{
public static List<MusicPlaylist> GetPlaylistsOnPage(this DbSet<MusicPlaylist> playlists, int num)
{
if (num < 1)
throw new ArgumentOutOfRangeException(nameof(num));
return playlists.AsQueryable().Skip((num - 1) * 20).Take(20).Include(pl => pl.Songs).ToList();
}
public static MusicPlaylist GetWithSongs(this DbSet<MusicPlaylist> playlists, int id)
=> playlists.Include(mpl => mpl.Songs).FirstOrDefault(mpl => mpl.Id == id);
}

View File

@@ -0,0 +1,61 @@
#nullable disable
namespace Nadeko.Bot.Db.Models;
public class MusicPlayerSettings
{
/// <summary>
/// Auto generated Id
/// </summary>
public int Id { get; set; }
/// <summary>
/// Id of the guild
/// </summary>
public ulong GuildId { get; set; }
/// <summary>
/// Queue repeat type
/// </summary>
public PlayerRepeatType PlayerRepeat { get; set; } = PlayerRepeatType.Queue;
/// <summary>
/// Channel id the bot will always try to send track related messages to
/// </summary>
public ulong? MusicChannelId { get; set; }
/// <summary>
/// Default volume player will be created with
/// </summary>
public int Volume { get; set; } = 100;
/// <summary>
/// Whether the bot should auto disconnect from the voice channel once the queue is done
/// This only has effect if
/// </summary>
public bool AutoDisconnect { get; set; }
/// <summary>
/// Selected quality preset for the music player
/// </summary>
public QualityPreset QualityPreset { get; set; }
/// <summary>
/// Whether the bot will automatically queue related songs
/// </summary>
public bool AutoPlay { get; set; }
}
public enum QualityPreset
{
Highest,
High,
Medium,
Low
}
public enum PlayerRepeatType
{
None,
Track,
Queue
}