diff --git a/Kyoo.Common/Controllers/IMetadataProvider.cs b/Kyoo.Common/Controllers/IMetadataProvider.cs index f17e2d8d..7f1bac18 100644 --- a/Kyoo.Common/Controllers/IMetadataProvider.cs +++ b/Kyoo.Common/Controllers/IMetadataProvider.cs @@ -53,4 +53,18 @@ namespace Kyoo.Controllers Task> GetPeople(Show show); } + + /// + /// A special that merge results. + /// This interface exists to specify witch provider to use but it can be used like any other metadata provider. + /// + public interface IProviderComposite : IMetadataProvider + { + /// + /// Select witch providers to use. + /// The associated with the given will be used. + /// + /// The list of providers to use + void UseProviders(IEnumerable providers); + } } diff --git a/Kyoo.Common/Utility/Merger.cs b/Kyoo.Common/Utility/Merger.cs index 55cc17e3..a1f35756 100644 --- a/Kyoo.Common/Utility/Merger.cs +++ b/Kyoo.Common/Utility/Merger.cs @@ -5,6 +5,7 @@ using System.ComponentModel; using System.Linq; using System.Reflection; using JetBrains.Annotations; +using Kyoo.Models; using Kyoo.Models.Attributes; namespace Kyoo @@ -111,7 +112,8 @@ namespace Kyoo /// Missing fields of first will be completed by fields of this item. If second is null, the function no-op. /// Fields of T will be merged /// - public static T Merge(T first, T second) + [ContractAnnotation("=> null; first:notnull => notnull; second:notnull => notnull")] + public static T Merge([CanBeNull] T first, [CanBeNull] T second) { if (first == null) return second; @@ -127,9 +129,7 @@ namespace Kyoo { object oldValue = property.GetValue(first); object newValue = property.GetValue(second); - object defaultValue = property.PropertyType.IsValueType - ? Activator.CreateInstance(property.PropertyType) - : null; + object defaultValue = property.PropertyType.GetClrDefault(); if (oldValue?.Equals(defaultValue) != false) property.SetValue(first, newValue); @@ -139,11 +139,14 @@ namespace Kyoo Type enumerableType = Utility.GetGenericDefinition(property.PropertyType, typeof(IEnumerable<>)) .GenericTypeArguments .First(); + Func equalityComparer = enumerableType.IsAssignableTo(typeof(IResource)) + ? (x, y) => x.Slug == y.Slug + : null; property.SetValue(first, Utility.RunGenericMethod( - typeof(Utility), + typeof(Merger), nameof(MergeLists), - enumerableType, - oldValue, newValue, null)); + enumerableType, + oldValue, newValue, equalityComparer)); } } diff --git a/Kyoo.Common/Utility/Utility.cs b/Kyoo.Common/Utility/Utility.cs index f7041c51..f7c1a94b 100644 --- a/Kyoo.Common/Utility/Utility.cs +++ b/Kyoo.Common/Utility/Utility.cs @@ -182,15 +182,49 @@ namespace Kyoo return types.FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType); } - public static MethodInfo GetMethod(Type type, BindingFlags flag, string name, Type[] generics, object[] args) + /// + /// Retrieve a method from an with the given name and respect the + /// amount of parameters and generic parameters. This works for polymorphic methods. + /// + /// + /// The type owning the method. For non static methods, this is the this. + /// + /// + /// The binding flags of the method. This allow you to specify public/private and so on. + /// + /// + /// The name of the method. + /// + /// + /// The list of generic parameters. + /// + /// + /// The list of parameters. + /// + /// No method match the given constraints. + /// The method handle of the matching method. + [PublicAPI] + [NotNull] + public static MethodInfo GetMethod([NotNull] Type type, + BindingFlags flag, + string name, + [NotNull] Type[] generics, + [NotNull] object[] args) { + if (type == null) + throw new ArgumentNullException(nameof(type)); + if (generics == null) + throw new ArgumentNullException(nameof(generics)); + if (args == null) + throw new ArgumentNullException(nameof(args)); + MethodInfo[] methods = type.GetMethods(flag | BindingFlags.Public) .Where(x => x.Name == name) .Where(x => x.GetGenericArguments().Length == generics.Length) .Where(x => x.GetParameters().Length == args.Length) - .IfEmpty(() => throw new NullReferenceException($"A method named {name} with " + - $"{args.Length} arguments and {generics.Length} generic " + - $"types could not be found on {type.Name}.")) + .IfEmpty(() => throw new ArgumentException($"A method named {name} with " + + $"{args.Length} arguments and {generics.Length} generic " + + $"types could not be found on {type.Name}.")) // TODO this won't work but I don't know why. // .Where(x => // { @@ -211,9 +245,34 @@ namespace Kyoo if (methods.Length == 1) return methods[0]; - throw new NullReferenceException($"Multiple methods named {name} match the generics and parameters constraints."); + throw new ArgumentException($"Multiple methods named {name} match the generics and parameters constraints."); } + /// + /// Run a generic static method for a runtime . + /// + /// + /// To run for a List where you don't know the type at compile type, + /// you could do: + /// + /// Utility.RunGenericMethod<object>( + /// typeof(Utility), + /// nameof(MergeLists), + /// enumerableType, + /// oldValue, newValue, equalityComparer) + /// + /// + /// The type that owns the method. For non static methods, the type of this. + /// The name of the method. You should use the nameof keyword. + /// The generic type to run the method with. + /// The list of arguments of the method + /// + /// The return type of the method. You can put for an unknown one. + /// + /// No method match the given constraints. + /// The return of the method you wanted to run. + /// + /// public static T RunGenericMethod( [NotNull] Type owner, [NotNull] string methodName, @@ -223,6 +282,34 @@ namespace Kyoo return RunGenericMethod(owner, methodName, new[] {type}, args); } + /// + /// Run a generic static method for a multiple runtime . + /// If your generic method only needs one type, see + /// + /// + /// + /// To run for a List where you don't know the type at compile type, + /// you could do: + /// + /// Utility.RunGenericMethod<object>( + /// typeof(Utility), + /// nameof(MergeLists), + /// enumerableType, + /// oldValue, newValue, equalityComparer) + /// + /// + /// The type that owns the method. For non static methods, the type of this. + /// The name of the method. You should use the nameof keyword. + /// The list of generic types to run the method with. + /// The list of arguments of the method + /// + /// The return type of the method. You can put for an unknown one. + /// + /// No method match the given constraints. + /// The return of the method you wanted to run. + /// + /// + [PublicAPI] public static T RunGenericMethod( [NotNull] Type owner, [NotNull] string methodName, @@ -238,9 +325,34 @@ namespace Kyoo if (types.Length < 1) throw new ArgumentException($"The {nameof(types)} array is empty. At least one type is needed."); MethodInfo method = GetMethod(owner, BindingFlags.Static, methodName, types, args); - return (T)method.MakeGenericMethod(types).Invoke(null, args?.ToArray()); + return (T)method.MakeGenericMethod(types).Invoke(null, args.ToArray()); } + /// + /// Run a generic method for a runtime . + /// + /// + /// To run for a List where you don't know the type at compile type, + /// you could do: + /// + /// Utility.RunGenericMethod<object>( + /// typeof(Utility), + /// nameof(MergeLists), + /// enumerableType, + /// oldValue, newValue, equalityComparer) + /// + /// + /// The this of the method to run. + /// The name of the method. You should use the nameof keyword. + /// The generic type to run the method with. + /// The list of arguments of the method + /// + /// The return type of the method. You can put for an unknown one. + /// + /// No method match the given constraints. + /// The return of the method you wanted to run. + /// + /// public static T RunGenericMethod( [NotNull] object instance, [NotNull] string methodName, @@ -250,6 +362,33 @@ namespace Kyoo return RunGenericMethod(instance, methodName, new[] {type}, args); } + /// + /// Run a generic method for a multiple runtime . + /// If your generic method only needs one type, see + /// + /// + /// + /// To run for a List where you don't know the type at compile type, + /// you could do: + /// + /// Utility.RunGenericMethod<object>( + /// typeof(Utility), + /// nameof(MergeLists), + /// enumerableType, + /// oldValue, newValue, equalityComparer) + /// + /// + /// The this of the method to run. + /// The name of the method. You should use the nameof keyword. + /// The list of generic types to run the method with. + /// The list of arguments of the method + /// + /// The return type of the method. You can put for an unknown one. + /// + /// No method match the given constraints. + /// The return of the method you wanted to run. + /// + /// public static T RunGenericMethod( [NotNull] object instance, [NotNull] string methodName, @@ -263,7 +402,7 @@ namespace Kyoo if (types == null || types.Length == 0) throw new ArgumentNullException(nameof(types)); MethodInfo method = GetMethod(instance.GetType(), BindingFlags.Instance, methodName, types, args); - return (T)method.MakeGenericMethod(types).Invoke(instance, args?.ToArray()); + return (T)method.MakeGenericMethod(types).Invoke(instance, args.ToArray()); } public static string ToQueryString(this Dictionary query) diff --git a/Kyoo.Tests/Utility/MergerTests.cs b/Kyoo.Tests/Utility/MergerTests.cs index 614d328f..008daee1 100644 --- a/Kyoo.Tests/Utility/MergerTests.cs +++ b/Kyoo.Tests/Utility/MergerTests.cs @@ -1,4 +1,8 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; using Kyoo.Models; +using Kyoo.Models.Attributes; using Xunit; namespace Kyoo.Tests @@ -17,5 +21,192 @@ namespace Kyoo.Tests Assert.Null(genre.Name); Assert.Null(genre.Slug); } + + [Fact] + public void MergeTest() + { + Genre genre = new() + { + ID = 5 + }; + Genre genre2 = new() + { + Name = "test" + }; + Genre ret = Merger.Merge(genre, genre2); + Assert.True(ReferenceEquals(genre, ret)); + Assert.Equal(5, ret.ID); + Assert.Equal("test", genre.Name); + Assert.Null(genre.Slug); + } + + [Fact] + [SuppressMessage("ReSharper", "ExpressionIsAlwaysNull")] + public void MergeNullTests() + { + Genre genre = new() + { + ID = 5 + }; + Assert.True(ReferenceEquals(genre, Merger.Merge(genre, null))); + Assert.True(ReferenceEquals(genre, Merger.Merge(null, genre))); + Assert.Null(Merger.Merge(null, null)); + } + + private class TestIOnMerge : IOnMerge + { + public void OnMerge(object other) + { + Exception exception = new(); + exception.Data[0] = other; + throw exception; + } + } + + [Fact] + public void OnMergeTest() + { + TestIOnMerge test = new(); + TestIOnMerge test2 = new(); + Assert.Throws(() => Merger.Merge(test, test2)); + try + { + Merger.Merge(test, test2); + } + catch (Exception ex) + { + Assert.True(ReferenceEquals(test2, ex.Data[0])); + } + } + + private class Test + { + public int ID { get; set; } + + public int[] Numbers { get; set; } + } + + [Fact] + public void GlobalMergeListTest() + { + Test test = new() + { + ID = 5, + Numbers = new [] { 1 } + }; + Test test2 = new() + { + Numbers = new [] { 3 } + }; + Test ret = Merger.Merge(test, test2); + Assert.True(ReferenceEquals(test, ret)); + Assert.Equal(5, ret.ID); + + Assert.Equal(2, ret.Numbers.Length); + Assert.Equal(1, ret.Numbers[0]); + Assert.Equal(3, ret.Numbers[1]); + } + + [Fact] + public void GlobalMergeListDuplicatesTest() + { + Test test = new() + { + ID = 5, + Numbers = new [] { 1 } + }; + Test test2 = new() + { + Numbers = new [] + { + 1, + 3, + 3 + } + }; + Test ret = Merger.Merge(test, test2); + Assert.True(ReferenceEquals(test, ret)); + Assert.Equal(5, ret.ID); + + Assert.Equal(4, ret.Numbers.Length); + Assert.Equal(1, ret.Numbers[0]); + Assert.Equal(1, ret.Numbers[1]); + Assert.Equal(3, ret.Numbers[2]); + Assert.Equal(3, ret.Numbers[3]); + } + + [Fact] + public void GlobalMergeListDuplicatesResourcesTest() + { + Show test = new() + { + ID = 5, + Genres = new [] { new Genre("test") } + }; + Show test2 = new() + { + Genres = new [] + { + new Genre("test"), + new Genre("test2") + } + }; + Show ret = Merger.Merge(test, test2); + Assert.True(ReferenceEquals(test, ret)); + Assert.Equal(5, ret.ID); + + Assert.Equal(2, ret.Genres.Count); + Assert.Equal("test", ret.Genres.ToArray()[0].Slug); + Assert.Equal("test2", ret.Genres.ToArray()[1].Slug); + } + + [Fact] + public void MergeListTest() + { + int[] first = { 1 }; + int[] second = { + 3, + 3 + }; + int[] ret = Merger.MergeLists(first, second); + + Assert.Equal(3, ret.Length); + Assert.Equal(1, ret[0]); + Assert.Equal(3, ret[1]); + Assert.Equal(3, ret[2]); + } + + [Fact] + public void MergeListDuplicateTest() + { + int[] first = { 1 }; + int[] second = { + 1, + 3, + 3 + }; + int[] ret = Merger.MergeLists(first, second); + + Assert.Equal(4, ret.Length); + Assert.Equal(1, ret[0]); + Assert.Equal(1, ret[1]); + Assert.Equal(3, ret[2]); + Assert.Equal(3, ret[3]); + } + + [Fact] + public void MergeListDuplicateCustomEqualityTest() + { + int[] first = { 1 }; + int[] second = { + 3, + 2 + }; + int[] ret = Merger.MergeLists(first, second, (x, y) => x % 2 == y % 2); + + Assert.Equal(2, ret.Length); + Assert.Equal(1, ret[0]); + Assert.Equal(2, ret[1]); + } } } \ No newline at end of file diff --git a/Kyoo.Tests/Utility/UtilityTests.cs b/Kyoo.Tests/Utility/UtilityTests.cs index 15469411..80d233c3 100644 --- a/Kyoo.Tests/Utility/UtilityTests.cs +++ b/Kyoo.Tests/Utility/UtilityTests.cs @@ -1,5 +1,6 @@ using System; using System.Linq.Expressions; +using System.Reflection; using Kyoo.Models; using Xunit; @@ -31,5 +32,47 @@ namespace Kyoo.Tests Assert.Equal("ID", Utility.GetPropertyName(memberCast)); Assert.Throws(() => Utility.GetPropertyName(null)); } + + [Fact] + public void GetMethodTest() + { + MethodInfo method = Utility.GetMethod(typeof(UtilityTests), + BindingFlags.Instance | BindingFlags.Public, + nameof(GetMethodTest), + Array.Empty(), + Array.Empty()); + Assert.Equal(MethodBase.GetCurrentMethod(), method); + } + + [Fact] + public void GetMethodInvalidGenericsTest() + { + Assert.Throws(() => Utility.GetMethod(typeof(UtilityTests), + BindingFlags.Instance | BindingFlags.Public, + nameof(GetMethodTest), + new [] { typeof(Utility) }, + Array.Empty())); + } + + [Fact] + public void GetMethodInvalidParamsTest() + { + Assert.Throws(() => Utility.GetMethod(typeof(UtilityTests), + BindingFlags.Instance | BindingFlags.Public, + nameof(GetMethodTest), + Array.Empty(), + new object[] { this })); + } + + [Fact] + public void GetMethodTest2() + { + MethodInfo method = Utility.GetMethod(typeof(Merger), + BindingFlags.Static | BindingFlags.Public, + nameof(Merger.MergeLists), + new [] { typeof(string) }, + new object[] { "string", "string2", null }); + Assert.Equal(nameof(Merger.MergeLists), method.Name); + } } } \ No newline at end of file diff --git a/Kyoo/Controllers/ProviderComposite.cs b/Kyoo/Controllers/ProviderComposite.cs index 9b7e498b..7d62266e 100644 --- a/Kyoo/Controllers/ProviderComposite.cs +++ b/Kyoo/Controllers/ProviderComposite.cs @@ -1,86 +1,126 @@ using System; using Kyoo.Models; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; namespace Kyoo.Controllers { - public class ProviderComposite : IMetadataProvider + /// + /// A metadata provider composite that merge results from all available providers. + /// + public class ProviderComposite : IProviderComposite { - private readonly IEnumerable _providers; + /// + /// The list of metadata providers + /// + private readonly ICollection _providers; + /// + /// The list of selected providers. If no provider has been selected, this is null. + /// + private ICollection _selectedProviders; + + /// + /// The logger used to print errors. + /// + private readonly ILogger _logger; - public ProviderComposite(IEnumerable providers) + /// + /// Since this is a composite and not a real provider, no metadata is available. + /// It is not meant to be stored or selected. This class will handle merge based on what is required. + /// + public Provider Provider => null; + + + /// + /// Create a new with a list of available providers. + /// + /// The list of providers to merge. + /// The logger used to print errors. + public ProviderComposite(IEnumerable providers, ILogger logger) { - _providers = providers; + _providers = providers.ToArray(); + _logger = logger; + } + + + /// + public void UseProviders(IEnumerable providers) + { + _selectedProviders = providers.ToArray(); } - public Provider Provider { get; } - public Task Get(T item) where T : class, IResource + /// + /// Return the list of providers that should be used for queries. + /// + /// The list of providers to use, respecting the . + private IEnumerable _GetProviders() { - throw new NotImplementedException(); + return _selectedProviders? + .Select(x => _providers.FirstOrDefault(y => y.Provider.Slug == x.Slug)) + .Where(x => x != null) + ?? _providers; } - public Task> Search(string query) where T : class, IResource + /// + public async Task Get(T item) + where T : class, IResource { - throw new NotImplementedException(); + T ret = null; + + foreach (IMetadataProvider provider in _GetProviders()) + { + try + { + ret = Merger.Merge(ret, await provider.Get(ret ?? item)); + } + catch (NotSupportedException) + { + // Silenced + } + catch (Exception ex) + { + _logger.LogError(ex, "The provider {Provider} could not get a {Type}", + provider.Provider.Name, typeof(T).Name); + } + } + + return Merger.Merge(ret, item); + } + + /// + public async Task> Search(string query) + where T : class, IResource + { + List ret = new(); + + foreach (IMetadataProvider provider in _GetProviders()) + { + try + { + ret.AddRange(await provider.Search(query)); + } + catch (NotSupportedException) + { + // Silenced + } + catch (Exception ex) + { + _logger.LogError(ex, "The provider {Provider} could not search for {Type}", + provider.Provider.Name, typeof(T).Name); + } + } + + return ret; } public Task> GetPeople(Show show) { throw new NotImplementedException(); } - - // private async Task GetMetadata(Func> providerCall, Library library, string what) - // where T : new() - // { - // T ret = new(); - // - // IEnumerable providers = library?.Providers - // .Select(x => _providers.FirstOrDefault(y => y.Provider.Slug == x.Slug)) - // .Where(x => x != null) - // ?? _providers; - // - // foreach (IMetadataProvider provider in providers) - // { - // try - // { - // ret = Merger.Merge(ret, await providerCall(provider)); - // } catch (Exception ex) - // { - // await Console.Error.WriteLineAsync( - // $"The provider {provider.Provider.Name} could not work for {what}. Exception: {ex.Message}"); - // } - // } - // return ret; - // } - // - // private async Task> GetMetadata( - // Func>> providerCall, - // Library library, - // string what) - // { - // List ret = new(); - // - // IEnumerable providers = library?.Providers - // .Select(x => _providers.FirstOrDefault(y => y.Provider.Slug == x.Slug)) - // .Where(x => x != null) - // ?? _providers; - // - // foreach (IMetadataProvider provider in providers) - // { - // try - // { - // ret.AddRange(await providerCall(provider) ?? new List()); - // } catch (Exception ex) - // { - // await Console.Error.WriteLineAsync( - // $"The provider {provider.Provider.Name} coudln't work for {what}. Exception: {ex.Message}"); - // } - // } - // return ret; - // } - // + // public async Task GetCollectionFromName(string name, Library library) // { // Collection collection = await GetMetadata(