mirror of
https://github.com/91270/MeiamSubtitles.git
synced 2026-11-03 22:16:35 +08:00
fix: accept valid Thunder subtitle encodings
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Shared\SubtitleContentValidator.cs" Link="Shared\SubtitleContentValidator.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
108
Shared/SubtitleContentValidator.cs
Normal file
108
Shared/SubtitleContentValidator.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
21
tests/MeiamSubtitles.Tests/MeiamSubtitles.Tests.csproj
Normal file
21
tests/MeiamSubtitles.Tests/MeiamSubtitles.Tests.csproj
Normal 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>
|
||||
48
tests/MeiamSubtitles.Tests/SubtitleContentValidatorTests.cs
Normal file
48
tests/MeiamSubtitles.Tests/SubtitleContentValidatorTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user