diff --git a/Jellyfin.Plugin.OpenSubtitles.sln b/Jellyfin.Plugin.OpenSubtitles.sln index 20d6f73..84f214a 100644 --- a/Jellyfin.Plugin.OpenSubtitles.sln +++ b/Jellyfin.Plugin.OpenSubtitles.sln @@ -3,6 +3,10 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.OpenSubtitles", "Jellyfin.Plugin.OpenSubtitles\Jellyfin.Plugin.OpenSubtitles.csproj", "{DCD0C8B9-A493-4A66-B8EE-8748D430B660}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{AF4DDF0B-B564-4925-B73D-C24A61923D42}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.OpenSubtitles.Tests", "tests\Jellyfin.Plugin.OpenSubtitles.Tests\Jellyfin.Plugin.OpenSubtitles.Tests.csproj", "{C033E72D-81A7-4F3A-A097-9A3934657145}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -13,5 +17,12 @@ Global {DCD0C8B9-A493-4A66-B8EE-8748D430B660}.Debug|Any CPU.Build.0 = Debug|Any CPU {DCD0C8B9-A493-4A66-B8EE-8748D430B660}.Release|Any CPU.ActiveCfg = Release|Any CPU {DCD0C8B9-A493-4A66-B8EE-8748D430B660}.Release|Any CPU.Build.0 = Release|Any CPU + {C033E72D-81A7-4F3A-A097-9A3934657145}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C033E72D-81A7-4F3A-A097-9A3934657145}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C033E72D-81A7-4F3A-A097-9A3934657145}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C033E72D-81A7-4F3A-A097-9A3934657145}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {C033E72D-81A7-4F3A-A097-9A3934657145} = {AF4DDF0B-B564-4925-B73D-C24A61923D42} EndGlobalSection EndGlobal diff --git a/Jellyfin.Plugin.OpenSubtitles/OpenSubtitlesHandler/OpenSubtitles.cs b/Jellyfin.Plugin.OpenSubtitles/OpenSubtitlesHandler/OpenSubtitles.cs index fe41dbd..4bf70b0 100644 --- a/Jellyfin.Plugin.OpenSubtitles/OpenSubtitlesHandler/OpenSubtitles.cs +++ b/Jellyfin.Plugin.OpenSubtitles/OpenSubtitlesHandler/OpenSubtitles.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Globalization; -using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -127,7 +126,11 @@ namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler /// The list of response data. public static async Task>> SearchSubtitlesAsync(Dictionary options, string apiKey, CancellationToken cancellationToken) { - var opts = System.Web.HttpUtility.ParseQueryString(string.Empty); + var opts = new Dictionary(); + foreach (var (key, value) in options) + { + opts.Add(key.ToLowerInvariant(), value.ToLowerInvariant()); + } var max = -1; var current = 1; @@ -138,23 +141,15 @@ namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler do { - opts.Clear(); - if (current > 1) { options["page"] = current.ToString(CultureInfo.InvariantCulture); } - foreach (var (key, value) in options.OrderBy(x => x.Key)) - { - opts.Add(key.ToLower(CultureInfo.InvariantCulture), value.ToLower(CultureInfo.InvariantCulture)); - } + var url = RequestHandler.AddQueryString("/subtitles", opts); + response = await RequestHandler.SendRequestAsync(url, HttpMethod.Get, null, null, apiKey, 1, cancellationToken).ConfigureAwait(false); - var qs = opts.ToString()!.Replace("%20", "+", StringComparison.Ordinal); - - response = await RequestHandler.SendRequestAsync($"/subtitles?{qs}", HttpMethod.Get, null, null, apiKey, 1, cancellationToken).ConfigureAwait(false); - - last = new ApiResponse(response, $"query: {qs}", $"page: {current}"); + last = new ApiResponse(response, $"url: {url}", $"page: {current}"); if (!last.Ok || last.Data == null) { diff --git a/Jellyfin.Plugin.OpenSubtitles/OpenSubtitlesHandler/OpenSubtitlesRequestHelper.cs b/Jellyfin.Plugin.OpenSubtitles/OpenSubtitlesHandler/OpenSubtitlesRequestHelper.cs index b0e6b0c..9f295a2 100644 --- a/Jellyfin.Plugin.OpenSubtitles/OpenSubtitlesHandler/OpenSubtitlesRequestHelper.cs +++ b/Jellyfin.Plugin.OpenSubtitles/OpenSubtitlesHandler/OpenSubtitlesRequestHelper.cs @@ -1,13 +1,12 @@ using System; +using System.Buffers.Binary; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; -using System.Net.Mime; -using System.Text; -using System.Text.Json; +using System.Net.Http.Json; using System.Threading; using System.Threading.Tasks; @@ -38,45 +37,32 @@ namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler public static OpenSubtitlesRequestHelper? Instance { get; set; } /// - /// Compute movie hash. + /// Calculates: size + 64bit chksum of the first and last 64k (even if they overlap because the file is smaller than 128k). /// - /// The input stream. + /// The input stream. /// The hash as Hexadecimal string. - public static string ComputeHash(Stream stream) + public static string ComputeHash(Stream input) { - var hash = ComputeMovieHash(stream); - return Convert.ToHexString(hash).ToLowerInvariant(); - } + const int HashLength = 8; // 64 bit hash + const long HashPos = 64 * 1024; // 64k - /// - /// Compute hash of specified movie stream. - /// - /// Hash of the movie. - private static byte[] ComputeMovieHash(Stream input) - { - using (input) + long streamsize = input.Length; + ulong hash = (ulong)streamsize; + + Span buffer = stackalloc byte[HashLength]; + while (input.Position < HashPos && input.Read(buffer) > 0) { - long streamSize = input.Length, lHash = streamSize; - int size = sizeof(long), count = 65536 / size; - var buffer = new byte[size]; - - for (int i = 0; i < count && input.Read(buffer, 0, size) > 0; i++) - { - lHash += BitConverter.ToInt64(buffer, 0); - } - - input.Position = Math.Max(0, streamSize - 65536); - - for (int i = 0; i < count && input.Read(buffer, 0, size) > 0; i++) - { - lHash += BitConverter.ToInt64(buffer, 0); - } - - var result = BitConverter.GetBytes(lHash); - Array.Reverse(result); - - return result; + hash += BinaryPrimitives.ReadUInt64LittleEndian(buffer); } + + input.Seek(-HashPos, SeekOrigin.End); + while (input.Read(buffer) > 0) + { + hash += BinaryPrimitives.ReadUInt64LittleEndian(buffer); + } + + BinaryPrimitives.WriteUInt64BigEndian(buffer, hash); + return Convert.ToHexString(buffer).ToLowerInvariant(); } internal async Task<(string body, Dictionary headers, HttpStatusCode statusCode)> SendRequestAsync( @@ -91,7 +77,7 @@ namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler HttpContent? content = null; if (method != HttpMethod.Get && body != null) { - content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, MediaTypeNames.Application.Json); + content = JsonContent.Create(body); } using var request = new HttpRequestMessage diff --git a/Jellyfin.Plugin.OpenSubtitles/OpenSubtitlesHandler/RequestHandler.cs b/Jellyfin.Plugin.OpenSubtitles/OpenSubtitlesHandler/RequestHandler.cs index a7af7de..e01c3d9 100644 --- a/Jellyfin.Plugin.OpenSubtitles/OpenSubtitlesHandler/RequestHandler.cs +++ b/Jellyfin.Plugin.OpenSubtitles/OpenSubtitlesHandler/RequestHandler.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Net.Http; +using System.Text; using System.Threading; using System.Threading.Tasks; +using System.Web; using Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models; namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler @@ -121,5 +124,32 @@ namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler Reason = value }; } + + /// + /// Append the given query keys and values to the URI. + /// + /// The base URI. + /// A dictionary of query keys and values to append. + /// The combined result. + public static string AddQueryString(string path, Dictionary param) + { + if (param.Count == 0) + { + return path; + } + + var url = new StringBuilder(path); + url.Append('?'); + foreach (var (key, value) in param.OrderBy(x => x.Key)) + { + url.Append(HttpUtility.UrlEncode(key)) + .Append('=') + .Append(HttpUtility.UrlEncode(value)) + .Append('&'); + } + + url.Length -= 1; // Remove last & + return url.ToString(); + } } } diff --git a/tests/Jellyfin.Plugin.OpenSubtitles.Tests/Jellyfin.Plugin.OpenSubtitles.Tests.csproj b/tests/Jellyfin.Plugin.OpenSubtitles.Tests/Jellyfin.Plugin.OpenSubtitles.Tests.csproj new file mode 100644 index 0000000..d65bb2f --- /dev/null +++ b/tests/Jellyfin.Plugin.OpenSubtitles.Tests/Jellyfin.Plugin.OpenSubtitles.Tests.csproj @@ -0,0 +1,32 @@ + + + + net6.0 + enable + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + Always + + + + + + + + diff --git a/tests/Jellyfin.Plugin.OpenSubtitles.Tests/OpenSubtitlesHandler/OpenSubtitlesRequestHelperTests.cs b/tests/Jellyfin.Plugin.OpenSubtitles.Tests/OpenSubtitlesHandler/OpenSubtitlesRequestHelperTests.cs new file mode 100644 index 0000000..5273e25 --- /dev/null +++ b/tests/Jellyfin.Plugin.OpenSubtitles.Tests/OpenSubtitlesHandler/OpenSubtitlesRequestHelperTests.cs @@ -0,0 +1,15 @@ +using System.IO; +using Xunit; + +namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Tests; + +public class OpenSubtitlesRequestHelperTests +{ + [Theory] + [InlineData("breakdance.avi", "8e245d9679d31e12")] + public void ComputeHash_Success(string filename, string hash) + { + using var str = File.OpenRead(Path.Join("Test Data", filename)); + Assert.Equal(hash, OpenSubtitlesRequestHelper.ComputeHash(str)); + } +} diff --git a/tests/Jellyfin.Plugin.OpenSubtitles.Tests/OpenSubtitlesHandler/RequestHandlerTests.cs b/tests/Jellyfin.Plugin.OpenSubtitles.Tests/OpenSubtitlesHandler/RequestHandlerTests.cs new file mode 100644 index 0000000..94baeb2 --- /dev/null +++ b/tests/Jellyfin.Plugin.OpenSubtitles.Tests/OpenSubtitlesHandler/RequestHandlerTests.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using Xunit; + +namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Tests; + +public static class RequestHandlerTests +{ + public static TheoryData, string> ComputeHash_Success_TestData() + => new TheoryData, string> + { + { + "/subtitles", + new Dictionary() + { + { "b", "c and d" }, + { "a", "1" } + }, + "/subtitles?a=1&b=c+and+d" + } + }; + + [Theory] + [MemberData(nameof(ComputeHash_Success_TestData))] + public static void ComputeHash_Success(string path, Dictionary param, string expected) + => Assert.Equal(expected, RequestHandler.AddQueryString(path, param)); +} diff --git a/tests/Jellyfin.Plugin.OpenSubtitles.Tests/Test Data/breakdance.avi b/tests/Jellyfin.Plugin.OpenSubtitles.Tests/Test Data/breakdance.avi new file mode 100644 index 0000000..c1a498a Binary files /dev/null and b/tests/Jellyfin.Plugin.OpenSubtitles.Tests/Test Data/breakdance.avi differ