diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index 95af52570..11597cb99 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -25,7 +25,7 @@ - + diff --git a/API.Tests/Helpers/PrivateObjectPrivateType.cs b/API.Tests/Helpers/PrivateObjectPrivateType.cs new file mode 100644 index 000000000..e99016828 --- /dev/null +++ b/API.Tests/Helpers/PrivateObjectPrivateType.cs @@ -0,0 +1,1864 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Reflection; + +namespace Microsoft.VisualStudio.TestTools.UnitTesting +{ + /// + /// This class represents the live NON public INTERNAL object in the system + /// + public class PrivateObject + { + // bind everything + private const BindingFlags BindToEveryThing = BindingFlags.Default | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public; + + private static BindingFlags constructorFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.CreateInstance | BindingFlags.NonPublic; + + private object target; // automatically initialized to null + private Type originalType; // automatically initialized to null + + private Dictionary> methodCache; // automatically initialized to null + + /// + /// Initializes a new instance of the class that contains + /// the already existing object of the private class + /// + /// object that serves as starting point to reach the private members + /// the derefrencing string using . that points to the object to be retrived as in m_X.m_Y.m_Z + public PrivateObject(object obj, string memberToAccess) + { + ValidateAccessString(memberToAccess); + + PrivateObject temp = obj as PrivateObject; + if (temp == null) + { + temp = new PrivateObject(obj); + } + + // Split The access string + string[] arr = memberToAccess.Split(new char[] { '.' }); + + for (int i = 0; i < arr.Length; i++) + { + object next = temp.InvokeHelper(arr[i], BindToEveryThing | BindingFlags.Instance | BindingFlags.GetField | BindingFlags.GetProperty, null, CultureInfo.InvariantCulture); + temp = new PrivateObject(next); + } + + this.target = temp.target; + this.originalType = temp.originalType; + } + + /// + /// Initializes a new instance of the class that wraps the + /// specified type. + /// + /// Name of the assembly + /// fully qualified name + /// Argmenets to pass to the constructor + public PrivateObject(string assemblyName, string typeName, params object[] args) + : this(assemblyName, typeName, null, args) + { + } + + /// + /// Initializes a new instance of the class that wraps the + /// specified type. + /// + /// Name of the assembly + /// fully qualified name + /// An array of objects representing the number, order, and type of the parameters for the constructor to get + /// Argmenets to pass to the constructor + public PrivateObject(string assemblyName, string typeName, Type[] parameterTypes, object[] args) + : this(Type.GetType(string.Format(CultureInfo.InvariantCulture, "{0}, {1}", typeName, assemblyName), false), parameterTypes, args) + { + } + + /// + /// Initializes a new instance of the class that wraps the + /// specified type. + /// + /// type of the object to create + /// Argmenets to pass to the constructor + public PrivateObject(Type type, params object[] args) + : this(type, null, args) + { + } + + /// + /// Initializes a new instance of the class that wraps the + /// specified type. + /// + /// type of the object to create + /// An array of objects representing the number, order, and type of the parameters for the constructor to get + /// Argmenets to pass to the constructor + public PrivateObject(Type type, Type[] parameterTypes, object[] args) + { + object o; + if (parameterTypes != null) + { + ConstructorInfo ci = type.GetConstructor(BindToEveryThing, null, parameterTypes, null); + if (ci == null) + { + throw new ArgumentException("The constructor with the specified signature could not be found. You might need to regenerate your private accessor, or the member may be private and defined on a base class. If the latter is true, you need to pass the type that defines the member into PrivateObject's constructor."); + } + + try + { + o = ci.Invoke(args); + } + catch (TargetInvocationException e) + { + Debug.Assert(e.InnerException != null, "Inner exception should not be null."); + if (e.InnerException != null) + { + throw e.InnerException; + } + + throw; + } + } + else + { + o = Activator.CreateInstance(type, constructorFlags, null, args, null); + } + + this.ConstructFrom(o); + } + + /// + /// Initializes a new instance of the class that wraps + /// the given object. + /// + /// object to wrap + public PrivateObject(object obj) + { + this.ConstructFrom(obj); + } + + /// + /// Initializes a new instance of the class that wraps + /// the given object. + /// + /// object to wrap + /// PrivateType object + public PrivateObject(object obj, PrivateType type) + { + this.target = obj; + this.originalType = type.ReferencedType; + } + + /// + /// Gets or sets the target + /// + public object Target + { + get + { + return this.target; + } + + set + { + this.target = value; + this.originalType = value.GetType(); + } + } + + /// + /// Gets the type of underlying object + /// + public Type RealType + { + get + { + return this.originalType; + } + } + + private Dictionary> GenericMethodCache + { + get + { + if (this.methodCache == null) + { + this.BuildGenericMethodCacheForType(this.originalType); + } + + Debug.Assert(this.methodCache != null, "Invalid method cache for type."); + + return this.methodCache; + } + } + + /// + /// returns the hash code of the target object + /// + /// int representing hashcode of the target object + public override int GetHashCode() + { + Debug.Assert(this.target != null, "target should not be null."); + return this.target.GetHashCode(); + } + + /// + /// Equals + /// + /// Object with whom to compare + /// returns true if the objects are equal. + public override bool Equals(object obj) + { + if (this != obj) + { + Debug.Assert(this.target != null, "target should not be null."); + if (typeof(PrivateObject) == obj?.GetType()) + { + return this.target.Equals(((PrivateObject)obj).target); + } + else + { + return false; + } + } + + return true; + } + + /// + /// Invokes the specified method + /// + /// Name of the method + /// Arguments to pass to the member to invoke. + /// Result of method call + public object Invoke(string name, params object[] args) + { + return this.Invoke(name, null, args, CultureInfo.InvariantCulture); + } + + /// + /// Invokes the specified method + /// + /// Name of the method + /// An array of objects representing the number, order, and type of the parameters for the method to get. + /// Arguments to pass to the member to invoke. + /// Result of method call + public object Invoke(string name, Type[] parameterTypes, object[] args) + { + return this.Invoke(name, parameterTypes, args, CultureInfo.InvariantCulture); + } + + /// + /// Invokes the specified method + /// + /// Name of the method + /// An array of objects representing the number, order, and type of the parameters for the method to get. + /// Arguments to pass to the member to invoke. + /// An array of types corresponding to the types of the generic arguments. + /// Result of method call + public object Invoke(string name, Type[] parameterTypes, object[] args, Type[] typeArguments) + { + return this.Invoke(name, BindToEveryThing, parameterTypes, args, CultureInfo.InvariantCulture, typeArguments); + } + + /// + /// Invokes the specified method + /// + /// Name of the method + /// Arguments to pass to the member to invoke. + /// Culture info + /// Result of method call + public object Invoke(string name, object[] args, CultureInfo culture) + { + return this.Invoke(name, null, args, culture); + } + + /// + /// Invokes the specified method + /// + /// Name of the method + /// An array of objects representing the number, order, and type of the parameters for the method to get. + /// Arguments to pass to the member to invoke. + /// Culture info + /// Result of method call + public object Invoke(string name, Type[] parameterTypes, object[] args, CultureInfo culture) + { + return this.Invoke(name, BindToEveryThing, parameterTypes, args, culture); + } + + /// + /// Invokes the specified method + /// + /// Name of the method + /// A bitmask comprised of one or more that specify how the search is conducted. + /// Arguments to pass to the member to invoke. + /// Result of method call + public object Invoke(string name, BindingFlags bindingFlags, params object[] args) + { + return this.Invoke(name, bindingFlags, null, args, CultureInfo.InvariantCulture); + } + + /// + /// Invokes the specified method + /// + /// Name of the method + /// A bitmask comprised of one or more that specify how the search is conducted. + /// An array of objects representing the number, order, and type of the parameters for the method to get. + /// Arguments to pass to the member to invoke. + /// Result of method call + public object Invoke(string name, BindingFlags bindingFlags, Type[] parameterTypes, object[] args) + { + return this.Invoke(name, bindingFlags, parameterTypes, args, CultureInfo.InvariantCulture); + } + + /// + /// Invokes the specified method + /// + /// Name of the method + /// A bitmask comprised of one or more that specify how the search is conducted. + /// Arguments to pass to the member to invoke. + /// Culture info + /// Result of method call + public object Invoke(string name, BindingFlags bindingFlags, object[] args, CultureInfo culture) + { + return this.Invoke(name, bindingFlags, null, args, culture); + } + + /// + /// Invokes the specified method + /// + /// Name of the method + /// A bitmask comprised of one or more that specify how the search is conducted. + /// An array of objects representing the number, order, and type of the parameters for the method to get. + /// Arguments to pass to the member to invoke. + /// Culture info + /// Result of method call + public object Invoke(string name, BindingFlags bindingFlags, Type[] parameterTypes, object[] args, CultureInfo culture) + { + return this.Invoke(name, bindingFlags, parameterTypes, args, culture, null); + } + + /// + /// Invokes the specified method + /// + /// Name of the method + /// A bitmask comprised of one or more that specify how the search is conducted. + /// An array of objects representing the number, order, and type of the parameters for the method to get. + /// Arguments to pass to the member to invoke. + /// Culture info + /// An array of types corresponding to the types of the generic arguments. + /// Result of method call + public object Invoke(string name, BindingFlags bindingFlags, Type[] parameterTypes, object[] args, CultureInfo culture, Type[] typeArguments) + { + if (parameterTypes != null) + { + bindingFlags |= BindToEveryThing | BindingFlags.Instance; + + // Fix up the parameter types + MethodInfo member = this.originalType.GetMethod(name, bindingFlags, null, parameterTypes, null); + + // If the method was not found and type arguments were provided for generic paramaters, + // attempt to look up a generic method. + if ((member == null) && (typeArguments != null)) + { + // This method may contain generic parameters...if so, the previous call to + // GetMethod() will fail because it doesn't fully support generic parameters. + + // Look in the method cache to see if there is a generic method + // on the incoming type that contains the correct signature. + member = this.GetGenericMethodFromCache(name, parameterTypes, typeArguments, bindingFlags, null); + } + + if (member == null) + { + throw new ArgumentException( + string.Format(CultureInfo.CurrentCulture, "The member specified ({0}) could not be found. You might need to regenerate your private accessor, or the member may be private and defined on a base class. If the latter is true, you need to pass the type that defines the member into PrivateObject's constructor.", name)); + } + + try + { + if (member.IsGenericMethodDefinition) + { + MethodInfo constructed = member.MakeGenericMethod(typeArguments); + return constructed.Invoke(this.target, bindingFlags, null, args, culture); + } + else + { + return member.Invoke(this.target, bindingFlags, null, args, culture); + } + } + catch (TargetInvocationException e) + { + Debug.Assert(e.InnerException != null, "Inner exception should not be null."); + if (e.InnerException != null) + { + throw e.InnerException; + } + + throw; + } + } + else + { + return this.InvokeHelper(name, bindingFlags | BindingFlags.InvokeMethod, args, culture); + } + } + + /// + /// Gets the array element using array of subsrcipts for each dimension + /// + /// Name of the member + /// the indices of array + /// An arrya of elements. + public object GetArrayElement(string name, params int[] indices) + { + return this.GetArrayElement(name, BindToEveryThing, indices); + } + + /// + /// Sets the array element using array of subsrcipts for each dimension + /// + /// Name of the member + /// Value to set + /// the indices of array + public void SetArrayElement(string name, object value, params int[] indices) + { + this.SetArrayElement(name, BindToEveryThing, value, indices); + } + + /// + /// Gets the array element using array of subsrcipts for each dimension + /// + /// Name of the member + /// A bitmask comprised of one or more that specify how the search is conducted. + /// the indices of array + /// An arrya of elements. + public object GetArrayElement(string name, BindingFlags bindingFlags, params int[] indices) + { + Array arr = (Array)this.InvokeHelper(name, BindingFlags.GetField | bindingFlags, null, CultureInfo.InvariantCulture); + return arr.GetValue(indices); + } + + /// + /// Sets the array element using array of subsrcipts for each dimension + /// + /// Name of the member + /// A bitmask comprised of one or more that specify how the search is conducted. + /// Value to set + /// the indices of array + public void SetArrayElement(string name, BindingFlags bindingFlags, object value, params int[] indices) + { + Array arr = (Array)this.InvokeHelper(name, BindingFlags.GetField | bindingFlags, null, CultureInfo.InvariantCulture); + arr.SetValue(value, indices); + } + + /// + /// Get the field + /// + /// Name of the field + /// The field. + public object GetField(string name) + { + return this.GetField(name, BindToEveryThing); + } + + /// + /// Sets the field + /// + /// Name of the field + /// value to set + public void SetField(string name, object value) + { + this.SetField(name, BindToEveryThing, value); + } + + /// + /// Gets the field + /// + /// Name of the field + /// A bitmask comprised of one or more that specify how the search is conducted. + /// The field. + public object GetField(string name, BindingFlags bindingFlags) + { + return this.InvokeHelper(name, BindingFlags.GetField | bindingFlags, null, CultureInfo.InvariantCulture); + } + + /// + /// Sets the field + /// + /// Name of the field + /// A bitmask comprised of one or more that specify how the search is conducted. + /// value to set + public void SetField(string name, BindingFlags bindingFlags, object value) + { + this.InvokeHelper(name, BindingFlags.SetField | bindingFlags, new object[] { value }, CultureInfo.InvariantCulture); + } + + /// + /// Get the field or property + /// + /// Name of the field or property + /// The field or property. + public object GetFieldOrProperty(string name) + { + return this.GetFieldOrProperty(name, BindToEveryThing); + } + + /// + /// Sets the field or property + /// + /// Name of the field or property + /// value to set + public void SetFieldOrProperty(string name, object value) + { + this.SetFieldOrProperty(name, BindToEveryThing, value); + } + + /// + /// Gets the field or property + /// + /// Name of the field or property + /// A bitmask comprised of one or more that specify how the search is conducted. + /// The field or property. + public object GetFieldOrProperty(string name, BindingFlags bindingFlags) + { + return this.InvokeHelper(name, BindingFlags.GetField | BindingFlags.GetProperty | bindingFlags, null, CultureInfo.InvariantCulture); + } + + /// + /// Sets the field or property + /// + /// Name of the field or property + /// A bitmask comprised of one or more that specify how the search is conducted. + /// value to set + public void SetFieldOrProperty(string name, BindingFlags bindingFlags, object value) + { + this.InvokeHelper(name, BindingFlags.SetField | BindingFlags.SetProperty | bindingFlags, new object[] { value }, CultureInfo.InvariantCulture); + } + + /// + /// Gets the property + /// + /// Name of the property + /// Arguments to pass to the member to invoke. + /// The property. + public object GetProperty(string name, params object[] args) + { + return this.GetProperty(name, null, args); + } + + /// + /// Gets the property + /// + /// Name of the property + /// An array of objects representing the number, order, and type of the parameters for the indexed property. + /// Arguments to pass to the member to invoke. + /// The property. + public object GetProperty(string name, Type[] parameterTypes, object[] args) + { + return this.GetProperty(name, BindToEveryThing, parameterTypes, args); + } + + /// + /// Set the property + /// + /// Name of the property + /// value to set + /// Arguments to pass to the member to invoke. + public void SetProperty(string name, object value, params object[] args) + { + this.SetProperty(name, null, value, args); + } + + /// + /// Set the property + /// + /// Name of the property + /// An array of objects representing the number, order, and type of the parameters for the indexed property. + /// value to set + /// Arguments to pass to the member to invoke. + public void SetProperty(string name, Type[] parameterTypes, object value, object[] args) + { + this.SetProperty(name, BindToEveryThing, value, parameterTypes, args); + } + + /// + /// Gets the property + /// + /// Name of the property + /// A bitmask comprised of one or more that specify how the search is conducted. + /// Arguments to pass to the member to invoke. + /// The property. + public object GetProperty(string name, BindingFlags bindingFlags, params object[] args) + { + return this.GetProperty(name, bindingFlags, null, args); + } + + /// + /// Gets the property + /// + /// Name of the property + /// A bitmask comprised of one or more that specify how the search is conducted. + /// An array of objects representing the number, order, and type of the parameters for the indexed property. + /// Arguments to pass to the member to invoke. + /// The property. + public object GetProperty(string name, BindingFlags bindingFlags, Type[] parameterTypes, object[] args) + { + if (parameterTypes != null) + { + PropertyInfo pi = this.originalType.GetProperty(name, bindingFlags, null, null, parameterTypes, null); + if (pi == null) + { + throw new ArgumentException( + string.Format(CultureInfo.CurrentCulture, "The member specified ({0}) could not be found. You might need to regenerate your private accessor, or the member may be private and defined on a base class. If the latter is true, you need to pass the type that defines the member into PrivateObject's constructor.", name)); + } + + return pi.GetValue(this.target, args); + } + else + { + return this.InvokeHelper(name, bindingFlags | BindingFlags.GetProperty, args, null); + } + } + + /// + /// Sets the property + /// + /// Name of the property + /// A bitmask comprised of one or more that specify how the search is conducted. + /// value to set + /// Arguments to pass to the member to invoke. + public void SetProperty(string name, BindingFlags bindingFlags, object value, params object[] args) + { + this.SetProperty(name, bindingFlags, value, null, args); + } + + /// + /// Sets the property + /// + /// Name of the property + /// A bitmask comprised of one or more that specify how the search is conducted. + /// value to set + /// An array of objects representing the number, order, and type of the parameters for the indexed property. + /// Arguments to pass to the member to invoke. + public void SetProperty(string name, BindingFlags bindingFlags, object value, Type[] parameterTypes, object[] args) + { + if (parameterTypes != null) + { + PropertyInfo pi = this.originalType.GetProperty(name, bindingFlags, null, null, parameterTypes, null); + if (pi == null) + { + throw new ArgumentException( + string.Format(CultureInfo.CurrentCulture, "The member specified ({0}) could not be found. You might need to regenerate your private accessor, or the member may be private and defined on a base class. If the latter is true, you need to pass the type that defines the member into PrivateObject's constructor.", name)); + } + + pi.SetValue(this.target, value, args); + } + else + { + object[] pass = new object[(args?.Length ?? 0) + 1]; + pass[0] = value; + args?.CopyTo(pass, 1); + this.InvokeHelper(name, bindingFlags | BindingFlags.SetProperty, pass, null); + } + } + + /// + /// Validate access string + /// + /// access string + private static void ValidateAccessString(string access) + { + if (access.Length == 0) + { + throw new ArgumentException("Access string has invalid syntax."); + } + + string[] arr = access.Split('.'); + foreach (string str in arr) + { + if ((str.Length == 0) || (str.IndexOfAny(new char[] { ' ', '\t', '\n' }) != -1)) + { + throw new ArgumentException("Access string has invalid syntax."); + } + } + } + + /// + /// Invokes the memeber + /// + /// Name of the member + /// Additional attributes + /// Arguments for the invocation + /// Culture + /// Result of the invocation + private object InvokeHelper(string name, BindingFlags bindingFlags, object[] args, CultureInfo culture) + { + Debug.Assert(this.target != null, "Internal Error: Null reference is returned for internal object"); + + // Invoke the actual Method + try + { + return this.originalType.InvokeMember(name, bindingFlags, null, this.target, args, culture); + } + catch (TargetInvocationException e) + { + Debug.Assert(e.InnerException != null, "Inner exception should not be null."); + if (e.InnerException != null) + { + throw e.InnerException; + } + + throw; + } + } + + private void ConstructFrom(object obj) + { + this.target = obj; + this.originalType = obj.GetType(); + } + + private void BuildGenericMethodCacheForType(Type t) + { + Debug.Assert(t != null, "type should not be null."); + this.methodCache = new Dictionary>(); + + MethodInfo[] members = t.GetMethods(BindToEveryThing); + LinkedList listByName; // automatically initialized to null + + foreach (MethodInfo member in members) + { + if (member.IsGenericMethod || member.IsGenericMethodDefinition) + { + if (!this.GenericMethodCache.TryGetValue(member.Name, out listByName)) + { + listByName = new LinkedList(); + this.GenericMethodCache.Add(member.Name, listByName); + } + + Debug.Assert(listByName != null, "list should not be null."); + listByName.AddLast(member); + } + } + } + + /// + /// Extracts the most appropriate generic method signature from the current private type. + /// + /// The name of the method in which to search the signature cache. + /// An array of types corresponding to the types of the parameters in which to search. + /// An array of types corresponding to the types of the generic arguments. + /// to further filter the method signatures. + /// Modifiers for parameters. + /// A methodinfo instance. + private MethodInfo GetGenericMethodFromCache(string methodName, Type[] parameterTypes, Type[] typeArguments, BindingFlags bindingFlags, ParameterModifier[] modifiers) + { + Debug.Assert(!string.IsNullOrEmpty(methodName), "Invalid method name."); + Debug.Assert(parameterTypes != null, "Invalid parameter type array."); + Debug.Assert(typeArguments != null, "Invalid type arguments array."); + + // Build a preliminary list of method candidates that contain roughly the same signature. + var methodCandidates = this.GetMethodCandidates(methodName, parameterTypes, typeArguments, bindingFlags, modifiers); + + // Search of ambiguous methods (methods with the same signature). + MethodInfo[] finalCandidates = new MethodInfo[methodCandidates.Count]; + methodCandidates.CopyTo(finalCandidates, 0); + + if ((parameterTypes != null) && (parameterTypes.Length == 0)) + { + for (int i = 0; i < finalCandidates.Length; i++) + { + MethodInfo methodInfo = finalCandidates[i]; + + if (!RuntimeTypeHelper.CompareMethodSigAndName(methodInfo, finalCandidates[0])) + { + throw new AmbiguousMatchException(); + } + } + + // All the methods have the exact same name and sig so return the most derived one. + return RuntimeTypeHelper.FindMostDerivedNewSlotMeth(finalCandidates, finalCandidates.Length) as MethodInfo; + } + + // Now that we have a preliminary list of candidates, select the most appropriate one. + return RuntimeTypeHelper.SelectMethod(bindingFlags, finalCandidates, parameterTypes, modifiers) as MethodInfo; + } + + private LinkedList GetMethodCandidates(string methodName, Type[] parameterTypes, Type[] typeArguments, BindingFlags bindingFlags, ParameterModifier[] modifiers) + { + Debug.Assert(!string.IsNullOrEmpty(methodName), "methodName should not be null."); + Debug.Assert(parameterTypes != null, "parameterTypes should not be null."); + Debug.Assert(typeArguments != null, "typeArguments should not be null."); + + LinkedList methodCandidates = new LinkedList(); + LinkedList methods = null; + + if (!this.GenericMethodCache.TryGetValue(methodName, out methods)) + { + return methodCandidates; + } + + Debug.Assert(methods != null, "methods should not be null."); + + foreach (MethodInfo candidate in methods) + { + bool paramMatch = true; + ParameterInfo[] candidateParams = null; + Type[] genericArgs = candidate.GetGenericArguments(); + Type sourceParameterType = null; + + if (genericArgs.Length != typeArguments.Length) + { + continue; + } + + // Since we can't just get the correct MethodInfo from Reflection, + // we will just match the number of parameters, their order, and their type + var methodCandidate = candidate; + candidateParams = methodCandidate.GetParameters(); + + if (candidateParams.Length != parameterTypes.Length) + { + continue; + } + + // Exact binding + if ((bindingFlags & BindingFlags.ExactBinding) != 0) + { + int i = 0; + + foreach (ParameterInfo candidateParam in candidateParams) + { + sourceParameterType = parameterTypes[i++]; + + if (candidateParam.ParameterType.ContainsGenericParameters) + { + // Since we have a generic parameter here, just make sure the IsArray matches. + if (candidateParam.ParameterType.IsArray != sourceParameterType.IsArray) + { + paramMatch = false; + break; + } + } + else + { + if (candidateParam.ParameterType != sourceParameterType) + { + paramMatch = false; + break; + } + } + } + + if (paramMatch) + { + methodCandidates.AddLast(methodCandidate); + continue; + } + } + else + { + methodCandidates.AddLast(methodCandidate); + } + } + + return methodCandidates; + } + } + + /// + /// This class represents a private class for the Private Accessor functionality. + /// + public class PrivateType + { + /// + /// Binds to everything + /// + private const BindingFlags BindToEveryThing = BindingFlags.Default + | BindingFlags.NonPublic | BindingFlags.Instance + | BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy; + + /// + /// The wrapped type. + /// + private Type type; + + /// + /// Initializes a new instance of the class that contains the private type. + /// + /// Assembly name + /// fully qualified name of the + public PrivateType(string assemblyName, string typeName) + { + Assembly asm = Assembly.Load(assemblyName); + + this.type = asm.GetType(typeName, true); + } + + /// + /// Initializes a new instance of the class that contains + /// the private type from the type object + /// + /// The wrapped Type to create. + public PrivateType(Type type) + { + if (type == null) + { + throw new ArgumentNullException("type"); + } + + this.type = type; + } + + /// + /// Gets the referenced type + /// + public Type ReferencedType => this.type; + + /// + /// Invokes static member + /// + /// Name of the member to InvokeHelper + /// Arguements to the invoction + /// Result of invocation + public object InvokeStatic(string name, params object[] args) + { + return this.InvokeStatic(name, null, args, CultureInfo.InvariantCulture); + } + + /// + /// Invokes static member + /// + /// Name of the member to InvokeHelper + /// An array of objects representing the number, order, and type of the parameters for the method to invoke + /// Arguements to the invoction + /// Result of invocation + public object InvokeStatic(string name, Type[] parameterTypes, object[] args) + { + return this.InvokeStatic(name, parameterTypes, args, CultureInfo.InvariantCulture); + } + + /// + /// Invokes static member + /// + /// Name of the member to InvokeHelper + /// An array of objects representing the number, order, and type of the parameters for the method to invoke + /// Arguements to the invoction + /// An array of types corresponding to the types of the generic arguments. + /// Result of invocation + public object InvokeStatic(string name, Type[] parameterTypes, object[] args, Type[] typeArguments) + { + return this.InvokeStatic(name, BindToEveryThing, parameterTypes, args, CultureInfo.InvariantCulture, typeArguments); + } + + /// + /// Invokes the static method + /// + /// Name of the member + /// Arguements to the invocation + /// Culture + /// Result of invocation + public object InvokeStatic(string name, object[] args, CultureInfo culture) + { + return this.InvokeStatic(name, null, args, culture); + } + + /// + /// Invokes the static method + /// + /// Name of the member + /// An array of objects representing the number, order, and type of the parameters for the method to invoke + /// Arguements to the invocation + /// Culture info + /// Result of invocation + public object InvokeStatic(string name, Type[] parameterTypes, object[] args, CultureInfo culture) + { + return this.InvokeStatic(name, BindingFlags.InvokeMethod, parameterTypes, args, culture); + } + + /// + /// Invokes the static method + /// + /// Name of the member + /// Additional invocation attributes + /// Arguements to the invocation + /// Result of invocation + public object InvokeStatic(string name, BindingFlags bindingFlags, params object[] args) + { + return this.InvokeStatic(name, bindingFlags, null, args, CultureInfo.InvariantCulture); + } + + /// + /// Invokes the static method + /// + /// Name of the member + /// Additional invocation attributes + /// An array of objects representing the number, order, and type of the parameters for the method to invoke + /// Arguements to the invocation + /// Result of invocation + public object InvokeStatic(string name, BindingFlags bindingFlags, Type[] parameterTypes, object[] args) + { + return this.InvokeStatic(name, bindingFlags, parameterTypes, args, CultureInfo.InvariantCulture); + } + + /// + /// Invokes the static method + /// + /// Name of the member + /// Additional invocation attributes + /// Arguements to the invocation + /// Culture + /// Result of invocation + public object InvokeStatic(string name, BindingFlags bindingFlags, object[] args, CultureInfo culture) + { + return this.InvokeStatic(name, bindingFlags, null, args, culture); + } + + /// + /// Invokes the static method + /// + /// Name of the member + /// Additional invocation attributes + /// /// An array of objects representing the number, order, and type of the parameters for the method to invoke + /// Arguements to the invocation + /// Culture + /// Result of invocation + public object InvokeStatic(string name, BindingFlags bindingFlags, Type[] parameterTypes, object[] args, CultureInfo culture) + { + return this.InvokeStatic(name, bindingFlags, parameterTypes, args, culture, null); + } + + /// + /// Invokes the static method + /// + /// Name of the member + /// Additional invocation attributes + /// /// An array of objects representing the number, order, and type of the parameters for the method to invoke + /// Arguements to the invocation + /// Culture + /// An array of types corresponding to the types of the generic arguments. + /// Result of invocation + public object InvokeStatic(string name, BindingFlags bindingFlags, Type[] parameterTypes, object[] args, CultureInfo culture, Type[] typeArguments) + { + if (parameterTypes != null) + { + MethodInfo member = this.type.GetMethod(name, bindingFlags | BindToEveryThing | BindingFlags.Static, null, parameterTypes, null); + if (member == null) + { + throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, "The member specified ({0}) could not be found. You might need to regenerate your private accessor, or the member may be private and defined on a base class. If the latter is true, you need to pass the type that defines the member into PrivateObject's constructor.", name)); + } + + try + { + if (member.IsGenericMethodDefinition) + { + MethodInfo constructed = member.MakeGenericMethod(typeArguments); + return constructed.Invoke(null, bindingFlags, null, args, culture); + } + else + { + return member.Invoke(null, bindingFlags, null, args, culture); + } + } + catch (TargetInvocationException e) + { + Debug.Assert(e.InnerException != null, "Inner Exception should not be null."); + if (e.InnerException != null) + { + throw e.InnerException; + } + + throw; + } + } + else + { + return this.InvokeHelperStatic(name, bindingFlags | BindingFlags.InvokeMethod, args, culture); + } + } + + /// + /// Gets the element in static array + /// + /// Name of the array + /// + /// A one-dimensional array of 32-bit integers that represent the indexes specifying + /// the position of the element to get. For instance, to access a[10][11] the indices would be {10,11} + /// + /// element at the specified location + public object GetStaticArrayElement(string name, params int[] indices) + { + return this.GetStaticArrayElement(name, BindToEveryThing, indices); + } + + /// + /// Sets the memeber of the static array + /// + /// Name of the array + /// value to set + /// + /// A one-dimensional array of 32-bit integers that represent the indexes specifying + /// the position of the element to set. For instance, to access a[10][11] the array would be {10,11} + /// + public void SetStaticArrayElement(string name, object value, params int[] indices) + { + this.SetStaticArrayElement(name, BindToEveryThing, value, indices); + } + + /// + /// Gets the element in satatic array + /// + /// Name of the array + /// Additional InvokeHelper attributes + /// + /// A one-dimensional array of 32-bit integers that represent the indexes specifying + /// the position of the element to get. For instance, to access a[10][11] the array would be {10,11} + /// + /// element at the spcified location + public object GetStaticArrayElement(string name, BindingFlags bindingFlags, params int[] indices) + { + Array arr = (Array)this.InvokeHelperStatic(name, BindingFlags.GetField | BindingFlags.GetProperty | bindingFlags, null, CultureInfo.InvariantCulture); + return arr.GetValue(indices); + } + + /// + /// Sets the memeber of the static array + /// + /// Name of the array + /// Additional InvokeHelper attributes + /// value to set + /// + /// A one-dimensional array of 32-bit integers that represent the indexes specifying + /// the position of the element to set. For instance, to access a[10][11] the array would be {10,11} + /// + public void SetStaticArrayElement(string name, BindingFlags bindingFlags, object value, params int[] indices) + { + Array arr = (Array)this.InvokeHelperStatic(name, BindingFlags.GetField | BindingFlags.GetProperty | BindingFlags.Static | bindingFlags, null, CultureInfo.InvariantCulture); + arr.SetValue(value, indices); + } + + /// + /// Gets the static field + /// + /// Name of the field + /// The static field. + public object GetStaticField(string name) + { + return this.GetStaticField(name, BindToEveryThing); + } + + /// + /// Sets the static field + /// + /// Name of the field + /// Arguement to the invocation + public void SetStaticField(string name, object value) + { + this.SetStaticField(name, BindToEveryThing, value); + } + + /// + /// Gets the static field using specified InvokeHelper attributes + /// + /// Name of the field + /// Additional invocation attributes + /// The static field. + public object GetStaticField(string name, BindingFlags bindingFlags) + { + return this.InvokeHelperStatic(name, BindingFlags.GetField | BindingFlags.Static | bindingFlags, null, CultureInfo.InvariantCulture); + } + + /// + /// Sets the static field using binding attributes + /// + /// Name of the field + /// Additional InvokeHelper attributes + /// Arguement to the invocation + public void SetStaticField(string name, BindingFlags bindingFlags, object value) + { + this.InvokeHelperStatic(name, BindingFlags.SetField | bindingFlags | BindingFlags.Static, new[] { value }, CultureInfo.InvariantCulture); + } + + /// + /// Gets the static field or property + /// + /// Name of the field or property + /// The static field or property. + public object GetStaticFieldOrProperty(string name) + { + return this.GetStaticFieldOrProperty(name, BindToEveryThing); + } + + /// + /// Sets the static field or property + /// + /// Name of the field or property + /// Value to be set to field or property + public void SetStaticFieldOrProperty(string name, object value) + { + this.SetStaticFieldOrProperty(name, BindToEveryThing, value); + } + + /// + /// Gets the static field or property using specified InvokeHelper attributes + /// + /// Name of the field or property + /// Additional invocation attributes + /// The static field or property. + public object GetStaticFieldOrProperty(string name, BindingFlags bindingFlags) + { + return this.InvokeHelperStatic(name, BindingFlags.GetField | BindingFlags.GetProperty | BindingFlags.Static | bindingFlags, null, CultureInfo.InvariantCulture); + } + + /// + /// Sets the static field or property using binding attributes + /// + /// Name of the field or property + /// Additional invocation attributes + /// Value to be set to field or property + public void SetStaticFieldOrProperty(string name, BindingFlags bindingFlags, object value) + { + this.InvokeHelperStatic(name, BindingFlags.SetField | BindingFlags.SetProperty | bindingFlags | BindingFlags.Static, new[] { value }, CultureInfo.InvariantCulture); + } + + /// + /// Gets the static property + /// + /// Name of the field or property + /// Arguements to the invocation + /// The static property. + public object GetStaticProperty(string name, params object[] args) + { + return this.GetStaticProperty(name, BindToEveryThing, args); + } + + /// + /// Sets the static property + /// + /// Name of the property + /// Value to be set to field or property + /// Arguments to pass to the member to invoke. + public void SetStaticProperty(string name, object value, params object[] args) + { + this.SetStaticProperty(name, BindToEveryThing, value, null, args); + } + + /// + /// Sets the static property + /// + /// Name of the property + /// Value to be set to field or property + /// An array of objects representing the number, order, and type of the parameters for the indexed property. + /// Arguments to pass to the member to invoke. + public void SetStaticProperty(string name, object value, Type[] parameterTypes, object[] args) + { + this.SetStaticProperty(name, BindingFlags.SetProperty, value, parameterTypes, args); + } + + /// + /// Gets the static property + /// + /// Name of the property + /// Additional invocation attributes. + /// Arguments to pass to the member to invoke. + /// The static property. + public object GetStaticProperty(string name, BindingFlags bindingFlags, params object[] args) + { + return this.GetStaticProperty(name, BindingFlags.GetProperty | BindingFlags.Static | bindingFlags, null, args); + } + + /// + /// Gets the static property + /// + /// Name of the property + /// Additional invocation attributes. + /// An array of objects representing the number, order, and type of the parameters for the indexed property. + /// Arguments to pass to the member to invoke. + /// The static property. + public object GetStaticProperty(string name, BindingFlags bindingFlags, Type[] parameterTypes, object[] args) + { + if (parameterTypes != null) + { + PropertyInfo pi = this.type.GetProperty(name, bindingFlags | BindingFlags.Static, null, null, parameterTypes, null); + if (pi == null) + { + throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, "The member specified ({0}) could not be found. You might need to regenerate your private accessor, or the member may be private and defined on a base class. If the latter is true, you need to pass the type that defines the member into PrivateObject's constructor.", name)); + } + + return pi.GetValue(null, args); + } + else + { + return this.InvokeHelperStatic(name, bindingFlags | BindingFlags.GetProperty, args, null); + } + } + + /// + /// Sets the static property + /// + /// Name of the property + /// Additional invocation attributes. + /// Value to be set to field or property + /// Optional index values for indexed properties. The indexes of indexed properties are zero-based. This value should be null for non-indexed properties. + public void SetStaticProperty(string name, BindingFlags bindingFlags, object value, params object[] args) + { + this.SetStaticProperty(name, bindingFlags, value, null, args); + } + + /// + /// Sets the static property + /// + /// Name of the property + /// Additional invocation attributes. + /// Value to be set to field or property + /// An array of objects representing the number, order, and type of the parameters for the indexed property. + /// Arguments to pass to the member to invoke. + public void SetStaticProperty(string name, BindingFlags bindingFlags, object value, Type[] parameterTypes, object[] args) + { + if (parameterTypes != null) + { + PropertyInfo pi = this.type.GetProperty(name, bindingFlags | BindingFlags.Static, null, null, parameterTypes, null); + if (pi == null) + { + throw new ArgumentException( + string.Format(CultureInfo.CurrentCulture, "The member specified ({0}) could not be found. You might need to regenerate your private accessor, or the member may be private and defined on a base class. If the latter is true, you need to pass the type that defines the member into PrivateObject's constructor.", name)); + } + + pi.SetValue(null, value, args); + } + else + { + object[] pass = new object[(args?.Length ?? 0) + 1]; + pass[0] = value; + args?.CopyTo(pass, 1); + this.InvokeHelperStatic(name, bindingFlags | BindingFlags.SetProperty, pass, null); + } + } + + /// + /// Invokes the static method + /// + /// Name of the member + /// Additional invocation attributes + /// Arguements to the invocation + /// Culture + /// Result of invocation + private object InvokeHelperStatic(string name, BindingFlags bindingFlags, object[] args, CultureInfo culture) + { + try + { + return this.type.InvokeMember(name, bindingFlags | BindToEveryThing | BindingFlags.Static, null, null, args, culture); + } + catch (TargetInvocationException e) + { + Debug.Assert(e.InnerException != null, "Inner Exception should not be null."); + if (e.InnerException != null) + { + throw e.InnerException; + } + + throw; + } + } + } + + /// + /// Provides method signature discovery for generic methods. + /// + internal class RuntimeTypeHelper + { + /// + /// Compares the method signatures of these two methods. + /// + /// Method1 + /// Method2 + /// True if they are similiar. + internal static bool CompareMethodSigAndName(MethodBase m1, MethodBase m2) + { + ParameterInfo[] params1 = m1.GetParameters(); + ParameterInfo[] params2 = m2.GetParameters(); + + if (params1.Length != params2.Length) + { + return false; + } + + int numParams = params1.Length; + for (int i = 0; i < numParams; i++) + { + if (params1[i].ParameterType != params2[i].ParameterType) + { + return false; + } + } + + return true; + } + + /// + /// Gets the hierarchy depth from the base type of the provided type. + /// + /// The type. + /// The depth. + internal static int GetHierarchyDepth(Type t) + { + int depth = 0; + + Type currentType = t; + do + { + depth++; + currentType = currentType.BaseType; + } + while (currentType != null); + + return depth; + } + + /// + /// Finds most dervied type with the provided information. + /// + /// Candidate matches. + /// Number of matches. + /// The most derived method. + internal static MethodBase FindMostDerivedNewSlotMeth(MethodBase[] match, int cMatches) + { + int deepestHierarchy = 0; + MethodBase methWithDeepestHierarchy = null; + + for (int i = 0; i < cMatches; i++) + { + // Calculate the depth of the hierarchy of the declaring type of the + // current method. + int currentHierarchyDepth = GetHierarchyDepth(match[i].DeclaringType); + + // Two methods with the same hierarchy depth are not allowed. This would + // mean that there are 2 methods with the same name and sig on a given type + // which is not allowed, unless one of them is vararg... + if (currentHierarchyDepth == deepestHierarchy) + { + if (methWithDeepestHierarchy != null) + { + Debug.Assert( + methWithDeepestHierarchy != null && ((match[i].CallingConvention & CallingConventions.VarArgs) + | (methWithDeepestHierarchy.CallingConvention & CallingConventions.VarArgs)) != 0, + "Calling conventions: " + match[i].CallingConvention + " - " + methWithDeepestHierarchy.CallingConvention); + } + + throw new AmbiguousMatchException(); + } + + // Check to see if this method is on the most derived class. + if (currentHierarchyDepth > deepestHierarchy) + { + deepestHierarchy = currentHierarchyDepth; + methWithDeepestHierarchy = match[i]; + } + } + + return methWithDeepestHierarchy; + } + + /// + /// Given a set of methods that match the base criteria, select a method based + /// upon an array of types. This method should return null if no method matches + /// the criteria. + /// + /// Binding specification. + /// Candidate matches + /// Types + /// Parameter modifiers. + /// Matching method. Null if none matches. + internal static MethodBase SelectMethod(BindingFlags bindingAttr, MethodBase[] match, Type[] types, ParameterModifier[] modifiers) + { + if (match == null) + { + throw new ArgumentNullException("match"); + } + + int i; + int j; + + Type[] realTypes = new Type[types.Length]; + for (i = 0; i < types.Length; i++) + { + realTypes[i] = types[i].UnderlyingSystemType; + } + + types = realTypes; + + // If there are no methods to match to, then return null, indicating that no method + // matches the criteria + if (match.Length == 0) + { + return null; + } + + // Find all the methods that can be described by the types parameter. + // Remove all of them that cannot. + int curIdx = 0; + for (i = 0; i < match.Length; i++) + { + ParameterInfo[] par = match[i].GetParameters(); + if (par.Length != types.Length) + { + continue; + } + + for (j = 0; j < types.Length; j++) + { + Type pCls = par[j].ParameterType; + + if (pCls.ContainsGenericParameters) + { + if (pCls.IsArray != types[j].IsArray) + { + break; + } + } + else + { + if (pCls == types[j]) + { + continue; + } + + if (pCls == typeof(object)) + { + continue; + } + else + { + if (!pCls.IsAssignableFrom(types[j])) + { + break; + } + } + } + } + + if (j == types.Length) + { + match[curIdx++] = match[i]; + } + } + + if (curIdx == 0) + { + return null; + } + + if (curIdx == 1) + { + return match[0]; + } + + // Walk all of the methods looking the most specific method to invoke + int currentMin = 0; + bool ambig = false; + int[] paramOrder = new int[types.Length]; + for (i = 0; i < types.Length; i++) + { + paramOrder[i] = i; + } + + for (i = 1; i < curIdx; i++) + { + int newMin = FindMostSpecificMethod(match[currentMin], paramOrder, null, match[i], paramOrder, null, types, null); + if (newMin == 0) + { + ambig = true; + } + else + { + if (newMin == 2) + { + currentMin = i; + ambig = false; + currentMin = i; + } + } + } + + if (ambig) + { + throw new AmbiguousMatchException(); + } + + return match[currentMin]; + } + + /// + /// Finds the most specific method in the two methods provided. + /// + /// Method 1 + /// Parameter order for Method 1 + /// Paramter array type. + /// Method 2 + /// Parameter order for Method 2 + /// >Paramter array type. + /// Types to search in. + /// Args. + /// An int representing the match. + internal static int FindMostSpecificMethod( + MethodBase m1, + int[] paramOrder1, + Type paramArrayType1, + MethodBase m2, + int[] paramOrder2, + Type paramArrayType2, + Type[] types, + object[] args) + { + // Find the most specific method based on the parameters. + int res = FindMostSpecific( + m1.GetParameters(), + paramOrder1, + paramArrayType1, + m2.GetParameters(), + paramOrder2, + paramArrayType2, + types, + args); + + // If the match was not ambiguous then return the result. + if (res != 0) + { + return res; + } + + // Check to see if the methods have the exact same name and signature. + if (CompareMethodSigAndName(m1, m2)) + { + // Determine the depth of the declaring types for both methods. + int hierarchyDepth1 = GetHierarchyDepth(m1.DeclaringType); + int hierarchyDepth2 = GetHierarchyDepth(m2.DeclaringType); + + // The most derived method is the most specific one. + if (hierarchyDepth1 == hierarchyDepth2) + { + return 0; + } + else if (hierarchyDepth1 < hierarchyDepth2) + { + return 2; + } + else + { + return 1; + } + } + + // The match is ambiguous. + return 0; + } + + /// + /// Finds the most specific method in the two methods provided. + /// + /// Method 1 + /// Parameter order for Method 1 + /// Paramter array type. + /// Method 2 + /// Parameter order for Method 2 + /// >Paramter array type. + /// Types to search in. + /// Args. + /// An int representing the match. + internal static int FindMostSpecific( + ParameterInfo[] p1, + int[] paramOrder1, + Type paramArrayType1, + ParameterInfo[] p2, + int[] paramOrder2, + Type paramArrayType2, + Type[] types, + object[] args) + { + // A method using params is always less specific than one not using params + if (paramArrayType1 != null && paramArrayType2 == null) + { + return 2; + } + + if (paramArrayType2 != null && paramArrayType1 == null) + { + return 1; + } + + bool p1Less = false; + bool p2Less = false; + + for (int i = 0; i < types.Length; i++) + { + if (args != null && args[i] == Type.Missing) + { + continue; + } + + Type c1, c2; + + // If a param array is present, then either + // the user re-ordered the parameters in which case + // the argument to the param array is either an array + // in which case the params is conceptually ignored and so paramArrayType1 == null + // or the argument to the param array is a single element + // in which case paramOrder[i] == p1.Length - 1 for that element + // or the user did not re-order the parameters in which case + // the paramOrder array could contain indexes larger than p.Length - 1 + //// so any index >= p.Length - 1 is being put in the param array + + if (paramArrayType1 != null && paramOrder1[i] >= p1.Length - 1) + { + c1 = paramArrayType1; + } + else + { + c1 = p1[paramOrder1[i]].ParameterType; + } + + if (paramArrayType2 != null && paramOrder2[i] >= p2.Length - 1) + { + c2 = paramArrayType2; + } + else + { + c2 = p2[paramOrder2[i]].ParameterType; + } + + if (c1 == c2) + { + continue; + } + + if (c1.ContainsGenericParameters || c2.ContainsGenericParameters) + { + continue; + } + + switch (FindMostSpecificType(c1, c2, types[i])) + { + case 0: + return 0; + case 1: + p1Less = true; + break; + case 2: + p2Less = true; + break; + } + } + + // Two way p1Less and p2Less can be equal. All the arguments are the + // same they both equal false, otherwise there were things that both + // were the most specific type on.... + if (p1Less == p2Less) + { + // it's possible that the 2 methods have same sig and default param in which case we match the one + // with the same number of args but only if they were exactly the same (that is p1Less and p2Lees are both false) + if (!p1Less && p1.Length != p2.Length && args != null) + { + if (p1.Length == args.Length) + { + return 1; + } + else if (p2.Length == args.Length) + { + return 2; + } + } + + return 0; + } + else + { + return (p1Less == true) ? 1 : 2; + } + } + + /// + /// Finds the most specific type in the two provided. + /// + /// Type 1 + /// Type 2 + /// The defining type + /// An int representing the match. + internal static int FindMostSpecificType(Type c1, Type c2, Type t) + { + // If the two types are exact move on... + if (c1 == c2) + { + return 0; + } + + if (c1 == t) + { + return 1; + } + + if (c2 == t) + { + return 2; + } + + bool c1FromC2; + bool c2FromC1; + + if (c1.IsByRef || c2.IsByRef) + { + if (c1.IsByRef && c2.IsByRef) + { + c1 = c1.GetElementType(); + c2 = c2.GetElementType(); + } + else if (c1.IsByRef) + { + if (c1.GetElementType() == c2) + { + return 2; + } + + c1 = c1.GetElementType(); + } + else + { + if (c2.GetElementType() == c1) + { + return 1; + } + + c2 = c2.GetElementType(); + } + } + + if (c1.IsPrimitive && c2.IsPrimitive) + { + c1FromC2 = true; + c2FromC1 = true; + } + else + { + c1FromC2 = c1.IsAssignableFrom(c2); + c2FromC1 = c2.IsAssignableFrom(c1); + } + + if (c1FromC2 == c2FromC1) + { + return 0; + } + + if (c1FromC2) + { + return 2; + } + else + { + return 1; + } + } + } +} \ No newline at end of file diff --git a/API.Tests/ParserTest.cs b/API.Tests/ParserTest.cs index d91e82092..895cca89a 100644 --- a/API.Tests/ParserTest.cs +++ b/API.Tests/ParserTest.cs @@ -1,13 +1,23 @@ +using System; using System.Collections.Generic; using API.Entities.Enums; using API.Parser; using Xunit; +using Xunit.Abstractions; using static API.Parser.Parser; namespace API.Tests { public class ParserTests { + private readonly ITestOutputHelper _testOutputHelper; + + + public ParserTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + [Theory] [InlineData("Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb)", "1")] [InlineData("My Girlfriend Is Shobitch v01 - ch. 09 - pg. 008.png", "1")] @@ -18,6 +28,7 @@ namespace API.Tests [InlineData("Dance in the Vampire Bund v16-17 (Digital) (NiceDragon)", "16-17")] [InlineData("Akame ga KILL! ZERO v01 (2016) (Digital) (LuCaZ).cbz", "1")] [InlineData("v001", "1")] + [InlineData("Vol 1", "1")] [InlineData("No Volume", "0")] [InlineData("U12 (Under 12) Vol. 0001 Ch. 0001 - Reiwa Scans (gb)", "1")] [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip", "1")] @@ -35,7 +46,10 @@ namespace API.Tests [InlineData("Dorohedoro v12 (2013) (Digital) (LostNerevarine-Empire).cbz", "12")] [InlineData("Yumekui_Merry_v01_c01[Bakayarou-Kuu].rar", "1")] [InlineData("Yumekui-Merry_DKThias_Chapter11v2.zip", "0")] - + [InlineData("Itoshi no Karin - c001-006x1 (v01) [Renzokusei Scans]", "1")] + [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 12", "0")] + [InlineData("VanDread-v01-c001[MD].zip", "1")] + [InlineData("Ichiban_Ushiro_no_Daimaou_v04_ch27_[VISCANS].zip", "4")] public void ParseVolumeTest(string filename, string expected) { Assert.Equal(expected, ParseVolume(filename)); @@ -79,7 +93,14 @@ namespace API.Tests [InlineData("Ichiban_Ushiro_no_Daimaou_v04_ch34_[VISCANS].zip", "Ichiban Ushiro no Daimaou")] [InlineData("Rent a Girlfriend v01.cbr", "Rent a Girlfriend")] [InlineData("Yumekui_Merry_v01_c01[Bakayarou-Kuu].rar", "Yumekui Merry")] - //[InlineData("[Tempus Edax Rerum] Epigraph of the Closed Curve - Chapter 6.zip", "Epigraph of the Closed Curve")] + [InlineData("Itoshi no Karin - c001-006x1 (v01) [Renzokusei Scans]", "Itoshi no Karin")] + [InlineData("Tonikaku Kawaii Vol-1 (Ch 01-08)", "Tonikaku Kawaii")] + [InlineData("Tonikaku Kawaii (Ch 59-67) (Ongoing)", "Tonikaku Kawaii")] + [InlineData("7thGARDEN v01 (2016) (Digital) (danke).cbz", "7thGARDEN")] + [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 12", "Kedouin Makoto - Corpse Party Musume")] + [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 09", "Kedouin Makoto - Corpse Party Musume")] + [InlineData("Goblin Slayer Side Story - Year One 025.5", "Goblin Slayer Side Story - Year One")] + [InlineData("Goblin Slayer - Brand New Day 006.5 (2019) (Digital) (danke-Empire)", "Goblin Slayer - Brand New Day")] public void ParseSeriesTest(string filename, string expected) { Assert.Equal(expected, ParseSeries(filename)); @@ -113,6 +134,12 @@ namespace API.Tests [InlineData("Goblin Slayer Side Story - Year One 017.5", "17.5")] [InlineData("Beelzebub_53[KSH].zip", "53")] [InlineData("Black Bullet - v4 c20.5 [batoto]", "20.5")] + [InlineData("Itoshi no Karin - c001-006x1 (v01) [Renzokusei Scans]", "1-6")] + [InlineData("APOSIMZ 040 (2020) (Digital) (danke-Empire).cbz", "40")] + [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 12", "12")] + [InlineData("Vol 1", "0")] + [InlineData("VanDread-v01-c001[MD].zip", "1")] + [InlineData("Goblin Slayer Side Story - Year One 025.5", "25.5")] //[InlineData("[Tempus Edax Rerum] Epigraph of the Closed Curve - Chapter 6.zip", "6")] public void ParseChaptersTest(string filename, string expected) { @@ -174,11 +201,22 @@ namespace API.Tests [InlineData("12-14", 12)] [InlineData("24", 24)] [InlineData("18-04", 4)] - public void MinimumNumberFromRangeTest(string input, int expected) + [InlineData("18-04.5", 4.5)] + [InlineData("40", 40)] + public void MinimumNumberFromRangeTest(string input, float expected) { Assert.Equal(expected, MinimumNumberFromRange(input)); } - + + [Theory] + [InlineData("Darker Than Black", "darkerthanblack")] + [InlineData("Darker Than Black - Something", "darkerthanblacksomething")] + [InlineData("", "")] + public void NormalizeTest(string input, string expected) + { + Assert.Equal(expected, Normalize(input)); + } + [Fact] public void ParseInfoTest() @@ -241,6 +279,30 @@ namespace API.Tests FullFilePath = filepath }); + filepath = @"E:\Manga\APOSIMZ\APOSIMZ 040 (2020) (Digital) (danke-Empire).cbz"; + expected.Add(filepath, new ParserInfo + { + Series = "APOSIMZ", Volumes = "0", Edition = "", + Chapters = "40", Filename = "APOSIMZ 040 (2020) (Digital) (danke-Empire).cbz", Format = MangaFormat.Archive, + FullFilePath = filepath + }); + + filepath = @"E:\Manga\Corpse Party Musume\Kedouin Makoto - Corpse Party Musume, Chapter 09.cbz"; + expected.Add(filepath, new ParserInfo + { + Series = "Kedouin Makoto - Corpse Party Musume", Volumes = "0", Edition = "", + Chapters = "9", Filename = "Kedouin Makoto - Corpse Party Musume, Chapter 09.cbz", Format = MangaFormat.Archive, + FullFilePath = filepath + }); + + filepath = @"E:\Manga\Goblin Slayer\Goblin Slayer - Brand New Day 006.5 (2019) (Digital) (danke-Empire).cbz"; + expected.Add(filepath, new ParserInfo + { + Series = "Goblin Slayer - Brand New Day", Volumes = "0", Edition = "", + Chapters = "6.5", Filename = "Goblin Slayer - Brand New Day 006.5 (2019) (Digital) (danke-Empire).cbz", Format = MangaFormat.Archive, + FullFilePath = filepath + }); + @@ -255,12 +317,20 @@ namespace API.Tests return; } Assert.NotNull(actual); + _testOutputHelper.WriteLine($"Validating {file}"); + _testOutputHelper.WriteLine("Format"); Assert.Equal(expectedInfo.Format, actual.Format); + _testOutputHelper.WriteLine("Series"); Assert.Equal(expectedInfo.Series, actual.Series); + _testOutputHelper.WriteLine("Chapters"); Assert.Equal(expectedInfo.Chapters, actual.Chapters); + _testOutputHelper.WriteLine("Volumes"); Assert.Equal(expectedInfo.Volumes, actual.Volumes); + _testOutputHelper.WriteLine("Edition"); Assert.Equal(expectedInfo.Edition, actual.Edition); + _testOutputHelper.WriteLine("Filename"); Assert.Equal(expectedInfo.Filename, actual.Filename); + _testOutputHelper.WriteLine("FullFilePath"); Assert.Equal(expectedInfo.FullFilePath, actual.FullFilePath); } } diff --git a/API.Tests/Services/DirectoryServiceTests.cs b/API.Tests/Services/DirectoryServiceTests.cs new file mode 100644 index 000000000..567c7e6a9 --- /dev/null +++ b/API.Tests/Services/DirectoryServiceTests.cs @@ -0,0 +1,32 @@ +using API.Interfaces; +using API.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services +{ + + public class DirectoryServiceTests + { + private readonly DirectoryService _directoryService; + private readonly ILogger _logger = Substitute.For>(); + + public DirectoryServiceTests() + { + _directoryService = new DirectoryService(_logger); + } + + [Fact] + public void GetFiles_Test() + { + //_directoryService.GetFiles() + } + + [Fact] + public void ListDirectory_Test() + { + + } + } +} \ No newline at end of file diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs new file mode 100644 index 000000000..96abd7b36 --- /dev/null +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using API.Entities; +using API.Entities.Enums; +using API.Interfaces; +using API.Interfaces.Services; +using API.Parser; +using API.Services; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NSubstitute; +using Xunit; +using Xunit.Abstractions; + +namespace API.Tests.Services +{ + public class ScannerServiceTests + { + private readonly ITestOutputHelper _testOutputHelper; + private readonly ScannerService _scannerService; + private readonly ILogger _logger = Substitute.For>(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IArchiveService _archiveService = Substitute.For(); + private readonly IMetadataService _metadataService; + private readonly ILogger _metadataLogger = Substitute.For>(); + private Library _libraryMock; + + public ScannerServiceTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + _scannerService = new ScannerService(_unitOfWork, _logger, _archiveService, _metadataService); + _metadataService= Substitute.For(_unitOfWork, _metadataLogger, _archiveService); + _libraryMock = new Library() + { + Id = 1, + Name = "Manga", + Folders = new List() + { + new FolderPath() + { + Id = 1, + LastScanned = DateTime.Now, + LibraryId = 1, + Path = "E:/Manga" + } + }, + LastModified = DateTime.Now, + Series = new List() + { + new Series() + { + Id = 0, + Name = "Darker Than Black" + } + } + }; + + } + + // [Fact] + // public void ExistingOrDefault_Should_BeFromLibrary() + // { + // var allSeries = new List() + // { + // new Series() {Id = 2, Name = "Darker Than Black"}, + // new Series() {Id = 3, Name = "Darker Than Black - Some Extension"}, + // new Series() {Id = 4, Name = "Akame Ga Kill"}, + // }; + // Assert.Equal(_libraryMock.Series.ElementAt(0).Id, ScannerService.ExistingOrDefault(_libraryMock, allSeries, "Darker Than Black").Id); + // Assert.Equal(_libraryMock.Series.ElementAt(0).Id, ScannerService.ExistingOrDefault(_libraryMock, allSeries, "Darker than Black").Id); + // } + // + // [Fact] + // public void ExistingOrDefault_Should_BeFromAllSeries() + // { + // var allSeries = new List() + // { + // new Series() {Id = 2, Name = "Darker Than Black"}, + // new Series() {Id = 3, Name = "Darker Than Black - Some Extension"}, + // new Series() {Id = 4, Name = "Akame Ga Kill"}, + // }; + // Assert.Equal(3, ScannerService.ExistingOrDefault(_libraryMock, allSeries, "Darker Than Black - Some Extension").Id); + // } + // + // [Fact] + // public void ExistingOrDefault_Should_BeNull() + // { + // var allSeries = new List() + // { + // new Series() {Id = 2, Name = "Darker Than Black"}, + // new Series() {Id = 3, Name = "Darker Than Black - Some Extension"}, + // new Series() {Id = 4, Name = "Akame Ga Kill"}, + // }; + // Assert.Null(ScannerService.ExistingOrDefault(_libraryMock, allSeries, "Non existing series")); + // } + + [Fact] + public void Should_CreateSeries_Test() + { + // var allSeries = new List(); + // var parsedSeries = new Dictionary>(); + // + // parsedSeries.Add("Darker Than Black", new List() + // { + // new ParserInfo() {Chapters = "0", Filename = "Something.cbz", Format = MangaFormat.Archive, FullFilePath = "E:/Manga/Something.cbz", Series = "Darker Than Black", Volumes = "1"}, + // new ParserInfo() {Chapters = "0", Filename = "Something.cbz", Format = MangaFormat.Archive, FullFilePath = "E:/Manga/Something.cbz", Series = "Darker than Black", Volumes = "2"} + // }); + // + // _scannerService.UpsertSeries(_libraryMock, parsedSeries, allSeries); + // + // Assert.Equal(1, _libraryMock.Series.Count); + // Assert.Equal(2, _libraryMock.Series.ElementAt(0).Volumes.Count); + // _testOutputHelper.WriteLine(_libraryMock.ToString()); + Assert.True(true); + } + } +} \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ArchiveService/ComicInfos/ComicInfo.xml b/API.Tests/Services/Test Data/ArchiveService/ComicInfos/ComicInfo.xml new file mode 100644 index 000000000..03aa39370 --- /dev/null +++ b/API.Tests/Services/Test Data/ArchiveService/ComicInfos/ComicInfo.xml @@ -0,0 +1,13 @@ + + + v01 + BTOOOM! + https://www.comixology.com/BTOOOM/digital-comic/450184 + By all counts, Ryouta Sakamoto is a loser when he's not holed up in his room, bombing things into oblivion in his favorite online action RPG. But his very own uneventful life is blown to pieces when he's abducted and taken to an uninhabited island, where he soon learns the hard way that he's being pitted against others just like him in a explosives-riddled death match! How could this be happening? Who's putting them up to this? And why!? The name, not to mention the objective, of this very real survival game is eerily familiar to Ryouta, who has mastered its virtual counterpart-BTOOOM! Can Ryouta still come out on top when he's playing for his life!? + Scraped metadata from Comixology [CMXDB450184] + Yen Press + Manga, Movies & TV + 194 + en + + diff --git a/API/API.csproj b/API/API.csproj index 8c3278449..008455438 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -11,6 +11,7 @@ + diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 483f5a8eb..e1eda477c 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -102,10 +102,6 @@ namespace API.Controllers .Include(u => u.UserPreferences) .SingleOrDefaultAsync(x => x.NormalizedUserName == loginDto.Username.ToUpper()); - var debugUsers = await _userManager.Users.Select(x => x.NormalizedUserName).ToListAsync(); - - _logger.LogInformation($"All Users: {string.Join(",", debugUsers)}"); - if (user == null) return Unauthorized("Invalid username"); var result = await _signInManager diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 3ecd6bf8a..b068ffa0b 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -145,6 +145,7 @@ namespace API.Controllers [HttpPost("scan")] public ActionResult Scan(int libraryId) { + // TODO: We shouldn't queue up a job if one is already in progress _taskScheduler.ScanLibrary(libraryId); return Ok(); } diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 78a16f015..e5bb04fa5 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -70,14 +70,7 @@ namespace API.Controllers { return Ok(await _unitOfWork.VolumeRepository.GetChapterDtoAsync(chapterId)); } - - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("scan")] - public ActionResult Scan(int libraryId, int seriesId) - { - _taskScheduler.ScanSeries(libraryId, seriesId); - return Ok(); - } + [HttpPost("update-rating")] public async Task UpdateSeriesRating(UpdateSeriesRatingDto updateSeriesRatingDto) diff --git a/API/Data/LibraryRepository.cs b/API/Data/LibraryRepository.cs index 436736439..9fe73a193 100644 --- a/API/Data/LibraryRepository.cs +++ b/API/Data/LibraryRepository.cs @@ -75,6 +75,22 @@ namespace API.Data .Include(l => l.Series) .SingleAsync(); } + /// + /// This returns a Library with all it's Series -> Volumes -> Chapters. This is expensive. Should only be called when needed. + /// + /// + /// + public async Task GetFullLibraryForIdAsync(int libraryId) + { + return await _context.Library + .Where(x => x.Id == libraryId) + .Include(f => f.Folders) + .Include(l => l.Series) + .ThenInclude(s => s.Volumes) + .ThenInclude(v => v.Chapters) + .ThenInclude(c => c.Files) + .SingleAsync(); + } public async Task LibraryExists(string libraryName) { diff --git a/API/Data/Migrations/20210207231256_SeriesNormalizedName.Designer.cs b/API/Data/Migrations/20210207231256_SeriesNormalizedName.Designer.cs new file mode 100644 index 000000000..04c5c3d3d --- /dev/null +++ b/API/Data/Migrations/20210207231256_SeriesNormalizedName.Designer.cs @@ -0,0 +1,721 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20210207231256_SeriesNormalizedName")] + partial class SeriesNormalizedName + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HideReadOnDetails") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("NumberOfPages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210207231256_SeriesNormalizedName.cs b/API/Data/Migrations/20210207231256_SeriesNormalizedName.cs new file mode 100644 index 000000000..262583441 --- /dev/null +++ b/API/Data/Migrations/20210207231256_SeriesNormalizedName.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class SeriesNormalizedName : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "NormalizedName", + table: "Series", + type: "TEXT", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "NormalizedName", + table: "Series"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index c5066809c..0042238ea 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -343,6 +343,9 @@ namespace API.Data.Migrations b.Property("Name") .HasColumnType("TEXT"); + b.Property("NormalizedName") + .HasColumnType("TEXT"); + b.Property("OriginalName") .HasColumnType("TEXT"); diff --git a/API/Entities/MangaFile.cs b/API/Entities/MangaFile.cs index 7d001fcb0..c4471949a 100644 --- a/API/Entities/MangaFile.cs +++ b/API/Entities/MangaFile.cs @@ -13,7 +13,7 @@ namespace API.Entities /// /// Number of pages for the given file /// - public int NumberOfPages { get; set; } + public int NumberOfPages { get; set; } // TODO: Refactor this to Pages public MangaFormat Format { get; set; } // Relationship Mapping diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index f7e5f366e..ddc9a3b61 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -12,6 +12,10 @@ namespace API.Entities /// public string Name { get; set; } /// + /// Used internally for name matching. + /// + public string NormalizedName { get; set; } + /// /// The name used to sort the Series. By default, will be the same as Name. /// public string SortName { get; set; } @@ -32,7 +36,7 @@ namespace API.Entities public int Pages { get; set; } // Relationships - public ICollection Volumes { get; set; } + public List Volumes { get; set; } public Library Library { get; set; } public int LibraryId { get; set; } } diff --git a/API/Entities/Volume.cs b/API/Entities/Volume.cs index 0b8077aae..999b9a801 100644 --- a/API/Entities/Volume.cs +++ b/API/Entities/Volume.cs @@ -9,7 +9,7 @@ namespace API.Entities public int Id { get; set; } public string Name { get; set; } public int Number { get; set; } - public ICollection Chapters { get; set; } + public IList Chapters { get; set; } public DateTime Created { get; set; } public DateTime LastModified { get; set; } public byte[] CoverImage { get; set; } diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index 8cda03754..ad3f48bcb 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -1,6 +1,7 @@ using API.Data; using API.Helpers; using API.Interfaces; +using API.Interfaces.Services; using API.Services; using AutoMapper; using Hangfire; @@ -24,6 +25,7 @@ namespace API.Extensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); diff --git a/API/Interfaces/ILibraryRepository.cs b/API/Interfaces/ILibraryRepository.cs index d1de28288..3955355f2 100644 --- a/API/Interfaces/ILibraryRepository.cs +++ b/API/Interfaces/ILibraryRepository.cs @@ -12,6 +12,7 @@ namespace API.Interfaces Task> GetLibraryDtosAsync(); Task LibraryExists(string libraryName); Task GetLibraryForIdAsync(int libraryId); + Task GetFullLibraryForIdAsync(int libraryId); Task> GetLibraryDtosForUsernameAsync(string userName); Task> GetLibrariesAsync(); Task DeleteLibrary(int libraryId); diff --git a/API/Interfaces/IScannerService.cs b/API/Interfaces/IScannerService.cs index 87274e88b..10dd9303f 100644 --- a/API/Interfaces/IScannerService.cs +++ b/API/Interfaces/IScannerService.cs @@ -11,12 +11,5 @@ void ScanLibrary(int libraryId, bool forceUpdate); void ScanLibraries(); - - /// - /// Performs a forced scan of just a series folder. - /// - /// - /// - void ScanSeries(int libraryId, int seriesId); } } \ No newline at end of file diff --git a/API/Interfaces/ITaskScheduler.cs b/API/Interfaces/ITaskScheduler.cs index f7e13d7a6..2659eebec 100644 --- a/API/Interfaces/ITaskScheduler.cs +++ b/API/Interfaces/ITaskScheduler.cs @@ -4,6 +4,6 @@ { void ScanLibrary(int libraryId, bool forceUpdate = false); void CleanupChapters(int[] chapterIds); - void ScanSeries(int libraryId, int seriesId); + void RefreshMetadata(int libraryId, bool forceUpdate = true); } } \ No newline at end of file diff --git a/API/Interfaces/Services/IMetadataService.cs b/API/Interfaces/Services/IMetadataService.cs new file mode 100644 index 000000000..830cab1eb --- /dev/null +++ b/API/Interfaces/Services/IMetadataService.cs @@ -0,0 +1,18 @@ +using API.Entities; + +namespace API.Interfaces.Services +{ + public interface IMetadataService + { + /// + /// Recalculates metadata for all entities in a library. + /// + /// + /// + void RefreshMetadata(int libraryId, bool forceUpdate = false); + + public void UpdateMetadata(Chapter chapter, bool forceUpdate); + public void UpdateMetadata(Volume volume, bool forceUpdate); + public void UpdateMetadata(Series series, bool forceUpdate); + } +} \ No newline at end of file diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index dccdcf729..cc2ac7b5e 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -22,7 +22,7 @@ namespace API.Parser RegexOptions.IgnoreCase | RegexOptions.Compiled), // Historys Strongest Disciple Kenichi_v11_c90-98.zip or Dance in the Vampire Bund v16-17 new Regex( - @"(?.*)(\b|_)v(?\d+-?\d*)", + @"(?.*)(\b|_)v(?\d+(-\d+)?)", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb) new Regex( @@ -79,11 +79,18 @@ namespace API.Parser new Regex( @"(?.*)(?:, Chapter )(?\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled), + // Goblin Slayer - Brand New Day 006.5 (2019) (Digital) (danke-Empire) + new Regex( + @"(?.*) (?\d+(?:.\d+|-\d+)?) \(\d{4}\)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), // Akame ga KILL! ZERO (2016-2019) (Digital) (LuCaZ) new Regex( @"(?.*)\(\d", RegexOptions.IgnoreCase | RegexOptions.Compiled), - + // Tonikaku Kawaii (Ch 59-67) (Ongoing) + new Regex( + @"(?.*)( |_)\((c |ch |chapter )", + RegexOptions.IgnoreCase | RegexOptions.Compiled), // Black Bullet (This is very loose, keep towards bottom) (?.*)(_)(v|vo|c|volume) new Regex( @"(?.*)(_)(v|vo|c|volume)( |_)\d+", @@ -111,28 +118,28 @@ namespace API.Parser private static readonly Regex[] MangaChapterRegex = new[] { new Regex( - @"(c|ch)(\.? ?)(?\d+-?\d*)", + @"(c|ch)(\.? ?)(?\d+(?:.\d+|-\d+)?)", RegexOptions.IgnoreCase | RegexOptions.Compiled), // [Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip new Regex( - @"v\d+\.(?\d+-?\d*)", + @"v\d+\.(?\d+(?:.\d+|-\d+)?)", RegexOptions.IgnoreCase | RegexOptions.Compiled), - // Hinowa ga CRUSH! 018 (2019) (Digital) (LuCaZ).cbz + // Hinowa ga CRUSH! 018 (2019) (Digital) (LuCaZ).cbz, Hinowa ga CRUSH! 018.5 (2019) (Digital) (LuCaZ).cbz new Regex( - @"(?.*) (?\d+) (?:\(\d{4}\))", + @"^(?!Vol)(?.*) (?\d+(?:.\d+|-\d+)?)(?: \(\d{4}\))?", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Tower Of God S01 014 (CBT) (digital).cbz new Regex( - @"(?.*) S(?\d+) (?\d+)", + @"(?.*) S(?\d+) (?\d+(?:.\d+|-\d+)?)", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Beelzebub_01_[Noodles].zip new Regex( - @"^((?!v|vo|vol|Volume).)*( |_)(?\.?\d+)( |_|\[|\()", + @"^((?!v|vo|vol|Volume).)*( |_)(?\.?\d+(?:.\d+|-\d+)?)( |_|\[|\()", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Yumekui-Merry_DKThias_Chapter21.zip new Regex( - @"Chapter(?\d+(-\d+)?)", + @"Chapter(?\d+(-\d+)?)", //(?:.\d+|-\d+)? RegexOptions.IgnoreCase | RegexOptions.Compiled), }; @@ -399,10 +406,15 @@ namespace API.Parser return ImageRegex.IsMatch(fileInfo.Extension); } - public static int MinimumNumberFromRange(string range) + public static float MinimumNumberFromRange(string range) { var tokens = range.Split("-"); - return tokens.Min(Int32.Parse); + return tokens.Min(float.Parse); + } + + public static string Normalize(string name) + { + return name.ToLower().Replace("-", "").Replace(" ", ""); } } } \ No newline at end of file diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index 500605883..6f3e31965 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -26,7 +26,6 @@ namespace API.Services public int GetNumberOfPagesFromArchive(string archivePath) { if (!IsValidArchive(archivePath)) return 0; - _logger.LogDebug($"Getting Page numbers from {archivePath}"); try { @@ -35,7 +34,7 @@ namespace API.Services } catch (Exception ex) { - _logger.LogError(ex, "There was an exception when reading archive stream."); + _logger.LogError(ex, "There was an exception when reading archive stream: {ArchivePath}. Defaulting to 0 pages", archivePath); return 0; } } @@ -53,9 +52,8 @@ namespace API.Services try { if (!IsValidArchive(filepath)) return Array.Empty(); - _logger.LogDebug($"Extracting Cover image from {filepath}"); - - using ZipArchive archive = ZipFile.OpenRead(filepath); + + using var archive = ZipFile.OpenRead(filepath); if (!archive.HasFiles()) return Array.Empty(); var folder = archive.Entries.SingleOrDefault(x => Path.GetFileNameWithoutExtension(x.Name).ToLower() == "folder"); @@ -66,7 +64,7 @@ namespace API.Services } catch (Exception ex) { - _logger.LogError(ex, "There was an exception when reading archive stream."); + _logger.LogError(ex, "There was an exception when reading archive stream: {Filepath}. Defaulting to no cover image", filepath); } return Array.Empty(); @@ -82,7 +80,7 @@ namespace API.Services } catch (Exception ex) { - _logger.LogError(ex, "There was a critical error and prevented thumbnail generation. Defaulting to no cover image."); + _logger.LogError(ex, "There was a critical error and prevented thumbnail generation on {EntryName}. Defaulting to no cover image", entry.FullName); } return Array.Empty(); @@ -119,20 +117,28 @@ namespace API.Services /// public bool IsValidArchive(string archivePath) { - if (!File.Exists(archivePath)) + try { - _logger.LogError($"Archive {archivePath} could not be found."); - return false; - } - if (!Parser.Parser.IsArchive(archivePath)) - { - _logger.LogError($"Archive {archivePath} is not a valid archive."); - return false; - } + if (!File.Exists(archivePath)) + { + _logger.LogError("Archive {ArchivePath} could not be found", archivePath); + return false; + } - using var archive = ZipFile.OpenRead(archivePath); - if (archive.Entries.Any(e => Parser.Parser.IsImage(e.FullName))) return true; - _logger.LogError($"Archive {archivePath} contains no images."); + if (!Parser.Parser.IsArchive(archivePath)) + { + _logger.LogError("Archive {ArchivePath} is not a valid archive", archivePath); + return false; + } + + using var archive = ZipFile.OpenRead(archivePath); + if (archive.Entries.Any(e => Parser.Parser.IsImage(e.FullName))) return true; + _logger.LogError("Archive {ArchivePath} contains no images", archivePath); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to validate archive ({ArchivePath}) due to problem opening archive", archivePath); + } return false; } @@ -151,7 +157,7 @@ namespace API.Services if (Directory.Exists(extractPath)) { - _logger.LogDebug($"Archive {archivePath} has already been extracted. Returning existing folder."); + _logger.LogDebug("Archive {ArchivePath} has already been extracted. Returning existing folder", archivePath); return; } @@ -161,14 +167,14 @@ namespace API.Services if (!archive.HasFiles() && !needsFlattening) return; archive.ExtractToDirectory(extractPath, true); - _logger.LogDebug($"Extracted archive to {extractPath} in {sw.ElapsedMilliseconds} milliseconds."); + _logger.LogDebug("Extracted archive to {ExtractPath} in {ElapsedMilliseconds} milliseconds", extractPath, sw.ElapsedMilliseconds); if (needsFlattening) { sw = Stopwatch.StartNew(); _logger.LogInformation("Extracted archive is nested in root folder, flattening..."); new DirectoryInfo(extractPath).Flatten(); - _logger.LogInformation($"Flattened in {sw.ElapsedMilliseconds} milliseconds"); + _logger.LogInformation("Flattened in {ElapsedMilliseconds} milliseconds", sw.ElapsedMilliseconds); } } } diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index 06ce2df99..31df9ac80 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -91,7 +91,7 @@ namespace API.Services /// Directory to scan /// Action to apply on file path /// - public static int TraverseTreeParallelForEach(string root, Action action) + public static int TraverseTreeParallelForEach(string root, Action action, string searchPattern) { //Count of files traversed and timer for diagnostic output var fileCount = 0; @@ -130,7 +130,7 @@ namespace API.Services // TODO: In future, we need to take LibraryType into consideration for what extensions to allow (RAW should allow images) // or we need to move this filtering to another area (Process) // or we can get all files and put a check in place during Process to abandon files - files = GetFilesWithCertainExtensions(currentDir, Parser.Parser.MangaFileExtensions) + files = GetFilesWithCertainExtensions(currentDir, searchPattern) .ToArray(); } catch (UnauthorizedAccessException e) { diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs new file mode 100644 index 000000000..589bdc49c --- /dev/null +++ b/API/Services/MetadataService.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Interfaces; +using API.Interfaces.Services; +using Microsoft.Extensions.Logging; + +namespace API.Services +{ + public class MetadataService : IMetadataService + { + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + private readonly IArchiveService _archiveService; + + public MetadataService(IUnitOfWork unitOfWork, ILogger logger, IArchiveService archiveService) + { + _unitOfWork = unitOfWork; + _logger = logger; + _archiveService = archiveService; + } + + private static bool ShouldFindCoverImage(byte[] coverImage, bool forceUpdate = false) + { + return forceUpdate || coverImage == null || !coverImage.Any(); + } + + public void UpdateMetadata(Chapter chapter, bool forceUpdate) + { + if (chapter != null && ShouldFindCoverImage(chapter.CoverImage, forceUpdate)) + { + chapter.Files ??= new List(); + var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault(); + if (firstFile != null) chapter.CoverImage = _archiveService.GetCoverImage(firstFile.FilePath, true); + } + } + + public void UpdateMetadata(Volume volume, bool forceUpdate) + { + if (volume != null && ShouldFindCoverImage(volume.CoverImage, forceUpdate)) + { + // TODO: Create a custom sorter for Chapters so it's consistent across the application + volume.Chapters ??= new List(); + var firstChapter = volume.Chapters.OrderBy(x => Double.Parse(x.Number)).FirstOrDefault(); + var firstFile = firstChapter?.Files.OrderBy(x => x.Chapter).FirstOrDefault(); + if (firstFile != null) volume.CoverImage = _archiveService.GetCoverImage(firstFile.FilePath, true); + } + } + + public void UpdateMetadata(Series series, bool forceUpdate) + { + if (series == null) return; + if (ShouldFindCoverImage(series.CoverImage, forceUpdate)) + { + series.Volumes ??= new List(); + var firstCover = series.Volumes.OrderBy(x => x.Number).FirstOrDefault(x => x.Number != 0); + if (firstCover == null && series.Volumes.Any()) + { + firstCover = series.Volumes.FirstOrDefault(x => x.Number == 0); + } + series.CoverImage = firstCover?.CoverImage; + } + + if (string.IsNullOrEmpty(series.Summary) || forceUpdate) + { + series.Summary = ""; + } + } + + public void RefreshMetadata(int libraryId, bool forceUpdate = false) + { + var sw = Stopwatch.StartNew(); + var library = Task.Run(() => _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId)).Result; + var allSeries = Task.Run(() => _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(libraryId)).Result.ToList(); + + _logger.LogInformation($"Beginning metadata refresh of {library.Name}"); + foreach (var series in allSeries) + { + series.NormalizedName = Parser.Parser.Normalize(series.Name); + + var volumes = _unitOfWork.SeriesRepository.GetVolumes(series.Id).ToList(); + foreach (var volume in volumes) + { + foreach (var chapter in volume.Chapters) + { + UpdateMetadata(chapter, forceUpdate); + } + + UpdateMetadata(volume, forceUpdate); + } + + UpdateMetadata(series, forceUpdate); + _unitOfWork.SeriesRepository.Update(series); + } + + + if (_unitOfWork.HasChanges() && Task.Run(() => _unitOfWork.Complete()).Result) + { + _logger.LogInformation($"Updated metadata for {library.Name} in {sw.ElapsedMilliseconds} ms."); + } + } + } +} \ No newline at end of file diff --git a/API/Services/ScannerService.cs b/API/Services/ScannerService.cs index 8121df16f..6766c97de 100644 --- a/API/Services/ScannerService.cs +++ b/API/Services/ScannerService.cs @@ -1,17 +1,21 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Collections.Immutable; using System.Diagnostics; +using System.Globalization; using System.IO; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading.Tasks; using API.Entities; using API.Entities.Enums; using API.Interfaces; +using API.Interfaces.Services; using API.Parser; +using Hangfire; using Microsoft.Extensions.Logging; +[assembly: InternalsVisibleTo("API.Tests")] namespace API.Services { public class ScannerService : IScannerService @@ -19,15 +23,20 @@ namespace API.Services private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IArchiveService _archiveService; + private readonly IMetadataService _metadataService; private ConcurrentDictionary> _scannedSeries; + private bool _forceUpdate; - public ScannerService(IUnitOfWork unitOfWork, ILogger logger, IArchiveService archiveService) + public ScannerService(IUnitOfWork unitOfWork, ILogger logger, IArchiveService archiveService, + IMetadataService metadataService) { _unitOfWork = unitOfWork; _logger = logger; _archiveService = archiveService; + _metadataService = metadataService; } + [DisableConcurrentExecution(timeoutInSeconds: 120)] public void ScanLibraries() { var libraries = Task.Run(() => _unitOfWork.LibraryRepository.GetLibrariesAsync()).Result.ToList(); @@ -37,34 +46,53 @@ namespace API.Services } } + private bool ShouldSkipFolderScan(FolderPath folder, ref int skippedFolders) + { + // NOTE: This solution isn't the best, but it has potential. We need to handle a few other cases so it works great. + return false; + + // if (/*_environment.IsProduction() && */!_forceUpdate && Directory.GetLastWriteTime(folder.Path) < folder.LastScanned) + // { + // _logger.LogDebug($"{folder.Path} hasn't been updated since last scan. Skipping."); + // skippedFolders += 1; + // return true; + // } + // + // return false; + } + + private void Cleanup() + { + _scannedSeries = null; + } + + [DisableConcurrentExecution(timeoutInSeconds: 360)] public void ScanLibrary(int libraryId, bool forceUpdate) - { - - var sw = Stopwatch.StartNew(); + { + _forceUpdate = forceUpdate; + var sw = Stopwatch.StartNew(); + Cleanup(); Library library; try { - library = Task.Run(() => _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId)).Result; + library = Task.Run(() => _unitOfWork.LibraryRepository.GetFullLibraryForIdAsync(libraryId)).Result; } catch (Exception ex) { // This usually only fails if user is not authenticated. - _logger.LogError($"There was an issue fetching Library {libraryId}.", ex); + _logger.LogError(ex, "There was an issue fetching Library {LibraryId}", libraryId); return; } _scannedSeries = new ConcurrentDictionary>(); - _logger.LogInformation($"Beginning scan on {library.Name}. Forcing metadata update: {forceUpdate}"); + _logger.LogInformation("Beginning scan on {LibraryName}. Forcing metadata update: {ForceUpdate}", library.Name, forceUpdate); var totalFiles = 0; + var skippedFolders = 0; foreach (var folderPath in library.Folders) { - if (!forceUpdate && Directory.GetLastWriteTime(folderPath.Path) <= folderPath.LastScanned) - { - _logger.LogDebug($"{folderPath.Path} hasn't been updated since last scan. Skipping."); - continue; - } - + if (ShouldSkipFolderScan(folderPath, ref skippedFolders)) continue; + try { totalFiles += DirectoryService.TraverseTreeParallelForEach(folderPath.Path, (f) => { @@ -74,84 +102,204 @@ namespace API.Services } catch (FileNotFoundException exception) { - _logger.LogError(exception, "The file could not be found"); + _logger.LogError(exception, "The file {Filename} could not be found", f); } - }); + }, Parser.Parser.MangaFileExtensions); } catch (ArgumentException ex) { - _logger.LogError(ex, $"The directory '{folderPath}' does not exist"); + _logger.LogError(ex, "The directory '{FolderPath}' does not exist", folderPath.Path); } + + folderPath.LastScanned = DateTime.Now; } + var scanElapsedTime = sw.ElapsedMilliseconds; + _logger.LogInformation("Folders Scanned {TotalFiles} files in {ElapsedScanTime} milliseconds", totalFiles, scanElapsedTime); + sw.Restart(); + if (skippedFolders == library.Folders.Count) + { + _logger.LogInformation("All Folders were skipped due to no modifications to the directories"); + _unitOfWork.LibraryRepository.Update(library); + _logger.LogInformation("Processed {TotalFiles} files in {ElapsedScanTime} milliseconds for {LibraryName}", totalFiles, sw.ElapsedMilliseconds, library.Name); + Cleanup(); + return; + } + + // Remove any series where there were no parsed infos var filtered = _scannedSeries.Where(kvp => kvp.Value.Count != 0); - var series = filtered.ToImmutableDictionary(v => v.Key, v => v.Value); - - // Perform DB activities - var allSeries = UpsertSeries(libraryId, forceUpdate, series, library); - - // Remove series that are no longer on disk - RemoveSeriesNotOnDisk(allSeries, series, library); - - foreach (var folder in library.Folders) folder.LastScanned = DateTime.Now; + var series = filtered.ToDictionary(v => v.Key, v => v.Value); + + UpdateLibrary(library, series); _unitOfWork.LibraryRepository.Update(library); if (Task.Run(() => _unitOfWork.Complete()).Result) { - _logger.LogInformation($"Scan completed on {library.Name}. Parsed {series.Keys.Count()} series."); + _logger.LogInformation("Scan completed on {LibraryName}. Parsed {ParsedSeriesCount} series in {ElapsedScanTime} ms", library.Name, series.Keys.Count, sw.ElapsedMilliseconds); } else { - _logger.LogError("There was a critical error that resulted in a failed scan. Please rescan."); + _logger.LogError("There was a critical error that resulted in a failed scan. Please check logs and rescan"); } + + _logger.LogInformation("Processed {TotalFiles} files in {ElapsedScanTime} milliseconds for {LibraryName}", totalFiles, sw.ElapsedMilliseconds + scanElapsedTime, library.Name); + } - _scannedSeries = null; - _logger.LogInformation("Processed {0} files in {1} milliseconds for {2}", totalFiles, sw.ElapsedMilliseconds, library.Name); - } - - private List UpsertSeries(int libraryId, bool forceUpdate, ImmutableDictionary> series, Library library) + private void UpdateLibrary(Library library, Dictionary> parsedSeries) { - var allSeries = Task.Run(() => _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(libraryId)).Result.ToList(); - foreach (var seriesKey in series.Keys) + // TODO: Split this into multiple threads + // First, remove any series that are not in parsedSeries list + var foundSeries = parsedSeries.Select(s => Parser.Parser.Normalize(s.Key)).ToList(); + var missingSeries = library.Series.Where(existingSeries => + !foundSeries.Contains(existingSeries.NormalizedName) || !parsedSeries.ContainsKey(existingSeries.Name) || + !parsedSeries.ContainsKey(existingSeries.OriginalName)); + var removeCount = 0; + foreach (var existingSeries in missingSeries) { - var mangaSeries = allSeries.SingleOrDefault(s => s.Name == seriesKey) ?? new Series + library.Series?.Remove(existingSeries); + removeCount += 1; + } + _logger.LogInformation("Removed {RemoveCount} series that are no longer on disk", removeCount); + + // Add new series that have parsedInfos + foreach (var info in parsedSeries) + { + var existingSeries = library.Series.SingleOrDefault(s => s.NormalizedName == Parser.Parser.Normalize(info.Key)); + if (existingSeries == null) { - Name = seriesKey, - OriginalName = seriesKey, - SortName = seriesKey, - Summary = "" - }; + existingSeries = new Series() + { + Name = info.Key, + OriginalName = info.Key, + NormalizedName = Parser.Parser.Normalize(info.Key), + SortName = info.Key, + Summary = "", + Volumes = new List() + }; + library.Series.Add(existingSeries); + } + existingSeries.NormalizedName = Parser.Parser.Normalize(info.Key); + } + + // Now, we only have to deal with series that exist on disk. Let's recalculate the volumes for each series + foreach (var existingSeries in library.Series) + { + _logger.LogInformation("Processing series {SeriesName}", existingSeries.Name); + UpdateVolumes(existingSeries, parsedSeries[existingSeries.Name].ToArray()); + existingSeries.Pages = existingSeries.Volumes.Sum(v => v.Pages); + _metadataService.UpdateMetadata(existingSeries, _forceUpdate); + } + + foreach (var folder in library.Folders) folder.LastScanned = DateTime.Now; + } + + private void UpdateVolumes(Series series, ParserInfo[] parsedInfos) + { + var startingVolumeCount = series.Volumes.Count; + // Add new volumes and update chapters per volume + var distinctVolumes = parsedInfos.Select(p => p.Volumes).Distinct().ToList(); + _logger.LogDebug("Updating {DistinctVolumes} volumes", distinctVolumes.Count); + foreach (var volumeNumber in distinctVolumes) + { + var infos = parsedInfos.Where(p => p.Volumes == volumeNumber).ToArray(); + + var volume = series.Volumes.SingleOrDefault(s => s.Name == volumeNumber); + if (volume == null) + { + volume = new Volume() + { + Name = volumeNumber, + Number = (int) Parser.Parser.MinimumNumberFromRange(volumeNumber), + IsSpecial = false, + Chapters = new List() + }; + series.Volumes.Add(volume); + } + + volume.IsSpecial = volume.Number == 0 && infos.All(p => p.Chapters == "0"); + _logger.LogDebug("Parsing {SeriesName} - Volume {VolumeNumber}", series.Name, volume.Name); + UpdateChapters(volume, infos); + volume.Pages = volume.Chapters.Sum(c => c.Pages); + _metadataService.UpdateMetadata(volume, _forceUpdate); + } + + + + // Remove existing volumes that aren't in parsedInfos and volumes that have no chapters + var existingVolumes = series.Volumes.ToList(); + foreach (var volume in existingVolumes) + { + // I can't remove based on chapter count as I haven't updated Chapters || volume.Chapters.Count == 0 + var hasInfo = parsedInfos.Any(v => v.Volumes == volume.Name); + if (!hasInfo) + { + series.Volumes.Remove(volume); + } + } + + _logger.LogDebug("Updated {SeriesName} volumes from {StartingVolumeCount} to {VolumeCount}", + series.Name, startingVolumeCount, series.Volumes.Count); + } + + private void UpdateChapters(Volume volume, ParserInfo[] parsedInfos) + { + var startingChapters = volume.Chapters.Count; + // Add new chapters + foreach (var info in parsedInfos) + { + var chapter = volume.Chapters.SingleOrDefault(c => c.Range == info.Chapters); + if (chapter == null) + { + chapter = new Chapter() + { + Number = Parser.Parser.MinimumNumberFromRange(info.Chapters) + "", + Range = info.Chapters, + Files = new List() + }; + volume.Chapters.Add(chapter); + } + + chapter.Files = new List(); + } + + // Add files + + foreach (var info in parsedInfos) + { + Chapter chapter = null; try { - mangaSeries = UpdateSeries(mangaSeries, series[seriesKey].ToArray(), forceUpdate); - _logger.LogInformation($"Created/Updated series {mangaSeries.Name} for {library.Name} library"); - library.Series ??= new List(); - library.Series.Add(mangaSeries); + chapter = volume.Chapters.SingleOrDefault(c => c.Range == info.Chapters); } catch (Exception ex) { - _logger.LogError(ex, $"There was an error during scanning of library. {seriesKey} will be skipped."); + _logger.LogError(ex, "There was an exception parsing chapter. Skipping Vol {VolumeNume} Chapter {ChapterNumber}", volume.Name, info.Chapters); } + if (chapter == null) continue; + // I need to reset Files for the first time, hence this work should be done in a separate loop + AddOrUpdateFileForChapter(chapter, info); + chapter.Number = Parser.Parser.MinimumNumberFromRange(info.Chapters) + ""; + chapter.Range = info.Chapters; + chapter.Pages = chapter.Files.Sum(f => f.NumberOfPages); + _metadataService.UpdateMetadata(chapter, _forceUpdate); } - - return allSeries; - } - - private void RemoveSeriesNotOnDisk(List allSeries, ImmutableDictionary> series, Library library) - { - var count = 0; - foreach (var existingSeries in allSeries) + + + + // Remove chapters that aren't in parsedInfos or have no files linked + var existingChapters = volume.Chapters.ToList(); + foreach (var existingChapter in existingChapters) { - if (!series.ContainsKey(existingSeries.Name) || !series.ContainsKey(existingSeries.OriginalName)) + var hasInfo = parsedInfos.Any(v => v.Chapters == existingChapter.Range); + if (!hasInfo || !existingChapter.Files.Any()) { - // Delete series, there is no file to backup any longer. - library.Series?.Remove(existingSeries); - count++; + volume.Chapters.Remove(existingChapter); } } - _logger.LogInformation($"Removed {count} series that are no longer on disk"); + + _logger.LogDebug("Updated chapters from {StartingChaptersCount} to {ChapterCount}", + startingChapters, volume.Chapters.Count); } - /// /// Attempts to either add a new instance of a show mapping to the scannedSeries bag or adds to an existing. @@ -185,159 +333,43 @@ namespace API.Services if (info == null) { - _logger.LogInformation($"Could not parse series from {path}"); + _logger.LogWarning("Could not parse series from {Path}", path); return; } TrackSeries(info); } - - private Series UpdateSeries(Series series, ParserInfo[] infos, bool forceUpdate) - { - var volumes = UpdateVolumesWithChapters(series, infos, forceUpdate); - series.Volumes = volumes; - series.Pages = volumes.Sum(v => v.Pages); - if (ShouldFindCoverImage(forceUpdate, series.CoverImage)) - { - var firstCover = volumes.OrderBy(x => x.Number).FirstOrDefault(x => x.Number != 0); - if (firstCover == null && volumes.Any()) - { - firstCover = volumes.FirstOrDefault(x => x.Number == 0); - } - series.CoverImage = firstCover?.CoverImage; - } - if (string.IsNullOrEmpty(series.Summary) || forceUpdate) - { - series.Summary = ""; - } - - - return series; - } private MangaFile CreateMangaFile(ParserInfo info) { - _logger.LogDebug($"Creating File Entry for {info.FullFilePath}"); - return new MangaFile() { FilePath = info.FullFilePath, Format = info.Format, - NumberOfPages = info.Format == MangaFormat.Archive ? _archiveService.GetNumberOfPagesFromArchive(info.FullFilePath): 1 + NumberOfPages = _archiveService.GetNumberOfPagesFromArchive(info.FullFilePath) }; } - - private bool ShouldFindCoverImage(bool forceUpdate, byte[] coverImage) + + private void AddOrUpdateFileForChapter(Chapter chapter, ParserInfo info) { - return forceUpdate || coverImage == null || !coverImage.Any(); - } - - /// - /// - /// - /// - /// - /// - /// - private ICollection UpdateChapters(Volume volume, IEnumerable infos, bool forceUpdate) - { - var chapters = new List(); - - foreach (var info in infos) + chapter.Files ??= new List(); + var existingFile = chapter.Files.SingleOrDefault(f => f.FilePath == info.FullFilePath); + if (existingFile != null) { - volume.Chapters ??= new List(); - var chapter = volume.Chapters.SingleOrDefault(c => c.Range == info.Chapters) ?? - chapters.SingleOrDefault(v => v.Range == info.Chapters) ?? - new Chapter() - { - Number = Parser.Parser.MinimumNumberFromRange(info.Chapters) + "", - Range = info.Chapters, - }; - - chapter.Files ??= new List(); - var existingFile = chapter.Files.SingleOrDefault(f => f.FilePath == info.FullFilePath); - if (existingFile != null) + existingFile.Format = info.Format; + existingFile.NumberOfPages = _archiveService.GetNumberOfPagesFromArchive(info.FullFilePath); + } + else + { + if (info.Format == MangaFormat.Archive) { - existingFile.Format = info.Format; - existingFile.NumberOfPages = _archiveService.GetNumberOfPagesFromArchive(info.FullFilePath); + chapter.Files.Add(CreateMangaFile(info)); } else { - if (info.Format == MangaFormat.Archive) - { - chapter.Files.Add(CreateMangaFile(info)); - } - else - { - _logger.LogDebug($"Ignoring {info.Filename} as it is not an archive."); - } - - } - - chapter.Number = Parser.Parser.MinimumNumberFromRange(info.Chapters) + ""; - chapter.Range = info.Chapters; - - chapters.Add(chapter); - } - - foreach (var chapter in chapters) - { - chapter.Pages = chapter.Files.Sum(f => f.NumberOfPages); - - if (ShouldFindCoverImage(forceUpdate, chapter.CoverImage)) - { - chapter.Files ??= new List(); - var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault(); - if (firstFile != null) chapter.CoverImage = _archiveService.GetCoverImage(firstFile.FilePath, true); + _logger.LogDebug("Ignoring {Filename}. Non-archives are not supported", info.Filename); } } - - return chapters; } - - - private ICollection UpdateVolumesWithChapters(Series series, ParserInfo[] infos, bool forceUpdate) - { - ICollection volumes = new List(); - IList existingVolumes = _unitOfWork.SeriesRepository.GetVolumes(series.Id).ToList(); - - foreach (var info in infos) - { - var volume = (existingVolumes.SingleOrDefault(v => v.Name == info.Volumes) ?? - volumes.SingleOrDefault(v => v.Name == info.Volumes)) ?? new Volume - { - Name = info.Volumes, - Number = Parser.Parser.MinimumNumberFromRange(info.Volumes), - }; - - - var chapters = UpdateChapters(volume, infos.Where(pi => pi.Volumes == volume.Name).ToArray(), forceUpdate); - volume.Chapters = chapters; - volume.Pages = chapters.Sum(c => c.Pages); - volumes.Add(volume); - } - - foreach (var volume in volumes) - { - if (ShouldFindCoverImage(forceUpdate, volume.CoverImage)) - { - // TODO: Create a custom sorter for Chapters so it's consistent across the application - var firstChapter = volume.Chapters.OrderBy(x => Double.Parse(x.Number)).FirstOrDefault(); - var firstFile = firstChapter?.Files.OrderBy(x => x.Chapter).FirstOrDefault(); - if (firstFile != null) volume.CoverImage = _archiveService.GetCoverImage(firstFile.FilePath, true); - } - } - - return volumes; - } - - - - public void ScanSeries(int libraryId, int seriesId) - { - throw new NotImplementedException(); - } - - } } \ No newline at end of file diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index a5ad6f901..7b7a4900f 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -2,6 +2,7 @@ using API.Entities.Enums; using API.Helpers.Converters; using API.Interfaces; +using API.Interfaces.Services; using Hangfire; using Microsoft.Extensions.Logging; @@ -12,13 +13,20 @@ namespace API.Services private readonly ICacheService _cacheService; private readonly ILogger _logger; private readonly IScannerService _scannerService; - public BackgroundJobServer Client => new BackgroundJobServer(); + private readonly IMetadataService _metadataService; - public TaskScheduler(ICacheService cacheService, ILogger logger, IScannerService scannerService, IUnitOfWork unitOfWork) + public BackgroundJobServer Client => new BackgroundJobServer(new BackgroundJobServerOptions() + { + WorkerCount = 1 + }); + + public TaskScheduler(ICacheService cacheService, ILogger logger, IScannerService scannerService, + IUnitOfWork unitOfWork, IMetadataService metadataService) { _cacheService = cacheService; _logger = logger; _scannerService = scannerService; + _metadataService = metadataService; _logger.LogInformation("Scheduling/Updating cache cleanup on a daily basis."); var setting = Task.Run(() => unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskScan)).Result; @@ -36,12 +44,6 @@ namespace API.Services } - public void ScanSeries(int libraryId, int seriesId) - { - _logger.LogInformation($"Enqueuing series scan for series: {seriesId}"); - BackgroundJob.Enqueue(() => _scannerService.ScanSeries(libraryId, seriesId)); - } - public void ScanLibrary(int libraryId, bool forceUpdate = false) { _logger.LogInformation($"Enqueuing library scan for: {libraryId}"); @@ -53,6 +55,18 @@ namespace API.Services BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds)); } + + public void RefreshMetadata(int libraryId, bool forceUpdate = true) + { + _logger.LogInformation($"Enqueuing library metadata refresh for: {libraryId}"); + BackgroundJob.Enqueue((() => _metadataService.RefreshMetadata(libraryId, forceUpdate))); + } + + public void ScanLibraryInternal(int libraryId, bool forceUpdate) + { + _scannerService.ScanLibrary(libraryId, forceUpdate); + _metadataService.RefreshMetadata(libraryId, forceUpdate); + } } } \ No newline at end of file