mirror of
https://gitlab.com/Kwoth/nadekobot.git
synced 2025-09-11 09:48:26 -04:00
Applied codestyle to all .cs files
This commit is contained in:
@@ -9,4 +9,4 @@ public interface ICachableTrackData
|
||||
public TimeSpan Duration { get; }
|
||||
MusicPlatform Platform { get; set; }
|
||||
string Title { get; set; }
|
||||
}
|
||||
}
|
@@ -4,4 +4,4 @@ namespace NadekoBot.Modules.Music;
|
||||
public interface ILocalTrackResolver : IPlatformQueryResolver
|
||||
{
|
||||
IAsyncEnumerable<ITrackInfo> ResolveDirectoryAsync(string dirPath);
|
||||
}
|
||||
}
|
@@ -21,9 +21,14 @@ public interface IMusicPlayer : IDisposable
|
||||
|
||||
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<(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);
|
||||
@@ -31,4 +36,4 @@ public interface IMusicPlayer : IDisposable
|
||||
void EnqueueTracks(IEnumerable<ITrackInfo> tracks, string queuer);
|
||||
void SetRepeat(PlayerRepeatType type);
|
||||
void ShuffleQueue();
|
||||
}
|
||||
}
|
@@ -2,21 +2,21 @@
|
||||
|
||||
public interface IMusicQueue
|
||||
{
|
||||
int Index { get; }
|
||||
int Count { get; }
|
||||
IQueuedTrackInfo Enqueue(ITrackInfo trackInfo, string queuer, out int index);
|
||||
IQueuedTrackInfo EnqueueNext(ITrackInfo song, string queuer, out int index);
|
||||
|
||||
|
||||
void EnqueueMany(IEnumerable<ITrackInfo> tracks, string queuer);
|
||||
|
||||
|
||||
public IReadOnlyCollection<IQueuedTrackInfo> List();
|
||||
IQueuedTrackInfo? GetCurrent(out int index);
|
||||
void Advance();
|
||||
void Clear();
|
||||
bool SetIndex(int index);
|
||||
bool TryRemoveAt(int index, out IQueuedTrackInfo? trackInfo, out bool isCurrent);
|
||||
int Index { get; }
|
||||
int Count { get; }
|
||||
void RemoveCurrent();
|
||||
IQueuedTrackInfo? MoveTrack(int from, int to);
|
||||
void Shuffle(Random rng);
|
||||
bool IsLast();
|
||||
}
|
||||
}
|
@@ -3,4 +3,4 @@
|
||||
public interface IPlatformQueryResolver
|
||||
{
|
||||
Task<ITrackInfo?> ResolveByQueryAsync(string query);
|
||||
}
|
||||
}
|
@@ -6,4 +6,4 @@ public interface IQueuedTrackInfo : ITrackInfo
|
||||
public ITrackInfo TrackInfo { get; }
|
||||
|
||||
public string Queuer { get; }
|
||||
}
|
||||
}
|
@@ -3,5 +3,4 @@ namespace NadekoBot.Modules.Music;
|
||||
|
||||
public interface IRadioResolver : IPlatformQueryResolver
|
||||
{
|
||||
|
||||
}
|
||||
}
|
@@ -5,4 +5,4 @@ public interface ISoundcloudResolver : IPlatformQueryResolver
|
||||
{
|
||||
bool IsSoundCloudLink(string url);
|
||||
IAsyncEnumerable<ITrackInfo> ResolvePlaylistAsync(string playlist);
|
||||
}
|
||||
}
|
@@ -5,16 +5,21 @@ public interface ITrackCacher
|
||||
Task<string?> GetOrCreateStreamLink(
|
||||
string id,
|
||||
MusicPlatform platform,
|
||||
Func<Task<(string StreamUrl, TimeSpan Expiry)>> streamUrlFactory
|
||||
);
|
||||
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 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);
|
||||
}
|
||||
}
|
@@ -8,4 +8,4 @@ public interface ITrackInfo
|
||||
public TimeSpan Duration { get; }
|
||||
public MusicPlatform Platform { get; }
|
||||
public ValueTask<string?> GetStreamUrl();
|
||||
}
|
||||
}
|
@@ -3,4 +3,4 @@
|
||||
public interface ITrackResolveProvider
|
||||
{
|
||||
Task<ITrackInfo?> QuerySongAsync(string query, MusicPlatform? forcePlatform);
|
||||
}
|
||||
}
|
@@ -12,4 +12,4 @@ public interface IVoiceProxy
|
||||
Task StopSpeakingAsync();
|
||||
public Task StartGateway();
|
||||
Task StopGateway();
|
||||
}
|
||||
}
|
@@ -8,4 +8,4 @@ public interface IYoutubeResolver : IPlatformQueryResolver
|
||||
public Task<ITrackInfo?> ResolveByIdAsync(string id);
|
||||
IAsyncEnumerable<ITrackInfo> ResolveTracksFromPlaylistAsync(string query);
|
||||
Task<ITrackInfo?> ResolveByQueryAsync(string query, bool tryExtractingId);
|
||||
}
|
||||
}
|
@@ -10,7 +10,10 @@ public sealed class CachableTrackData : ICachableTrackData
|
||||
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 TimeSpan Duration
|
||||
=> TimeSpan.FromMilliseconds(TotalDurationMs);
|
||||
|
||||
public MusicPlatform Platform { get; set; }
|
||||
}
|
||||
}
|
@@ -5,61 +5,6 @@ namespace NadekoBot.Modules.Music.Common;
|
||||
|
||||
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
|
||||
{
|
||||
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
|
||||
);
|
||||
|
||||
private LpTimeProcDelegate _lpTimeProc;
|
||||
private readonly uint _eventId;
|
||||
private readonly Action<object> _callback;
|
||||
@@ -77,7 +22,56 @@ public sealed class MultimediaTimer : IDisposable
|
||||
_eventId = timeSetEvent((uint)period, 1, _lpTimeProc, 0, TimerMode.Periodic);
|
||||
}
|
||||
|
||||
private void CallbackInternal(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);
|
||||
|
||||
/// <summary>
|
||||
/// The timeKillEvent function cancels a specified timer event.
|
||||
/// </summary>
|
||||
/// <param name="uTimerID">
|
||||
/// Identifier of the timer event to cancel.
|
||||
/// This identifier was returned by the timeSetEvent function when the timer event was set up.
|
||||
/// </param>
|
||||
/// <returns>Returns TIMERR_NOERROR if successful or MMSYSERR_INVALPARAM if the specified timer event does not exist.</returns>
|
||||
[DllImport("Winmm.dll")]
|
||||
private static extern int timeKillEvent(uint uTimerID);
|
||||
|
||||
private void CallbackInternal(
|
||||
uint uTimerId,
|
||||
uint uMsg,
|
||||
int dwUser,
|
||||
int dw1,
|
||||
int dw2)
|
||||
=> _callback(_state);
|
||||
|
||||
public void Dispose()
|
||||
@@ -85,4 +79,17 @@ public sealed class MultimediaTimer : IDisposable
|
||||
_lpTimeProc = default;
|
||||
timeKillEvent(_eventId);
|
||||
}
|
||||
}
|
||||
|
||||
private delegate void LpTimeProcDelegate(
|
||||
uint uTimerID,
|
||||
uint uMsg,
|
||||
int dwUser,
|
||||
int dw1,
|
||||
int dw2);
|
||||
|
||||
private enum TimerMode
|
||||
{
|
||||
OneShot,
|
||||
Periodic
|
||||
}
|
||||
}
|
@@ -20,8 +20,8 @@ public static class MusicExtensions
|
||||
}
|
||||
|
||||
public static string PrettyVolume(this IMusicPlayer mp)
|
||||
=> $"🔉 {(int) (mp.Volume * 100)}%";
|
||||
|
||||
=> $"🔉 {(int)(mp.Volume * 100)}%";
|
||||
|
||||
public static string PrettyName(this ITrackInfo trackInfo)
|
||||
=> $"**[{trackInfo.Title.TrimTo(60).Replace("[", "\\[").Replace("]", "\\]")}]({trackInfo.Url.TrimTo(50, true)})**";
|
||||
|
||||
@@ -45,7 +45,7 @@ public static class MusicExtensions
|
||||
}
|
||||
|
||||
public static ICachableTrackData ToCachedData(this ITrackInfo trackInfo, string id)
|
||||
=> new CachableTrackData()
|
||||
=> new CachableTrackData
|
||||
{
|
||||
TotalDurationMs = trackInfo.Duration.TotalMilliseconds,
|
||||
Id = id,
|
||||
@@ -54,4 +54,4 @@ public static class MusicExtensions
|
||||
Platform = trackInfo.Platform,
|
||||
Title = trackInfo.Title
|
||||
};
|
||||
}
|
||||
}
|
@@ -6,5 +6,5 @@ public enum MusicPlatform
|
||||
Radio,
|
||||
Youtube,
|
||||
Local,
|
||||
SoundCloud,
|
||||
}
|
||||
SoundCloud
|
||||
}
|
@@ -1,28 +1,29 @@
|
||||
using Ayu.Discord.Voice;
|
||||
using NadekoBot.Services.Database.Models;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using Ayu.Discord.Voice;
|
||||
using NadekoBot.Services.Database.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Music;
|
||||
|
||||
public sealed class MusicPlayer : IMusicPlayer
|
||||
{
|
||||
private delegate void AdjustVolumeDelegate(Span<byte> data, float volume);
|
||||
|
||||
private readonly AdjustVolumeDelegate AdjustVolume;
|
||||
private readonly VoiceClient _vc;
|
||||
|
||||
public event Func<IMusicPlayer, IQueuedTrackInfo, Task>? OnCompleted;
|
||||
public event Func<IMusicPlayer, IQueuedTrackInfo, int, Task>? OnStarted;
|
||||
public event Func<IMusicPlayer, Task>? OnQueueStopped;
|
||||
public bool IsKilled { get; private set; }
|
||||
public bool IsStopped { get; private set; }
|
||||
public bool IsPaused { get; private set; }
|
||||
public PlayerRepeatType Repeat { get; private set; }
|
||||
|
||||
public int CurrentIndex => _queue.Index;
|
||||
|
||||
public float Volume => _volume;
|
||||
private float _volume = 1.0f;
|
||||
|
||||
public int CurrentIndex
|
||||
=> _queue.Index;
|
||||
|
||||
public float Volume { get; private set; } = 1.0f;
|
||||
|
||||
private readonly AdjustVolumeDelegate AdjustVolume;
|
||||
private readonly VoiceClient _vc;
|
||||
|
||||
private readonly IMusicQueue _queue;
|
||||
private readonly ITrackResolveProvider _trackResolveProvider;
|
||||
@@ -50,7 +51,7 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||
AdjustVolume = AdjustVolumeInt16;
|
||||
else
|
||||
AdjustVolume = AdjustVolumeFloat32;
|
||||
|
||||
|
||||
_songBuffer = new PoopyBufferImmortalized(_vc.InputLength);
|
||||
|
||||
_thread = new(async () =>
|
||||
@@ -63,34 +64,18 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||
private static VoiceClient GetVoiceClient(QualityPreset qualityPreset)
|
||||
=> qualityPreset switch
|
||||
{
|
||||
QualityPreset.Highest => new(
|
||||
SampleRate._48k,
|
||||
Bitrate._192k,
|
||||
Channels.Two,
|
||||
FrameDelay.Delay20,
|
||||
BitDepthEnum.Float32
|
||||
),
|
||||
QualityPreset.High => new(
|
||||
SampleRate._48k,
|
||||
Bitrate._128k,
|
||||
Channels.Two,
|
||||
FrameDelay.Delay40,
|
||||
BitDepthEnum.Float32
|
||||
),
|
||||
QualityPreset.Medium => new(
|
||||
SampleRate._48k,
|
||||
QualityPreset.Highest => new(),
|
||||
QualityPreset.High => new(SampleRate._48k, Bitrate._128k, Channels.Two, FrameDelay.Delay40),
|
||||
QualityPreset.Medium => new(SampleRate._48k,
|
||||
Bitrate._96k,
|
||||
Channels.Two,
|
||||
FrameDelay.Delay40,
|
||||
BitDepthEnum.UInt16
|
||||
),
|
||||
QualityPreset.Low => new(
|
||||
SampleRate._48k,
|
||||
BitDepthEnum.UInt16),
|
||||
QualityPreset.Low => new(SampleRate._48k,
|
||||
Bitrate._64k,
|
||||
Channels.Two,
|
||||
FrameDelay.Delay40,
|
||||
BitDepthEnum.UInt16
|
||||
),
|
||||
BitDepthEnum.UInt16),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(qualityPreset), qualityPreset, null)
|
||||
};
|
||||
|
||||
@@ -103,7 +88,7 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||
// wait until a song is available in the queue
|
||||
// or until the queue is resumed
|
||||
var track = _queue.GetCurrent(out var index);
|
||||
|
||||
|
||||
if (track is null || IsStopped)
|
||||
{
|
||||
await Task.Delay(500);
|
||||
@@ -134,9 +119,8 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||
using var source = FfmpegTrackDataSource.CreateAsync(
|
||||
_vc.BitDepth,
|
||||
streamUrl,
|
||||
track.Platform == MusicPlatform.Local
|
||||
);
|
||||
|
||||
track.Platform == MusicPlatform.Local);
|
||||
|
||||
// start moving data from the source into the buffer
|
||||
// this method will return once the sufficient prebuffering is done
|
||||
await _songBuffer.BufferAsync(source, token);
|
||||
@@ -191,10 +175,8 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||
sw.Start();
|
||||
Thread.Sleep(2);
|
||||
|
||||
var delay = sw.ElapsedTicks * ticksPerMs > 3f
|
||||
? _vc.Delay - 16
|
||||
: _vc.Delay - 3;
|
||||
|
||||
var delay = sw.ElapsedTicks * ticksPerMs > 3f ? _vc.Delay - 16 : _vc.Delay - 3;
|
||||
|
||||
var errorCount = 0;
|
||||
while (!IsStopped && !IsKilled)
|
||||
{
|
||||
@@ -205,23 +187,23 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||
_skipped = false;
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
if (IsPaused)
|
||||
{
|
||||
await Task.Delay(200);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
sw.Restart();
|
||||
var ticks = sw.ElapsedTicks;
|
||||
try
|
||||
{
|
||||
var result = CopyChunkToOutput(_songBuffer, _vc);
|
||||
|
||||
|
||||
// if song is finished
|
||||
if (result is null)
|
||||
break;
|
||||
|
||||
|
||||
if (result is true)
|
||||
{
|
||||
if (errorCount > 0)
|
||||
@@ -229,12 +211,12 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||
_ = _proxy.StartSpeakingAsync();
|
||||
errorCount = 0;
|
||||
}
|
||||
|
||||
|
||||
// todo future windows multimedia api
|
||||
|
||||
|
||||
// wait for slightly less than the latency
|
||||
Thread.Sleep(delay);
|
||||
|
||||
|
||||
// and then spin out the rest
|
||||
while ((sw.ElapsedTicks - ticks) * ticksPerMs <= _vc.Delay - 0.1f)
|
||||
Thread.SpinWait(100);
|
||||
@@ -243,16 +225,16 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||
{
|
||||
// result is false is either when the gateway is being swapped
|
||||
// or if the bot is reconnecting, or just disconnected for whatever reason
|
||||
|
||||
|
||||
// tolerate up to 15x200ms of failures (3 seconds)
|
||||
if (++errorCount <= 15)
|
||||
{
|
||||
await Task.Delay(200);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
Log.Warning("Can't send data to voice channel");
|
||||
|
||||
|
||||
IsStopped = true;
|
||||
// if errors are happening for more than 3 seconds
|
||||
// Stop the player
|
||||
@@ -268,8 +250,8 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||
catch (Win32Exception)
|
||||
{
|
||||
IsStopped = true;
|
||||
Log.Error("Please install ffmpeg and make sure it's added to your " +
|
||||
"PATH environment variable before trying again");
|
||||
Log.Error("Please install ffmpeg and make sure it's added to your "
|
||||
+ "PATH environment variable before trying again");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -284,12 +266,13 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||
cancellationTokenSource.Cancel();
|
||||
// turn off green in vc
|
||||
_ = OnCompleted?.Invoke(this, track);
|
||||
|
||||
|
||||
HandleQueuePostTrack();
|
||||
_skipped = false;
|
||||
|
||||
_ = _proxy.StopSpeakingAsync();;
|
||||
|
||||
|
||||
_ = _proxy.StopSpeakingAsync();
|
||||
;
|
||||
|
||||
await Task.Delay(100);
|
||||
}
|
||||
}
|
||||
@@ -300,12 +283,9 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||
var data = sb.Read(vc.InputLength, out var length);
|
||||
|
||||
// if nothing is read from the buffer, song is finished
|
||||
if (data.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (data.Length == 0) return null;
|
||||
|
||||
AdjustVolume(data, _volume);
|
||||
AdjustVolume(data, Volume);
|
||||
return _proxy.SendPcmFrame(vc, data, length);
|
||||
}
|
||||
|
||||
@@ -322,7 +302,7 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||
|
||||
if (repeat == PlayerRepeatType.Track || isStopped)
|
||||
return;
|
||||
|
||||
|
||||
// if queue is being repeated, advance no matter what
|
||||
if (repeat == PlayerRepeatType.None)
|
||||
{
|
||||
@@ -334,37 +314,36 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||
OnQueueStopped?.Invoke(this);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
_queue.Advance();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
_queue.Advance();
|
||||
}
|
||||
|
||||
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void AdjustVolumeInt16(Span<byte> audioSamples, float volume)
|
||||
{
|
||||
if (Math.Abs(volume - 1f) < 0.0001f) return;
|
||||
|
||||
|
||||
var samples = MemoryMarshal.Cast<byte, short>(audioSamples);
|
||||
|
||||
|
||||
for (var i = 0; i < samples.Length; i++)
|
||||
{
|
||||
ref var sample = ref samples[i];
|
||||
sample = (short) (sample * volume);
|
||||
sample = (short)(sample * volume);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void AdjustVolumeFloat32(Span<byte> audioSamples, float volume)
|
||||
{
|
||||
if (Math.Abs(volume - 1f) < 0.0001f) return;
|
||||
|
||||
|
||||
var samples = MemoryMarshal.Cast<byte, float>(audioSamples);
|
||||
|
||||
|
||||
for (var i = 0; i < samples.Length; i++)
|
||||
{
|
||||
ref var sample = ref samples[i];
|
||||
@@ -373,7 +352,7 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||
}
|
||||
|
||||
public async Task<(IQueuedTrackInfo? QueuedTrack, int Index)> TryEnqueueTrackAsync(
|
||||
string query,
|
||||
string query,
|
||||
string queuer,
|
||||
bool asNext,
|
||||
MusicPlatform? forcePlatform = null)
|
||||
@@ -389,7 +368,7 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||
|
||||
return (_queue.Enqueue(song, queuer, out index), index);
|
||||
}
|
||||
|
||||
|
||||
public async Task EnqueueManyAsync(IEnumerable<(string Query, MusicPlatform Platform)> queries, string queuer)
|
||||
{
|
||||
var errorCount = 0;
|
||||
@@ -397,30 +376,31 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||
{
|
||||
if (IsKilled)
|
||||
break;
|
||||
|
||||
|
||||
await chunk.Select(async data =>
|
||||
{
|
||||
var (query, platform) = data;
|
||||
try
|
||||
{
|
||||
await TryEnqueueTrackAsync(query, queuer, false, forcePlatform: platform);
|
||||
errorCount = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error resolving {MusicPlatform} Track {TrackQuery}", platform, query);
|
||||
++errorCount;
|
||||
}
|
||||
}).WhenAll();
|
||||
{
|
||||
var (query, platform) = data;
|
||||
try
|
||||
{
|
||||
await TryEnqueueTrackAsync(query, queuer, false, platform);
|
||||
errorCount = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error resolving {MusicPlatform} Track {TrackQuery}", platform, query);
|
||||
++errorCount;
|
||||
}
|
||||
})
|
||||
.WhenAll();
|
||||
|
||||
await Task.Delay(1000);
|
||||
|
||||
|
||||
// > 10 errors in a row = kill
|
||||
if (errorCount > 10)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void EnqueueTrack(ITrackInfo track, string queuer)
|
||||
=> _queue.Enqueue(track, queuer, out _);
|
||||
|
||||
@@ -475,7 +455,7 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||
if (normalizedVolume is < 0f or > 1f)
|
||||
throw new ArgumentOutOfRangeException(nameof(newVolume), "Volume must be in range 0-100");
|
||||
|
||||
_volume = normalizedVolume;
|
||||
Volume = normalizedVolume;
|
||||
}
|
||||
|
||||
public void Kill()
|
||||
@@ -496,9 +476,12 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TogglePause() => IsPaused = !IsPaused;
|
||||
public IQueuedTrackInfo? MoveTrack(int from, int to) => _queue.MoveTrack(from, to);
|
||||
|
||||
public bool TogglePause()
|
||||
=> IsPaused = !IsPaused;
|
||||
|
||||
public IQueuedTrackInfo? MoveTrack(int from, int to)
|
||||
=> _queue.MoveTrack(from, to);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
@@ -511,7 +494,5 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||
_vc.Dispose();
|
||||
}
|
||||
|
||||
public event Func<IMusicPlayer, IQueuedTrackInfo, Task>? OnCompleted;
|
||||
public event Func<IMusicPlayer, IQueuedTrackInfo, int, Task>? OnStarted;
|
||||
public event Func<IMusicPlayer, Task>? OnQueueStopped;
|
||||
}
|
||||
private delegate void AdjustVolumeDelegate(Span<byte> data, float volume);
|
||||
}
|
@@ -7,11 +7,20 @@ public sealed partial class MusicQueue
|
||||
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 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)
|
||||
@@ -20,14 +29,13 @@ public sealed partial class MusicQueue
|
||||
Queuer = queuer;
|
||||
}
|
||||
|
||||
public ValueTask<string?> GetStreamUrl() => TrackInfo.GetStreamUrl();
|
||||
public ValueTask<string?> GetStreamUrl()
|
||||
=> TrackInfo.GetStreamUrl();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed partial class MusicQueue : IMusicQueue
|
||||
{
|
||||
private LinkedList<QueuedTrackInfo> _tracks;
|
||||
|
||||
public int Index
|
||||
{
|
||||
get
|
||||
@@ -41,8 +49,6 @@ public sealed partial class MusicQueue : IMusicQueue
|
||||
}
|
||||
}
|
||||
|
||||
private int _index;
|
||||
|
||||
public int Count
|
||||
{
|
||||
get
|
||||
@@ -54,6 +60,10 @@ public sealed partial class MusicQueue : IMusicQueue
|
||||
}
|
||||
}
|
||||
|
||||
private LinkedList<QueuedTrackInfo> _tracks;
|
||||
|
||||
private int _index;
|
||||
|
||||
private readonly object locker = new();
|
||||
|
||||
public MusicQueue()
|
||||
@@ -77,17 +87,12 @@ public sealed partial class MusicQueue : IMusicQueue
|
||||
{
|
||||
lock (locker)
|
||||
{
|
||||
if (_tracks.Count == 0)
|
||||
{
|
||||
return Enqueue(trackInfo, queuer, out index);
|
||||
}
|
||||
if (_tracks.Count == 0) return Enqueue(trackInfo, queuer, out index);
|
||||
|
||||
var currentNode = _tracks.First!;
|
||||
int i;
|
||||
for (i = 1; i <= _index; i++)
|
||||
{
|
||||
currentNode = currentNode.Next!; // can't be null because index is always in range of the count
|
||||
}
|
||||
|
||||
var added = new QueuedTrackInfo(trackInfo, queuer);
|
||||
index = i;
|
||||
@@ -160,10 +165,7 @@ public sealed partial class MusicQueue : IMusicQueue
|
||||
{
|
||||
var removedNode = _tracks.First!;
|
||||
int i;
|
||||
for (i = 0; i < index; i++)
|
||||
{
|
||||
removedNode = removedNode.Next!;
|
||||
}
|
||||
for (i = 0; i < index; i++) removedNode = removedNode.Next!;
|
||||
|
||||
trackInfo = removedNode.Value;
|
||||
_tracks.Remove(removedNode);
|
||||
@@ -303,14 +305,11 @@ public sealed partial class MusicQueue : IMusicQueue
|
||||
if (index < 0 || index >= _tracks.Count)
|
||||
return false;
|
||||
|
||||
if (index == _index)
|
||||
{
|
||||
isCurrent = true;
|
||||
}
|
||||
if (index == _index) isCurrent = true;
|
||||
|
||||
RemoveAtInternal(index, out trackInfo);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,6 +1,6 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using StackExchange.Redis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace NadekoBot.Modules.Music;
|
||||
|
||||
@@ -14,13 +14,12 @@ public sealed class RedisTrackCacher : ITrackCacher
|
||||
public async Task<string?> GetOrCreateStreamLink(
|
||||
string id,
|
||||
MusicPlatform platform,
|
||||
Func<Task<(string StreamUrl, TimeSpan Expiry)>> streamUrlFactory
|
||||
)
|
||||
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)
|
||||
{
|
||||
@@ -28,17 +27,16 @@ public sealed class RedisTrackCacher : ITrackCacher
|
||||
var success = await CreateAndCacheStreamUrlAsync(trackStreamKey, streamUrlFactory);
|
||||
if (!success)
|
||||
return null;
|
||||
|
||||
|
||||
return await GetOrCreateStreamLink(id, platform, streamUrlFactory);
|
||||
}
|
||||
|
||||
// cache new one for future use
|
||||
_ = Task.Run(() => CreateAndCacheStreamUrlAsync(trackStreamKey, streamUrlFactory));
|
||||
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static string CreateStreamKey(string id, MusicPlatform platform)
|
||||
@@ -64,7 +62,11 @@ public sealed class RedisTrackCacher : ITrackCacher
|
||||
}
|
||||
}
|
||||
|
||||
public Task CacheStreamUrlAsync(string id, MusicPlatform platform, string url, TimeSpan expiry)
|
||||
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)
|
||||
@@ -73,10 +75,10 @@ public sealed class RedisTrackCacher : ITrackCacher
|
||||
// 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.StringSetAsync(dataKey, url, expiry);
|
||||
await db.ListRightPushAsync(trackStreamKey, dataKey);
|
||||
}
|
||||
|
||||
@@ -86,7 +88,7 @@ public sealed class RedisTrackCacher : ITrackCacher
|
||||
// from the list of cached trackurls until it finds a non-expired key
|
||||
|
||||
var db = _multiplexer.GetDatabase();
|
||||
while(true)
|
||||
while (true)
|
||||
{
|
||||
string? dataKey = await db.ListLeftPopAsync(trackStreamKey);
|
||||
if (dataKey == default)
|
||||
@@ -103,7 +105,7 @@ public sealed class RedisTrackCacher : ITrackCacher
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static string CreateCachedDataKey(string id, MusicPlatform platform)
|
||||
=> $"track:data:{platform}:{id}";
|
||||
|
||||
|
||||
public Task CacheTrackDataAsync(ICachableTrackData data)
|
||||
{
|
||||
var db = _multiplexer.GetDatabase();
|
||||
@@ -111,13 +113,13 @@ public sealed class RedisTrackCacher : ITrackCacher
|
||||
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));
|
||||
return db.StringSetAsync(trackDataKey, dataString, TimeSpan.FromDays(1));
|
||||
}
|
||||
|
||||
public async Task<ICachableTrackData?> GetCachedDataByIdAsync(string id, MusicPlatform platform)
|
||||
{
|
||||
var db = _multiplexer.GetDatabase();
|
||||
|
||||
|
||||
var trackDataKey = CreateCachedDataKey(id, platform);
|
||||
var data = await db.StringGetAsync(trackDataKey);
|
||||
if (data == default)
|
||||
@@ -129,10 +131,11 @@ public sealed class RedisTrackCacher : ITrackCacher
|
||||
[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);
|
||||
|
||||
@@ -149,17 +152,18 @@ public sealed class RedisTrackCacher : ITrackCacher
|
||||
|
||||
// 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();
|
||||
@@ -175,13 +179,14 @@ public sealed class RedisTrackCacher : ITrackCacher
|
||||
{
|
||||
var db = _multiplexer.GetDatabase();
|
||||
var key = CreateCachedPlaylistKey(playlistId, platform);
|
||||
await db.ListRightPushAsync(key, ids.Select(x => (RedisValue) x).ToArray());
|
||||
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());
|
||||
@@ -201,4 +206,4 @@ public sealed class RedisTrackCacher : ITrackCacher
|
||||
|
||||
return val;
|
||||
}
|
||||
}
|
||||
}
|
@@ -10,7 +10,12 @@ public sealed class RemoteTrackInfo : ITrackInfo
|
||||
|
||||
private readonly Func<Task<string?>> _streamFactory;
|
||||
|
||||
public RemoteTrackInfo(string title, string url, string thumbnail, TimeSpan duration, MusicPlatform platform,
|
||||
public RemoteTrackInfo(
|
||||
string title,
|
||||
string url,
|
||||
string thumbnail,
|
||||
TimeSpan duration,
|
||||
MusicPlatform platform,
|
||||
Func<Task<string?>> streamFactory)
|
||||
{
|
||||
_streamFactory = streamFactory;
|
||||
@@ -21,5 +26,6 @@ public sealed class RemoteTrackInfo : ITrackInfo
|
||||
Platform = platform;
|
||||
}
|
||||
|
||||
public async ValueTask<string?> GetStreamUrl() => await _streamFactory();
|
||||
}
|
||||
public async ValueTask<string?> GetStreamUrl()
|
||||
=> await _streamFactory();
|
||||
}
|
@@ -8,10 +8,14 @@ public sealed class SimpleTrackInfo : ITrackInfo
|
||||
public TimeSpan Duration { get; }
|
||||
public MusicPlatform Platform { get; }
|
||||
public string? StreamUrl { get; }
|
||||
public ValueTask<string?> GetStreamUrl() => new(StreamUrl);
|
||||
|
||||
public SimpleTrackInfo(string title, string url, string thumbnail, TimeSpan duration,
|
||||
MusicPlatform platform, string streamUrl)
|
||||
public SimpleTrackInfo(
|
||||
string title,
|
||||
string url,
|
||||
string thumbnail,
|
||||
TimeSpan duration,
|
||||
MusicPlatform platform,
|
||||
string streamUrl)
|
||||
{
|
||||
Title = title;
|
||||
Url = url;
|
||||
@@ -20,4 +24,7 @@ public sealed class SimpleTrackInfo : ITrackInfo
|
||||
Platform = platform;
|
||||
StreamUrl = streamUrl;
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask<string?> GetStreamUrl()
|
||||
=> new(StreamUrl);
|
||||
}
|
@@ -12,19 +12,19 @@ public sealed class VoiceProxy : IVoiceProxy
|
||||
Started,
|
||||
Stopped
|
||||
}
|
||||
|
||||
|
||||
private const int MAX_ERROR_COUNT = 20;
|
||||
private const int DELAY_ON_ERROR_MILISECONDS = 200;
|
||||
|
||||
public VoiceProxyState State
|
||||
=> _gateway switch
|
||||
{
|
||||
{Started: true, Stopped: false} => VoiceProxyState.Started,
|
||||
{Stopped: false} => VoiceProxyState.Created,
|
||||
{ Started: true, Stopped: false } => VoiceProxyState.Started,
|
||||
{ Stopped: false } => VoiceProxyState.Created,
|
||||
_ => VoiceProxyState.Stopped
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
private VoiceGateway _gateway;
|
||||
|
||||
public VoiceProxy(VoiceGateway initial)
|
||||
@@ -35,10 +35,7 @@ public sealed class VoiceProxy : IVoiceProxy
|
||||
try
|
||||
{
|
||||
var gw = _gateway;
|
||||
if (gw is null || gw.Stopped || !gw.Started)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (gw is null || gw.Stopped || !gw.Started) return false;
|
||||
|
||||
vc.SendPcmFrame(gw, data, 0, length);
|
||||
return true;
|
||||
@@ -54,10 +51,7 @@ public sealed class VoiceProxy : IVoiceProxy
|
||||
var errorCount = 0;
|
||||
do
|
||||
{
|
||||
if (State == VoiceProxyState.Stopped)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (State == VoiceProxyState.Stopped) break;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -98,9 +92,9 @@ public sealed class VoiceProxy : IVoiceProxy
|
||||
|
||||
public Task StopGateway()
|
||||
{
|
||||
if(_gateway is { } gw)
|
||||
if (_gateway is { } gw)
|
||||
return gw.StopAsync();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
@@ -8,25 +8,22 @@ public sealed class LocalTrackResolver : ILocalTrackResolver
|
||||
{
|
||||
private static readonly HashSet<string> _musicExtensions = new[]
|
||||
{
|
||||
".MP4", ".MP3", ".FLAC", ".OGG", ".WAV", ".WMA", ".WMV",
|
||||
".AAC", ".MKV", ".WEBM", ".M4A", ".AA", ".AAX",
|
||||
".MP4", ".MP3", ".FLAC", ".OGG", ".WAV", ".WMA", ".WMV", ".AAC", ".MKV", ".WEBM", ".M4A", ".AA", ".AAX",
|
||||
".ALAC", ".AIFF", ".MOV", ".FLV", ".OGG", ".M4V"
|
||||
}.ToHashSet();
|
||||
|
||||
|
||||
public async Task<ITrackInfo?> ResolveByQueryAsync(string query)
|
||||
{
|
||||
if (!File.Exists(query))
|
||||
return null;
|
||||
|
||||
var trackDuration = await Ffprobe.GetTrackDurationAsync(query);
|
||||
return new SimpleTrackInfo(
|
||||
Path.GetFileNameWithoutExtension(query),
|
||||
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)}\""
|
||||
);
|
||||
$"\"{Path.GetFullPath(query)}\"");
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<ITrackInfo> ResolveDirectoryAsync(string dirPath)
|
||||
@@ -43,17 +40,17 @@ public sealed class LocalTrackResolver : ILocalTrackResolver
|
||||
}
|
||||
|
||||
var files = dir.EnumerateFiles()
|
||||
.Where(x =>
|
||||
{
|
||||
if (!x.Attributes.HasFlag(FileAttributes.Hidden | FileAttributes.System)
|
||||
&& _musicExtensions.Contains(x.Extension.ToUpperInvariant())) return true;
|
||||
return false;
|
||||
});
|
||||
.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;
|
||||
@@ -61,9 +58,8 @@ public sealed class LocalTrackResolver : ILocalTrackResolver
|
||||
var fileChunks = files.Skip(1).Chunk(10);
|
||||
foreach (var chunk in fileChunks)
|
||||
{
|
||||
var part = await chunk.Select(x => ResolveByQueryAsync(x.FullName))
|
||||
.WhenAll();
|
||||
|
||||
var part = await chunk.Select(x => ResolveByQueryAsync(x.FullName)).WhenAll();
|
||||
|
||||
// nullable reference types being annoying
|
||||
foreach (var p in part)
|
||||
{
|
||||
@@ -84,7 +80,7 @@ public static class Ffprobe
|
||||
|
||||
try
|
||||
{
|
||||
using var p = Process.Start(new ProcessStartInfo()
|
||||
using var p = Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "ffprobe",
|
||||
Arguments =
|
||||
@@ -94,7 +90,7 @@ public static class Ffprobe
|
||||
RedirectStandardError = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
CreateNoWindow = true,
|
||||
CreateNoWindow = true
|
||||
});
|
||||
|
||||
if (p is null)
|
||||
@@ -118,7 +114,7 @@ public static class Ffprobe
|
||||
{
|
||||
Log.Error(ex, "Unknown exception running ffprobe; {ErrorMessage}", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
return TimeSpan.Zero;
|
||||
}
|
||||
}
|
||||
}
|
@@ -10,33 +10,23 @@ public class RadioResolver : IRadioResolver
|
||||
private readonly Regex asxRegex = new("<ref href=\"(?<url>.*?)\"", RegexOptions.Compiled);
|
||||
private readonly Regex xspfRegex = new("<location>(?<url>.*?)</location>", RegexOptions.Compiled);
|
||||
|
||||
public RadioResolver()
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<ITrackInfo> ResolveByQueryAsync(string query)
|
||||
{
|
||||
if (IsRadioLink(query))
|
||||
query = await HandleStreamContainers(query);
|
||||
|
||||
return new SimpleTrackInfo(
|
||||
query.TrimTo(50),
|
||||
return new SimpleTrackInfo(query.TrimTo(50),
|
||||
query,
|
||||
"https://cdn.discordapp.com/attachments/155726317222887425/261850925063340032/1482522097_radio.png",
|
||||
TimeSpan.MaxValue,
|
||||
MusicPlatform.Radio,
|
||||
query
|
||||
);
|
||||
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"));
|
||||
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)
|
||||
{
|
||||
@@ -50,8 +40,8 @@ public class RadioResolver : IRadioResolver
|
||||
{
|
||||
return query;
|
||||
}
|
||||
|
||||
if (query.Contains(".pls"))
|
||||
{
|
||||
//File1=http://armitunes.com:8000/
|
||||
//Regex.Match(query)
|
||||
try
|
||||
@@ -65,14 +55,13 @@ public class RadioResolver : IRadioResolver
|
||||
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
|
||||
*/
|
||||
# This is a comment
|
||||
C:\xxx4xx\xxxxxx3x\xx2xxxx\xx.mp3
|
||||
C:\xxx5xx\x6xxxxxx\x7xxxxx\xx.mp3
|
||||
*/
|
||||
try
|
||||
{
|
||||
var m = m3uRegex.Match(file);
|
||||
@@ -85,9 +74,7 @@ public class RadioResolver : IRadioResolver
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
if (query.Contains(".asx"))
|
||||
{
|
||||
//<ref href="http://armitunes.com:8000"/>
|
||||
try
|
||||
{
|
||||
@@ -100,15 +87,14 @@ public class RadioResolver : IRadioResolver
|
||||
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>
|
||||
*/
|
||||
<?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);
|
||||
@@ -120,8 +106,7 @@ public class RadioResolver : IRadioResolver
|
||||
Log.Warning($"Failed reading .xspf:\n{file}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,5 +1,6 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace NadekoBot.Modules.Music.Resolvers;
|
||||
|
||||
@@ -16,37 +17,29 @@ public sealed class SoundcloudResolver : ISoundcloudResolver
|
||||
_httpFactory = httpFactory;
|
||||
}
|
||||
|
||||
public bool IsSoundCloudLink(string url) =>
|
||||
System.Text.RegularExpressions.Regex.IsMatch(url, "(.*)(soundcloud.com|snd.sc)(.*)");
|
||||
public bool IsSoundCloudLink(string url)
|
||||
=> Regex.IsMatch(url, "(.*)(soundcloud.com|snd.sc)(.*)");
|
||||
|
||||
public async IAsyncEnumerable<ITrackInfo> ResolvePlaylistAsync(string playlist)
|
||||
{
|
||||
playlist = Uri.EscapeDataString(playlist);
|
||||
|
||||
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var responseString = await http.GetStringAsync($"https://scapi.nadeko.bot/resolve?url={playlist}");
|
||||
var scvids = JObject.Parse(responseString)["tracks"]?.ToObject<SoundCloudVideo[]>();
|
||||
if (scvids is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
if (scvids is null) yield break;
|
||||
|
||||
foreach (var videosChunk in scvids.Where(x => x.Streamable is true).Chunk(5))
|
||||
{
|
||||
var cachableTracks = videosChunk
|
||||
.Select(VideoModelToCachedData)
|
||||
.ToList();
|
||||
var cachableTracks = videosChunk.Select(VideoModelToCachedData).ToList();
|
||||
|
||||
await cachableTracks.Select(_trackCacher.CacheTrackDataAsync).WhenAll();
|
||||
foreach(var info in cachableTracks.Select(CachableDataToTrackInfo))
|
||||
{
|
||||
yield return info;
|
||||
}
|
||||
foreach (var info in cachableTracks.Select(CachableDataToTrackInfo)) yield return info;
|
||||
}
|
||||
}
|
||||
|
||||
private ICachableTrackData VideoModelToCachedData(SoundCloudVideo svideo)
|
||||
=> new CachableTrackData()
|
||||
=> new CachableTrackData
|
||||
{
|
||||
Title = svideo.FullName,
|
||||
Url = svideo.TrackLink,
|
||||
@@ -55,16 +48,14 @@ public sealed class SoundcloudResolver : ISoundcloudResolver
|
||||
Id = svideo.Id.ToString(),
|
||||
Platform = MusicPlatform.SoundCloud
|
||||
};
|
||||
|
||||
|
||||
private ITrackInfo CachableDataToTrackInfo(ICachableTrackData trackData)
|
||||
=> new SimpleTrackInfo(
|
||||
trackData.Title,
|
||||
=> new SimpleTrackInfo(trackData.Title,
|
||||
trackData.Url,
|
||||
trackData.Thumbnail,
|
||||
trackData.Duration,
|
||||
trackData.Platform,
|
||||
GetStreamUrl(trackData.Id)
|
||||
);
|
||||
GetStreamUrl(trackData.Id));
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private string GetStreamUrl(string trackId)
|
||||
@@ -75,7 +66,7 @@ public sealed class SoundcloudResolver : ISoundcloudResolver
|
||||
var cached = await _trackCacher.GetCachedDataByQueryAsync(query, MusicPlatform.SoundCloud);
|
||||
if (cached is not null)
|
||||
return CachableDataToTrackInfo(cached);
|
||||
|
||||
|
||||
var svideo = !IsSoundCloudLink(query)
|
||||
? await _sc.GetVideoByQueryAsync(query)
|
||||
: await _sc.ResolveVideoAsync(query);
|
||||
@@ -85,7 +76,7 @@ public sealed class SoundcloudResolver : ISoundcloudResolver
|
||||
|
||||
var cachableData = VideoModelToCachedData(svideo);
|
||||
await _trackCacher.CacheTrackDataByQueryAsync(query, cachableData);
|
||||
|
||||
|
||||
return CachableDataToTrackInfo(cachableData);
|
||||
}
|
||||
}
|
||||
}
|
@@ -7,8 +7,11 @@ public sealed class TrackResolveProvider : ITrackResolveProvider
|
||||
private readonly ISoundcloudResolver _soundcloudResolver;
|
||||
private readonly IRadioResolver _radioResolver;
|
||||
|
||||
public TrackResolveProvider(IYoutubeResolver ytResolver, ILocalTrackResolver localResolver,
|
||||
ISoundcloudResolver soundcloudResolver, IRadioResolver radioResolver)
|
||||
public TrackResolveProvider(
|
||||
IYoutubeResolver ytResolver,
|
||||
ILocalTrackResolver localResolver,
|
||||
ISoundcloudResolver soundcloudResolver,
|
||||
IRadioResolver radioResolver)
|
||||
{
|
||||
_ytResolver = ytResolver;
|
||||
_localResolver = localResolver;
|
||||
@@ -45,13 +48,9 @@ public sealed class TrackResolveProvider : ITrackResolveProvider
|
||||
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"));
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
@@ -5,17 +5,22 @@ namespace NadekoBot.Modules.Music;
|
||||
|
||||
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"};
|
||||
private static readonly string[] durationFormats =
|
||||
{
|
||||
"ss", "m\\:ss", "mm\\:ss", "h\\:mm\\:ss", "hh\\:mm\\:ss", "hhh\\:mm\\:ss"
|
||||
};
|
||||
|
||||
public Regex YtVideoIdRegex { get; }
|
||||
= new(
|
||||
@"(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\?(?:\S*?&?v\=))|youtu\.be\/)(?<id>[a-zA-Z0-9_-]{6,11})",
|
||||
RegexOptions.Compiled
|
||||
);
|
||||
private static readonly Regex expiryRegex = new(@"(?:[\?\&]expire\=(?<timestamp>\d+))");
|
||||
|
||||
|
||||
private static readonly Regex _simplePlaylistRegex = new(@"&list=(?<id>[\w\-]{12,})", RegexOptions.Compiled);
|
||||
|
||||
public Regex YtVideoIdRegex { get; } =
|
||||
new(@"(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\?(?:\S*?&?v\=))|youtu\.be\/)(?<id>[a-zA-Z0-9_-]{6,11})",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private readonly ITrackCacher _trackCacher;
|
||||
|
||||
|
||||
private readonly YtdlOperation _ytdlPlaylistOperation;
|
||||
private readonly YtdlOperation _ytdlIdOperation;
|
||||
private readonly YtdlOperation _ytdlSearchOperation;
|
||||
@@ -27,72 +32,51 @@ public sealed class YtdlYoutubeResolver : IYoutubeResolver
|
||||
_trackCacher = trackCacher;
|
||||
_google = google;
|
||||
|
||||
_ytdlPlaylistOperation =
|
||||
new("-4 " +
|
||||
"--geo-bypass " +
|
||||
"--encoding UTF8 " +
|
||||
"-f bestaudio " +
|
||||
"-e " +
|
||||
"--get-url " +
|
||||
"--get-id " +
|
||||
"--get-thumbnail " +
|
||||
"--get-duration " +
|
||||
"--no-check-certificate " +
|
||||
"-i " +
|
||||
"--yes-playlist " +
|
||||
"-- \"{0}\"");
|
||||
_ytdlPlaylistOperation = new("-4 "
|
||||
+ "--geo-bypass "
|
||||
+ "--encoding UTF8 "
|
||||
+ "-f bestaudio "
|
||||
+ "-e "
|
||||
+ "--get-url "
|
||||
+ "--get-id "
|
||||
+ "--get-thumbnail "
|
||||
+ "--get-duration "
|
||||
+ "--no-check-certificate "
|
||||
+ "-i "
|
||||
+ "--yes-playlist "
|
||||
+ "-- \"{0}\"");
|
||||
|
||||
_ytdlIdOperation =
|
||||
new("-4 " +
|
||||
"--geo-bypass " +
|
||||
"--encoding UTF8 " +
|
||||
"-f bestaudio " +
|
||||
"-e " +
|
||||
"--get-url " +
|
||||
"--get-id " +
|
||||
"--get-thumbnail " +
|
||||
"--get-duration " +
|
||||
"--no-check-certificate " +
|
||||
"-- \"{0}\"");
|
||||
|
||||
_ytdlSearchOperation =
|
||||
new("-4 " +
|
||||
"--geo-bypass " +
|
||||
"--encoding UTF8 " +
|
||||
"-f bestaudio " +
|
||||
"-e " +
|
||||
"--get-url " +
|
||||
"--get-id " +
|
||||
"--get-thumbnail " +
|
||||
"--get-duration " +
|
||||
"--no-check-certificate " +
|
||||
"--default-search " +
|
||||
"\"ytsearch:\" -- \"{0}\"");
|
||||
_ytdlIdOperation = new("-4 "
|
||||
+ "--geo-bypass "
|
||||
+ "--encoding UTF8 "
|
||||
+ "-f bestaudio "
|
||||
+ "-e "
|
||||
+ "--get-url "
|
||||
+ "--get-id "
|
||||
+ "--get-thumbnail "
|
||||
+ "--get-duration "
|
||||
+ "--no-check-certificate "
|
||||
+ "-- \"{0}\"");
|
||||
|
||||
_ytdlSearchOperation = new("-4 "
|
||||
+ "--geo-bypass "
|
||||
+ "--encoding UTF8 "
|
||||
+ "-f bestaudio "
|
||||
+ "-e "
|
||||
+ "--get-url "
|
||||
+ "--get-id "
|
||||
+ "--get-thumbnail "
|
||||
+ "--get-duration "
|
||||
+ "--no-check-certificate "
|
||||
+ "--default-search "
|
||||
+ "\"ytsearch:\" -- \"{0}\"");
|
||||
}
|
||||
|
||||
private readonly struct YtTrackData
|
||||
{
|
||||
public readonly string Title;
|
||||
public readonly string Id;
|
||||
public readonly string Thumbnail;
|
||||
public readonly string? StreamUrl;
|
||||
public readonly TimeSpan Duration;
|
||||
|
||||
public YtTrackData(string title, string id, string thumbnail, string? streamUrl, TimeSpan duration)
|
||||
{
|
||||
Title = title.Trim();
|
||||
Id = id.Trim();
|
||||
Thumbnail = thumbnail;
|
||||
StreamUrl = streamUrl;
|
||||
Duration = duration;
|
||||
}
|
||||
}
|
||||
|
||||
private YtTrackData ResolveYtdlData(string ytdlOutputString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ytdlOutputString))
|
||||
return default;
|
||||
|
||||
|
||||
var dataArray = ytdlOutputString.Trim().Split('\n');
|
||||
|
||||
if (dataArray.Length < 5)
|
||||
@@ -102,26 +86,15 @@ public sealed class YtdlYoutubeResolver : IYoutubeResolver
|
||||
}
|
||||
|
||||
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;
|
||||
var thumbnail = Uri.IsWellFormedUriString(dataArray[3], UriKind.Absolute) ? dataArray[3].Trim() : string.Empty;
|
||||
|
||||
return new(
|
||||
dataArray[0],
|
||||
dataArray[1],
|
||||
thumbnail,
|
||||
dataArray[2],
|
||||
time
|
||||
);
|
||||
return new(dataArray[0], dataArray[1], thumbnail, dataArray[2], time);
|
||||
}
|
||||
|
||||
private ITrackInfo DataToInfo(in YtTrackData trackData)
|
||||
=> new RemoteTrackInfo(
|
||||
trackData.Title,
|
||||
=> new RemoteTrackInfo(trackData.Title,
|
||||
$"https://youtube.com/watch?v={trackData.Id}",
|
||||
trackData.Thumbnail,
|
||||
trackData.Duration,
|
||||
@@ -129,13 +102,10 @@ public sealed class YtdlYoutubeResolver : IYoutubeResolver
|
||||
CreateCacherFactory(trackData.Id));
|
||||
|
||||
private Func<Task<string?>> CreateCacherFactory(string id)
|
||||
=> () => _trackCacher.GetOrCreateStreamLink(
|
||||
id,
|
||||
=> () => _trackCacher.GetOrCreateStreamLink(id,
|
||||
MusicPlatform.Youtube,
|
||||
async () => await ExtractNewStreamUrlAsync(id)
|
||||
);
|
||||
async () => await ExtractNewStreamUrlAsync(id));
|
||||
|
||||
private static readonly Regex expiryRegex = new(@"(?:[\?\&]expire\=(?<timestamp>\d+))");
|
||||
private static TimeSpan GetExpiry(string streamUrl)
|
||||
{
|
||||
var match = expiryRegex.Match(streamUrl);
|
||||
@@ -157,54 +127,41 @@ public sealed class YtdlYoutubeResolver : IYoutubeResolver
|
||||
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)
|
||||
);
|
||||
|
||||
await Task.WhenAll(_trackCacher.CacheTrackDataAsync(toReturn.ToCachedData(id)),
|
||||
CacheStreamUrlAsync(trackInfo));
|
||||
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
return DataToInfo(new(
|
||||
cachedData.Title,
|
||||
cachedData.Id,
|
||||
cachedData.Thumbnail,
|
||||
null,
|
||||
cachedData.Duration
|
||||
));
|
||||
return DataToInfo(new(cachedData.Title, cachedData.Id, cachedData.Thumbnail, null, cachedData.Duration));
|
||||
}
|
||||
|
||||
private Task CacheStreamUrlAsync(YtTrackData trackInfo)
|
||||
=> _trackCacher.CacheStreamUrlAsync(
|
||||
trackInfo.Id,
|
||||
=> _trackCacher.CacheStreamUrlAsync(trackInfo.Id,
|
||||
MusicPlatform.Youtube,
|
||||
trackInfo.StreamUrl!,
|
||||
GetExpiry(trackInfo.StreamUrl!)
|
||||
);
|
||||
|
||||
|
||||
private static readonly Regex _simplePlaylistRegex = new(@"&list=(?<id>[\w\-]{12,})", RegexOptions.Compiled);
|
||||
GetExpiry(trackInfo.StreamUrl!));
|
||||
|
||||
public async IAsyncEnumerable<ITrackInfo> ResolveTracksByPlaylistIdAsync(string playlistId)
|
||||
{
|
||||
@@ -241,11 +198,9 @@ public sealed class YtdlYoutubeResolver : IYoutubeResolver
|
||||
continue;
|
||||
|
||||
var info = DataToInfo(in trackData);
|
||||
await Task.WhenAll(
|
||||
_trackCacher.CacheTrackDataAsync(info.ToCachedData(trackData.Id)),
|
||||
CacheStreamUrlAsync(trackData)
|
||||
);
|
||||
|
||||
await Task.WhenAll(_trackCacher.CacheTrackDataAsync(info.ToCachedData(trackData.Id)),
|
||||
CacheStreamUrlAsync(trackData));
|
||||
|
||||
trackIds.Add(trackData.Id);
|
||||
yield return info;
|
||||
}
|
||||
@@ -306,7 +261,7 @@ public sealed class YtdlYoutubeResolver : IYoutubeResolver
|
||||
|
||||
public Task<ITrackInfo?> ResolveByQueryAsync(string query)
|
||||
=> ResolveByQueryAsync(query, true);
|
||||
|
||||
|
||||
public async Task<ITrackInfo?> ResolveByQueryAsync(string query, bool tryResolving)
|
||||
{
|
||||
if (tryResolving)
|
||||
@@ -315,29 +270,44 @@ public sealed class YtdlYoutubeResolver : IYoutubeResolver
|
||||
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)
|
||||
);
|
||||
await Task.WhenAll(_trackCacher.CacheTrackDataByQueryAsync(query, trackInfo.ToCachedData(trackData.Id)),
|
||||
CacheStreamUrlAsync(trackData));
|
||||
return trackInfo;
|
||||
}
|
||||
|
||||
return DataToInfo(new(
|
||||
cachedData.Title,
|
||||
cachedData.Id,
|
||||
cachedData.Thumbnail,
|
||||
null,
|
||||
cachedData.Duration
|
||||
));
|
||||
|
||||
return DataToInfo(new(cachedData.Title, cachedData.Id, cachedData.Thumbnail, null, cachedData.Duration));
|
||||
}
|
||||
}
|
||||
|
||||
private readonly struct YtTrackData
|
||||
{
|
||||
public readonly string Title;
|
||||
public readonly string Id;
|
||||
public readonly string Thumbnail;
|
||||
public readonly string? StreamUrl;
|
||||
public readonly TimeSpan Duration;
|
||||
|
||||
public YtTrackData(
|
||||
string title,
|
||||
string id,
|
||||
string thumbnail,
|
||||
string? streamUrl,
|
||||
TimeSpan duration)
|
||||
{
|
||||
Title = title.Trim();
|
||||
Id = id.Trim();
|
||||
Thumbnail = thumbnail;
|
||||
StreamUrl = streamUrl;
|
||||
Duration = duration;
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,14 +1,27 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Services.Database.Models;
|
||||
using NadekoBot.Modules.Administration.Services;
|
||||
using NadekoBot.Modules.Music.Services;
|
||||
using NadekoBot.Services.Database.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Music;
|
||||
|
||||
[NoPublicBot]
|
||||
public sealed partial class Music : NadekoModule<IMusicService>
|
||||
{
|
||||
public enum All { All = -1 }
|
||||
|
||||
public enum InputRepeatType
|
||||
{
|
||||
N = 0, No = 0, None = 0,
|
||||
T = 1, Track = 1, S = 1, Song = 1,
|
||||
Q = 2, Queue = 2, Playlist = 2, Pl = 2
|
||||
}
|
||||
|
||||
public const string MusicIconUrl = "http://i.imgur.com/nhKS3PT.png";
|
||||
|
||||
private const int LQ_ITEMS_PER_PAGE = 9;
|
||||
|
||||
private static readonly SemaphoreSlim voiceChannelLock = new(1, 1);
|
||||
private readonly ILogCommandService _logService;
|
||||
|
||||
public Music(ILogCommandService _logService)
|
||||
@@ -16,9 +29,9 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
|
||||
private async Task<bool> ValidateAsync()
|
||||
{
|
||||
var user = (IGuildUser) ctx.User;
|
||||
var user = (IGuildUser)ctx.User;
|
||||
var userVoiceChannelId = user.VoiceChannel?.Id;
|
||||
|
||||
|
||||
if (userVoiceChannelId is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.must_be_in_voice);
|
||||
@@ -35,7 +48,6 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
return true;
|
||||
}
|
||||
|
||||
private static readonly SemaphoreSlim voiceChannelLock = new(1, 1);
|
||||
private async Task EnsureBotInVoiceChannelAsync(ulong voiceChannelId, IGuildUser botUser = null)
|
||||
{
|
||||
botUser ??= await ctx.Guild.GetCurrentUserAsync();
|
||||
@@ -50,12 +62,12 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
voiceChannelLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task<bool> QueuePreconditionInternalAsync()
|
||||
{
|
||||
var user = (IGuildUser) ctx.User;
|
||||
var user = (IGuildUser)ctx.User;
|
||||
var voiceChannelId = user.VoiceChannel?.Id;
|
||||
|
||||
|
||||
if (voiceChannelId is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.must_be_in_voice);
|
||||
@@ -63,10 +75,10 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
}
|
||||
|
||||
_ = 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);
|
||||
@@ -81,18 +93,15 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
var succ = await QueuePreconditionInternalAsync();
|
||||
if (!succ)
|
||||
return;
|
||||
|
||||
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel) ctx.Channel);
|
||||
|
||||
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
|
||||
if (mp is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.no_player);
|
||||
return;
|
||||
}
|
||||
|
||||
var (trackInfo, index) = await mp.TryEnqueueTrackAsync(query,
|
||||
ctx.User.ToString(),
|
||||
asNext,
|
||||
forcePlatform);
|
||||
|
||||
var (trackInfo, index) = await mp.TryEnqueueTrackAsync(query, ctx.User.ToString(), asNext, forcePlatform);
|
||||
if (trackInfo is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.song_not_found);
|
||||
@@ -102,10 +111,10 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
try
|
||||
{
|
||||
var embed = _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithAuthor(GetText(strs.queued_song) + " #" + (index + 1), MusicIconUrl)
|
||||
.WithDescription($"{trackInfo.PrettyName()}\n{GetText(strs.queue)} ")
|
||||
.WithFooter(trackInfo.Platform.ToString());
|
||||
.WithOkColor()
|
||||
.WithAuthor(GetText(strs.queued_song) + " #" + (index + 1), MusicIconUrl)
|
||||
.WithDescription($"{trackInfo.PrettyName()}\n{GetText(strs.queue)} ")
|
||||
.WithFooter(trackInfo.Platform.ToString());
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(trackInfo.Thumbnail))
|
||||
embed.WithThumbnailUrl(trackInfo.Thumbnail);
|
||||
@@ -128,12 +137,12 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
{
|
||||
if (--index < 0)
|
||||
return;
|
||||
|
||||
|
||||
var succ = await QueuePreconditionInternalAsync();
|
||||
if (!succ)
|
||||
return;
|
||||
|
||||
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel) ctx.Channel);
|
||||
|
||||
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
|
||||
if (mp is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.no_player);
|
||||
@@ -142,13 +151,14 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
|
||||
mp.MoveTo(index);
|
||||
}
|
||||
|
||||
|
||||
// join vc
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Join()
|
||||
{
|
||||
var user = (IGuildUser) ctx.User;
|
||||
var user = (IGuildUser)ctx.User;
|
||||
|
||||
var voiceChannelId = user.VoiceChannel?.Id;
|
||||
|
||||
@@ -162,7 +172,8 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
}
|
||||
|
||||
// leave vc (destroy)
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Destroy()
|
||||
{
|
||||
@@ -172,39 +183,45 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
|
||||
await _service.LeaveVoiceChannelAsync(ctx.Guild.Id);
|
||||
}
|
||||
|
||||
|
||||
// play - no args = next
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[Priority(2)]
|
||||
public Task Play()
|
||||
=> Next();
|
||||
|
||||
|
||||
// play - index = skip to that index
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[Priority(1)]
|
||||
public Task Play(int index)
|
||||
=> MoveToIndex(index);
|
||||
|
||||
|
||||
// play - query = q(query)
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[Priority(0)]
|
||||
public Task Play([Leftover] string query)
|
||||
=> QueueByQuery(query);
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
=> QueueByQuery(query);
|
||||
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public Task Queue([Leftover] string query)
|
||||
=> QueueByQuery(query);
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public Task QueueNext([Leftover] string query)
|
||||
=> QueueByQuery(query, asNext: true);
|
||||
=> QueueByQuery(query, true);
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Volume(int vol)
|
||||
{
|
||||
@@ -213,7 +230,7 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
await ReplyErrorLocalizedAsync(strs.volume_input_invalid);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
var valid = await ValidateAsync();
|
||||
if (!valid)
|
||||
return;
|
||||
@@ -222,7 +239,8 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
await ReplyConfirmLocalizedAsync(strs.volume_set(vol));
|
||||
}
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Next()
|
||||
{
|
||||
@@ -231,17 +249,12 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
return;
|
||||
|
||||
var success = await _service.PlayAsync(ctx.Guild.Id, ((IGuildUser)ctx.User).VoiceChannel.Id);
|
||||
if (!success)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.no_player);
|
||||
return;
|
||||
}
|
||||
if (!success) await ReplyErrorLocalizedAsync(strs.no_player);
|
||||
}
|
||||
|
||||
private const int LQ_ITEMS_PER_PAGE = 9;
|
||||
|
||||
// list queue, relevant page
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task ListQueue()
|
||||
{
|
||||
@@ -251,12 +264,13 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
await ReplyErrorLocalizedAsync(strs.no_player);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
await ListQueue((mp.CurrentIndex / LQ_ITEMS_PER_PAGE) + 1);
|
||||
}
|
||||
|
||||
|
||||
// list queue, specify page
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task ListQueue(int page)
|
||||
{
|
||||
@@ -269,15 +283,12 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
await ReplyErrorLocalizedAsync(strs.no_player);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
IEmbedBuilder printAction(int curPage)
|
||||
{
|
||||
var desc = string.Empty;
|
||||
var current = mp.GetCurrentTrack(out var currentIndex);
|
||||
if (current is not null)
|
||||
{
|
||||
desc = $"`🔊` {current.PrettyFullName()}\n\n" + desc;
|
||||
}
|
||||
if (current is not null) desc = $"`🔊` {current.PrettyFullName()}\n\n" + desc;
|
||||
|
||||
var repeatType = mp.Repeat;
|
||||
var add = string.Empty;
|
||||
@@ -301,42 +312,37 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
}
|
||||
|
||||
|
||||
desc += tracks
|
||||
.Skip(LQ_ITEMS_PER_PAGE * curPage)
|
||||
.Take(LQ_ITEMS_PER_PAGE)
|
||||
.Select((v, index) =>
|
||||
{
|
||||
index += LQ_ITEMS_PER_PAGE * curPage;
|
||||
if (index == currentIndex)
|
||||
return $"**⇒**`{index + 1}.` {v.PrettyFullName()}";
|
||||
|
||||
return $"`{index + 1}.` {v.PrettyFullName()}";
|
||||
})
|
||||
.Join('\n');
|
||||
|
||||
desc += tracks.Skip(LQ_ITEMS_PER_PAGE * curPage)
|
||||
.Take(LQ_ITEMS_PER_PAGE)
|
||||
.Select((v, index) =>
|
||||
{
|
||||
index += LQ_ITEMS_PER_PAGE * curPage;
|
||||
if (index == currentIndex)
|
||||
return $"**⇒**`{index + 1}.` {v.PrettyFullName()}";
|
||||
|
||||
return $"`{index + 1}.` {v.PrettyFullName()}";
|
||||
})
|
||||
.Join('\n');
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(add))
|
||||
desc = add + "\n" + desc;
|
||||
|
||||
var embed = _eb.Create()
|
||||
.WithAuthor(GetText(strs.player_queue(curPage + 1, (tracks.Count / LQ_ITEMS_PER_PAGE) + 1)),
|
||||
MusicIconUrl)
|
||||
.WithDescription(desc)
|
||||
.WithFooter($" {mp.PrettyVolume()} | 🎶 {tracks.Count} | ⌛ {mp.PrettyTotalTime()} ")
|
||||
.WithOkColor();
|
||||
.WithAuthor(GetText(strs.player_queue(curPage + 1, (tracks.Count / LQ_ITEMS_PER_PAGE) + 1)),
|
||||
MusicIconUrl)
|
||||
.WithDescription(desc)
|
||||
.WithFooter($" {mp.PrettyVolume()} | 🎶 {tracks.Count} | ⌛ {mp.PrettyTotalTime()} ")
|
||||
.WithOkColor();
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
await ctx.SendPaginatedConfirmAsync(
|
||||
page,
|
||||
printAction,
|
||||
tracks.Count,
|
||||
LQ_ITEMS_PER_PAGE,
|
||||
false);
|
||||
await ctx.SendPaginatedConfirmAsync(page, printAction, tracks.Count, LQ_ITEMS_PER_PAGE, false);
|
||||
}
|
||||
|
||||
// search
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task QueueSearch([Leftover] string query)
|
||||
{
|
||||
@@ -350,19 +356,14 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
return;
|
||||
}
|
||||
|
||||
var resultsString = videos
|
||||
.Select((x, i) => $"`{i + 1}.`\n\t{Format.Bold(x.Title)}\n\t{x.Url}")
|
||||
.Join('\n');
|
||||
|
||||
var resultsString = videos.Select((x, i) => $"`{i + 1}.`\n\t{Format.Bold(x.Title)}\n\t{x.Url}").Join('\n');
|
||||
|
||||
var msg = await SendConfirmAsync(resultsString);
|
||||
|
||||
try
|
||||
{
|
||||
var input = await GetUserInputAsync(ctx.User.Id, ctx.Channel.Id);
|
||||
if (input is null
|
||||
|| !int.TryParse(input, out var index)
|
||||
|| (index -= 1) < 0
|
||||
|| index >= videos.Count)
|
||||
if (input is null || !int.TryParse(input, out var index) || (index -= 1) < 0 || index >= videos.Count)
|
||||
{
|
||||
_logService.AddDeleteIgnore(msg.Id);
|
||||
try
|
||||
@@ -375,6 +376,7 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
query = videos[index].Url;
|
||||
|
||||
await Play(query);
|
||||
@@ -392,7 +394,8 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
}
|
||||
}
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[Priority(1)]
|
||||
public async Task TrackRemove(int index)
|
||||
@@ -402,7 +405,7 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
await ReplyErrorLocalizedAsync(strs.removed_song_error);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
var valid = await ValidateAsync();
|
||||
if (!valid)
|
||||
return;
|
||||
@@ -412,24 +415,24 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
await ReplyErrorLocalizedAsync(strs.no_player);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!mp.TryRemoveTrackAt(index - 1, out var song))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.removed_song_error);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
var embed = _eb.Create()
|
||||
.WithAuthor(GetText(strs.removed_song) + " #" + index, MusicIconUrl)
|
||||
.WithDescription(song.PrettyName())
|
||||
.WithFooter(song.PrettyInfo())
|
||||
.WithErrorColor();
|
||||
.WithAuthor(GetText(strs.removed_song) + " #" + index, MusicIconUrl)
|
||||
.WithDescription(song.PrettyName())
|
||||
.WithFooter(song.PrettyInfo())
|
||||
.WithErrorColor();
|
||||
|
||||
await _service.SendToOutputAsync(ctx.Guild.Id, embed);
|
||||
}
|
||||
|
||||
public enum All { All = -1 }
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[Priority(0)]
|
||||
public async Task TrackRemove(All _ = All.All)
|
||||
@@ -443,12 +446,13 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
await ReplyErrorLocalizedAsync(strs.no_player);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
mp.Clear();
|
||||
await ReplyConfirmLocalizedAsync(strs.queue_cleared);
|
||||
}
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Stop()
|
||||
{
|
||||
@@ -461,33 +465,28 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
await ReplyErrorLocalizedAsync(strs.no_player);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
mp.Stop();
|
||||
}
|
||||
|
||||
public enum InputRepeatType
|
||||
{
|
||||
N = 0, No = 0, None = 0,
|
||||
T = 1, Track = 1, S = 1, Song = 1,
|
||||
Q = 2, Queue = 2, Playlist = 2, Pl = 2,
|
||||
}
|
||||
private PlayerRepeatType InputToDbType(InputRepeatType type)
|
||||
=> type switch
|
||||
{
|
||||
InputRepeatType.None => PlayerRepeatType.None,
|
||||
InputRepeatType.Queue => PlayerRepeatType.Queue,
|
||||
InputRepeatType.Track => PlayerRepeatType.Track,
|
||||
_ => PlayerRepeatType.Queue
|
||||
};
|
||||
|
||||
private PlayerRepeatType InputToDbType(InputRepeatType type) => type switch
|
||||
{
|
||||
InputRepeatType.None => PlayerRepeatType.None,
|
||||
InputRepeatType.Queue => PlayerRepeatType.Queue,
|
||||
InputRepeatType.Track => PlayerRepeatType.Track,
|
||||
_ => PlayerRepeatType.Queue
|
||||
};
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task QueueRepeat(InputRepeatType type = InputRepeatType.Queue)
|
||||
{
|
||||
var valid = await ValidateAsync();
|
||||
if (!valid)
|
||||
return;
|
||||
|
||||
|
||||
await _service.SetRepeatAsync(ctx.Guild.Id, InputToDbType(type));
|
||||
|
||||
if (type == InputRepeatType.None)
|
||||
@@ -497,8 +496,9 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.repeating_track);
|
||||
}
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Pause()
|
||||
{
|
||||
@@ -514,19 +514,22 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
|
||||
mp.TogglePause();
|
||||
}
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public Task Radio(string radioLink)
|
||||
=> QueueByQuery(radioLink, false, MusicPlatform.Radio);
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[OwnerOnly]
|
||||
public Task Local([Leftover] string path)
|
||||
=> QueueByQuery(path, false, MusicPlatform.Local);
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[OwnerOnly]
|
||||
public async Task LocalPlaylist([Leftover] string dirPath)
|
||||
@@ -534,9 +537,9 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
if (string.IsNullOrWhiteSpace(dirPath))
|
||||
return;
|
||||
|
||||
var user = (IGuildUser) ctx.User;
|
||||
var user = (IGuildUser)ctx.User;
|
||||
var voiceChannelId = user.VoiceChannel?.Id;
|
||||
|
||||
|
||||
if (voiceChannelId is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.must_be_in_voice);
|
||||
@@ -544,29 +547,30 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
}
|
||||
|
||||
_ = 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);
|
||||
|
||||
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
|
||||
if (mp is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.no_player);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
await _service.EnqueueDirectoryAsync(mp, dirPath, ctx.User.ToString());
|
||||
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.dir_queue_complete);
|
||||
}
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task MoveSong(int from, int to)
|
||||
{
|
||||
@@ -579,8 +583,8 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
var valid = await ValidateAsync();
|
||||
if (!valid)
|
||||
return;
|
||||
|
||||
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel) ctx.Channel);
|
||||
|
||||
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
|
||||
if (mp is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.no_player);
|
||||
@@ -593,13 +597,13 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
await ReplyErrorLocalizedAsync(strs.invalid_input);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
var embed = _eb.Create()
|
||||
.WithTitle(track.Title.TrimTo(65))
|
||||
.WithAuthor(GetText(strs.song_moved), MusicIconUrl)
|
||||
.AddField(GetText(strs.from_position), $"#{from + 1}", true)
|
||||
.AddField(GetText(strs.to_position), $"#{to + 1}", true)
|
||||
.WithOkColor();
|
||||
.WithTitle(track.Title.TrimTo(65))
|
||||
.WithAuthor(GetText(strs.song_moved), MusicIconUrl)
|
||||
.AddField(GetText(strs.from_position), $"#{from + 1}", true)
|
||||
.AddField(GetText(strs.to_position), $"#{to + 1}", true)
|
||||
.WithOkColor();
|
||||
|
||||
if (Uri.IsWellFormedUriString(track.Url, UriKind.Absolute))
|
||||
embed.WithUrl(track.Url);
|
||||
@@ -607,12 +611,14 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public Task SoundCloudQueue([Leftover] string query)
|
||||
=> QueueByQuery(query, false, MusicPlatform.SoundCloud);
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task SoundCloudPl([Leftover] string playlist)
|
||||
{
|
||||
@@ -623,13 +629,13 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
if (!succ)
|
||||
return;
|
||||
|
||||
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel) ctx.Channel);
|
||||
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
|
||||
if (mp is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.no_player);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
_ = ctx.Channel.TriggerTypingAsync();
|
||||
|
||||
await _service.EnqueueSoundcloudPlaylistAsync(mp, playlist, ctx.User.ToString());
|
||||
@@ -637,7 +643,8 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
await ctx.OkAsync();
|
||||
}
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Playlist([Leftover] string playlistQuery)
|
||||
{
|
||||
@@ -648,7 +655,7 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
if (!succ)
|
||||
return;
|
||||
|
||||
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel) ctx.Channel);
|
||||
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
|
||||
if (mp is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.no_player);
|
||||
@@ -664,14 +671,16 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
await ReplyErrorLocalizedAsync(strs.no_search_results);
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.OkAsync();
|
||||
}
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task NowPlaying()
|
||||
{
|
||||
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel) ctx.Channel);
|
||||
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
|
||||
if (mp is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.no_player);
|
||||
@@ -682,35 +691,39 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
if (currentTrack is null)
|
||||
return;
|
||||
|
||||
var embed = _eb.Create().WithOkColor()
|
||||
.WithAuthor(GetText(strs.now_playing), MusicIconUrl)
|
||||
.WithDescription(currentTrack.PrettyName())
|
||||
.WithThumbnailUrl(currentTrack.Thumbnail)
|
||||
.WithFooter($"{mp.PrettyVolume()} | {mp.PrettyTotalTime()} | {currentTrack.Platform} | {currentTrack.Queuer}");
|
||||
var embed = _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithAuthor(GetText(strs.now_playing), MusicIconUrl)
|
||||
.WithDescription(currentTrack.PrettyName())
|
||||
.WithThumbnailUrl(currentTrack.Thumbnail)
|
||||
.WithFooter(
|
||||
$"{mp.PrettyVolume()} | {mp.PrettyTotalTime()} | {currentTrack.Platform} | {currentTrack.Queuer}");
|
||||
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task PlaylistShuffle()
|
||||
{
|
||||
var valid = await ValidateAsync();
|
||||
if (!valid)
|
||||
return;
|
||||
|
||||
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel) ctx.Channel);
|
||||
|
||||
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
|
||||
if (mp is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.no_player);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
mp.ShuffleQueue();
|
||||
await ReplyConfirmLocalizedAsync(strs.queue_shuffled);
|
||||
}
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageMessages)]
|
||||
public async Task SetMusicChannel()
|
||||
@@ -719,8 +732,9 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.set_music_channel);
|
||||
}
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageMessages)]
|
||||
public async Task UnsetMusicChannel()
|
||||
@@ -730,19 +744,21 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
await ReplyConfirmLocalizedAsync(strs.unset_music_channel);
|
||||
}
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task AutoDisconnect()
|
||||
{
|
||||
var newState = await _service.ToggleAutoDisconnectAsync(ctx.Guild.Id);
|
||||
|
||||
if(newState)
|
||||
if (newState)
|
||||
await ReplyConfirmLocalizedAsync(strs.autodc_enable);
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.autodc_disable);
|
||||
}
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
public async Task MusicQuality()
|
||||
@@ -750,8 +766,9 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
var quality = await _service.GetMusicQualityAsync(ctx.Guild.Id);
|
||||
await ReplyConfirmLocalizedAsync(strs.current_music_quality(Format.Bold(quality.ToString())));
|
||||
}
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
public async Task MusicQuality(QualityPreset preset)
|
||||
@@ -759,4 +776,4 @@ public sealed partial class Music : NadekoModule<IMusicService>
|
||||
await _service.SetMusicQualityAsync(ctx.Guild.Id, preset);
|
||||
await ReplyConfirmLocalizedAsync(strs.music_quality_set(Format.Bold(preset.ToString())));
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,7 +1,7 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Services.Database.Models;
|
||||
using NadekoBot.Db;
|
||||
using NadekoBot.Modules.Music.Services;
|
||||
using NadekoBot.Services.Database.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Music;
|
||||
|
||||
@@ -10,6 +10,7 @@ public sealed partial class Music
|
||||
[Group]
|
||||
public sealed class PlaylistCommands : NadekoModule<IMusicService>
|
||||
{
|
||||
private static readonly SemaphoreSlim _playlistLock = new(1, 1);
|
||||
private readonly DbService _db;
|
||||
private readonly IBotCredentials _creds;
|
||||
|
||||
@@ -18,7 +19,7 @@ public sealed partial class Music
|
||||
_db = db;
|
||||
_creds = creds;
|
||||
}
|
||||
|
||||
|
||||
private async Task EnsureBotInVoiceChannelAsync(ulong voiceChannelId, IGuildUser botUser = null)
|
||||
{
|
||||
botUser ??= await ctx.Guild.GetCurrentUserAsync();
|
||||
@@ -34,7 +35,8 @@ public sealed partial class Music
|
||||
}
|
||||
}
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Playlists([Leftover] int num = 1)
|
||||
{
|
||||
@@ -48,17 +50,17 @@ public sealed partial class Music
|
||||
playlists = uow.MusicPlaylists.GetPlaylistsOnPage(num);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task DeletePlaylist([Leftover] int id)
|
||||
{
|
||||
@@ -69,14 +71,12 @@ public sealed partial class Music
|
||||
var pl = uow.MusicPlaylists.FirstOrDefault(x => x.Id == id);
|
||||
|
||||
if (pl != null)
|
||||
{
|
||||
if (_creds.IsOwner(ctx.User) || pl.AuthorId == ctx.User.Id)
|
||||
{
|
||||
uow.MusicPlaylists.Remove(pl);
|
||||
await uow.SaveChangesAsync();
|
||||
success = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -89,7 +89,8 @@ public sealed partial class Music
|
||||
await ReplyConfirmLocalizedAsync(strs.playlist_deleted);
|
||||
}
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task PlaylistShow(int id, int page = 1)
|
||||
{
|
||||
@@ -102,21 +103,22 @@ public sealed partial class Music
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
[NadekoCommand]
|
||||
[Aliases]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Save([Leftover] string name)
|
||||
{
|
||||
@@ -127,49 +129,45 @@ public sealed partial class Music
|
||||
}
|
||||
|
||||
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();
|
||||
.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;
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
playlist = new()
|
||||
{
|
||||
Name = name,
|
||||
Author = ctx.User.Username,
|
||||
AuthorId = ctx.User.Id,
|
||||
Songs = songs.ToList(),
|
||||
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()));
|
||||
.WithOkColor()
|
||||
.WithTitle(GetText(strs.playlist_saved))
|
||||
.AddField(GetText(strs.name), name)
|
||||
.AddField(GetText(strs.id), playlist.Id.ToString()));
|
||||
}
|
||||
|
||||
|
||||
private static readonly SemaphoreSlim _playlistLock = new(1, 1);
|
||||
|
||||
[NadekoCommand, Aliases]
|
||||
[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 user = (IGuildUser)ctx.User;
|
||||
var voiceChannelId = user.VoiceChannel?.Id;
|
||||
|
||||
|
||||
if (voiceChannelId is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.must_be_in_voice);
|
||||
@@ -177,23 +175,23 @@ public sealed partial class Music
|
||||
}
|
||||
|
||||
_ = 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);
|
||||
|
||||
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
|
||||
if (mp is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.no_player);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
MusicPlaylist mpl;
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
@@ -209,22 +207,17 @@ public sealed partial class Music
|
||||
IUserMessage msg = null;
|
||||
try
|
||||
{
|
||||
msg = await ctx.Channel
|
||||
.SendMessageAsync(GetText(strs.attempting_to_queue(Format.Bold(mpl.Songs.Count.ToString()))));
|
||||
msg = await ctx.Channel.SendMessageAsync(
|
||||
GetText(strs.attempting_to_queue(Format.Bold(mpl.Songs.Count.ToString()))));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
|
||||
await mp.EnqueueManyAsync(
|
||||
mpl.Songs.Select(x => (x.Query, (MusicPlatform) x.ProviderType)),
|
||||
ctx.User.ToString()
|
||||
);
|
||||
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));
|
||||
}
|
||||
if (msg != null) await msg.ModifyAsync(m => m.Content = GetText(strs.playlist_queue_complete));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -232,4 +225,4 @@ public sealed partial class Music
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,6 +1,6 @@
|
||||
#nullable disable
|
||||
using System.Reflection;
|
||||
using Ayu.Discord.Voice;
|
||||
using System.Reflection;
|
||||
|
||||
namespace NadekoBot.Modules.Music.Services;
|
||||
|
||||
@@ -8,10 +8,10 @@ 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();
|
||||
private readonly ConcurrentDictionary<ulong, SemaphoreSlim> _voiceGatewayLocks = new();
|
||||
|
||||
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly MethodInfo _sendVoiceStateUpdateMethodInfo;
|
||||
private readonly object _dnetApiClient;
|
||||
@@ -23,13 +23,17 @@ public sealed class AyuVoiceStateService : INService
|
||||
_currentUserId = _client.CurrentUser.Id;
|
||||
|
||||
var prop = _client.GetType()
|
||||
.GetProperties(BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
.First(x => x.Name == "ApiClient" && x.PropertyType.Name == "DiscordSocketApiClient");
|
||||
.GetProperties(BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
.First(x => x.Name == "ApiClient" && x.PropertyType.Name == "DiscordSocketApiClient");
|
||||
_dnetApiClient = prop.GetValue(_client, null);
|
||||
_sendVoiceStateUpdateMethodInfo = _dnetApiClient.GetType()
|
||||
.GetMethod("SendVoiceStateUpdateAsync",
|
||||
types: new[] { typeof(ulong), typeof(ulong?), typeof(bool), typeof(bool), typeof(RequestOptions) });
|
||||
|
||||
.GetMethod("SendVoiceStateUpdateAsync",
|
||||
new[]
|
||||
{
|
||||
typeof(ulong), typeof(ulong?), typeof(bool),
|
||||
typeof(bool), typeof(RequestOptions)
|
||||
});
|
||||
|
||||
_client.LeftGuild += ClientOnLeftGuild;
|
||||
}
|
||||
|
||||
@@ -44,29 +48,32 @@ public sealed class AyuVoiceStateService : INService
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task InvokeSendVoiceStateUpdateAsync(ulong guildId, ulong? channelId = null, bool isDeafened = false, bool isMuted = false)
|
||||
private Task InvokeSendVoiceStateUpdateAsync(
|
||||
ulong guildId,
|
||||
ulong? channelId = null,
|
||||
bool isDeafened = false,
|
||||
bool isMuted = false)
|
||||
// return _voiceStateUpdate(guildId, channelId, isDeafened, isMuted);
|
||||
=> (Task) _sendVoiceStateUpdateMethodInfo.Invoke(_dnetApiClient, new object[] {guildId, channelId, isMuted, isDeafened, null});
|
||||
=> (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 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 (user is SocketGuildUser guildUser
|
||||
&& guildUser.Guild.Id == guildId
|
||||
&& newState.VoiceChannel?.Id is null)
|
||||
{
|
||||
if (user is SocketGuildUser guildUser && guildUser.Guild.Id == guildId && newState.VoiceChannel?.Id is null)
|
||||
complete.TrySetResult(true);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
@@ -89,6 +96,7 @@ public sealed class AyuVoiceStateService : INService
|
||||
_client.UserVoiceStateUpdated -= OnUserVoiceStateUpdated;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LeaveVoiceChannel(ulong guildId)
|
||||
{
|
||||
var gwLock = GetVoiceGatewayLock(guildId);
|
||||
@@ -105,8 +113,10 @@ public sealed class AyuVoiceStateService : INService
|
||||
|
||||
private async Task<IVoiceProxy> InternalConnectToVcAsync(ulong guildId, ulong channelId)
|
||||
{
|
||||
var voiceStateUpdatedSource = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var voiceServerUpdatedSource = new TaskCompletionSource<SocketVoiceServer>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var voiceStateUpdatedSource =
|
||||
new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var voiceServerUpdatedSource =
|
||||
new TaskCompletionSource<SocketVoiceServer>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
Task OnUserVoiceStateUpdated(SocketUser user, SocketVoiceState oldState, SocketVoiceState newState)
|
||||
{
|
||||
@@ -123,14 +133,11 @@ public sealed class AyuVoiceStateService : INService
|
||||
|
||||
Task OnVoiceServerUpdated(SocketVoiceServer data)
|
||||
{
|
||||
if (data.Guild.Id == guildId)
|
||||
{
|
||||
voiceServerUpdatedSource.TrySetResult(data);
|
||||
}
|
||||
if (data.Guild.Id == guildId) voiceServerUpdatedSource.TrySetResult(data);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
_client.VoiceServerUpdated += OnVoiceServerUpdated;
|
||||
@@ -149,10 +156,8 @@ public sealed class AyuVoiceStateService : INService
|
||||
// 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
|
||||
@@ -164,26 +169,20 @@ public sealed class AyuVoiceStateService : INService
|
||||
return null;
|
||||
|
||||
var voiceServerData = await voiceServerUpdatedSource.Task;
|
||||
|
||||
VoiceGateway CreateVoiceGatewayLocal() =>
|
||||
new(
|
||||
guildId,
|
||||
_currentUserId,
|
||||
session,
|
||||
voiceServerData.Token,
|
||||
voiceServerData.Endpoint
|
||||
);
|
||||
|
||||
var current = _voiceProxies.AddOrUpdate(
|
||||
guildId,
|
||||
VoiceGateway CreateVoiceGatewayLocal()
|
||||
{
|
||||
return new(guildId, _currentUserId, session, voiceServerData.Token, voiceServerData.Endpoint);
|
||||
}
|
||||
|
||||
var current = _voiceProxies.AddOrUpdate(guildId,
|
||||
gid => new VoiceProxy(CreateVoiceGatewayLocal()),
|
||||
(gid, currentProxy) =>
|
||||
{
|
||||
_ = currentProxy.StopGateway();
|
||||
currentProxy.SetGateway(CreateVoiceGatewayLocal());
|
||||
return currentProxy;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
_ = current.StartGateway(); // don't await, this blocks until gateway is closed
|
||||
return current;
|
||||
@@ -212,4 +211,4 @@ public sealed class AyuVoiceStateService : INService
|
||||
|
||||
public bool TryGetProxy(ulong guildId, out IVoiceProxy proxy)
|
||||
=> _voiceProxies.TryGetValue(guildId, out proxy);
|
||||
}
|
||||
}
|
@@ -1,23 +1,23 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using NadekoBot.Services.Database.Models;
|
||||
using NadekoBot.Services.Database.Models;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace NadekoBot.Modules.Music.Services;
|
||||
|
||||
public interface IMusicService : IPlaceholderProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Leave voice channel in the specified guild if it's connected to one
|
||||
/// 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
|
||||
/// 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);
|
||||
@@ -32,4 +32,4 @@ public interface IMusicService : IPlaceholderProvider
|
||||
Task<bool> ToggleAutoDisconnectAsync(ulong guildId);
|
||||
Task<QualityPreset> GetMusicQualityAsync(ulong guildId);
|
||||
Task SetMusicQualityAsync(ulong guildId, QualityPreset preset);
|
||||
}
|
||||
}
|
@@ -1,6 +1,6 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using NadekoBot.Db;
|
||||
using NadekoBot.Services.Database.Models;
|
||||
using NadekoBot.Db;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace NadekoBot.Modules.Music.Services;
|
||||
|
||||
@@ -22,9 +22,17 @@ public sealed class MusicService : IMusicService
|
||||
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,
|
||||
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;
|
||||
@@ -42,10 +50,10 @@ public sealed class MusicService : IMusicService
|
||||
_players = new();
|
||||
_outputChannels = new ConcurrentDictionary<ulong, (ITextChannel, ITextChannel?)>();
|
||||
_settings = new();
|
||||
|
||||
|
||||
_client.LeftGuild += ClientOnLeftGuild;
|
||||
}
|
||||
|
||||
|
||||
private void DisposeMusicPlayer(IMusicPlayer musicPlayer)
|
||||
{
|
||||
musicPlayer.Kill();
|
||||
@@ -55,10 +63,7 @@ public sealed class MusicService : IMusicService
|
||||
private void RemoveMusicPlayer(ulong guildId)
|
||||
{
|
||||
_outputChannels.TryRemove(guildId, out _);
|
||||
if (_players.TryRemove(guildId, out var mp))
|
||||
{
|
||||
DisposeMusicPlayer(mp);
|
||||
}
|
||||
if (_players.TryRemove(guildId, out var mp)) DisposeMusicPlayer(mp);
|
||||
}
|
||||
|
||||
private Task ClientOnLeftGuild(SocketGuild guild)
|
||||
@@ -73,7 +78,7 @@ public sealed class MusicService : IMusicService
|
||||
await _voiceStateService.LeaveVoiceChannel(guildId);
|
||||
}
|
||||
|
||||
public Task JoinVoiceChannelAsync(ulong guildId, ulong voiceChannelId)
|
||||
public Task JoinVoiceChannelAsync(ulong guildId, ulong voiceChannelId)
|
||||
=> _voiceStateService.JoinVoiceChannel(guildId, voiceChannelId);
|
||||
|
||||
public async Task<IMusicPlayer?> GetOrCreateMusicPlayerAsync(ITextChannel contextChannel)
|
||||
@@ -81,7 +86,7 @@ public sealed class MusicService : IMusicService
|
||||
var newPLayer = await CreateMusicPlayerInternalAsync(contextChannel.GuildId, contextChannel);
|
||||
if (newPLayer is null)
|
||||
return null;
|
||||
|
||||
|
||||
return _players.GetOrAdd(contextChannel.GuildId, newPLayer);
|
||||
}
|
||||
|
||||
@@ -109,7 +114,7 @@ public sealed class MusicService : IMusicService
|
||||
{
|
||||
if (mp.IsKilled)
|
||||
break;
|
||||
|
||||
|
||||
mp.EnqueueTrack(track, queuer);
|
||||
}
|
||||
}
|
||||
@@ -121,7 +126,7 @@ public sealed class MusicService : IMusicService
|
||||
{
|
||||
if (mp.IsKilled)
|
||||
break;
|
||||
|
||||
|
||||
mp.EnqueueTrack(track, queuer);
|
||||
++i;
|
||||
}
|
||||
@@ -134,10 +139,7 @@ public sealed class MusicService : IMusicService
|
||||
var queue = new MusicQueue();
|
||||
var resolver = _trackResolveProvider;
|
||||
|
||||
if (!_voiceStateService.TryGetProxy(guildId, out var proxy))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (!_voiceStateService.TryGetProxy(guildId, out var proxy)) return null;
|
||||
|
||||
var settings = await GetSettingsInternalAsync(guildId);
|
||||
|
||||
@@ -147,30 +149,19 @@ public sealed class MusicService : IMusicService
|
||||
overrideChannel = _client.GetGuild(guildId)?.GetTextChannel(channelId);
|
||||
|
||||
if (overrideChannel is null)
|
||||
{
|
||||
Log.Warning("Saved music output channel doesn't exist, falling back to current channel");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
_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);
|
||||
|
||||
if (settings.Volume is >= 0 and <= 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);
|
||||
@@ -197,10 +188,10 @@ public sealed class MusicService : IMusicService
|
||||
{
|
||||
_ = lastFinishedMessage?.DeleteAsync();
|
||||
var embed = _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithAuthor(GetText(guildId, strs.finished_song), Music.MusicIconUrl)
|
||||
.WithDescription(trackInfo.PrettyName())
|
||||
.WithFooter(trackInfo.PrettyTotalTime());
|
||||
.WithOkColor()
|
||||
.WithAuthor(GetText(guildId, strs.finished_song), Music.MusicIconUrl)
|
||||
.WithDescription(trackInfo.PrettyName())
|
||||
.WithFooter(trackInfo.PrettyTotalTime());
|
||||
|
||||
lastFinishedMessage = await SendToOutputAsync(guildId, embed);
|
||||
};
|
||||
@@ -212,10 +203,11 @@ public sealed class MusicService : IMusicService
|
||||
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()}");
|
||||
var embed = _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithAuthor(GetText(guildId, strs.playing_song(index + 1)), Music.MusicIconUrl)
|
||||
.WithDescription(trackInfo.PrettyName())
|
||||
.WithFooter($"{mp.PrettyVolume()} | {trackInfo.PrettyInfo()}");
|
||||
|
||||
lastPlayingMessage = await SendToOutputAsync(guildId, embed);
|
||||
};
|
||||
@@ -225,12 +217,8 @@ public sealed class MusicService : IMusicService
|
||||
=> mp =>
|
||||
{
|
||||
if (_settings.TryGetValue(guildId, out var settings))
|
||||
{
|
||||
if (settings.AutoDisconnect)
|
||||
{
|
||||
return LeaveVoiceChannelAsync(guildId);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
@@ -238,19 +226,12 @@ public sealed class MusicService : IMusicService
|
||||
// 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 (!TryGetMusicPlayer(guildId, out var mp)) return false;
|
||||
|
||||
if (mp.IsStopped)
|
||||
{
|
||||
if (!_voiceStateService.TryGetProxy(guildId, out var proxy)
|
||||
if (!_voiceStateService.TryGetProxy(guildId, out var proxy)
|
||||
|| proxy.State == VoiceProxy.VoiceProxyState.Stopped)
|
||||
{
|
||||
await JoinVoiceChannelAsync(guildId, voiceChannelId);
|
||||
}
|
||||
}
|
||||
|
||||
mp.Next();
|
||||
return true;
|
||||
@@ -261,22 +242,19 @@ public sealed class MusicService : IMusicService
|
||||
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;
|
||||
}
|
||||
if (videos.Count > 0) return videos;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -289,13 +267,14 @@ public sealed class MusicService : IMusicService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning("Failed getting video results with Google Api. " +
|
||||
"Probably google api key missing: {ErrorMessage}", ex.Message);
|
||||
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);
|
||||
|
||||
@@ -304,11 +283,10 @@ public sealed class MusicService : IMusicService
|
||||
// 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();
|
||||
var randomPlayingTrack = _players.Select(x => x.Value.GetCurrentTrack(out _))
|
||||
.Where(x => x is not null)
|
||||
.Shuffle()
|
||||
.FirstOrDefault();
|
||||
|
||||
if (randomPlayingTrack is null)
|
||||
return "-";
|
||||
@@ -319,17 +297,14 @@ public sealed class MusicService : IMusicService
|
||||
// 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);
|
||||
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);
|
||||
var count = _players.Sum(x => x.Value.GetQueuedTracks().Count);
|
||||
|
||||
return count.ToString();
|
||||
});
|
||||
@@ -348,7 +323,7 @@ public sealed class MusicService : IMusicService
|
||||
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
|
||||
private async Task ModifySettingsInternalAsync<TState>(
|
||||
ulong guildId,
|
||||
Action<MusicPlayerSettings, TState> action,
|
||||
@@ -360,7 +335,7 @@ public sealed class MusicService : IMusicService
|
||||
await uow.SaveChangesAsync();
|
||||
_settings[guildId] = ms;
|
||||
}
|
||||
|
||||
|
||||
public async Task<bool> SetMusicChannelAsync(ulong guildId, ulong? channelId)
|
||||
{
|
||||
if (channelId is null)
|
||||
@@ -368,29 +343,31 @@ public sealed class MusicService : IMusicService
|
||||
await UnsetMusicChannelAsync(guildId);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
var channel = _client.GetGuild(guildId)?.GetTextChannel(channelId.Value);
|
||||
if (channel is null)
|
||||
return false;
|
||||
|
||||
await ModifySettingsInternalAsync(guildId, (settings, chId) =>
|
||||
{
|
||||
settings.MusicChannelId = chId;
|
||||
}, channelId);
|
||||
await ModifySettingsInternalAsync(guildId,
|
||||
(settings, chId) =>
|
||||
{
|
||||
settings.MusicChannelId = chId;
|
||||
},
|
||||
channelId);
|
||||
|
||||
_outputChannels.AddOrUpdate(guildId, (channel, channel), (key, old) => (old.Default, channel));
|
||||
|
||||
_outputChannels.AddOrUpdate(guildId,
|
||||
(channel, channel),
|
||||
(key, old) => (old.Default, channel));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task UnsetMusicChannelAsync(ulong guildId)
|
||||
{
|
||||
await ModifySettingsInternalAsync(guildId, (settings, _) =>
|
||||
{
|
||||
settings.MusicChannelId = null;
|
||||
}, (ulong?)null);
|
||||
await ModifySettingsInternalAsync(guildId,
|
||||
(settings, _) =>
|
||||
{
|
||||
settings.MusicChannelId = null;
|
||||
},
|
||||
(ulong?)null);
|
||||
|
||||
if (_outputChannels.TryGetValue(guildId, out var old))
|
||||
_outputChannels[guildId] = (old.Default, null);
|
||||
@@ -398,10 +375,12 @@ public sealed class MusicService : IMusicService
|
||||
|
||||
public async Task SetRepeatAsync(ulong guildId, PlayerRepeatType repeatType)
|
||||
{
|
||||
await ModifySettingsInternalAsync(guildId, (settings, type) =>
|
||||
{
|
||||
settings.PlayerRepeat = type;
|
||||
}, repeatType);
|
||||
await ModifySettingsInternalAsync(guildId,
|
||||
(settings, type) =>
|
||||
{
|
||||
settings.PlayerRepeat = type;
|
||||
},
|
||||
repeatType);
|
||||
|
||||
if (TryGetMusicPlayer(guildId, out var mp))
|
||||
mp.SetRepeat(repeatType);
|
||||
@@ -411,12 +390,14 @@ public sealed class MusicService : IMusicService
|
||||
{
|
||||
if (value is < 0 or > 100)
|
||||
throw new ArgumentOutOfRangeException(nameof(value));
|
||||
|
||||
await ModifySettingsInternalAsync(guildId, (settings, newValue) =>
|
||||
{
|
||||
settings.Volume = newValue;
|
||||
}, value);
|
||||
|
||||
|
||||
await ModifySettingsInternalAsync(guildId,
|
||||
(settings, newValue) =>
|
||||
{
|
||||
settings.Volume = newValue;
|
||||
},
|
||||
value);
|
||||
|
||||
if (TryGetMusicPlayer(guildId, out var mp))
|
||||
mp.SetVolume(value);
|
||||
}
|
||||
@@ -424,10 +405,12 @@ public sealed class MusicService : IMusicService
|
||||
public async Task<bool> ToggleAutoDisconnectAsync(ulong guildId)
|
||||
{
|
||||
var newState = false;
|
||||
await ModifySettingsInternalAsync(guildId, (settings, _) =>
|
||||
{
|
||||
newState = settings.AutoDisconnect = !settings.AutoDisconnect;
|
||||
}, default(object));
|
||||
await ModifySettingsInternalAsync(guildId,
|
||||
(settings, _) =>
|
||||
{
|
||||
newState = settings.AutoDisconnect = !settings.AutoDisconnect;
|
||||
},
|
||||
default(object));
|
||||
|
||||
return newState;
|
||||
}
|
||||
@@ -440,10 +423,12 @@ public sealed class MusicService : IMusicService
|
||||
}
|
||||
|
||||
public Task SetMusicQualityAsync(ulong guildId, QualityPreset preset)
|
||||
=> ModifySettingsInternalAsync(guildId, (settings, _) =>
|
||||
{
|
||||
settings.QualityPreset = preset;
|
||||
}, preset);
|
||||
=> ModifySettingsInternalAsync(guildId,
|
||||
(settings, _) =>
|
||||
{
|
||||
settings.QualityPreset = preset;
|
||||
},
|
||||
preset);
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
@@ -3,7 +3,6 @@ namespace NadekoBot.Modules.Music.Services;
|
||||
|
||||
public sealed partial class YtLoader
|
||||
{
|
||||
|
||||
public class InitRange
|
||||
{
|
||||
public string Start { get; set; }
|
||||
@@ -69,4 +68,4 @@ public sealed partial class YtLoader
|
||||
_videoId = videoId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -7,15 +7,16 @@ namespace NadekoBot.Modules.Music.Services;
|
||||
|
||||
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 static readonly string[] durationFormats = new[]
|
||||
|
||||
private static readonly string[] durationFormats =
|
||||
{
|
||||
@"m\:ss", @"mm\:ss", @"h\:mm\:ss", @"hh\:mm\:ss", @"hhh\:mm\:ss"
|
||||
};
|
||||
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
|
||||
public YtLoader(IHttpClientFactory httpFactory)
|
||||
=> _httpFactory = httpFactory;
|
||||
|
||||
@@ -54,7 +55,7 @@ public sealed partial class YtLoader
|
||||
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;");
|
||||
|
||||
@@ -76,20 +77,19 @@ public sealed partial class YtLoader
|
||||
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();
|
||||
|
||||
.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))
|
||||
if (!track.TryGetProperty("videoRenderer", out var elem))
|
||||
continue;
|
||||
|
||||
var videoId = elem.GetProperty("videoId").GetString();
|
||||
@@ -97,18 +97,20 @@ public sealed partial class YtLoader
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
return tracks;
|
||||
}
|
||||
|
||||
@@ -120,11 +122,9 @@ public sealed partial class YtLoader
|
||||
return null; // todo future try selecting html
|
||||
startIndex += YT_RESULT_INITIAL_DATA.Length;
|
||||
|
||||
var endIndex = 140_000 + startIndex + responseSpan[(startIndex + 20_000)..].IndexOf(YT_RESULT_JSON_END) + 20_000;
|
||||
var endIndex =
|
||||
140_000 + startIndex + responseSpan[(startIndex + 20_000)..].IndexOf(YT_RESULT_JSON_END) + 20_000;
|
||||
startIndex += 140_000;
|
||||
return response.AsMemory(
|
||||
startIndex,
|
||||
endIndex - startIndex
|
||||
);
|
||||
return response.AsMemory(startIndex, endIndex - startIndex);
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user