mirror of
https://gitlab.com/Kwoth/nadekobot.git
synced 2025-09-11 17:58:26 -04:00
Fixed some crashes in response strings source generator, reorganized more submodules into their folders
This commit is contained in:
12
src/NadekoBot/Modules/Music/_Common/ICachableTrackData.cs
Normal file
12
src/NadekoBot/Modules/Music/_Common/ICachableTrackData.cs
Normal 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; }
|
||||
}
|
@@ -0,0 +1,7 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Music;
|
||||
|
||||
public interface ILocalTrackResolver : IPlatformQueryResolver
|
||||
{
|
||||
IAsyncEnumerable<ITrackInfo> ResolveDirectoryAsync(string dirPath);
|
||||
}
|
39
src/NadekoBot/Modules/Music/_Common/IMusicPlayer.cs
Normal file
39
src/NadekoBot/Modules/Music/_Common/IMusicPlayer.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using NadekoBot.Services.Database.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; }
|
||||
|
||||
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();
|
||||
}
|
22
src/NadekoBot/Modules/Music/_Common/IMusicQueue.cs
Normal file
22
src/NadekoBot/Modules/Music/_Common/IMusicQueue.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
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();
|
||||
}
|
@@ -0,0 +1,6 @@
|
||||
namespace NadekoBot.Modules.Music;
|
||||
|
||||
public interface IPlatformQueryResolver
|
||||
{
|
||||
Task<ITrackInfo?> ResolveByQueryAsync(string query);
|
||||
}
|
9
src/NadekoBot/Modules/Music/_Common/IQueuedTrackInfo.cs
Normal file
9
src/NadekoBot/Modules/Music/_Common/IQueuedTrackInfo.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Music;
|
||||
|
||||
public interface IQueuedTrackInfo : ITrackInfo
|
||||
{
|
||||
public ITrackInfo TrackInfo { get; }
|
||||
|
||||
public string Queuer { get; }
|
||||
}
|
6
src/NadekoBot/Modules/Music/_Common/IRadioResolver.cs
Normal file
6
src/NadekoBot/Modules/Music/_Common/IRadioResolver.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Music;
|
||||
|
||||
public interface IRadioResolver : IPlatformQueryResolver
|
||||
{
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Music;
|
||||
|
||||
public interface ISoundcloudResolver : IPlatformQueryResolver
|
||||
{
|
||||
bool IsSoundCloudLink(string url);
|
||||
IAsyncEnumerable<ITrackInfo> ResolvePlaylistAsync(string playlist);
|
||||
}
|
25
src/NadekoBot/Modules/Music/_Common/ITrackCacher.cs
Normal file
25
src/NadekoBot/Modules/Music/_Common/ITrackCacher.cs
Normal 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);
|
||||
}
|
11
src/NadekoBot/Modules/Music/_Common/ITrackInfo.cs
Normal file
11
src/NadekoBot/Modules/Music/_Common/ITrackInfo.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace NadekoBot.Modules.Music;
|
||||
|
||||
public interface ITrackInfo
|
||||
{
|
||||
public string Title { get; }
|
||||
public string Url { get; }
|
||||
public string Thumbnail { get; }
|
||||
public TimeSpan Duration { get; }
|
||||
public MusicPlatform Platform { get; }
|
||||
public ValueTask<string?> GetStreamUrl();
|
||||
}
|
@@ -0,0 +1,6 @@
|
||||
namespace NadekoBot.Modules.Music;
|
||||
|
||||
public interface ITrackResolveProvider
|
||||
{
|
||||
Task<ITrackInfo?> QuerySongAsync(string query, MusicPlatform? forcePlatform);
|
||||
}
|
15
src/NadekoBot/Modules/Music/_Common/IVoiceProxy.cs
Normal file
15
src/NadekoBot/Modules/Music/_Common/IVoiceProxy.cs
Normal 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();
|
||||
}
|
11
src/NadekoBot/Modules/Music/_Common/IYoutubeResolver.cs
Normal file
11
src/NadekoBot/Modules/Music/_Common/IYoutubeResolver.cs
Normal 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);
|
||||
}
|
@@ -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; }
|
||||
}
|
95
src/NadekoBot/Modules/Music/_Common/Impl/MultimediaTimer.cs
Normal file
95
src/NadekoBot/Modules/Music/_Common/Impl/MultimediaTimer.cs
Normal 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
|
||||
}
|
||||
}
|
57
src/NadekoBot/Modules/Music/_Common/Impl/MusicExtensions.cs
Normal file
57
src/NadekoBot/Modules/Music/_Common/Impl/MusicExtensions.cs
Normal 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
|
||||
};
|
||||
}
|
10
src/NadekoBot/Modules/Music/_Common/Impl/MusicPlatform.cs
Normal file
10
src/NadekoBot/Modules/Music/_Common/Impl/MusicPlatform.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Music;
|
||||
|
||||
public enum MusicPlatform
|
||||
{
|
||||
Radio,
|
||||
Youtube,
|
||||
Local,
|
||||
SoundCloud
|
||||
}
|
498
src/NadekoBot/Modules/Music/_Common/Impl/MusicPlayer.cs
Normal file
498
src/NadekoBot/Modules/Music/_Common/Impl/MusicPlayer.cs
Normal file
@@ -0,0 +1,498 @@
|
||||
using Ayu.Discord.Voice;
|
||||
using NadekoBot.Services.Database.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 ISongBuffer _songBuffer;
|
||||
|
||||
private bool _skipped;
|
||||
private int? _forceIndex;
|
||||
private readonly Thread _thread;
|
||||
private readonly Random _rng;
|
||||
|
||||
public MusicPlayer(
|
||||
IMusicQueue queue,
|
||||
ITrackResolveProvider trackResolveProvider,
|
||||
IVoiceProxy proxy,
|
||||
QualityPreset qualityPreset)
|
||||
{
|
||||
_queue = queue;
|
||||
_trackResolveProvider = trackResolveProvider;
|
||||
_proxy = proxy;
|
||||
_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;
|
||||
}
|
||||
|
||||
// todo 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);
|
||||
|
||||
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 { } forceIndex)
|
||||
{
|
||||
_queue.SetIndex(forceIndex);
|
||||
_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);
|
||||
}
|
315
src/NadekoBot/Modules/Music/_Common/Impl/MusicQueue.cs
Normal file
315
src/NadekoBot/Modules/Music/_Common/Impl/MusicQueue.cs
Normal file
@@ -0,0 +1,315 @@
|
||||
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 indermediate 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 index)
|
||||
{
|
||||
lock (locker)
|
||||
{
|
||||
var added = new QueuedTrackInfo(trackInfo, queuer);
|
||||
index = _tracks.Count;
|
||||
_tracks.AddLast(added);
|
||||
return added;
|
||||
}
|
||||
}
|
||||
|
||||
public IQueuedTrackInfo EnqueueNext(ITrackInfo trackInfo, string queuer, out int index)
|
||||
{
|
||||
lock (locker)
|
||||
{
|
||||
if (_tracks.Count == 0) return Enqueue(trackInfo, queuer, out index);
|
||||
|
||||
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);
|
||||
index = i;
|
||||
|
||||
_tracks.AddAfter(currentNode, added);
|
||||
|
||||
return added;
|
||||
}
|
||||
}
|
||||
|
||||
public void EnqueueMany(IEnumerable<ITrackInfo> tracks, string queuer)
|
||||
{
|
||||
lock (locker)
|
||||
{
|
||||
foreach (var track in tracks)
|
||||
{
|
||||
var added = new QueuedTrackInfo(track, queuer);
|
||||
_tracks.AddLast(added);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<IQueuedTrackInfo> List()
|
||||
{
|
||||
lock (locker)
|
||||
{
|
||||
return _tracks.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public IQueuedTrackInfo? GetCurrent(out int index)
|
||||
{
|
||||
lock (locker)
|
||||
{
|
||||
index = _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 index)
|
||||
{
|
||||
lock (locker)
|
||||
{
|
||||
if (index < 0 || index >= _tracks.Count)
|
||||
return false;
|
||||
|
||||
_index = index;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveAtInternal(int index, out IQueuedTrackInfo trackInfo)
|
||||
{
|
||||
var removedNode = _tracks.First!;
|
||||
int i;
|
||||
for (i = 0; i < index; 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);
|
||||
var temp = list[struck];
|
||||
list[struck] = list[i];
|
||||
list[i] = temp;
|
||||
|
||||
// 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 bool TryRemoveAt(int index, out IQueuedTrackInfo? trackInfo, out bool isCurrent)
|
||||
{
|
||||
lock (locker)
|
||||
{
|
||||
isCurrent = false;
|
||||
trackInfo = null;
|
||||
|
||||
if (index < 0 || index >= _tracks.Count)
|
||||
return false;
|
||||
|
||||
if (index == _index) isCurrent = true;
|
||||
|
||||
RemoveAtInternal(index, out trackInfo);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
209
src/NadekoBot/Modules/Music/_Common/Impl/RedisTrackCacher.cs
Normal file
209
src/NadekoBot/Modules/Music/_Common/Impl/RedisTrackCacher.cs
Normal file
@@ -0,0 +1,209 @@
|
||||
using StackExchange.Redis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace NadekoBot.Modules.Music;
|
||||
|
||||
public sealed class RedisTrackCacher : ITrackCacher
|
||||
{
|
||||
private readonly ConnectionMultiplexer _multiplexer;
|
||||
|
||||
public RedisTrackCacher(ConnectionMultiplexer multiplexer)
|
||||
=> _multiplexer = multiplexer;
|
||||
|
||||
public async Task<string?> GetOrCreateStreamLink(
|
||||
string id,
|
||||
MusicPlatform platform,
|
||||
Func<Task<(string StreamUrl, TimeSpan Expiry)>> streamUrlFactory)
|
||||
{
|
||||
var trackStreamKey = CreateStreamKey(id, platform);
|
||||
|
||||
var value = await GetStreamFromCacheInternalAsync(trackStreamKey);
|
||||
|
||||
// if there is no cached value
|
||||
if (value == default)
|
||||
{
|
||||
// otherwise retrieve and cache a new value, and run this method again
|
||||
var success = await CreateAndCacheStreamUrlAsync(trackStreamKey, streamUrlFactory);
|
||||
if (!success)
|
||||
return null;
|
||||
|
||||
return await GetOrCreateStreamLink(id, platform, streamUrlFactory);
|
||||
}
|
||||
|
||||
// cache new one for future use
|
||||
_ = Task.Run(() => CreateAndCacheStreamUrlAsync(trackStreamKey, streamUrlFactory));
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static string CreateStreamKey(string id, MusicPlatform platform)
|
||||
=> $"track:stream:{platform}:{id}";
|
||||
|
||||
private async Task<bool> CreateAndCacheStreamUrlAsync(
|
||||
string trackStreamKey,
|
||||
Func<Task<(string StreamUrl, TimeSpan Expiry)>> factory)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = await factory();
|
||||
if (data == default)
|
||||
return false;
|
||||
|
||||
await CacheStreamUrlInternalAsync(trackStreamKey, data.StreamUrl, data.Expiry);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error resolving stream link for {TrackCacheKey}", trackStreamKey);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public Task CacheStreamUrlAsync(
|
||||
string id,
|
||||
MusicPlatform platform,
|
||||
string url,
|
||||
TimeSpan expiry)
|
||||
=> CacheStreamUrlInternalAsync(CreateStreamKey(id, platform), url, expiry);
|
||||
|
||||
private async Task CacheStreamUrlInternalAsync(string trackStreamKey, string url, TimeSpan expiry)
|
||||
{
|
||||
// keys need to be expired after an hour
|
||||
// to make sure client doesn't get an expired stream url
|
||||
// to achieve this, track keys will be just pointers to real data
|
||||
// but that data will expire
|
||||
|
||||
var db = _multiplexer.GetDatabase();
|
||||
var dataKey = $"entry:{Guid.NewGuid()}:{trackStreamKey}";
|
||||
await db.StringSetAsync(dataKey, url, expiry);
|
||||
await db.ListRightPushAsync(trackStreamKey, dataKey);
|
||||
}
|
||||
|
||||
private async Task<string?> GetStreamFromCacheInternalAsync(string trackStreamKey)
|
||||
{
|
||||
// Job of the method which retrieves keys is to pop the elements
|
||||
// from the list of cached trackurls until it finds a non-expired key
|
||||
|
||||
var db = _multiplexer.GetDatabase();
|
||||
while (true)
|
||||
{
|
||||
string? dataKey = await db.ListLeftPopAsync(trackStreamKey);
|
||||
if (dataKey == default)
|
||||
return null;
|
||||
|
||||
var streamUrl = await db.StringGetAsync(dataKey);
|
||||
if (streamUrl == default)
|
||||
continue;
|
||||
|
||||
return streamUrl;
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static string CreateCachedDataKey(string id, MusicPlatform platform)
|
||||
=> $"track:data:{platform}:{id}";
|
||||
|
||||
public Task CacheTrackDataAsync(ICachableTrackData data)
|
||||
{
|
||||
var db = _multiplexer.GetDatabase();
|
||||
|
||||
var trackDataKey = CreateCachedDataKey(data.Id, data.Platform);
|
||||
var dataString = JsonSerializer.Serialize((object)data);
|
||||
// cache for 1 day
|
||||
return db.StringSetAsync(trackDataKey, dataString, TimeSpan.FromDays(1));
|
||||
}
|
||||
|
||||
public async Task<ICachableTrackData?> GetCachedDataByIdAsync(string id, MusicPlatform platform)
|
||||
{
|
||||
var db = _multiplexer.GetDatabase();
|
||||
|
||||
var trackDataKey = CreateCachedDataKey(id, platform);
|
||||
var data = await db.StringGetAsync(trackDataKey);
|
||||
if (data == default)
|
||||
return null;
|
||||
|
||||
return JsonSerializer.Deserialize<CachableTrackData>(data);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static string CreateCachedQueryDataKey(string query, MusicPlatform platform)
|
||||
=> $"track:query_to_id:{platform}:{query}";
|
||||
|
||||
public async Task<ICachableTrackData?> GetCachedDataByQueryAsync(string query, MusicPlatform platform)
|
||||
{
|
||||
query = Uri.EscapeDataString(query.Trim());
|
||||
|
||||
var db = _multiplexer.GetDatabase();
|
||||
var queryDataKey = CreateCachedQueryDataKey(query, platform);
|
||||
|
||||
var trackId = await db.StringGetAsync(queryDataKey);
|
||||
if (trackId == default)
|
||||
return null;
|
||||
|
||||
return await GetCachedDataByIdAsync(trackId, platform);
|
||||
}
|
||||
|
||||
public async Task CacheTrackDataByQueryAsync(string query, ICachableTrackData data)
|
||||
{
|
||||
query = Uri.EscapeDataString(query.Trim());
|
||||
|
||||
// first cache the data
|
||||
await CacheTrackDataAsync(data);
|
||||
|
||||
// then map the query to cached data's id
|
||||
var db = _multiplexer.GetDatabase();
|
||||
|
||||
var queryDataKey = CreateCachedQueryDataKey(query, data.Platform);
|
||||
await db.StringSetAsync(queryDataKey, data.Id, TimeSpan.FromDays(7));
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static string CreateCachedPlaylistKey(string playlistId, MusicPlatform platform)
|
||||
=> $"playlist:{platform}:{playlistId}";
|
||||
|
||||
public async Task<IReadOnlyCollection<string>> GetPlaylistTrackIdsAsync(string playlistId, MusicPlatform platform)
|
||||
{
|
||||
var db = _multiplexer.GetDatabase();
|
||||
var key = CreateCachedPlaylistKey(playlistId, platform);
|
||||
var vals = await db.ListRangeAsync(key);
|
||||
if (vals == default || vals.Length == 0)
|
||||
return Array.Empty<string>();
|
||||
|
||||
return vals.Select(x => x.ToString()).ToList();
|
||||
}
|
||||
|
||||
public async Task CachePlaylistTrackIdsAsync(string playlistId, MusicPlatform platform, IEnumerable<string> ids)
|
||||
{
|
||||
var db = _multiplexer.GetDatabase();
|
||||
var key = CreateCachedPlaylistKey(playlistId, platform);
|
||||
await db.ListRightPushAsync(key, ids.Select(x => (RedisValue)x).ToArray());
|
||||
await db.KeyExpireAsync(key, TimeSpan.FromDays(7));
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static string CreateCachedPlaylistQueryKey(string query, MusicPlatform platform)
|
||||
=> $"playlist:query:{platform}:{query}";
|
||||
|
||||
public Task CachePlaylistIdByQueryAsync(string query, MusicPlatform platform, string playlistId)
|
||||
{
|
||||
query = Uri.EscapeDataString(query.Trim());
|
||||
var key = CreateCachedPlaylistQueryKey(query, platform);
|
||||
var db = _multiplexer.GetDatabase();
|
||||
return db.StringSetAsync(key, playlistId, TimeSpan.FromDays(7));
|
||||
}
|
||||
|
||||
public async Task<string?> GetPlaylistIdByQueryAsync(string query, MusicPlatform platform)
|
||||
{
|
||||
query = Uri.EscapeDataString(query.Trim());
|
||||
var key = CreateCachedPlaylistQueryKey(query, platform);
|
||||
|
||||
var val = await _multiplexer.GetDatabase().StringGetAsync(key);
|
||||
if (val == default)
|
||||
return null;
|
||||
|
||||
return val;
|
||||
}
|
||||
}
|
31
src/NadekoBot/Modules/Music/_Common/Impl/RemoteTrackInfo.cs
Normal file
31
src/NadekoBot/Modules/Music/_Common/Impl/RemoteTrackInfo.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
namespace NadekoBot.Modules.Music;
|
||||
|
||||
public sealed class RemoteTrackInfo : ITrackInfo
|
||||
{
|
||||
public string Title { get; }
|
||||
public string Url { get; }
|
||||
public string Thumbnail { get; }
|
||||
public TimeSpan Duration { get; }
|
||||
public MusicPlatform Platform { get; }
|
||||
|
||||
private readonly Func<Task<string?>> _streamFactory;
|
||||
|
||||
public RemoteTrackInfo(
|
||||
string title,
|
||||
string url,
|
||||
string thumbnail,
|
||||
TimeSpan duration,
|
||||
MusicPlatform platform,
|
||||
Func<Task<string?>> streamFactory)
|
||||
{
|
||||
_streamFactory = streamFactory;
|
||||
Title = title;
|
||||
Url = url;
|
||||
Thumbnail = thumbnail;
|
||||
Duration = duration;
|
||||
Platform = platform;
|
||||
}
|
||||
|
||||
public async ValueTask<string?> GetStreamUrl()
|
||||
=> await _streamFactory();
|
||||
}
|
30
src/NadekoBot/Modules/Music/_Common/Impl/SimpleTrackInfo.cs
Normal file
30
src/NadekoBot/Modules/Music/_Common/Impl/SimpleTrackInfo.cs
Normal 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);
|
||||
}
|
100
src/NadekoBot/Modules/Music/_Common/Impl/VoiceProxy.cs
Normal file
100
src/NadekoBot/Modules/Music/_Common/Impl/VoiceProxy.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
#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 gateway)
|
||||
=> _gateway = gateway;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
@@ -0,0 +1,120 @@
|
||||
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;
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
@@ -0,0 +1,112 @@
|
||||
#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"))
|
||||
//File1=http://armitunes.com:8000/
|
||||
//Regex.Match(query)
|
||||
try
|
||||
{
|
||||
var m = plsRegex.Match(file);
|
||||
var res = m.Groups["url"]?.ToString();
|
||||
return res?.Trim();
|
||||
}
|
||||
catch
|
||||
{
|
||||
Log.Warning($"Failed reading .pls:\n{file}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (query.Contains(".m3u"))
|
||||
/*
|
||||
# This is a comment
|
||||
C:\xxx4xx\xxxxxx3x\xx2xxxx\xx.mp3
|
||||
C:\xxx5xx\x6xxxxxx\x7xxxxx\xx.mp3
|
||||
*/
|
||||
try
|
||||
{
|
||||
var m = m3uRegex.Match(file);
|
||||
var res = m.Groups["url"]?.ToString();
|
||||
return res?.Trim();
|
||||
}
|
||||
catch
|
||||
{
|
||||
Log.Warning($"Failed reading .m3u:\n{file}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (query.Contains(".asx"))
|
||||
//<ref href="http://armitunes.com:8000"/>
|
||||
try
|
||||
{
|
||||
var m = asxRegex.Match(file);
|
||||
var res = m.Groups["url"]?.ToString();
|
||||
return res?.Trim();
|
||||
}
|
||||
catch
|
||||
{
|
||||
Log.Warning($"Failed reading .asx:\n{file}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (query.Contains(".xspf"))
|
||||
/*
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<playlist version="1" xmlns="http://xspf.org/ns/0/">
|
||||
<trackList>
|
||||
<track><location>file:///mp3s/song_1.mp3</location></track>
|
||||
*/
|
||||
try
|
||||
{
|
||||
var m = xspfRegex.Match(file);
|
||||
var res = m.Groups["url"]?.ToString();
|
||||
return res?.Trim();
|
||||
}
|
||||
catch
|
||||
{
|
||||
Log.Warning($"Failed reading .xspf:\n{file}");
|
||||
return null;
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
}
|
@@ -0,0 +1,82 @@
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace NadekoBot.Modules.Music.Resolvers;
|
||||
|
||||
public sealed class SoundcloudResolver : ISoundcloudResolver
|
||||
{
|
||||
private readonly SoundCloudApiService _sc;
|
||||
private readonly ITrackCacher _trackCacher;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
|
||||
public SoundcloudResolver(SoundCloudApiService sc, ITrackCacher trackCacher, IHttpClientFactory httpFactory)
|
||||
{
|
||||
_sc = sc;
|
||||
_trackCacher = trackCacher;
|
||||
_httpFactory = httpFactory;
|
||||
}
|
||||
|
||||
public bool IsSoundCloudLink(string url)
|
||||
=> Regex.IsMatch(url, "(.*)(soundcloud.com|snd.sc)(.*)");
|
||||
|
||||
public async IAsyncEnumerable<ITrackInfo> ResolvePlaylistAsync(string playlist)
|
||||
{
|
||||
playlist = Uri.EscapeDataString(playlist);
|
||||
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var responseString = await http.GetStringAsync($"https://scapi.nadeko.bot/resolve?url={playlist}");
|
||||
var scvids = JObject.Parse(responseString)["tracks"]?.ToObject<SoundCloudVideo[]>();
|
||||
if (scvids is null) yield break;
|
||||
|
||||
foreach (var videosChunk in scvids.Where(x => x.Streamable is true).Chunk(5))
|
||||
{
|
||||
var cachableTracks = videosChunk.Select(VideoModelToCachedData).ToList();
|
||||
|
||||
await cachableTracks.Select(_trackCacher.CacheTrackDataAsync).WhenAll();
|
||||
foreach (var info in cachableTracks.Select(CachableDataToTrackInfo)) yield return info;
|
||||
}
|
||||
}
|
||||
|
||||
private ICachableTrackData VideoModelToCachedData(SoundCloudVideo svideo)
|
||||
=> new CachableTrackData
|
||||
{
|
||||
Title = svideo.FullName,
|
||||
Url = svideo.TrackLink,
|
||||
Thumbnail = svideo.ArtworkUrl,
|
||||
TotalDurationMs = svideo.Duration,
|
||||
Id = svideo.Id.ToString(),
|
||||
Platform = MusicPlatform.SoundCloud
|
||||
};
|
||||
|
||||
private ITrackInfo CachableDataToTrackInfo(ICachableTrackData trackData)
|
||||
=> new SimpleTrackInfo(trackData.Title,
|
||||
trackData.Url,
|
||||
trackData.Thumbnail,
|
||||
trackData.Duration,
|
||||
trackData.Platform,
|
||||
GetStreamUrl(trackData.Id));
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private string GetStreamUrl(string trackId)
|
||||
=> $"https://api.soundcloud.com/tracks/{trackId}/stream?client_id=368b0c85751007cd588d869d3ae61ac0";
|
||||
|
||||
public async Task<ITrackInfo?> ResolveByQueryAsync(string query)
|
||||
{
|
||||
var cached = await _trackCacher.GetCachedDataByQueryAsync(query, MusicPlatform.SoundCloud);
|
||||
if (cached is not null)
|
||||
return CachableDataToTrackInfo(cached);
|
||||
|
||||
var svideo = !IsSoundCloudLink(query)
|
||||
? await _sc.GetVideoByQueryAsync(query)
|
||||
: await _sc.ResolveVideoAsync(query);
|
||||
|
||||
if (svideo is null)
|
||||
return null;
|
||||
|
||||
var cachableData = VideoModelToCachedData(svideo);
|
||||
await _trackCacher.CacheTrackDataByQueryAsync(query, cachableData);
|
||||
|
||||
return CachableDataToTrackInfo(cachableData);
|
||||
}
|
||||
}
|
@@ -0,0 +1,56 @@
|
||||
namespace NadekoBot.Modules.Music;
|
||||
|
||||
public sealed class TrackResolveProvider : ITrackResolveProvider
|
||||
{
|
||||
private readonly IYoutubeResolver _ytResolver;
|
||||
private readonly ILocalTrackResolver _localResolver;
|
||||
private readonly ISoundcloudResolver _soundcloudResolver;
|
||||
private readonly IRadioResolver _radioResolver;
|
||||
|
||||
public TrackResolveProvider(
|
||||
IYoutubeResolver ytResolver,
|
||||
ILocalTrackResolver localResolver,
|
||||
ISoundcloudResolver soundcloudResolver,
|
||||
IRadioResolver radioResolver)
|
||||
{
|
||||
_ytResolver = ytResolver;
|
||||
_localResolver = localResolver;
|
||||
_soundcloudResolver = soundcloudResolver;
|
||||
_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 MusicPlatform.SoundCloud:
|
||||
return _soundcloudResolver.ResolveByQueryAsync(query);
|
||||
case null:
|
||||
var match = _ytResolver.YtVideoIdRegex.Match(query);
|
||||
if (match.Success)
|
||||
return _ytResolver.ResolveByIdAsync(match.Groups["id"].Value);
|
||||
else if (_soundcloudResolver.IsSoundCloudLink(query))
|
||||
return _soundcloudResolver.ResolveByQueryAsync(query);
|
||||
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"));
|
||||
}
|
@@ -0,0 +1,313 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
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)
|
||||
{
|
||||
_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}\"");
|
||||
|
||||
_ytdlIdOperation = new("-4 "
|
||||
+ "--geo-bypass "
|
||||
+ "--encoding UTF8 "
|
||||
+ "-f bestaudio "
|
||||
+ "-e "
|
||||
+ "--get-url "
|
||||
+ "--get-id "
|
||||
+ "--get-thumbnail "
|
||||
+ "--get-duration "
|
||||
+ "--no-check-certificate "
|
||||
+ "-- \"{0}\"");
|
||||
|
||||
_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}\"");
|
||||
}
|
||||
|
||||
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.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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user