diff --git a/Kyoo.Common/Utility.cs b/Kyoo.Common/Utility.cs
deleted file mode 100644
index 743cf545..00000000
--- a/Kyoo.Common/Utility.cs
+++ /dev/null
@@ -1,703 +0,0 @@
-using System;
-using System.Collections;
-using System.Collections.Generic;
-using System.Globalization;
-using System.Linq;
-using System.Linq.Expressions;
-using System.Reflection;
-using System.Runtime.ExceptionServices;
-using System.Text;
-using System.Text.RegularExpressions;
-using System.Threading.Tasks;
-using JetBrains.Annotations;
-using Kyoo.Models.Attributes;
-
-namespace Kyoo
-{
- ///
- /// A set of utility functions that can be used everywhere.
- ///
- public static class Utility
- {
- ///
- /// Is the lambda expression a member (like x => x.Body).
- ///
- /// The expression that should be checked
- /// True if the expression is a member, false otherwise
- public static bool IsPropertyExpression(LambdaExpression ex)
- {
- return ex == null ||
- ex.Body is MemberExpression ||
- ex.Body.NodeType == ExpressionType.Convert && ((UnaryExpression)ex.Body).Operand is MemberExpression;
- }
-
- ///
- /// Get the name of a property. Useful for selectors as members ex: Load(x => x.Shows)
- ///
- /// The expression
- /// The name of the expression
- /// If the expression is not a property, ArgumentException is thrown.
- public static string GetPropertyName(LambdaExpression ex)
- {
- if (!IsPropertyExpression(ex))
- throw new ArgumentException($"{ex} is not a property expression.");
- MemberExpression member = ex.Body.NodeType == ExpressionType.Convert
- ? ((UnaryExpression)ex.Body).Operand as MemberExpression
- : ex.Body as MemberExpression;
- return member!.Member.Name;
- }
-
- ///
- /// Get the value of a member (property or field)
- ///
- /// The member value
- /// The owner of this member
- /// The value boxed as an object
- /// if or is null.
- /// The member is not a field or a property.
- public static object GetValue([NotNull] this MemberInfo member, [NotNull] object obj)
- {
- if (member == null)
- throw new ArgumentNullException(nameof(member));
- if (obj == null)
- throw new ArgumentNullException(nameof(obj));
- return member switch
- {
- PropertyInfo property => property.GetValue(obj),
- FieldInfo field => field.GetValue(obj),
- _ => throw new ArgumentException($"Can't get value of a non property/field (member: {member}).")
- };
- }
-
- ///
- /// Slugify a string (Replace spaces by -, Uniformize accents é -> e)
- ///
- /// The string to slugify
- /// The slug version of the given string
- public static string ToSlug(string str)
- {
- if (str == null)
- return null;
-
- str = str.ToLowerInvariant();
-
- string normalizedString = str.Normalize(NormalizationForm.FormD);
- StringBuilder stringBuilder = new();
- foreach (char c in normalizedString)
- {
- UnicodeCategory unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
- if (unicodeCategory != UnicodeCategory.NonSpacingMark)
- stringBuilder.Append(c);
- }
- str = stringBuilder.ToString().Normalize(NormalizationForm.FormC);
-
- str = Regex.Replace(str, @"\s", "-", RegexOptions.Compiled);
- str = Regex.Replace(str, @"[^\w\s\p{Pd}]", "", RegexOptions.Compiled);
- str = str.Trim('-', '_');
- str = Regex.Replace(str, @"([-_]){2,}", "$1", RegexOptions.Compiled);
- return str;
- }
-
- ///
- /// Merge two lists, can keep duplicates or remove them.
- ///
- /// The first enumerable to merge
- /// The second enumerable to merge, if items from this list are equals to one from the first, they are not kept
- /// Equality function to compare items. If this is null, duplicated elements are kept
- /// The two list merged as an array
- public static T[] MergeLists(IEnumerable first,
- IEnumerable second,
- Func isEqual = null)
- {
- if (first == null)
- return second.ToArray();
- if (second == null)
- return first.ToArray();
- if (isEqual == null)
- return first.Concat(second).ToArray();
- List list = first.ToList();
- return list.Concat(second.Where(x => !list.Any(y => isEqual(x, y)))).ToArray();
- }
-
- ///
- /// Set every fields of first to those of second. Ignore fields marked with the attribute
- /// At the end, the OnMerge method of first will be called if first is a
- ///
- /// The object to assign
- /// The object containing new values
- /// Fields of T will be used
- ///
- public static T Assign(T first, T second)
- {
- Type type = typeof(T);
- IEnumerable properties = type.GetProperties()
- .Where(x => x.CanRead && x.CanWrite
- && Attribute.GetCustomAttribute(x, typeof(NotMergeableAttribute)) == null);
-
- foreach (PropertyInfo property in properties)
- {
- object value = property.GetValue(second);
- property.SetValue(first, value);
- }
-
- if (first is IOnMerge merge)
- merge.OnMerge(second);
- return first;
- }
-
- ///
- /// Set every default values of first to the value of second. ex: {id: 0, slug: "test"}, {id: 4, slug: "foo"} -> {id: 4, slug: "test"}.
- /// At the end, the OnMerge method of first will be called if first is a
- ///
- /// The object to complete
- /// Missing fields of first will be completed by fields of this item. If second is null, the function no-op.
- /// Filter fields that will be merged
- /// Fields of T will be completed
- ///
- /// If first is null
- public static T Complete([NotNull] T first, [CanBeNull] T second, Func where = null)
- {
- if (first == null)
- throw new ArgumentNullException(nameof(first));
- if (second == null)
- return first;
-
- Type type = typeof(T);
- IEnumerable properties = type.GetProperties()
- .Where(x => x.CanRead && x.CanWrite
- && Attribute.GetCustomAttribute(x, typeof(NotMergeableAttribute)) == null);
-
- if (where != null)
- properties = properties.Where(where);
-
- foreach (PropertyInfo property in properties)
- {
- object value = property.GetValue(second);
- object defaultValue = property.PropertyType.IsValueType
- ? Activator.CreateInstance(property.PropertyType)
- : null;
-
- if (value?.Equals(defaultValue) == false && value != property.GetValue(first))
- property.SetValue(first, value);
- }
-
- if (first is IOnMerge merge)
- merge.OnMerge(second);
- return first;
- }
-
- ///
- /// An advanced function.
- /// This will set missing values of to the corresponding values of .
- /// Enumerable will be merged (concatenated).
- /// At the end, the OnMerge method of first will be called if first is a .
- ///
- /// The object to complete
- /// 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)
- {
- if (first == null)
- return second;
- if (second == null)
- return first;
-
- Type type = typeof(T);
- IEnumerable properties = type.GetProperties()
- .Where(x => x.CanRead && x.CanWrite
- && Attribute.GetCustomAttribute(x, typeof(NotMergeableAttribute)) == null);
-
- foreach (PropertyInfo property in properties)
- {
- object oldValue = property.GetValue(first);
- object newValue = property.GetValue(second);
- object defaultValue = property.PropertyType.IsValueType
- ? Activator.CreateInstance(property.PropertyType)
- : null;
-
- if (oldValue?.Equals(defaultValue) != false)
- property.SetValue(first, newValue);
- else if (typeof(IEnumerable).IsAssignableFrom(property.PropertyType)
- && property.PropertyType != typeof(string))
- {
- property.SetValue(first, RunGenericMethod(
- typeof(Utility),
- nameof(MergeLists),
- GetEnumerableType(property.PropertyType),
- oldValue, newValue, null));
- }
- }
-
- if (first is IOnMerge merge)
- merge.OnMerge(second);
- return first;
- }
-
- ///
- /// Set every fields of to the default value.
- ///
- /// The object to nullify
- /// Fields of T will be nullified
- ///
- public static T Nullify(T obj)
- {
- Type type = typeof(T);
- foreach (PropertyInfo property in type.GetProperties())
- {
- if (!property.CanWrite)
- continue;
-
- object defaultValue = property.PropertyType.IsValueType
- ? Activator.CreateInstance(property.PropertyType)
- : null;
- property.SetValue(obj, defaultValue);
- }
-
- return obj;
- }
-
- ///
- /// Return every in the inheritance tree of the parameter (interfaces are not returned)
- ///
- /// The starting type
- /// A list of types
- /// can't be null
- public static IEnumerable GetInheritanceTree([NotNull] this Type type)
- {
- if (type == null)
- throw new ArgumentNullException(nameof(type));
- for (; type != null; type = type.BaseType)
- yield return type;
- }
-
- ///
- /// Check if inherit from a generic type .
- ///
- /// Does this object's type is a
- /// The generic type to check against (Only generic types are supported like typeof(IEnumerable<>).
- /// True if obj inherit from genericType. False otherwise
- /// obj and genericType can't be null
- public static bool IsOfGenericType([NotNull] object obj, [NotNull] Type genericType)
- {
- if (obj == null)
- throw new ArgumentNullException(nameof(obj));
- return IsOfGenericType(obj.GetType(), genericType);
- }
-
- ///
- /// Check if inherit from a generic type .
- ///
- /// The type to check
- /// The generic type to check against (Only generic types are supported like typeof(IEnumerable<>).
- /// True if obj inherit from genericType. False otherwise
- /// obj and genericType can't be null
- public static bool IsOfGenericType([NotNull] Type type, [NotNull] Type genericType)
- {
- if (type == null)
- throw new ArgumentNullException(nameof(type));
- if (genericType == null)
- throw new ArgumentNullException(nameof(genericType));
- if (!genericType.IsGenericType)
- throw new ArgumentException($"{nameof(genericType)} is not a generic type.");
-
- IEnumerable types = genericType.IsInterface
- ? type.GetInterfaces()
- : type.GetInheritanceTree();
- return types.Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType);
- }
-
- ///
- /// Get the generic definition of .
- /// For example, calling this function with List<string> and typeof(IEnumerable<>) will return IEnumerable<string>
- ///
- /// The type to check
- /// The generic type to check against (Only generic types are supported like typeof(IEnumerable<>).
- /// The generic definition of genericType that type inherit or null if type does not implement the generic type.
- /// and can't be null
- /// must be a generic type
- public static Type GetGenericDefinition([NotNull] Type type, [NotNull] Type genericType)
- {
- if (type == null)
- throw new ArgumentNullException(nameof(type));
- if (genericType == null)
- throw new ArgumentNullException(nameof(genericType));
- if (!genericType.IsGenericType)
- throw new ArgumentException($"{nameof(genericType)} is not a generic type.");
-
- IEnumerable types = genericType.IsInterface
- ? type.GetInterfaces()
- : type.GetInheritanceTree();
- return types.FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType);
- }
-
- ///
- /// A Select where the index of the item can be used.
- ///
- /// The IEnumerable to map. If self is null, an empty list is returned
- /// The function that will map each items
- /// The type of items in
- /// The type of items in the returned list
- /// The list mapped.
- /// mapper can't be null
- public static IEnumerable Map([CanBeNull] this IEnumerable self,
- [NotNull] Func mapper)
- {
- if (self == null)
- yield break;
- if (mapper == null)
- throw new ArgumentNullException(nameof(mapper));
-
- using IEnumerator enumerator = self.GetEnumerator();
- int index = 0;
-
- while (enumerator.MoveNext())
- {
- yield return mapper(enumerator.Current, index);
- index++;
- }
- }
-
- ///
- /// A map where the mapping function is asynchronous.
- /// Note: might interest you.
- ///
- /// The IEnumerable to map. If self is null, an empty list is returned
- /// The asynchronous function that will map each items
- /// The type of items in
- /// The type of items in the returned list
- /// The list mapped as an AsyncEnumerable
- /// mapper can't be null
- public static async IAsyncEnumerable MapAsync([CanBeNull] this IEnumerable self,
- [NotNull] Func> mapper)
- {
- if (self == null)
- yield break;
- if (mapper == null)
- throw new ArgumentNullException(nameof(mapper));
-
- using IEnumerator enumerator = self.GetEnumerator();
- int index = 0;
-
- while (enumerator.MoveNext())
- {
- yield return await mapper(enumerator.Current, index);
- index++;
- }
- }
-
- ///
- /// An asynchronous version of Select.
- ///
- /// The IEnumerable to map
- /// The asynchronous function that will map each items
- /// The type of items in
- /// The type of items in the returned list
- /// The list mapped as an AsyncEnumerable
- /// mapper can't be null
- public static async IAsyncEnumerable SelectAsync([CanBeNull] this IEnumerable self,
- [NotNull] Func> mapper)
- {
- if (self == null)
- yield break;
- if (mapper == null)
- throw new ArgumentNullException(nameof(mapper));
-
- using IEnumerator enumerator = self.GetEnumerator();
-
- while (enumerator.MoveNext())
- yield return await mapper(enumerator.Current);
- }
-
- ///
- /// Convert an AsyncEnumerable to a List by waiting for every item.
- ///
- /// The async list
- /// The type of items in the async list and in the returned list.
- /// A task that will return a simple list
- /// The list can't be null
- public static async Task> ToListAsync([NotNull] this IAsyncEnumerable self)
- {
- if (self == null)
- throw new ArgumentNullException(nameof(self));
-
- List ret = new();
-
- await foreach(T i in self)
- ret.Add(i);
- return ret;
- }
-
- ///
- /// If the enumerable is empty, execute an action.
- ///
- /// The enumerable to check
- /// The action to execute is the list is empty
- /// The type of items inside the list
- ///
- public static IEnumerable IfEmpty(this IEnumerable self, Action action)
- {
- using IEnumerator enumerator = self.GetEnumerator();
-
- if (!enumerator.MoveNext())
- {
- action();
- yield break;
- }
-
- do
- {
- yield return enumerator.Current;
- }
- while (enumerator.MoveNext());
- }
-
- ///
- /// A foreach used as a function with a little specificity: the list can be null.
- ///
- /// The list to enumerate. If this is null, the function result in a no-op
- /// The action to execute for each arguments
- /// The type of items in the list
- public static void ForEach([CanBeNull] this IEnumerable self, Action action)
- {
- if (self == null)
- return;
- foreach (T i in self)
- action(i);
- }
-
- ///
- /// A foreach used as a function with a little specificity: the list can be null.
- ///
- /// The list to enumerate. If this is null, the function result in a no-op
- /// The action to execute for each arguments
- public static void ForEach([CanBeNull] this IEnumerable self, Action action)
- {
- if (self == null)
- return;
- foreach (object i in self)
- action(i);
- }
-
- public static async Task ForEachAsync([CanBeNull] this IEnumerable self, Func action)
- {
- if (self == null)
- return;
- foreach (T i in self)
- await action(i);
- }
-
- public static async Task ForEachAsync([CanBeNull] this IAsyncEnumerable self, Action action)
- {
- if (self == null)
- return;
- await foreach (T i in self)
- action(i);
- }
-
- public static async Task ForEachAsync([CanBeNull] this IEnumerable self, Func action)
- {
- if (self == null)
- return;
- foreach (object i in self)
- await action(i);
- }
-
- public static MethodInfo GetMethod(Type type, BindingFlags flag, string name, Type[] generics, object[] 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}."))
- // TODO this won't work but I don't know why.
- // .Where(x =>
- // {
- // int i = 0;
- // return x.GetGenericArguments().All(y => y.IsAssignableFrom(generics[i++]));
- // })
- // .IfEmpty(() => throw new NullReferenceException($"No method {name} match the generics specified."))
-
- // TODO this won't work for Type because T is specified in arguments but not in the parameters type.
- // .Where(x =>
- // {
- // int i = 0;
- // return x.GetParameters().All(y => y.ParameterType.IsInstanceOfType(args[i++]));
- // })
- // .IfEmpty(() => throw new NullReferenceException($"No method {name} match the parameters's types."))
- .Take(2)
- .ToArray();
-
- if (methods.Length == 1)
- return methods[0];
- throw new NullReferenceException($"Multiple methods named {name} match the generics and parameters constraints.");
- }
-
- public static T RunGenericMethod(
- [NotNull] Type owner,
- [NotNull] string methodName,
- [NotNull] Type type,
- params object[] args)
- {
- return RunGenericMethod(owner, methodName, new[] {type}, args);
- }
-
- public static T RunGenericMethod(
- [NotNull] Type owner,
- [NotNull] string methodName,
- [NotNull] Type[] types,
- params object[] args)
- {
- if (owner == null)
- throw new ArgumentNullException(nameof(owner));
- if (methodName == null)
- throw new ArgumentNullException(nameof(methodName));
- if (types == null)
- throw new ArgumentNullException(nameof(types));
- 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());
- }
-
- public static T RunGenericMethod(
- [NotNull] object instance,
- [NotNull] string methodName,
- [NotNull] Type type,
- params object[] args)
- {
- return RunGenericMethod(instance, methodName, new[] {type}, args);
- }
-
- public static T RunGenericMethod(
- [NotNull] object instance,
- [NotNull] string methodName,
- [NotNull] Type[] types,
- params object[] args)
- {
- if (instance == null)
- throw new ArgumentNullException(nameof(instance));
- if (methodName == null)
- throw new ArgumentNullException(nameof(methodName));
- 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());
- }
-
- [NotNull]
- public static Type GetEnumerableType([NoEnumeration] [NotNull] IEnumerable list)
- {
- if (list == null)
- throw new ArgumentNullException(nameof(list));
- Type type = list.GetType().GetInterfaces().FirstOrDefault(t => typeof(IEnumerable).IsAssignableFrom(t)
- && t.GetGenericArguments().Any()) ?? list.GetType();
- return type.GetGenericArguments().First();
- }
-
- public static Type GetEnumerableType([NotNull] Type listType)
- {
- if (listType == null)
- throw new ArgumentNullException(nameof(listType));
- if (!typeof(IEnumerable).IsAssignableFrom(listType))
- throw new InvalidOperationException($"The {nameof(listType)} parameter was not an IEnumerable.");
- Type type = listType.GetInterfaces().FirstOrDefault(t => typeof(IEnumerable).IsAssignableFrom(t)
- && t.GetGenericArguments().Any()) ?? listType;
- return type.GetGenericArguments().First();
- }
-
- public static IEnumerable> BatchBy(this List list, int countPerList)
- {
- for (int i = 0; i < list.Count; i += countPerList)
- yield return list.GetRange(i, Math.Min(list.Count - i, countPerList));
- }
-
- public static IEnumerable BatchBy(this IEnumerable list, int countPerList)
- {
- T[] ret = new T[countPerList];
- int i = 0;
-
- using IEnumerator enumerator = list.GetEnumerator();
- while (enumerator.MoveNext())
- {
- ret[i] = enumerator.Current;
- i++;
- if (i < countPerList)
- continue;
- i = 0;
- yield return ret;
- }
-
- Array.Resize(ref ret, i);
- yield return ret;
- }
-
- public static string ToQueryString(this Dictionary query)
- {
- if (!query.Any())
- return string.Empty;
- return "?" + string.Join('&', query.Select(x => $"{x.Key}={x.Value}"));
- }
-
- [System.Diagnostics.CodeAnalysis.DoesNotReturn]
- public static void ReThrow([NotNull] this Exception ex)
- {
- if (ex == null)
- throw new ArgumentNullException(nameof(ex));
- ExceptionDispatchInfo.Capture(ex).Throw();
- }
-
- public static Task Then(this Task task, Action map)
- {
- return task.ContinueWith(x =>
- {
- if (x.IsFaulted)
- x.Exception!.InnerException!.ReThrow();
- if (x.IsCanceled)
- throw new TaskCanceledException();
- map(x.Result);
- return x.Result;
- }, TaskContinuationOptions.ExecuteSynchronously);
- }
-
- public static Task Map(this Task task, Func map)
- {
- return task.ContinueWith(x =>
- {
- if (x.IsFaulted)
- x.Exception!.InnerException!.ReThrow();
- if (x.IsCanceled)
- throw new TaskCanceledException();
- return map(x.Result);
- }, TaskContinuationOptions.ExecuteSynchronously);
- }
-
- public static Task Cast(this Task task)
- {
- return task.ContinueWith(x =>
- {
- if (x.IsFaulted)
- x.Exception!.InnerException!.ReThrow();
- if (x.IsCanceled)
- throw new TaskCanceledException();
- return (T)((dynamic)x).Result;
- }, TaskContinuationOptions.ExecuteSynchronously);
- }
-
- ///
- /// Get a friendly type name (supporting generics)
- /// For example a list of string will be displayed as List<string> and not as List`1.
- ///
- /// The type to use
- /// The friendly name of the type
- public static string FriendlyName(this Type type)
- {
- if (!type.IsGenericType)
- return type.Name;
- string generics = string.Join(", ", type.GetGenericArguments().Select(x => x.FriendlyName()));
- return $"{type.Name[..type.Name.IndexOf('`')]}<{generics}>";
- }
- }
-}
\ No newline at end of file
diff --git a/Kyoo.Common/Utility/EnumerableExtensions.cs b/Kyoo.Common/Utility/EnumerableExtensions.cs
new file mode 100644
index 00000000..e4d0379e
--- /dev/null
+++ b/Kyoo.Common/Utility/EnumerableExtensions.cs
@@ -0,0 +1,247 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using JetBrains.Annotations;
+
+namespace Kyoo
+{
+ ///
+ /// A set of extensions class for enumerable.
+ ///
+ public static class EnumerableExtensions
+ {
+ ///
+ /// A Select where the index of the item can be used.
+ ///
+ /// The IEnumerable to map. If self is null, an empty list is returned
+ /// The function that will map each items
+ /// The type of items in
+ /// The type of items in the returned list
+ /// The list mapped or null if the input map was null.
+ /// mapper can't be null
+ public static IEnumerable Map([CanBeNull] this IEnumerable self,
+ [NotNull] Func mapper)
+ {
+ if (self == null)
+ return null;
+ if (mapper == null)
+ throw new ArgumentNullException(nameof(mapper));
+
+ static IEnumerable Generator(IEnumerable self, Func mapper)
+ {
+ using IEnumerator enumerator = self.GetEnumerator();
+ int index = 0;
+
+ while (enumerator.MoveNext())
+ {
+ yield return mapper(enumerator.Current, index);
+ index++;
+ }
+ }
+ return Generator(self, mapper);
+ }
+
+ ///
+ /// A map where the mapping function is asynchronous.
+ /// Note: might interest you.
+ ///
+ /// The IEnumerable to map. If self is null, an empty list is returned
+ /// The asynchronous function that will map each items
+ /// The type of items in
+ /// The type of items in the returned list
+ /// The list mapped as an AsyncEnumerable
+ /// mapper can't be null
+ public static async IAsyncEnumerable MapAsync([CanBeNull] this IEnumerable self,
+ [NotNull] Func> mapper)
+ {
+ if (self == null)
+ yield break;
+ if (mapper == null)
+ throw new ArgumentNullException(nameof(mapper));
+
+ using IEnumerator enumerator = self.GetEnumerator();
+ int index = 0;
+
+ while (enumerator.MoveNext())
+ {
+ yield return await mapper(enumerator.Current, index);
+ index++;
+ }
+ }
+
+ ///
+ /// An asynchronous version of Select.
+ ///
+ /// The IEnumerable to map
+ /// The asynchronous function that will map each items
+ /// The type of items in
+ /// The type of items in the returned list
+ /// The list mapped as an AsyncEnumerable
+ /// mapper can't be null
+ public static async IAsyncEnumerable SelectAsync([CanBeNull] this IEnumerable self,
+ [NotNull] Func> mapper)
+ {
+ if (self == null)
+ yield break;
+ if (mapper == null)
+ throw new ArgumentNullException(nameof(mapper));
+
+ using IEnumerator enumerator = self.GetEnumerator();
+
+ while (enumerator.MoveNext())
+ yield return await mapper(enumerator.Current);
+ }
+
+ ///
+ /// Convert an AsyncEnumerable to a List by waiting for every item.
+ ///
+ /// The async list
+ /// The type of items in the async list and in the returned list.
+ /// A task that will return a simple list
+ /// The list can't be null
+ public static async Task> ToListAsync([NotNull] this IAsyncEnumerable self)
+ {
+ if (self == null)
+ throw new ArgumentNullException(nameof(self));
+
+ List ret = new();
+
+ await foreach(T i in self)
+ ret.Add(i);
+ return ret;
+ }
+
+ ///
+ /// If the enumerable is empty, execute an action.
+ ///
+ /// The enumerable to check
+ /// The action to execute is the list is empty
+ /// The type of items inside the list
+ ///
+ public static IEnumerable IfEmpty(this IEnumerable self, Action action)
+ {
+ using IEnumerator enumerator = self.GetEnumerator();
+
+ if (!enumerator.MoveNext())
+ {
+ action();
+ yield break;
+ }
+
+ do
+ {
+ yield return enumerator.Current;
+ }
+ while (enumerator.MoveNext());
+ }
+
+ ///
+ /// A foreach used as a function with a little specificity: the list can be null.
+ ///
+ /// The list to enumerate. If this is null, the function result in a no-op
+ /// The action to execute for each arguments
+ /// The type of items in the list
+ public static void ForEach([CanBeNull] this IEnumerable self, Action action)
+ {
+ if (self == null)
+ return;
+ foreach (T i in self)
+ action(i);
+ }
+
+ ///
+ /// A foreach used as a function with a little specificity: the list can be null.
+ ///
+ /// The list to enumerate. If this is null, the function result in a no-op
+ /// The action to execute for each arguments
+ public static void ForEach([CanBeNull] this IEnumerable self, Action action)
+ {
+ if (self == null)
+ return;
+ foreach (object i in self)
+ action(i);
+ }
+
+ ///
+ /// A foreach used as a function with a little specificity: the list can be null.
+ ///
+ /// The list to enumerate. If this is null, the function result in a no-op
+ /// The action to execute for each arguments
+ public static async Task ForEachAsync([CanBeNull] this IEnumerable self, Func action)
+ {
+ if (self == null)
+ return;
+ foreach (object i in self)
+ await action(i);
+ }
+
+ ///
+ /// A foreach used as a function with a little specificity: the list can be null.
+ ///
+ /// The list to enumerate. If this is null, the function result in a no-op
+ /// The asynchronous action to execute for each arguments
+ /// The type of items in the list.
+ public static async Task ForEachAsync([CanBeNull] this IEnumerable self, Func action)
+ {
+ if (self == null)
+ return;
+ foreach (T i in self)
+ await action(i);
+ }
+
+ ///
+ /// A foreach used as a function with a little specificity: the list can be null.
+ ///
+ /// The async list to enumerate. If this is null, the function result in a no-op
+ /// The action to execute for each arguments
+ /// The type of items in the list.
+ public static async Task ForEachAsync([CanBeNull] this IAsyncEnumerable self, Action action)
+ {
+ if (self == null)
+ return;
+ await foreach (T i in self)
+ action(i);
+ }
+
+ ///
+ /// Split a list in a small chunk of data.
+ ///
+ /// The list to split
+ /// The number of items in each chunk
+ /// The type of data in the initial list.
+ /// A list of chunks
+ public static IEnumerable> BatchBy(this List list, int countPerList)
+ {
+ for (int i = 0; i < list.Count; i += countPerList)
+ yield return list.GetRange(i, Math.Min(list.Count - i, countPerList));
+ }
+
+ ///
+ /// Split a list in a small chunk of data.
+ ///
+ /// The list to split
+ /// The number of items in each chunk
+ /// The type of data in the initial list.
+ /// A list of chunks
+ public static IEnumerable BatchBy(this IEnumerable list, int countPerList)
+ {
+ T[] ret = new T[countPerList];
+ int i = 0;
+
+ using IEnumerator enumerator = list.GetEnumerator();
+ while (enumerator.MoveNext())
+ {
+ ret[i] = enumerator.Current;
+ i++;
+ if (i < countPerList)
+ continue;
+ i = 0;
+ yield return ret;
+ }
+
+ Array.Resize(ref ret, i);
+ yield return ret;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Kyoo.Common/Utility/Merger.cs b/Kyoo.Common/Utility/Merger.cs
new file mode 100644
index 00000000..047b5b09
--- /dev/null
+++ b/Kyoo.Common/Utility/Merger.cs
@@ -0,0 +1,178 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using JetBrains.Annotations;
+using Kyoo.Models.Attributes;
+
+namespace Kyoo
+{
+ ///
+ /// A class containing helper methods to merge objects.
+ ///
+ public static class Merger
+ {
+ ///
+ /// Merge two lists, can keep duplicates or remove them.
+ ///
+ /// The first enumerable to merge
+ /// The second enumerable to merge, if items from this list are equals to one from the first, they are not kept
+ /// Equality function to compare items. If this is null, duplicated elements are kept
+ /// The two list merged as an array
+ public static T[] MergeLists(IEnumerable first,
+ IEnumerable second,
+ Func isEqual = null)
+ {
+ if (first == null)
+ return second.ToArray();
+ if (second == null)
+ return first.ToArray();
+ if (isEqual == null)
+ return first.Concat(second).ToArray();
+ List list = first.ToList();
+ return list.Concat(second.Where(x => !list.Any(y => isEqual(x, y)))).ToArray();
+ }
+
+ ///
+ /// Set every fields of first to those of second. Ignore fields marked with the attribute
+ /// At the end, the OnMerge method of first will be called if first is a
+ ///
+ /// The object to assign
+ /// The object containing new values
+ /// Fields of T will be used
+ ///
+ public static T Assign(T first, T second)
+ {
+ Type type = typeof(T);
+ IEnumerable properties = type.GetProperties()
+ .Where(x => x.CanRead && x.CanWrite
+ && Attribute.GetCustomAttribute(x, typeof(NotMergeableAttribute)) == null);
+
+ foreach (PropertyInfo property in properties)
+ {
+ object value = property.GetValue(second);
+ property.SetValue(first, value);
+ }
+
+ if (first is IOnMerge merge)
+ merge.OnMerge(second);
+ return first;
+ }
+
+ ///
+ /// Set every default values of first to the value of second. ex: {id: 0, slug: "test"}, {id: 4, slug: "foo"} -> {id: 4, slug: "test"}.
+ /// At the end, the OnMerge method of first will be called if first is a
+ ///
+ /// The object to complete
+ /// Missing fields of first will be completed by fields of this item. If second is null, the function no-op.
+ /// Filter fields that will be merged
+ /// Fields of T will be completed
+ ///
+ /// If first is null
+ public static T Complete([NotNull] T first, [CanBeNull] T second, Func where = null)
+ {
+ if (first == null)
+ throw new ArgumentNullException(nameof(first));
+ if (second == null)
+ return first;
+
+ Type type = typeof(T);
+ IEnumerable properties = type.GetProperties()
+ .Where(x => x.CanRead && x.CanWrite
+ && Attribute.GetCustomAttribute(x, typeof(NotMergeableAttribute)) == null);
+
+ if (where != null)
+ properties = properties.Where(where);
+
+ foreach (PropertyInfo property in properties)
+ {
+ object value = property.GetValue(second);
+ object defaultValue = property.PropertyType.IsValueType
+ ? Activator.CreateInstance(property.PropertyType)
+ : null;
+
+ if (value?.Equals(defaultValue) == false && value != property.GetValue(first))
+ property.SetValue(first, value);
+ }
+
+ if (first is IOnMerge merge)
+ merge.OnMerge(second);
+ return first;
+ }
+
+ ///
+ /// An advanced function.
+ /// This will set missing values of to the corresponding values of .
+ /// Enumerable will be merged (concatenated).
+ /// At the end, the OnMerge method of first will be called if first is a .
+ ///
+ /// The object to complete
+ /// 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)
+ {
+ if (first == null)
+ return second;
+ if (second == null)
+ return first;
+
+ Type type = typeof(T);
+ IEnumerable properties = type.GetProperties()
+ .Where(x => x.CanRead && x.CanWrite
+ && Attribute.GetCustomAttribute(x, typeof(NotMergeableAttribute)) == null);
+
+ foreach (PropertyInfo property in properties)
+ {
+ object oldValue = property.GetValue(first);
+ object newValue = property.GetValue(second);
+ object defaultValue = property.PropertyType.IsValueType
+ ? Activator.CreateInstance(property.PropertyType)
+ : null;
+
+ if (oldValue?.Equals(defaultValue) != false)
+ property.SetValue(first, newValue);
+ else if (typeof(IEnumerable).IsAssignableFrom(property.PropertyType)
+ && property.PropertyType != typeof(string))
+ {
+ Type enumerableType = Utility.GetGenericDefinition(property.PropertyType, typeof(IEnumerable<>))
+ .GenericTypeArguments
+ .First();
+ property.SetValue(first, Utility.RunGenericMethod(
+ typeof(Utility),
+ nameof(MergeLists),
+ enumerableType,
+ oldValue, newValue, null));
+ }
+ }
+
+ if (first is IOnMerge merge)
+ merge.OnMerge(second);
+ return first;
+ }
+
+ ///
+ /// Set every fields of to the default value.
+ ///
+ /// The object to nullify
+ /// Fields of T will be nullified
+ ///
+ public static T Nullify(T obj)
+ {
+ Type type = typeof(T);
+ foreach (PropertyInfo property in type.GetProperties())
+ {
+ if (!property.CanWrite)
+ continue;
+
+ object defaultValue = property.PropertyType.IsValueType
+ ? Activator.CreateInstance(property.PropertyType)
+ : null;
+ property.SetValue(obj, defaultValue);
+ }
+
+ return obj;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Kyoo.Common/Utility/Utility.cs b/Kyoo.Common/Utility/Utility.cs
new file mode 100644
index 00000000..e25b595b
--- /dev/null
+++ b/Kyoo.Common/Utility/Utility.cs
@@ -0,0 +1,325 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Reflection;
+using System.Runtime.ExceptionServices;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using JetBrains.Annotations;
+
+namespace Kyoo
+{
+ ///
+ /// A set of utility functions that can be used everywhere.
+ ///
+ public static class Utility
+ {
+ ///
+ /// Is the lambda expression a member (like x => x.Body).
+ ///
+ /// The expression that should be checked
+ /// True if the expression is a member, false otherwise
+ public static bool IsPropertyExpression(LambdaExpression ex)
+ {
+ if (ex == null)
+ return false;
+ return ex.Body is MemberExpression ||
+ ex.Body.NodeType == ExpressionType.Convert && ((UnaryExpression)ex.Body).Operand is MemberExpression;
+ }
+
+ ///
+ /// Get the name of a property. Useful for selectors as members ex: Load(x => x.Shows)
+ ///
+ /// The expression
+ /// The name of the expression
+ /// If the expression is not a property, ArgumentException is thrown.
+ public static string GetPropertyName(LambdaExpression ex)
+ {
+ if (!IsPropertyExpression(ex))
+ throw new ArgumentException($"{ex} is not a property expression.");
+ MemberExpression member = ex.Body.NodeType == ExpressionType.Convert
+ ? ((UnaryExpression)ex.Body).Operand as MemberExpression
+ : ex.Body as MemberExpression;
+ return member!.Member.Name;
+ }
+
+ ///
+ /// Get the value of a member (property or field)
+ ///
+ /// The member value
+ /// The owner of this member
+ /// The value boxed as an object
+ /// if or is null.
+ /// The member is not a field or a property.
+ public static object GetValue([NotNull] this MemberInfo member, [NotNull] object obj)
+ {
+ if (member == null)
+ throw new ArgumentNullException(nameof(member));
+ if (obj == null)
+ throw new ArgumentNullException(nameof(obj));
+ return member switch
+ {
+ PropertyInfo property => property.GetValue(obj),
+ FieldInfo field => field.GetValue(obj),
+ _ => throw new ArgumentException($"Can't get value of a non property/field (member: {member}).")
+ };
+ }
+
+ ///
+ /// Slugify a string (Replace spaces by -, Uniformize accents é -> e)
+ ///
+ /// The string to slugify
+ /// The slug version of the given string
+ public static string ToSlug(string str)
+ {
+ if (str == null)
+ return null;
+
+ str = str.ToLowerInvariant();
+
+ string normalizedString = str.Normalize(NormalizationForm.FormD);
+ StringBuilder stringBuilder = new();
+ foreach (char c in normalizedString)
+ {
+ UnicodeCategory unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
+ if (unicodeCategory != UnicodeCategory.NonSpacingMark)
+ stringBuilder.Append(c);
+ }
+ str = stringBuilder.ToString().Normalize(NormalizationForm.FormC);
+
+ str = Regex.Replace(str, @"\s", "-", RegexOptions.Compiled);
+ str = Regex.Replace(str, @"[^\w\s\p{Pd}]", "", RegexOptions.Compiled);
+ str = str.Trim('-', '_');
+ str = Regex.Replace(str, @"([-_]){2,}", "$1", RegexOptions.Compiled);
+ return str;
+ }
+
+ ///
+ /// Return every in the inheritance tree of the parameter (interfaces are not returned)
+ ///
+ /// The starting type
+ /// A list of types
+ /// can't be null
+ public static IEnumerable GetInheritanceTree([NotNull] this Type type)
+ {
+ if (type == null)
+ throw new ArgumentNullException(nameof(type));
+ for (; type != null; type = type.BaseType)
+ yield return type;
+ }
+
+ ///
+ /// Check if inherit from a generic type .
+ ///
+ /// Does this object's type is a
+ /// The generic type to check against (Only generic types are supported like typeof(IEnumerable<>).
+ /// True if obj inherit from genericType. False otherwise
+ /// obj and genericType can't be null
+ public static bool IsOfGenericType([NotNull] object obj, [NotNull] Type genericType)
+ {
+ if (obj == null)
+ throw new ArgumentNullException(nameof(obj));
+ return IsOfGenericType(obj.GetType(), genericType);
+ }
+
+ ///
+ /// Check if inherit from a generic type .
+ ///
+ /// The type to check
+ /// The generic type to check against (Only generic types are supported like typeof(IEnumerable<>).
+ /// True if obj inherit from genericType. False otherwise
+ /// obj and genericType can't be null
+ public static bool IsOfGenericType([NotNull] Type type, [NotNull] Type genericType)
+ {
+ if (type == null)
+ throw new ArgumentNullException(nameof(type));
+ if (genericType == null)
+ throw new ArgumentNullException(nameof(genericType));
+ if (!genericType.IsGenericType)
+ throw new ArgumentException($"{nameof(genericType)} is not a generic type.");
+
+ IEnumerable types = genericType.IsInterface
+ ? type.GetInterfaces()
+ : type.GetInheritanceTree();
+ return types.Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType);
+ }
+
+ ///
+ /// Get the generic definition of .
+ /// For example, calling this function with List<string> and typeof(IEnumerable<>) will return IEnumerable<string>
+ ///
+ /// The type to check
+ /// The generic type to check against (Only generic types are supported like typeof(IEnumerable<>).
+ /// The generic definition of genericType that type inherit or null if type does not implement the generic type.
+ /// and can't be null
+ /// must be a generic type
+ public static Type GetGenericDefinition([NotNull] Type type, [NotNull] Type genericType)
+ {
+ if (type == null)
+ throw new ArgumentNullException(nameof(type));
+ if (genericType == null)
+ throw new ArgumentNullException(nameof(genericType));
+ if (!genericType.IsGenericType)
+ throw new ArgumentException($"{nameof(genericType)} is not a generic type.");
+
+ IEnumerable types = genericType.IsInterface
+ ? type.GetInterfaces()
+ : type.GetInheritanceTree();
+ return types.FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType);
+ }
+
+ public static MethodInfo GetMethod(Type type, BindingFlags flag, string name, Type[] generics, object[] 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}."))
+ // TODO this won't work but I don't know why.
+ // .Where(x =>
+ // {
+ // int i = 0;
+ // return x.GetGenericArguments().All(y => y.IsAssignableFrom(generics[i++]));
+ // })
+ // .IfEmpty(() => throw new NullReferenceException($"No method {name} match the generics specified."))
+
+ // TODO this won't work for Type because T is specified in arguments but not in the parameters type.
+ // .Where(x =>
+ // {
+ // int i = 0;
+ // return x.GetParameters().All(y => y.ParameterType.IsInstanceOfType(args[i++]));
+ // })
+ // .IfEmpty(() => throw new NullReferenceException($"No method {name} match the parameters's types."))
+ .Take(2)
+ .ToArray();
+
+ if (methods.Length == 1)
+ return methods[0];
+ throw new NullReferenceException($"Multiple methods named {name} match the generics and parameters constraints.");
+ }
+
+ public static T RunGenericMethod(
+ [NotNull] Type owner,
+ [NotNull] string methodName,
+ [NotNull] Type type,
+ params object[] args)
+ {
+ return RunGenericMethod(owner, methodName, new[] {type}, args);
+ }
+
+ public static T RunGenericMethod(
+ [NotNull] Type owner,
+ [NotNull] string methodName,
+ [NotNull] Type[] types,
+ params object[] args)
+ {
+ if (owner == null)
+ throw new ArgumentNullException(nameof(owner));
+ if (methodName == null)
+ throw new ArgumentNullException(nameof(methodName));
+ if (types == null)
+ throw new ArgumentNullException(nameof(types));
+ 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());
+ }
+
+ public static T RunGenericMethod(
+ [NotNull] object instance,
+ [NotNull] string methodName,
+ [NotNull] Type type,
+ params object[] args)
+ {
+ return RunGenericMethod(instance, methodName, new[] {type}, args);
+ }
+
+ public static T RunGenericMethod(
+ [NotNull] object instance,
+ [NotNull] string methodName,
+ [NotNull] Type[] types,
+ params object[] args)
+ {
+ if (instance == null)
+ throw new ArgumentNullException(nameof(instance));
+ if (methodName == null)
+ throw new ArgumentNullException(nameof(methodName));
+ 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());
+ }
+
+ public static string ToQueryString(this Dictionary query)
+ {
+ if (!query.Any())
+ return string.Empty;
+ return "?" + string.Join('&', query.Select(x => $"{x.Key}={x.Value}"));
+ }
+
+ [System.Diagnostics.CodeAnalysis.DoesNotReturn]
+ public static void ReThrow([NotNull] this Exception ex)
+ {
+ if (ex == null)
+ throw new ArgumentNullException(nameof(ex));
+ ExceptionDispatchInfo.Capture(ex).Throw();
+ }
+
+ public static Task Then(this Task task, Action map)
+ {
+ return task.ContinueWith(x =>
+ {
+ if (x.IsFaulted)
+ x.Exception!.InnerException!.ReThrow();
+ if (x.IsCanceled)
+ throw new TaskCanceledException();
+ map(x.Result);
+ return x.Result;
+ }, TaskContinuationOptions.ExecuteSynchronously);
+ }
+
+ public static Task Map(this Task task, Func map)
+ {
+ return task.ContinueWith(x =>
+ {
+ if (x.IsFaulted)
+ x.Exception!.InnerException!.ReThrow();
+ if (x.IsCanceled)
+ throw new TaskCanceledException();
+ return map(x.Result);
+ }, TaskContinuationOptions.ExecuteSynchronously);
+ }
+
+ public static Task Cast(this Task task)
+ {
+ return task.ContinueWith(x =>
+ {
+ if (x.IsFaulted)
+ x.Exception!.InnerException!.ReThrow();
+ if (x.IsCanceled)
+ throw new TaskCanceledException();
+ return (T)((dynamic)x).Result;
+ }, TaskContinuationOptions.ExecuteSynchronously);
+ }
+
+ ///
+ /// Get a friendly type name (supporting generics)
+ /// For example a list of string will be displayed as List<string> and not as List`1.
+ ///
+ /// The type to use
+ /// The friendly name of the type
+ public static string FriendlyName(this Type type)
+ {
+ if (!type.IsGenericType)
+ return type.Name;
+ string generics = string.Join(", ", type.GetGenericArguments().Select(x => x.FriendlyName()));
+ return $"{type.Name[..type.Name.IndexOf('`')]}<{generics}>";
+ }
+ }
+}
\ No newline at end of file
diff --git a/Kyoo.CommonAPI/LocalRepository.cs b/Kyoo.CommonAPI/LocalRepository.cs
index 7498fc14..d63c1b06 100644
--- a/Kyoo.CommonAPI/LocalRepository.cs
+++ b/Kyoo.CommonAPI/LocalRepository.cs
@@ -225,8 +225,8 @@ namespace Kyoo.Controllers
T old = await GetWithTracking(edited.ID);
if (resetOld)
- Utility.Nullify(old);
- Utility.Complete(old, edited, x => x.GetCustomAttribute() == null);
+ Merger.Nullify(old);
+ Merger.Complete(old, edited, x => x.GetCustomAttribute() == null);
await EditRelations(old, edited, resetOld);
await Database.SaveChangesAsync();
return old;
diff --git a/Kyoo.Tests/UtilityTests.cs b/Kyoo.Tests/Utility/UtilityTests.cs
similarity index 56%
rename from Kyoo.Tests/UtilityTests.cs
rename to Kyoo.Tests/Utility/UtilityTests.cs
index e046cdb9..15469411 100644
--- a/Kyoo.Tests/UtilityTests.cs
+++ b/Kyoo.Tests/Utility/UtilityTests.cs
@@ -13,12 +13,23 @@ namespace Kyoo.Tests
Expression> member = x => x.ID;
Expression> memberCast = x => x.ID;
- Assert.True(Utility.IsPropertyExpression(null));
+ Assert.False(Utility.IsPropertyExpression(null));
Assert.True(Utility.IsPropertyExpression(member));
Assert.True(Utility.IsPropertyExpression(memberCast));
Expression> call = x => x.GetID("test");
Assert.False(Utility.IsPropertyExpression(call));
}
+
+ [Fact]
+ public void GetPropertyName_Test()
+ {
+ Expression> member = x => x.ID;
+ Expression> memberCast = x => x.ID;
+
+ Assert.Equal("ID", Utility.GetPropertyName(member));
+ Assert.Equal("ID", Utility.GetPropertyName(memberCast));
+ Assert.Throws(() => Utility.GetPropertyName(null));
+ }
}
}
\ No newline at end of file
diff --git a/Kyoo/Controllers/ProviderManager.cs b/Kyoo/Controllers/ProviderManager.cs
index 61f41593..4a0a0cd1 100644
--- a/Kyoo/Controllers/ProviderManager.cs
+++ b/Kyoo/Controllers/ProviderManager.cs
@@ -29,7 +29,7 @@ namespace Kyoo.Controllers
{
try
{
- ret = Utility.Merge(ret, await providerCall(provider));
+ ret = Merger.Merge(ret, await providerCall(provider));
} catch (Exception ex)
{
await Console.Error.WriteLineAsync(
diff --git a/Kyoo/Controllers/Repositories/EpisodeRepository.cs b/Kyoo/Controllers/Repositories/EpisodeRepository.cs
index 8452b950..6487e6b1 100644
--- a/Kyoo/Controllers/Repositories/EpisodeRepository.cs
+++ b/Kyoo/Controllers/Repositories/EpisodeRepository.cs
@@ -213,7 +213,7 @@ namespace Kyoo.Controllers
/// Set track's index and ensure that every tracks is well-formed.
///
/// The resource to fix.
- /// The parameter is returnned.
+ /// The parameter is returned.
private async Task ValidateTracks(Episode resource)
{
resource.Tracks = await resource.Tracks.MapAsync((x, i) =>