Added .q support for invidious. If you have ytProvider set to invidious in data/searches.yml, invidious will be used to queue up songs and play them work.
This commit is contained in:
parent
6d0eac2d6f
commit
1ea0e63379
25 changed files with 345 additions and 104 deletions
|
@ -37,6 +37,7 @@
|
||||||
<!-- <PackageReference Include="Grpc.AspNetCore" Version="2.62.0" />-->
|
<!-- <PackageReference Include="Grpc.AspNetCore" Version="2.62.0" />-->
|
||||||
<PackageReference Include="Google.Protobuf" Version="3.26.1" />
|
<PackageReference Include="Google.Protobuf" Version="3.26.1" />
|
||||||
<PackageReference Include="Grpc.Net.ClientFactory" Version="2.62.0" />
|
<PackageReference Include="Grpc.Net.ClientFactory" Version="2.62.0" />
|
||||||
|
<PackageReference Include="Grpc.AspNetCore" Version="2.62.0" />
|
||||||
<PackageReference Include="Grpc.Tools" Version="2.63.0">
|
<PackageReference Include="Grpc.Tools" Version="2.63.0">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
|
|
@ -274,8 +274,8 @@ public sealed partial class WaifuConfig
|
||||||
public class WaifuDecayConfig
|
public class WaifuDecayConfig
|
||||||
{
|
{
|
||||||
[Comment("""
|
[Comment("""
|
||||||
Percentage (0 - 100) of the waifu value to reduce.
|
Unclaimed waifus will decay by this percentage (0 - 100).
|
||||||
Set 0 to disable
|
Default is 0 (disabled)
|
||||||
For example if a waifu has a price of 500$, setting this value to 10 would reduce the waifu value by 10% (50$)
|
For example if a waifu has a price of 500$, setting this value to 10 would reduce the waifu value by 10% (50$)
|
||||||
""")]
|
""")]
|
||||||
public int UnclaimedDecayPercent { get; set; } = 0;
|
public int UnclaimedDecayPercent { get; set; } = 0;
|
||||||
|
@ -283,6 +283,7 @@ public sealed partial class WaifuConfig
|
||||||
[Comment("""
|
[Comment("""
|
||||||
Claimed waifus will decay by this percentage (0 - 100).
|
Claimed waifus will decay by this percentage (0 - 100).
|
||||||
Default is 0 (disabled)
|
Default is 0 (disabled)
|
||||||
|
For example if a waifu has a price of $500, setting this value to 10 would reduce the waifu by 10% ($50)
|
||||||
""")]
|
""")]
|
||||||
public int ClaimedDecayPercent { get; set; } = 0;
|
public int ClaimedDecayPercent { get; set; } = 0;
|
||||||
|
|
||||||
|
|
|
@ -130,13 +130,14 @@ public sealed partial class Music
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// todo check locally queued songs
|
||||||
var songs = mp.GetQueuedTracks()
|
var songs = mp.GetQueuedTracks()
|
||||||
.Select(s => new PlaylistSong
|
.Select(s => new PlaylistSong
|
||||||
{
|
{
|
||||||
Provider = s.Platform.ToString(),
|
Provider = s.Platform.ToString(),
|
||||||
ProviderType = (MusicType)s.Platform,
|
ProviderType = (MusicType)s.Platform,
|
||||||
Title = s.Title,
|
Title = s.Title,
|
||||||
Query = s.Platform == MusicPlatform.Local ? s.GetStreamUrl().Result!.Trim('"') : s.Url
|
Query = s.Url
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
using EllieBot.Db.Models;
|
using EllieBot.Db.Models;
|
||||||
|
using EllieBot.Modules.Music.Resolvers;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
namespace EllieBot.Modules.Music.Services;
|
namespace EllieBot.Modules.Music.Services;
|
||||||
|
@ -8,7 +9,7 @@ public sealed class MusicService : IMusicService, IPlaceholderProvider
|
||||||
private readonly AyuVoiceStateService _voiceStateService;
|
private readonly AyuVoiceStateService _voiceStateService;
|
||||||
private readonly ITrackResolveProvider _trackResolveProvider;
|
private readonly ITrackResolveProvider _trackResolveProvider;
|
||||||
private readonly DbService _db;
|
private readonly DbService _db;
|
||||||
private readonly IYoutubeResolver _ytResolver;
|
private readonly IYoutubeResolverFactory _ytResolver;
|
||||||
private readonly ILocalTrackResolver _localResolver;
|
private readonly ILocalTrackResolver _localResolver;
|
||||||
private readonly DiscordSocketClient _client;
|
private readonly DiscordSocketClient _client;
|
||||||
private readonly IBotStrings _strings;
|
private readonly IBotStrings _strings;
|
||||||
|
@ -24,7 +25,7 @@ public sealed class MusicService : IMusicService, IPlaceholderProvider
|
||||||
AyuVoiceStateService voiceStateService,
|
AyuVoiceStateService voiceStateService,
|
||||||
ITrackResolveProvider trackResolveProvider,
|
ITrackResolveProvider trackResolveProvider,
|
||||||
DbService db,
|
DbService db,
|
||||||
IYoutubeResolver ytResolver,
|
IYoutubeResolverFactory ytResolver,
|
||||||
ILocalTrackResolver localResolver,
|
ILocalTrackResolver localResolver,
|
||||||
DiscordSocketClient client,
|
DiscordSocketClient client,
|
||||||
IBotStrings strings,
|
IBotStrings strings,
|
||||||
|
@ -93,7 +94,7 @@ public sealed class MusicService : IMusicService, IPlaceholderProvider
|
||||||
public async Task<int> EnqueueYoutubePlaylistAsync(IMusicPlayer mp, string query, string queuer)
|
public async Task<int> EnqueueYoutubePlaylistAsync(IMusicPlayer mp, string query, string queuer)
|
||||||
{
|
{
|
||||||
var count = 0;
|
var count = 0;
|
||||||
await foreach (var track in _ytResolver.ResolveTracksFromPlaylistAsync(query))
|
await foreach (var track in _ytResolver.GetYoutubeResolver().ResolveTracksFromPlaylistAsync(query))
|
||||||
{
|
{
|
||||||
if (mp.IsKilled)
|
if (mp.IsKilled)
|
||||||
break;
|
break;
|
||||||
|
@ -139,6 +140,7 @@ public sealed class MusicService : IMusicService, IPlaceholderProvider
|
||||||
|
|
||||||
var mp = new MusicPlayer(queue,
|
var mp = new MusicPlayer(queue,
|
||||||
resolver,
|
resolver,
|
||||||
|
_ytResolver,
|
||||||
proxy,
|
proxy,
|
||||||
_googleApiService,
|
_googleApiService,
|
||||||
settings.QualityPreset,
|
settings.QualityPreset,
|
||||||
|
|
|
@ -8,5 +8,4 @@ public interface ITrackInfo
|
||||||
public string Thumbnail { get; }
|
public string Thumbnail { get; }
|
||||||
public TimeSpan Duration { get; }
|
public TimeSpan Duration { get; }
|
||||||
public MusicPlatform Platform { get; }
|
public MusicPlatform Platform { get; }
|
||||||
public ValueTask<string?> GetStreamUrl();
|
|
||||||
}
|
}
|
|
@ -4,8 +4,8 @@ namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
public interface IYoutubeResolver : IPlatformQueryResolver
|
public interface IYoutubeResolver : IPlatformQueryResolver
|
||||||
{
|
{
|
||||||
public Regex YtVideoIdRegex { get; }
|
|
||||||
public Task<ITrackInfo?> ResolveByIdAsync(string id);
|
public Task<ITrackInfo?> ResolveByIdAsync(string id);
|
||||||
IAsyncEnumerable<ITrackInfo> ResolveTracksFromPlaylistAsync(string query);
|
IAsyncEnumerable<ITrackInfo> ResolveTracksFromPlaylistAsync(string query);
|
||||||
Task<ITrackInfo?> ResolveByQueryAsync(string query, bool tryExtractingId);
|
Task<ITrackInfo?> ResolveByQueryAsync(string query, bool tryExtractingId);
|
||||||
|
Task<string?> GetStreamUrl(string query);
|
||||||
}
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
using EllieBot.Voice;
|
using EllieBot.Voice;
|
||||||
using EllieBot.Db.Models;
|
using EllieBot.Db.Models;
|
||||||
|
using EllieBot.Modules.Music.Resolvers;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
@ -27,6 +28,7 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||||
|
|
||||||
private readonly IMusicQueue _queue;
|
private readonly IMusicQueue _queue;
|
||||||
private readonly ITrackResolveProvider _trackResolveProvider;
|
private readonly ITrackResolveProvider _trackResolveProvider;
|
||||||
|
private readonly IYoutubeResolverFactory _ytResolverFactory;
|
||||||
private readonly IVoiceProxy _proxy;
|
private readonly IVoiceProxy _proxy;
|
||||||
private readonly IGoogleApiService _googleApiService;
|
private readonly IGoogleApiService _googleApiService;
|
||||||
private readonly ISongBuffer _songBuffer;
|
private readonly ISongBuffer _songBuffer;
|
||||||
|
@ -41,6 +43,7 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||||
public MusicPlayer(
|
public MusicPlayer(
|
||||||
IMusicQueue queue,
|
IMusicQueue queue,
|
||||||
ITrackResolveProvider trackResolveProvider,
|
ITrackResolveProvider trackResolveProvider,
|
||||||
|
IYoutubeResolverFactory ytResolverFactory,
|
||||||
IVoiceProxy proxy,
|
IVoiceProxy proxy,
|
||||||
IGoogleApiService googleApiService,
|
IGoogleApiService googleApiService,
|
||||||
QualityPreset qualityPreset,
|
QualityPreset qualityPreset,
|
||||||
|
@ -48,6 +51,7 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||||
{
|
{
|
||||||
_queue = queue;
|
_queue = queue;
|
||||||
_trackResolveProvider = trackResolveProvider;
|
_trackResolveProvider = trackResolveProvider;
|
||||||
|
_ytResolverFactory = ytResolverFactory;
|
||||||
_proxy = proxy;
|
_proxy = proxy;
|
||||||
_googleApiService = googleApiService;
|
_googleApiService = googleApiService;
|
||||||
AutoPlay = autoPlay;
|
AutoPlay = autoPlay;
|
||||||
|
@ -118,7 +122,7 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||||
// make sure song buffer is ready to be (re)used
|
// make sure song buffer is ready to be (re)used
|
||||||
_songBuffer.Reset();
|
_songBuffer.Reset();
|
||||||
|
|
||||||
var streamUrl = await track.GetStreamUrl();
|
var streamUrl = await GetStreamUrl(track);
|
||||||
// start up the data source
|
// start up the data source
|
||||||
using var source = FfmpegTrackDataSource.CreateAsync(
|
using var source = FfmpegTrackDataSource.CreateAsync(
|
||||||
_vc.BitDepth,
|
_vc.BitDepth,
|
||||||
|
@ -256,6 +260,7 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||||
IsStopped = true;
|
IsStopped = true;
|
||||||
Log.Error("Please install ffmpeg and make sure it's added to your "
|
Log.Error("Please install ffmpeg and make sure it's added to your "
|
||||||
+ "PATH environment variable before trying again");
|
+ "PATH environment variable before trying again");
|
||||||
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
|
@ -264,6 +269,7 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Error(ex, "Unknown error in music loop: {ErrorMessage}", ex.Message);
|
Log.Error(ex, "Unknown error in music loop: {ErrorMessage}", ex.Message);
|
||||||
|
await Task.Delay(3_000);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
@ -303,6 +309,14 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<string?> GetStreamUrl(IQueuedTrackInfo track)
|
||||||
|
{
|
||||||
|
if (track.TrackInfo is SimpleTrackInfo sti)
|
||||||
|
return sti.StreamUrl;
|
||||||
|
|
||||||
|
return await _ytResolverFactory.GetYoutubeResolver().GetStreamUrl(track.TrackInfo.Id);
|
||||||
|
}
|
||||||
|
|
||||||
private bool? CopyChunkToOutput(ISongBuffer sb, VoiceClient vc)
|
private bool? CopyChunkToOutput(ISongBuffer sb, VoiceClient vc)
|
||||||
{
|
{
|
||||||
var data = sb.Read(vc.InputLength, out var length);
|
var data = sb.Read(vc.InputLength, out var length);
|
||||||
|
|
|
@ -28,9 +28,6 @@ public sealed partial class MusicQueue
|
||||||
TrackInfo = trackInfo;
|
TrackInfo = trackInfo;
|
||||||
Queuer = queuer;
|
Queuer = queuer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ValueTask<string?> GetStreamUrl()
|
|
||||||
=> TrackInfo.GetStreamUrl();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,11 +6,5 @@ public sealed record RemoteTrackInfo(
|
||||||
string Url,
|
string Url,
|
||||||
string Thumbnail,
|
string Thumbnail,
|
||||||
TimeSpan Duration,
|
TimeSpan Duration,
|
||||||
MusicPlatform Platform,
|
|
||||||
Func<Task<string?>> _streamFactory) : ITrackInfo
|
|
||||||
{
|
|
||||||
private readonly Func<Task<string?>> _streamFactory = _streamFactory;
|
|
||||||
|
|
||||||
public async ValueTask<string?> GetStreamUrl()
|
MusicPlatform Platform) : ITrackInfo;
|
||||||
=> await _streamFactory();
|
|
||||||
}
|
|
|
@ -24,7 +24,4 @@ public sealed class SimpleTrackInfo : ITrackInfo
|
||||||
Platform = platform;
|
Platform = platform;
|
||||||
StreamUrl = streamUrl;
|
StreamUrl = streamUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ValueTask<string?> GetStreamUrl()
|
|
||||||
=> new(StreamUrl);
|
|
||||||
}
|
}
|
12
src/EllieBot/Modules/Music/_common/Resolvers/InvTrackInfo.cs
Normal file
12
src/EllieBot/Modules/Music/_common/Resolvers/InvTrackInfo.cs
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public sealed class InvTrackInfo : ITrackInfo
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
public required string Title { get; init; }
|
||||||
|
public required string Url { get; init; }
|
||||||
|
public required string Thumbnail { get; init; }
|
||||||
|
public required TimeSpan Duration { get; init; }
|
||||||
|
public required MusicPlatform Platform { get; init; }
|
||||||
|
public required string? StreamUrl { get; init; }
|
||||||
|
}
|
|
@ -0,0 +1,108 @@
|
||||||
|
using EllieBot.Modules.Searches;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public sealed class InvidiousYoutubeResolver : IYoutubeResolver
|
||||||
|
{
|
||||||
|
private readonly IHttpClientFactory _httpFactory;
|
||||||
|
private readonly SearchesConfigService _sc;
|
||||||
|
private readonly EllieRandom _rng;
|
||||||
|
|
||||||
|
private string InvidiousApiUrl
|
||||||
|
=> _sc.Data.InvidiousInstances[_rng.Next(0, _sc.Data.InvidiousInstances.Count)];
|
||||||
|
|
||||||
|
public InvidiousYoutubeResolver(IHttpClientFactory httpFactory, SearchesConfigService sc)
|
||||||
|
{
|
||||||
|
_rng = new EllieRandom();
|
||||||
|
_httpFactory = httpFactory;
|
||||||
|
_sc = sc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ITrackInfo?> ResolveByQueryAsync(string query)
|
||||||
|
{
|
||||||
|
using var http = _httpFactory.CreateClient();
|
||||||
|
|
||||||
|
var items = await http.GetFromJsonAsync<List<InvidiousSearchResponse>>(
|
||||||
|
$"{InvidiousApiUrl}/api/v1/search"
|
||||||
|
+ $"?q={query}"
|
||||||
|
+ $"&type=video");
|
||||||
|
|
||||||
|
if (items is null || items.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
|
||||||
|
var res = items.First();
|
||||||
|
|
||||||
|
return new InvTrackInfo()
|
||||||
|
{
|
||||||
|
Id = res.VideoId,
|
||||||
|
Title = res.Title,
|
||||||
|
Url = $"https://youtube.com/watch?v={res.VideoId}",
|
||||||
|
Thumbnail = res.Thumbnails?.Select(x => x.Url).FirstOrDefault() ?? string.Empty,
|
||||||
|
Duration = TimeSpan.FromSeconds(res.LengthSeconds),
|
||||||
|
Platform = MusicPlatform.Youtube,
|
||||||
|
StreamUrl = null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ITrackInfo?> ResolveByIdAsync(string id)
|
||||||
|
=> await InternalResolveByIdAsync(id);
|
||||||
|
|
||||||
|
private async Task<InvTrackInfo?> InternalResolveByIdAsync(string id)
|
||||||
|
{
|
||||||
|
using var http = _httpFactory.CreateClient();
|
||||||
|
|
||||||
|
var res = await http.GetFromJsonAsync<InvidiousVideoResponse>(
|
||||||
|
$"{InvidiousApiUrl}/api/v1/videos/{id}");
|
||||||
|
|
||||||
|
if (res is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new InvTrackInfo()
|
||||||
|
{
|
||||||
|
Id = res.VideoId,
|
||||||
|
Title = res.Title,
|
||||||
|
Url = $"https://youtube.com/watch?v={res.VideoId}",
|
||||||
|
Thumbnail = res.Thumbnails?.Select(x => x.Url).FirstOrDefault() ?? string.Empty,
|
||||||
|
Duration = TimeSpan.FromSeconds(res.LengthSeconds),
|
||||||
|
Platform = MusicPlatform.Youtube,
|
||||||
|
StreamUrl = res.AdaptiveFormats.FirstOrDefault(x => x.AudioQuality == "AUDIO_QUALITY_HIGH")?.Url
|
||||||
|
?? res.AdaptiveFormats.FirstOrDefault(x => x.AudioQuality == "AUDIO_QUALITY_MEDIUM")?.Url
|
||||||
|
?? res.AdaptiveFormats.FirstOrDefault(x => x.AudioQuality == "AUDIO_QUALITY_LOW")?.Url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<ITrackInfo> ResolveTracksFromPlaylistAsync(string query)
|
||||||
|
{
|
||||||
|
using var http = _httpFactory.CreateClient();
|
||||||
|
var res = await http.GetFromJsonAsync<InvidiousPlaylistResponse>(
|
||||||
|
$"{InvidiousApiUrl}/api/v1/search?type=video&q={query}");
|
||||||
|
|
||||||
|
if (res is null)
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
foreach (var video in res.Videos)
|
||||||
|
{
|
||||||
|
yield return new InvTrackInfo()
|
||||||
|
{
|
||||||
|
Id = video.VideoId,
|
||||||
|
Title = video.Title,
|
||||||
|
Url = $"https://youtube.com/watch?v={video.VideoId}",
|
||||||
|
Thumbnail = video.Thumbnails?.Select(x => x.Url).FirstOrDefault() ?? string.Empty,
|
||||||
|
Duration = TimeSpan.FromSeconds(video.LengthSeconds),
|
||||||
|
Platform = MusicPlatform.Youtube,
|
||||||
|
StreamUrl = null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ITrackInfo?> ResolveByQueryAsync(string query, bool tryExtractingId)
|
||||||
|
=> ResolveByQueryAsync(query);
|
||||||
|
|
||||||
|
public async Task<string?> GetStreamUrl(string videoId)
|
||||||
|
{
|
||||||
|
var video = await InternalResolveByIdAsync(videoId);
|
||||||
|
return video?.StreamUrl;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,13 +1,15 @@
|
||||||
namespace EllieBot.Modules.Music;
|
using EllieBot.Modules.Music.Resolvers;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
public sealed class TrackResolveProvider : ITrackResolveProvider
|
public sealed class TrackResolveProvider : ITrackResolveProvider
|
||||||
{
|
{
|
||||||
private readonly IYoutubeResolver _ytResolver;
|
private readonly IYoutubeResolverFactory _ytResolver;
|
||||||
private readonly ILocalTrackResolver _localResolver;
|
private readonly ILocalTrackResolver _localResolver;
|
||||||
private readonly IRadioResolver _radioResolver;
|
private readonly IRadioResolver _radioResolver;
|
||||||
|
|
||||||
public TrackResolveProvider(
|
public TrackResolveProvider(
|
||||||
IYoutubeResolver ytResolver,
|
IYoutubeResolverFactory ytResolver,
|
||||||
ILocalTrackResolver localResolver,
|
ILocalTrackResolver localResolver,
|
||||||
IRadioResolver radioResolver)
|
IRadioResolver radioResolver)
|
||||||
{
|
{
|
||||||
|
@ -23,19 +25,22 @@ public sealed class TrackResolveProvider : ITrackResolveProvider
|
||||||
case MusicPlatform.Radio:
|
case MusicPlatform.Radio:
|
||||||
return _radioResolver.ResolveByQueryAsync(query);
|
return _radioResolver.ResolveByQueryAsync(query);
|
||||||
case MusicPlatform.Youtube:
|
case MusicPlatform.Youtube:
|
||||||
return _ytResolver.ResolveByQueryAsync(query);
|
return _ytResolver.GetYoutubeResolver().ResolveByQueryAsync(query);
|
||||||
case MusicPlatform.Local:
|
case MusicPlatform.Local:
|
||||||
return _localResolver.ResolveByQueryAsync(query);
|
return _localResolver.ResolveByQueryAsync(query);
|
||||||
case null:
|
case null:
|
||||||
var match = _ytResolver.YtVideoIdRegex.Match(query);
|
var match = YoutubeHelpers.YtVideoIdRegex.Match(query);
|
||||||
|
|
||||||
if (match.Success)
|
if (match.Success)
|
||||||
return _ytResolver.ResolveByIdAsync(match.Groups["id"].Value);
|
return _ytResolver.GetYoutubeResolver().ResolveByIdAsync(match.Groups["id"].Value);
|
||||||
else if (Uri.TryCreate(query, UriKind.Absolute, out var uri) && uri.IsFile)
|
|
||||||
|
if (Uri.TryCreate(query, UriKind.Absolute, out var uri) && uri.IsFile)
|
||||||
return _localResolver.ResolveByQueryAsync(uri.AbsolutePath);
|
return _localResolver.ResolveByQueryAsync(uri.AbsolutePath);
|
||||||
else if (IsRadioLink(query))
|
|
||||||
|
if (IsRadioLink(query))
|
||||||
return _radioResolver.ResolveByQueryAsync(query);
|
return _radioResolver.ResolveByQueryAsync(query);
|
||||||
else
|
|
||||||
return _ytResolver.ResolveByQueryAsync(query, false);
|
return _ytResolver.GetYoutubeResolver().ResolveByQueryAsync(query, false);
|
||||||
default:
|
default:
|
||||||
Log.Error("Unsupported platform: {MusicPlatform}", forcePlatform);
|
Log.Error("Unsupported platform: {MusicPlatform}", forcePlatform);
|
||||||
return Task.FromResult<ITrackInfo?>(null);
|
return Task.FromResult<ITrackInfo?>(null);
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public sealed class YoutubeHelpers
|
||||||
|
{
|
||||||
|
public static Regex YtVideoIdRegex { get; } =
|
||||||
|
new(@"(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\?(?:\S*?&?v\=))|youtu\.be\/)(?<id>[a-zA-Z0-9_-]{6,11})",
|
||||||
|
RegexOptions.Compiled);
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using EllieBot.Modules.Searches;
|
||||||
|
using EllieBot.Modules.Searches.Services;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Music.Resolvers;
|
||||||
|
|
||||||
|
public interface IYoutubeResolverFactory
|
||||||
|
{
|
||||||
|
IYoutubeResolver GetYoutubeResolver();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class YoutubeResolverFactory : IYoutubeResolverFactory
|
||||||
|
{
|
||||||
|
private readonly SearchesConfigService _ss;
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
|
||||||
|
public YoutubeResolverFactory(SearchesConfigService ss, IServiceProvider services)
|
||||||
|
{
|
||||||
|
_ss = ss;
|
||||||
|
_services = services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IYoutubeResolver GetYoutubeResolver()
|
||||||
|
{
|
||||||
|
var conf = _ss.Data;
|
||||||
|
if (conf.YtProvider == YoutubeSearcher.Invidious)
|
||||||
|
{
|
||||||
|
return _services.GetRequiredService<InvidiousYoutubeResolver>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return _services.GetRequiredService<YtdlYoutubeResolver>();
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,9 +16,6 @@ public sealed class YtdlYoutubeResolver : IYoutubeResolver
|
||||||
|
|
||||||
private static readonly Regex _simplePlaylistRegex = new(@"&list=(?<id>[\w\-]{12,})", RegexOptions.Compiled);
|
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 ITrackCacher _trackCacher;
|
||||||
|
|
||||||
|
@ -46,7 +43,8 @@ public sealed class YtdlYoutubeResolver : IYoutubeResolver
|
||||||
+ "--no-check-certificate "
|
+ "--no-check-certificate "
|
||||||
+ "-i "
|
+ "-i "
|
||||||
+ "--yes-playlist "
|
+ "--yes-playlist "
|
||||||
+ "-- \"{0}\"", scs.Data.YtProvider != YoutubeSearcher.Ytdl);
|
+ "-- \"{0}\"",
|
||||||
|
scs.Data.YtProvider != YoutubeSearcher.Ytdl);
|
||||||
|
|
||||||
_ytdlIdOperation = new("-4 "
|
_ytdlIdOperation = new("-4 "
|
||||||
+ "--geo-bypass "
|
+ "--geo-bypass "
|
||||||
|
@ -58,7 +56,8 @@ public sealed class YtdlYoutubeResolver : IYoutubeResolver
|
||||||
+ "--get-thumbnail "
|
+ "--get-thumbnail "
|
||||||
+ "--get-duration "
|
+ "--get-duration "
|
||||||
+ "--no-check-certificate "
|
+ "--no-check-certificate "
|
||||||
+ "-- \"{0}\"", scs.Data.YtProvider != YoutubeSearcher.Ytdl);
|
+ "-- \"{0}\"",
|
||||||
|
scs.Data.YtProvider != YoutubeSearcher.Ytdl);
|
||||||
|
|
||||||
_ytdlSearchOperation = new("-4 "
|
_ytdlSearchOperation = new("-4 "
|
||||||
+ "--geo-bypass "
|
+ "--geo-bypass "
|
||||||
|
@ -71,7 +70,8 @@ public sealed class YtdlYoutubeResolver : IYoutubeResolver
|
||||||
+ "--get-duration "
|
+ "--get-duration "
|
||||||
+ "--no-check-certificate "
|
+ "--no-check-certificate "
|
||||||
+ "--default-search "
|
+ "--default-search "
|
||||||
+ "\"ytsearch:\" -- \"{0}\"", scs.Data.YtProvider != YoutubeSearcher.Ytdl);
|
+ "\"ytsearch:\" -- \"{0}\"",
|
||||||
|
scs.Data.YtProvider != YoutubeSearcher.Ytdl);
|
||||||
}
|
}
|
||||||
|
|
||||||
private YtTrackData ResolveYtdlData(string ytdlOutputString)
|
private YtTrackData ResolveYtdlData(string ytdlOutputString)
|
||||||
|
@ -102,8 +102,7 @@ public sealed class YtdlYoutubeResolver : IYoutubeResolver
|
||||||
$"https://youtube.com/watch?v={trackData.Id}",
|
$"https://youtube.com/watch?v={trackData.Id}",
|
||||||
trackData.Thumbnail,
|
trackData.Thumbnail,
|
||||||
trackData.Duration,
|
trackData.Duration,
|
||||||
MusicPlatform.Youtube,
|
MusicPlatform.Youtube);
|
||||||
CreateCacherFactory(trackData.Id));
|
|
||||||
|
|
||||||
private Func<Task<string?>> CreateCacherFactory(string id)
|
private Func<Task<string?>> CreateCacherFactory(string id)
|
||||||
=> () => _trackCacher.GetOrCreateStreamLink(id,
|
=> () => _trackCacher.GetOrCreateStreamLink(id,
|
||||||
|
@ -268,7 +267,7 @@ public sealed class YtdlYoutubeResolver : IYoutubeResolver
|
||||||
{
|
{
|
||||||
if (tryResolving)
|
if (tryResolving)
|
||||||
{
|
{
|
||||||
var match = YtVideoIdRegex.Match(query);
|
var match = YoutubeHelpers.YtVideoIdRegex.Match(query);
|
||||||
if (match.Success)
|
if (match.Success)
|
||||||
return await ResolveByIdAsync(match.Groups["id"].Value);
|
return await ResolveByIdAsync(match.Groups["id"].Value);
|
||||||
}
|
}
|
||||||
|
@ -290,6 +289,8 @@ public sealed class YtdlYoutubeResolver : IYoutubeResolver
|
||||||
return DataToInfo(new(cachedData.Title, cachedData.Id, cachedData.Thumbnail, null, cachedData.Duration));
|
return DataToInfo(new(cachedData.Title, cachedData.Id, cachedData.Thumbnail, null, cachedData.Duration));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<string?> GetStreamUrl(string videoId)
|
||||||
|
=> CreateCacherFactory(videoId)();
|
||||||
private readonly struct YtTrackData
|
private readonly struct YtTrackData
|
||||||
{
|
{
|
||||||
public readonly string Title;
|
public readonly string Title;
|
||||||
|
|
|
@ -72,9 +72,9 @@ public partial class Searches
|
||||||
}
|
}
|
||||||
|
|
||||||
[Cmd]
|
[Cmd]
|
||||||
public async Task Image([Leftover] string? query)
|
public async Task Image([Leftover] string query)
|
||||||
{
|
{
|
||||||
query = query?.Trim();
|
query = query.Trim();
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(query))
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
{
|
{
|
||||||
|
@ -106,22 +106,25 @@ public partial class Searches
|
||||||
.WithImageUrl(entry.Link);
|
.WithImageUrl(entry.Link);
|
||||||
}
|
}
|
||||||
|
|
||||||
embeds.Add(CreateEmbed(data.Entries.First())
|
await Response()
|
||||||
.WithFooter(
|
.Paginated()
|
||||||
GetText(strs.results_in(data.Info.TotalResults, data.Info.SearchTime)),
|
.Items(data.Entries)
|
||||||
"https://i.imgur.com/G46fm8J.png"));
|
.PageSize(1)
|
||||||
|
.AddFooter(false)
|
||||||
var random = data.Entries.Skip(1)
|
.Page((items, _) =>
|
||||||
.Shuffle()
|
|
||||||
.Take(3)
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
foreach (var entry in random)
|
|
||||||
{
|
{
|
||||||
embeds.Add(CreateEmbed(entry));
|
var item = items.FirstOrDefault();
|
||||||
}
|
|
||||||
|
|
||||||
await Response().Embeds(embeds).SendAsync();
|
if (item is null)
|
||||||
|
return _sender.CreateEmbed()
|
||||||
|
.WithDescription(GetText(strs.no_search_results));
|
||||||
|
|
||||||
|
var embed = CreateEmbed(item);
|
||||||
|
embeds.Add(embed);
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
})
|
||||||
|
.SendAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private TypedKey<string> GetYtCacheKey(string query)
|
private TypedKey<string> GetYtCacheKey(string query)
|
||||||
|
@ -158,7 +161,7 @@ public partial class Searches
|
||||||
|
|
||||||
var maybeResult = await GetYoutubeUrlFromCacheAsync(query)
|
var maybeResult = await GetYoutubeUrlFromCacheAsync(query)
|
||||||
?? await _searchFactory.GetYoutubeSearchService().SearchAsync(query);
|
?? await _searchFactory.GetYoutubeSearchService().SearchAsync(query);
|
||||||
if (maybeResult is not {} result || result is {Url: null})
|
if (maybeResult is not { } result || result is { Url: null })
|
||||||
{
|
{
|
||||||
await Response().Error(strs.no_results).SendAsync();
|
await Response().Error(strs.no_results).SendAsync();
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -5,5 +5,59 @@ namespace EllieBot.Modules.Searches;
|
||||||
public sealed class InvidiousSearchResponse
|
public sealed class InvidiousSearchResponse
|
||||||
{
|
{
|
||||||
[JsonPropertyName("videoId")]
|
[JsonPropertyName("videoId")]
|
||||||
public string VideoId { get; set; } = null!;
|
public required string VideoId { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("title")]
|
||||||
|
public required string Title { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("videoThumbnails")]
|
||||||
|
public required List<InvidiousThumbnail> Thumbnails { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("lengthSeconds")]
|
||||||
|
public required int LengthSeconds { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("description")]
|
||||||
|
public required string Description { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class InvidiousVideoResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("title")]
|
||||||
|
public required string Title { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("videoId")]
|
||||||
|
public required string VideoId { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("lengthSeconds")]
|
||||||
|
public required int LengthSeconds { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("videoThumbnails")]
|
||||||
|
public required List<InvidiousThumbnail> Thumbnails { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("adaptiveFormats")]
|
||||||
|
public required List<InvidiousAdaptiveFormat> AdaptiveFormats { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class InvidiousAdaptiveFormat
|
||||||
|
{
|
||||||
|
[JsonPropertyName("url")]
|
||||||
|
public required string Url { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("audioQuality")]
|
||||||
|
public string? AudioQuality { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class InvidiousPlaylistResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("title")]
|
||||||
|
public required string Title { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("videos")]
|
||||||
|
public required List<InvidiousVideoResponse> Videos { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class InvidiousThumbnail
|
||||||
|
{
|
||||||
|
[JsonPropertyName("url")]
|
||||||
|
public required string Url { get; init; }
|
||||||
}
|
}
|
|
@ -32,13 +32,14 @@ public sealed class InvidiousYtSearchService : IYoutubeSearchService, IEService
|
||||||
|
|
||||||
var instance = instances[_rng.Next(0, instances.Count)];
|
var instance = instances[_rng.Next(0, instances.Count)];
|
||||||
|
|
||||||
|
var url = $"{instance}/api/v1/search"
|
||||||
|
+ $"?q={query}"
|
||||||
|
+ $"&type=video";
|
||||||
using var http = _http.CreateClient();
|
using var http = _http.CreateClient();
|
||||||
var res = await http.GetFromJsonAsync<List<InvidiousSearchResponse>>(
|
var res = await http.GetFromJsonAsync<List<InvidiousSearchResponse>>(
|
||||||
$"{instance}/api/v1/search"
|
url);
|
||||||
+ $"?q={query}"
|
|
||||||
+ $"&type=video");
|
|
||||||
|
|
||||||
if (res is null or {Count: 0})
|
if (res is null or { Count: 0 })
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return new VideoInfo(res[0].VideoId);
|
return new VideoInfo(res[0].VideoId);
|
||||||
|
|
|
@ -7,7 +7,7 @@ namespace EllieBot.Modules.Searches;
|
||||||
public partial class SearchesConfig : ICloneable<SearchesConfig>
|
public partial class SearchesConfig : ICloneable<SearchesConfig>
|
||||||
{
|
{
|
||||||
[Comment("DO NOT CHANGE")]
|
[Comment("DO NOT CHANGE")]
|
||||||
public int Version { get; set; } = 0;
|
public int Version { get; set; } = 3;
|
||||||
|
|
||||||
[Comment("""
|
[Comment("""
|
||||||
Which engine should .search command
|
Which engine should .search command
|
||||||
|
@ -26,7 +26,7 @@ public partial class SearchesConfig : ICloneable<SearchesConfig>
|
||||||
|
|
||||||
|
|
||||||
[Comment("""
|
[Comment("""
|
||||||
Which search provider will be used for the `.youtube` command.
|
Which search provider will be used for the `.youtube` and `.q` commands.
|
||||||
|
|
||||||
- `ytDataApiv3` - uses google's official youtube data api. Requires `GoogleApiKey` set in creds and youtube data api enabled in developers console
|
- `ytDataApiv3` - uses google's official youtube data api. Requires `GoogleApiKey` set in creds and youtube data api enabled in developers console
|
||||||
|
|
||||||
|
@ -58,7 +58,6 @@ public partial class SearchesConfig : ICloneable<SearchesConfig>
|
||||||
[Comment("""
|
[Comment("""
|
||||||
Set the invidious instance urls in case you want to use 'invidious' for `.youtube` search
|
Set the invidious instance urls in case you want to use 'invidious' for `.youtube` search
|
||||||
Ellie will use a random one for each request.
|
Ellie will use a random one for each request.
|
||||||
These instances may be used for music queue functionality in the future.
|
|
||||||
Use a fully qualified url. Example: https://my-invidious-instance.mydomain.com
|
Use a fully qualified url. Example: https://my-invidious-instance.mydomain.com
|
||||||
|
|
||||||
Instances specified must have api available.
|
Instances specified must have api available.
|
||||||
|
|
|
@ -54,5 +54,13 @@ public class SearchesConfigService : ConfigServiceBase<SearchesConfig>
|
||||||
c.Version = 2;
|
c.Version = 2;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.Version < 3)
|
||||||
|
{
|
||||||
|
ModifyConfig(c =>
|
||||||
|
{
|
||||||
|
c.Version = 3;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -445,9 +445,10 @@ public partial class Xp : EllieModule<XpService>
|
||||||
if (!string.IsNullOrWhiteSpace(item.Desc))
|
if (!string.IsNullOrWhiteSpace(item.Desc))
|
||||||
eb.AddField(GetText(strs.desc), item.Desc);
|
eb.AddField(GetText(strs.desc), item.Desc);
|
||||||
|
|
||||||
|
#if GLOBAL_ELLIE
|
||||||
if (key == "default")
|
if (key == "default")
|
||||||
eb.WithDescription(GetText(strs.xpshop_website));
|
eb.WithDescription(GetText(strs.xpshop_website));
|
||||||
|
#endif
|
||||||
|
|
||||||
var tier = _service.GetXpShopTierRequirement(type);
|
var tier = _service.GetXpShopTierRequirement(type);
|
||||||
if (tier != PatronTier.None)
|
if (tier != PatronTier.None)
|
||||||
|
|
|
@ -54,7 +54,6 @@ public sealed class ReplacementContext
|
||||||
public ReplacementContext WithOverride(string key, Func<string> repFactory)
|
public ReplacementContext WithOverride(string key, Func<string> repFactory)
|
||||||
=> WithOverride(key, () => new ValueTask<string>(repFactory()));
|
=> WithOverride(key, () => new ValueTask<string>(repFactory()));
|
||||||
|
|
||||||
|
|
||||||
public ReplacementContext WithOverride(Regex regex, Func<Match, ValueTask<string>> repFactory)
|
public ReplacementContext WithOverride(Regex regex, Func<Match, ValueTask<string>> repFactory)
|
||||||
{
|
{
|
||||||
if (_regexPatterns.Add(regex.ToString()))
|
if (_regexPatterns.Add(regex.ToString()))
|
||||||
|
|
|
@ -51,7 +51,9 @@ public static class ServiceCollectionExtensions
|
||||||
svcs.RegisterMany<MusicService>(Reuse.Singleton);
|
svcs.RegisterMany<MusicService>(Reuse.Singleton);
|
||||||
|
|
||||||
svcs.AddSingleton<ITrackResolveProvider, TrackResolveProvider>();
|
svcs.AddSingleton<ITrackResolveProvider, TrackResolveProvider>();
|
||||||
svcs.AddSingleton<IYoutubeResolver, YtdlYoutubeResolver>();
|
svcs.AddSingleton<YtdlYoutubeResolver>();
|
||||||
|
svcs.AddSingleton<InvidiousYoutubeResolver>();
|
||||||
|
svcs.AddSingleton<IYoutubeResolverFactory, YoutubeResolverFactory>();
|
||||||
svcs.AddSingleton<ILocalTrackResolver, LocalTrackResolver>();
|
svcs.AddSingleton<ILocalTrackResolver, LocalTrackResolver>();
|
||||||
svcs.AddSingleton<IRadioResolver, RadioResolver>();
|
svcs.AddSingleton<IRadioResolver, RadioResolver>();
|
||||||
svcs.AddSingleton<ITrackCacher, TrackCacher>();
|
svcs.AddSingleton<ITrackCacher, TrackCacher>();
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# DO NOT CHANGE
|
# DO NOT CHANGE
|
||||||
version: 2
|
version: 3
|
||||||
# Which engine should .search command
|
# Which engine should .search command
|
||||||
# 'google_scrape' - default. Scrapes the webpage for results. May break. Requires no api keys.
|
# 'google_scrape' - default. Scrapes the webpage for results. May break. Requires no api keys.
|
||||||
# 'google' - official google api. Requires googleApiKey and google.searchId set in creds.yml
|
# 'google' - official google api. Requires googleApiKey and google.searchId set in creds.yml
|
||||||
|
@ -9,7 +9,7 @@ webSearchEngine: Google_Scrape
|
||||||
# 'google'- official google api. googleApiKey and google.imageSearchId set in creds.yml
|
# 'google'- official google api. googleApiKey and google.imageSearchId set in creds.yml
|
||||||
# 'searx' requires at least one searx instance specified in the 'searxInstances' property below
|
# 'searx' requires at least one searx instance specified in the 'searxInstances' property below
|
||||||
imgSearchEngine: Google
|
imgSearchEngine: Google
|
||||||
# Which search provider will be used for the `.youtube` command.
|
# Which search provider will be used for the `.youtube` and `.q` commands.
|
||||||
#
|
#
|
||||||
# - `ytDataApiv3` - uses google's official youtube data api. Requires `GoogleApiKey` set in creds and youtube data api enabled in developers console
|
# - `ytDataApiv3` - uses google's official youtube data api. Requires `GoogleApiKey` set in creds and youtube data api enabled in developers console
|
||||||
#
|
#
|
||||||
|
@ -35,7 +35,6 @@ ytProvider: Ytdlp
|
||||||
searxInstances: []
|
searxInstances: []
|
||||||
# Set the invidious instance urls in case you want to use 'invidious' for `.youtube` search
|
# Set the invidious instance urls in case you want to use 'invidious' for `.youtube` search
|
||||||
# Ellie will use a random one for each request.
|
# Ellie will use a random one for each request.
|
||||||
# These instances may be used for music queue functionality in the future.
|
|
||||||
# Use a fully qualified url. Example: https://my-invidious-instance.mydomain.com
|
# Use a fully qualified url. Example: https://my-invidious-instance.mydomain.com
|
||||||
#
|
#
|
||||||
# Instances specified must have api available.
|
# Instances specified must have api available.
|
||||||
|
|
Loading…
Reference in a new issue