diff --git a/Emby.MeiamSub.Thunder/Emby.MeiamSub.Thunder.csproj b/Emby.MeiamSub.Thunder/Emby.MeiamSub.Thunder.csproj index 1c2e992..e94f12d 100644 --- a/Emby.MeiamSub.Thunder/Emby.MeiamSub.Thunder.csproj +++ b/Emby.MeiamSub.Thunder/Emby.MeiamSub.Thunder.csproj @@ -33,6 +33,10 @@ + + + + diff --git a/Emby.MeiamSub.Thunder/ThunderProvider.cs b/Emby.MeiamSub.Thunder/ThunderProvider.cs index 425d32d..eccc758 100644 --- a/Emby.MeiamSub.Thunder/ThunderProvider.cs +++ b/Emby.MeiamSub.Thunder/ThunderProvider.cs @@ -1,4 +1,5 @@ using Emby.MeiamSub.Thunder.Model; +using MeiamSubtitles.Shared; using MediaBrowser.Common; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Base; @@ -285,7 +286,7 @@ namespace Emby.MeiamSub.Thunder await response.Content.CopyToAsync(stream, 81920, cancellationToken); var data = stream.ToArray(); stream.Dispose(); - ValidateSubtitleData(data, format, response.ContentType); + SubtitleContentValidator.Validate(data, format, response.ContentType); return new SubtitleResponse() { @@ -355,38 +356,6 @@ namespace Emby.MeiamSub.Thunder return value == SRT || value == ASS || value == SSA ? value : null; } - private static void ValidateSubtitleData(byte[] data, string format, string mediaType) - { - if (data == null || data.Length < 8) - { - throw new InvalidDataException("Subtitle response is empty."); - } - - if (data[0] == (byte)'P' && data[1] == (byte)'K' || - data.Length >= 7 && Encoding.ASCII.GetString(data, 0, 7) == "Rar!\u001a\u0007") - { - throw new InvalidDataException("Compressed subtitle responses are not supported by Thunder."); - } - - var sample = Encoding.UTF8.GetString(data, 0, Math.Min(data.Length, 4096)).TrimStart('\uFEFF', ' ', '\r', '\n', '\t'); - if (sample.StartsWith("<", StringComparison.Ordinal) || - sample.StartsWith("{", StringComparison.Ordinal) || - sample.StartsWith("[", StringComparison.Ordinal) || - mediaType?.IndexOf("html", StringComparison.OrdinalIgnoreCase) >= 0 || - mediaType?.IndexOf("json", StringComparison.OrdinalIgnoreCase) >= 0) - { - throw new InvalidDataException("Thunder returned an error document instead of a subtitle."); - } - - var valid = format == SRT - ? sample.IndexOf("-->", StringComparison.Ordinal) >= 0 - : sample.IndexOf("[Script Info]", StringComparison.OrdinalIgnoreCase) >= 0 || sample.IndexOf("Dialogue:", StringComparison.OrdinalIgnoreCase) >= 0; - if (!valid) - { - throw new InvalidDataException($"Downloaded content is not a valid {format} subtitle."); - } - } - /// /// 规范化语言代码 /// diff --git a/Jellyfin.MeiamSub.Thunder/Jellyfin.MeiamSub.Thunder.csproj b/Jellyfin.MeiamSub.Thunder/Jellyfin.MeiamSub.Thunder.csproj index 607c6ba..00beecc 100644 --- a/Jellyfin.MeiamSub.Thunder/Jellyfin.MeiamSub.Thunder.csproj +++ b/Jellyfin.MeiamSub.Thunder/Jellyfin.MeiamSub.Thunder.csproj @@ -21,6 +21,10 @@ + + + + diff --git a/Jellyfin.MeiamSub.Thunder/ThunderProvider.cs b/Jellyfin.MeiamSub.Thunder/ThunderProvider.cs index f48a204..a2976c7 100644 --- a/Jellyfin.MeiamSub.Thunder/ThunderProvider.cs +++ b/Jellyfin.MeiamSub.Thunder/ThunderProvider.cs @@ -1,4 +1,5 @@ using Jellyfin.MeiamSub.Thunder.Model; +using MeiamSubtitles.Shared; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Subtitles; using MediaBrowser.Model.Providers; @@ -282,7 +283,7 @@ namespace Jellyfin.MeiamSub.Thunder var format = NormalizeFormat(downloadSub.Format) ?? throw new InvalidDataException($"Unsupported subtitle format: {downloadSub.Format}"); var data = await response.Content.ReadAsByteArrayAsync(cancellationToken); - ValidateSubtitleData(data, format, response.Content.Headers.ContentType?.MediaType); + SubtitleContentValidator.Validate(data, format, response.Content.Headers.ContentType?.MediaType); var stream = new MemoryStream(data, writable: false); return new SubtitleResponse() @@ -353,38 +354,6 @@ namespace Jellyfin.MeiamSub.Thunder return value == SRT || value == ASS || value == SSA ? value : null; } - private static void ValidateSubtitleData(byte[] data, string format, string mediaType) - { - if (data == null || data.Length < 8) - { - throw new InvalidDataException("Subtitle response is empty."); - } - - if (data[0] == (byte)'P' && data[1] == (byte)'K' || - data.Length >= 7 && Encoding.ASCII.GetString(data, 0, 7) == "Rar!\u001a\u0007") - { - throw new InvalidDataException("Compressed subtitle responses are not supported by Thunder."); - } - - var sample = Encoding.UTF8.GetString(data, 0, Math.Min(data.Length, 4096)).TrimStart('\uFEFF', ' ', '\r', '\n', '\t'); - if (sample.StartsWith("<", StringComparison.Ordinal) || - sample.StartsWith("{", StringComparison.Ordinal) || - sample.StartsWith("[", StringComparison.Ordinal) || - mediaType?.IndexOf("html", StringComparison.OrdinalIgnoreCase) >= 0 || - mediaType?.IndexOf("json", StringComparison.OrdinalIgnoreCase) >= 0) - { - throw new InvalidDataException("Thunder returned an error document instead of a subtitle."); - } - - var valid = format == SRT - ? sample.Contains("-->", StringComparison.Ordinal) - : sample.Contains("[Script Info]", StringComparison.OrdinalIgnoreCase) || sample.Contains("Dialogue:", StringComparison.OrdinalIgnoreCase); - if (!valid) - { - throw new InvalidDataException($"Downloaded content is not a valid {format} subtitle."); - } - } - /// /// 规范化语言代码 /// diff --git a/MeiamSubtitles.sln b/MeiamSubtitles.sln index 722f4c4..507d870 100644 --- a/MeiamSubtitles.sln +++ b/MeiamSubtitles.sln @@ -13,6 +13,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.MeiamSub.Assrt", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Emby.MeiamSub.Assrt", "Emby.MeiamSub.Assrt\Emby.MeiamSub.Assrt.csproj", "{D276A8D0-4B17-4EEF-94B6-61D646DE5A22}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MeiamSubtitles.Tests", "tests\MeiamSubtitles.Tests\MeiamSubtitles.Tests.csproj", "{B8A8BFC1-8A0D-48C5-89AC-4DDDC2DDC3C9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -39,6 +41,10 @@ Global {D276A8D0-4B17-4EEF-94B6-61D646DE5A22}.Debug|Any CPU.Build.0 = Debug|Any CPU {D276A8D0-4B17-4EEF-94B6-61D646DE5A22}.Release|Any CPU.ActiveCfg = Release|Any CPU {D276A8D0-4B17-4EEF-94B6-61D646DE5A22}.Release|Any CPU.Build.0 = Release|Any CPU + {B8A8BFC1-8A0D-48C5-89AC-4DDDC2DDC3C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8A8BFC1-8A0D-48C5-89AC-4DDDC2DDC3C9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8A8BFC1-8A0D-48C5-89AC-4DDDC2DDC3C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B8A8BFC1-8A0D-48C5-89AC-4DDDC2DDC3C9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Shared/SubtitleContentValidator.cs b/Shared/SubtitleContentValidator.cs new file mode 100644 index 0000000..4aab87b --- /dev/null +++ b/Shared/SubtitleContentValidator.cs @@ -0,0 +1,108 @@ +using System; +using System.IO; +using System.Text; + +namespace MeiamSubtitles.Shared +{ + internal static class SubtitleContentValidator + { + public static void Validate(byte[] data, string format, string mediaType) + { + if (data == null || data.Length < 8) + { + throw new InvalidDataException("Subtitle response is empty."); + } + + if ((data[0] == (byte)'P' && data[1] == (byte)'K') || + (data.Length >= 7 && Encoding.ASCII.GetString(data, 0, 7) == "Rar!\u001a\u0007")) + { + throw new InvalidDataException("Compressed subtitle responses are not supported by Thunder."); + } + + var sample = DecodeSample(data); + if (mediaType?.IndexOf("html", StringComparison.OrdinalIgnoreCase) >= 0 || + mediaType?.IndexOf("json", StringComparison.OrdinalIgnoreCase) >= 0) + { + throw new InvalidDataException("Thunder returned an error document instead of a subtitle."); + } + + var valid = format == "srt" + ? sample.IndexOf("-->", StringComparison.Ordinal) >= 0 + : sample.IndexOf("[Script Info]", StringComparison.OrdinalIgnoreCase) >= 0 || + sample.IndexOf("Dialogue:", StringComparison.OrdinalIgnoreCase) >= 0; + + if (valid) + { + return; + } + + if (sample.StartsWith("<", StringComparison.Ordinal) || + sample.StartsWith("{", StringComparison.Ordinal) || + sample.StartsWith("[", StringComparison.Ordinal)) + { + throw new InvalidDataException("Thunder returned an error document instead of a subtitle."); + } + + throw new InvalidDataException($"Downloaded content is not a valid {format} subtitle."); + } + + private static string DecodeSample(byte[] data) + { + var length = Math.Min(data.Length, 4096); + var encoding = DetectEncoding(data); + return encoding.GetString(data, 0, length) + .TrimStart('\uFEFF', ' ', '\r', '\n', '\t'); + } + + private static Encoding DetectEncoding(byte[] data) + { + if (data.Length >= 2) + { + if (data[0] == 0xFF && data[1] == 0xFE) + { + return Encoding.Unicode; + } + + if (data[0] == 0xFE && data[1] == 0xFF) + { + return Encoding.BigEndianUnicode; + } + } + + var length = Math.Min(data.Length, 512); + var pairs = length / 2; + if (pairs >= 4) + { + // Some Thunder SRT responses are UTF-16 without a BOM. Detect the + // characteristic null-byte pattern before falling back to UTF-8. + var littleEndianNulls = 0; + var bigEndianNulls = 0; + for (var i = 0; i < pairs * 2; i += 2) + { + if (data[i + 1] == 0) + { + littleEndianNulls++; + } + + if (data[i] == 0) + { + bigEndianNulls++; + } + } + + var threshold = Math.Max(2, pairs / 4); + if (littleEndianNulls >= threshold && littleEndianNulls > bigEndianNulls * 2) + { + return Encoding.Unicode; + } + + if (bigEndianNulls >= threshold && bigEndianNulls > littleEndianNulls * 2) + { + return Encoding.BigEndianUnicode; + } + } + + return new UTF8Encoding(false, false); + } + } +} diff --git a/tests/MeiamSubtitles.Tests/MeiamSubtitles.Tests.csproj b/tests/MeiamSubtitles.Tests/MeiamSubtitles.Tests.csproj new file mode 100644 index 0000000..f8ba95c --- /dev/null +++ b/tests/MeiamSubtitles.Tests/MeiamSubtitles.Tests.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + false + true + enable + enable + + + + + + + + + + + + + diff --git a/tests/MeiamSubtitles.Tests/SubtitleContentValidatorTests.cs b/tests/MeiamSubtitles.Tests/SubtitleContentValidatorTests.cs new file mode 100644 index 0000000..c7f5082 --- /dev/null +++ b/tests/MeiamSubtitles.Tests/SubtitleContentValidatorTests.cs @@ -0,0 +1,48 @@ +using System.IO; +using System.Text; +using MeiamSubtitles.Shared; +using Xunit; + +namespace MeiamSubtitles.Tests; + +public class SubtitleContentValidatorTests +{ + [Fact] + public void AcceptsAssWithScriptInfoHeader() + { + var data = Encoding.UTF8.GetBytes("[Script Info]\r\nScriptType: v4.00+\r\n[Events]\r\nDialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,Hello"); + + SubtitleContentValidator.Validate(data, "ass", "application/octet-stream"); + } + + [Fact] + public void AcceptsUtf16LittleEndianSrtWithoutBom() + { + var data = Encoding.Unicode.GetBytes("1\r\n00:00:01,000 --> 00:00:02,000\r\nHello\r\n"); + + SubtitleContentValidator.Validate(data, "srt", "application/octet-stream"); + } + + [Theory] + [InlineData("temporary error", "application/octet-stream")] + [InlineData("{\"error\":\"temporary error\"}", "application/json")] + [InlineData("[\"temporary error\"]", "application/octet-stream")] + public void RejectsErrorDocuments(string content, string mediaType) + { + var exception = Assert.Throws(() => + SubtitleContentValidator.Validate(Encoding.UTF8.GetBytes(content), "srt", mediaType)); + + Assert.Contains("error document", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void RejectsCompressedSubtitleResponse() + { + var data = new byte[] { (byte)'P', (byte)'K', 3, 4, 5, 6, 7, 8 }; + + var exception = Assert.Throws(() => + SubtitleContentValidator.Validate(data, "srt", "application/octet-stream")); + + Assert.Contains("Compressed", exception.Message, StringComparison.Ordinal); + } +}