aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Jellyfin.Extensions/AlphanumericComparator.cs144
-rw-r--r--src/Jellyfin.Extensions/CopyToExtensions.cs26
-rw-r--r--src/Jellyfin.Extensions/DictionaryExtensions.cs64
-rw-r--r--src/Jellyfin.Extensions/EnumerableExtensions.cs51
-rw-r--r--src/Jellyfin.Extensions/Jellyfin.Extensions.csproj26
-rw-r--r--src/Jellyfin.Extensions/Json/Converters/JsonBoolNumberConverter.cs30
-rw-r--r--src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverter.cs19
-rw-r--r--src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs28
-rw-r--r--src/Jellyfin.Extensions/Json/Converters/JsonDateTimeConverter.cs34
-rw-r--r--src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs81
-rw-r--r--src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs26
-rw-r--r--src/Jellyfin.Extensions/Json/Converters/JsonLowerCaseConverter.cs25
-rw-r--r--src/Jellyfin.Extensions/Json/Converters/JsonNullableGuidConverter.cs33
-rw-r--r--src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs45
-rw-r--r--src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverterFactory.cs27
-rw-r--r--src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverter.cs19
-rw-r--r--src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs28
-rw-r--r--src/Jellyfin.Extensions/Json/Converters/JsonStringConverter.cs39
-rw-r--r--src/Jellyfin.Extensions/Json/Converters/JsonVersionConverter.cs23
-rw-r--r--src/Jellyfin.Extensions/Json/JsonDefaults.cs90
-rw-r--r--src/Jellyfin.Extensions/ShuffleExtensions.cs41
-rw-r--r--src/Jellyfin.Extensions/SplitStringExtensions.cs115
-rw-r--r--src/Jellyfin.Extensions/StreamExtensions.cs63
-rw-r--r--src/Jellyfin.Extensions/StringBuilderExtensions.cs35
-rw-r--r--src/Jellyfin.Extensions/StringExtensions.cs31
25 files changed, 1143 insertions, 0 deletions
diff --git a/src/Jellyfin.Extensions/AlphanumericComparator.cs b/src/Jellyfin.Extensions/AlphanumericComparator.cs
new file mode 100644
index 000000000..e3c81eba8
--- /dev/null
+++ b/src/Jellyfin.Extensions/AlphanumericComparator.cs
@@ -0,0 +1,144 @@
+using System;
+using System.Collections.Generic;
+
+namespace Jellyfin.Extensions
+{
+ /// <summary>
+ /// Alphanumeric <see cref="IComparer{T}" />.
+ /// </summary>
+ public class AlphanumericComparator : IComparer<string?>
+ {
+ /// <summary>
+ /// Compares two objects and returns a value indicating whether one is less than, equal to, or greater than the other.
+ /// </summary>
+ /// <param name="s1">The first object to compare.</param>
+ /// <param name="s2">The second object to compare.</param>
+ /// <returns>A signed integer that indicates the relative values of <c>x</c> and <c>y</c>.</returns>
+ public static int CompareValues(string? s1, string? s2)
+ {
+ if (s1 == null && s2 == null)
+ {
+ return 0;
+ }
+ else if (s1 == null)
+ {
+ return -1;
+ }
+ else if (s2 == null)
+ {
+ return 1;
+ }
+
+ int len1 = s1.Length;
+ int len2 = s2.Length;
+
+ // Early return for empty strings
+ if (len1 == 0 && len2 == 0)
+ {
+ return 0;
+ }
+ else if (len1 == 0)
+ {
+ return -1;
+ }
+ else if (len2 == 0)
+ {
+ return 1;
+ }
+
+ int pos1 = 0;
+ int pos2 = 0;
+
+ do
+ {
+ int start1 = pos1;
+ int start2 = pos2;
+
+ bool isNum1 = char.IsDigit(s1[pos1++]);
+ bool isNum2 = char.IsDigit(s2[pos2++]);
+
+ while (pos1 < len1 && char.IsDigit(s1[pos1]) == isNum1)
+ {
+ pos1++;
+ }
+
+ while (pos2 < len2 && char.IsDigit(s2[pos2]) == isNum2)
+ {
+ pos2++;
+ }
+
+ var span1 = s1.AsSpan(start1, pos1 - start1);
+ var span2 = s2.AsSpan(start2, pos2 - start2);
+
+ if (isNum1 && isNum2)
+ {
+ // Trim leading zeros so we can compare the length
+ // of the strings to find the largest number
+ span1 = span1.TrimStart('0');
+ span2 = span2.TrimStart('0');
+ var span1Len = span1.Length;
+ var span2Len = span2.Length;
+ if (span1Len < span2Len)
+ {
+ return -1;
+ }
+ else if (span1Len > span2Len)
+ {
+ return 1;
+ }
+ else if (span1Len >= 20) // Number is probably too big for a ulong
+ {
+ // Trim all the first digits that are the same
+ int i = 0;
+ while (i < span1Len && span1[i] == span2[i])
+ {
+ i++;
+ }
+
+ // If there are no more digits it's the same number
+ if (i == span1Len)
+ {
+ continue;
+ }
+
+ // Only need to compare the most significant digit
+ span1 = span1.Slice(i, 1);
+ span2 = span2.Slice(i, 1);
+ }
+
+ if (!ulong.TryParse(span1, out var num1)
+ || !ulong.TryParse(span2, out var num2))
+ {
+ return 0;
+ }
+ else if (num1 < num2)
+ {
+ return -1;
+ }
+ else if (num1 > num2)
+ {
+ return 1;
+ }
+ }
+ else
+ {
+ int result = span1.CompareTo(span2, StringComparison.InvariantCulture);
+ if (result != 0)
+ {
+ return result;
+ }
+ }
+#pragma warning disable SA1500 // TODO remove with StyleCop.Analyzers v1.2.0 https://github.com/DotNetAnalyzers/StyleCopAnalyzers/pull/3196
+ } while (pos1 < len1 && pos2 < len2);
+#pragma warning restore SA1500
+
+ return len1 - len2;
+ }
+
+ /// <inheritdoc />
+ public int Compare(string? x, string? y)
+ {
+ return CompareValues(x, y);
+ }
+ }
+}
diff --git a/src/Jellyfin.Extensions/CopyToExtensions.cs b/src/Jellyfin.Extensions/CopyToExtensions.cs
new file mode 100644
index 000000000..72d37b5b6
--- /dev/null
+++ b/src/Jellyfin.Extensions/CopyToExtensions.cs
@@ -0,0 +1,26 @@
+using System.Collections.Generic;
+
+namespace Jellyfin.Extensions
+{
+ /// <summary>
+ /// Provides <c>CopyTo</c> extensions methods for <see cref="IReadOnlyList{T}" />.
+ /// </summary>
+ public static class CopyToExtensions
+ {
+ /// <summary>
+ /// Copies all the elements of the current collection to the specified list
+ /// starting at the specified destination array index. The index is specified as a 32-bit integer.
+ /// </summary>
+ /// <param name="source">The current collection that is the source of the elements.</param>
+ /// <param name="destination">The list that is the destination of the elements copied from the current collection.</param>
+ /// <param name="index">A 32-bit integer that represents the index in <c>destination</c> at which copying begins.</param>
+ /// <typeparam name="T">The type of the array.</typeparam>
+ public static void CopyTo<T>(this IReadOnlyList<T> source, IList<T> destination, int index = 0)
+ {
+ for (int i = 0; i < source.Count; i++)
+ {
+ destination[index + i] = source[i];
+ }
+ }
+ }
+}
diff --git a/src/Jellyfin.Extensions/DictionaryExtensions.cs b/src/Jellyfin.Extensions/DictionaryExtensions.cs
new file mode 100644
index 000000000..5bb828d01
--- /dev/null
+++ b/src/Jellyfin.Extensions/DictionaryExtensions.cs
@@ -0,0 +1,64 @@
+using System.Collections.Generic;
+
+namespace Jellyfin.Extensions
+{
+ /// <summary>
+ /// Static extensions for the <see cref="IReadOnlyDictionary{TKey,TValue}"/> interface.
+ /// </summary>
+ public static class DictionaryExtensions
+ {
+ /// <summary>
+ /// Gets a string from a string dictionary, checking all keys sequentially,
+ /// stopping at the first key that returns a result that's neither null nor blank.
+ /// </summary>
+ /// <param name="dictionary">The dictionary.</param>
+ /// <param name="key1">The first checked key.</param>
+ /// <returns>System.String.</returns>
+ public static string? GetFirstNotNullNorWhiteSpaceValue(this IReadOnlyDictionary<string, string> dictionary, string key1)
+ {
+ return dictionary.GetFirstNotNullNorWhiteSpaceValue(key1, string.Empty, string.Empty);
+ }
+
+ /// <summary>
+ /// Gets a string from a string dictionary, checking all keys sequentially,
+ /// stopping at the first key that returns a result that's neither null nor blank.
+ /// </summary>
+ /// <param name="dictionary">The dictionary.</param>
+ /// <param name="key1">The first checked key.</param>
+ /// <param name="key2">The second checked key.</param>
+ /// <returns>System.String.</returns>
+ public static string? GetFirstNotNullNorWhiteSpaceValue(this IReadOnlyDictionary<string, string> dictionary, string key1, string key2)
+ {
+ return dictionary.GetFirstNotNullNorWhiteSpaceValue(key1, key2, string.Empty);
+ }
+
+ /// <summary>
+ /// Gets a string from a string dictionary, checking all keys sequentially,
+ /// stopping at the first key that returns a result that's neither null nor blank.
+ /// </summary>
+ /// <param name="dictionary">The dictionary.</param>
+ /// <param name="key1">The first checked key.</param>
+ /// <param name="key2">The second checked key.</param>
+ /// <param name="key3">The third checked key.</param>
+ /// <returns>System.String.</returns>
+ public static string? GetFirstNotNullNorWhiteSpaceValue(this IReadOnlyDictionary<string, string> dictionary, string key1, string key2, string key3)
+ {
+ if (dictionary.TryGetValue(key1, out var val) && !string.IsNullOrWhiteSpace(val))
+ {
+ return val;
+ }
+
+ if (!string.IsNullOrEmpty(key2) && dictionary.TryGetValue(key2, out val) && !string.IsNullOrWhiteSpace(val))
+ {
+ return val;
+ }
+
+ if (!string.IsNullOrEmpty(key3) && dictionary.TryGetValue(key3, out val) && !string.IsNullOrWhiteSpace(val))
+ {
+ return val;
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/src/Jellyfin.Extensions/EnumerableExtensions.cs b/src/Jellyfin.Extensions/EnumerableExtensions.cs
new file mode 100644
index 000000000..b5fe93357
--- /dev/null
+++ b/src/Jellyfin.Extensions/EnumerableExtensions.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Collections.Generic;
+
+namespace Jellyfin.Extensions
+{
+ /// <summary>
+ /// Static extensions for the <see cref="IEnumerable{T}"/> interface.
+ /// </summary>
+ public static class EnumerableExtensions
+ {
+ /// <summary>
+ /// Determines whether the value is contained in the source collection.
+ /// </summary>
+ /// <param name="source">An instance of the <see cref="IEnumerable{String}"/> interface.</param>
+ /// <param name="value">The value to look for in the collection.</param>
+ /// <param name="stringComparison">The string comparison.</param>
+ /// <returns>A value indicating whether the value is contained in the collection.</returns>
+ /// <exception cref="ArgumentNullException">The source is null.</exception>
+ public static bool Contains(this IEnumerable<string> source, ReadOnlySpan<char> value, StringComparison stringComparison)
+ {
+ if (source == null)
+ {
+ throw new ArgumentNullException(nameof(source));
+ }
+
+ if (source is IList<string> list)
+ {
+ int len = list.Count;
+ for (int i = 0; i < len; i++)
+ {
+ if (value.Equals(list[i], stringComparison))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ foreach (string element in source)
+ {
+ if (value.Equals(element, stringComparison))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
new file mode 100644
index 000000000..981b796e0
--- /dev/null
+++ b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
@@ -0,0 +1,26 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net5.0</TargetFramework>
+ <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
+ <GenerateDocumentationFile>true</GenerateDocumentationFile>
+ </PropertyGroup>
+
+ <PropertyGroup>
+ <Authors>Jellyfin Contributors</Authors>
+ <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
+ <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <Compile Include="../../SharedVersion.cs" />
+ </ItemGroup>
+
+ <!-- Code Analyzers-->
+ <ItemGroup>
+ <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
+ <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" />
+ <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
+ </ItemGroup>
+
+</Project>
diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonBoolNumberConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonBoolNumberConverter.cs
new file mode 100644
index 000000000..c2543cf7c
--- /dev/null
+++ b/src/Jellyfin.Extensions/Json/Converters/JsonBoolNumberConverter.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Extensions.Json.Converters
+{
+ /// <summary>
+ /// Converts a number to a boolean.
+ /// This is needed for HDHomerun.
+ /// </summary>
+ public class JsonBoolNumberConverter : JsonConverter<bool>
+ {
+ /// <inheritdoc />
+ public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.Number)
+ {
+ return Convert.ToBoolean(reader.GetInt32());
+ }
+
+ return reader.GetBoolean();
+ }
+
+ /// <inheritdoc />
+ public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options)
+ {
+ writer.WriteBooleanValue(value);
+ }
+ }
+}
diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverter.cs
new file mode 100644
index 000000000..0d0cc2d06
--- /dev/null
+++ b/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverter.cs
@@ -0,0 +1,19 @@
+namespace Jellyfin.Extensions.Json.Converters
+{
+ /// <summary>
+ /// Convert comma delimited string to array of type.
+ /// </summary>
+ /// <typeparam name="T">Type to convert to.</typeparam>
+ public sealed class JsonCommaDelimitedArrayConverter<T> : JsonDelimitedArrayConverter<T>
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="JsonCommaDelimitedArrayConverter{T}"/> class.
+ /// </summary>
+ public JsonCommaDelimitedArrayConverter() : base()
+ {
+ }
+
+ /// <inheritdoc />
+ protected override char Delimiter => ',';
+ }
+}
diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs b/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs
new file mode 100644
index 000000000..cc9311a24
--- /dev/null
+++ b/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs
@@ -0,0 +1,28 @@
+using System;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Extensions.Json.Converters
+{
+ /// <summary>
+ /// Json comma delimited array converter factory.
+ /// </summary>
+ /// <remarks>
+ /// This must be applied as an attribute, adding to the JsonConverter list causes stack overflow.
+ /// </remarks>
+ public class JsonCommaDelimitedArrayConverterFactory : JsonConverterFactory
+ {
+ /// <inheritdoc />
+ public override bool CanConvert(Type typeToConvert)
+ {
+ return true;
+ }
+
+ /// <inheritdoc />
+ public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options)
+ {
+ var structType = typeToConvert.GetElementType() ?? typeToConvert.GenericTypeArguments[0];
+ return (JsonConverter?)Activator.CreateInstance(typeof(JsonCommaDelimitedArrayConverter<>).MakeGenericType(structType));
+ }
+ }
+}
diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonDateTimeConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonDateTimeConverter.cs
new file mode 100644
index 000000000..8ae080b15
--- /dev/null
+++ b/src/Jellyfin.Extensions/Json/Converters/JsonDateTimeConverter.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Extensions.Json.Converters
+{
+ /// <summary>
+ /// Legacy DateTime converter.
+ /// Milliseconds aren't output if zero by default.
+ /// </summary>
+ public class JsonDateTimeConverter : JsonConverter<DateTime>
+ {
+ /// <inheritdoc />
+ public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ return reader.GetDateTime();
+ }
+
+ /// <inheritdoc />
+ public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
+ {
+ if (value.Millisecond == 0)
+ {
+ // Remaining ticks value will be 0, manually format.
+ writer.WriteStringValue(value.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffffffZ", CultureInfo.InvariantCulture));
+ }
+ else
+ {
+ writer.WriteStringValue(value);
+ }
+ }
+ }
+}
diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs
new file mode 100644
index 000000000..c39805aa3
--- /dev/null
+++ b/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs
@@ -0,0 +1,81 @@
+using System;
+using System.ComponentModel;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Extensions.Json.Converters
+{
+ /// <summary>
+ /// Convert delimited string to array of type.
+ /// </summary>
+ /// <typeparam name="T">Type to convert to.</typeparam>
+ public abstract class JsonDelimitedArrayConverter<T> : JsonConverter<T[]?>
+ {
+ private readonly TypeConverter _typeConverter;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="JsonDelimitedArrayConverter{T}"/> class.
+ /// </summary>
+ protected JsonDelimitedArrayConverter()
+ {
+ _typeConverter = TypeDescriptor.GetConverter(typeof(T));
+ }
+
+ /// <summary>
+ /// Gets the array delimiter.
+ /// </summary>
+ protected virtual char Delimiter { get; }
+
+ /// <inheritdoc />
+ public override T[]? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.String)
+ {
+ // GetString can't return null here because we already handled it above
+ var stringEntries = reader.GetString()?.Split(Delimiter, StringSplitOptions.RemoveEmptyEntries);
+ if (stringEntries == null || stringEntries.Length == 0)
+ {
+ return Array.Empty<T>();
+ }
+
+ var parsedValues = new object[stringEntries.Length];
+ var convertedCount = 0;
+ for (var i = 0; i < stringEntries.Length; i++)
+ {
+ try
+ {
+ parsedValues[i] = _typeConverter.ConvertFrom(stringEntries[i].Trim());
+ convertedCount++;
+ }
+ catch (FormatException)
+ {
+ // TODO log when upgraded to .Net6
+ // https://github.com/dotnet/runtime/issues/42975
+ // _logger.LogDebug(e, "Error converting value.");
+ }
+ }
+
+ var typedValues = new T[convertedCount];
+ var typedValueIndex = 0;
+ for (var i = 0; i < stringEntries.Length; i++)
+ {
+ if (parsedValues[i] != null)
+ {
+ typedValues.SetValue(parsedValues[i], typedValueIndex);
+ typedValueIndex++;
+ }
+ }
+
+ return typedValues;
+ }
+
+ return JsonSerializer.Deserialize<T[]>(ref reader, options);
+ }
+
+ /// <inheritdoc />
+ public override void Write(Utf8JsonWriter writer, T[]? value, JsonSerializerOptions options)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs
new file mode 100644
index 000000000..be94dd519
--- /dev/null
+++ b/src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Extensions.Json.Converters
+{
+ /// <summary>
+ /// Converts a GUID object or value to/from JSON.
+ /// </summary>
+ public class JsonGuidConverter : JsonConverter<Guid>
+ {
+ /// <inheritdoc />
+ public override Guid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ var guidStr = reader.GetString();
+ return guidStr == null ? Guid.Empty : new Guid(guidStr);
+ }
+
+ /// <inheritdoc />
+ public override void Write(Utf8JsonWriter writer, Guid value, JsonSerializerOptions options)
+ {
+ writer.WriteStringValue(value.ToString("N", CultureInfo.InvariantCulture));
+ }
+ }
+}
diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonLowerCaseConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonLowerCaseConverter.cs
new file mode 100644
index 000000000..cd582ced6
--- /dev/null
+++ b/src/Jellyfin.Extensions/Json/Converters/JsonLowerCaseConverter.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Extensions.Json.Converters
+{
+ /// <summary>
+ /// Converts an object to a lowercase string.
+ /// </summary>
+ /// <typeparam name="T">The object type.</typeparam>
+ public class JsonLowerCaseConverter<T> : JsonConverter<T>
+ {
+ /// <inheritdoc />
+ public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ return JsonSerializer.Deserialize<T>(ref reader, options);
+ }
+
+ /// <inheritdoc />
+ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
+ {
+ writer.WriteStringValue(value?.ToString()?.ToLowerInvariant());
+ }
+ }
+}
diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonNullableGuidConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonNullableGuidConverter.cs
new file mode 100644
index 000000000..6192d1598
--- /dev/null
+++ b/src/Jellyfin.Extensions/Json/Converters/JsonNullableGuidConverter.cs
@@ -0,0 +1,33 @@
+using System;
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Extensions.Json.Converters
+{
+ /// <summary>
+ /// Converts a GUID object or value to/from JSON.
+ /// </summary>
+ public class JsonNullableGuidConverter : JsonConverter<Guid?>
+ {
+ /// <inheritdoc />
+ public override Guid? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ var guidStr = reader.GetString();
+ return guidStr == null ? null : new Guid(guidStr);
+ }
+
+ /// <inheritdoc />
+ public override void Write(Utf8JsonWriter writer, Guid? value, JsonSerializerOptions options)
+ {
+ if (value == null || value == Guid.Empty)
+ {
+ writer.WriteNullValue();
+ }
+ else
+ {
+ writer.WriteStringValue(value.Value.ToString("N", CultureInfo.InvariantCulture));
+ }
+ }
+ }
+}
diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs
new file mode 100644
index 000000000..6de238b39
--- /dev/null
+++ b/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Extensions.Json.Converters
+{
+ /// <summary>
+ /// Converts a nullable struct or value to/from JSON.
+ /// Required - some clients send an empty string.
+ /// </summary>
+ /// <typeparam name="TStruct">The struct type.</typeparam>
+ public class JsonNullableStructConverter<TStruct> : JsonConverter<TStruct?>
+ where TStruct : struct
+ {
+ /// <inheritdoc />
+ public override TStruct? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.Null)
+ {
+ return null;
+ }
+
+ // Token is empty string.
+ if (reader.TokenType == JsonTokenType.String && ((reader.HasValueSequence && reader.ValueSequence.IsEmpty) || reader.ValueSpan.IsEmpty))
+ {
+ return null;
+ }
+
+ return JsonSerializer.Deserialize<TStruct>(ref reader, options);
+ }
+
+ /// <inheritdoc />
+ public override void Write(Utf8JsonWriter writer, TStruct? value, JsonSerializerOptions options)
+ {
+ if (value.HasValue)
+ {
+ JsonSerializer.Serialize(writer, value.Value, options);
+ }
+ else
+ {
+ writer.WriteNullValue();
+ }
+ }
+ }
+}
diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverterFactory.cs b/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverterFactory.cs
new file mode 100644
index 000000000..e7749589a
--- /dev/null
+++ b/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverterFactory.cs
@@ -0,0 +1,27 @@
+using System;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Extensions.Json.Converters
+{
+ /// <summary>
+ /// Json nullable struct converter factory.
+ /// </summary>
+ public class JsonNullableStructConverterFactory : JsonConverterFactory
+ {
+ /// <inheritdoc />
+ public override bool CanConvert(Type typeToConvert)
+ {
+ return typeToConvert.IsGenericType
+ && typeToConvert.GetGenericTypeDefinition() == typeof(Nullable<>)
+ && typeToConvert.GenericTypeArguments[0].IsValueType;
+ }
+
+ /// <inheritdoc />
+ public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options)
+ {
+ var structType = typeToConvert.GenericTypeArguments[0];
+ return (JsonConverter?)Activator.CreateInstance(typeof(JsonNullableStructConverter<>).MakeGenericType(structType));
+ }
+ }
+}
diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverter.cs
new file mode 100644
index 000000000..6e59fe464
--- /dev/null
+++ b/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverter.cs
@@ -0,0 +1,19 @@
+namespace Jellyfin.Extensions.Json.Converters
+{
+ /// <summary>
+ /// Convert Pipe delimited string to array of type.
+ /// </summary>
+ /// <typeparam name="T">Type to convert to.</typeparam>
+ public sealed class JsonPipeDelimitedArrayConverter<T> : JsonDelimitedArrayConverter<T>
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="JsonPipeDelimitedArrayConverter{T}"/> class.
+ /// </summary>
+ public JsonPipeDelimitedArrayConverter() : base()
+ {
+ }
+
+ /// <inheritdoc />
+ protected override char Delimiter => '|';
+ }
+}
diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs b/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs
new file mode 100644
index 000000000..579674f18
--- /dev/null
+++ b/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs
@@ -0,0 +1,28 @@
+using System;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Extensions.Json.Converters
+{
+ /// <summary>
+ /// Json Pipe delimited array converter factory.
+ /// </summary>
+ /// <remarks>
+ /// This must be applied as an attribute, adding to the JsonConverter list causes stack overflow.
+ /// </remarks>
+ public class JsonPipeDelimitedArrayConverterFactory : JsonConverterFactory
+ {
+ /// <inheritdoc />
+ public override bool CanConvert(Type typeToConvert)
+ {
+ return true;
+ }
+
+ /// <inheritdoc />
+ public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options)
+ {
+ var structType = typeToConvert.GetElementType() ?? typeToConvert.GenericTypeArguments[0];
+ return (JsonConverter?)Activator.CreateInstance(typeof(JsonPipeDelimitedArrayConverter<>).MakeGenericType(structType));
+ }
+ }
+}
diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonStringConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonStringConverter.cs
new file mode 100644
index 000000000..1a7a8c4f5
--- /dev/null
+++ b/src/Jellyfin.Extensions/Json/Converters/JsonStringConverter.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Buffers;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Extensions.Json.Converters
+{
+ /// <summary>
+ /// Converter to allow the serializer to read strings.
+ /// </summary>
+ public class JsonStringConverter : JsonConverter<string?>
+ {
+ /// <inheritdoc />
+ public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ return reader.TokenType switch
+ {
+ JsonTokenType.Null => null,
+ JsonTokenType.String => reader.GetString(),
+ _ => GetRawValue(reader)
+ };
+ }
+
+ /// <inheritdoc />
+ public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options)
+ {
+ writer.WriteStringValue(value);
+ }
+
+ private static string GetRawValue(Utf8JsonReader reader)
+ {
+ var utf8Bytes = reader.HasValueSequence
+ ? reader.ValueSequence.ToArray()
+ : reader.ValueSpan;
+ return Encoding.UTF8.GetString(utf8Bytes);
+ }
+ }
+}
diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonVersionConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonVersionConverter.cs
new file mode 100644
index 000000000..51ffec1cb
--- /dev/null
+++ b/src/Jellyfin.Extensions/Json/Converters/JsonVersionConverter.cs
@@ -0,0 +1,23 @@
+using System;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Extensions.Json.Converters
+{
+ /// <summary>
+ /// Converts a Version object or value to/from JSON.
+ /// </summary>
+ /// <remarks>
+ /// Required to send <see cref="Version"/> as a string instead of an object.
+ /// </remarks>
+ public class JsonVersionConverter : JsonConverter<Version>
+ {
+ /// <inheritdoc />
+ public override Version Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ => new Version(reader.GetString()!); // Will throw ArgumentNullException on null
+
+ /// <inheritdoc />
+ public override void Write(Utf8JsonWriter writer, Version value, JsonSerializerOptions options)
+ => writer.WriteStringValue(value.ToString());
+ }
+}
diff --git a/src/Jellyfin.Extensions/Json/JsonDefaults.cs b/src/Jellyfin.Extensions/Json/JsonDefaults.cs
new file mode 100644
index 000000000..f4ec91123
--- /dev/null
+++ b/src/Jellyfin.Extensions/Json/JsonDefaults.cs
@@ -0,0 +1,90 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Jellyfin.Extensions.Json.Converters;
+
+namespace Jellyfin.Extensions.Json
+{
+ /// <summary>
+ /// Helper class for having compatible JSON throughout the codebase.
+ /// </summary>
+ public static class JsonDefaults
+ {
+ /// <summary>
+ /// Pascal case json profile media type.
+ /// </summary>
+ public const string PascalCaseMediaType = "application/json; profile=\"PascalCase\"";
+
+ /// <summary>
+ /// Camel case json profile media type.
+ /// </summary>
+ public const string CamelCaseMediaType = "application/json; profile=\"CamelCase\"";
+
+ /// <summary>
+ /// When changing these options, update
+ /// Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
+ /// -> AddJellyfinApi
+ /// -> AddJsonOptions.
+ /// </summary>
+ private static readonly JsonSerializerOptions _jsonSerializerOptions = new ()
+ {
+ ReadCommentHandling = JsonCommentHandling.Disallow,
+ WriteIndented = false,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ NumberHandling = JsonNumberHandling.AllowReadingFromString,
+ Converters =
+ {
+ new JsonGuidConverter(),
+ new JsonNullableGuidConverter(),
+ new JsonVersionConverter(),
+ new JsonStringEnumConverter(),
+ new JsonNullableStructConverterFactory(),
+ new JsonBoolNumberConverter(),
+ new JsonDateTimeConverter(),
+ new JsonStringConverter()
+ }
+ };
+
+ private static readonly JsonSerializerOptions _pascalCaseJsonSerializerOptions = new (_jsonSerializerOptions)
+ {
+ PropertyNamingPolicy = null
+ };
+
+ private static readonly JsonSerializerOptions _camelCaseJsonSerializerOptions = new (_jsonSerializerOptions)
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+
+ /// <summary>
+ /// Gets the default <see cref="JsonSerializerOptions" /> options.
+ /// </summary>
+ /// <remarks>
+ /// The return value must not be modified.
+ /// If the defaults must be modified the author must use the copy constructor.
+ /// </remarks>
+ /// <returns>The default <see cref="JsonSerializerOptions" /> options.</returns>
+ public static JsonSerializerOptions Options
+ => _jsonSerializerOptions;
+
+ /// <summary>
+ /// Gets camelCase json options.
+ /// </summary>
+ /// <remarks>
+ /// The return value must not be modified.
+ /// If the defaults must be modified the author must use the copy constructor.
+ /// </remarks>
+ /// <returns>The camelCase <see cref="JsonSerializerOptions" /> options.</returns>
+ public static JsonSerializerOptions CamelCaseOptions
+ => _camelCaseJsonSerializerOptions;
+
+ /// <summary>
+ /// Gets PascalCase json options.
+ /// </summary>
+ /// <remarks>
+ /// The return value must not be modified.
+ /// If the defaults must be modified the author must use the copy constructor.
+ /// </remarks>
+ /// <returns>The PascalCase <see cref="JsonSerializerOptions" /> options.</returns>
+ public static JsonSerializerOptions PascalCaseOptions
+ => _pascalCaseJsonSerializerOptions;
+ }
+}
diff --git a/src/Jellyfin.Extensions/ShuffleExtensions.cs b/src/Jellyfin.Extensions/ShuffleExtensions.cs
new file mode 100644
index 000000000..4e481983f
--- /dev/null
+++ b/src/Jellyfin.Extensions/ShuffleExtensions.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Collections.Generic;
+
+namespace Jellyfin.Extensions
+{
+ /// <summary>
+ /// Provides <c>Shuffle</c> extensions methods for <see cref="IList{T}" />.
+ /// </summary>
+ public static class ShuffleExtensions
+ {
+ private static readonly Random _rng = new Random();
+
+ /// <summary>
+ /// Shuffles the items in a list.
+ /// </summary>
+ /// <param name="list">The list that should get shuffled.</param>
+ /// <typeparam name="T">The type.</typeparam>
+ public static void Shuffle<T>(this IList<T> list)
+ {
+ list.Shuffle(_rng);
+ }
+
+ /// <summary>
+ /// Shuffles the items in a list.
+ /// </summary>
+ /// <param name="list">The list that should get shuffled.</param>
+ /// <param name="rng">The random number generator to use.</param>
+ /// <typeparam name="T">The type.</typeparam>
+ public static void Shuffle<T>(this IList<T> list, Random rng)
+ {
+ int n = list.Count;
+ while (n > 1)
+ {
+ int k = rng.Next(n--);
+ T value = list[k];
+ list[k] = list[n];
+ list[n] = value;
+ }
+ }
+ }
+}
diff --git a/src/Jellyfin.Extensions/SplitStringExtensions.cs b/src/Jellyfin.Extensions/SplitStringExtensions.cs
new file mode 100644
index 000000000..5fa5c0123
--- /dev/null
+++ b/src/Jellyfin.Extensions/SplitStringExtensions.cs
@@ -0,0 +1,115 @@
+/*
+MIT License
+
+Copyright (c) 2019 Gérald Barré
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ */
+
+// TODO: remove when analyzer is fixed: https://github.com/dotnet/roslyn-analyzers/issues/5158
+#pragma warning disable CA1034 // Nested types should not be visible
+
+using System;
+using System.Diagnostics.Contracts;
+using System.Runtime.InteropServices;
+
+namespace Jellyfin.Extensions
+{
+ /// <summary>
+ /// Extension class for splitting lines without unnecessary allocations.
+ /// </summary>
+ public static class SplitStringExtensions
+ {
+ /// <summary>
+ /// Creates a new string split enumerator.
+ /// </summary>
+ /// <param name="str">The string to split.</param>
+ /// <param name="separator">The separator to split on.</param>
+ /// <returns>The enumerator struct.</returns>
+ [Pure]
+ public static Enumerator SpanSplit(this string str, char separator) => new (str.AsSpan(), separator);
+
+ /// <summary>
+ /// Creates a new span split enumerator.
+ /// </summary>
+ /// <param name="str">The span to split.</param>
+ /// <param name="separator">The separator to split on.</param>
+ /// <returns>The enumerator struct.</returns>
+ [Pure]
+ public static Enumerator Split(this ReadOnlySpan<char> str, char separator) => new (str, separator);
+
+ /// <summary>
+ /// Provides an enumerator for the substrings seperated by the separator.
+ /// </summary>
+ [StructLayout(LayoutKind.Auto)]
+ public ref struct Enumerator
+ {
+ private readonly char _separator;
+ private ReadOnlySpan<char> _str;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="Enumerator"/> struct.
+ /// </summary>
+ /// <param name="str">The span to split.</param>
+ /// <param name="separator">The separator to split on.</param>
+ public Enumerator(ReadOnlySpan<char> str, char separator)
+ {
+ _str = str;
+ _separator = separator;
+ Current = default;
+ }
+
+ /// <summary>
+ /// Gets a reference to the item at the current position of the enumerator.
+ /// </summary>
+ public ReadOnlySpan<char> Current { get; private set; }
+
+ /// <summary>
+ /// Returns <c>this</c>.
+ /// </summary>
+ /// <returns><c>this</c>.</returns>
+ public readonly Enumerator GetEnumerator() => this;
+
+ /// <summary>
+ /// Advances the enumerator to the next item.
+ /// </summary>
+ /// <returns><c>true</c> if there is a next element; otherwise <c>false</c>.</returns>
+ public bool MoveNext()
+ {
+ if (_str.Length == 0)
+ {
+ return false;
+ }
+
+ var span = _str;
+ var index = span.IndexOf(_separator);
+ if (index == -1)
+ {
+ _str = ReadOnlySpan<char>.Empty;
+ Current = span;
+ return true;
+ }
+
+ Current = span.Slice(0, index);
+ _str = span[(index + 1)..];
+ return true;
+ }
+ }
+ }
+}
diff --git a/src/Jellyfin.Extensions/StreamExtensions.cs b/src/Jellyfin.Extensions/StreamExtensions.cs
new file mode 100644
index 000000000..9751d9d42
--- /dev/null
+++ b/src/Jellyfin.Extensions/StreamExtensions.cs
@@ -0,0 +1,63 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+
+namespace Jellyfin.Extensions
+{
+ /// <summary>
+ /// Class BaseExtensions.
+ /// </summary>
+ public static class StreamExtensions
+ {
+ /// <summary>
+ /// Reads all lines in the <see cref="Stream" />.
+ /// </summary>
+ /// <param name="stream">The <see cref="Stream" /> to read from.</param>
+ /// <returns>All lines in the stream.</returns>
+ public static string[] ReadAllLines(this Stream stream)
+ => ReadAllLines(stream, Encoding.UTF8);
+
+ /// <summary>
+ /// Reads all lines in the <see cref="Stream" />.
+ /// </summary>
+ /// <param name="stream">The <see cref="Stream" /> to read from.</param>
+ /// <param name="encoding">The character encoding to use.</param>
+ /// <returns>All lines in the stream.</returns>
+ public static string[] ReadAllLines(this Stream stream, Encoding encoding)
+ {
+ using (StreamReader reader = new StreamReader(stream, encoding))
+ {
+ return ReadAllLines(reader).ToArray();
+ }
+ }
+
+ /// <summary>
+ /// Reads all lines in the <see cref="TextReader" />.
+ /// </summary>
+ /// <param name="reader">The <see cref="TextReader" /> to read from.</param>
+ /// <returns>All lines in the stream.</returns>
+ public static IEnumerable<string> ReadAllLines(this TextReader reader)
+ {
+ string? line;
+ while ((line = reader.ReadLine()) != null)
+ {
+ yield return line;
+ }
+ }
+
+ /// <summary>
+ /// Reads all lines in the <see cref="TextReader" />.
+ /// </summary>
+ /// <param name="reader">The <see cref="TextReader" /> to read from.</param>
+ /// <returns>All lines in the stream.</returns>
+ public static async IAsyncEnumerable<string> ReadAllLinesAsync(this TextReader reader)
+ {
+ string? line;
+ while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null)
+ {
+ yield return line;
+ }
+ }
+ }
+}
diff --git a/src/Jellyfin.Extensions/StringBuilderExtensions.cs b/src/Jellyfin.Extensions/StringBuilderExtensions.cs
new file mode 100644
index 000000000..02ff7cc1f
--- /dev/null
+++ b/src/Jellyfin.Extensions/StringBuilderExtensions.cs
@@ -0,0 +1,35 @@
+using System.Collections.Generic;
+using System.Text;
+
+namespace Jellyfin.Extensions
+{
+ /// <summary>
+ /// Extension methods for the <see cref="StringBuilder"/> class.
+ /// </summary>
+ public static class StringBuilderExtensions
+ {
+ /// <summary>
+ /// Concatenates and appends the members of a collection in single quotes using the specified delimiter.
+ /// </summary>
+ /// <param name="builder">The string builder.</param>
+ /// <param name="delimiter">The character delimiter.</param>
+ /// <param name="values">The collection of strings to concatenate.</param>
+ /// <returns>The updated string builder.</returns>
+ public static StringBuilder AppendJoinInSingleQuotes(this StringBuilder builder, char delimiter, IReadOnlyList<string> values)
+ {
+ var len = values.Count;
+ for (var i = 0; i < len; i++)
+ {
+ builder.Append('\'')
+ .Append(values[i])
+ .Append('\'')
+ .Append(delimiter);
+ }
+
+ // remove last ,
+ builder.Length--;
+
+ return builder;
+ }
+ }
+}
diff --git a/src/Jellyfin.Extensions/StringExtensions.cs b/src/Jellyfin.Extensions/StringExtensions.cs
new file mode 100644
index 000000000..acc695ed2
--- /dev/null
+++ b/src/Jellyfin.Extensions/StringExtensions.cs
@@ -0,0 +1,31 @@
+using System;
+
+namespace Jellyfin.Extensions
+{
+ /// <summary>
+ /// Provides extensions methods for <see cref="string" />.
+ /// </summary>
+ public static class StringExtensions
+ {
+ /// <summary>
+ /// Counts the number of occurrences of [needle] in the string.
+ /// </summary>
+ /// <param name="value">The haystack to search in.</param>
+ /// <param name="needle">The character to search for.</param>
+ /// <returns>The number of occurrences of the [needle] character.</returns>
+ public static int Count(this ReadOnlySpan<char> value, char needle)
+ {
+ var count = 0;
+ var length = value.Length;
+ for (var i = 0; i < length; i++)
+ {
+ if (value[i] == needle)
+ {
+ count++;
+ }
+ }
+
+ return count;
+ }
+ }
+}