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:
Toastie (DCS Team) 2024-09-20 00:38:05 +12:00
parent 6d0eac2d6f
commit 1ea0e63379
Signed by: toastie_t0ast
GPG key ID: 27F3B6855AFD40A4
25 changed files with 345 additions and 104 deletions

View file

@ -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>

View file

@ -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;

View file

@ -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();

View file

@ -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,

View file

@ -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();
} }

View file

@ -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);
} }

View file

@ -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);
@ -406,19 +420,19 @@ public sealed class MusicPlayer : IMusicPlayer
break; break;
await chunk.Select(async data => await chunk.Select(async data =>
{
var (query, platform) = data;
try
{ {
var (query, platform) = data; await TryEnqueueTrackAsync(query, queuer, false, platform);
try errorCount = 0;
{ }
await TryEnqueueTrackAsync(query, queuer, false, platform); catch (Exception ex)
errorCount = 0; {
} Log.Warning(ex, "Error resolving {MusicPlatform} Track {TrackQuery}", platform, query);
catch (Exception ex) ++errorCount;
{ }
Log.Warning(ex, "Error resolving {MusicPlatform} Track {TrackQuery}", platform, query); })
++errorCount;
}
})
.WhenAll(); .WhenAll();
await Task.Delay(1000); await Task.Delay(1000);

View file

@ -28,9 +28,6 @@ public sealed partial class MusicQueue
TrackInfo = trackInfo; TrackInfo = trackInfo;
Queuer = queuer; Queuer = queuer;
} }
public ValueTask<string?> GetStreamUrl()
=> TrackInfo.GetStreamUrl();
} }
} }

View file

@ -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();
}

View file

@ -24,7 +24,4 @@ public sealed class SimpleTrackInfo : ITrackInfo
Platform = platform; Platform = platform;
StreamUrl = streamUrl; StreamUrl = streamUrl;
} }
public ValueTask<string?> GetStreamUrl()
=> new(StreamUrl);
} }

View 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; }
}

View file

@ -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;
}
}

View file

@ -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);

View file

@ -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);
}

View file

@ -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>();
}
}

View file

@ -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;
@ -32,7 +29,7 @@ public sealed class YtdlYoutubeResolver : IYoutubeResolver
{ {
_trackCacher = trackCacher; _trackCacher = trackCacher;
_google = google; _google = google;
_ytdlPlaylistOperation = new("-4 " _ytdlPlaylistOperation = new("-4 "
+ "--geo-bypass " + "--geo-bypass "
@ -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;

View file

@ -60,21 +60,21 @@ public partial class Searches
descStr = descStr.TrimTo(4096); descStr = descStr.TrimTo(4096);
var embed = _sender.CreateEmbed() var embed = _sender.CreateEmbed()
.WithOkColor() .WithOkColor()
.WithAuthor(ctx.User) .WithAuthor(ctx.User)
.WithTitle(query.TrimTo(64)!) .WithTitle(query.TrimTo(64)!)
.WithDescription(descStr) .WithDescription(descStr)
.WithFooter( .WithFooter(
GetText(strs.results_in(data.Info.TotalResults, data.Info.SearchTime)), GetText(strs.results_in(data.Info.TotalResults, data.Info.SearchTime)),
"https://i.imgur.com/G46fm8J.png"); "https://i.imgur.com/G46fm8J.png");
await Response().Embed(embed).SendAsync(); await Response().Embed(embed).SendAsync();
} }
[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))
{ {
@ -99,29 +99,32 @@ public partial class Searches
EmbedBuilder CreateEmbed(IImageSearchResultEntry entry) EmbedBuilder CreateEmbed(IImageSearchResultEntry entry)
{ {
return _sender.CreateEmbed() return _sender.CreateEmbed()
.WithOkColor() .WithOkColor()
.WithAuthor(ctx.User) .WithAuthor(ctx.User)
.WithTitle(query) .WithTitle(query)
.WithUrl("https://google.com") .WithUrl("https://google.com")
.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)
.Page((items, _) =>
{
var item = items.FirstOrDefault();
var random = data.Entries.Skip(1) if (item is null)
.Shuffle() return _sender.CreateEmbed()
.Take(3) .WithDescription(GetText(strs.no_search_results));
.ToArray();
foreach (var entry in random) var embed = CreateEmbed(item);
{ embeds.Add(embed);
embeds.Add(CreateEmbed(entry));
}
await Response().Embeds(embeds).SendAsync(); 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;

View file

@ -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; }
} }

View file

@ -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);

View file

@ -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.

View file

@ -54,5 +54,13 @@ public class SearchesConfigService : ConfigServiceBase<SearchesConfig>
c.Version = 2; c.Version = 2;
}); });
} }
if (data.Version < 3)
{
ModifyConfig(c =>
{
c.Version = 3;
});
}
} }
} }

View file

@ -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)

View file

@ -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()))

View file

@ -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>();

View file

@ -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,14 +9,14 @@ 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
# #
# - `ytdl` - default, uses youtube-dl. Requires `youtube-dl` to be installed and it's path added to env variables. Slow. # - `ytdl` - default, uses youtube-dl. Requires `youtube-dl` to be installed and it's path added to env variables. Slow.
# #
# - `ytdlp` - recommended easy, uses `yt-dlp`. Requires `yt-dlp` to be installed and it's path added to env variables # - `ytdlp` - recommended easy, uses `yt-dlp`. Requires `yt-dlp` to be installed and it's path added to env variables
# #
# - `invidious` - recommended advanced, uses invidious api. Requires at least one invidious instance specified in the `invidiousInstances` property # - `invidious` - recommended advanced, uses invidious api. Requires at least one invidious instance specified in the `invidiousInstances` property
ytProvider: Ytdlp ytProvider: Ytdlp
# Set the searx instance urls in case you want to use 'searx' for either img or web search. # Set the searx instance urls in case you want to use 'searx' for either img or web search.
@ -24,20 +24,19 @@ ytProvider: Ytdlp
# Use a fully qualified url. Example: `https://my-searx-instance.mydomain.com` # Use a fully qualified url. Example: `https://my-searx-instance.mydomain.com`
# Instances specified must support 'format=json' query parameter. # Instances specified must support 'format=json' query parameter.
# - In case you're running your own searx instance, set # - In case you're running your own searx instance, set
# #
# search: # search:
# formats: # formats:
# - json # - json
# #
# in 'searxng/settings.yml' on your server # in 'searxng/settings.yml' on your server
# #
# - If you're using a public instance, make sure that the instance you're using supports it (they usually don't) # - If you're using a public instance, make sure that the instance you're using supports it (they usually don't)
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.
# You check that by opening an api endpoint in your browser. For example: https://my-invidious-instance.mydomain.com/api/v1/trending # You check that by opening an api endpoint in your browser. For example: https://my-invidious-instance.mydomain.com/api/v1/trending
invidiousInstances: [] invidiousInstances: []