9 Commits

Author SHA1 Message Date
Meiam
eb44362717 release: v1.0.17.0 2026-09-16 16:18:27 +08:00
Meiam
9455450cf2 fix: broaden Emby and Jellyfin compatibility 2026-09-16 16:10:15 +08:00
91270
c473839f4d Merge pull request #150 from 91270/codex/pr-149-takeover
修复 Thunder 对合法 ASS/SSA 与 UTF-16 字幕的误拒绝,并加强错误响应校验。
2026-08-31 10:16:02 +08:00
Meiam
46bc076b8f fix: strengthen Thunder subtitle validation 2026-08-31 10:12:47 +08:00
yjt
7ec1583bd0 fix: accept valid Thunder subtitle encodings 2026-08-23 18:19:13 +08:00
Meiam
bf6304df96 文档: 更新搬瓦工套餐与优惠码 2026-08-10 15:47:28 +08:00
Meiam
87a5c361b8 修复: 媒体文件不可读时回退名称搜索 2026-08-07 15:41:33 +08:00
Meiam
dba34a6231 文档: 调整 README 徽章展示 2026-08-07 15:31:33 +08:00
Meiam
1fe2a70221 文档: 完善安装配置与故障排查说明 2026-08-07 15:30:07 +08:00
15 changed files with 645 additions and 176 deletions

View File

@@ -1,15 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyVersion>1.0.16.0</AssemblyVersion>
<FileVersion>1.0.16.0</FileVersion>
<Version>1.0.16.0</Version>
<AssemblyVersion>1.0.17.0</AssemblyVersion>
<FileVersion>1.0.17.0</FileVersion>
<Version>1.0.17.0</Version>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<!-- Emby 4.9 provides SharpCompress 0.33.0 at runtime. Upgrading the compile-time
reference would make the plugin request an assembly version the host does not provide. -->
<!-- 已验证 Emby 4.8.10.0、4.9.5.0 和 4.10.0.40 均提供 SharpCompress 0.33.0。
保持与宿主程序集版本一致,避免插件加载或解压失败。 -->
<NoWarn>$(NoWarn);NU1902</NoWarn>
</PropertyGroup>
@@ -26,8 +26,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="MediaBrowser.Common" Version="4.9.1.90" />
<PackageReference Include="MediaBrowser.Server.Core" Version="4.9.1.90" />
<!-- 使用旧版宿主可解析的 SDK,避免插件要求 4.9.1.90 程序集。 -->
<PackageReference Include="MediaBrowser.Common" Version="4.8.10" />
<PackageReference Include="MediaBrowser.Server.Core" Version="4.8.10" />
<PackageReference Include="SharpCompress" Version="0.33.0" ExcludeAssets="runtime" PrivateAssets="all" />
</ItemGroup>

View File

@@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyVersion>1.0.16.0</AssemblyVersion>
<FileVersion>1.0.16.0</FileVersion>
<Version>1.0.16.0</Version>
<AssemblyVersion>1.0.17.0</AssemblyVersion>
<FileVersion>1.0.17.0</FileVersion>
<Version>1.0.17.0</Version>
</PropertyGroup>
<PropertyGroup>
@@ -24,8 +24,9 @@
<ItemGroup>
<PackageReference Include="MediaBrowser.Common" Version="4.9.1.90" />
<PackageReference Include="MediaBrowser.Server.Core" Version="4.9.1.90" />
<!-- 使用旧版宿主可解析的 SDK,避免插件要求 4.9.1.90 程序集。 -->
<PackageReference Include="MediaBrowser.Common" Version="4.8.10" />
<PackageReference Include="MediaBrowser.Server.Core" Version="4.8.10" />
</ItemGroup>
@@ -33,6 +34,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;
@@ -76,7 +77,13 @@ namespace Emby.MeiamSub.Thunder
/// <returns>远程字幕信息列表</returns>
public async Task<IEnumerable<RemoteSubtitleInfo>> Search(SubtitleSearchRequest request, CancellationToken cancellationToken)
{
_logger.Info("{0} Search | SubtitleSearchRequest -> {1}", new object[2] { Name, _jsonSerializer.SerializeToString(request) });
if (request == null)
{
_logger.Info("{0} Search | Request is null, skip search.", Name);
return Array.Empty<RemoteSubtitleInfo>();
}
_logger.Info("{0} Search | ContentType -> {1} | Language -> {2}", Name, request.ContentType, request.Language);
var subtitles = await SearchSubtitlesAsync(request, cancellationToken);
@@ -130,11 +137,25 @@ namespace Emby.MeiamSub.Thunder
return Array.Empty<RemoteSubtitleInfo>();
}
var cid = string.Empty;
var stopWatch = Stopwatch.StartNew();
var cid = await GetCidByFileAsync(request.MediaPath, cancellationToken);
stopWatch.Stop();
_logger.Info("{0} Search | FileHash -> {1} (Took {2}ms)", new object[3] { Name, cid, stopWatch.ElapsedMilliseconds });
try
{
cid = await GetCidByFileAsync(request.MediaPath, cancellationToken);
_logger.Info("{0} Search | FileHash -> {1} (Took {2}ms)", Name, cid, stopWatch.ElapsedMilliseconds);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
{
_logger.Warn("{0} Search | File hash unavailable ({1}: {2}), continue with name search.", Name, ex.GetType().Name, ex.Message);
}
finally
{
stopWatch.Stop();
}
HttpRequestOptions options = new HttpRequestOptions
@@ -186,7 +207,7 @@ namespace Emby.MeiamSub.Thunder
ProviderName = $"{Name}",
Format = NormalizeFormat(item.Ext),
Comment = $"Format : {NormalizeFormat(item.Ext)}",
IsHashMatch = cid == item.Cid,
IsHashMatch = !string.IsNullOrEmpty(cid) && string.Equals(cid, item.Cid, StringComparison.OrdinalIgnoreCase),
});
}
}
@@ -265,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()
{
@@ -335,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

@@ -2,10 +2,10 @@
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Version>1.0.16.0</Version>
<AssemblyVersion>1.0.16.0</AssemblyVersion>
<FileVersion>1.0.16.0</FileVersion>
<TargetFramework>net8.0</TargetFramework>
<Version>1.0.17.0</Version>
<AssemblyVersion>1.0.17.0</AssemblyVersion>
<FileVersion>1.0.17.0</FileVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
@@ -13,7 +13,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.11.11" />
<!-- 使用已验证的最低兼容基线,保持同一 DLL 可运行于 Jellyfin 10.10.7、10.11.11 和 12.1。 -->
<PackageReference Include="Jellyfin.Controller" Version="10.10.7" />
<PackageReference Include="SharpCompress" Version="0.50.1" GeneratePathProperty="true" />
</ItemGroup>

View File

@@ -4,9 +4,9 @@
"guid": "E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0",
"name": "MeiamSub.Assrt",
"owner": "Meiam",
"targetAbi": "10.11.0.0",
"timestamp": "2026-08-07T07:11:29.0000000Z",
"version": "1.0.16.0",
"targetAbi": "10.10.7.0",
"timestamp": "2026-09-16T08:13:02.0000000Z",
"version": "1.0.17.0",
"status": "Active",
"imagePath": "thumb.png"
}

View File

@@ -2,9 +2,9 @@
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Version>1.0.16.0</Version>
<AssemblyVersion>1.0.16.0</AssemblyVersion>
<TargetFramework>net8.0</TargetFramework>
<Version>1.0.17.0</Version>
<AssemblyVersion>1.0.17.0</AssemblyVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
@@ -12,7 +12,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.11.11" />
<!-- 使用已验证的最低兼容基线,保持同一 DLL 可运行于 Jellyfin 10.10.7、10.11.11 和 12.1。 -->
<PackageReference Include="Jellyfin.Controller" Version="10.10.7" />
</ItemGroup>
<ItemGroup>
@@ -21,6 +22,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;
@@ -135,11 +136,27 @@ namespace Jellyfin.MeiamSub.Thunder
return Array.Empty<RemoteSubtitleInfo>();
}
var cid = string.Empty;
var stopWatch = Stopwatch.StartNew();
var cid = await GetCidByFileAsync(request.MediaPath, cancellationToken);
stopWatch.Stop();
_logger.LogInformation(Name + " Search | FileHash -> " + cid + " (Took " + stopWatch.ElapsedMilliseconds + "ms)");
try
{
cid = await GetCidByFileAsync(request.MediaPath, cancellationToken);
_logger.LogInformation("{Provider} Search | FileHash -> {FileHash} (Took {ElapsedMilliseconds}ms)",
Name, cid, stopWatch.ElapsedMilliseconds);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
{
_logger.LogWarning("{Provider} Search | File hash unavailable ({ExceptionType}: {Message}), continue with name search.",
Name, ex.GetType().Name, ex.Message);
}
finally
{
stopWatch.Stop();
}
using var options = new HttpRequestMessage
{
@@ -190,7 +207,7 @@ namespace Jellyfin.MeiamSub.Thunder
ProviderName = $"{Name}",
Format = NormalizeFormat(item.Ext),
Comment = $"Format : {NormalizeFormat(item.Ext)}",
IsHashMatch = cid == item.Cid,
IsHashMatch = !string.IsNullOrEmpty(cid) && string.Equals(cid, item.Cid, StringComparison.OrdinalIgnoreCase),
});
}
}
@@ -266,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()
@@ -337,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

@@ -4,9 +4,9 @@
"guid": "E4CE9DA9-EF00-417C-96F2-861C512D45EB",
"name": "MeiamSub.Thunder",
"owner": "Meiam",
"targetAbi": "10.11.0.0",
"timestamp": "2026-08-07T07:11:29.0000000Z",
"version": "1.0.16.0",
"targetAbi": "10.10.7.0",
"timestamp": "2026-09-16T08:13:02.0000000Z",
"version": "1.0.17.0",
"status": "Active",
"imagePath": "thumb.png"
}

201
LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

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

200
README.md
View File

@@ -1,109 +1,165 @@
# 🎬 MeiamSubtitles
# MeiamSubtitles
**MeiamSubtitles** 是一款专为 **Emby** 和 **Jellyfin** 媒体服务器打造的中文字幕下载插件。它集成了 **迅雷影音** 与 **射手网** 的强大搜索能力,支持精准的视频哈希(Hash)匹配,让您的媒体库自动补全高质量字幕。
---
**MeiamSubtitles** 是一款专为 **Emby** 和 **Jellyfin** 媒体服务器打造的中文字幕下载插件。它集成了 **[Assrt(伪射手网)](https://assrt.net/)** 与 **迅雷影音** 的字幕搜索能力,支持影片名称与视频特征(CID)匹配,帮助您的媒体库便捷地搜索和补全中文字幕。
<p align="left">
<a href="https://github.com/91270/MeiamSubtitles/releases/latest"><img src="https://img.shields.io/github/v/release/91270/MeiamSubtitles" alt="Release"></a>
<img src="https://img.shields.io/badge/.NET-Standard%202.1%20%7C%209.0-blueviolet.svg" alt=".NET Status">
<img src="https://img.shields.io/badge/Platform-Linux%20%7C%20Win%20%7C%20OSX-brightgreen.svg" alt="Platform">
<img src="https://img.shields.io/badge/license-Apache%202-blue" alt="LICENSE">
<a href="https://github.com/91270/Emby.MeiamSub"><img src="https://img.shields.io/github/stars/91270/Emby.MeiamSub?label=Star%20this%20repo" alt="Star"></a>
<a href="#插件与兼容性"><img src="https://img.shields.io/badge/Platform-Linux%20%7C%20Windows%20%7C%20macOS-brightgreen.svg" alt="Platform"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a>
<a href="https://www.592.la/"><img src="https://img.shields.io/badge/博客-Meiam's%20Home-brightgreen.svg" alt="博客"></a>
</p>
当前维护两个字幕源:
- **[Assrt(伪射手网)](https://assrt.net/)**:按影片名称搜索,支持下载并解析 ZIP、RAR 等压缩字幕;需要配置 Assrt API Token。
- **Thunder**:根据视频文件计算 CID,优先匹配对应文件的字幕;无需额外配置。
> Shooter 的旧版接口已很少返回有效字幕,自 v1.0.16.0 起停止编译、维护和发布。
## 📣 广告时间
> **搬瓦工 $99 年付**:建站神器重出江湖,THE PLAN V1 传家宝套餐,18机房随意切换。
> **循环优惠码**:`NODESEEK2026` (6.77%)
> **[直达通道 (传家宝套餐)](https://bwh88.net/aff.php?aff=117&pid=87)**
> **搬瓦工 $99 年付**:建站神器重出江湖,THE PLAN V1 传家宝套餐,18 机房随意切换。
>
> **配置**:2 核 / 2G / 40G SSD / 1TB 流量 / 2.5Gbps,支持 CN2 GIA 等大陆优化线路。
>
> **当前推荐循环优惠码**:`BWHNCXNVXV`(最高约 6.81%,实际优惠以结账页验证结果为准)
>
> **[直达通道(传家宝套餐)](https://bwh88.net/aff.php?aff=117&pid=87)**
## ✨ 核心特性
## 功能
- **🚀 精准匹配**: 支持迅雷看看 (CID) 和射手网 (Hash) 双重校验逻辑,确保字幕与视频内容完美同步。
- **⚡ 极致性能**: 核心采样算法全面采用**异步 I/O (Async/Await)** 模式,在大规模媒体库扫描时不会阻塞服务器线程。
- **🌐 广泛兼容**: 深度适配 **Jellyfin 10.11+** 及 **Emby v4.9+**,支持 `zho`、`chi` 等多种国际化语言代码映射。
- **🛡️ 稳定可靠**: 针对射手网 API 的老化问题增加了防御性校验,能有效处理乱码返回,保证系统长效稳定。
- **📝 详尽日志**: 记录哈希计算耗时与接口原始响应,让问题排查不再是黑盒。
- 同时支持 Emby 和 Jellyfin。
- 支持 `zho`、`chi`、`zh-CN` 等中文语言代码。
- 使用异步文件读取计算视频特征,适用于本地及网络媒体库。
- 对搜索结果进行格式校验、去重和排序。
- 下载前校验响应内容,避免将空响应或错误页面保存为字幕。
- Assrt 支持从压缩包中选择并提取 `.srt`、`.ass`、`.ssa` 字幕。
- 日志隐藏 Assrt Token,且不记录完整接口响应。
## 📦 项目组件说明
## 插件与兼容性
| 组件名称 | 适用平台 | 目标框架 | 说明 |
| :--- | :--- | :--- | :--- |
| **Emby.MeiamSub.Thunder** | Emby | .NET Standard 2.1 | 迅雷看看字幕插件 |
| **Emby.MeiamSub.Assrt** | Emby | .NET Standard 2.1 | Assrt 字幕插件 |
| **Jellyfin.MeiamSub.Thunder** | Jellyfin | .NET 9.0 | 迅雷看看字幕插件 (现代 DI 架构) |
| **Jellyfin.MeiamSub.Assrt** | Jellyfin | .NET 9.0 | Assrt 字幕插件 |
| **Emby.MeiamSub.DevTool** | 开发调试 | .NET 8.0 | 哈希算法测试与 API 模拟工具 |
| 插件 | 平台 | 目标框架 | 字幕源 |
| --- | --- | --- | --- |
| `Emby.MeiamSub.Assrt` | Emby(版本限制见下文) | .NET Standard 2.1 | [Assrt.net](https://assrt.net/) |
| `Emby.MeiamSub.Thunder` | Emby(版本限制见下文) | .NET Standard 2.1 | Thunder XMP |
| `Jellyfin.MeiamSub.Assrt` | Jellyfin(版本限制见下文) | .NET 8.0 | [Assrt.net](https://assrt.net/) |
| `Jellyfin.MeiamSub.Thunder` | Jellyfin(版本限制见下文) | .NET 8.0 | Thunder XMP |
> **插件状态:** 当前仅发布 Assrt 和 Thunder。Shooter 的旧版哈希接口已很少返回有效字幕,自 v1.0.16.0 起停止编译和发布。
| 平台 | 兼容版本 |
| --- | --- |
| Emby | 4.8.10.0 至 4.10.0.40 |
| Jellyfin | 10.10.7 至 12.1 |
---
## 安装
## 🚀 快速安装
### Jellyfin 存储库
### 第一步:获取插件
前往 [GitHub Releases](https://github.com/91270/Emby.MeiamSub/releases) 下载最新版本的发布包。
推荐通过插件存储库安装,以便接收后续更新。
> **🔔 推荐建议**:在媒体库设置中**不勾选**本插件作为默认自动下载器。建议仅在手动“搜索字幕”时使用,以获得更精准的人工筛选体验。
1. 打开 Jellyfin 控制台的“插件 → 存储库”。
2. 添加存储库名称 `MeiamSub`。
3. 填写以下地址:
### 第二步:部署插件
```text
https://github.com/91270/MeiamSubtitles.Release/raw/main/Plugin/manifest-stable.json
```
#### 🔹 方式 A:Jellyfin 存储库安装 (强烈推荐)
Jellyfin 用户可直接添加官方存储库,实现一键安装与自动更新:
1. 控制台 -> **插件** -> **存储库** -> 点击“添加”。
2. 输入名称 `MeiamSub` 和 URL:
`https://github.com/91270/MeiamSubtitles.Release/raw/main/Plugin/manifest-stable.json`
3. 在“目录”中找到插件并安装,重启服务即可。
4. 在“目录”中分别安装 `MeiamSub.Assrt` 或 `MeiamSub.Thunder`。
5. 重启 Jellyfin。
#### 🔹 方式 B:手动安装 (Emby/通用)
将下载的 `.dll` 文件(Jellyfin 用户请下载 `.zip` 并解压完整目录)放入服务器的 `plugins` 文件夹:
### 手动安装
- **Windows**: `AppData\Local\jellyfin\plugins` 或 `Emby-Server\programdata\plugins`
- **Linux/Docker**: `/config/plugins` 或 `/var/lib/emby/plugins`
- **群晖/威联通**: 对应套件安装目录下的 `plugins` 文件夹
从 [Releases](https://github.com/91270/MeiamSubtitles/releases/latest) 下载与平台对应的压缩包。
---
#### Emby
## ❓ 常见问题排查 (FAQ)
1. 解压 `Emby_v*.zip`。
2. 将需要的插件 DLL 放入 Emby 的 `plugins` 目录。
3. 不要额外复制 `SharpCompress.dll`;Assrt 复用 Emby 自带版本。
4. 重启 Emby。
<details>
<summary><b>1. 为什么在 Jellyfin 10.11+ 中搜不到字幕?</b></summary>
新版 Jellyfin 采用了三位字母的语言代码(如 <code>zho</code>)。请确保您已升级至本插件的 <b>v1.0.13.0</b> 或更高版本,该版本已完美解决语言映射兼容性。
</details>
#### Jellyfin
<details>
<summary><b>2. 为什么射手网有时候返回结果为空?</b></summary>
由于射手网 API 维护状态不佳,对于部分冷门资源或 Hash 不匹配的文件,API 可能会返回非法数据。插件目前已增加防御逻辑,会自动忽略这些无效返回以保护服务器稳定。
</details>
1. 解压 `Jellyfin_v*.zip`。
2. 将需要的整个插件目录复制到 Jellyfin 的 `plugins` 目录。
3. Assrt 目录中的 `SharpCompress.dll` 是 Jellyfin 所需依赖,请勿单独删除。
4. 重启 Jellyfin。
<details>
<summary><b>3. 安装本插件后会影响 Open Subtitles 吗?</b></summary>
不会。本插件已将优先级 (Order) 调整为 100(低优先级),并在代码层面优化了并发逻辑,确保官方插件能优先获取请求机会。
</details>
### 从旧版本升级
<details>
<summary><b>4. 如何提供有效的错误反馈?</b></summary>
如果确定有字幕但搜不到,请在 Issue 中提供日志里的 <code>Target</code> 文件名、计算出的 <code>FileHash</code> 以及 <code>ResponseBody</code> 内容。
</details>
- 升级到 v1.0.16.0 或更高版本后,删除旧的 Shooter DLL 和插件目录;Shooter 已停止维护和发布。
- 同一个插件不要同时保留多个版本,否则服务器可能重复加载或启动失败。
- Emby Assrt 只需保留插件 DLL,不要额外复制 `SharpCompress.dll`。
- Jellyfin Assrt 必须保留发布包中同目录的 `SharpCompress.dll`。
---
常见插件目录:
## 🤝 贡献与感谢
| 环境 | 目录示例 |
| --- | --- |
| Jellyfin Windows | `%LOCALAPPDATA%\jellyfin\plugins` |
| Jellyfin Linux | `/var/lib/jellyfin/plugins` |
| Emby Windows | `%APPDATA%\Emby-Server\programdata\plugins` |
| Docker | 容器映射的 `/config/plugins` |
欢迎通过提交 Issue 或 Pull Request 来完善本项目。
## 配置
- **开发守则**: 遵循异步命名规范,详情请参阅 [.docs/开发协议.md](.docs/开发协议.md)。
- **任务进展**: 查看 [.docs/任务清单.md](.docs/任务清单.md) 了解当前计划。
- **致谢**: 感谢 [Emby.Subtitle.Subscene](https://github.com/nRafinia/Emby.Subtitle.Subscene) 提供的灵感与参考。
### Assrt
---
1. 从 [Assrt](https://assrt.net/) 获取 API Token。
2. 打开插件配置页,将 Token 填入 `Assrt API Token`。
3. 在媒体库的字幕下载器中启用 `MeiamSub.Assrt`。
## ⭐️ 给个星星
未配置 Token 时,Assrt 会跳过搜索并在日志中给出提示。
如果你喜欢这个项目,请给一个 **Star**!这对我非常重要。
### Thunder
如果你有稳定的 Google Drive 团队盘资源可供媒体库插件测试,欢迎联系:`91270#QQ.COM`
Thunder 默认使用视频文件计算 CID。Jellyfin 版本还可在插件设置中启用元数据搜索,用影片名称辅助匹配。
---
*Powered by Meiam*
## 自动下载说明
建议先通过“搜索字幕”手动确认匹配效果,再决定是否启用媒体库自动下载。
- 同一媒体库的字幕下载语言只应添加一次。例如只保留一个 `zho`,重复添加会让服务器发起多次下载请求。
- Emby 与 Jellyfin 如果共用媒体目录,会采用不同的字幕文件命名方式,因此目录中可能同时出现 `.zh-CN.*` 和 `.zho.*` 文件。
- `SearchAllProviders` 表示同时搜索多个字幕源,不代表下载全部搜索结果。
- 自动下载行为由 Emby/Jellyfin 调度;插件只响应服务器发出的搜索与下载请求。
## 常见问题
### 搜不到字幕
- 确认字幕语言已选择中文,并使用最新版插件。
- Assrt 用户请确认 Token 已填写且有效。
- Thunder 依赖视频文件内容计算 CID,请确认服务器能够读取媒体文件。
- 某些影片或版本可能没有对应资源,可尝试启用元数据搜索或手动选择其他结果。
### 自动生成了多个字幕文件
首先检查媒体库的“字幕下载语言”是否存在重复项。服务器会为每个语言项分别执行一次下载,产生 `.0`、`.1` 等后缀。
手动连续下载不同的搜索结果时,服务器也会为避免覆盖已有字幕而添加数字后缀。这属于 Emby/Jellyfin 的文件保存行为,不表示插件正在循环下载。
### Assrt 提示找不到 SharpCompress
- Emby:仅安装插件 DLL,不要携带其他版本的 `SharpCompress.dll`。
- Jellyfin:确保 Assrt 插件目录中包含发布包附带的 `SharpCompress.dll`。
### 如何反馈问题
请在 [Issues](https://github.com/91270/MeiamSubtitles/issues) 中提供:
- Emby/Jellyfin 版本与操作系统;
- 插件名称和版本;
- 媒体文件名、容器格式及问题复现步骤;
- 对应时间段的服务器日志。
提交日志前请删除 Token、媒体路径和其他隐私信息。
## 支持项目
欢迎提交 Issue、Pull Request,或者为项目点一个 Star。
## License
[Apache License 2.0](LICENSE)

View File

@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
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.");
}
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 errorDocument = false;
foreach (var sample in DecodeSamples(data))
{
if (sample.StartsWith("<", StringComparison.Ordinal) ||
sample.StartsWith("{", StringComparison.Ordinal))
{
errorDocument = true;
continue;
}
if (IsValidSubtitle(sample, format))
{
return;
}
if (sample.StartsWith("[", StringComparison.Ordinal))
{
errorDocument = true;
}
}
if (errorDocument)
{
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 bool IsValidSubtitle(string sample, string format)
{
return format == "srt"
? sample.IndexOf("-->", StringComparison.Ordinal) >= 0
: sample.IndexOf("[Script Info]", StringComparison.OrdinalIgnoreCase) >= 0 ||
sample.IndexOf("Dialogue:", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static IEnumerable<string> DecodeSamples(byte[] data)
{
var length = Math.Min(data.Length, 4096);
if (data.Length >= 2)
{
if (data[0] == 0xFF && data[1] == 0xFE)
{
yield return DecodeSample(data, length, Encoding.Unicode);
yield break;
}
if (data[0] == 0xFE && data[1] == 0xFF)
{
yield return DecodeSample(data, length, Encoding.BigEndianUnicode);
yield break;
}
}
if (data.Length >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF)
{
yield return DecodeSample(data, length, Encoding.UTF8);
yield break;
}
// Thunder may return UTF-16 subtitles without a BOM. Try the common
// single-byte/UTF-8 representation and both UTF-16 byte orders, then
// accept only a candidate that contains the expected subtitle syntax.
yield return DecodeSample(data, length, new UTF8Encoding(false, false));
yield return DecodeSample(data, length, Encoding.Unicode);
yield return DecodeSample(data, length, Encoding.BigEndianUnicode);
}
private static string DecodeSample(byte[] data, int length, Encoding encoding)
{
return encoding.GetString(data, 0, length)
.TrimStart('\uFEFF', ' ', '\r', '\n', '\t');
}
}
}

View File

@@ -16,6 +16,8 @@ param (
)
$ErrorActionPreference = "Stop"
$SupportedEmbySdkVersion = "4.8.10"
$SupportedJellyfinSdkVersion = "10.10.7"
Write-Host "=== Start Release Process, Target Version: $Version ===" -ForegroundColor Cyan
@@ -32,12 +34,20 @@ $EmbyThunderCsproj = "Emby.MeiamSub.Thunder\Emby.MeiamSub.Thunder.csproj"
$EmbyAssrtCsproj = "Emby.MeiamSub.Assrt\Emby.MeiamSub.Assrt.csproj"
# Update NuGet package version if specified
if ($JellyfinVersion -and $JellyfinVersion -ne $SupportedJellyfinSdkVersion) {
throw "Jellyfin SDK must remain at $SupportedJellyfinSdkVersion for the verified 10.10.7-12.1 compatibility range. Update the compatibility matrix before changing it."
}
if ($JellyfinVersion) {
Write-Host "Upgrading Jellyfin.Controller NuGet dependency to $JellyfinVersion..." -ForegroundColor Yellow
(Get-Content $ThunderCsproj) -replace '<PackageReference Include="Jellyfin.Controller" Version="[^"]+" />', "<PackageReference Include=`"Jellyfin.Controller`" Version=`"$JellyfinVersion`" />" | Set-Content $ThunderCsproj
(Get-Content $AssrtCsproj) -replace '<PackageReference Include="Jellyfin.Controller" Version="[^"]+" />', "<PackageReference Include=`"Jellyfin.Controller`" Version=`"$JellyfinVersion`" />" | Set-Content $AssrtCsproj
}
if ($EmbyVersion -and $EmbyVersion -ne $SupportedEmbySdkVersion) {
throw "Emby SDK must remain at $SupportedEmbySdkVersion for the verified 4.8.10.0-4.10.0.40 compatibility range. Update the compatibility matrix before changing it."
}
if ($EmbyVersion) {
Write-Host "Upgrading MediaBrowser NuGet dependencies to $EmbyVersion..." -ForegroundColor Yellow
(Get-Content $EmbyThunderCsproj) -replace '<PackageReference Include="MediaBrowser.Common" Version="[^"]+" />', "<PackageReference Include=`"MediaBrowser.Common`" Version=`"$EmbyVersion`" />" `

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,84 @@
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");
}
[Fact]
public void AcceptsUtf16LittleEndianSrtWithoutBomWithLongChineseCue()
{
var content = "1\r\n00:00:01,000 --> 00:00:20,000\r\n" + new string('中', 300) + "\r\n";
var data = Encoding.Unicode.GetBytes(content);
SubtitleContentValidator.Validate(data, "srt", "application/octet-stream");
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public void AcceptsUtf16SrtInEitherByteOrder(bool bigEndian, bool withBom)
{
var encoding = new UnicodeEncoding(bigEndian, withBom);
var content = "1\r\n00:00:01,000 --> 00:00:02,000\r\n你好\r\n";
var body = encoding.GetBytes(content);
var data = withBom ? encoding.GetPreamble().Concat(body).ToArray() : body;
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);
}
[Theory]
[InlineData("<html><!-- service unavailable --><body>Error</body></html>", "srt")]
[InlineData("{\"error\":\"download --> failed\"}", "srt")]
[InlineData("<html><body>Dialogue: download failed</body></html>", "ass")]
public void RejectsErrorDocumentsThatContainSubtitleMarkers(string content, string format)
{
var exception = Assert.Throws<InvalidDataException>(() =>
SubtitleContentValidator.Validate(Encoding.UTF8.GetBytes(content), format, "application/octet-stream"));
Assert.Contains("error document", exception.Message, StringComparison.OrdinalIgnoreCase);
}
}