fix: accept valid Thunder subtitle encodings

This commit is contained in:
yjt
2026-08-23 18:19:13 +08:00
parent bf6304df96
commit 7ec1583bd0
8 changed files with 195 additions and 66 deletions

View File

@@ -33,6 +33,10 @@
<Folder Include="Configuration\" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\Shared\SubtitleContentValidator.cs" Link="Shared\SubtitleContentValidator.cs" />
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
</Target>

View File

@@ -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.");
}
}
/// <summary>
/// 规范化语言代码
/// </summary>

View File

@@ -21,6 +21,10 @@
</None>
</ItemGroup>
<ItemGroup>
<Compile Include="..\Shared\SubtitleContentValidator.cs" Link="Shared\SubtitleContentValidator.cs" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Configuration\configPage.html" />
</ItemGroup>

View File

@@ -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.");
}
}
/// <summary>
/// 规范化语言代码
/// </summary>

View File

@@ -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

View File

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

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\..\Shared\SubtitleContentValidator.cs" Link="Shared\SubtitleContentValidator.cs" />
</ItemGroup>
</Project>

View File

@@ -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("<html><body>temporary error</body></html>", "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<InvalidDataException>(() =>
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<InvalidDataException>(() =>
SubtitleContentValidator.Validate(data, "srt", "application/octet-stream"));
Assert.Contains("Compressed", exception.Message, StringComparison.Ordinal);
}
}