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);
+ }
+}