Global usings and file scoped namespaces

This commit is contained in:
Kwoth
2021-12-19 05:14:11 +01:00
parent bc31dae965
commit ee33313519
548 changed files with 47528 additions and 49115 deletions

View File

@@ -1,14 +1,11 @@
using System;
namespace NadekoBot.Modules.Music;
namespace NadekoBot.Modules.Music
public interface ICachableTrackData
{
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; }
}
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

@@ -1,9 +1,6 @@
using System.Collections.Generic;
namespace NadekoBot.Modules.Music;
namespace NadekoBot.Modules.Music
public interface ILocalTrackResolver : IPlatformQueryResolver
{
public interface ILocalTrackResolver : IPlatformQueryResolver
{
IAsyncEnumerable<ITrackInfo> ResolveDirectoryAsync(string dirPath);
}
IAsyncEnumerable<ITrackInfo> ResolveDirectoryAsync(string dirPath);
}

View File

@@ -1,39 +1,36 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Threading.Tasks;
using NadekoBot.Services.Database.Models;
#nullable enable
namespace NadekoBot.Modules.Music
namespace NadekoBot.Modules.Music;
public interface IMusicPlayer : IDisposable
{
public interface IMusicPlayer : IDisposable
{
float Volume { get; }
bool IsPaused { get; }
bool IsStopped { get; }
bool IsKilled { get; }
int CurrentIndex { get; }
public PlayerRepeatType Repeat { get; }
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 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);
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();
}
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();
}

View File

@@ -1,27 +1,23 @@
#nullable enable
using System;
using System.Collections.Generic;
namespace NadekoBot.Modules.Music;
namespace NadekoBot.Modules.Music
public interface IMusicQueue
{
public interface IMusicQueue
{
IQueuedTrackInfo Enqueue(ITrackInfo trackInfo, string queuer, out int index);
IQueuedTrackInfo EnqueueNext(ITrackInfo song, string queuer, out int index);
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);
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);
int Index { get; }
int Count { get; }
void RemoveCurrent();
IQueuedTrackInfo? MoveTrack(int from, int to);
void Shuffle(Random rng);
bool IsLast();
}
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);
int Index { get; }
int Count { get; }
void RemoveCurrent();
IQueuedTrackInfo? MoveTrack(int from, int to);
void Shuffle(Random rng);
bool IsLast();
}

View File

@@ -1,10 +1,9 @@
#nullable enable
using System.Threading.Tasks;
namespace NadekoBot.Modules.Music
namespace NadekoBot.Modules.Music;
public interface IPlatformQueryResolver
{
public interface IPlatformQueryResolver
{
Task<ITrackInfo?> ResolveByQueryAsync(string query);
}
Task<ITrackInfo?> ResolveByQueryAsync(string query);
}

View File

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

View File

@@ -1,7 +1,6 @@
namespace NadekoBot.Modules.Music
namespace NadekoBot.Modules.Music;
public interface IRadioResolver : IPlatformQueryResolver
{
public interface IRadioResolver : IPlatformQueryResolver
{
}
}

View File

@@ -1,10 +1,7 @@
using System.Collections.Generic;
namespace NadekoBot.Modules.Music;
namespace NadekoBot.Modules.Music
public interface ISoundcloudResolver : IPlatformQueryResolver
{
public interface ISoundcloudResolver : IPlatformQueryResolver
{
bool IsSoundCloudLink(string url);
IAsyncEnumerable<ITrackInfo> ResolvePlaylistAsync(string playlist);
}
bool IsSoundCloudLink(string url);
IAsyncEnumerable<ITrackInfo> ResolvePlaylistAsync(string playlist);
}

View File

@@ -1,26 +1,23 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace NadekoBot.Modules.Music
{
public interface ITrackCacher
{
Task<string?> GetOrCreateStreamLink(
string id,
MusicPlatform platform,
Func<Task<(string StreamUrl, TimeSpan Expiry)>> streamUrlFactory
);
namespace NadekoBot.Modules.Music;
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);
}
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

@@ -1,16 +1,14 @@
#nullable enable
using System;
using System.Threading.Tasks;
namespace NadekoBot.Modules.Music
namespace NadekoBot.Modules.Music;
public interface ITrackInfo
{
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();
}
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

@@ -1,10 +1,9 @@
#nullable enable
using System.Threading.Tasks;
namespace NadekoBot.Modules.Music
namespace NadekoBot.Modules.Music;
public interface ITrackResolveProvider
{
public interface ITrackResolveProvider
{
Task<ITrackInfo?> QuerySongAsync(string query, MusicPlatform? forcePlatform);
}
Task<ITrackInfo?> QuerySongAsync(string query, MusicPlatform? forcePlatform);
}

View File

@@ -1,17 +1,15 @@
using System;
using System.Threading.Tasks;
using System.Threading.Tasks;
using Ayu.Discord.Voice;
namespace NadekoBot.Modules.Music
namespace NadekoBot.Modules.Music;
public interface IVoiceProxy
{
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();
}
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

@@ -1,15 +1,13 @@
#nullable enable
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace NadekoBot.Modules.Music
namespace NadekoBot.Modules.Music;
public interface IYoutubeResolver : IPlatformQueryResolver
{
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);
}
public Regex YtVideoIdRegex { get; }
public Task<ITrackInfo?> ResolveByIdAsync(string id);
IAsyncEnumerable<ITrackInfo> ResolveTracksFromPlaylistAsync(string query);
Task<ITrackInfo?> ResolveByQueryAsync(string query, bool tryExtractingId);
}

View File

@@ -1,17 +1,15 @@
using System;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
namespace NadekoBot.Modules.Music
namespace NadekoBot.Modules.Music;
public sealed class CachableTrackData : ICachableTrackData
{
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; }
}
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

@@ -1,92 +1,89 @@
using System;
using System.Runtime.InteropServices;
using Serilog;
using System.Runtime.InteropServices;
namespace NadekoBot.Modules.Music.Common
namespace NadekoBot.Modules.Music.Common;
public sealed class MultimediaTimer : IDisposable
{
public sealed class MultimediaTimer : IDisposable
private delegate void LpTimeProcDelegate(uint uTimerID, uint uMsg, int dwUser, int dw1, int dw2);
/// <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
);
private enum TimerMode
{
private delegate void LpTimeProcDelegate(uint uTimerID, uint uMsg, int dwUser, int dw1, int dw2);
/// <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
);
private enum TimerMode
{
OneShot,
Periodic,
}
OneShot,
Periodic,
}
/// <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
);
/// <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 LpTimeProcDelegate _lpTimeProc;
private readonly uint _eventId;
private readonly Action<object> _callback;
private readonly object _state;
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");
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;
_callback = callback;
_state = state;
_lpTimeProc = CallbackInternal;
_eventId = timeSetEvent((uint)period, 1, _lpTimeProc, 0, TimerMode.Periodic);
}
_lpTimeProc = CallbackInternal;
_eventId = timeSetEvent((uint)period, 1, _lpTimeProc, 0, TimerMode.Periodic);
}
private void CallbackInternal(uint uTimerId, uint uMsg, int dwUser, int dw1, int dw2)
{
_callback(_state);
}
private void CallbackInternal(uint uTimerId, uint uMsg, int dwUser, int dw1, int dw2)
{
_callback(_state);
}
public void Dispose()
{
_lpTimeProc = default;
timeKillEvent(_eventId);
}
public void Dispose()
{
_lpTimeProc = default;
timeKillEvent(_eventId);
}
}

View File

@@ -1,61 +1,59 @@
using System;
using Discord;
using Discord;
using NadekoBot.Extensions;
namespace NadekoBot.Modules.Music
namespace NadekoBot.Modules.Music;
public static class MusicExtensions
{
public static class MusicExtensions
public static string PrettyTotalTime(this IMusicPlayer mp)
{
public static string PrettyTotalTime(this IMusicPlayer mp)
long sum = 0;
foreach (var track in mp.GetQueuedTracks())
{
long sum = 0;
foreach (var track in mp.GetQueuedTracks())
{
if (track.Duration == TimeSpan.MaxValue)
return "∞";
if (track.Duration == TimeSpan.MaxValue)
return "∞";
sum += track.Duration.Ticks;
}
var total = new TimeSpan(sum);
return total.ToString(@"hh\:mm\:ss");
sum += track.Duration.Ticks;
}
public static string PrettyVolume(this IMusicPlayer mp)
=> $"🔉 {(int) (mp.Volume * 100)}%";
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 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 PrettyInfo(this IQueuedTrackInfo trackInfo)
=> $"{trackInfo.PrettyTotalTime()} | {trackInfo.Platform} | {trackInfo.Queuer}";
public static string PrettyFullName(this IQueuedTrackInfo trackInfo)
=> $@"{trackInfo.PrettyName()}
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");
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
};
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

@@ -1,10 +1,9 @@
namespace NadekoBot.Modules.Music
namespace NadekoBot.Modules.Music;
public enum MusicPlatform
{
public enum MusicPlatform
{
Radio,
Youtube,
Local,
SoundCloud,
}
Radio,
Youtube,
Local,
SoundCloud,
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,323 +1,319 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace NadekoBot.Modules.Music
namespace NadekoBot.Modules.Music;
public sealed partial class MusicQueue
{
public sealed partial class MusicQueue
private sealed class QueuedTrackInfo : IQueuedTrackInfo
{
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)
{
public ITrackInfo TrackInfo { get; }
public string Queuer { get; }
TrackInfo = trackInfo;
Queuer = queuer;
}
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 ValueTask<string?> GetStreamUrl() => TrackInfo.GetStreamUrl();
}
}
public sealed partial class MusicQueue : IMusicQueue
{
private LinkedList<QueuedTrackInfo> _tracks;
public QueuedTrackInfo(ITrackInfo trackInfo, string queuer)
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)
{
TrackInfo = trackInfo;
Queuer = queuer;
return _index;
}
public ValueTask<string?> GetStreamUrl() => TrackInfo.GetStreamUrl();
}
}
public sealed partial class MusicQueue : IMusicQueue
private int _index;
public int Count
{
private LinkedList<QueuedTrackInfo> _tracks;
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;
}
}
}
private int _index;
public int Count
{
get
{
lock (locker)
{
return _tracks.Count;
}
}
}
private readonly object locker = new object();
public MusicQueue()
{
_index = 0;
_tracks = new LinkedList<QueuedTrackInfo>();
}
public IQueuedTrackInfo Enqueue(ITrackInfo trackInfo, string queuer, out int index)
get
{
lock (locker)
{
var added = new QueuedTrackInfo(trackInfo, queuer);
index = _tracks.Count;
_tracks.AddLast(added);
return added;
return _tracks.Count;
}
}
}
public IQueuedTrackInfo EnqueueNext(ITrackInfo trackInfo, string queuer, out int index)
private readonly object locker = new object();
public MusicQueue()
{
_index = 0;
_tracks = new LinkedList<QueuedTrackInfo>();
}
public IQueuedTrackInfo Enqueue(ITrackInfo trackInfo, string queuer, out int index)
{
lock (locker)
{
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)
{
if (_tracks.Count == 0)
{
return Enqueue(trackInfo, queuer, out index);
}
LinkedListNode<QueuedTrackInfo> 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;
return Enqueue(trackInfo, queuer, out index);
}
}
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!;
LinkedListNode<QueuedTrackInfo> currentNode = _tracks.First!;
int i;
for (i = 0; i < index; i++)
for (i = 1; i <= _index; i++)
{
removedNode = removedNode.Next!;
currentNode = currentNode.Next!; // can't be null because index is always in range of the count
}
trackInfo = removedNode.Value;
_tracks.Remove(removedNode);
var added = new QueuedTrackInfo(trackInfo, queuer);
index = i;
if (i <= _index)
--_index;
_tracks.AddAfter(currentNode, added);
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;
return added;
}
}
public void RemoveCurrent()
public void EnqueueMany(IEnumerable<ITrackInfo> tracks, string queuer)
{
lock (locker)
{
lock (locker)
foreach (var track in tracks)
{
if (_index < _tracks.Count)
RemoveAtInternal(_index, out _);
var added = new QueuedTrackInfo(track, queuer);
_tracks.AddLast(added);
}
}
}
public IQueuedTrackInfo? MoveTrack(int from, int to)
public IReadOnlyCollection<IQueuedTrackInfo> List()
{
lock (locker)
{
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");
return _tracks.ToList();
}
}
lock (locker)
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 (from >= Count || to >= Count)
return null;
// 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--;
// 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++;
// 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
}
// 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!;
// 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);
// 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);
// 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)
public void Shuffle(Random rng)
{
lock (locker)
{
lock (locker)
var list = _tracks.ToList();
for (var i = 0; i < list.Count; i++)
{
var list = _tracks.ToList();
var struck = rng.Next(i, list.Count);
var temp = list[struck];
list[struck] = list[i];
list[i] = temp;
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 LinkedList<QueuedTrackInfo>(list);
// could preserving the index during shuffling be done better?
if (i == _index)
_index = struck;
else if (struck == _index)
_index = i;
}
_tracks = new LinkedList<QueuedTrackInfo>(list);
}
}
public bool IsLast()
public bool IsLast()
{
lock (locker)
{
lock (locker)
{
return _index == _tracks.Count // if there are no tracks
|| _index == _tracks.Count - 1;
}
return _index == _tracks.Count // if there are no tracks
|| _index == _tracks.Count - 1;
}
}
public bool TryRemoveAt(int index, out IQueuedTrackInfo? trackInfo, out bool isCurrent)
public bool TryRemoveAt(int index, out IQueuedTrackInfo? trackInfo, out bool isCurrent)
{
lock (locker)
{
lock (locker)
isCurrent = false;
trackInfo = null;
if (index < 0 || index >= _tracks.Count)
return false;
if (index == _index)
{
isCurrent = false;
trackInfo = null;
if (index < 0 || index >= _tracks.Count)
return false;
if (index == _index)
{
isCurrent = true;
}
RemoveAtInternal(index, out trackInfo);
return true;
isCurrent = true;
}
RemoveAtInternal(index, out trackInfo);
return true;
}
}
}

View File

@@ -1,213 +1,208 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading.Tasks;
using Serilog;
using StackExchange.Redis;
namespace NadekoBot.Modules.Music
namespace NadekoBot.Modules.Music;
public sealed class RedisTrackCacher : ITrackCacher
{
public sealed class RedisTrackCacher : ITrackCacher
private readonly ConnectionMultiplexer _multiplexer;
public RedisTrackCacher(ConnectionMultiplexer multiplexer)
{
private readonly ConnectionMultiplexer _multiplexer;
public RedisTrackCacher(ConnectionMultiplexer multiplexer)
{
_multiplexer = multiplexer;
}
_multiplexer = multiplexer;
}
public async Task<string?> GetOrCreateStreamLink(
string id,
MusicPlatform platform,
Func<Task<(string StreamUrl, TimeSpan Expiry)>> streamUrlFactory
)
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)
{
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;
// 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;
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}";
[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)
private async Task<bool> CreateAndCacheStreamUrlAsync(
string trackStreamKey,
Func<Task<(string StreamUrl, TimeSpan Expiry)>> factory)
{
try
{
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: 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, expiry: 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);
var data = await factory();
if (data == default)
return null;
return false;
return JsonSerializer.Deserialize<CachableTrackData>(data);
await CacheStreamUrlInternalAsync(trackStreamKey, data.StreamUrl, data.Expiry);
return true;
}
[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)
catch (Exception ex)
{
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;
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: 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, expiry: 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;
}
}

View File

@@ -1,30 +1,28 @@
#nullable enable
using System;
using System.Threading.Tasks;
namespace NadekoBot.Modules.Music
namespace NadekoBot.Modules.Music;
public sealed class RemoteTrackInfo : ITrackInfo
{
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)
{
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();
_streamFactory = streamFactory;
Title = title;
Url = url;
Thumbnail = thumbnail;
Duration = duration;
Platform = platform;
}
public async ValueTask<string?> GetStreamUrl() => await _streamFactory();
}

View File

@@ -1,28 +1,26 @@
#nullable enable
using System;
using System.Threading.Tasks;
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 ValueTask<string?> GetStreamUrl() => new ValueTask<string?>(StreamUrl);
namespace NadekoBot.Modules.Music;
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 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 ValueTask<string?> GetStreamUrl() => new ValueTask<string?>(StreamUrl);
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;
}
}

View File

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

View File

@@ -1,133 +1,126 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using NadekoBot.Modules.Music;
using NadekoBot.Extensions;
using Serilog;
#nullable enable
namespace NadekoBot.Modules.Music.Resolvers
namespace NadekoBot.Modules.Music.Resolvers;
public sealed class LocalTrackResolver : ILocalTrackResolver
{
public sealed class LocalTrackResolver : ILocalTrackResolver
private static readonly HashSet<string> _musicExtensions = new[]
{
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();
".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;
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 DirectoryInfo(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 Task.WhenAll(chunk.Select(x => ResolveByQueryAsync(x.FullName)));
// nullable reference types being annoying
foreach (var p in part)
{
if (p is null)
continue;
yield return p;
}
}
}
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 static class Ffprobe
public async IAsyncEnumerable<ITrackInfo> ResolveDirectoryAsync(string dirPath)
{
public static async Task<TimeSpan> GetTrackDurationAsync(string query)
DirectoryInfo dir;
try
{
query = query.Replace("\"", "");
dir = new DirectoryInfo(dirPath);
}
catch (Exception ex)
{
Log.Error(ex, "Specified directory {DirectoryPath} could not be opened", dirPath);
yield break;
}
try
var files = dir.EnumerateFiles()
.Where(x =>
{
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 (!x.Attributes.HasFlag(FileAttributes.Hidden | FileAttributes.System)
&& _musicExtensions.Contains(x.Extension.ToUpperInvariant())) return true;
return false;
});
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);
}
var firstFile = files.FirstOrDefault()?.FullName;
if (firstFile is null)
yield break;
return TimeSpan.Zero;
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 Task.WhenAll(chunk.Select(x => ResolveByQueryAsync(x.FullName)));
// 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

@@ -1,135 +1,131 @@
using System;
using System.Net.Http;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using NadekoBot.Modules.Music;
using NadekoBot.Extensions;
using Serilog;
namespace NadekoBot.Modules.Music.Resolvers
namespace NadekoBot.Modules.Music.Resolvers;
public class RadioResolver : IRadioResolver
{
public class RadioResolver : IRadioResolver
private readonly Regex plsRegex = new Regex("File1=(?<url>.*?)\\n", RegexOptions.Compiled);
private readonly Regex m3uRegex = new Regex("(?<url>^[^#].*)", RegexOptions.Compiled | RegexOptions.Multiline);
private readonly Regex asxRegex = new Regex("<ref href=\"(?<url>.*?)\"", RegexOptions.Compiled);
private readonly Regex xspfRegex = new Regex("<location>(?<url>.*?)</location>", RegexOptions.Compiled);
public RadioResolver()
{
private readonly Regex plsRegex = new Regex("File1=(?<url>.*?)\\n", RegexOptions.Compiled);
private readonly Regex m3uRegex = new Regex("(?<url>^[^#].*)", RegexOptions.Compiled | RegexOptions.Multiline);
private readonly Regex asxRegex = new Regex("<ref href=\"(?<url>.*?)\"", RegexOptions.Compiled);
private readonly Regex xspfRegex = new Regex("<location>(?<url>.*?)</location>", RegexOptions.Compiled);
}
public RadioResolver()
public async Task<ITrackInfo> ResolveByQueryAsync(string query)
{
if (IsRadioLink(query))
query = await HandleStreamContainers(query).ConfigureAwait(false);
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).ConfigureAwait(false);
}
}
public async Task<ITrackInfo> ResolveByQueryAsync(string query)
catch
{
if (IsRadioLink(query))
query = await HandleStreamContainers(query).ConfigureAwait(false);
return new SimpleTrackInfo(
query.TrimTo(50),
query,
"https://cdn.discordapp.com/attachments/155726317222887425/261850925063340032/1482522097_radio.png",
TimeSpan.MaxValue,
MusicPlatform.Radio,
query
);
return 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)
if (query.Contains(".pls"))
{
string file = null;
//File1=http://armitunes.com:8000/
//Regex.Match(query)
try
{
using (var http = new HttpClient())
{
file = await http.GetStringAsync(query).ConfigureAwait(false);
}
var m = plsRegex.Match(file);
var res = m.Groups["url"]?.ToString();
return res?.Trim();
}
catch
{
return query;
Log.Warning($"Failed reading .pls:\n{file}");
return null;
}
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;
}
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;
}
}

View File

@@ -1,100 +1,95 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using NadekoBot.Services;
using NadekoBot.Extensions;
using Newtonsoft.Json.Linq;
#nullable enable
namespace NadekoBot.Modules.Music.Resolvers
{
public sealed class SoundcloudResolver : ISoundcloudResolver
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)
{
private readonly SoundCloudApiService _sc;
private readonly ITrackCacher _trackCacher;
private readonly IHttpClientFactory _httpFactory;
_sc = sc;
_trackCacher = trackCacher;
_httpFactory = httpFactory;
}
public SoundcloudResolver(SoundCloudApiService sc, ITrackCacher trackCacher, IHttpClientFactory httpFactory)
public bool IsSoundCloudLink(string url) =>
System.Text.RegularExpressions.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)
{
_sc = sc;
_trackCacher = trackCacher;
_httpFactory = httpFactory;
yield break;
}
public bool IsSoundCloudLink(string url) =>
System.Text.RegularExpressions.Regex.IsMatch(url, "(.*)(soundcloud.com|snd.sc)(.*)");
public async IAsyncEnumerable<ITrackInfo> ResolvePlaylistAsync(string playlist)
foreach (var videosChunk in scvids.Where(x => x.Streamable is true).Chunk(5))
{
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)
var cachableTracks = videosChunk
.Select(VideoModelToCachedData)
.ToList();
await Task.WhenAll(cachableTracks.Select(_trackCacher.CacheTrackDataAsync));
foreach(var info in cachableTracks.Select(CachableDataToTrackInfo))
{
yield break;
yield return info;
}
foreach (var videosChunk in scvids.Where(x => x.Streamable is true).Chunk(5))
{
var cachableTracks = videosChunk
.Select(VideoModelToCachedData)
.ToList();
await Task.WhenAll(cachableTracks.Select(_trackCacher.CacheTrackDataAsync));
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).ConfigureAwait(false)
: await _sc.ResolveVideoAsync(query).ConfigureAwait(false);
if (svideo is null)
return null;
var cachableData = VideoModelToCachedData(svideo);
await _trackCacher.CacheTrackDataByQueryAsync(query, cachableData);
return CachableDataToTrackInfo(cachableData);
}
}
}
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).ConfigureAwait(false)
: await _sc.ResolveVideoAsync(query).ConfigureAwait(false);
if (svideo is null)
return null;
var cachableData = VideoModelToCachedData(svideo);
await _trackCacher.CacheTrackDataByQueryAsync(query, cachableData);
return CachableDataToTrackInfo(cachableData);
}
}

View File

@@ -1,63 +1,60 @@
#nullable enable
using System;
using System.Threading.Tasks;
using Serilog;
namespace NadekoBot.Modules.Music
namespace NadekoBot.Modules.Music;
public sealed class TrackResolveProvider : ITrackResolveProvider
{
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)
{
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"));
_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"));
}

View File

@@ -1,353 +1,348 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using NadekoBot.Services;
using NadekoBot.Extensions;
using Serilog;
namespace NadekoBot.Modules.Music
namespace NadekoBot.Modules.Music;
public sealed class YtdlYoutubeResolver : IYoutubeResolver
{
public sealed class YtdlYoutubeResolver : IYoutubeResolver
private static readonly string[] durationFormats = new[]
{"ss", "m\\:ss", "mm\\:ss", "h\\:mm\\:ss", "hh\\:mm\\:ss", "hhh\\:mm\\:ss"};
public Regex YtVideoIdRegex { get; }
= new Regex(
@"(?: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 IGoogleApiService _google;
public YtdlYoutubeResolver(ITrackCacher trackCacher, IGoogleApiService google)
{
private static readonly string[] durationFormats = new[]
{"ss", "m\\:ss", "mm\\:ss", "h\\:mm\\:ss", "hh\\:mm\\:ss", "hhh\\:mm\\:ss"};
_trackCacher = trackCacher;
_google = google;
public Regex YtVideoIdRegex { get; }
= new Regex(
@"(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\?(?:\S*?&?v\=))|youtu\.be\/)(?<id>[a-zA-Z0-9_-]{6,11})",
RegexOptions.Compiled
);
_ytdlPlaylistOperation =
new YtdlOperation("-4 " +
"--geo-bypass " +
"--encoding UTF8 " +
"-f bestaudio " +
"-e " +
"--get-url " +
"--get-id " +
"--get-thumbnail " +
"--get-duration " +
"--no-check-certificate " +
"-i " +
"--yes-playlist " +
"-- \"{0}\"");
private readonly ITrackCacher _trackCacher;
private readonly YtdlOperation _ytdlPlaylistOperation;
private readonly YtdlOperation _ytdlIdOperation;
private readonly YtdlOperation _ytdlSearchOperation;
private IGoogleApiService _google;
public YtdlYoutubeResolver(ITrackCacher trackCacher, IGoogleApiService google)
{
_trackCacher = trackCacher;
_google = google;
_ytdlPlaylistOperation =
new YtdlOperation("-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 YtdlOperation("-4 " +
"--geo-bypass " +
"--encoding UTF8 " +
"-f bestaudio " +
"-e " +
"--get-url " +
"--get-id " +
"--get-thumbnail " +
"--get-duration " +
"--no-check-certificate " +
"-- \"{0}\"");
_ytdlIdOperation =
new YtdlOperation("-4 " +
"--geo-bypass " +
"--encoding UTF8 " +
"-f bestaudio " +
"-e " +
"--get-url " +
"--get-id " +
"--get-thumbnail " +
"--get-duration " +
"--no-check-certificate " +
"-- \"{0}\"");
_ytdlSearchOperation =
new YtdlOperation("-4 " +
"--geo-bypass " +
"--encoding UTF8 " +
"-f bestaudio " +
"-e " +
"--get-url " +
"--get-id " +
"--get-thumbnail " +
"--get-duration " +
"--no-check-certificate " +
"--default-search " +
"\"ytsearch:\" -- \"{0}\"");
}
_ytdlSearchOperation =
new YtdlOperation("-4 " +
"--geo-bypass " +
"--encoding UTF8 " +
"-f bestaudio " +
"-e " +
"--get-url " +
"--get-id " +
"--get-thumbnail " +
"--get-duration " +
"--no-check-certificate " +
"--default-search " +
"\"ytsearch:\" -- \"{0}\"");
}
private readonly struct YtTrackData
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)
{
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;
}
}
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 YtTrackData(
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 readonly Regex expiryRegex = new Regex(@"(?:[\?\&]expire\=(?<timestamp>\d+))");
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 YtTrackData(
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!)
);
private static readonly Regex _simplePlaylistRegex
= new Regex(@"&list=(?<id>[\w\-]{12,})", RegexOptions.Compiled);
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 YtTrackData(
cachedData.Title,
cachedData.Id,
cachedData.Thumbnail,
null,
cachedData.Duration
));
Title = title.Trim();
Id = id.Trim();
Thumbnail = thumbnail;
StreamUrl = streamUrl;
Duration = duration;
}
}
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 YtTrackData(
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 readonly Regex expiryRegex = new Regex(@"(?:[\?\&]expire\=(?<timestamp>\d+))");
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 YtTrackData(
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!)
);
private static readonly Regex _simplePlaylistRegex
= new Regex(@"&list=(?<id>[\w\-]{12,})", RegexOptions.Compiled);
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 YtTrackData(
cachedData.Title,
cachedData.Id,
cachedData.Thumbnail,
null,
cachedData.Duration
));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading;
using System.Threading.Tasks;
using Discord;
using Discord.Commands;
@@ -9,244 +6,239 @@ using NadekoBot.Common.Attributes;
using NadekoBot.Services;
using NadekoBot.Services.Database.Models;
using NadekoBot.Db;
using NadekoBot.Db.Models;
using NadekoBot.Extensions;
using NadekoBot.Modules;
using NadekoBot.Modules.Music;
using NadekoBot.Modules.Music.Services;
using Serilog;
namespace NadekoBot.Modules.Music
namespace NadekoBot.Modules.Music;
public sealed partial class Music
{
public sealed partial class Music
[Group]
public sealed class PlaylistCommands : NadekoModule<IMusicService>
{
[Group]
public sealed class PlaylistCommands : NadekoModule<IMusicService>
private readonly DbService _db;
private readonly IBotCredentials _creds;
public PlaylistCommands(DbService db, IBotCredentials creds)
{
private readonly DbService _db;
private readonly IBotCredentials _creds;
public PlaylistCommands(DbService db, IBotCredentials creds)
{
_db = db;
_creds = creds;
}
_db = db;
_creds = creds;
}
private async Task EnsureBotInVoiceChannelAsync(ulong voiceChannelId, IGuildUser botUser = null)
private async Task EnsureBotInVoiceChannelAsync(ulong voiceChannelId, IGuildUser botUser = null)
{
botUser ??= await ctx.Guild.GetCurrentUserAsync();
await voiceChannelLock.WaitAsync();
try
{
botUser ??= await ctx.Guild.GetCurrentUserAsync();
await voiceChannelLock.WaitAsync();
try
{
if (botUser.VoiceChannel?.Id is null || !_service.TryGetMusicPlayer(ctx.Guild.Id, out _))
await _service.JoinVoiceChannelAsync(ctx.Guild.Id, voiceChannelId);
}
finally
{
voiceChannelLock.Release();
}
if (botUser.VoiceChannel?.Id is null || !_service.TryGetMusicPlayer(ctx.Guild.Id, out _))
await _service.JoinVoiceChannelAsync(ctx.Guild.Id, voiceChannelId);
}
finally
{
voiceChannelLock.Release();
}
}
[NadekoCommand, Aliases]
[RequireContext(ContextType.Guild)]
public async Task Playlists([Leftover] int num = 1)
{
if (num <= 0)
return;
List<MusicPlaylist> playlists;
using (var uow = _db.GetDbContext())
{
playlists = uow.MusicPlaylists.GetPlaylistsOnPage(num);
}
[NadekoCommand, Aliases]
[RequireContext(ContextType.Guild)]
public async Task Playlists([Leftover] int num = 1)
var embed = _eb
.Create(ctx)
.WithAuthor(GetText(strs.playlists_page(num)), MusicIconUrl)
.WithDescription(string.Join("\n", playlists.Select(r =>
GetText(strs.playlists(r.Id, r.Name, r.Author, r.Songs.Count)))))
.WithOkColor();
await ctx.Channel.EmbedAsync(embed).ConfigureAwait(false);
}
[NadekoCommand, Aliases]
[RequireContext(ContextType.Guild)]
public async Task DeletePlaylist([Leftover] int id)
{
var success = false;
try
{
if (num <= 0)
return;
List<MusicPlaylist> playlists;
using (var uow = _db.GetDbContext())
{
playlists = uow.MusicPlaylists.GetPlaylistsOnPage(num);
}
var pl = uow.MusicPlaylists.FirstOrDefault(x => x.Id == id);
var embed = _eb
.Create(ctx)
.WithAuthor(GetText(strs.playlists_page(num)), MusicIconUrl)
.WithDescription(string.Join("\n", playlists.Select(r =>
GetText(strs.playlists(r.Id, r.Name, r.Author, r.Songs.Count)))))
.WithOkColor();
await ctx.Channel.EmbedAsync(embed).ConfigureAwait(false);
}
[NadekoCommand, Aliases]
[RequireContext(ContextType.Guild)]
public async Task DeletePlaylist([Leftover] int id)
{
var success = false;
try
{
using (var uow = _db.GetDbContext())
if (pl != null)
{
var pl = uow.MusicPlaylists.FirstOrDefault(x => x.Id == id);
if (pl != null)
if (_creds.IsOwner(ctx.User) || pl.AuthorId == ctx.User.Id)
{
if (_creds.IsOwner(ctx.User) || pl.AuthorId == ctx.User.Id)
{
uow.MusicPlaylists.Remove(pl);
await uow.SaveChangesAsync();
success = true;
}
uow.MusicPlaylists.Remove(pl);
await uow.SaveChangesAsync();
success = true;
}
}
}
catch (Exception ex)
{
Log.Warning(ex, "Error deleting playlist");
}
if (!success)
await ReplyErrorLocalizedAsync(strs.playlist_delete_fail).ConfigureAwait(false);
else
await ReplyConfirmLocalizedAsync(strs.playlist_deleted).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.Warning(ex, "Error deleting playlist");
}
[NadekoCommand, Aliases]
[RequireContext(ContextType.Guild)]
public async Task PlaylistShow(int id, int page = 1)
{
if (page-- < 1)
return;
if (!success)
await ReplyErrorLocalizedAsync(strs.playlist_delete_fail).ConfigureAwait(false);
else
await ReplyConfirmLocalizedAsync(strs.playlist_deleted).ConfigureAwait(false);
}
[NadekoCommand, Aliases]
[RequireContext(ContextType.Guild)]
public async Task PlaylistShow(int id, int page = 1)
{
if (page-- < 1)
return;
MusicPlaylist mpl;
using (var uow = _db.GetDbContext())
{
mpl = uow.MusicPlaylists.GetWithSongs(id);
}
await ctx.SendPaginatedConfirmAsync(page, (cur) =>
{
var i = 0;
var str = string.Join("\n", mpl.Songs
.Skip(cur * 20)
.Take(20)
.Select(x => $"`{++i}.` [{x.Title.TrimTo(45)}]({x.Query}) `{x.Provider}`"));
return _eb.Create()
.WithTitle($"\"{mpl.Name}\" by {mpl.Author}")
.WithOkColor()
.WithDescription(str);
}, mpl.Songs.Count, 20).ConfigureAwait(false);
}
[NadekoCommand, Aliases]
[RequireContext(ContextType.Guild)]
public async Task Save([Leftover] string name)
{
if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp))
{
await ReplyErrorLocalizedAsync(strs.no_player);
return;
}
var songs = mp.GetQueuedTracks()
.Select(s => new PlaylistSong()
{
Provider = s.Platform.ToString(),
ProviderType = (MusicType)s.Platform,
Title = s.Title,
Query = s.Platform == MusicPlatform.Local ? s.GetStreamUrl().Result!.Trim('"') : s.Url,
}).ToList();
MusicPlaylist playlist;
using (var uow = _db.GetDbContext())
{
playlist = new MusicPlaylist
{
Name = name,
Author = ctx.User.Username,
AuthorId = ctx.User.Id,
Songs = songs.ToList(),
};
uow.MusicPlaylists.Add(playlist);
await uow.SaveChangesAsync();
}
await ctx.Channel.EmbedAsync(_eb.Create()
.WithOkColor()
.WithTitle(GetText(strs.playlist_saved))
.AddField(GetText(strs.name), name)
.AddField(GetText(strs.id), playlist.Id.ToString()));
}
private static readonly SemaphoreSlim _playlistLock = new SemaphoreSlim(1, 1);
[NadekoCommand, Aliases]
[RequireContext(ContextType.Guild)]
public async Task Load([Leftover] int id)
{
// expensive action, 1 at a time
await _playlistLock.WaitAsync();
try
{
var user = (IGuildUser) ctx.User;
var voiceChannelId = user.VoiceChannel?.Id;
if (voiceChannelId is null)
{
await ReplyErrorLocalizedAsync(strs.must_be_in_voice);
return;
}
_ = ctx.Channel.TriggerTypingAsync();
var botUser = await ctx.Guild.GetCurrentUserAsync();
await EnsureBotInVoiceChannelAsync(voiceChannelId!.Value, botUser);
if (botUser.VoiceChannel?.Id != voiceChannelId)
{
await ReplyErrorLocalizedAsync(strs.not_with_bot_in_voice);
return;
}
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel) ctx.Channel);
if (mp is null)
{
await ReplyErrorLocalizedAsync(strs.no_player);
return;
}
MusicPlaylist mpl;
using (var uow = _db.GetDbContext())
{
mpl = uow.MusicPlaylists.GetWithSongs(id);
}
await ctx.SendPaginatedConfirmAsync(page, (cur) =>
if (mpl is null)
{
var i = 0;
var str = string.Join("\n", mpl.Songs
.Skip(cur * 20)
.Take(20)
.Select(x => $"`{++i}.` [{x.Title.TrimTo(45)}]({x.Query}) `{x.Provider}`"));
return _eb.Create()
.WithTitle($"\"{mpl.Name}\" by {mpl.Author}")
.WithOkColor()
.WithDescription(str);
}, mpl.Songs.Count, 20).ConfigureAwait(false);
}
[NadekoCommand, Aliases]
[RequireContext(ContextType.Guild)]
public async Task Save([Leftover] string name)
{
if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp))
{
await ReplyErrorLocalizedAsync(strs.no_player);
await ReplyErrorLocalizedAsync(strs.playlist_id_not_found).ConfigureAwait(false);
return;
}
var songs = mp.GetQueuedTracks()
.Select(s => new PlaylistSong()
{
Provider = s.Platform.ToString(),
ProviderType = (MusicType)s.Platform,
Title = s.Title,
Query = s.Platform == MusicPlatform.Local ? s.GetStreamUrl().Result!.Trim('"') : s.Url,
}).ToList();
MusicPlaylist playlist;
using (var uow = _db.GetDbContext())
{
playlist = new MusicPlaylist
{
Name = name,
Author = ctx.User.Username,
AuthorId = ctx.User.Id,
Songs = songs.ToList(),
};
uow.MusicPlaylists.Add(playlist);
await uow.SaveChangesAsync();
}
await ctx.Channel.EmbedAsync(_eb.Create()
.WithOkColor()
.WithTitle(GetText(strs.playlist_saved))
.AddField(GetText(strs.name), name)
.AddField(GetText(strs.id), playlist.Id.ToString()));
}
private static readonly SemaphoreSlim _playlistLock = new SemaphoreSlim(1, 1);
[NadekoCommand, Aliases]
[RequireContext(ContextType.Guild)]
public async Task Load([Leftover] int id)
{
// expensive action, 1 at a time
await _playlistLock.WaitAsync();
IUserMessage msg = null;
try
{
var user = (IGuildUser) ctx.User;
var voiceChannelId = user.VoiceChannel?.Id;
if (voiceChannelId is null)
{
await ReplyErrorLocalizedAsync(strs.must_be_in_voice);
return;
}
_ = ctx.Channel.TriggerTypingAsync();
var botUser = await ctx.Guild.GetCurrentUserAsync();
await EnsureBotInVoiceChannelAsync(voiceChannelId!.Value, botUser);
if (botUser.VoiceChannel?.Id != voiceChannelId)
{
await ReplyErrorLocalizedAsync(strs.not_with_bot_in_voice);
return;
}
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel) ctx.Channel);
if (mp is null)
{
await ReplyErrorLocalizedAsync(strs.no_player);
return;
}
MusicPlaylist mpl;
using (var uow = _db.GetDbContext())
{
mpl = uow.MusicPlaylists.GetWithSongs(id);
}
if (mpl is null)
{
await ReplyErrorLocalizedAsync(strs.playlist_id_not_found).ConfigureAwait(false);
return;
}
IUserMessage msg = null;
try
{
msg = await ctx.Channel
.SendMessageAsync(GetText(strs.attempting_to_queue(Format.Bold(mpl.Songs.Count.ToString()))))
.ConfigureAwait(false);
}
catch (Exception)
{
}
await mp.EnqueueManyAsync(
mpl.Songs.Select(x => (x.Query, (MusicPlatform) x.ProviderType)),
ctx.User.ToString()
);
if (msg != null)
{
await msg.ModifyAsync(m => m.Content = GetText(strs.playlist_queue_complete));
}
}
finally
{
_playlistLock.Release();
msg = await ctx.Channel
.SendMessageAsync(GetText(strs.attempting_to_queue(Format.Bold(mpl.Songs.Count.ToString()))))
.ConfigureAwait(false);
}
catch (Exception)
{
}
await mp.EnqueueManyAsync(
mpl.Songs.Select(x => (x.Query, (MusicPlatform) x.ProviderType)),
ctx.User.ToString()
);
if (msg != null)
{
await msg.ModifyAsync(m => m.Content = GetText(strs.playlist_queue_complete));
}
}
finally
{
_playlistLock.Release();
}
}
}
}
}

View File

@@ -1,5 +1,4 @@
using System.Collections.Concurrent;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
@@ -7,215 +6,214 @@ using Ayu.Discord.Voice;
using Discord.WebSocket;
using NadekoBot.Services;
namespace NadekoBot.Modules.Music.Services
namespace NadekoBot.Modules.Music.Services;
public sealed class AyuVoiceStateService : INService
{
public sealed class AyuVoiceStateService : INService
// public delegate Task VoiceProxyUpdatedDelegate(ulong guildId, IVoiceProxy proxy);
// public event VoiceProxyUpdatedDelegate OnVoiceProxyUpdate = delegate { return Task.CompletedTask; };
private readonly ConcurrentDictionary<ulong, IVoiceProxy> _voiceProxies = new ConcurrentDictionary<ulong, IVoiceProxy>();
private readonly ConcurrentDictionary<ulong, SemaphoreSlim> _voiceGatewayLocks = new ConcurrentDictionary<ulong, SemaphoreSlim>();
private readonly DiscordSocketClient _client;
private readonly MethodInfo _sendVoiceStateUpdateMethodInfo;
private readonly object _dnetApiClient;
private readonly ulong _currentUserId;
public AyuVoiceStateService(DiscordSocketClient client)
{
// public delegate Task VoiceProxyUpdatedDelegate(ulong guildId, IVoiceProxy proxy);
// public event VoiceProxyUpdatedDelegate OnVoiceProxyUpdate = delegate { return Task.CompletedTask; };
private readonly ConcurrentDictionary<ulong, IVoiceProxy> _voiceProxies = new ConcurrentDictionary<ulong, IVoiceProxy>();
private readonly ConcurrentDictionary<ulong, SemaphoreSlim> _voiceGatewayLocks = new ConcurrentDictionary<ulong, SemaphoreSlim>();
private readonly DiscordSocketClient _client;
private readonly MethodInfo _sendVoiceStateUpdateMethodInfo;
private readonly object _dnetApiClient;
private readonly ulong _currentUserId;
_client = client;
_currentUserId = _client.CurrentUser.Id;
public AyuVoiceStateService(DiscordSocketClient client)
{
_client = client;
_currentUserId = _client.CurrentUser.Id;
var prop = _client.GetType()
.GetProperties(BindingFlags.NonPublic | BindingFlags.Instance)
.First(x => x.Name == "ApiClient" && x.PropertyType.Name == "DiscordSocketApiClient");
_dnetApiClient = prop.GetValue(_client, null);
_sendVoiceStateUpdateMethodInfo = _dnetApiClient.GetType().GetMethod("SendVoiceStateUpdateAsync");
var prop = _client.GetType()
.GetProperties(BindingFlags.NonPublic | BindingFlags.Instance)
.First(x => x.Name == "ApiClient" && x.PropertyType.Name == "DiscordSocketApiClient");
_dnetApiClient = prop.GetValue(_client, null);
_sendVoiceStateUpdateMethodInfo = _dnetApiClient.GetType().GetMethod("SendVoiceStateUpdateAsync");
_client.LeftGuild += ClientOnLeftGuild;
_client.LeftGuild += ClientOnLeftGuild;
}
private Task ClientOnLeftGuild(SocketGuild guild)
{
if (_voiceProxies.TryRemove(guild.Id, out var proxy))
{
proxy.StopGateway();
proxy.SetGateway(null);
}
private Task ClientOnLeftGuild(SocketGuild guild)
return Task.CompletedTask;
}
private Task InvokeSendVoiceStateUpdateAsync(ulong guildId, ulong? channelId = null, bool isDeafened = false, bool isMuted = false)
{
// return _voiceStateUpdate(guildId, channelId, isDeafened, isMuted);
return (Task) _sendVoiceStateUpdateMethodInfo.Invoke(_dnetApiClient, new object[] {guildId, channelId, isMuted, isDeafened, null});
}
private Task SendLeaveVoiceChannelInternalAsync(ulong guildId)
=> InvokeSendVoiceStateUpdateAsync(guildId);
private Task SendJoinVoiceChannelInternalAsync(ulong guildId, ulong channelId)
=> InvokeSendVoiceStateUpdateAsync(guildId, channelId);
private SemaphoreSlim GetVoiceGatewayLock(ulong guildId) => _voiceGatewayLocks.GetOrAdd(guildId, new SemaphoreSlim(1, 1));
private async Task LeaveVoiceChannelInternalAsync(ulong guildId)
{
var complete = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
Task OnUserVoiceStateUpdated(SocketUser user, SocketVoiceState oldState, SocketVoiceState newState)
{
if (_voiceProxies.TryRemove(guild.Id, out var proxy))
if (user is SocketGuildUser guildUser
&& guildUser.Guild.Id == guildId
&& newState.VoiceChannel?.Id is null)
{
proxy.StopGateway();
proxy.SetGateway(null);
complete.TrySetResult(true);
}
return Task.CompletedTask;
}
private Task InvokeSendVoiceStateUpdateAsync(ulong guildId, ulong? channelId = null, bool isDeafened = false, bool isMuted = false)
try
{
// return _voiceStateUpdate(guildId, channelId, isDeafened, isMuted);
return (Task) _sendVoiceStateUpdateMethodInfo.Invoke(_dnetApiClient, new object[] {guildId, channelId, isMuted, isDeafened, null});
_client.UserVoiceStateUpdated += OnUserVoiceStateUpdated;
if (_voiceProxies.TryGetValue(guildId, out var proxy))
{
_ = proxy.StopGateway();
proxy.SetGateway(null);
}
await SendLeaveVoiceChannelInternalAsync(guildId);
await Task.WhenAny(Task.Delay(1500), complete.Task);
}
finally
{
_client.UserVoiceStateUpdated -= OnUserVoiceStateUpdated;
}
}
public async Task LeaveVoiceChannel(ulong guildId)
{
var gwLock = GetVoiceGatewayLock(guildId);
await gwLock.WaitAsync().ConfigureAwait(false);
try
{
await LeaveVoiceChannelInternalAsync(guildId);
}
finally
{
gwLock.Release();
}
}
private async Task<IVoiceProxy> InternalConnectToVcAsync(ulong guildId, ulong channelId)
{
var voiceStateUpdatedSource = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
var voiceServerUpdatedSource = new TaskCompletionSource<SocketVoiceServer>(TaskCreationOptions.RunContinuationsAsynchronously);
Task OnUserVoiceStateUpdated(SocketUser user, SocketVoiceState oldState, SocketVoiceState newState)
{
if (user is SocketGuildUser guildUser && guildUser.Guild.Id == guildId)
{
if (newState.VoiceChannel?.Id == channelId)
voiceStateUpdatedSource.TrySetResult(newState.VoiceSessionId);
voiceStateUpdatedSource.TrySetResult(null);
}
return Task.CompletedTask;
}
private Task SendLeaveVoiceChannelInternalAsync(ulong guildId)
=> InvokeSendVoiceStateUpdateAsync(guildId);
private Task SendJoinVoiceChannelInternalAsync(ulong guildId, ulong channelId)
=> InvokeSendVoiceStateUpdateAsync(guildId, channelId);
private SemaphoreSlim GetVoiceGatewayLock(ulong guildId) => _voiceGatewayLocks.GetOrAdd(guildId, new SemaphoreSlim(1, 1));
private async Task LeaveVoiceChannelInternalAsync(ulong guildId)
Task OnVoiceServerUpdated(SocketVoiceServer data)
{
var complete = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
Task OnUserVoiceStateUpdated(SocketUser user, SocketVoiceState oldState, SocketVoiceState newState)
if (data.Guild.Id == guildId)
{
if (user is SocketGuildUser guildUser
&& guildUser.Guild.Id == guildId
&& newState.VoiceChannel?.Id is null)
{
complete.TrySetResult(true);
}
return Task.CompletedTask;
voiceServerUpdatedSource.TrySetResult(data);
}
try
{
_client.UserVoiceStateUpdated += OnUserVoiceStateUpdated;
if (_voiceProxies.TryGetValue(guildId, out var proxy))
{
_ = proxy.StopGateway();
proxy.SetGateway(null);
}
await SendLeaveVoiceChannelInternalAsync(guildId);
await Task.WhenAny(Task.Delay(1500), complete.Task);
}
finally
{
_client.UserVoiceStateUpdated -= OnUserVoiceStateUpdated;
}
return Task.CompletedTask;
}
public async Task LeaveVoiceChannel(ulong guildId)
{
var gwLock = GetVoiceGatewayLock(guildId);
await gwLock.WaitAsync().ConfigureAwait(false);
try
{
await LeaveVoiceChannelInternalAsync(guildId);
}
finally
{
gwLock.Release();
}
}
private async Task<IVoiceProxy> InternalConnectToVcAsync(ulong guildId, ulong channelId)
{
var voiceStateUpdatedSource = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
var voiceServerUpdatedSource = new TaskCompletionSource<SocketVoiceServer>(TaskCreationOptions.RunContinuationsAsynchronously);
Task OnUserVoiceStateUpdated(SocketUser user, SocketVoiceState oldState, SocketVoiceState newState)
{
if (user is SocketGuildUser guildUser && guildUser.Guild.Id == guildId)
{
if (newState.VoiceChannel?.Id == channelId)
voiceStateUpdatedSource.TrySetResult(newState.VoiceSessionId);
voiceStateUpdatedSource.TrySetResult(null);
}
return Task.CompletedTask;
}
Task OnVoiceServerUpdated(SocketVoiceServer data)
{
if (data.Guild.Id == guildId)
{
voiceServerUpdatedSource.TrySetResult(data);
}
return Task.CompletedTask;
}
try
try
{
_client.VoiceServerUpdated += OnVoiceServerUpdated;
_client.UserVoiceStateUpdated += OnUserVoiceStateUpdated;
await SendJoinVoiceChannelInternalAsync(guildId, channelId);
// create a delay task, how much to wait for gateway response
var delayTask = Task.Delay(2500);
// either delay or successful voiceStateUpdate
var maybeUpdateTask = Task.WhenAny(delayTask, voiceStateUpdatedSource.Task);
// either delay or successful voiceServerUpdate
var maybeServerTask = Task.WhenAny(delayTask, voiceServerUpdatedSource.Task);
// wait for both to end (max 1s) and check if either of them is a delay task
var results = await Task.WhenAll(maybeUpdateTask, maybeServerTask);
if (results[0] == delayTask || results[1] == delayTask)
{
_client.VoiceServerUpdated += OnVoiceServerUpdated;
_client.UserVoiceStateUpdated += OnUserVoiceStateUpdated;
// if either is delay, return null - connection unsuccessful
return null;
}
await SendJoinVoiceChannelInternalAsync(guildId, channelId);
// if both are succesful, that means we can safely get
// the values from completion sources
// create a delay task, how much to wait for gateway response
var delayTask = Task.Delay(2500);
var session = await voiceStateUpdatedSource.Task;
// either delay or successful voiceStateUpdate
var maybeUpdateTask = Task.WhenAny(delayTask, voiceStateUpdatedSource.Task);
// either delay or successful voiceServerUpdate
var maybeServerTask = Task.WhenAny(delayTask, voiceServerUpdatedSource.Task);
// session can be null. Means we disconnected, or connected to the wrong channel (?!)
if (session is null)
return null;
// wait for both to end (max 1s) and check if either of them is a delay task
var results = await Task.WhenAll(maybeUpdateTask, maybeServerTask);
if (results[0] == delayTask || results[1] == delayTask)
{
// if either is delay, return null - connection unsuccessful
return null;
}
// if both are succesful, that means we can safely get
// the values from completion sources
var session = await voiceStateUpdatedSource.Task;
// session can be null. Means we disconnected, or connected to the wrong channel (?!)
if (session is null)
return null;
var voiceServerData = await voiceServerUpdatedSource.Task;
var voiceServerData = await voiceServerUpdatedSource.Task;
VoiceGateway CreateVoiceGatewayLocal() =>
new VoiceGateway(
guildId,
_currentUserId,
session,
voiceServerData.Token,
voiceServerData.Endpoint
);
var current = _voiceProxies.AddOrUpdate(
VoiceGateway CreateVoiceGatewayLocal() =>
new VoiceGateway(
guildId,
(gid) => new VoiceProxy(CreateVoiceGatewayLocal()),
(gid, currentProxy) =>
{
_ = currentProxy.StopGateway();
currentProxy.SetGateway(CreateVoiceGatewayLocal());
return currentProxy;
}
_currentUserId,
session,
voiceServerData.Token,
voiceServerData.Endpoint
);
_ = current.StartGateway(); // don't await, this blocks until gateway is closed
return current;
}
finally
{
_client.VoiceServerUpdated -= OnVoiceServerUpdated;
_client.UserVoiceStateUpdated -= OnUserVoiceStateUpdated;
}
}
var current = _voiceProxies.AddOrUpdate(
guildId,
(gid) => new VoiceProxy(CreateVoiceGatewayLocal()),
(gid, currentProxy) =>
{
_ = currentProxy.StopGateway();
currentProxy.SetGateway(CreateVoiceGatewayLocal());
return currentProxy;
}
);
public async Task<IVoiceProxy> JoinVoiceChannel(ulong guildId, ulong channelId, bool forceReconnect = true)
_ = current.StartGateway(); // don't await, this blocks until gateway is closed
return current;
}
finally
{
var gwLock = GetVoiceGatewayLock(guildId);
await gwLock.WaitAsync().ConfigureAwait(false);
try
{
await LeaveVoiceChannelInternalAsync(guildId);
return await InternalConnectToVcAsync(guildId, channelId);
}
finally
{
gwLock.Release();
}
_client.VoiceServerUpdated -= OnVoiceServerUpdated;
_client.UserVoiceStateUpdated -= OnUserVoiceStateUpdated;
}
public bool TryGetProxy(ulong guildId, out IVoiceProxy proxy)
=> _voiceProxies.TryGetValue(guildId, out proxy);
}
public async Task<IVoiceProxy> JoinVoiceChannel(ulong guildId, ulong channelId, bool forceReconnect = true)
{
var gwLock = GetVoiceGatewayLock(guildId);
await gwLock.WaitAsync().ConfigureAwait(false);
try
{
await LeaveVoiceChannelInternalAsync(guildId);
return await InternalConnectToVcAsync(guildId, channelId);
}
finally
{
gwLock.Release();
}
}
public bool TryGetProxy(ulong guildId, out IVoiceProxy proxy)
=> _voiceProxies.TryGetValue(guildId, out proxy);
}

View File

@@ -1,42 +1,39 @@
#nullable enable
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Discord;
using NadekoBot.Common;
using NadekoBot.Modules.Music;
using NadekoBot.Services.Database.Models;
namespace NadekoBot.Modules.Music.Services
{
public interface IMusicService : IPlaceholderProvider
{
/// <summary>
/// Leave voice channel in the specified guild if it's connected to one
/// </summary>
/// <param name="guildId">Id of the guild</param>
public Task LeaveVoiceChannelAsync(ulong guildId);
namespace NadekoBot.Modules.Music.Services;
/// <summary>
/// Joins the voice channel with the specified id
/// </summary>
/// <param name="guildId">Id of the guild where the voice channel is</param>
/// <param name="voiceChannelId">Id of the voice channel</param>
public Task JoinVoiceChannelAsync(ulong guildId, ulong voiceChannelId);
public interface IMusicService : IPlaceholderProvider
{
/// <summary>
/// Leave voice channel in the specified guild if it's connected to one
/// </summary>
/// <param name="guildId">Id of the guild</param>
public Task LeaveVoiceChannelAsync(ulong guildId);
/// <summary>
/// Joins the voice channel with the specified id
/// </summary>
/// <param name="guildId">Id of the guild where the voice channel is</param>
/// <param name="voiceChannelId">Id of the voice channel</param>
public Task JoinVoiceChannelAsync(ulong guildId, ulong voiceChannelId);
Task<IMusicPlayer?> GetOrCreateMusicPlayerAsync(ITextChannel contextChannel);
bool TryGetMusicPlayer(ulong guildId, [MaybeNullWhen(false)] out IMusicPlayer musicPlayer);
Task<int> EnqueueYoutubePlaylistAsync(IMusicPlayer mp, string playlistId, string queuer);
Task EnqueueDirectoryAsync(IMusicPlayer mp, string dirPath, string queuer);
Task<int> EnqueueSoundcloudPlaylistAsync(IMusicPlayer mp, string playlist, string queuer);
Task<IUserMessage?> SendToOutputAsync(ulong guildId, IEmbedBuilder embed);
Task<bool> PlayAsync(ulong guildId, ulong voiceChannelId);
Task<IList<(string Title, string Url)>> SearchVideosAsync(string query);
Task<bool> SetMusicChannelAsync(ulong guildId, ulong? channelId);
Task SetRepeatAsync(ulong guildId, PlayerRepeatType repeatType);
Task SetVolumeAsync(ulong guildId, int value);
Task<bool> ToggleAutoDisconnectAsync(ulong guildId);
Task<QualityPreset> GetMusicQualityAsync(ulong guildId);
Task SetMusicQualityAsync(ulong guildId, QualityPreset preset);
}
}
Task<IMusicPlayer?> GetOrCreateMusicPlayerAsync(ITextChannel contextChannel);
bool TryGetMusicPlayer(ulong guildId, [MaybeNullWhen(false)] out IMusicPlayer musicPlayer);
Task<int> EnqueueYoutubePlaylistAsync(IMusicPlayer mp, string playlistId, string queuer);
Task EnqueueDirectoryAsync(IMusicPlayer mp, string dirPath, string queuer);
Task<int> EnqueueSoundcloudPlaylistAsync(IMusicPlayer mp, string playlist, string queuer);
Task<IUserMessage?> SendToOutputAsync(ulong guildId, IEmbedBuilder embed);
Task<bool> PlayAsync(ulong guildId, ulong voiceChannelId);
Task<IList<(string Title, string Url)>> SearchVideosAsync(string query);
Task<bool> SetMusicChannelAsync(ulong guildId, ulong? channelId);
Task SetRepeatAsync(ulong guildId, PlayerRepeatType repeatType);
Task SetVolumeAsync(ulong guildId, int value);
Task<bool> ToggleAutoDisconnectAsync(ulong guildId);
Task<QualityPreset> GetMusicQualityAsync(ulong guildId);
Task SetMusicQualityAsync(ulong guildId, QualityPreset preset);
}

View File

@@ -1,462 +1,455 @@
#nullable enable
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading.Tasks;
using Discord;
using Discord.WebSocket;
using NadekoBot.Services;
using NadekoBot.Services.Database.Models;
using NadekoBot.Db;
using NadekoBot.Modules.Music;
using NadekoBot.Db.Models;
using NadekoBot.Extensions;
using Serilog;
namespace NadekoBot.Modules.Music.Services
namespace NadekoBot.Modules.Music.Services;
public sealed class MusicService : IMusicService
{
public sealed class MusicService : IMusicService
private readonly AyuVoiceStateService _voiceStateService;
private readonly ITrackResolveProvider _trackResolveProvider;
private readonly DbService _db;
private readonly IYoutubeResolver _ytResolver;
private readonly ILocalTrackResolver _localResolver;
private readonly ISoundcloudResolver _scResolver;
private readonly DiscordSocketClient _client;
private readonly IBotStrings _strings;
private readonly IGoogleApiService _googleApiService;
private readonly YtLoader _ytLoader;
private readonly IEmbedBuilderService _eb;
private readonly ConcurrentDictionary<ulong, IMusicPlayer> _players;
private readonly ConcurrentDictionary<ulong, (ITextChannel Default, ITextChannel? Override)> _outputChannels;
private readonly ConcurrentDictionary<ulong, MusicPlayerSettings> _settings;
public MusicService(AyuVoiceStateService voiceStateService, ITrackResolveProvider trackResolveProvider,
DbService db, IYoutubeResolver ytResolver, ILocalTrackResolver localResolver, ISoundcloudResolver scResolver,
DiscordSocketClient client, IBotStrings strings, IGoogleApiService googleApiService, YtLoader ytLoader,
IEmbedBuilderService eb)
{
private readonly AyuVoiceStateService _voiceStateService;
private readonly ITrackResolveProvider _trackResolveProvider;
private readonly DbService _db;
private readonly IYoutubeResolver _ytResolver;
private readonly ILocalTrackResolver _localResolver;
private readonly ISoundcloudResolver _scResolver;
private readonly DiscordSocketClient _client;
private readonly IBotStrings _strings;
private readonly IGoogleApiService _googleApiService;
private readonly YtLoader _ytLoader;
private readonly IEmbedBuilderService _eb;
_voiceStateService = voiceStateService;
_trackResolveProvider = trackResolveProvider;
_db = db;
_ytResolver = ytResolver;
_localResolver = localResolver;
_scResolver = scResolver;
_client = client;
_strings = strings;
_googleApiService = googleApiService;
_ytLoader = ytLoader;
_eb = eb;
private readonly ConcurrentDictionary<ulong, IMusicPlayer> _players;
private readonly ConcurrentDictionary<ulong, (ITextChannel Default, ITextChannel? Override)> _outputChannels;
private readonly ConcurrentDictionary<ulong, MusicPlayerSettings> _settings;
public MusicService(AyuVoiceStateService voiceStateService, ITrackResolveProvider trackResolveProvider,
DbService db, IYoutubeResolver ytResolver, ILocalTrackResolver localResolver, ISoundcloudResolver scResolver,
DiscordSocketClient client, IBotStrings strings, IGoogleApiService googleApiService, YtLoader ytLoader,
IEmbedBuilderService eb)
{
_voiceStateService = voiceStateService;
_trackResolveProvider = trackResolveProvider;
_db = db;
_ytResolver = ytResolver;
_localResolver = localResolver;
_scResolver = scResolver;
_client = client;
_strings = strings;
_googleApiService = googleApiService;
_ytLoader = ytLoader;
_eb = eb;
_players = new ConcurrentDictionary<ulong, IMusicPlayer>();
_outputChannels = new ConcurrentDictionary<ulong, (ITextChannel, ITextChannel?)>();
_settings = new ConcurrentDictionary<ulong, MusicPlayerSettings>();
_players = new ConcurrentDictionary<ulong, IMusicPlayer>();
_outputChannels = new ConcurrentDictionary<ulong, (ITextChannel, ITextChannel?)>();
_settings = new ConcurrentDictionary<ulong, MusicPlayerSettings>();
_client.LeftGuild += ClientOnLeftGuild;
}
_client.LeftGuild += ClientOnLeftGuild;
}
private void DisposeMusicPlayer(IMusicPlayer musicPlayer)
private void DisposeMusicPlayer(IMusicPlayer musicPlayer)
{
musicPlayer.Kill();
_ = Task.Delay(10_000).ContinueWith(_ => musicPlayer.Dispose());
}
private void RemoveMusicPlayer(ulong guildId)
{
_outputChannels.TryRemove(guildId, out _);
if (_players.TryRemove(guildId, out var mp))
{
musicPlayer.Kill();
_ = Task.Delay(10_000).ContinueWith(_ => musicPlayer.Dispose());
DisposeMusicPlayer(mp);
}
}
private void RemoveMusicPlayer(ulong guildId)
{
_outputChannels.TryRemove(guildId, out _);
if (_players.TryRemove(guildId, out var mp))
{
DisposeMusicPlayer(mp);
}
}
private Task ClientOnLeftGuild(SocketGuild guild)
{
RemoveMusicPlayer(guild.Id);
return Task.CompletedTask;
}
private Task ClientOnLeftGuild(SocketGuild guild)
{
RemoveMusicPlayer(guild.Id);
return Task.CompletedTask;
}
public async Task LeaveVoiceChannelAsync(ulong guildId)
{
RemoveMusicPlayer(guildId);
await _voiceStateService.LeaveVoiceChannel(guildId);
}
public async Task LeaveVoiceChannelAsync(ulong guildId)
{
RemoveMusicPlayer(guildId);
await _voiceStateService.LeaveVoiceChannel(guildId);
}
public Task JoinVoiceChannelAsync(ulong guildId, ulong voiceChannelId)
=> _voiceStateService.JoinVoiceChannel(guildId, voiceChannelId);
public Task JoinVoiceChannelAsync(ulong guildId, ulong voiceChannelId)
=> _voiceStateService.JoinVoiceChannel(guildId, voiceChannelId);
public async Task<IMusicPlayer?> GetOrCreateMusicPlayerAsync(ITextChannel contextChannel)
{
var newPLayer = await CreateMusicPlayerInternalAsync(contextChannel.GuildId, contextChannel);
if (newPLayer is null)
return null;
public async Task<IMusicPlayer?> GetOrCreateMusicPlayerAsync(ITextChannel contextChannel)
{
var newPLayer = await CreateMusicPlayerInternalAsync(contextChannel.GuildId, contextChannel);
if (newPLayer is null)
return null;
return _players.GetOrAdd(contextChannel.GuildId, newPLayer);
return _players.GetOrAdd(contextChannel.GuildId, newPLayer);
}
public bool TryGetMusicPlayer(ulong guildId, [MaybeNullWhen(false)] out IMusicPlayer musicPlayer)
=> _players.TryGetValue(guildId, out musicPlayer);
public async Task<int> EnqueueYoutubePlaylistAsync(IMusicPlayer mp, string query, string queuer)
{
var count = 0;
await foreach (var track in _ytResolver.ResolveTracksFromPlaylistAsync(query))
{
if (mp.IsKilled)
break;
mp.EnqueueTrack(track, queuer);
++count;
}
public bool TryGetMusicPlayer(ulong guildId, [MaybeNullWhen(false)] out IMusicPlayer musicPlayer)
=> _players.TryGetValue(guildId, out musicPlayer);
return count;
}
public async Task<int> EnqueueYoutubePlaylistAsync(IMusicPlayer mp, string query, string queuer)
public async Task EnqueueDirectoryAsync(IMusicPlayer mp, string dirPath, string queuer)
{
await foreach (var track in _localResolver.ResolveDirectoryAsync(dirPath))
{
var count = 0;
await foreach (var track in _ytResolver.ResolveTracksFromPlaylistAsync(query))
{
if (mp.IsKilled)
break;
mp.EnqueueTrack(track, queuer);
++count;
}
return count;
}
public async Task EnqueueDirectoryAsync(IMusicPlayer mp, string dirPath, string queuer)
{
await foreach (var track in _localResolver.ResolveDirectoryAsync(dirPath))
{
if (mp.IsKilled)
break;
if (mp.IsKilled)
break;
mp.EnqueueTrack(track, queuer);
}
mp.EnqueueTrack(track, queuer);
}
}
public async Task<int> EnqueueSoundcloudPlaylistAsync(IMusicPlayer mp, string playlist, string queuer)
public async Task<int> EnqueueSoundcloudPlaylistAsync(IMusicPlayer mp, string playlist, string queuer)
{
var i = 0;
await foreach (var track in _scResolver.ResolvePlaylistAsync(playlist))
{
var i = 0;
await foreach (var track in _scResolver.ResolvePlaylistAsync(playlist))
{
if (mp.IsKilled)
break;
if (mp.IsKilled)
break;
mp.EnqueueTrack(track, queuer);
++i;
}
return i;
mp.EnqueueTrack(track, queuer);
++i;
}
private async Task<IMusicPlayer?> CreateMusicPlayerInternalAsync(ulong guildId, ITextChannel defaultChannel)
return i;
}
private async Task<IMusicPlayer?> CreateMusicPlayerInternalAsync(ulong guildId, ITextChannel defaultChannel)
{
var queue = new MusicQueue();
var resolver = _trackResolveProvider;
if (!_voiceStateService.TryGetProxy(guildId, out var proxy))
{
var queue = new MusicQueue();
var resolver = _trackResolveProvider;
return null;
}
if (!_voiceStateService.TryGetProxy(guildId, out var proxy))
var settings = await GetSettingsInternalAsync(guildId);
ITextChannel? overrideChannel = null;
if (settings.MusicChannelId is ulong channelId)
{
overrideChannel = _client.GetGuild(guildId)?.GetTextChannel(channelId);
if (overrideChannel is null)
{
return null;
}
var settings = await GetSettingsInternalAsync(guildId);
ITextChannel? overrideChannel = null;
if (settings.MusicChannelId is ulong channelId)
{
overrideChannel = _client.GetGuild(guildId)?.GetTextChannel(channelId);
if (overrideChannel is null)
{
Log.Warning("Saved music output channel doesn't exist, falling back to current channel");
}
Log.Warning("Saved music output channel doesn't exist, falling back to current channel");
}
}
_outputChannels[guildId] = (defaultChannel, overrideChannel);
_outputChannels[guildId] = (defaultChannel, overrideChannel);
var mp = new MusicPlayer(
queue,
resolver,
proxy,
settings.QualityPreset
);
var mp = new MusicPlayer(
queue,
resolver,
proxy,
settings.QualityPreset
);
mp.SetRepeat(settings.PlayerRepeat);
mp.SetRepeat(settings.PlayerRepeat);
if (settings.Volume >= 0 && settings.Volume <= 100)
{
mp.SetVolume(settings.Volume);
}
else
{
Log.Error("Saved Volume is outside of valid range >= 0 && <=100 ({Volume})", settings.Volume);
}
mp.OnCompleted += OnTrackCompleted(guildId);
mp.OnStarted += OnTrackStarted(guildId);
mp.OnQueueStopped += OnQueueStopped(guildId);
return mp;
}
public Task<IUserMessage?> SendToOutputAsync(ulong guildId, IEmbedBuilder embed)
if (settings.Volume >= 0 && settings.Volume <= 100)
{
if (_outputChannels.TryGetValue(guildId, out var chan))
return (chan.Default ?? chan.Override).EmbedAsync(embed);
return Task.FromResult<IUserMessage?>(null);
mp.SetVolume(settings.Volume);
}
private Func<IMusicPlayer, IQueuedTrackInfo, Task> OnTrackCompleted(ulong guildId)
else
{
IUserMessage? lastFinishedMessage = null;
return async (mp, trackInfo) =>
{
_ = lastFinishedMessage?.DeleteAsync();
var embed = _eb.Create()
.WithOkColor()
.WithAuthor(GetText(guildId, strs.finished_song), Music.MusicIconUrl)
.WithDescription(trackInfo.PrettyName())
.WithFooter(trackInfo.PrettyTotalTime());
lastFinishedMessage = await SendToOutputAsync(guildId, embed);
};
Log.Error("Saved Volume is outside of valid range >= 0 && <=100 ({Volume})", settings.Volume);
}
private Func<IMusicPlayer, IQueuedTrackInfo, int, Task> OnTrackStarted(ulong guildId)
mp.OnCompleted += OnTrackCompleted(guildId);
mp.OnStarted += OnTrackStarted(guildId);
mp.OnQueueStopped += OnQueueStopped(guildId);
return mp;
}
public Task<IUserMessage?> SendToOutputAsync(ulong guildId, IEmbedBuilder embed)
{
if (_outputChannels.TryGetValue(guildId, out var chan))
return (chan.Default ?? chan.Override).EmbedAsync(embed);
return Task.FromResult<IUserMessage?>(null);
}
private Func<IMusicPlayer, IQueuedTrackInfo, Task> OnTrackCompleted(ulong guildId)
{
IUserMessage? lastFinishedMessage = null;
return async (mp, trackInfo) =>
{
IUserMessage? lastPlayingMessage = null;
return async (mp, trackInfo, index) =>
{
_ = lastPlayingMessage?.DeleteAsync();
var embed = _eb.Create().WithOkColor()
.WithAuthor(GetText(guildId, strs.playing_song(index + 1)), Music.MusicIconUrl)
.WithDescription(trackInfo.PrettyName())
.WithFooter($"{mp.PrettyVolume()} | {trackInfo.PrettyInfo()}");
_ = lastFinishedMessage?.DeleteAsync();
var embed = _eb.Create()
.WithOkColor()
.WithAuthor(GetText(guildId, strs.finished_song), Music.MusicIconUrl)
.WithDescription(trackInfo.PrettyName())
.WithFooter(trackInfo.PrettyTotalTime());
lastPlayingMessage = await SendToOutputAsync(guildId, embed);
};
}
lastFinishedMessage = await SendToOutputAsync(guildId, embed);
};
}
private Func<IMusicPlayer, Task> OnQueueStopped(ulong guildId)
=> (mp) =>
{
if (_settings.TryGetValue(guildId, out var settings))
{
if (settings.AutoDisconnect)
{
return LeaveVoiceChannelAsync(guildId);
}
}
return Task.CompletedTask;
};
// this has to be done because dragging bot to another vc isn't supported yet
public async Task<bool> PlayAsync(ulong guildId, ulong voiceChannelId)
private Func<IMusicPlayer, IQueuedTrackInfo, int, Task> OnTrackStarted(ulong guildId)
{
IUserMessage? lastPlayingMessage = null;
return async (mp, trackInfo, index) =>
{
if (!TryGetMusicPlayer(guildId, out var mp))
{
return false;
}
_ = lastPlayingMessage?.DeleteAsync();
var embed = _eb.Create().WithOkColor()
.WithAuthor(GetText(guildId, strs.playing_song(index + 1)), Music.MusicIconUrl)
.WithDescription(trackInfo.PrettyName())
.WithFooter($"{mp.PrettyVolume()} | {trackInfo.PrettyInfo()}");
if (mp.IsStopped)
{
if (!_voiceStateService.TryGetProxy(guildId, out var proxy)
|| proxy.State == VoiceProxy.VoiceProxyState.Stopped)
{
await JoinVoiceChannelAsync(guildId, voiceChannelId);
}
}
lastPlayingMessage = await SendToOutputAsync(guildId, embed);
};
}
mp.Next();
return true;
}
private async Task<IList<(string Title, string Url)>> SearchYtLoaderVideosAsync(string query)
{
var result = await _ytLoader.LoadResultsAsync(query);
return result.Select(x => (x.Title, x.Url)).ToList();
}
private async Task<IList<(string Title, string Url)>> SearchGoogleApiVideosAsync(string query)
{
var result = await _googleApiService.GetVideoInfosByKeywordAsync(query, 5);
return result.Select(x => (x.Name, x.Url)).ToList();
}
public async Task<IList<(string Title, string Url)>> SearchVideosAsync(string query)
{
try
{
IList<(string, string)> videos = await SearchYtLoaderVideosAsync(query);
if (videos.Count > 0)
{
return videos;
}
}
catch (Exception ex)
{
Log.Warning("Failed geting videos with YtLoader: {ErrorMessage}", ex.Message);
}
try
{
return await SearchGoogleApiVideosAsync(query);
}
catch (Exception ex)
{
Log.Warning("Failed getting video results with Google Api. " +
"Probably google api key missing: {ErrorMessage}", ex.Message);
}
return Array.Empty<(string, string)>();
}
private string GetText(ulong guildId, LocStr str)
=> _strings.GetText(str, guildId);
public IEnumerable<(string Name, Func<string> Func)> GetPlaceholders()
{
// random song that's playing
yield return ("%music.playing%", () =>
{
var randomPlayingTrack = _players
.Select(x => x.Value.GetCurrentTrack(out _))
.Where(x => x is not null)
.Shuffle()
.FirstOrDefault();
if (randomPlayingTrack is null)
return "-";
return randomPlayingTrack.Title;
});
// number of servers currently listening to music
yield return ("%music.servers%", () =>
{
var count = _players
.Select(x => x.Value.GetCurrentTrack(out _))
.Count(x => x is not null);
return count.ToString();
});
yield return ("%music.queued%", () =>
{
var count = _players
.Sum(x => x.Value.GetQueuedTracks().Count);
return count.ToString();
});
}
#region Settings
private async Task<MusicPlayerSettings> GetSettingsInternalAsync(ulong guildId)
private Func<IMusicPlayer, Task> OnQueueStopped(ulong guildId)
=> (mp) =>
{
if (_settings.TryGetValue(guildId, out var settings))
return settings;
using var uow = _db.GetDbContext();
var toReturn = _settings[guildId] = await uow.MusicPlayerSettings.ForGuildAsync(guildId);
await uow.SaveChangesAsync();
return toReturn;
}
private async Task ModifySettingsInternalAsync<TState>(
ulong guildId,
Action<MusicPlayerSettings, TState> action,
TState state)
{
using var uow = _db.GetDbContext();
var ms = await uow.MusicPlayerSettings.ForGuildAsync(guildId);
action(ms, state);
await uow.SaveChangesAsync();
_settings[guildId] = ms;
}
public async Task<bool> SetMusicChannelAsync(ulong guildId, ulong? channelId)
{
if (channelId is null)
{
await UnsetMusicChannelAsync(guildId);
return true;
if (settings.AutoDisconnect)
{
return LeaveVoiceChannelAsync(guildId);
}
}
var channel = _client.GetGuild(guildId)?.GetTextChannel(channelId.Value);
if (channel is null)
return false;
await ModifySettingsInternalAsync(guildId, (settings, chId) =>
return Task.CompletedTask;
};
// this has to be done because dragging bot to another vc isn't supported yet
public async Task<bool> PlayAsync(ulong guildId, ulong voiceChannelId)
{
if (!TryGetMusicPlayer(guildId, out var mp))
{
return false;
}
if (mp.IsStopped)
{
if (!_voiceStateService.TryGetProxy(guildId, out var proxy)
|| proxy.State == VoiceProxy.VoiceProxyState.Stopped)
{
settings.MusicChannelId = chId;
}, channelId);
await JoinVoiceChannelAsync(guildId, voiceChannelId);
}
}
_outputChannels.AddOrUpdate(guildId,
(channel, channel),
(key, old) => (old.Default, channel));
mp.Next();
return true;
}
private async Task<IList<(string Title, string Url)>> SearchYtLoaderVideosAsync(string query)
{
var result = await _ytLoader.LoadResultsAsync(query);
return result.Select(x => (x.Title, x.Url)).ToList();
}
private async Task<IList<(string Title, string Url)>> SearchGoogleApiVideosAsync(string query)
{
var result = await _googleApiService.GetVideoInfosByKeywordAsync(query, 5);
return result.Select(x => (x.Name, x.Url)).ToList();
}
public async Task<IList<(string Title, string Url)>> SearchVideosAsync(string query)
{
try
{
IList<(string, string)> videos = await SearchYtLoaderVideosAsync(query);
if (videos.Count > 0)
{
return videos;
}
}
catch (Exception ex)
{
Log.Warning("Failed geting videos with YtLoader: {ErrorMessage}", ex.Message);
}
try
{
return await SearchGoogleApiVideosAsync(query);
}
catch (Exception ex)
{
Log.Warning("Failed getting video results with Google Api. " +
"Probably google api key missing: {ErrorMessage}", ex.Message);
}
return Array.Empty<(string, string)>();
}
private string GetText(ulong guildId, LocStr str)
=> _strings.GetText(str, guildId);
public IEnumerable<(string Name, Func<string> Func)> GetPlaceholders()
{
// random song that's playing
yield return ("%music.playing%", () =>
{
var randomPlayingTrack = _players
.Select(x => x.Value.GetCurrentTrack(out _))
.Where(x => x is not null)
.Shuffle()
.FirstOrDefault();
if (randomPlayingTrack is null)
return "-";
return randomPlayingTrack.Title;
});
// number of servers currently listening to music
yield return ("%music.servers%", () =>
{
var count = _players
.Select(x => x.Value.GetCurrentTrack(out _))
.Count(x => x is not null);
return count.ToString();
});
yield return ("%music.queued%", () =>
{
var count = _players
.Sum(x => x.Value.GetQueuedTracks().Count);
return count.ToString();
});
}
#region Settings
private async Task<MusicPlayerSettings> GetSettingsInternalAsync(ulong guildId)
{
if (_settings.TryGetValue(guildId, out var settings))
return settings;
using var uow = _db.GetDbContext();
var toReturn = _settings[guildId] = await uow.MusicPlayerSettings.ForGuildAsync(guildId);
await uow.SaveChangesAsync();
return toReturn;
}
private async Task ModifySettingsInternalAsync<TState>(
ulong guildId,
Action<MusicPlayerSettings, TState> action,
TState state)
{
using var uow = _db.GetDbContext();
var ms = await uow.MusicPlayerSettings.ForGuildAsync(guildId);
action(ms, state);
await uow.SaveChangesAsync();
_settings[guildId] = ms;
}
public async Task<bool> SetMusicChannelAsync(ulong guildId, ulong? channelId)
{
if (channelId is null)
{
await UnsetMusicChannelAsync(guildId);
return true;
}
public async Task UnsetMusicChannelAsync(ulong guildId)
{
await ModifySettingsInternalAsync(guildId, (settings, _) =>
{
settings.MusicChannelId = null;
}, (ulong?)null);
if (_outputChannels.TryGetValue(guildId, out var old))
_outputChannels[guildId] = (old.Default, null);
}
public async Task SetRepeatAsync(ulong guildId, PlayerRepeatType repeatType)
{
await ModifySettingsInternalAsync(guildId, (settings, type) =>
{
settings.PlayerRepeat = type;
}, repeatType);
if (TryGetMusicPlayer(guildId, out var mp))
mp.SetRepeat(repeatType);
}
public async Task SetVolumeAsync(ulong guildId, int value)
{
if (value < 0 || value > 100)
throw new ArgumentOutOfRangeException(nameof(value));
await ModifySettingsInternalAsync(guildId, (settings, newValue) =>
{
settings.Volume = newValue;
}, value);
var channel = _client.GetGuild(guildId)?.GetTextChannel(channelId.Value);
if (channel is null)
return false;
await ModifySettingsInternalAsync(guildId, (settings, chId) =>
{
settings.MusicChannelId = chId;
}, channelId);
_outputChannels.AddOrUpdate(guildId,
(channel, channel),
(key, old) => (old.Default, channel));
if (TryGetMusicPlayer(guildId, out var mp))
mp.SetVolume(value);
}
public async Task<bool> ToggleAutoDisconnectAsync(ulong guildId)
{
var newState = false;
await ModifySettingsInternalAsync(guildId, (settings, _) =>
{
newState = settings.AutoDisconnect = !settings.AutoDisconnect;
}, default(object));
return newState;
}
public async Task<QualityPreset> GetMusicQualityAsync(ulong guildId)
{
using var uow = _db.GetDbContext();
var settings = await uow.MusicPlayerSettings.ForGuildAsync(guildId);
return settings.QualityPreset;
}
public Task SetMusicQualityAsync(ulong guildId, QualityPreset preset)
{
return ModifySettingsInternalAsync(guildId, (settings, _) =>
{
settings.QualityPreset = preset;
}, preset);
}
#endregion
return true;
}
public async Task UnsetMusicChannelAsync(ulong guildId)
{
await ModifySettingsInternalAsync(guildId, (settings, _) =>
{
settings.MusicChannelId = null;
}, (ulong?)null);
if (_outputChannels.TryGetValue(guildId, out var old))
_outputChannels[guildId] = (old.Default, null);
}
public async Task SetRepeatAsync(ulong guildId, PlayerRepeatType repeatType)
{
await ModifySettingsInternalAsync(guildId, (settings, type) =>
{
settings.PlayerRepeat = type;
}, repeatType);
if (TryGetMusicPlayer(guildId, out var mp))
mp.SetRepeat(repeatType);
}
public async Task SetVolumeAsync(ulong guildId, int value)
{
if (value < 0 || value > 100)
throw new ArgumentOutOfRangeException(nameof(value));
await ModifySettingsInternalAsync(guildId, (settings, newValue) =>
{
settings.Volume = newValue;
}, value);
if (TryGetMusicPlayer(guildId, out var mp))
mp.SetVolume(value);
}
public async Task<bool> ToggleAutoDisconnectAsync(ulong guildId)
{
var newState = false;
await ModifySettingsInternalAsync(guildId, (settings, _) =>
{
newState = settings.AutoDisconnect = !settings.AutoDisconnect;
}, default(object));
return newState;
}
public async Task<QualityPreset> GetMusicQualityAsync(ulong guildId)
{
using var uow = _db.GetDbContext();
var settings = await uow.MusicPlayerSettings.ForGuildAsync(guildId);
return settings.QualityPreset;
}
public Task SetMusicQualityAsync(ulong guildId, QualityPreset preset)
{
return ModifySettingsInternalAsync(guildId, (settings, _) =>
{
settings.QualityPreset = preset;
}, preset);
}
#endregion
}

View File

@@ -1,75 +1,71 @@
using System;
namespace NadekoBot.Modules.Music.Services;
namespace NadekoBot.Modules.Music.Services
public sealed partial class YtLoader
{
public sealed partial class YtLoader
public class InitRange
{
public string Start { get; set; }
public string End { get; set; }
}
public class InitRange
public class IndexRange
{
public string Start { get; set; }
public string End { get; set; }
}
public class ColorInfo
{
public string Primaries { get; set; }
public string TransferCharacteristics { get; set; }
public string MatrixCoefficients { get; set; }
}
public class YtAdaptiveFormat
{
public int Itag { get; set; }
public string MimeType { get; set; }
public int Bitrate { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public InitRange InitRange { get; set; }
public IndexRange IndexRange { get; set; }
public string LastModified { get; set; }
public string ContentLength { get; set; }
public string Quality { get; set; }
public int Fps { get; set; }
public string QualityLabel { get; set; }
public string ProjectionType { get; set; }
public int AverageBitrate { get; set; }
public ColorInfo ColorInfo { get; set; }
public string ApproxDurationMs { get; set; }
public string SignatureCipher { get; set; }
}
public abstract class TrackInfo
{
public abstract string Url { get; }
public abstract string Title { get; }
public abstract TimeSpan Duration { get; }
}
public sealed class YtTrackInfo : TrackInfo
{
private const string BaseYoutubeUrl = "https://youtube.com/watch?v=";
public override string Url { get; }
public override string Title { get; }
public override TimeSpan Duration { get; }
private readonly string _videoId;
public YtTrackInfo(string title, string videoId, TimeSpan duration)
{
public string Start { get; set; }
public string End { get; set; }
}
Title = title;
Url = BaseYoutubeUrl + videoId;
Duration = duration;
public class IndexRange
{
public string Start { get; set; }
public string End { get; set; }
}
public class ColorInfo
{
public string Primaries { get; set; }
public string TransferCharacteristics { get; set; }
public string MatrixCoefficients { get; set; }
}
public class YtAdaptiveFormat
{
public int Itag { get; set; }
public string MimeType { get; set; }
public int Bitrate { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public InitRange InitRange { get; set; }
public IndexRange IndexRange { get; set; }
public string LastModified { get; set; }
public string ContentLength { get; set; }
public string Quality { get; set; }
public int Fps { get; set; }
public string QualityLabel { get; set; }
public string ProjectionType { get; set; }
public int AverageBitrate { get; set; }
public ColorInfo ColorInfo { get; set; }
public string ApproxDurationMs { get; set; }
public string SignatureCipher { get; set; }
}
public abstract class TrackInfo
{
public abstract string Url { get; }
public abstract string Title { get; }
public abstract TimeSpan Duration { get; }
}
public sealed class YtTrackInfo : TrackInfo
{
private const string BaseYoutubeUrl = "https://youtube.com/watch?v=";
public override string Url { get; }
public override string Title { get; }
public override TimeSpan Duration { get; }
private readonly string _videoId;
public YtTrackInfo(string title, string videoId, TimeSpan duration)
{
Title = title;
Url = BaseYoutubeUrl + videoId;
Duration = duration;
_videoId = videoId;
}
_videoId = videoId;
}
}
}

View File

@@ -1,140 +1,133 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Globalization;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Discord.Net;
using Serilog;
namespace NadekoBot.Modules.Music.Services
namespace NadekoBot.Modules.Music.Services;
public sealed partial class YtLoader
{
public sealed partial class YtLoader
{
private readonly IHttpClientFactory _httpFactory;
private static readonly byte[] YT_RESULT_INITIAL_DATA = Encoding.UTF8.GetBytes("var ytInitialData = ");
private static readonly byte[] YT_RESULT_JSON_END = Encoding.UTF8.GetBytes(";<");
private readonly IHttpClientFactory _httpFactory;
private static readonly byte[] YT_RESULT_INITIAL_DATA = Encoding.UTF8.GetBytes("var ytInitialData = ");
private static readonly byte[] YT_RESULT_JSON_END = Encoding.UTF8.GetBytes(";<");
private static readonly string[] durationFormats = new[]
{
@"m\:ss", @"mm\:ss", @"h\:mm\:ss", @"hh\:mm\:ss", @"hhh\:mm\:ss"
};
private static readonly string[] durationFormats = new[]
{
@"m\:ss", @"mm\:ss", @"h\:mm\:ss", @"hh\:mm\:ss", @"hhh\:mm\:ss"
};
public YtLoader(IHttpClientFactory httpFactory)
public YtLoader(IHttpClientFactory httpFactory)
{
_httpFactory = httpFactory;
}
// public async Task<TrackInfo> LoadTrackByIdAsync(string videoId)
// {
// using var http = new HttpClient();
// http.DefaultRequestHeaders.Add("X-YouTube-Client-Name", "1");
// http.DefaultRequestHeaders.Add("X-YouTube-Client-Version", "2.20210520.09.00");
// http.DefaultRequestHeaders.Add("Cookie", "CONSENT=YES+cb.20210530-19-p0.en+FX+071;");
//
// var responseString = await http.GetStringAsync($"https://youtube.com?" +
// $"pbj=1" +
// $"&hl=en" +
// $"&v=" + videoId);
//
// var jsonDoc = JsonDocument.Parse(responseString).RootElement;
// var elem = jsonDoc.EnumerateArray()
// .FirstOrDefault(x => x.TryGetProperty("page", out var elem) && elem.GetString() == "watch");
//
// var formatsJsonArray = elem.GetProperty("streamingdata")
// .GetProperty("formats")
// .GetRawText();
//
// var formats = JsonSerializer.Deserialize<List<YtAdaptiveFormat>>(formatsJsonArray);
// var result = formats
// .Where(x => x.MimeType.StartsWith("audio/"))
// .OrderByDescending(x => x.Bitrate)
// .FirstOrDefault();
//
// if (result is null)
// return null;
//
// return new YtTrackInfo("1", "2", TimeSpan.Zero);
// }
public async Task<IList<TrackInfo>> LoadResultsAsync(string query)
{
query = Uri.EscapeDataString(query);
using var http = _httpFactory.CreateClient();
http.DefaultRequestHeaders.Add("Cookie", "CONSENT=YES+cb.20210530-19-p0.en+FX+071;");
byte[] response;
try
{
_httpFactory = httpFactory;
response = await http.GetByteArrayAsync($"https://youtube.com/results?hl=en&search_query={query}");
}
catch (HttpRequestException ex)
{
Log.Warning("Unable to retrieve data with YtLoader: {ErrorMessage}", ex.Message);
return null;
}
// public async Task<TrackInfo> LoadTrackByIdAsync(string videoId)
// {
// using var http = new HttpClient();
// http.DefaultRequestHeaders.Add("X-YouTube-Client-Name", "1");
// http.DefaultRequestHeaders.Add("X-YouTube-Client-Version", "2.20210520.09.00");
// http.DefaultRequestHeaders.Add("Cookie", "CONSENT=YES+cb.20210530-19-p0.en+FX+071;");
//
// var responseString = await http.GetStringAsync($"https://youtube.com?" +
// $"pbj=1" +
// $"&hl=en" +
// $"&v=" + videoId);
//
// var jsonDoc = JsonDocument.Parse(responseString).RootElement;
// var elem = jsonDoc.EnumerateArray()
// .FirstOrDefault(x => x.TryGetProperty("page", out var elem) && elem.GetString() == "watch");
//
// var formatsJsonArray = elem.GetProperty("streamingdata")
// .GetProperty("formats")
// .GetRawText();
//
// var formats = JsonSerializer.Deserialize<List<YtAdaptiveFormat>>(formatsJsonArray);
// var result = formats
// .Where(x => x.MimeType.StartsWith("audio/"))
// .OrderByDescending(x => x.Bitrate)
// .FirstOrDefault();
//
// if (result is null)
// return null;
//
// return new YtTrackInfo("1", "2", TimeSpan.Zero);
// }
// there is a lot of useless html above the script tag, however if html gets significantly reduced
// this will result in the json being cut off
public async Task<IList<TrackInfo>> LoadResultsAsync(string query)
var mem = GetScriptResponseSpan(response);
var root = JsonDocument.Parse(mem).RootElement;
var tracksJsonItems = root
.GetProperty("contents")
.GetProperty("twoColumnSearchResultsRenderer")
.GetProperty("primaryContents")
.GetProperty("sectionListRenderer")
.GetProperty("contents")
[0]
.GetProperty("itemSectionRenderer")
.GetProperty("contents")
.EnumerateArray();
var tracks = new List<TrackInfo>();
foreach (var track in tracksJsonItems)
{
query = Uri.EscapeDataString(query);
using var http = _httpFactory.CreateClient();
http.DefaultRequestHeaders.Add("Cookie", "CONSENT=YES+cb.20210530-19-p0.en+FX+071;");
if(!track.TryGetProperty("videoRenderer", out var elem))
continue;
byte[] response;
try
{
response = await http.GetByteArrayAsync($"https://youtube.com/results?hl=en&search_query={query}");
}
catch (HttpRequestException ex)
{
Log.Warning("Unable to retrieve data with YtLoader: {ErrorMessage}", ex.Message);
return null;
}
var videoId = elem.GetProperty("videoId").GetString();
// var thumb = elem.GetProperty("thumbnail").GetProperty("thumbnails")[0].GetProperty("url").GetString();
var title = elem.GetProperty("title").GetProperty("runs")[0].GetProperty("text").GetString();
var durationString = elem.GetProperty("lengthText").GetProperty("simpleText").GetString();
// there is a lot of useless html above the script tag, however if html gets significantly reduced
// this will result in the json being cut off
var mem = GetScriptResponseSpan(response);
var root = JsonDocument.Parse(mem).RootElement;
var tracksJsonItems = root
.GetProperty("contents")
.GetProperty("twoColumnSearchResultsRenderer")
.GetProperty("primaryContents")
.GetProperty("sectionListRenderer")
.GetProperty("contents")
[0]
.GetProperty("itemSectionRenderer")
.GetProperty("contents")
.EnumerateArray();
var tracks = new List<TrackInfo>();
foreach (var track in tracksJsonItems)
{
if(!track.TryGetProperty("videoRenderer", out var elem))
continue;
var videoId = elem.GetProperty("videoId").GetString();
// var thumb = elem.GetProperty("thumbnail").GetProperty("thumbnails")[0].GetProperty("url").GetString();
var title = elem.GetProperty("title").GetProperty("runs")[0].GetProperty("text").GetString();
var durationString = elem.GetProperty("lengthText").GetProperty("simpleText").GetString();
if (!TimeSpan.TryParseExact(durationString, durationFormats, CultureInfo.InvariantCulture,
if (!TimeSpan.TryParseExact(durationString, durationFormats, CultureInfo.InvariantCulture,
out var duration))
{
Log.Warning("Cannot parse duration: {DurationString}", durationString);
continue;
}
tracks.Add(new YtTrackInfo(title, videoId, duration));
if (tracks.Count >= 5)
break;
{
Log.Warning("Cannot parse duration: {DurationString}", durationString);
continue;
}
tracks.Add(new YtTrackInfo(title, videoId, duration));
if (tracks.Count >= 5)
break;
}
return tracks;
}
return tracks;
}
private Memory<byte> GetScriptResponseSpan(byte[] response)
{
var responseSpan = response.AsSpan().Slice(140_000);
var startIndex = responseSpan.IndexOf(YT_RESULT_INITIAL_DATA);
if (startIndex == -1)
return null; // todo future try selecting html
startIndex += YT_RESULT_INITIAL_DATA.Length;
private Memory<byte> GetScriptResponseSpan(byte[] response)
{
var responseSpan = response.AsSpan().Slice(140_000);
var startIndex = responseSpan.IndexOf(YT_RESULT_INITIAL_DATA);
if (startIndex == -1)
return null; // todo future try selecting html
startIndex += YT_RESULT_INITIAL_DATA.Length;
var endIndex = 140_000 + startIndex + responseSpan.Slice(startIndex + 20_000).IndexOf(YT_RESULT_JSON_END) + 20_000;
startIndex += 140_000;
return response.AsMemory(
startIndex,
endIndex - startIndex
);
}
var endIndex = 140_000 + startIndex + responseSpan.Slice(startIndex + 20_000).IndexOf(YT_RESULT_JSON_END) + 20_000;
startIndex += 140_000;
return response.AsMemory(
startIndex,
endIndex - startIndex
);
}
}