85 Commits

Author SHA1 Message Date
Meiam
5176b552e2 发布: 更新滚动预发布版至 v1.0.17.1 2026-09-17 11:17:42 +08:00
Meiam
66a132ceb3 构建: 区分正式与预发布流程 2026-09-17 11:15:48 +08:00
Meiam
c40d83eb8d 修复: 统一迅雷字幕编码为 UTF-8 2026-09-17 10:45:22 +08:00
Meiam
f811632e8a 文档: 更新当前框架徽章 2026-09-17 10:45:22 +08:00
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
Meiam
5c309f44ee 发布: 停止 Shooter 并发布 1.0.16.0 2026-08-07 15:14:09 +08:00
Meiam
d9407006df 发布: 提升字幕下载可靠性并弃用 Shooter 2026-08-07 14:22:31 +08:00
Meiam
786cb82868 更新: 替换 Assrt 插件图片 2026-08-07 12:20:32 +08:00
Meiam
38a46d2f53 修复: 支持 Assrt 压缩字幕下载 2026-08-07 11:56:46 +08:00
Meiam
220fe5459d 文档: 更新 README 中的搬瓦工优惠码 2026-07-07 17:38:37 +08:00
Meiam
66ed8ff49c 新增: Assrt(伪射手)字幕提供程序,支持 Emby/Jellyfin 双平台 2026-06-15 15:19:15 +08:00
Meiam
c4adc97bbf fix: ignore network paths when computing video hash to prevent crashes (#136) 2026-06-11 11:43:51 +08:00
Meiam
ba40c107cf chore: support custom notes in release script 2026-06-11 11:19:44 +08:00
Meiam
58448f73db release: v1.0.14.0 2026-06-11 11:17:13 +08:00
Meiam
423021ee32 适配 Jellyfin 10.11.11 2026-06-11 11:02:38 +08:00
Meiam
74bba07091 新增: 迅雷字幕元数据搜索选项移植到 Jellyfin
任务摘要:将 PR #133 的元数据搜索功能从 Emby.MeiamSub.Thunder 移植到 Jellyfin.MeiamSub.Thunder。新增 EnableUseMetadata 配置项和 configPage.html 配置页面,修改 Plugin.cs 注册 IHasWebPages,ThunderProvider.cs 添加元数据搜索逻辑。同步更新开发协议、任务清单和归档索引。
2026-02-11 18:20:21 +08:00
Meiam
eb3268de44 更新:忽略文件 2026-02-11 17:13:53 +08:00
91270
856a6777c7 Merge pull request #133 from Mayfly777w/master
迅雷字幕提供额外匹配选项
2026-01-11 23:21:02 +08:00
Mayfly777w
60ff15c57a 迅雷字幕提供额外匹配选项
迅雷字幕提供额外匹配选项,可以以元数据中的系列名和季集信息来作为匹配对象
影视/电影做了特殊处理,可以正常匹配
2026-01-01 22:25:42 +08:00
Meiam
0761c11a69 新增(Jellyfin): 为 Shooter 和 Thunder 插件添加图标并配置编译输出 2025-12-23 13:49:52 +08:00
Meiam
72ca0c5c39 docs: 恢复 README.md 中被误删的广告位内容 2025-12-22 20:10:56 +08:00
Meiam
2c02096298 docs: 深度美化 README.md 排版并更新特性说明 2025-12-22 20:08:26 +08:00
Meiam
2e622cda22 chore: 从版本库中移除 GEMINI.md (保留本地文件) 2025-12-22 19:37:48 +08:00
Meiam
df8ca3cbd7 chore: 完善部署与发布文档,准备发布 v1.0.13.0 2025-12-22 19:31:48 +08:00
Meiam
2b4068e8a9 docs: 完善 README.md,增加常见问题排查指南 (FAQ) 2025-12-22 18:10:03 +08:00
Meiam
35ea11883f fix: 加固射手网 API 稳定性并进行全量代码格式化
- 修复:针对射手网 API 年久失修、无结果时返回乱码或非法内容的问题,增加了 JSON 合法性校验逻辑,确保插件在异常返回下能静默退出而不崩溃。
- 优化:执行了全量代码格式化 (dotnet format),确保缩进、换行及代码风格符合 .NET 官方规范。
- 维护:清理了所有项目中不再使用的提示性条目逻辑,保持代码简洁。
2025-12-22 18:06:54 +08:00
Meiam
8f79853e00 feat: 升级调试工具并还原哈希匹配逻辑
1. 调试工具升级:在 DevTool 中实现了射手网和迅雷影音的实时 HASH 计算与 API 接口测试功能。
2. 还原匹配逻辑:确认 Open Subtitles 冲突并非由于 IsHashMatch 引起,现已恢复精准哈希匹配 (IsHashMatch = true) 逻辑。
3. 优化语言识别:完善了 NormalizeLanguage 逻辑,确保支持 zho, chi, zh 等多种语言代码。
4. 日志级别调整:为了方便排查搜索不到字幕的问题,将字幕接口的原始返回内容恢复为 Info 记录级别。
2025-12-22 17:49:39 +08:00
Meiam
e7d1629166 fix: 解决 Emby 重复下载问题并修复 Jellyfin SDK 兼容性
- 修复:在 Emby 插件中显式为 RemoteSubtitleInfo 设置 Language 属性,确保服务器能正确识别下载语言,防止循环下载。
- 兼容性:在 Jellyfin 插件中移除暂不兼容的语言属性赋值,确保项目在最新 SDK 下编译通过。
- 维护:完成所有 Provider 文件的日志增强逻辑(耗时监控、参数显式化)。
2025-12-22 16:19:47 +08:00
Meiam
b88712ead1 fix: 兼容 Jellyfin zho 语言代码并增强搜索日志
- 修复:优化 NormalizeLanguage 逻辑,增加对 zho, zh, chi 等多种语言代码映射的支持,解决 Jellyfin 10.11+ 搜索失效问题。
- 增强:在搜索日志中显式输出目标文件名和语言。
- 监控:引入 Stopwatch 记录并输出文件哈希计算耗时,便于排查性能瓶颈。
- 诊断:在日志中记录完整的异常类型信息。
- 日志:恢复字幕接口原始返回内容的 Info 级别记录,方便问题排查。
2025-12-22 15:28:39 +08:00
Meiam
01c6911855 docs: 优化 README.md,更新技术栈徽章与安装说明 2025-12-22 14:34:08 +08:00
Meiam
68f4130a30 docs: 更新 GEMINI.md 以反映最新的架构和依赖变更 2025-12-22 14:32:18 +08:00
Meiam
41c62c7016 docs: 完善代码署名与文档注释
- 文档:为所有修改过的类(Plugin, PluginServiceRegistrator)及关键方法补充 XML 文档注释。
- 规范:统一添加 '修改人: Meiam' 及 '修改时间: 2025-12-22' 标识,确保代码变更可追溯。
2025-12-22 14:28:29 +08:00
Meiam
96f93818d0 refactor: 优化 Emby 插件日志分级与语言映射逻辑
- 补充提交:提交之前遗漏的 Emby 插件核心逻辑优化。
- 优化:细化日志级别,将 API 响应体记录降级为 Debug。
- 重构:使用 NormalizeLanguage 统一处理语言代码转换。
2025-12-22 14:25:36 +08:00
Meiam
f48fc0910b refactor: 深度架构优化与 Jellyfin/Emby 规范化对齐
1. 引入 IHttpClientFactory:重构 Jellyfin 插件的 HTTP 调用模式,使用依赖注入管理 HttpClient 生命周期。
2. 优化服务注册:在 PluginServiceRegistrator 中统一配置命名 HTTP 客户端的超时与默认 Header。
3. 增强资源加载:优化 Emby 插件缩略图获取逻辑,增加安全性检查。
4. 代码逻辑优化:提取私有方法 NormalizeLanguage 统一处理跨平台语言代码映射。
5. 精简配置:简化 PluginConfiguration,移除冗余引用,同时确保与 Jellyfin 10.x 基类兼容。
6. 日志分级:细化 API 响应内容的记录级别,减少生产环境日志输出。
2025-12-22 14:24:05 +08:00
Meiam
6743851405 refactor: 异步 I/O 改造、异常处理增强及依赖版本升级
1. 异步 I/O 改造:将文件哈希匹配逻辑重构为异步模式,使用 ReadExactlyAsync 提升大文件处理性能。
2. 异常处理:在搜索与下载核心逻辑中增加 try-catch 块及日志记录,确保插件运行稳定性。
3. 命名与风格:统一跨平台项目的变量命名规范(如 remoteSubtitles)与 HTTP 请求构建方式。
4. 注释补全:为核心类和方法补全中文 XML 文档注释,并标注修改人信息。
5. 依赖升级:更新 Jellyfin 核心库至 10.11.5 并升级至 net9.0,更新 Emby 核心库至 4.9.1.90。
2025-12-22 14:09:28 +08:00
Meiam
08fbdfb034 更新说明文档 2025-09-29 10:26:06 +08:00
Meiam
c833ca4eaf 发布 v1.0.12.0 2025-09-29 10:16:35 +08:00
91270
5cd133bc07 Merge pull request #110 from moetayuko/master
Fix thunder json deserialization for jellyfin
2025-03-05 11:25:12 +08:00
Moeta Yuko
fe089c6cb5 Fix thunder json deserialization for jellyfin
Fixes #107
2025-02-24 19:58:09 +08:00
Meiam
5fa9433908 支持新版本 Emby , 迅雷调整到新接口 2025-01-14 17:38:16 +08:00
Meiam
7e960e028c Update 2024-10-27 10:29:24 +08:00
Meiam
ffbe5ce39b update 2024-10-27 10:28:47 +08:00
Meiam
8000919485 Update 2024-10-27 10:27:38 +08:00
Meiam
8095bb9fe4 Update README.md 2024-06-25 13:04:53 +08:00
Meiam
2f80603f99 Update Readme.md 2024-06-25 13:01:34 +08:00
Meiam
bb8135590f Emby .NET 框架更换到 netstandard2.0 适配最新版本 2024-05-24 09:37:43 +08:00
Meiam
f82702f844 解决 Jellyfin 新版调整依赖注入 2024-05-23 19:51:52 +08:00
Meiam
ded87375e1 适配新版 Jellyfin 2024-05-23 16:58:41 +08:00
91270
706971992d Update README.md 2023-06-09 17:24:54 +08:00
Meiam
bbcfd01580 Update README.md 2023-02-15 13:08:59 +08:00
Meiam
008383cf1e Update 2023-02-14 21:58:18 +08:00
Meiam
a6feb50e45 Update 2023-02-14 21:41:57 +08:00
Meiam
0ef2a92705 Update 2023-02-14 21:41:14 +08:00
Meiam
af2d333106 Update 2023-02-14 21:40:21 +08:00
Meiam
630147e853 更新库文件 2023-02-14 21:36:02 +08:00
Meiam
d05afc5ffd 插件库更新 2023-02-14 21:14:40 +08:00
Meiam
237839efea 添加插件库 2023-02-14 21:07:23 +08:00
Meiam
0e5a5d1b0d 编译后执行操作 2023-02-14 19:57:08 +08:00
Meiam
8bebe5ba5b 修复射手无法查询无字幕问题 2023-02-09 22:38:29 +08:00
91270
0c5a3656c8 Update README.md 2023-02-09 16:29:38 +08:00
Meiam
339b05b763 Update 2023-02-07 20:50:19 +08:00
91270
54b13d647d Update README.md 2023-02-02 21:01:48 +08:00
91270
2e06131f8d Update README.md 2023-02-02 21:00:57 +08:00
91270
8f69a7ffca Update README.md 2023-02-02 21:00:40 +08:00
Meiam
bf178f7cef 适配 Emby v4.7.1.0 2022-06-02 13:07:33 +08:00
91270
39aafddd23 Merge pull request #46 from LuckyPuppy514/master
适配Emby v4.7.1.0
2022-06-01 17:34:49 +08:00
LuckyPuppy514
f8854e2a85 适配EMBY v4.7.1.0 2022-06-01 17:00:45 +08:00
LuckyPuppy514
d63de08b8e 适配EMBY v4.7.1.0 2022-06-01 16:57:38 +08:00
Meiam
ac28e193e3 版本号修改 2022-05-28 10:10:14 +08:00
Meiam
f796c05f99 适配新版本 2022-05-28 10:02:49 +08:00
91270
ddeb8e0c14 Merge pull request #38 from hisune/master
readme增加威联通的位置说明
2022-04-21 21:56:16 +08:00
Hisune
7a0fb35fc5 Update README.md 2022-04-18 10:11:45 +08:00
Meiam
74e576de93 程序逻辑优化 2022-04-10 14:21:09 +08:00
Meiam
bc72ca4ab0 降低依赖侵入,适配低版本 2022-04-09 16:07:26 +08:00
Meiam
2d4940156d 修改说明文件 2022-03-31 21:32:22 +08:00
Meiam
7da60776cb 更新说明文件 2022-03-31 21:20:14 +08:00
Meiam
03ce0a0dda 适配 Jellyfin 最新版本 2022-03-31 21:08:24 +08:00
53 changed files with 4752 additions and 484 deletions

11
.gitignore vendored
View File

@@ -360,4 +360,13 @@ MigrationBackup/
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
FodyWeavers.xsd
TestServer/
.agent/
GEMINI.md
*.bat
*.md

View File

@@ -0,0 +1,427 @@
using Emby.MeiamSub.Assrt.Model;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Model.Logging;
using MediaBrowser.Model.Providers;
using MediaBrowser.Model.Serialization;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using SharpCompress.Archives;
using System.Net.Http;
namespace Emby.MeiamSub.Assrt
{
/// <summary>
/// Assrt (伪射手) 字幕提供程序 (Emby 适配版)
/// </summary>
public class AssrtProvider : ISubtitleProvider, IHasOrder
{
private const string ApiBaseUrl = "https://api.assrt.net/v1";
public const string ASS = "ass";
public const string SSA = "ssa";
public const string SRT = "srt";
protected readonly ILogger _logger;
private readonly IJsonSerializer _jsonSerializer;
private readonly IHttpClient _httpClient;
public int Order => 90; // 优先级高于老旧源
public string Name => "MeiamSub.Assrt";
public IEnumerable<VideoContentType> SupportedMediaTypes => new[] { VideoContentType.Movie, VideoContentType.Episode };
public AssrtProvider(ILogManager logManager, IJsonSerializer jsonSerializer, IHttpClient httpClient)
{
_logger = logManager.GetLogger(GetType().Name);
_jsonSerializer = jsonSerializer;
_httpClient = httpClient;
_logger.Info($"{Name} Init");
}
public async Task<IEnumerable<RemoteSubtitleInfo>> Search(SubtitleSearchRequest request, CancellationToken cancellationToken)
{
_logger.Info($"{Name} Search | Received request for: {(request?.MediaPath ?? "NULL")}");
try
{
if (request == null)
{
return Array.Empty<RemoteSubtitleInfo>();
}
var config = Plugin.Instance?.Options;
if (config == null || string.IsNullOrWhiteSpace(config.AssrtApiToken))
{
_logger.Warn($"{Name} Search | AssrtApiToken is empty, skip search. Please configure the token in plugin settings.");
return Array.Empty<RemoteSubtitleInfo>();
}
var language = NormalizeLanguage(request.Language);
if (language != "chi")
{
_logger.Info($"{Name} Search | Language '{request.Language}' not supported, skip search.");
return Array.Empty<RemoteSubtitleInfo>();
}
string queryText;
if (config.EnableUseMetadata)
{
if (request.ContentType == VideoContentType.Episode)
{
queryText = $"{request.SeriesName} S{request.ParentIndexNumber:D2}E{request.IndexNumber:D2}";
}
else if (request.ContentType == VideoContentType.Movie)
{
queryText = request.Name;
}
else
{
queryText = !string.IsNullOrEmpty(request.MediaPath)
? Path.GetFileNameWithoutExtension(request.MediaPath)
: string.Empty;
}
}
else
{
queryText = !string.IsNullOrEmpty(request.MediaPath)
? Path.GetFileNameWithoutExtension(request.MediaPath)
: string.Empty;
}
if (string.IsNullOrWhiteSpace(queryText))
{
_logger.Warn($"{Name} Search | Query text is empty, skip search.");
return Array.Empty<RemoteSubtitleInfo>();
}
_logger.Info($"{Name} Search | Target -> '{queryText}' | Language -> '{language}'");
var searchUrl = $"{ApiBaseUrl}/sub/search?token={Uri.EscapeDataString(config.AssrtApiToken)}&q={Uri.EscapeDataString(queryText)}&is_file=1&cnt=15";
var maskedSearchUrl = $"{ApiBaseUrl}/sub/search?token={MaskToken(config.AssrtApiToken)}&q={Uri.EscapeDataString(queryText)}&is_file=1&cnt=15";
_logger.Info($"{Name} Search | Request Url -> {maskedSearchUrl}");
var options = new HttpRequestOptions
{
Url = searchUrl,
UserAgent = Name,
TimeoutMs = 20000,
AcceptHeader = "application/json"
};
var response = await _httpClient.GetResponse(options);
if (response.StatusCode != HttpStatusCode.OK)
{
_logger.Error($"{Name} Search | API responded with status: {response.StatusCode}");
return Array.Empty<RemoteSubtitleInfo>();
}
string jsonString;
using (var reader = new StreamReader(response.Content, Encoding.UTF8))
{
jsonString = await reader.ReadToEndAsync();
}
_logger.Info("{0} Search | Response -> {1}", Name, response.StatusCode);
var searchResponse = _jsonSerializer.DeserializeFromString<AssrtSearchResponse>(jsonString);
if (searchResponse == null || searchResponse.status != 0 || searchResponse.sub?.subs == null || !searchResponse.sub.subs.Any())
{
_logger.Warn($"{Name} Search | No subtitles found or API error. Request -> Target: '{queryText}', Language: '{language}', Url: '{maskedSearchUrl}' | Response -> Status: {searchResponse?.status}, Content: '{jsonString}'");
return Array.Empty<RemoteSubtitleInfo>();
}
var remoteSubtitles = new List<RemoteSubtitleInfo>();
foreach (var item in searchResponse.sub.subs)
{
if (item.id == null) continue;
var subtitleId = item.id.ToString();
var format = ExtractFormat(item.subtype) ?? ExtractFormat(item.native_name) ?? SRT;
var langDesc = item.lang?.desc ?? "中文";
var downloadInfo = new AssrtDownloadInfo
{
Id = subtitleId,
Format = format,
Language = request.Language
};
var encodedId = Base64Encode(_jsonSerializer.SerializeToString(downloadInfo));
remoteSubtitles.Add(new RemoteSubtitleInfo
{
Id = encodedId,
Name = $"[MEIAMSUB] {item.native_name ?? item.videoname ?? subtitleId} | {langDesc} | 伪射手",
Author = "Assrt",
ProviderName = Name,
Format = format,
Comment = string.IsNullOrEmpty(item.videoname) ? $"格式: {format}" : $"适配视频: {item.videoname} (格式: {format})",
IsHashMatch = false
});
}
_logger.Info($"{Name} Search | Summary -> Found {remoteSubtitles.Count} subtitles.");
return remoteSubtitles;
}
catch (Exception ex)
{
_logger.Error($"{Name} Search | Exception occurred: [{ex.GetType().Name}] {ex.Message}");
}
return Array.Empty<RemoteSubtitleInfo>();
}
public async Task<SubtitleResponse> GetSubtitles(string id, CancellationToken cancellationToken)
{
_logger.Info($"{Name} GetSubtitles | Requesting ID: {id}");
AssrtDownloadInfo downloadInfo = null;
try
{
var decodedString = Base64Decode(id);
downloadInfo = _jsonSerializer.DeserializeFromString<AssrtDownloadInfo>(decodedString);
if (downloadInfo == null || string.IsNullOrEmpty(downloadInfo.Id))
{
return new SubtitleResponse { Language = "chi", Format = SRT, Stream = new MemoryStream() };
}
var config = Plugin.Instance?.Options;
if (config == null || string.IsNullOrWhiteSpace(config.AssrtApiToken))
{
_logger.Error($"{Name} GetSubtitles | AssrtApiToken is not configured.");
return new SubtitleResponse { Language = downloadInfo.Language, Format = downloadInfo.Format, Stream = new MemoryStream() };
}
// 1. 请求详情接口获取下载地址
var detailUrl = $"{ApiBaseUrl}/sub/detail?token={Uri.EscapeDataString(config.AssrtApiToken)}&id={Uri.EscapeDataString(downloadInfo.Id)}";
var detailOptions = new HttpRequestOptions
{
Url = detailUrl,
UserAgent = Name,
TimeoutMs = 20000,
AcceptHeader = "application/json"
};
var detailResponseMsg = await _httpClient.GetResponse(detailOptions);
if (detailResponseMsg.StatusCode != HttpStatusCode.OK)
{
_logger.Error($"{Name} GetSubtitles | Failed to fetch details. Status: {detailResponseMsg.StatusCode}");
return new SubtitleResponse { Language = downloadInfo.Language, Format = downloadInfo.Format, Stream = new MemoryStream() };
}
string detailJson;
using (var reader = new StreamReader(detailResponseMsg.Content, Encoding.UTF8))
{
detailJson = await reader.ReadToEndAsync();
}
var detailResponse = _jsonSerializer.DeserializeFromString<AssrtDetailResponse>(detailJson);
if (detailResponse == null || detailResponse.status != 0)
{
_logger.Error($"{Name} GetSubtitles | Detail API error. Status: {detailResponse?.status}, Response: {detailJson}");
return new SubtitleResponse { Language = downloadInfo.Language, Format = downloadInfo.Format, Stream = new MemoryStream() };
}
var targetSub = detailResponse?.sub?.subs?.FirstOrDefault();
if (targetSub == null || string.IsNullOrEmpty(targetSub.url))
{
_logger.Error($"{Name} GetSubtitles | Subtitle download URL not found in API response.");
return new SubtitleResponse { Language = downloadInfo.Language, Format = downloadInfo.Format, Stream = new MemoryStream() };
}
var downloadUrl = targetSub.url;
var downloadFilename = targetSub.filename;
_logger.Info($"{Name} GetSubtitles | Downloading subtitle from: {downloadUrl}");
// 2. 下载字幕流 (手动接管重定向以确保每次重定向请求均带有正确的防盗链 Headers)
var handler = new HttpClientHandler { AllowAutoRedirect = false };
using var cleanHttpClient = new HttpClient(handler);
var currentUrl = downloadUrl;
HttpResponseMessage downloadResponse = null;
int redirectCount = 0;
while (redirectCount < 5)
{
var requestMsg = new HttpRequestMessage(HttpMethod.Get, currentUrl);
requestMsg.Version = HttpVersion.Version11;
requestMsg.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
requestMsg.Headers.Add("Referer", "https://assrt.net/");
downloadResponse = await cleanHttpClient.SendAsync(requestMsg, cancellationToken);
if (downloadResponse.StatusCode == HttpStatusCode.Redirect ||
downloadResponse.StatusCode == HttpStatusCode.MovedPermanently ||
downloadResponse.StatusCode == HttpStatusCode.Found ||
downloadResponse.StatusCode == HttpStatusCode.SeeOther ||
(int)downloadResponse.StatusCode == 307 ||
(int)downloadResponse.StatusCode == 308)
{
var location = downloadResponse.Headers.Location;
if (location != null)
{
if (!location.IsAbsoluteUri)
{
location = new Uri(new Uri(currentUrl), location);
}
currentUrl = location.AbsoluteUri;
redirectCount++;
_logger.Info($"{Name} GetSubtitles | Redirecting to -> {currentUrl}");
downloadResponse.Dispose();
continue;
}
}
break;
}
if (downloadResponse == null || !downloadResponse.IsSuccessStatusCode)
{
_logger.Error($"{Name} GetSubtitles | Failed to download subtitle file. Status: {downloadResponse?.StatusCode}");
return new SubtitleResponse { Language = downloadInfo.Language, Format = downloadInfo.Format, Stream = new MemoryStream() };
}
var responseStream = await downloadResponse.Content.ReadAsStreamAsync();
// 3. 下载原始文件;若为压缩包,则从 ZIP/RAR/7z/TAR 等归档中提取字幕。
var memoryStream = new MemoryStream();
await responseStream.CopyToAsync(memoryStream);
memoryStream.Position = 0;
var directFormat = ExtractFormat(downloadFilename);
if (directFormat == null)
{
_logger.Info($"{Name} GetSubtitles | Archive detected, extracting: {downloadFilename}");
try
{
using (var archive = ArchiveFactory.Open(memoryStream))
{
var entry = archive.Entries.FirstOrDefault(e =>
!e.IsDirectory && ExtractFormat(e.Key) != null);
if (entry != null)
{
var extractedStream = new MemoryStream();
using (var entryStream = entry.OpenEntryStream())
{
await entryStream.CopyToAsync(extractedStream);
}
extractedStream.Position = 0;
var extFormat = ExtractFormat(entry.Key) ?? downloadInfo.Format;
_logger.Info($"{Name} GetSubtitles | Extracted file: '{entry.Key}' with format: {extFormat}");
return new SubtitleResponse
{
Language = downloadInfo.Language,
IsForced = false,
Format = extFormat,
Stream = extractedStream
};
}
else
{
_logger.Warn($"{Name} GetSubtitles | Archive contains no supported srt/ass/ssa subtitle.");
return new SubtitleResponse { Language = downloadInfo.Language, Format = downloadInfo.Format, Stream = new MemoryStream() };
}
}
}
catch (Exception ex)
{
_logger.Error($"{Name} GetSubtitles | Failed to extract archive '{downloadFilename}': {ex.Message}");
return new SubtitleResponse { Language = downloadInfo.Language, Format = downloadInfo.Format, Stream = new MemoryStream() };
}
}
// 4. 非压缩的字幕文件直接返回。
memoryStream.Position = 0;
return new SubtitleResponse
{
Language = downloadInfo.Language,
IsForced = false,
Format = directFormat ?? downloadInfo.Format,
Stream = memoryStream
};
}
catch (Exception ex)
{
_logger.Error($"{Name} GetSubtitles | Exception: {ex.Message}");
}
return new SubtitleResponse { Language = downloadInfo?.Language ?? "chi", Format = downloadInfo?.Format ?? SRT, Stream = new MemoryStream() };
}
#region 辅助工具函数
public static string Base64Encode(string plainText)
{
var plainTextBytes = Encoding.UTF8.GetBytes(plainText);
return Convert.ToBase64String(plainTextBytes);
}
public static string Base64Decode(string base64EncodedData)
{
var base64EncodedBytes = Convert.FromBase64String(base64EncodedData);
return Encoding.UTF8.GetString(base64EncodedBytes);
}
protected string ExtractFormat(string text)
{
if (string.IsNullOrEmpty(text))
{
return null;
}
text = text.ToLower();
if (text.Contains(ASS)) return ASS;
if (text.Contains(SSA)) return SSA;
if (text.Contains(SRT)) return SRT;
return null;
}
private static string NormalizeLanguage(string language)
{
if (string.IsNullOrEmpty(language)) return language;
if (language.Equals("zh-CN", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zh-TW", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zh-HK", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zh", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zho", StringComparison.OrdinalIgnoreCase) ||
language.Equals("chi", StringComparison.OrdinalIgnoreCase))
{
return "chi";
}
if (language.Equals("en", StringComparison.OrdinalIgnoreCase) ||
language.Equals("eng", StringComparison.OrdinalIgnoreCase))
{
return "eng";
}
return language;
}
private static string MaskToken(string token)
{
if (string.IsNullOrEmpty(token)) return string.Empty;
if (token.Length <= 8) return "***";
return token.Substring(0, 4) + "***" + token.Substring(token.Length - 4);
}
#endregion
}
}

View File

@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyVersion>1.0.17.1</AssemblyVersion>
<FileVersion>1.0.17.1</FileVersion>
<Version>1.0.17.1</Version>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<!-- 已验证 Emby 4.8.10.0、4.9.5.0 和 4.10.0.40 均提供 SharpCompress 0.33.0。
保持与宿主程序集版本一致,避免插件加载或解压失败。 -->
<NoWarn>$(NoWarn);NU1902</NoWarn>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<OutputPath>..\Release</OutputPath>
</PropertyGroup>
<ItemGroup>
<None Remove="Thumb.png" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Thumb.png" />
</ItemGroup>
<ItemGroup>
<!-- 使用旧版宿主可解析的 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>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
</Target>
</Project>

View File

@@ -0,0 +1,57 @@
using System.Collections.Generic;
namespace Emby.MeiamSub.Assrt.Model
{
public class AssrtSearchResponse
{
public int status { get; set; }
public AssrtSearchSubInfo sub { get; set; }
}
public class AssrtSearchSubInfo
{
public string keyword { get; set; }
public string result { get; set; }
public List<AssrtSearchItem> subs { get; set; }
}
public class AssrtSearchItem
{
public object id { get; set; }
public string native_name { get; set; }
public string videoname { get; set; }
public string subtype { get; set; }
public AssrtLanguageInfo lang { get; set; }
}
public class AssrtLanguageInfo
{
public string desc { get; set; }
public int lang { get; set; }
}
public class AssrtDetailResponse
{
public int status { get; set; }
public AssrtDetailSubInfo sub { get; set; }
}
public class AssrtDetailSubInfo
{
public List<AssrtDetailItem> subs { get; set; }
}
public class AssrtDetailItem
{
public object id { get; set; }
public string filename { get; set; }
public string url { get; set; }
}
public class AssrtDownloadInfo
{
public string Id { get; set; }
public string Format { get; set; }
public string Language { get; set; }
}
}

View File

@@ -0,0 +1,92 @@
using Emby.Web.GenericEdit;
using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Model.Drawing;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using System;
using System.ComponentModel;
using System.IO;
namespace Emby.MeiamSub.Assrt
{
/// <summary>
/// Assrt 插件入口
/// </summary>
public class Plugin : BasePluginSimpleUI<PluginConfiguration>, IHasThumbImage
{
public Plugin(IApplicationPaths applicationPaths, IApplicationHost applicationHost) : base(applicationHost)
{
Instance = this;
}
/// <summary>
/// 插件ID
/// </summary>
public override Guid Id => new Guid("D276A8D0-4B17-4EEF-94B6-61D646DE5A22");
/// <summary>
/// 插件名称
/// </summary>
public override string Name => "MeiamSub.Assrt";
/// <summary>
/// 插件描述
/// </summary>
public override string Description => "Download subtitles from Assrt (伪射手网)";
/// <summary>
/// 缩略图格式化类型
/// </summary>
public ImageFormat ThumbImageFormat => ImageFormat.Gif;
/// <summary>
/// 获取插件选项
/// </summary>
public PluginConfiguration Options => this.GetOptions();
public static Plugin Instance { get; private set; }
/// <summary>
/// 获取插件缩略图资源流
/// </summary>
/// <returns>图片资源流,若不存在则返回 null</returns>
public Stream GetThumbImage()
{
var type = GetType();
var resourceName = $"{type.Namespace}.Thumb.png";
var stream = type.Assembly.GetManifestResourceStream(resourceName);
if (stream == null)
{
return null;
}
return stream;
}
}
/// <summary>
/// 插件配置类
/// </summary>
public class PluginConfiguration : EditableOptionsBase
{
public override string EditorTitle => "MeiamSub Assrt Options";
[DisplayName("Assrt API Token")]
[Description("请输入您在 assrt.net 注册获取的 32 位 API Token")]
public string AssrtApiToken { get; set; }
[DisplayName("使用元数据搜索字幕")]
[Description("勾选此项后,使用元数据中的剧集名称和季集编号搜索字幕,而非文件名。建议开启。")]
public bool EnableUseMetadata { get; set; }
public PluginConfiguration()
{
AssrtApiToken = string.Empty;
EnableUseMetadata = true;
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

View File

@@ -1,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>

View File

@@ -1,95 +1,219 @@
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using System.Web;
namespace Emby.Subtitle.DevTool
{
class Program
{
static void Main(string[] args)
private static readonly HttpClient _httpClient = new HttpClient();
static async Task Main(string[] args)
{
//Console.WriteLine(ComputeFileHash($"X:\\Favorites\\Movie\\八佰 (2020)\\八佰 (2020) 1080p TrueHD.mkv"));
Console.WriteLine(ComputeFileHash($"D:\\Documents\\Downloads\\testidx.avi"));
// 设置控制台编码为 UTF8 防止中文乱码
Console.OutputEncoding = Encoding.UTF8;
Console.WriteLine("================ MeiamSubtitles 调试工具 ================");
// 待测试的影音文件路径
var testFilePath = @"D:\Source\MeiamSubtitles\TestServer\Movie\2009\三傻大闹宝莱坞\三傻大闹宝莱坞 (2009) - 1080p.mkv";
if (!File.Exists(testFilePath))
{
Console.WriteLine($"[错误] 文件不存在: {testFilePath}");
return;
}
Console.WriteLine($"[文件] {testFilePath}");
Console.WriteLine("-------------------------------------------------------");
// 1. 射手网 (Shooter)
Console.WriteLine("\n[1/2] 正在请求:射手网 (Shooter)...");
var shooterHash = ComputeShooterHash(testFilePath);
Console.WriteLine($" > HASH: {shooterHash}");
await TestShooterApi(testFilePath, shooterHash);
// 2. 迅雷影音 (Thunder)
Console.WriteLine("\n[2/2] 正在请求:迅雷影音 (Thunder)...");
var thunderCid = await GetThunderCidAsync(testFilePath);
Console.WriteLine($" > CID: {thunderCid}");
await TestThunderApi(testFilePath, thunderCid);
Console.WriteLine("\n-------------------------------------------------------");
Console.WriteLine("调试结束,按任意键退出...");
Console.ReadKey();
}
private static string GetCid(string filePath)
{
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
var reader = new BinaryReader(stream);
var fileSize = new FileInfo(filePath).Length;
var SHA1 = new SHA1CryptoServiceProvider();
var buffer = new byte[0xf000];
if (fileSize < 0xf000)
{
reader.Read(buffer, 0, (int)fileSize);
buffer = SHA1.ComputeHash(buffer, 0, (int)fileSize);
}
else
{
reader.Read(buffer, 0, 0x5000);
stream.Seek(fileSize / 3, SeekOrigin.Begin);
reader.Read(buffer, 0x5000, 0x5000);
stream.Seek(fileSize - 0x5000, SeekOrigin.Begin);
reader.Read(buffer, 0xa000, 0x5000);
#region API 测试方法
buffer = SHA1.ComputeHash(buffer, 0, 0xf000);
}
var result = "";
foreach (var i in buffer)
private static async Task TestShooterApi(string filePath, string hash)
{
try
{
result += string.Format("{0:X2}", i);
var url = "https://www.shooter.cn/api/subapi.php";
var formData = new Dictionary<string, string>
{
{ "filehash", hash},
{ "pathinfo", Path.GetFileName(filePath)},
{ "format", "json"},
{ "lang", "chn"}
};
var content = new FormUrlEncodedContent(formData);
var response = await _httpClient.PostAsync(url, content);
var result = await response.Content.ReadAsStringAsync();
Console.WriteLine($" > STATUS: {response.StatusCode}");
if (!result.Trim().StartsWith("["))
{
Console.WriteLine($" > [警告] API 返回了非法内容 (可能已失效或乱码): {result}");
}
else
{
Console.WriteLine($" > RETURN: {result}");
}
}
catch (Exception ex)
{
Console.WriteLine($" > ERROR: {ex.Message}");
}
return result;
}
public static string ComputeFileHash(string filePath)
private static async Task TestThunderApi(string filePath, string cid)
{
try
{
// 迅雷搜索接口通常基于文件名,CID 用于后续匹配校验
var fileName = Path.GetFileName(filePath);
var url = $"https://api-shoulei-ssl.xunlei.com/oracle/subtitle?name={HttpUtility.UrlEncode(fileName)}";
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("User-Agent", "MeiamSub.Thunder");
var response = await _httpClient.SendAsync(request);
var result = await response.Content.ReadAsStringAsync();
Console.WriteLine($" > STATUS: {response.StatusCode}");
Console.WriteLine($" > RETURN: {result}");
}
catch (Exception ex)
{
Console.WriteLine($" > ERROR: {ex.Message}");
}
}
#endregion
#region HASH 算法实现
/// <summary>
/// 射手网 HASH 算法
/// </summary>
public static string ComputeShooterHash(string filePath)
{
FileInfo fileInfo = new FileInfo(filePath);
if (!fileInfo.Exists || fileInfo.Length < 8 * 1024) return "";
string ret = "";
if (!fileInfo.Exists || fileInfo.Length < 8 * 1024)
using (FileStream fs = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read))
{
long[] offset = new long[4];
offset[3] = fileInfo.Length - 8 * 1024;
offset[2] = fileInfo.Length / 3;
offset[1] = fileInfo.Length / 3 * 2;
offset[0] = 4 * 1024;
string ret = "";
byte[] bBuf = new byte[4096];
using (MD5 md5 = MD5.Create())
{
for (int i = 0; i < 4; ++i)
{
fs.Seek(offset[i], SeekOrigin.Begin);
fs.Read(bBuf, 0, 4096);
byte[] data = md5.ComputeHash(bBuf);
StringBuilder sBuilder = new StringBuilder();
for (int j = 0; j < data.Length; j++) sBuilder.Append(data[j].ToString("x2"));
if (!string.IsNullOrEmpty(ret)) ret += ";";
ret += sBuilder.ToString();
}
}
return ret;
}
FileStream fs = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read);
long[] offset = new long[4];
offset[3] = fileInfo.Length - 8 * 1024;
offset[2] = fileInfo.Length / 3;
offset[1] = fileInfo.Length / 3 * 2;
offset[0] = 4 * 1024;
byte[] bBuf = new byte[1024 * 4];
for (int i = 0; i < 4; ++i)
{
fs.Seek(offset[i], 0);
fs.Read(bBuf, 0, 4 * 1024);
MD5 md5Hash = MD5.Create();
byte[] data = md5Hash.ComputeHash(bBuf);
StringBuilder sBuilder = new StringBuilder();
for (int j = 0; j < data.Length; j++)
{
sBuilder.Append(data[j].ToString("x2"));
}
if (!string.IsNullOrEmpty(ret))
{
ret += ";";
}
ret += sBuilder.ToString();
}
fs.Close();
return ret;
}
/// <summary>
/// 迅雷 CID 算法 (基于 SHA1)
/// </summary>
public static async Task<string> GetThunderCidAsync(string filePath)
{
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous))
{
var fileSize = new FileInfo(filePath).Length;
using (var sha1 = SHA1.Create())
{
var buffer = new byte[0xf000];
if (fileSize < 0xf000)
{
await stream.ReadAsync(buffer, 0, (int)fileSize);
buffer = sha1.ComputeHash(buffer, 0, (int)fileSize);
}
else
{
await stream.ReadAsync(buffer, 0, 0x5000);
stream.Seek(fileSize / 3, SeekOrigin.Begin);
await stream.ReadAsync(buffer, 0x5000, 0x5000);
stream.Seek(fileSize - 0x5000, SeekOrigin.Begin);
await stream.ReadAsync(buffer, 0xa000, 0x5000);
buffer = sha1.ComputeHash(buffer, 0, 0xf000);
}
var result = "";
foreach (var i in buffer) result += string.Format("{0:X2}", i);
return result;
}
}
}
/// <summary>
/// QQ 播放器 VUID 算法
/// </summary>
public static async Task<string> ComputeQQVuidAsync(string filePath)
{
FileInfo fileInfo = new FileInfo(filePath);
if (!fileInfo.Exists || fileInfo.Length < 8 * 1024) return "";
using (var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous))
{
long[] offsets = new long[3];
offsets[0] = 0;
offsets[1] = fileInfo.Length / 3;
offsets[2] = fileInfo.Length - 8 * 1024;
StringBuilder combinedMd5 = new StringBuilder();
byte[] buffer = new byte[4096];
using (var md5 = MD5.Create())
{
foreach (var offset in offsets)
{
fs.Seek(offset, SeekOrigin.Begin);
await fs.ReadAsync(buffer, 0, 4096);
byte[] hashBytes = md5.ComputeHash(buffer);
foreach (byte b in hashBytes) combinedMd5.Append(b.ToString("x2"));
}
byte[] finalHash = md5.ComputeHash(Encoding.ASCII.GetBytes(combinedMd5.ToString()));
StringBuilder result = new StringBuilder();
foreach (byte b in finalHash) result.Append(b.ToString("x2"));
return result.ToString();
}
}
}
#endregion
}
}

View File

@@ -1,26 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyVersion>1.0.4.0</AssemblyVersion>
<FileVersion>1.0.4.0</FileVersion>
<Version>1.0.4</Version>
<AssemblyVersion>1.0.16.0</AssemblyVersion>
<FileVersion>1.0.16.0</FileVersion>
<Version>1.0.16.0</Version>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<WarningLevel>2</WarningLevel>
<TargetFramework>netstandard2.1</TargetFramework>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<OutputPath>..\Release</OutputPath>
</PropertyGroup>
<ItemGroup>
<None Remove="Thumb.png" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Thumb.png" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MediaBrowser.Common" Version="4.4.2" />
<PackageReference Include="MediaBrowser.Server.Core" Version="4.4.2" />
<PackageReference Include="MediaBrowser.Common" Version="4.9.1.90" />
<PackageReference Include="MediaBrowser.Server.Core" Version="4.9.1.90" />
</ItemGroup>
</Project>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
</Target>
</Project>

View File

@@ -9,7 +9,6 @@ namespace Emby.MeiamSub.Shooter.Model
public string Url { get; set; }
public string Format { get; set; }
public string Language { get; set; }
public string TwoLetterISOLanguageName { get; set; }
public bool? IsForced { get; set; }
}
}

View File

@@ -1,4 +1,5 @@
using MediaBrowser.Common.Plugins;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Drawing;
using System;
using System.IO;
@@ -11,6 +12,12 @@ namespace Emby.MeiamSub.Shooter
/// </summary>
public class Plugin : BasePlugin, IHasThumbImage
{
public Plugin(IApplicationPaths applicationPaths)
{
Instance = this;
}
/// <summary>
/// 插件ID
/// </summary>
@@ -24,21 +31,38 @@ namespace Emby.MeiamSub.Shooter
/// <summary>
/// 插件描述
/// </summary>
public override string Description => "Download subtitles from Shooter";
public override string Description => "Deprecated: the legacy Shooter API rarely returns usable results. Use Assrt or Thunder instead.";
/// <summary>
/// 缩略图格式化类型
/// </summary>
public ImageFormat ThumbImageFormat => ImageFormat.Gif;
public static Plugin Instance { get; private set; }
/// <summary>
/// 缩略图资源文件
/// 获取插件缩略图资源流
/// <para>修改人: Meiam</para>
/// <para>修改时间: 2025-12-22</para>
/// <para>备注: 增加了资源加载的安全性检查,防止因资源名不匹配导致的空引用。</para>
/// </summary>
/// <returns></returns>
/// <returns>图片资源流,若不存在则返回 null</returns>
public Stream GetThumbImage()
{
var type = GetType();
return type.Assembly.GetManifestResourceStream(type.Namespace + ".Thumb.png");
var resourceName = $"{type.Namespace}.Thumb.png";
var stream = type.Assembly.GetManifestResourceStream(resourceName);
if (stream == null)
{
// 如果找不到资源,尝试不带命名空间的名称,或者记录错误
// 这里我们至少确保不会返回 null 导致外部空引用(虽然 Emby 可能处理 null)
// 但为了稳健,如果真的没有,返回 null 是正确的,Emby 会显示默认图标
return null;
}
return stream;
}
}
}

View File

@@ -1,4 +1,4 @@
using Emby.MeiamSub.Shooter.Model;
using Emby.MeiamSub.Shooter.Model;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Subtitles;
@@ -7,6 +7,7 @@ using MediaBrowser.Model.Providers;
using MediaBrowser.Model.Serialization;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
@@ -19,7 +20,10 @@ using System.Web;
namespace Emby.MeiamSub.Shooter
{
/// <summary>
/// 迅雷字幕组件
/// 射手网字幕提供程序
/// 负责与射手网 API 进行交互,通过文件哈希匹配并下载字幕。
/// <para>修改人: Meiam</para>
/// <para>修改时间: 2025-12-22</para>
/// </summary>
public class ShooterProvider : ISubtitleProvider, IHasOrder
{
@@ -28,41 +32,43 @@ namespace Emby.MeiamSub.Shooter
public const string SSA = "ssa";
public const string SRT = "srt";
private readonly ILogger _logger;
protected readonly ILogger _logger;
private readonly IJsonSerializer _jsonSerializer;
private readonly IHttpClient _httpClient;
public int Order => 0;
public int Order => 100;
public string Name => "MeiamSub.Shooter";
/// <summary>
/// 支持电影、剧集
/// </summary>
public IEnumerable<VideoContentType> SupportedMediaTypes => new List<VideoContentType>() { VideoContentType.Movie, VideoContentType.Episode };
public IEnumerable<VideoContentType> SupportedMediaTypes => new[] { VideoContentType.Movie, VideoContentType.Episode };
#endregion
#region 构造函数
public ShooterProvider(ILogger logger, IJsonSerializer jsonSerializer,IHttpClient httpClient)
public ShooterProvider(ILogManager logManager, IJsonSerializer jsonSerializer, IHttpClient httpClient)
{
_logger = logger;
_logger = logManager.GetLogger(GetType().Name);
_jsonSerializer = jsonSerializer;
_httpClient = httpClient;
_logger.Info("{0} Init", new object[1] { Name });
}
#endregion
#region 查询字幕
/// <summary>
/// 查询请求
/// 搜索字幕 (ISubtitleProvider 接口实现)
/// 根据媒体信息请求字幕列表。
/// </summary>
/// <param name="request"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <param name="request">包含媒体路径、语言等信息的搜索请求对象</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>远程字幕信息列表</returns>
public async Task<IEnumerable<RemoteSubtitleInfo>> Search(SubtitleSearchRequest request, CancellationToken cancellationToken)
{
_logger.Debug($"MeiamSub.Shooter Search | Request -> { _jsonSerializer.SerializeToString(request) }");
_logger.Info("{0} Search | SubtitleSearchRequest -> {1}", new object[2] { Name, _jsonSerializer.SerializeToString(request) });
var subtitles = await SearchSubtitlesAsync(request);
var subtitles = await SearchSubtitlesAsync(request, cancellationToken);
return subtitles;
}
@@ -72,79 +78,126 @@ namespace Emby.MeiamSub.Shooter
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request)
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request, CancellationToken cancellationToken)
{
if (request.Language != "chi" && request.Language != "eng")
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 增加异常处理,确保单个插件错误不影响系统整体运行
try
{
return Array.Empty<RemoteSubtitleInfo>();
}
var language = NormalizeLanguage(request.Language);
FileInfo fileInfo = new FileInfo(request.MediaPath);
_logger.Info("{0} Search | Target -> {1} | Language -> {2}", Name, Path.GetFileName(request.MediaPath), language);
var hash = ComputeFileHash(fileInfo);
HttpRequestOptions options = new HttpRequestOptions
{
Url = $"http://www.shooter.cn/api/subapi.php",
UserAgent = "Emby.MeiamSub.Shooter",
TimeoutMs = 30000,
AcceptHeader = "*/*",
};
options.SetPostData(new Dictionary<string, string>
{
{ "filehash", HttpUtility.UrlEncode(hash)},
{ "pathinfo", HttpUtility.UrlEncode(request.MediaPath)},
{ "format", "json"},
{ "lang",request.Language == "chi" ? "chn" : "eng"}
});
_logger.Debug($"MeiamSub.Shooter Search | Request -> { _jsonSerializer.SerializeToString(options) }");
var response = await _httpClient.Post(options);
_logger.Debug($"MeiamSub.Shooter Search | Response -> { _jsonSerializer.SerializeToString(response) }");
if (response.StatusCode == HttpStatusCode.OK && response.ContentType.Contains("application/json"))
{
var subtitleResponse = _jsonSerializer.DeserializeFromStream<List<SubtitleResponseRoot>>(response.Content);
if (subtitleResponse != null)
if (language != "chi" && language != "eng")
{
_logger.Debug($"MeiamSub.Shooter Search | Response -> { _jsonSerializer.SerializeToString(subtitleResponse) }");
_logger.Info("{0} Search | Summary -> Language not supported, skip search.", Name);
return Array.Empty<RemoteSubtitleInfo>();
}
var remoteSubtitleInfos = new List<RemoteSubtitleInfo>();
if (string.IsNullOrEmpty(request.MediaPath) ||
request.MediaPath.Contains("://") ||
request.MediaPath.StartsWith("smb://", StringComparison.OrdinalIgnoreCase) ||
request.MediaPath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
request.MediaPath.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
_logger.Warn("{0} Search | Network path detected, skip hash-based search: {1}", Name, request.MediaPath);
return Array.Empty<RemoteSubtitleInfo>();
}
foreach(var subFileInfo in subtitleResponse)
FileInfo fileInfo = new FileInfo(request.MediaPath);
var stopWatch = Stopwatch.StartNew();
var hash = await ComputeFileHashAsync(fileInfo, cancellationToken);
stopWatch.Stop();
_logger.Info("{0} Search | FileHash -> {1} (Took {2}ms)", new object[3] { Name, hash, stopWatch.ElapsedMilliseconds });
HttpRequestOptions options = new HttpRequestOptions
{
Url = $"https://www.shooter.cn/api/subapi.php",
UserAgent = $"{Name}",
TimeoutMs = 30000,
AcceptHeader = "*/*",
};
options.SetPostData(new Dictionary<string, string>
{
{ "filehash", HttpUtility.UrlEncode(hash)},
{ "pathinfo", HttpUtility.UrlEncode(request.MediaPath)},
{ "format", "json"},
{ "lang", language == "chi" ? "chn" : "eng"}
});
_logger.Info("{0} Search | Request -> {1}", new object[2] { Name, _jsonSerializer.SerializeToString(options) });
var response = await _httpClient.Post(options);
_logger.Info("{0} Search | Response -> {1} | Content-Type -> {2}",
new object[3] { Name, response.StatusCode, response.ContentType ?? "unknown" });
if (response.StatusCode == HttpStatusCode.OK && response.ContentType.Contains("application/json"))
{
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 增加对射手网 API 返回非法内容(如乱码)的校验
string responseBody;
using (var reader = new StreamReader(response.Content, Encoding.UTF8))
{
foreach(var subFile in subFileInfo.Files)
{
remoteSubtitleInfos.Add(new RemoteSubtitleInfo()
{
Id = Base64Encode(_jsonSerializer.SerializeToString(new DownloadSubInfo
{
Url = subFile.Link,
Format = subFile.Ext,
Language = request.Language,
TwoLetterISOLanguageName = request.TwoLetterISOLanguageName,
IsForced = request.IsForced
})),
Name = $"[MEIAMSUB] { Path.GetFileName(request.MediaPath) } | {request.TwoLetterISOLanguageName} | 射手",
Author = "Meiam ",
ProviderName = "MeiamSub.Shooter",
Format = subFile.Ext,
Comment = $"Format : { ExtractFormat(subFile.Ext)}"
});
}
responseBody = await reader.ReadToEndAsync();
}
_logger.Debug($"MeiamSub.Shooter Search | Summary -> Get { remoteSubtitleInfos.Count } Subtitles");
if (string.IsNullOrEmpty(responseBody) || !responseBody.Trim().StartsWith("["))
{
_logger.Info("{0} Search | Summary -> API returned invalid content (likely no subtitles found or API error).", Name);
return Array.Empty<RemoteSubtitleInfo>();
}
return remoteSubtitleInfos;
var subtitleResponse = _jsonSerializer.DeserializeFromString<List<SubtitleResponseRoot>>(responseBody);
if (subtitleResponse != null)
{
var remoteSubtitles = new List<RemoteSubtitleInfo>();
foreach (var subFileInfo in subtitleResponse)
{
foreach (var subFile in subFileInfo.Files.Where(m =>
!string.IsNullOrWhiteSpace(m.Link) && NormalizeFormat(m.Ext) != null))
{
remoteSubtitles.Add(new RemoteSubtitleInfo()
{
Id = Base64Encode(_jsonSerializer.SerializeToString(new DownloadSubInfo
{
Url = subFile.Link,
Format = NormalizeFormat(subFile.Ext),
Language = request.Language,
IsForced = request.IsForced
})),
Name = $"[MEIAMSUB] {Path.GetFileName(request.MediaPath)} | {request.Language} | 射手",
Language = request.Language,
Author = "Meiam ",
ProviderName = $"{Name}",
Format = NormalizeFormat(subFile.Ext),
Comment = $"Format : {ExtractFormat(subFile.Ext)}",
IsHashMatch = true
});
}
}
_logger.Info("{0} Search | Summary -> Get {1} Subtitles", new object[2] { Name, remoteSubtitles.Count });
return remoteSubtitles;
}
}
}
catch (Exception ex)
{
_logger.Error("{0} Search | Exception -> [{1}] {2}", Name, ex.GetType().Name, ex.Message);
}
_logger.Debug($"MeiamSub.Shooter Search | Summary -> Get 0 Subtitles");
_logger.Info("{0} Search | Summary -> Get 0 Subtitles", new object[1] { Name });
return Array.Empty<RemoteSubtitleInfo>();
}
@@ -152,19 +205,17 @@ namespace Emby.MeiamSub.Shooter
#region 下载字幕
/// <summary>
/// 下载请求
/// 获取字幕内容 (ISubtitleProvider 接口实现)
/// 根据字幕 ID 下载具体的字幕文件流。
/// </summary>
/// <param name="id"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <param name="id">字幕唯一标识符 (Base64 编码的 JSON 数据)</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>包含字幕流的响应对象</returns>
public async Task<SubtitleResponse> GetSubtitles(string id, CancellationToken cancellationToken)
{
await Task.Run(() =>
{
_logger.Debug($"MeiamSub.Shooter DownloadSub | Request -> {id}");
});
_logger.Info("{0} DownloadSub | Request -> {1}", new object[2] { Name, id });
return await DownloadSubAsync(id);
return await DownloadSubAsync(id, cancellationToken);
}
/// <summary>
@@ -172,38 +223,61 @@ namespace Emby.MeiamSub.Shooter
/// </summary>
/// <param name="info"></param>
/// <returns></returns>
private async Task<SubtitleResponse> DownloadSubAsync(string info)
private async Task<SubtitleResponse> DownloadSubAsync(string info, CancellationToken cancellationToken)
{
var downloadSub = _jsonSerializer.DeserializeFromString<DownloadSubInfo>(Base64Decode(info));
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 增加异常处理
downloadSub.Url = downloadSub.Url.Replace("https://www.shooter.cn", "http://www.shooter.cn");
_logger.Debug($"MeiamSub.Shooter DownloadSub | Url -> { downloadSub.Url } | Format -> { downloadSub.Format } | Language -> { downloadSub.Language } ");
var response = await _httpClient.GetResponse(new HttpRequestOptions
try
{
Url = downloadSub.Url,
UserAgent = "Emby.MeiamSub.Shooter",
TimeoutMs = 30000,
AcceptHeader = "*/*",
});
var downloadSub = _jsonSerializer.DeserializeFromString<DownloadSubInfo>(Base64Decode(info));
_logger.Debug($"MeiamSub.Shooter DownloadSub | Response -> { response.StatusCode }");
if (response.StatusCode == HttpStatusCode.OK)
{
return new SubtitleResponse()
if (downloadSub == null)
{
Language = downloadSub.Language,
IsForced = false,
Format = downloadSub.Format,
Stream = response.Content,
};
return new SubtitleResponse();
}
_logger.Info($"{0} DownloadSub | Url -> {1} | Format -> {2} | Language -> {3} ",
new object[4] { Name, downloadSub.Url, downloadSub.Format, downloadSub.Language });
var response = await _httpClient.GetResponse(new HttpRequestOptions
{
Url = downloadSub.Url,
UserAgent = $"{Name}",
TimeoutMs = 30000,
AcceptHeader = "*/*",
});
_logger.Info("{0} DownloadSub | Request -> {1}", new object[2] { Name, response.StatusCode });
if (response.StatusCode == HttpStatusCode.OK)
{
var format = NormalizeFormat(downloadSub.Format)
?? throw new InvalidDataException($"Unsupported subtitle format: {downloadSub.Format}");
var stream = new MemoryStream();
await response.Content.CopyToAsync(stream, 81920, cancellationToken);
var data = stream.ToArray();
stream.Dispose();
ValidateSubtitleData(data, format, response.ContentType);
return new SubtitleResponse()
{
Language = downloadSub.Language,
IsForced = downloadSub.IsForced ?? false,
Format = format,
Stream = new MemoryStream(data, writable: false),
};
}
}
catch (Exception ex)
{
_logger.Error("{0} DownloadSub | Error -> {1}", Name, ex.Message);
}
return new SubtitleResponse();
throw new InvalidDataException($"{Name} subtitle download failed.");
}
#endregion
@@ -238,27 +312,94 @@ namespace Emby.MeiamSub.Shooter
/// <returns></returns>
protected string ExtractFormat(string text)
{
string result = null;
if (text != null)
if (string.IsNullOrEmpty(text))
{
text = text.ToLower();
if (text.Contains(ASS)) result = ASS;
else if (text.Contains(SSA)) result = SSA;
else if (text.Contains(SRT)) result = SRT;
else result = null;
return null;
}
text = text.ToLower();
if (text.Contains(ASS)) return ASS;
if (text.Contains(SSA)) return SSA;
if (text.Contains(SRT)) return SRT;
return null;
}
private static string NormalizeFormat(string format)
{
var value = format?.Trim().TrimStart('.').ToLowerInvariant();
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 Shooter.");
}
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) && format == SRT ||
mediaType?.IndexOf("html", StringComparison.OrdinalIgnoreCase) >= 0 || mediaType?.IndexOf("json", StringComparison.OrdinalIgnoreCase) >= 0)
{
throw new InvalidDataException("Shooter 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.");
}
return result;
}
/// <summary>
/// 获取文件 Hash (射手)
/// 规范化语言代码
/// </summary>
/// <param name="filePath"></param>
/// <param name="language"></param>
/// <returns></returns>
public static string ComputeFileHash(FileInfo fileInfo)
private static string NormalizeLanguage(string language)
{
if (string.IsNullOrEmpty(language)) return language;
if (language.Equals("zh-CN", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zh-TW", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zh-HK", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zh", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zho", StringComparison.OrdinalIgnoreCase) ||
language.Equals("chi", StringComparison.OrdinalIgnoreCase))
{
return "chi";
}
if (language.Equals("en", StringComparison.OrdinalIgnoreCase) ||
language.Equals("eng", StringComparison.OrdinalIgnoreCase))
{
return "eng";
}
return language;
}
/// <summary>
/// 异步计算文件 Hash (射手网专用算法)
/// <para>修改人: Meiam</para>
/// <para>修改时间: 2025-12-22</para>
/// <para>备注: 采用异步 I/O 读取文件特定位置的 4KB 数据块进行 MD5 计算。</para>
/// </summary>
/// <param name="fileInfo">文件信息对象</param>
/// <returns>计算得到的文件 Hash 字符串,如果文件过小或不存在则返回空字符串</returns>
public static async Task<string> ComputeFileHashAsync(FileInfo fileInfo, CancellationToken cancellationToken)
{
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 改造为异步方法,优化 I/O 性能并增加 using 语句释放资源
string ret = "";
if (!fileInfo.Exists || fileInfo.Length < 8 * 1024)
@@ -266,42 +407,58 @@ namespace Emby.MeiamSub.Shooter
return ret;
}
FileStream fs = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read);
long[] offset = new long[4];
offset[3] = fileInfo.Length - 8 * 1024;
offset[2] = fileInfo.Length / 3;
offset[1] = fileInfo.Length / 3 * 2;
offset[0] = 4 * 1024;
byte[] bBuf = new byte[1024 * 4];
for (int i = 0; i < 4; ++i)
using (FileStream fs = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous))
{
fs.Seek(offset[i], 0);
fs.Read(bBuf, 0, 4 * 1024);
long[] offset = new long[4];
offset[3] = fileInfo.Length - 8 * 1024;
offset[2] = fileInfo.Length / 3;
offset[1] = fileInfo.Length / 3 * 2;
offset[0] = 4 * 1024;
MD5 md5Hash = MD5.Create();
byte[] data = md5Hash.ComputeHash(bBuf);
StringBuilder sBuilder = new StringBuilder();
byte[] bBuf = new byte[1024 * 4];
for (int j = 0; j < data.Length; j++)
for (int i = 0; i < 4; ++i)
{
sBuilder.Append(data[j].ToString("x2"));
}
fs.Seek(offset[i], SeekOrigin.Begin);
await ReadFullAsync(fs, bBuf, 0, 4 * 1024, cancellationToken);
if (!string.IsNullOrEmpty(ret))
{
ret += ";";
}
using (MD5 md5Hash = MD5.Create())
{
byte[] data = md5Hash.ComputeHash(bBuf);
StringBuilder sBuilder = new StringBuilder();
ret += sBuilder.ToString();
for (int j = 0; j < data.Length; j++)
{
sBuilder.Append(data[j].ToString("x2"));
}
if (!string.IsNullOrEmpty(ret))
{
ret += ";";
}
ret += sBuilder.ToString();
}
}
}
fs.Close();
return ret;
}
private static async Task ReadFullAsync(Stream stream, byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
while (count > 0)
{
var read = await stream.ReadAsync(buffer, offset, count, cancellationToken);
if (read == 0)
{
throw new EndOfStreamException();
}
offset += read;
count -= read;
}
}
#endregion
}
}

View File

@@ -1,15 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyVersion>1.0.4.0</AssemblyVersion>
<FileVersion>1.0.4.0</FileVersion>
<Version>1.0.4</Version>
<AssemblyVersion>1.0.17.1</AssemblyVersion>
<FileVersion>1.0.17.1</FileVersion>
<Version>1.0.17.1</Version>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<TargetFramework>netstandard2.1</TargetFramework>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<OutputPath>..\Release</OutputPath>
</PropertyGroup>
@@ -22,9 +22,26 @@
<EmbeddedResource Include="Thumb.png" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MediaBrowser.Common" Version="4.4.2" />
<PackageReference Include="MediaBrowser.Server.Core" Version="4.4.2" />
<!-- 使用旧版宿主可解析的 SDK,避免插件要求 4.9.1.90 程序集。 -->
<PackageReference Include="MediaBrowser.Common" Version="4.8.10" />
<PackageReference Include="MediaBrowser.Server.Core" Version="4.8.10" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<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>
</Project>

View File

@@ -9,7 +9,6 @@ namespace Emby.MeiamSub.Thunder.Model
public string Url { get; set; }
public string Format { get; set; }
public string Language { get; set; }
public string TwoLetterISOLanguageName { get; set; }
public bool? IsForced { get; set; }
}
}

View File

@@ -2,43 +2,30 @@
namespace Emby.MeiamSub.Thunder.Model
{
public class SublistItem
{
/// <summary>
///
/// </summary>
public string scid { get; set; }
/// <summary>
///
/// </summary>
public string sname { get; set; }
/// <summary>
/// 未知语言
/// </summary>
public string language { get; set; }
/// <summary>
///
/// </summary>
public string rate { get; set; }
/// <summary>
///
/// </summary>
public string surl { get; set; }
/// <summary>
///
/// </summary>
public int svote { get; set; }
/// <summary>
///
/// </summary>
public int roffset { get; set; }
}
public class SubtitleResponseRoot
{
/// <summary>
///
/// </summary>
public List<SublistItem> sublist { get; set; }
public int Code { get; set; }
public List<SublistItem> Data { get; set; }
public string Result { get; set; }
}
public class SublistItem
{
public string Gcid { get; set; }
public string Cid { get; set; }
public string Url { get; set; }
public string Ext { get; set; }
public string Name { get; set; }
public long Duration { get; set; }
public string[] Languages { get; set; }
public string Langs => Languages != null ? string.Join(",", Languages) : string.Empty;
public int Source { get; set; }
public int Score { get; set; }
public int FingerprintfScore { get; set; }
public string ExtraName { get; set; }
}
}

View File

@@ -1,6 +1,13 @@
using MediaBrowser.Common.Plugins;
using Emby.Web.GenericEdit;
using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Model.Drawing;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using System;
using System.ComponentModel;
using System.IO;
namespace Emby.MeiamSub.Thunder
@@ -9,8 +16,14 @@ namespace Emby.MeiamSub.Thunder
/// <summary>
/// 插件入口
/// </summary>
public class Plugin : BasePlugin, IHasThumbImage
public class Plugin : BasePluginSimpleUI<PluginConfiguration>, IHasThumbImage
{
public Plugin(IApplicationPaths applicationPaths, IApplicationHost applicationHost) : base(applicationHost)
{
Instance = this;
}
/// <summary>
/// 插件ID
/// </summary>
@@ -32,13 +45,48 @@ namespace Emby.MeiamSub.Thunder
public ImageFormat ThumbImageFormat => ImageFormat.Gif;
/// <summary>
/// 缩略图资源文件
/// 获取插件选项
/// </summary>
/// <returns></returns>
public PluginConfiguration Options => this.GetOptions();
public static Plugin Instance { get; private set; }
/// <summary>
/// 获取插件缩略图资源流
/// <para>修改人: Meiam</para>
/// <para>修改时间: 2025-12-22</para>
/// <para>备注: 增加了资源加载的安全性检查,防止因资源名不匹配导致的空引用。</para>
/// </summary>
/// <returns>图片资源流,若不存在则返回 null</returns>
public Stream GetThumbImage()
{
var type = GetType();
return type.Assembly.GetManifestResourceStream(type.Namespace + ".Thumb.png");
var resourceName = $"{type.Namespace}.Thumb.png";
var stream = type.Assembly.GetManifestResourceStream(resourceName);
if (stream == null)
{
return null;
}
return stream;
}
}
/// <summary>
/// 插件配置类
/// </summary>
public class PluginConfiguration : EditableOptionsBase
{
public override string EditorTitle => "MeiamSub Thunder Options";
[Description("勾选此项后,使用元数据中的剧集名称和季集编号搜索字幕")]
public bool EnableUseMetadata { get; set; }
public PluginConfiguration()
{
// 默认值
EnableUseMetadata = false;
}
}
}

View File

@@ -1,25 +1,33 @@
using Emby.MeiamSub.Thunder.Model;
using Emby.MeiamSub.Thunder.Model;
using MeiamSubtitles.Shared;
using MediaBrowser.Common;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Base;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Model.Logging;
using MediaBrowser.Model.Providers;
using MediaBrowser.Model.Serialization;
using MediaBrowser.Model.System;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
namespace Emby.MeiamSub.Thunder
{
/// <summary>
/// 迅雷字幕组件
/// 迅雷看看字幕提供程序
/// 负责与迅雷 API 进行交互,通过 CID (Content ID) 匹配并下载字幕。
/// <para>修改人: Meiam</para>
/// <para>修改时间: 2025-12-22</para>
/// </summary>
public class ThunderProvider : ISubtitleProvider, IHasOrder
{
@@ -28,41 +36,56 @@ namespace Emby.MeiamSub.Thunder
public const string SSA = "ssa";
public const string SRT = "srt";
private readonly ILogger _logger;
protected readonly ILogger _logger;
private readonly IJsonSerializer _jsonSerializer;
private readonly IHttpClient _httpClient;
private readonly IServiceRoot _serviceRoot;
private Plugin MainPlugin { get; set; }
public int Order => 100;
public int Order => 0;
public string Name => "MeiamSub.Thunder";
/// <summary>
/// 支持电影、剧集
/// </summary>
public IEnumerable<VideoContentType> SupportedMediaTypes => new List<VideoContentType>() { VideoContentType.Movie, VideoContentType.Episode };
public IEnumerable<VideoContentType> SupportedMediaTypes => new[] { VideoContentType.Movie, VideoContentType.Episode };
#endregion
#region 构造函数
public ThunderProvider(ILogger logger, IJsonSerializer jsonSerializer,IHttpClient httpClient)
public ThunderProvider(ILogManager logManager, IJsonSerializer jsonSerializer, IHttpClient httpClient, IApplicationHost applicationHost)
{
_logger = logger;
_logger = logManager.GetLogger(GetType().Name);
_jsonSerializer = jsonSerializer;
_httpClient = httpClient;
_serviceRoot = new ServiceRoot(applicationHost);
MainPlugin = _serviceRoot.GetService<IApplicationHost>().Plugins.OfType<Plugin>().FirstOrDefault();
_logger.Info("{0} Init", new object[1] { Name });
}
#endregion
#region 查询字幕
/// <summary>
/// 查询请求
/// 搜索字幕 (ISubtitleProvider 接口实现)
/// 根据媒体信息请求字幕列表。
/// </summary>
/// <param name="request"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <param name="request">包含媒体路径、语言等信息的搜索请求对象</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>远程字幕信息列表</returns>
public async Task<IEnumerable<RemoteSubtitleInfo>> Search(SubtitleSearchRequest request, CancellationToken cancellationToken)
{
_logger.Debug($"MeiamSub.Thunder Search | Request -> { _jsonSerializer.SerializeToString(request) }");
if (request == null)
{
_logger.Info("{0} Search | Request is null, skip search.", Name);
return Array.Empty<RemoteSubtitleInfo>();
}
var subtitles = await SearchSubtitlesAsync(request);
_logger.Info("{0} Search | ContentType -> {1} | Language -> {2}", Name, request.ContentType, request.Language);
var subtitles = await SearchSubtitlesAsync(request, cancellationToken);
return subtitles;
}
@@ -72,61 +95,136 @@ namespace Emby.MeiamSub.Thunder
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request)
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request, CancellationToken cancellationToken)
{
if (request.Language != "chi")
// 修改人:Mayfly777w
// 修改时间:2026-01-01
// 备注:如果勾选采用元数据中的剧集名来作为字幕搜索匹配,则使用,否则默认用文件名
string MovieName;
if (MainPlugin.Options.EnableUseMetadata)
{
return Array.Empty<RemoteSubtitleInfo>();
if (request.ContentType == VideoContentType.Episode)
{
MovieName = $"{request.SeriesName} S{request.ParentIndexNumber}E{request.IndexNumber}";
}
else if (request.ContentType == VideoContentType.Movie)
{
MovieName = request.Name;
}
else
{
MovieName = Path.GetFileName(request.MediaPath);
}
}
else
{
MovieName = Path.GetFileName(request.MediaPath);
}
var cid = GetCidByFile(request.MediaPath);
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 增加异常处理
var response = await _httpClient.GetResponse(new HttpRequestOptions
try
{
Url = $"http://sub.xmp.sandai.net:8000/subxl/{cid}.json",
UserAgent = "Emby.MeiamSub.Thunder",
TimeoutMs = 30000,
AcceptHeader = "*/*",
});
var language = NormalizeLanguage(request.Language);
_logger.Debug($"MeiamSub.Thunder Search | Response -> { _jsonSerializer.SerializeToString(response) }");
_logger.Info("{0} Search | Target -> {1} | Language -> {2}", Name, MovieName, language);
if (response.StatusCode == HttpStatusCode.OK)
{
var subtitleResponse = _jsonSerializer.DeserializeFromStream<SubtitleResponseRoot>(response.Content);
if (subtitleResponse != null)
if (language != "chi")
{
_logger.Debug($"MeiamSub.Thunder Search | Response -> { _jsonSerializer.SerializeToString(subtitleResponse) }");
_logger.Info("{0} Search | Summary -> Language not supported, skip search.", Name);
return Array.Empty<RemoteSubtitleInfo>();
}
var subtitles = subtitleResponse.sublist.Where(m => !string.IsNullOrEmpty(m.sname));
var cid = string.Empty;
var stopWatch = Stopwatch.StartNew();
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();
}
if (subtitles.Count() > 0)
HttpRequestOptions options = new HttpRequestOptions
{
Url = $"https://api-shoulei-ssl.xunlei.com/oracle/subtitle?name={Uri.EscapeDataString(MovieName)}",
UserAgent = $"{Name}",
TimeoutMs = 30000,
AcceptHeader = "*/*",
};
var response = await _httpClient.GetResponse(options);
_logger.Info("{0} Search | Response -> {1}", new object[2] { Name, response.StatusCode });
if (response.StatusCode == HttpStatusCode.OK)
{
var subtitleResponse = _jsonSerializer.DeserializeFromStream<SubtitleResponseRoot>(response.Content);
if (subtitleResponse.Code == 0)
{
_logger.Debug($"MeiamSub.Thunder Search | Summary -> Get { subtitles.Count() } Subtitles");
var subtitles = (subtitleResponse.Data ?? new List<SublistItem>())
.Where(m => !string.IsNullOrWhiteSpace(m.Name) &&
!string.IsNullOrWhiteSpace(m.Url) &&
NormalizeFormat(m.Ext) != null)
.GroupBy(m => m.Url, StringComparer.OrdinalIgnoreCase)
.Select(m => m.First())
.OrderByDescending(m => !string.IsNullOrEmpty(cid) && string.Equals(cid, m.Cid, StringComparison.OrdinalIgnoreCase))
.ThenByDescending(m => m.Langs?.IndexOf("简体", StringComparison.OrdinalIgnoreCase) >= 0)
.Take(20)
.ToList();
return subtitles.Select(m => new RemoteSubtitleInfo()
var remoteSubtitles = new List<RemoteSubtitleInfo>();
if (subtitles.Count() > 0)
{
Id = Base64Encode(_jsonSerializer.SerializeToString(new DownloadSubInfo
foreach (var item in subtitles)
{
Url = m.surl,
Format = ExtractFormat(m.sname),
Language = request.Language,
TwoLetterISOLanguageName = request.TwoLetterISOLanguageName,
IsForced = request.IsForced
})),
Name = $"[MEIAMSUB] { Path.GetFileName(request.MediaPath) } | {request.TwoLetterISOLanguageName} | 迅雷",
Author = "Meiam ",
CommunityRating = Convert.ToSingle(m.rate),
ProviderName = "MeiamSub.Thunder",
Format = ExtractFormat(m.sname),
Comment = $"Format : { ExtractFormat(m.sname)} - Rate : { m.rate }"
}).OrderByDescending(m => m.CommunityRating);
remoteSubtitles.Add(new RemoteSubtitleInfo()
{
Id = Base64Encode(_jsonSerializer.SerializeToString(new DownloadSubInfo
{
Url = item.Url,
Format = NormalizeFormat(item.Ext),
Language = request.Language,
IsForced = request.IsForced
})),
Name = $"[MEIAMSUB] {item.Name} | {(item.Langs == string.Empty ? "未知" : item.Langs)} | 迅雷",
Language = request.Language,
Author = "Meiam ",
ProviderName = $"{Name}",
Format = NormalizeFormat(item.Ext),
Comment = $"Format : {NormalizeFormat(item.Ext)}",
IsHashMatch = !string.IsNullOrEmpty(cid) && string.Equals(cid, item.Cid, StringComparison.OrdinalIgnoreCase),
});
}
}
_logger.Info("{0} Search | Summary -> Get {1} Subtitles", new object[2] { Name, remoteSubtitles.Count });
return remoteSubtitles;
}
}
}
catch (Exception ex)
{
_logger.Error("{0} Search | Exception -> [{1}] {2}", Name, ex.GetType().Name, ex.Message);
}
_logger.Info("{0} Search | Summary -> Get 0 Subtitles", new object[1] { Name });
_logger.Debug($"MeiamSub.Thunder Search | Summary -> Get 0 Subtitles");
return Array.Empty<RemoteSubtitleInfo>();
}
@@ -134,19 +232,17 @@ namespace Emby.MeiamSub.Thunder
#region 下载字幕
/// <summary>
/// 下载请求
/// 获取字幕内容 (ISubtitleProvider 接口实现)
/// 根据字幕 ID 下载具体的字幕文件流。
/// </summary>
/// <param name="id"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <param name="id">字幕唯一标识符 (Base64 编码的 JSON 数据)</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>包含字幕流的响应对象</returns>
public async Task<SubtitleResponse> GetSubtitles(string id, CancellationToken cancellationToken)
{
await Task.Run(() =>
{
_logger.Debug($"MeiamSub.Thunder DownloadSub | Request -> {id}");
});
_logger.Info("{0} DownloadSub | Request -> {1}", new object[2] { Name, id });
return await DownloadSubAsync(id);
return await DownloadSubAsync(id, cancellationToken);
}
/// <summary>
@@ -154,35 +250,59 @@ namespace Emby.MeiamSub.Thunder
/// </summary>
/// <param name="info"></param>
/// <returns></returns>
private async Task<SubtitleResponse> DownloadSubAsync(string info)
private async Task<SubtitleResponse> DownloadSubAsync(string info, CancellationToken cancellationToken)
{
var downloadSub = _jsonSerializer.DeserializeFromString<DownloadSubInfo>(Base64Decode(info));
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 增加异常处理
_logger.Debug($"MeiamSub.Thunder DownloadSub | Url -> { downloadSub.Url } | Format -> { downloadSub.Format } | Language -> { downloadSub.Language } ");
var response = await _httpClient.GetResponse(new HttpRequestOptions
try
{
Url = downloadSub.Url,
UserAgent = "Emby.MeiamSub.Thunder",
TimeoutMs = 30000,
AcceptHeader = "*/*",
});
var downloadSub = _jsonSerializer.DeserializeFromString<DownloadSubInfo>(Base64Decode(info));
_logger.Debug($"MeiamSub.Thunder DownloadSub | Response -> { response.StatusCode }");
if (response.StatusCode == HttpStatusCode.OK)
{
return new SubtitleResponse()
if (downloadSub == null)
{
Language = downloadSub.Language,
IsForced = false,
Format = downloadSub.Format,
Stream = response.Content,
};
return new SubtitleResponse();
}
_logger.Info("{0} DownloadSub | Url -> {1} | Format -> {2} | Language -> {3} ",
new object[4] { Name, downloadSub.Url, downloadSub.Format, downloadSub.Language });
var response = await _httpClient.GetResponse(new HttpRequestOptions
{
Url = downloadSub.Url,
UserAgent = $"{Name}",
TimeoutMs = 30000,
AcceptHeader = "*/*",
});
_logger.Info("{0} DownloadSub | Response -> {1}", new object[2] { Name, response.StatusCode });
if (response.StatusCode == HttpStatusCode.OK)
{
var format = NormalizeFormat(downloadSub.Format)
?? throw new InvalidDataException($"Unsupported subtitle format: {downloadSub.Format}");
var stream = new MemoryStream();
await response.Content.CopyToAsync(stream, 81920, cancellationToken);
var data = stream.ToArray();
stream.Dispose();
var utf8Data = SubtitleContentValidator.ValidateAndConvertToUtf8(data, format, response.ContentType);
return new SubtitleResponse()
{
Language = downloadSub.Language,
IsForced = downloadSub.IsForced ?? false,
Format = format,
Stream = new MemoryStream(utf8Data, writable: false),
};
}
}
catch (Exception ex)
{
_logger.Error("{0} DownloadSub | Error -> {1}", Name, ex.Message);
}
return new SubtitleResponse();
throw new InvalidDataException($"{Name} subtitle download failed.");
}
#endregion
@@ -217,53 +337,121 @@ namespace Emby.MeiamSub.Thunder
/// <returns></returns>
protected string ExtractFormat(string text)
{
string result = null;
if (text != null)
if (string.IsNullOrEmpty(text))
{
text = text.ToLower();
if (text.Contains(ASS)) result = ASS;
else if (text.Contains(SSA)) result = SSA;
else if (text.Contains(SRT)) result = SRT;
else result = null;
return null;
}
return result;
text = text.ToLower();
if (text.Contains(ASS)) return ASS;
if (text.Contains(SSA)) return SSA;
if (text.Contains(SRT)) return SRT;
return null;
}
private static string NormalizeFormat(string format)
{
var value = format?.Trim().TrimStart('.').ToLowerInvariant();
return value == SRT || value == ASS || value == SSA ? value : null;
}
/// <summary>
/// 获取文件 CID (迅雷)
/// 规范化语言代码
/// </summary>
/// <param name="filePath"></param>
/// <param name="language"></param>
/// <returns></returns>
private string GetCidByFile(string filePath)
private static string NormalizeLanguage(string language)
{
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
var reader = new BinaryReader(stream);
var fileSize = new FileInfo(filePath).Length;
var SHA1 = new SHA1CryptoServiceProvider();
var buffer = new byte[0xf000];
if (fileSize < 0xf000)
{
reader.Read(buffer, 0, (int)fileSize);
buffer = SHA1.ComputeHash(buffer, 0, (int)fileSize);
}
else
{
reader.Read(buffer, 0, 0x5000);
stream.Seek(fileSize / 3, SeekOrigin.Begin);
reader.Read(buffer, 0x5000, 0x5000);
stream.Seek(fileSize - 0x5000, SeekOrigin.Begin);
reader.Read(buffer, 0xa000, 0x5000);
if (string.IsNullOrEmpty(language)) return language;
buffer = SHA1.ComputeHash(buffer, 0, 0xf000);
}
var result = "";
foreach (var i in buffer)
if (language.Equals("zh-CN", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zh-TW", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zh-HK", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zh", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zho", StringComparison.OrdinalIgnoreCase) ||
language.Equals("chi", StringComparison.OrdinalIgnoreCase))
{
result += string.Format("{0:X2}", i);
return "chi";
}
if (language.Equals("en", StringComparison.OrdinalIgnoreCase) ||
language.Equals("eng", StringComparison.OrdinalIgnoreCase))
{
return "eng";
}
return language;
}
/// <summary>
/// 异步计算文件 CID (迅雷专用算法)
/// <para>修改人: Meiam</para>
/// <para>修改时间: 2025-12-22</para>
/// <para>备注: 采用异步 I/O 读取文件特定位置的数据块进行 SHA1 计算。</para>
/// </summary>
/// <param name="filePath">文件路径</param>
/// <returns>计算得到的 CID 字符串</returns>
private async Task<string> GetCidByFileAsync(string filePath, CancellationToken cancellationToken)
{
// 修改人: Meiam
// 修改时间: 2026-06-11
// 备注: 增加网络路径拦截,避免 FileStream 打开非本地路径抛出异常
if (string.IsNullOrEmpty(filePath) ||
filePath.Contains("://") ||
filePath.StartsWith("smb://", StringComparison.OrdinalIgnoreCase) ||
filePath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
filePath.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
_logger.Warn("Network path detected, skip hash calculation: {0}", filePath);
return string.Empty;
}
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous))
{
// 使用 BinaryReader 配合 BaseStream 需要小心,因为 BinaryReader 本身不支持异步 Read
// 这里我们直接操作 stream 进行异步读取,不再使用 BinaryReader,因为只是读取字节数组
var fileSize = new FileInfo(filePath).Length;
using (var sha1 = SHA1.Create())
{
var buffer = new byte[0xf000];
if (fileSize < 0xf000)
{
await ReadFullAsync(stream, buffer, 0, (int)fileSize, cancellationToken);
buffer = sha1.ComputeHash(buffer, 0, (int)fileSize);
}
else
{
await ReadFullAsync(stream, buffer, 0, 0x5000, cancellationToken);
stream.Seek(fileSize / 3, SeekOrigin.Begin);
await ReadFullAsync(stream, buffer, 0x5000, 0x5000, cancellationToken);
stream.Seek(fileSize - 0x5000, SeekOrigin.Begin);
await ReadFullAsync(stream, buffer, 0xa000, 0x5000, cancellationToken);
buffer = sha1.ComputeHash(buffer, 0, 0xf000);
}
var result = "";
foreach (var i in buffer)
{
result += string.Format("{0:X2}", i);
}
return result;
}
}
}
private static async Task ReadFullAsync(Stream stream, byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
while (count > 0)
{
var read = await stream.ReadAsync(buffer, offset, count, cancellationToken);
if (read == 0)
{
throw new EndOfStreamException();
}
offset += read;
count -= read;
}
return result;
}
#endregion
}

View File

@@ -1,37 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.31424.327
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Emby.MeiamSub.Thunder", "Emby.MeiamSub.Thunder\Emby.MeiamSub.Thunder.csproj", "{96F3F427-0EC3-4610-81C3-2C92D773EDC8}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Emby.MeiamSub.DevTool", "Emby.MeiamSub.DevTool\Emby.MeiamSub.DevTool.csproj", "{6B0C23EA-EC24-4FB0-948E-094E84AEBF21}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Emby.MeiamSub.Shooter", "Emby.MeiamSub.Shooter\Emby.MeiamSub.Shooter.csproj", "{0F502AEB-0FF4-44FA-8391-13AD61FC5490}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{96F3F427-0EC3-4610-81C3-2C92D773EDC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{96F3F427-0EC3-4610-81C3-2C92D773EDC8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{96F3F427-0EC3-4610-81C3-2C92D773EDC8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{96F3F427-0EC3-4610-81C3-2C92D773EDC8}.Release|Any CPU.Build.0 = Release|Any CPU
{6B0C23EA-EC24-4FB0-948E-094E84AEBF21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6B0C23EA-EC24-4FB0-948E-094E84AEBF21}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6B0C23EA-EC24-4FB0-948E-094E84AEBF21}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6B0C23EA-EC24-4FB0-948E-094E84AEBF21}.Release|Any CPU.Build.0 = Release|Any CPU
{0F502AEB-0FF4-44FA-8391-13AD61FC5490}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0F502AEB-0FF4-44FA-8391-13AD61FC5490}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0F502AEB-0FF4-44FA-8391-13AD61FC5490}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0F502AEB-0FF4-44FA-8391-13AD61FC5490}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {AC5A2964-C7C8-419B-A8DD-63424233E6AA}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,404 @@
using Jellyfin.MeiamSub.Assrt.Model;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using SharpCompress.Archives;
namespace Jellyfin.MeiamSub.Assrt
{
/// <summary>
/// Assrt (伪射手) 字幕提供程序
/// </summary>
public class AssrtProvider : ISubtitleProvider, IHasOrder
{
private const string ApiBaseUrl = "https://api.assrt.net/v1";
public const string ASS = "ass";
public const string SSA = "ssa";
public const string SRT = "srt";
private readonly ILogger<AssrtProvider> _logger;
private readonly IHttpClientFactory _httpClientFactory;
public int Order => 90; // 优先级高于旧版
public string Name => "MeiamSub.Assrt";
public IEnumerable<VideoContentType> SupportedMediaTypes => new[] { VideoContentType.Movie, VideoContentType.Episode };
public AssrtProvider(ILogger<AssrtProvider> logger, IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_logger.LogInformation($"{Name} Init");
}
public async Task<IEnumerable<RemoteSubtitleInfo>> Search(SubtitleSearchRequest request, CancellationToken cancellationToken)
{
_logger.LogInformation($"{Name} Search | Received request for: {(request?.MediaPath ?? "NULL")}");
try
{
if (request == null)
{
return Array.Empty<RemoteSubtitleInfo>();
}
var config = Plugin.Instance?.Configuration;
if (config == null || string.IsNullOrWhiteSpace(config.AssrtApiToken))
{
_logger.LogWarning($"{Name} Search | AssrtApiToken is empty, skip search. Please configure the token in plugin settings.");
return Array.Empty<RemoteSubtitleInfo>();
}
var language = NormalizeLanguage(request.Language);
if (language != "chi")
{
_logger.LogInformation($"{Name} Search | Language '{request.Language}' not supported, skip search.");
return Array.Empty<RemoteSubtitleInfo>();
}
string queryText;
if (config.EnableUseMetadata)
{
if (request.ContentType == VideoContentType.Episode)
{
queryText = $"{request.SeriesName} S{request.ParentIndexNumber:D2}E{request.IndexNumber:D2}";
}
else if (request.ContentType == VideoContentType.Movie)
{
queryText = request.Name;
}
else
{
queryText = !string.IsNullOrEmpty(request.MediaPath)
? Path.GetFileNameWithoutExtension(request.MediaPath)
: string.Empty;
}
}
else
{
queryText = !string.IsNullOrEmpty(request.MediaPath)
? Path.GetFileNameWithoutExtension(request.MediaPath)
: string.Empty;
}
if (string.IsNullOrWhiteSpace(queryText))
{
_logger.LogWarning($"{Name} Search | Query text is empty, skip search.");
return Array.Empty<RemoteSubtitleInfo>();
}
_logger.LogInformation($"{Name} Search | Target -> '{queryText}' | Language -> '{language}'");
using var httpClient = _httpClientFactory.CreateClient(Name);
// 拼接搜索请求,is_file=1 用于对文件名检索进行关键词降噪
var searchUrl = $"{ApiBaseUrl}/sub/search?token={Uri.EscapeDataString(config.AssrtApiToken)}&q={Uri.EscapeDataString(queryText)}&is_file=1&cnt=15";
// 隐藏敏感 Token 信息以便记录日志
var maskedSearchUrl = $"{ApiBaseUrl}/sub/search?token={MaskToken(config.AssrtApiToken)}&q={Uri.EscapeDataString(queryText)}&is_file=1&cnt=15";
_logger.LogInformation($"{Name} Search | Request Url -> {maskedSearchUrl}");
var response = await httpClient.GetAsync(searchUrl, cancellationToken);
var jsonString = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogInformation("{Provider} Search | Response -> {StatusCode}", Name, response.StatusCode);
if (!response.IsSuccessStatusCode)
{
_logger.LogError($"{Name} Search | API responded with status: {response.StatusCode}");
return Array.Empty<RemoteSubtitleInfo>();
}
var searchResponse = JsonSerializer.Deserialize<AssrtSearchResponse>(jsonString);
if (searchResponse == null || searchResponse.Status != 0 || searchResponse.Sub?.List == null || !searchResponse.Sub.List.Any())
{
_logger.LogWarning($"{Name} Search | No subtitles found or API error. Request -> Target: '{queryText}', Language: '{language}', Url: '{maskedSearchUrl}' | Response -> Status: {searchResponse?.Status}, Content: '{jsonString}'");
return Array.Empty<RemoteSubtitleInfo>();
}
var remoteSubtitles = new List<RemoteSubtitleInfo>();
foreach (var item in searchResponse.Sub.List)
{
if (item.Id == null) continue;
var subtitleId = item.Id.ToString();
var format = ExtractFormat(item.Subtype) ?? ExtractFormat(item.Name) ?? SRT;
var langDesc = item.Lang?.Desc ?? "中文";
var downloadInfo = new AssrtDownloadInfo
{
Id = subtitleId,
Format = format,
Language = request.Language,
TwoLetterISOLanguageName = request.TwoLetterISOLanguageName
};
var encodedId = Base64Encode(JsonSerializer.Serialize(downloadInfo));
remoteSubtitles.Add(new RemoteSubtitleInfo
{
Id = encodedId,
Name = $"[MEIAMSUB] {item.Name ?? item.VideoName ?? subtitleId} | {langDesc} | 伪射手",
Author = "Assrt",
ProviderName = Name,
Format = format,
Comment = string.IsNullOrEmpty(item.VideoName) ? $"格式: {format}" : $"适配视频: {item.VideoName} (格式: {format})",
IsHashMatch = false // Assrt.net 接口主要是文本搜索,返回 false 以提示用户是名字检索匹配
});
}
_logger.LogInformation($"{Name} Search | Summary -> Found {remoteSubtitles.Count} subtitles.");
return remoteSubtitles;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{Name} Search | Exception occurred: {ex.Message}");
}
return Array.Empty<RemoteSubtitleInfo>();
}
public async Task<SubtitleResponse> GetSubtitles(string id, CancellationToken cancellationToken)
{
_logger.LogInformation($"{Name} GetSubtitles | Requesting ID: {id}");
AssrtDownloadInfo downloadInfo = null;
try
{
var decodedString = Base64Decode(id);
downloadInfo = JsonSerializer.Deserialize<AssrtDownloadInfo>(decodedString);
if (downloadInfo == null || string.IsNullOrEmpty(downloadInfo.Id))
{
throw new ArgumentException($"{Name} GetSubtitles | 无法解析字幕下载信息,ID 为空。");
}
var config = Plugin.Instance?.Configuration;
if (config == null || string.IsNullOrWhiteSpace(config.AssrtApiToken))
{
_logger.LogError($"{Name} GetSubtitles | AssrtApiToken is not configured.");
throw new InvalidOperationException($"{Name} GetSubtitles | AssrtApiToken 未配置,请在插件设置中填写 Token。");
}
using var httpClient = _httpClientFactory.CreateClient(Name);
// 1. 请求详情接口获取真实的下载地址
var detailUrl = $"{ApiBaseUrl}/sub/detail?token={Uri.EscapeDataString(config.AssrtApiToken)}&id={Uri.EscapeDataString(downloadInfo.Id)}";
var detailResponseMsg = await httpClient.GetAsync(detailUrl, cancellationToken);
if (!detailResponseMsg.IsSuccessStatusCode)
{
_logger.LogError($"{Name} GetSubtitles | Failed to fetch details. Status: {detailResponseMsg.StatusCode}");
throw new HttpRequestException($"{Name} GetSubtitles | 获取字幕详情失败,HTTP 状态码: {detailResponseMsg.StatusCode}");
}
var detailJson = await detailResponseMsg.Content.ReadAsStringAsync(cancellationToken);
var detailResponse = JsonSerializer.Deserialize<AssrtDetailResponse>(detailJson);
if (detailResponse == null || detailResponse.Status != 0)
{
_logger.LogError($"{Name} GetSubtitles | Detail API error. Status: {detailResponse?.Status}, Response: {detailJson}");
throw new InvalidOperationException($"{Name} GetSubtitles | Assrt API returned error status: {detailResponse?.Status}");
}
var targetSub = detailResponse?.Sub?.Subs?.FirstOrDefault();
if (targetSub == null || string.IsNullOrEmpty(targetSub.Url))
{
_logger.LogError($"{Name} GetSubtitles | Subtitle download URL not found in API response.");
throw new InvalidOperationException($"{Name} GetSubtitles | API 响应中未找到字幕下载地址。");
}
var downloadUrl = targetSub.Url;
var downloadFilename = targetSub.Filename;
_logger.LogInformation($"{Name} GetSubtitles | Downloading subtitle from: {downloadUrl}");
// 2. 下载字幕流 (手动接管重定向以确保每次重定向请求均带有正确的防盗链 Headers)
var handler = new HttpClientHandler { AllowAutoRedirect = false };
using var cleanHttpClient = new HttpClient(handler);
var currentUrl = downloadUrl;
HttpResponseMessage downloadResponse = null;
int redirectCount = 0;
while (redirectCount < 5)
{
var requestMsg = new HttpRequestMessage(HttpMethod.Get, currentUrl);
requestMsg.Version = HttpVersion.Version11;
requestMsg.Headers.ConnectionClose = true; // 强制关闭长连接,防范服务器 Keep-Alive 超时关闭
requestMsg.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
requestMsg.Headers.Add("Referer", "https://assrt.net/");
downloadResponse = await cleanHttpClient.SendAsync(requestMsg, cancellationToken);
if (downloadResponse.StatusCode == HttpStatusCode.Redirect ||
downloadResponse.StatusCode == HttpStatusCode.MovedPermanently ||
downloadResponse.StatusCode == HttpStatusCode.Found ||
downloadResponse.StatusCode == HttpStatusCode.SeeOther ||
(int)downloadResponse.StatusCode == 307 ||
(int)downloadResponse.StatusCode == 308)
{
var location = downloadResponse.Headers.Location;
if (location != null)
{
if (!location.IsAbsoluteUri)
{
location = new Uri(new Uri(currentUrl), location);
}
currentUrl = location.AbsoluteUri;
redirectCount++;
_logger.LogInformation($"{Name} GetSubtitles | Redirecting to -> {currentUrl}");
downloadResponse.Dispose();
continue;
}
}
break;
}
if (downloadResponse == null || !downloadResponse.IsSuccessStatusCode)
{
_logger.LogError($"{Name} GetSubtitles | Failed to download subtitle file. Status: {downloadResponse?.StatusCode}");
throw new HttpRequestException($"{Name} GetSubtitles | 字幕文件下载失败,HTTP 状态码: {downloadResponse?.StatusCode}");
}
var responseStream = await downloadResponse.Content.ReadAsStreamAsync(cancellationToken);
// 3. 下载原始文件;若为压缩包,则从 ZIP/RAR/7z/TAR 等归档中提取字幕。
var memoryStream = new MemoryStream();
await responseStream.CopyToAsync(memoryStream, cancellationToken);
memoryStream.Position = 0;
var directFormat = ExtractFormat(downloadFilename);
if (directFormat == null)
{
_logger.LogInformation($"{Name} GetSubtitles | Archive detected, extracting: {downloadFilename}");
try
{
using var archive = ArchiveFactory.OpenArchive(memoryStream);
var entry = archive.Entries.FirstOrDefault(e =>
!e.IsDirectory && ExtractFormat(e.Key) != null);
if (entry != null)
{
var extractedStream = new MemoryStream();
using (var entryStream = entry.OpenEntryStream())
{
await entryStream.CopyToAsync(extractedStream, cancellationToken);
}
extractedStream.Position = 0;
var extFormat = ExtractFormat(entry.Key) ?? downloadInfo.Format;
_logger.LogInformation($"{Name} GetSubtitles | Extracted file: '{entry.Key}' with format: {extFormat}");
return new SubtitleResponse
{
Language = downloadInfo.Language,
IsForced = false,
Format = extFormat,
Stream = extractedStream
};
}
else
{
throw new InvalidOperationException($"{Name} GetSubtitles | Archive contains no supported srt/ass/ssa subtitle.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, $"{Name} GetSubtitles | Failed to extract archive '{downloadFilename}': {ex.Message}");
throw;
}
}
// 4. 非压缩的字幕文件直接返回。
memoryStream.Position = 0;
return new SubtitleResponse
{
Language = downloadInfo.Language,
IsForced = false,
Format = directFormat ?? downloadInfo.Format,
Stream = memoryStream
};
}
catch (Exception ex)
{
_logger.LogError(ex, $"{Name} GetSubtitles | Exception: {ex.Message}");
throw;
}
}
#region 辅助工具函数
public static string Base64Encode(string plainText)
{
var plainTextBytes = Encoding.UTF8.GetBytes(plainText);
return Convert.ToBase64String(plainTextBytes);
}
public static string Base64Decode(string base64EncodedData)
{
var base64EncodedBytes = Convert.FromBase64String(base64EncodedData);
return Encoding.UTF8.GetString(base64EncodedBytes);
}
protected string ExtractFormat(string text)
{
if (string.IsNullOrEmpty(text))
{
return null;
}
text = text.ToLower();
if (text.Contains(ASS)) return ASS;
if (text.Contains(SSA)) return SSA;
if (text.Contains(SRT)) return SRT;
return null;
}
private static string NormalizeLanguage(string language)
{
if (string.IsNullOrEmpty(language)) return language;
if (language.Equals("zh-CN", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zh-TW", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zh-HK", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zh", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zho", StringComparison.OrdinalIgnoreCase) ||
language.Equals("chi", StringComparison.OrdinalIgnoreCase))
{
return "chi";
}
if (language.Equals("en", StringComparison.OrdinalIgnoreCase) ||
language.Equals("eng", StringComparison.OrdinalIgnoreCase))
{
return "eng";
}
return language;
}
private static string MaskToken(string token)
{
if (string.IsNullOrEmpty(token)) return string.Empty;
if (token.Length <= 8) return "***";
return token.Substring(0, 4) + "***" + token.Substring(token.Length - 4);
}
#endregion
}
}

View File

@@ -0,0 +1,26 @@
using MediaBrowser.Model.Plugins;
namespace Jellyfin.MeiamSub.Assrt.Configuration
{
/// <summary>
/// 插件配置类
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// Assrt API 鉴权 Token
/// </summary>
public string AssrtApiToken { get; set; }
/// <summary>
/// 是否使用元数据中的剧集名称和季集编号搜索字幕
/// </summary>
public bool EnableUseMetadata { get; set; }
public PluginConfiguration()
{
AssrtApiToken = string.Empty;
EnableUseMetadata = true; // 默认为 true,因为 Assrt 是基于文本搜索,元数据匹配非常合适
}
}
}

View File

@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<title>MeiamSub.Assrt</title>
</head>
<body>
<div id="MeiamSubAssrtConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
data-require="emby-checkbox,emby-input">
<div data-role="content">
<div class="content-primary">
<form id="MeiamSubAssrtConfigForm">
<div class="verticalSection">
<h2 class="sectionTitle">伪射手字幕搜索设置 (Assrt)</h2>
<div class="inputContainer">
<label class="inputLabel" for="AssrtApiToken">Assrt API Token</label>
<input id="AssrtApiToken" name="AssrtApiToken" type="text" is="emby-input" placeholder="请输入 Assrt.net API Token" />
<div class="fieldDescription">
请在 <a href="https://assrt.net" target="_blank">assrt.net</a> 注册并获取你的 32 位 API Token。
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableUseMetadata" name="EnableUseMetadata" type="checkbox"
is="emby-checkbox" />
<span>使用元数据搜索字幕</span>
</label>
<div class="fieldDescription">
勾选此项后,使用元数据中的剧集名称和季集编号搜索字幕,而非文件名。
<br />
剧集将使用 "剧集名 S01E01" 格式;电影将使用影片名称。建议开启以提升模糊检索成功率。
</div>
</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>保存</span>
</button>
</div>
</form>
</div>
</div>
<script type="text/javascript">
var MeiamSubAssrtConfig = {
pluginUniqueId: 'E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0'
};
document.querySelector('#MeiamSubAssrtConfigPage')
.addEventListener('pageshow', function () {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(MeiamSubAssrtConfig.pluginUniqueId).then(function (config) {
document.querySelector('#AssrtApiToken').value = config.AssrtApiToken || '';
document.querySelector('#EnableUseMetadata').checked = config.EnableUseMetadata;
Dashboard.hideLoadingMsg();
});
});
document.querySelector('#MeiamSubAssrtConfigForm')
.addEventListener('submit', function (e) {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(MeiamSubAssrtConfig.pluginUniqueId).then(function (config) {
config.AssrtApiToken = document.querySelector('#AssrtApiToken').value;
config.EnableUseMetadata = document.querySelector('#EnableUseMetadata').checked;
ApiClient.updatePluginConfiguration(MeiamSubAssrtConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
e.preventDefault();
return false;
});
</script>
</div>
</body>
</html>

View File

@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Version>1.0.17.1</Version>
<AssemblyVersion>1.0.17.1</AssemblyVersion>
<FileVersion>1.0.17.1</FileVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<OutputPath>..\Release</OutputPath>
</PropertyGroup>
<ItemGroup>
<!-- 使用已验证的最低兼容基线,保持同一 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>
<ItemGroup>
<None Update="thumb.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Configuration\configPage.html" />
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
<Copy SourceFiles="$(PkgSharpCompress)\lib\net8.0\SharpCompress.dll" DestinationFolder="$(SolutionDir)$(ConfigurationName)\dependencies\Jellyfin\" SkipUnchangedFiles="true" />
</Target>
</Project>

View File

@@ -0,0 +1,98 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.MeiamSub.Assrt.Model
{
/// <summary>
/// 字幕搜索响应根结构
/// </summary>
public class AssrtSearchResponse
{
[JsonPropertyName("status")]
public int Status { get; set; }
[JsonPropertyName("sub")]
public AssrtSearchSubInfo Sub { get; set; }
}
public class AssrtSearchSubInfo
{
[JsonPropertyName("keyword")]
public string Keyword { get; set; }
[JsonPropertyName("result")]
public string Result { get; set; }
[JsonPropertyName("subs")]
public List<AssrtSearchItem> List { get; set; }
}
public class AssrtSearchItem
{
[JsonPropertyName("id")]
public object Id { get; set; } // 可以是数字也可以是字符串,用 object 接收再 ToString()
[JsonPropertyName("native_name")]
public string Name { get; set; }
[JsonPropertyName("videoname")]
public string VideoName { get; set; }
[JsonPropertyName("subtype")]
public string Subtype { get; set; }
[JsonPropertyName("lang")]
public AssrtLanguageInfo Lang { get; set; }
}
public class AssrtLanguageInfo
{
[JsonPropertyName("desc")]
public string Desc { get; set; }
[JsonPropertyName("lang")]
public int Lang { get; set; }
}
/// <summary>
/// 字幕详情响应根结构
/// </summary>
public class AssrtDetailResponse
{
[JsonPropertyName("status")]
public int Status { get; set; }
[JsonPropertyName("sub")]
public AssrtDetailSubInfo Sub { get; set; }
}
public class AssrtDetailSubInfo
{
[JsonPropertyName("subs")]
public List<AssrtDetailItem> Subs { get; set; }
}
public class AssrtDetailItem
{
[JsonPropertyName("id")]
public object Id { get; set; }
[JsonPropertyName("filename")]
public string Filename { get; set; }
[JsonPropertyName("url")]
public string Url { get; set; }
}
/// <summary>
/// 插件内部传递的下载参数
/// </summary>
public class AssrtDownloadInfo
{
public string Id { get; set; }
public string Format { get; set; }
public string Language { get; set; }
public string TwoLetterISOLanguageName { get; set; }
}
}

View File

@@ -0,0 +1,55 @@
using Jellyfin.MeiamSub.Assrt.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using System;
using System.Collections.Generic;
namespace Jellyfin.MeiamSub.Assrt
{
/// <summary>
/// Assrt 插件入口
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
/// <summary>
/// 插件ID
/// </summary>
public override Guid Id => new Guid("E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0");
/// <summary>
/// 插件名称
/// </summary>
public override string Name => "MeiamSub.Assrt";
/// <summary>
/// 插件描述
/// </summary>
public override string Description => "Download subtitles from Assrt (伪射手网)";
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
public static Plugin Instance { get; private set; }
/// <summary>
/// 获取插件配置页面列表
/// </summary>
/// <returns>配置页面信息</returns>
public IEnumerable<PluginPageInfo> GetPages()
{
return new[]
{
new PluginPageInfo
{
Name = Name,
EmbeddedResourcePath = GetType().Namespace + ".Configuration.configPage.html"
}
};
}
}
}

View File

@@ -0,0 +1,26 @@
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Controller;
using Microsoft.Extensions.DependencyInjection;
using System;
namespace Jellyfin.MeiamSub.Assrt
{
/// <summary>
/// 插件服务注册器
/// </summary>
public class PluginServiceRegistrator : IPluginServiceRegistrator
{
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
serviceCollection.AddHttpClient("MeiamSub.Assrt", client =>
{
client.Timeout = TimeSpan.FromSeconds(20); // 适当的超时时间
client.DefaultRequestHeaders.Add("User-Agent", "MeiamSub.Assrt/1.0.14.0 (Jellyfin)");
client.DefaultRequestHeaders.Add("Accept", "*/*");
});
serviceCollection.AddSingleton<ISubtitleProvider, AssrtProvider>();
}
}
}

View File

@@ -0,0 +1,12 @@
{
"category": "Subtitles",
"description": "Download subtitles from Assrt (伪射手网)",
"guid": "E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0",
"name": "MeiamSub.Assrt",
"owner": "Meiam",
"targetAbi": "10.10.7.0",
"timestamp": "2026-09-17T03:17:33.0000000Z",
"version": "1.0.17.1",
"status": "Active",
"imagePath": "thumb.png"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

View File

@@ -0,0 +1,8 @@
using MediaBrowser.Model.Plugins;
namespace Jellyfin.MeiamSub.Shooter.Configuration
{
public class PluginConfiguration : BasePluginConfiguration
{
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Version>1.0.16.0</Version>
<AssemblyVersion>1.0.16.0</AssemblyVersion>
<FileVersion>1.0.16.0</FileVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<OutputPath>..\Release</OutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.11.11" />
</ItemGroup>
<ItemGroup>
<None Update="thumb.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
</Target>
</Project>

View File

@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace Jellyfin.MeiamSub.Shooter.Model
{
public class DownloadSubInfo
{
public string Url { get; set; }
public string Format { get; set; }
public string Language { get; set; }
public string TwoLetterISOLanguageName { get; set; }
public bool? IsForced { get; set; }
}
}

View File

@@ -0,0 +1,19 @@
using System.Collections.Generic;
using System.Runtime.Serialization;
namespace Jellyfin.MeiamSub.Shooter.Model
{
public class SubtitleResponseRoot
{
public string Desc { get; set; }
public int Delay { get; set; }
public List<SubFileInfo> Files { get; set; }
}
public class SubFileInfo
{
public string Ext { get; set; }
public string Link { get; set; }
}
}

View File

@@ -0,0 +1,41 @@
using Jellyfin.MeiamSub.Shooter.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Serialization;
using System;
namespace Jellyfin.MeiamSub.Shooter
{
/// <summary>
/// 插件入口
/// <para>修改人: Meiam</para>
/// <para>修改时间: 2025-12-22</para>
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>
{
/// <summary>
/// 插件ID
/// </summary>
public override Guid Id => new Guid("038D37A2-7A1E-4C01-9B6D-AA215D29AB4C");
/// <summary>
/// 插件名称
/// </summary>
public override string Name => "MeiamSub.Shooter";
/// <summary>
/// 插件描述
/// </summary>
public override string Description => "Deprecated: the legacy Shooter API rarely returns usable results. Use Assrt or Thunder instead.";
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
public static Plugin Instance { get; private set; }
}
}

View File

@@ -0,0 +1,38 @@
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Controller;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Jellyfin.MeiamSub.Shooter
{
/// <summary>
/// 插件服务注册器
/// 负责注册插件所需的依赖服务,如 HTTP 客户端和字幕提供程序。
/// <para>修改人: Meiam</para>
/// <para>修改时间: 2025-12-22</para>
/// </summary>
public class PluginServiceRegistrator : IPluginServiceRegistrator
{
/// <summary>
/// 注册服务
/// </summary>
/// <param name="serviceCollection">服务集合</param>
/// <param name="applicationHos">应用程序宿主</param>
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHos)
{
serviceCollection.AddHttpClient("MeiamSub.Shooter", client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Add("User-Agent", "MeiamSub.Shooter");
client.DefaultRequestHeaders.Add("Accept", "*/*");
});
serviceCollection.AddSingleton<ISubtitleProvider, ShooterProvider>();
}
}
}

View File

@@ -0,0 +1,491 @@
using Jellyfin.MeiamSub.Shooter.Model;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using static System.Net.WebRequestMethods;
namespace Jellyfin.MeiamSub.Shooter
{
/// <summary>
/// 射手网字幕提供程序
/// 负责与射手网 API 进行交互,通过文件哈希匹配并下载字幕。
/// <para>修改人: Meiam</para>
/// <para>修改时间: 2025-12-22</para>
/// </summary>
public class ShooterProvider : ISubtitleProvider, IHasOrder
{
#region 变量声明
public const string ASS = "ass";
public const string SSA = "ssa";
public const string SRT = "srt";
private readonly ILogger<ShooterProvider> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private const string ApiUrl = "https://www.shooter.cn/api/subapi.php";
public int Order => 100;
public string Name => "MeiamSub.Shooter";
/// <summary>
/// 支持电影、剧集
/// </summary>
public IEnumerable<VideoContentType> SupportedMediaTypes => new[] { VideoContentType.Movie, VideoContentType.Episode };
#endregion
#region 构造函数
public ShooterProvider(ILogger<ShooterProvider> logger, IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_logger.LogInformation($"{Name} Init");
}
#endregion
#region 查询字幕
/// <summary>
/// 搜索字幕 (ISubtitleProvider 接口实现)
/// 根据媒体信息请求字幕列表。
/// </summary>
/// <param name="request">包含媒体路径、语言等信息的搜索请求对象</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>远程字幕信息列表</returns>
public async Task<IEnumerable<RemoteSubtitleInfo>> Search(SubtitleSearchRequest request, CancellationToken cancellationToken)
{
_logger.LogInformation("DEBUG: Received Search request for " + (request?.MediaPath ?? "NULL"));
var subtitles = await SearchSubtitlesAsync(request, cancellationToken);
return subtitles;
}
/// <summary>
/// 查询字幕
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request, CancellationToken cancellationToken)
{
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 增加极致探测日志,定位方法中断的具体位置
_logger.LogInformation("DEBUG: Entering SearchSubtitlesAsync");
try
{
if (request == null)
{
_logger.LogInformation("DEBUG: Request is null");
return Array.Empty<RemoteSubtitleInfo>();
}
var language = NormalizeLanguage(request.Language);
var fileName = string.Empty;
if (!string.IsNullOrEmpty(request.MediaPath))
{
fileName = Path.GetFileName(request.MediaPath);
}
_logger.LogInformation(Name + " Search | Target -> " + fileName + " | Language -> " + language);
if (language != "chi" && language != "eng")
{
_logger.LogInformation(Name + " Search | Summary -> Language not supported, skip search.");
return Array.Empty<RemoteSubtitleInfo>();
}
if (string.IsNullOrEmpty(request.MediaPath))
{
_logger.LogInformation(Name + " Search | Summary -> MediaPath is empty, skip search.");
return Array.Empty<RemoteSubtitleInfo>();
}
if (request.MediaPath.Contains("://") ||
request.MediaPath.StartsWith("smb://", StringComparison.OrdinalIgnoreCase) ||
request.MediaPath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
request.MediaPath.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning(Name + " Search | Network path detected, skip hash-based Shooter search: {Path}", request.MediaPath);
return Array.Empty<RemoteSubtitleInfo>();
}
FileInfo fileInfo = new FileInfo(request.MediaPath);
_logger.LogInformation("DEBUG: Starting hash calculation for " + fileName);
var stopWatch = Stopwatch.StartNew();
var hash = await ComputeFileHashAsync(fileInfo, cancellationToken);
stopWatch.Stop();
_logger.LogInformation(Name + " Search | FileHash -> " + hash + " (Took " + stopWatch.ElapsedMilliseconds + "ms)");
var formData = new Dictionary<string, string>
{
{ "filehash", hash},
{ "pathinfo", request.MediaPath},
{ "format", "json"},
{ "lang", language == "chi" ? "chn" : "eng"}
};
var content = new FormUrlEncodedContent(formData);
// 设置请求头
content.Headers.ContentType = new MediaTypeHeaderValue("application/x-www-form-urlencoded");
using var httpClient = _httpClientFactory.CreateClient(Name);
// 发送 POST 请求
using var response = await httpClient.PostAsync(ApiUrl, content, cancellationToken);
_logger.LogInformation("{Provider} Search | Response -> {StatusCode} | Content-Type -> {ContentType}",
Name, response.StatusCode, response.Content.Headers.ContentType?.MediaType ?? "unknown");
// 处理响应
if (response.IsSuccessStatusCode && response.Content.Headers.ContentType?.MediaType?.Equals("application/json", StringComparison.OrdinalIgnoreCase) == true)
{
var responseBody = await response.Content.ReadAsStringAsync();
if (string.IsNullOrEmpty(responseBody) || !responseBody.Trim().StartsWith("["))
{
_logger.LogInformation($"{Name} Search | Summary -> API returned invalid content (likely no subtitles found or API error).");
return Array.Empty<RemoteSubtitleInfo>();
}
var subtitles = JsonSerializer.Deserialize<List<SubtitleResponseRoot>>(responseBody);
if (subtitles != null)
{
var remoteSubtitles = new List<RemoteSubtitleInfo>();
foreach (var subFileInfo in subtitles)
{
foreach (var subFile in subFileInfo.Files.Where(m =>
!string.IsNullOrWhiteSpace(m.Link) && NormalizeFormat(m.Ext) != null))
{
remoteSubtitles.Add(new RemoteSubtitleInfo()
{
Id = Base64Encode(JsonSerializer.Serialize(new DownloadSubInfo
{
Url = subFile.Link,
Format = NormalizeFormat(subFile.Ext),
Language = request.Language,
TwoLetterISOLanguageName = request.TwoLetterISOLanguageName,
})),
Name = $"[MEIAMSUB] {Path.GetFileName(request.MediaPath)} | {request.TwoLetterISOLanguageName} | 射手",
Author = "Meiam ",
ProviderName = $"{Name}",
Format = NormalizeFormat(subFile.Ext),
Comment = $"Format : {ExtractFormat(subFile.Ext)}",
IsHashMatch = true
});
}
}
_logger.LogInformation($"{Name} Search | Summary -> Get {remoteSubtitles.Count} Subtitles");
return remoteSubtitles;
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, $"{Name} Search | Exception -> {ex.Message}");
}
_logger.LogInformation($"{Name} Search | Summary -> Get 0 Subtitles");
return Array.Empty<RemoteSubtitleInfo>();
}
#endregion
#region 下载字幕
/// <summary>
/// 获取字幕内容 (ISubtitleProvider 接口实现)
/// 根据字幕 ID 下载具体的字幕文件流。
/// </summary>
/// <param name="id">字幕唯一标识符 (Base64 编码的 JSON 数据)</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>包含字幕流的响应对象</returns>
public async Task<SubtitleResponse> GetSubtitles(string id, CancellationToken cancellationToken)
{
_logger.LogInformation($"{Name} DownloadSub | Request -> {id}");
return await DownloadSubAsync(id, cancellationToken);
}
/// <summary>
/// 下载字幕
/// </summary>
/// <param name="info"></param>
/// <returns></returns>
private async Task<SubtitleResponse> DownloadSubAsync(string info, CancellationToken cancellationToken)
{
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 增加异常处理
try
{
var downloadSub = JsonSerializer.Deserialize<DownloadSubInfo>(Base64Decode(info));
if (downloadSub == null)
{
return new SubtitleResponse();
}
_logger.LogInformation($"{Name} DownloadSub | Url -> {downloadSub.Url} | Format -> {downloadSub.Format} | Language -> {downloadSub.Language} ");
using var options = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri(downloadSub.Url)
};
using var httpClient = _httpClientFactory.CreateClient(Name);
using var response = await httpClient.SendAsync(options, cancellationToken);
_logger.LogInformation($"{Name} DownloadSub | Response -> {response.StatusCode}");
if (response.StatusCode == HttpStatusCode.OK)
{
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);
var stream = new MemoryStream(data, writable: false);
return new SubtitleResponse()
{
Language = downloadSub.Language,
IsForced = false,
Format = format,
Stream = stream,
};
}
}
catch (Exception ex)
{
_logger.LogError(ex, "{0} DownloadSub | Error -> {1}", Name, ex.Message);
}
throw new InvalidDataException($"{Name} subtitle download failed.");
}
#endregion
#region 内部方法
/// <summary>
/// Base64 加密
/// </summary>
/// <param name="plainText">明文</param>
/// <returns></returns>
public static string Base64Encode(string plainText)
{
var plainTextBytes = Encoding.UTF8.GetBytes(plainText);
return Convert.ToBase64String(plainTextBytes);
}
/// <summary>
/// Base64 解密
/// </summary>
/// <param name="base64EncodedData"></param>
/// <returns></returns>
public static string Base64Decode(string base64EncodedData)
{
var base64EncodedBytes = Convert.FromBase64String(base64EncodedData);
return Encoding.UTF8.GetString(base64EncodedBytes);
}
/// <summary>
/// 提取格式化字幕类型
/// </summary>
/// <param name="text"></param>
/// <returns></returns>
protected string ExtractFormat(string text)
{
if (string.IsNullOrEmpty(text))
{
return null;
}
text = text.ToLower();
if (text.Contains(ASS)) return ASS;
if (text.Contains(SSA)) return SSA;
if (text.Contains(SRT)) return SRT;
return null;
}
private static string NormalizeFormat(string format)
{
var value = format?.Trim().TrimStart('.').ToLowerInvariant();
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 Shooter.");
}
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) && format == SRT ||
mediaType?.IndexOf("html", StringComparison.OrdinalIgnoreCase) >= 0 || mediaType?.IndexOf("json", StringComparison.OrdinalIgnoreCase) >= 0)
{
throw new InvalidDataException("Shooter 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>
/// <param name="language"></param>
/// <returns></returns>
private static string NormalizeLanguage(string language)
{
if (string.IsNullOrEmpty(language)) return language;
if (language.Equals("zh-CN", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zh-TW", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zh-HK", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zh", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zho", StringComparison.OrdinalIgnoreCase) ||
language.Equals("chi", StringComparison.OrdinalIgnoreCase))
{
return "chi";
}
if (language.Equals("en", StringComparison.OrdinalIgnoreCase) ||
language.Equals("eng", StringComparison.OrdinalIgnoreCase))
{
return "eng";
}
return language;
}
/// <summary>
/// 异步计算文件 Hash (射手网专用算法)
/// <para>修改人: Meiam</para>
/// <para>修改时间: 2025-12-22</para>
/// <para>备注: 采用异步 I/O 读取文件特定位置的 4KB 数据块进行 MD5 计算。</para>
/// </summary>
/// <param name="fileInfo">文件信息对象</param>
/// <returns>计算得到的文件 Hash 字符串,如果文件过小或不存在则返回空字符串</returns>
public static async Task<string> ComputeFileHashAsync(FileInfo fileInfo, CancellationToken cancellationToken)
{
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 改造为异步方法,优化 I/O 性能并增加 using 语句释放资源
string ret = "";
if (!fileInfo.Exists || fileInfo.Length < 8 * 1024)
{
return ret;
}
using (FileStream fs = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous))
{
long[] offset = new long[4];
offset[3] = fileInfo.Length - 8 * 1024;
offset[2] = fileInfo.Length / 3;
offset[1] = fileInfo.Length / 3 * 2;
offset[0] = 4 * 1024;
byte[] bBuf = new byte[1024 * 4];
for (int i = 0; i < 4; ++i)
{
fs.Seek(offset[i], SeekOrigin.Begin);
await fs.ReadExactlyAsync(bBuf, 0, 4 * 1024, cancellationToken);
using (MD5 md5Hash = MD5.Create())
{
byte[] data = md5Hash.ComputeHash(bBuf);
StringBuilder sBuilder = new StringBuilder();
for (int j = 0; j < data.Length; j++)
{
sBuilder.Append(data[j].ToString("x2"));
}
if (!string.IsNullOrEmpty(ret))
{
ret += ";";
}
ret += sBuilder.ToString();
}
}
}
return ret;
}
#endregion
}
}

View File

@@ -0,0 +1,12 @@
{
"category": "Subtitles",
"description": "Deprecated: the legacy Shooter API rarely returns usable results. Use Assrt or Thunder instead.",
"guid": "038D37A2-7A1E-4C01-9B6D-AA215D29AB4C",
"name": "MeiamSub.Shooter",
"owner": "Meiam",
"targetAbi": "10.11.0.0",
"timestamp": "2026-08-07T07:09:27.0000000Z",
"version": "1.0.16.0",
"status": "Deprecated",
"imagePath": "thumb.png"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1,25 @@
using MediaBrowser.Model.Plugins;
namespace Jellyfin.MeiamSub.Thunder.Configuration
{
/// <summary>
/// 插件配置类
/// <para>修改人: Meiam (移植自 PR #133 by Mayfly777w)</para>
/// <para>修改时间: 2026-02-11</para>
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// 是否使用元数据中的剧集名称和季集编号搜索字幕
/// <para>启用后,剧集使用 "{SeriesName} S{Season}E{Episode}" 格式搜索</para>
/// <para>电影使用元数据中的影片名称搜索</para>
/// <para>默认关闭,使用文件名搜索</para>
/// </summary>
public bool EnableUseMetadata { get; set; }
public PluginConfiguration()
{
EnableUseMetadata = false;
}
}
}

View File

@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<title>MeiamSub.Thunder</title>
</head>
<body>
<div id="MeiamSubThunderConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
data-require="emby-checkbox">
<div data-role="content">
<div class="content-primary">
<form id="MeiamSubThunderConfigForm">
<div class="verticalSection">
<h2 class="sectionTitle">迅雷字幕搜索设置</h2>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableUseMetadata" name="EnableUseMetadata" type="checkbox"
is="emby-checkbox" />
<span>使用元数据搜索字幕</span>
</label>
<div class="fieldDescription">
勾选此项后,使用元数据中的剧集名称和季集编号搜索字幕,而非文件名。
<br />
剧集将使用 "剧集名 S01E01" 格式;电影将使用影片名称。
</div>
</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>保存</span>
</button>
</div>
</form>
</div>
</div>
<script type="text/javascript">
var MeiamSubThunderConfig = {
pluginUniqueId: 'E4CE9DA9-EF00-417C-96F2-861C512D45EB'
};
document.querySelector('#MeiamSubThunderConfigPage')
.addEventListener('pageshow', function () {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(MeiamSubThunderConfig.pluginUniqueId).then(function (config) {
document.querySelector('#EnableUseMetadata').checked = config.EnableUseMetadata;
Dashboard.hideLoadingMsg();
});
});
document.querySelector('#MeiamSubThunderConfigForm')
.addEventListener('submit', function (e) {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(MeiamSubThunderConfig.pluginUniqueId).then(function (config) {
config.EnableUseMetadata = document.querySelector('#EnableUseMetadata').checked;
ApiClient.updatePluginConfiguration(MeiamSubThunderConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
e.preventDefault();
return false;
});
</script>
</div>
</body>
</html>

View File

@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Version>1.0.17.1</Version>
<AssemblyVersion>1.0.17.1</AssemblyVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<OutputPath>..\Release</OutputPath>
</PropertyGroup>
<ItemGroup>
<!-- 使用已验证的最低兼容基线,保持同一 DLL 可运行于 Jellyfin 10.10.7、10.11.11 和 12.1。 -->
<PackageReference Include="Jellyfin.Controller" Version="10.10.7" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<None Update="thumb.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<Compile Include="..\Shared\SubtitleContentValidator.cs" Link="Shared\SubtitleContentValidator.cs" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Configuration\configPage.html" />
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
</Target>
</Project>

View File

@@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace Jellyfin.MeiamSub.Thunder.Model
{
public class DownloadSubInfo
{
public string Url { get; set; }
public string Format { get; set; }
public string Language { get; set; }
public string TwoLetterISOLanguageName { get; set; }
}
}

View File

@@ -0,0 +1,29 @@
using System.Collections.Generic;
namespace Jellyfin.MeiamSub.Thunder.Model
{
public class SubtitleResponseRoot
{
public int Code { get; set; }
public List<SublistItem> Data { get; set; }
public string Result { get; set; }
}
public class SublistItem
{
public string Gcid { get; set; }
public string Cid { get; set; }
public string Url { get; set; }
public string Ext { get; set; }
public string Name { get; set; }
public long Duration { get; set; }
public string[] Languages { get; set; }
public string Langs => Languages != null ? string.Join(",", Languages) : string.Empty;
public int Source { get; set; }
public int Score { get; set; }
public int FingerprintfScore { get; set; }
public string ExtraName { get; set; }
}
}

View File

@@ -0,0 +1,58 @@
using Jellyfin.MeiamSub.Thunder.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using System;
using System.Collections.Generic;
namespace Jellyfin.MeiamSub.Thunder
{
/// <summary>
/// 插件入口
/// <para>修改人: Meiam (移植自 PR #133 by Mayfly777w)</para>
/// <para>修改时间: 2026-02-11</para>
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
/// <summary>
/// 插件ID
/// </summary>
public override Guid Id => new Guid("E4CE9DA9-EF00-417C-96F2-861C512D45EB");
/// <summary>
/// 插件名称
/// </summary>
public override string Name => "MeiamSub.Thunder";
/// <summary>
/// 插件描述
/// </summary>
public override string Description => "Download subtitles from Thunder XMP";
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
public static Plugin Instance { get; private set; }
/// <summary>
/// 获取插件配置页面列表
/// </summary>
/// <returns>配置页面信息</returns>
public IEnumerable<PluginPageInfo> GetPages()
{
return new[]
{
new PluginPageInfo
{
Name = Name,
EmbeddedResourcePath = GetType().Namespace + ".Configuration.configPage.html"
}
};
}
}
}

View File

@@ -0,0 +1,38 @@
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Controller;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Jellyfin.MeiamSub.Thunder
{
/// <summary>
/// 插件服务注册器
/// 负责注册插件所需的依赖服务,如 HTTP 客户端和字幕提供程序。
/// <para>修改人: Meiam</para>
/// <para>修改时间: 2025-12-22</para>
/// </summary>
public class PluginServiceRegistrator : IPluginServiceRegistrator
{
/// <summary>
/// 注册服务
/// </summary>
/// <param name="serviceCollection">服务集合</param>
/// <param name="applicationHos">应用程序宿主</param>
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHos)
{
serviceCollection.AddHttpClient("MeiamSub.Thunder", client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Add("User-Agent", "MeiamSub.Thunder");
client.DefaultRequestHeaders.Add("Accept", "*/*");
});
serviceCollection.AddSingleton<ISubtitleProvider, ThunderProvider>();
}
}
}

View File

@@ -0,0 +1,441 @@
using Jellyfin.MeiamSub.Thunder.Model;
using MeiamSubtitles.Shared;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.MeiamSub.Thunder
{
/// <summary>
/// 迅雷看看字幕提供程序
/// 负责与迅雷 API 进行交互,通过 CID (Content ID) 匹配并下载字幕。
/// <para>修改人: Meiam</para>
/// <para>修改时间: 2025-12-22</para>
/// </summary>
public class ThunderProvider : ISubtitleProvider, IHasOrder
{
#region 变量声明
public const string ASS = "ass";
public const string SSA = "ssa";
public const string SRT = "srt";
private readonly ILogger<ThunderProvider> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private static readonly JsonSerializerOptions _deserializeOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
public int Order => 100;
public string Name => "MeiamSub.Thunder";
/// <summary>
/// 支持电影、剧集
/// </summary>
public IEnumerable<VideoContentType> SupportedMediaTypes => new[] { VideoContentType.Movie, VideoContentType.Episode };
#endregion
#region 构造函数
public ThunderProvider(ILogger<ThunderProvider> logger, IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_logger.LogInformation($"{Name} Init");
}
#endregion
#region 查询字幕
/// <summary>
/// 搜索字幕 (ISubtitleProvider 接口实现)
/// 根据媒体信息请求字幕列表。
/// </summary>
/// <param name="request">包含媒体路径、语言等信息的搜索请求对象</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>远程字幕信息列表</returns>
public async Task<IEnumerable<RemoteSubtitleInfo>> Search(SubtitleSearchRequest request, CancellationToken cancellationToken)
{
var subtitles = await SearchSubtitlesAsync(request, cancellationToken);
return subtitles;
}
/// <summary>
/// 查询字幕
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request, CancellationToken cancellationToken)
{
// 修改人: Meiam (移植自 PR #133 by Mayfly777w)
// 修改时间: 2026-02-11
// 备注: 增加元数据搜索选项,可以以元数据中的系列名和季集信息来作为匹配对象
try
{
if (request == null)
{
_logger.LogInformation("{Provider} Search | Request is null", Name);
return Array.Empty<RemoteSubtitleInfo>();
}
var language = NormalizeLanguage(request.Language);
// 根据配置决定搜索名称:使用元数据 或 文件名
string movieName;
if (Plugin.Instance?.Configuration?.EnableUseMetadata == true)
{
if (request.ContentType == VideoContentType.Episode)
{
movieName = $"{request.SeriesName} S{request.ParentIndexNumber}E{request.IndexNumber}";
}
else if (request.ContentType == VideoContentType.Movie)
{
movieName = request.Name;
}
else
{
movieName = !string.IsNullOrEmpty(request.MediaPath)
? Path.GetFileName(request.MediaPath)
: string.Empty;
}
}
else
{
movieName = !string.IsNullOrEmpty(request.MediaPath)
? Path.GetFileName(request.MediaPath)
: string.Empty;
}
_logger.LogInformation(Name + " Search | Target -> " + movieName + " | Language -> " + language);
if (language != "chi")
{
_logger.LogInformation(Name + " Search | Summary -> Language not supported, skip search.");
return Array.Empty<RemoteSubtitleInfo>();
}
if (string.IsNullOrEmpty(request.MediaPath))
{
_logger.LogInformation(Name + " Search | Summary -> MediaPath is empty, skip search.");
return Array.Empty<RemoteSubtitleInfo>();
}
var cid = string.Empty;
var stopWatch = Stopwatch.StartNew();
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
{
Method = HttpMethod.Get,
RequestUri = new Uri($"https://api-shoulei-ssl.xunlei.com/oracle/subtitle?name={Uri.EscapeDataString(movieName)}")
};
using var httpClient = _httpClientFactory.CreateClient(Name);
using var response = await httpClient.SendAsync(options, cancellationToken);
_logger.LogInformation("{Provider} Search | Response -> {StatusCode}", Name, response.StatusCode);
if (response.StatusCode == HttpStatusCode.OK)
{
var subtitleResponse = JsonSerializer.Deserialize<SubtitleResponseRoot>(await response.Content.ReadAsStringAsync(), _deserializeOptions);
if (subtitleResponse != null)
{
var subtitles = (subtitleResponse.Data ?? new List<SublistItem>())
.Where(m => !string.IsNullOrWhiteSpace(m.Name) &&
!string.IsNullOrWhiteSpace(m.Url) &&
NormalizeFormat(m.Ext) != null)
.GroupBy(m => m.Url, StringComparer.OrdinalIgnoreCase)
.Select(m => m.First())
.OrderByDescending(m => !string.IsNullOrEmpty(cid) && string.Equals(cid, m.Cid, StringComparison.OrdinalIgnoreCase))
.ThenByDescending(m => m.Langs?.Contains("简体", StringComparison.OrdinalIgnoreCase) == true)
.Take(20)
.ToList();
var remoteSubtitles = new List<RemoteSubtitleInfo>();
if (subtitles.Count() > 0)
{
foreach (var item in subtitles)
{
remoteSubtitles.Add(new RemoteSubtitleInfo()
{
Id = Base64Encode(JsonSerializer.Serialize(new DownloadSubInfo
{
Url = item.Url,
Format = NormalizeFormat(item.Ext),
Language = request.Language,
TwoLetterISOLanguageName = request.TwoLetterISOLanguageName,
})),
Name = $"[MEIAMSUB] {item.Name} | {(item.Langs == string.Empty ? "未知" : item.Langs)} | 迅雷",
Author = "Meiam ",
ProviderName = $"{Name}",
Format = NormalizeFormat(item.Ext),
Comment = $"Format : {NormalizeFormat(item.Ext)}",
IsHashMatch = !string.IsNullOrEmpty(cid) && string.Equals(cid, item.Cid, StringComparison.OrdinalIgnoreCase),
});
}
}
_logger.LogInformation($"{Name} Search | Summary -> Get {subtitles.Count()} Subtitles");
return remoteSubtitles;
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, $"{Name} Search | Exception -> {ex.Message}");
}
_logger.LogInformation($"{Name} Search | Summary -> Get 0 Subtitles");
return Array.Empty<RemoteSubtitleInfo>();
}
#endregion
#region 下载字幕
/// <summary>
/// 获取字幕内容 (ISubtitleProvider 接口实现)
/// 根据字幕 ID 下载具体的字幕文件流。
/// </summary>
/// <param name="id">字幕唯一标识符 (Base64 编码的 JSON 数据)</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>包含字幕流的响应对象</returns>
public async Task<SubtitleResponse> GetSubtitles(string id, CancellationToken cancellationToken)
{
_logger.LogInformation($"{Name} DownloadSub | Request -> {id}");
return await DownloadSubAsync(id, cancellationToken);
}
/// <summary>
/// 下载字幕
/// </summary>
/// <param name="info"></param>
/// <returns></returns>
private async Task<SubtitleResponse> DownloadSubAsync(string info, CancellationToken cancellationToken)
{
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 增加异常处理
try
{
var downloadSub = JsonSerializer.Deserialize<DownloadSubInfo>(Base64Decode(info));
if (downloadSub == null)
{
return new SubtitleResponse();
}
_logger.LogInformation($"{Name} DownloadSub | Url -> {downloadSub.Url} | Format -> {downloadSub.Format} | Language -> {downloadSub.Language} ");
using var options = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri(downloadSub.Url)
};
using var httpClient = _httpClientFactory.CreateClient(Name);
using var response = await httpClient.SendAsync(options, cancellationToken);
_logger.LogInformation($"{Name} DownloadSub | Response -> {response.StatusCode}");
if (response.StatusCode == HttpStatusCode.OK)
{
var format = NormalizeFormat(downloadSub.Format)
?? throw new InvalidDataException($"Unsupported subtitle format: {downloadSub.Format}");
var data = await response.Content.ReadAsByteArrayAsync(cancellationToken);
var utf8Data = SubtitleContentValidator.ValidateAndConvertToUtf8(
data,
format,
response.Content.Headers.ContentType?.MediaType);
var stream = new MemoryStream(utf8Data, writable: false);
return new SubtitleResponse()
{
Language = downloadSub.Language,
IsForced = false,
Format = format,
Stream = stream,
};
}
}
catch (Exception ex)
{
_logger.LogError(ex, "{Provider} DownloadSub | Exception -> [{Type}] {Message}", Name, ex.GetType().Name, ex.Message);
}
throw new InvalidDataException($"{Name} subtitle download failed.");
}
#endregion
#region 内部方法
/// <summary>
/// Base64 加密
/// </summary>
/// <param name="plainText">明文</param>
/// <returns></returns>
public static string Base64Encode(string plainText)
{
var plainTextBytes = Encoding.UTF8.GetBytes(plainText);
return Convert.ToBase64String(plainTextBytes);
}
/// <summary>
/// Base64 解密
/// </summary>
/// <param name="base64EncodedData"></param>
/// <returns></returns>
public static string Base64Decode(string base64EncodedData)
{
var base64EncodedBytes = Convert.FromBase64String(base64EncodedData);
return Encoding.UTF8.GetString(base64EncodedBytes);
}
/// <summary>
/// 提取格式化字幕类型
/// </summary>
/// <param name="text"></param>
/// <returns></returns>
protected string ExtractFormat(string text)
{
if (string.IsNullOrEmpty(text))
{
return null;
}
text = text.ToLower();
if (text.Contains(ASS)) return ASS;
if (text.Contains(SSA)) return SSA;
if (text.Contains(SRT)) return SRT;
return null;
}
private static string NormalizeFormat(string format)
{
var value = format?.Trim().TrimStart('.').ToLowerInvariant();
return value == SRT || value == ASS || value == SSA ? value : null;
}
/// <summary>
/// 规范化语言代码
/// </summary>
/// <param name="language"></param>
/// <returns></returns>
private static string NormalizeLanguage(string language)
{
if (string.IsNullOrEmpty(language)) return language;
if (language.Equals("zh-CN", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zh-TW", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zh-HK", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zh", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zho", StringComparison.OrdinalIgnoreCase) ||
language.Equals("chi", StringComparison.OrdinalIgnoreCase))
{
return "chi";
}
if (language.Equals("en", StringComparison.OrdinalIgnoreCase) ||
language.Equals("eng", StringComparison.OrdinalIgnoreCase))
{
return "eng";
}
return language;
}
/// <summary>
/// 异步计算文件 CID (迅雷专用算法)
/// <para>修改人: Meiam</para>
/// <para>修改时间: 2025-12-22</para>
/// <para>备注: 采用异步 I/O 读取文件特定位置的数据块进行 SHA1 计算。</para>
/// </summary>
/// <param name="filePath">文件路径</param>
/// <returns>计算得到的 CID 字符串</returns>
private async Task<string> GetCidByFileAsync(string filePath, CancellationToken cancellationToken)
{
// 修改人: Meiam
// 修改时间: 2026-06-11
// 备注: 增加网络路径拦截,避免 FileStream 打开非本地路径抛出异常
if (string.IsNullOrEmpty(filePath) ||
filePath.Contains("://") ||
filePath.StartsWith("smb://", StringComparison.OrdinalIgnoreCase) ||
filePath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
filePath.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("Network path detected, skip hash calculation: {Path}", filePath);
return string.Empty;
}
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous))
{
var fileSize = new FileInfo(filePath).Length;
using (var sha1 = SHA1.Create())
{
var buffer = new byte[0xf000];
if (fileSize < 0xf000)
{
await stream.ReadExactlyAsync(buffer, 0, (int)fileSize, cancellationToken);
buffer = sha1.ComputeHash(buffer, 0, (int)fileSize);
}
else
{
await stream.ReadExactlyAsync(buffer, 0, 0x5000, cancellationToken);
stream.Seek(fileSize / 3, SeekOrigin.Begin);
await stream.ReadExactlyAsync(buffer, 0x5000, 0x5000, cancellationToken);
stream.Seek(fileSize - 0x5000, SeekOrigin.Begin);
await stream.ReadExactlyAsync(buffer, 0xa000, 0x5000, cancellationToken);
buffer = sha1.ComputeHash(buffer, 0, 0xf000);
}
var result = "";
foreach (var i in buffer)
{
result += string.Format("{0:X2}", i);
}
return result;
}
}
}
#endregion
}
}

View File

@@ -0,0 +1,12 @@
{
"category": "Subtitles",
"description": "Download subtitles from Thunder XMP",
"guid": "E4CE9DA9-EF00-417C-96F2-861C512D45EB",
"name": "MeiamSub.Thunder",
"owner": "Meiam",
"targetAbi": "10.10.7.0",
"timestamp": "2026-09-17T03:17:33.0000000Z",
"version": "1.0.17.1",
"status": "Active",
"imagePath": "thumb.png"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

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.

55
MeiamSubtitles.sln Normal file
View File

@@ -0,0 +1,55 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.10.34916.146
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Emby.MeiamSub.DevTool", "Emby.MeiamSub.DevTool\Emby.MeiamSub.DevTool.csproj", "{6B0C23EA-EC24-4FB0-948E-094E84AEBF21}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.MeiamSub.Thunder", "Jellyfin.MeiamSub.Thunder\Jellyfin.MeiamSub.Thunder.csproj", "{4676AA1B-CC6C-42DC-BD69-6A293BAE8823}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Emby.MeiamSub.Thunder", "Emby.MeiamSub.Thunder\Emby.MeiamSub.Thunder.csproj", "{96F4C65C-11B1-46F4-B343-115168688C2D}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.MeiamSub.Assrt", "Jellyfin.MeiamSub.Assrt\Jellyfin.MeiamSub.Assrt.csproj", "{E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0}"
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
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{6B0C23EA-EC24-4FB0-948E-094E84AEBF21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6B0C23EA-EC24-4FB0-948E-094E84AEBF21}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6B0C23EA-EC24-4FB0-948E-094E84AEBF21}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6B0C23EA-EC24-4FB0-948E-094E84AEBF21}.Release|Any CPU.Build.0 = Release|Any CPU
{4676AA1B-CC6C-42DC-BD69-6A293BAE8823}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4676AA1B-CC6C-42DC-BD69-6A293BAE8823}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4676AA1B-CC6C-42DC-BD69-6A293BAE8823}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4676AA1B-CC6C-42DC-BD69-6A293BAE8823}.Release|Any CPU.Build.0 = Release|Any CPU
{96F4C65C-11B1-46F4-B343-115168688C2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{96F4C65C-11B1-46F4-B343-115168688C2D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{96F4C65C-11B1-46F4-B343-115168688C2D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{96F4C65C-11B1-46F4-B343-115168688C2D}.Release|Any CPU.Build.0 = Release|Any CPU
{E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0}.Release|Any CPU.Build.0 = Release|Any CPU
{D276A8D0-4B17-4EEF-94B6-61D646DE5A22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{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
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {AC5A2964-C7C8-419B-A8DD-63424233E6AA}
EndGlobalSection
EndGlobal

194
README.md
View File

@@ -1,74 +1,174 @@
# Emby.MeiamSub
Emby 中文字幕插件 ,支持 迅雷影音、射手网、 精准匹配,自动下载
# MeiamSubtitles
**MeiamSubtitles** 是一款专为 **Emby** 和 **Jellyfin** 媒体服务器打造的中文字幕下载插件。它集成了 **[Assrt(伪射手网)](https://assrt.net/)** 与 **迅雷影音** 的字幕搜索能力,支持影片名称与视频特征(CID)匹配,帮助您的媒体库便捷地搜索和补全中文字幕。
[![.NET CORE](https://img.shields.io/badge/.NET%20Core-3.1-d.svg)](#)
[![Platform](https://img.shields.io/badge/Platform-Linux%20%7C%20Win%20%7C%20OSX-brightgreen.svg)](#)
[![LICENSE](https://img.shields.io/badge/license-Apache%202-blue)](#)
[![Star](https://img.shields.io/github/stars/91270/Emby.MeiamSub?label=Star%20this%20repo)](https://github.com/91270/Emby.MeiamSub)
[![Fork](https://img.shields.io/github/forks/91270/Emby.MeiamSub?label=Fork%20this%20repo)](https://github.com/91270/Emby.MeiamSub/fork)
[![博客](https://img.shields.io/badge/博客-Meiam's%20Home-brightgreen.svg)](https://www.592.la/)
<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%208.0-blueviolet.svg" alt=".NET Status">
<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,优先匹配对应文件的字幕;无需额外配置。
&nbsp;
> Shooter 的旧版接口已很少返回有效字幕,自 v1.0.16.0 起停止编译、维护和发布。
## 给个星星! ⭐️
如果你喜欢这个项目或者它帮助你, 请给 Star~(辛苦咯)
## 📣 广告时间
> **搬瓦工 $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)**
&nbsp;
## 功能
## 功能介绍
- 同时支持 Emby 和 Jellyfin。
- 支持 `zho`、`chi`、`zh-CN` 等中文语言代码。
- 使用异步文件读取计算视频特征,适用于本地及网络媒体库。
- 对搜索结果进行格式校验、去重和排序。
- 下载前校验响应内容,避免将空响应或错误页面保存为字幕。
- Assrt 支持从压缩包中选择并提取 `.srt`、`.ass`、`.ssa` 字幕。
- 日志隐藏 Assrt Token,且不记录完整接口响应。
## 插件与兼容性
- [x] 支持 迅雷看看 字幕下载 Hash匹配
- [x] 支持 射手影音 字幕下载 Hash匹配
| 插件 | 平台 | 目标框架 | 字幕源 |
| --- | --- | --- | --- |
| `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 |
| 平台 | 兼容版本 |
| --- | --- |
| Emby | 4.8.10.0 至 4.10.0.40 |
| Jellyfin | 10.10.7 至 12.1 |
## 项目说明
## 版本渠道
| # | 模块功能 | 项目文件 | 说明
|---|-------------------------------|-------------------------------|-------------------------------
| 1 | 开发程序 | Emby.MeiamSub.DevTool | 项目开发测试调试使用
| 2 | 字幕插件 | Emby.MeiamSub.Thunder | 迅雷看看字幕插件
| 3 | 字幕插件 | Emby.MeiamSub.Shooter | 射手影音字幕插件
- **正式版**:版本号末位为 `0`,例如 `1.0.18.0`。正式版会发布到 GitHub Releases,并同步到 Jellyfin 稳定插件仓库。
- **预发布版**:GitHub 只保留一个滚动更新的“预发布版”,固定使用 `preview` 标签和 `Emby_preview.zip`、`Jellyfin_preview.zip` 两个附件。
- 每次更新预发布版时,插件内部版本末位继续递增,例如 `1.0.17.1`、`1.0.17.2`,用于识别用户实际安装的测试批次;旧测试包会被新包替换。
- 预发布版不会写入稳定插件仓库,测试用户需要从 GitHub 的 `preview` Release 手动下载安装。
- 正式版版本号必须高于同一轮全部预发布版。例如 `1.0.17.1`、`1.0.17.2` 验证完成后发布 `1.0.18.0`。
- 正式版发布后,滚动预发布 Release 会转为草稿保留;开始下一轮测试时再更新并重新公开。
## 安装
### Jellyfin 存储库
## 使用插件
推荐通过插件存储库安装,以便接收后续更新。
首先下载已编译好的插件 [LINK](https://github.com/91270/Emby.MeiamSub/releases)
1. 打开 Jellyfin 控制台的“插件 → 存储库”。
2. 添加存储库名称 `MeiamSub`。
3. 填写以下地址:
### WINDOWS
```bash
复制插件文件到 Emby-Server\Programdata\Plugins\
复制插件文件到 Emby-Server\System\Plugins\
重启服务
```
```text
https://github.com/91270/MeiamSubtitles.Release/raw/main/Plugin/manifest-stable.json
```
### LINUX
```bash
复制插件文件到 /opt/emby-server/system/plugins
复制插件文件到 /var/lib/emby/plugins
重启服务
```
4. 在“目录”中分别安装 `MeiamSub.Assrt` 或 `MeiamSub.Thunder`。
5. 重启 Jellyfin。
&nbsp;
### 手动安装
### 群晖
```bash
复制插件文件到 /var/packages/EmbyServer/target/var/plugins
复制插件文件到 /var/packages/EmbyServer/target/system/plugins
重启服务
```
从 [Releases](https://github.com/91270/MeiamSubtitles/releases/latest) 下载与平台对应的压缩包。
&nbsp;
#### Emby
## 贡献
1. 解压 `Emby_v*.zip`。
2. 将需要的插件 DLL 放入 Emby 的 `plugins` 目录。
3. 不要额外复制 `SharpCompress.dll`;Assrt 复用 Emby 自带版本。
4. 重启 Emby。
贡献的最简单的方法之一就是是参与讨论和讨论问题(issue)。你也可以通过提交的 Pull Request 代码变更作出贡献。
#### Jellyfin
## 致谢
1. 解压 `Jellyfin_v*.zip`。
2. 将需要的整个插件目录复制到 Jellyfin 的 `plugins` 目录。
3. Assrt 目录中的 `SharpCompress.dll` 是 Jellyfin 所需依赖,请勿单独删除。
4. 重启 Jellyfin。
[Emby.Subtitle.Subscene](https://github.com/nRafinia/Emby.Subtitle.Subscene)
### 从旧版本升级
- 升级到 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` |
## 配置
### Assrt
1. 从 [Assrt](https://assrt.net/) 获取 API Token。
2. 打开插件配置页,将 Token 填入 `Assrt API Token`。
3. 在媒体库的字幕下载器中启用 `MeiamSub.Assrt`。
未配置 Token 时,Assrt 会跳过搜索并在日志中给出提示。
### Thunder
Thunder 默认使用视频文件计算 CID。Jellyfin 版本还可在插件设置中启用元数据搜索,用影片名称辅助匹配。
## 自动下载说明
建议先通过“搜索字幕”手动确认匹配效果,再决定是否启用媒体库自动下载。
- 同一媒体库的字幕下载语言只应添加一次。例如只保留一个 `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,142 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace MeiamSubtitles.Shared
{
internal static class SubtitleContentValidator
{
private static readonly Encoding StrictUtf8 = new UTF8Encoding(false, true);
private static readonly Encoding StrictUtf16Le = new UnicodeEncoding(false, true, true);
private static readonly Encoding StrictUtf16Be = new UnicodeEncoding(true, true, true);
static SubtitleContentValidator()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
}
public static void Validate(byte[] data, string format, string mediaType)
{
DecodeAndValidate(data, format, mediaType);
}
public static byte[] ValidateAndConvertToUtf8(byte[] data, string format, string mediaType)
{
var text = DecodeAndValidate(data, format, mediaType);
return Encoding.UTF8.GetBytes(text);
}
private static string DecodeAndValidate(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 sample;
}
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)
{
// Decode the complete subtitle because the selected text is returned to
// the host after validation and must not be truncated to a probe window.
var length = data.Length;
if (data.Length >= 2)
{
if (data[0] == 0xFF && data[1] == 0xFE)
{
yield return DecodeSample(data, length, StrictUtf16Le);
yield break;
}
if (data[0] == 0xFE && data[1] == 0xFF)
{
yield return DecodeSample(data, length, StrictUtf16Be);
yield break;
}
}
if (data.Length >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF)
{
yield return DecodeSample(data, length, StrictUtf8);
yield break;
}
// Thunder may return UTF-16 or GB18030 subtitles without a BOM. Decode
// strictly so invalid UTF-8 cannot hide the actual legacy encoding.
foreach (var encoding in new[] { StrictUtf8, StrictUtf16Le, StrictUtf16Be, GetGb18030Encoding() })
{
string sample;
try
{
sample = DecodeSample(data, length, encoding);
}
catch (DecoderFallbackException)
{
continue;
}
yield return sample;
}
}
private static Encoding GetGb18030Encoding()
{
return Encoding.GetEncoding(54936, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback);
}
private static string DecodeSample(byte[] data, int length, Encoding encoding)
{
return encoding.GetString(data, 0, length)
.TrimStart('\uFEFF', ' ', '\r', '\n', '\t');
}
}
}

360
publish.ps1 Normal file
View File

@@ -0,0 +1,360 @@
param (
[Parameter(Mandatory=$true)]
[string]$Version,
[Parameter(Mandatory=$false)]
[ValidateSet("Stable", "Preview")]
[string]$Channel = "Stable",
[Parameter(Mandatory=$false)]
[string]$JellyfinVersion,
[Parameter(Mandatory=$false)]
[string]$EmbyVersion,
[Parameter(Mandatory=$false)]
[string]$Notes,
[Parameter(Mandatory=$false)]
[switch]$PublishToGitHub,
[Parameter(Mandatory=$false)]
[switch]$ValidateOnly
)
$ErrorActionPreference = "Stop"
$SupportedEmbySdkVersion = "4.8.10"
$SupportedJellyfinSdkVersion = "10.10.7"
$RepositoryRoot = (Resolve-Path $PSScriptRoot).Path.Replace('\', '/')
$GitArguments = @("-c", "safe.directory=$RepositoryRoot")
$PreviewTag = "preview"
# Define paths
$ThunderCsproj = "Jellyfin.MeiamSub.Thunder\Jellyfin.MeiamSub.Thunder.csproj"
$ThunderMeta = "Jellyfin.MeiamSub.Thunder\meta.json"
$AssrtCsproj = "Jellyfin.MeiamSub.Assrt\Jellyfin.MeiamSub.Assrt.csproj"
$AssrtMeta = "Jellyfin.MeiamSub.Assrt\meta.json"
$EmbyThunderCsproj = "Emby.MeiamSub.Thunder\Emby.MeiamSub.Thunder.csproj"
$EmbyAssrtCsproj = "Emby.MeiamSub.Assrt\Emby.MeiamSub.Assrt.csproj"
if ($Version -notmatch '^\d+\.\d+\.\d+\.\d+$') {
throw "版本号必须使用四段数字格式,例如 1.0.18.0 或 1.0.17.1。"
}
$VersionParts = $Version.Split('.') | ForEach-Object { [int]$_ }
$Revision = $VersionParts[3]
if ($Channel -eq "Stable" -and $Revision -ne 0) {
throw "正式版版本号末位必须为 0,例如 1.0.18.0。"
}
if ($Channel -eq "Preview" -and $Revision -eq 0) {
throw "预发布版版本号末位必须大于 0,例如 1.0.17.1。"
}
if ($Channel -eq "Preview" -and $PublishToGitHub -and [string]::IsNullOrWhiteSpace($Notes)) {
throw "发布滚动预发布版时必须通过 -Notes 说明本轮测试重点。"
}
$CurrentVersionMatch = [regex]::Match((Get-Content -LiteralPath $ThunderCsproj -Raw), '<Version>([\d\.]+)</Version>')
if (-not $CurrentVersionMatch.Success) {
throw "无法读取当前插件版本。"
}
$CurrentVersion = [version]$CurrentVersionMatch.Groups[1].Value
if ([version]$Version -le $CurrentVersion) {
throw "目标版本 $Version 必须高于当前版本 $CurrentVersion。"
}
$WorkingTreeChanges = @(git @GitArguments status --porcelain)
if ($LASTEXITCODE -ne 0) {
throw "无法检查 Git 工作树状态。"
}
if ($WorkingTreeChanges.Count -gt 0) {
throw "发布前工作树必须保持干净,请先提交或处理现有变更。"
}
if ($Channel -eq "Stable") {
$ExistingLocalTag = git @GitArguments tag --list "v$Version"
if ($LASTEXITCODE -ne 0) {
throw "无法检查本地 Git 标签。"
}
if ($ExistingLocalTag) {
throw "本地标签 v$Version 已存在,禁止覆盖已经使用的正式版本。"
}
if ($PublishToGitHub) {
git @GitArguments ls-remote --exit-code --tags origin "refs/tags/v$Version" | Out-Null
if ($LASTEXITCODE -eq 0) {
throw "远端标签 v$Version 已存在,禁止覆盖已经公开的正式版本。"
}
if ($LASTEXITCODE -ne 2) {
throw "无法检查远端标签,请检查网络或 Git 认证。"
}
}
}
$ChannelLabel = if ($Channel -eq "Preview") { "预发布" } else { "正式发布" }
Write-Host "=== 开始${ChannelLabel}流程,目标版本:$Version ===" -ForegroundColor Cyan
if ($ValidateOnly) {
Write-Host "发布参数、工作树和标签检查通过。" -ForegroundColor Green
return
}
# 1. Get current UTC timestamp
$Timestamp = [DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.0000000Z")
Write-Host "Current package timestamp (UTC): $Timestamp" -ForegroundColor Gray
# 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`" />" `
-replace '<PackageReference Include="MediaBrowser.Server.Core" Version="[^"]+" />', "<PackageReference Include=`"MediaBrowser.Server.Core`" Version=`"$EmbyVersion`" />" | Set-Content $EmbyThunderCsproj
(Get-Content $EmbyAssrtCsproj) -replace '<PackageReference Include="MediaBrowser.Common" Version="[^"]+" />', "<PackageReference Include=`"MediaBrowser.Common`" Version=`"$EmbyVersion`" />" `
-replace '<PackageReference Include="MediaBrowser.Server.Core" Version="[^"]+" />', "<PackageReference Include=`"MediaBrowser.Server.Core`" Version=`"$EmbyVersion`" />" | Set-Content $EmbyAssrtCsproj
}
Write-Host "Updating version and timestamp in project files..." -ForegroundColor Yellow
# Update Jellyfin Thunder
(Get-Content $ThunderCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" | Set-Content $ThunderCsproj
(Get-Content $ThunderMeta) -replace '"version":\s*"[\d\.]+"', "`"version`": `"$Version`"" `
-replace '"timestamp":\s*"[^"]+"', "`"timestamp`": `"$Timestamp`"" | Set-Content $ThunderMeta
# Update Jellyfin Assrt
(Get-Content $AssrtCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" `
-replace '<FileVersion>[\d\.]+</FileVersion>', "<FileVersion>$Version</FileVersion>" | Set-Content $AssrtCsproj
(Get-Content $AssrtMeta) -replace '"version":\s*"[\d\.]+"', "`"version`": `"$Version`"" `
-replace '"timestamp":\s*"[^"]+"', "`"timestamp`": `"$Timestamp`"" | Set-Content $AssrtMeta
# Update Emby Thunder
(Get-Content $EmbyThunderCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" `
-replace '<FileVersion>[\d\.]+</FileVersion>', "<FileVersion>$Version</FileVersion>" | Set-Content $EmbyThunderCsproj
# Update Emby Assrt
(Get-Content $EmbyAssrtCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" `
-replace '<FileVersion>[\d\.]+</FileVersion>', "<FileVersion>$Version</FileVersion>" | Set-Content $EmbyAssrtCsproj
Write-Host "Versions and timestamps updated successfully!" -ForegroundColor Green
# 2. Compile solution
Write-Host "Compiling solution in Release mode..." -ForegroundColor Yellow
dotnet build MeiamSubtitles.sln -c Release
if ($LASTEXITCODE -ne 0) {
throw "解决方案编译失败。"
}
Write-Host "Running automated tests..." -ForegroundColor Yellow
dotnet test tests\MeiamSubtitles.Tests\MeiamSubtitles.Tests.csproj -c Release --no-restore
if ($LASTEXITCODE -ne 0) {
throw "自动测试失败。"
}
# 3. Create zip archives
Write-Host "Packaging zip archives..." -ForegroundColor Yellow
$TempDir = "Release\temp_pack"
$EmbyPackDir = "$TempDir\Emby"
$JellyfinThunderPackDir = "$TempDir\Jellyfin\Jellyfin.MeiamSub.Thunder_$Version"
$JellyfinAssrtPackDir = "$TempDir\Jellyfin\Jellyfin.MeiamSub.Assrt_$Version"
# Clean temp directories
if (Test-Path $TempDir) { Remove-Item -Recurse -Force $TempDir }
New-Item -ItemType Directory -Force -Path $EmbyPackDir | Out-Null
New-Item -ItemType Directory -Force -Path $JellyfinThunderPackDir | Out-Null
New-Item -ItemType Directory -Force -Path $JellyfinAssrtPackDir | Out-Null
# Copy Emby DLLs
Copy-Item Release\Emby.MeiamSub.Thunder.dll $EmbyPackDir\
Copy-Item Release\Emby.MeiamSub.Assrt.dll $EmbyPackDir\
# Copy Jellyfin Thunder artifacts
Copy-Item Release\Jellyfin.MeiamSub.Thunder.dll $JellyfinThunderPackDir\
Copy-Item Jellyfin.MeiamSub.Thunder\meta.json $JellyfinThunderPackDir\
Copy-Item Jellyfin.MeiamSub.Thunder\thumb.png $JellyfinThunderPackDir\
# Copy Jellyfin Assrt artifacts
Copy-Item Release\Jellyfin.MeiamSub.Assrt.dll $JellyfinAssrtPackDir\
Copy-Item Release\dependencies\Jellyfin\* $JellyfinAssrtPackDir\
Copy-Item Jellyfin.MeiamSub.Assrt\meta.json $JellyfinAssrtPackDir\
Copy-Item Jellyfin.MeiamSub.Assrt\thumb.png $JellyfinAssrtPackDir\
# Archive target paths. Preview assets use stable names because the single
# rolling preview release replaces them on every test iteration.
if ($Channel -eq "Preview") {
$EmbyZip = "Release\Emby_preview.zip"
$JellyfinZip = "Release\Jellyfin_preview.zip"
} else {
$EmbyZip = "Release\Emby_v$Version.zip"
$JellyfinZip = "Release\Jellyfin_v$Version.zip"
}
if (Test-Path $EmbyZip) { Remove-Item -Force $EmbyZip }
if (Test-Path $JellyfinZip) { Remove-Item -Force $JellyfinZip }
# Compress
Compress-Archive -Path "$EmbyPackDir\*" -DestinationPath $EmbyZip -Force
Compress-Archive -Path "$TempDir\Jellyfin\*" -DestinationPath $JellyfinZip -Force
# Cleanup temp
Remove-Item -Recurse -Force $TempDir
Write-Host "=== Packaging Finished successfully! ===" -ForegroundColor Green
Write-Host "Emby Package: $EmbyZip" -ForegroundColor Gray
Write-Host "Jellyfin Package: $JellyfinZip" -ForegroundColor Gray
# 4. Publish to GitHub Release if specified
if ($PublishToGitHub) {
Write-Host "=== Start GitHub Release process ===" -ForegroundColor Cyan
Write-Host "Staging and committing release changes..." -ForegroundColor Yellow
$ReleaseMetadataFiles = @(
$ThunderCsproj,
$ThunderMeta,
$AssrtCsproj,
$AssrtMeta,
$EmbyThunderCsproj,
$EmbyAssrtCsproj
)
git @GitArguments add -- $ReleaseMetadataFiles
if ($LASTEXITCODE -ne 0) {
throw "暂存发布版本文件失败。"
}
git @GitArguments diff --cached --check
if ($LASTEXITCODE -ne 0) {
throw "暂存内容存在空白错误。"
}
$CommitMessage = if ($Channel -eq "Preview") {
"发布: 更新滚动预发布版至 v$Version"
} else {
"发布: v$Version"
}
git @GitArguments commit -m $CommitMessage
if ($LASTEXITCODE -ne 0) {
throw "创建发布提交失败。"
}
Write-Host "Pushing changes to remote repository..." -ForegroundColor Yellow
git @GitArguments push origin master
if ($LASTEXITCODE -ne 0) {
throw "推送发布提交失败。"
}
if ($Channel -eq "Preview") {
Write-Host "Updating rolling GitHub preview release..." -ForegroundColor Yellow
$CommitSha = git @GitArguments rev-parse HEAD
if ($LASTEXITCODE -ne 0) {
throw "无法读取预发布提交哈希。"
}
$EmbyHash = (Get-FileHash -LiteralPath $EmbyZip -Algorithm SHA256).Hash
$JellyfinHash = (Get-FileHash -LiteralPath $JellyfinZip -Algorithm SHA256).Hash
$PreviewNotesFile = "Release\preview-notes.md"
$PreviewNotes = @"
## 当前测试版本
- 插件版本:$Version
- 源码提交:$CommitSha
- Emby 包 SHA-256:$EmbyHash
- Jellyfin 包 SHA-256:$JellyfinHash
## 本轮测试重点
$Notes
## 安装说明
停止 Emby 或 Jellyfin,移除旧版 Thunder DLL 或插件目录,再安装本页最新测试包并重启。反馈问题时请注明插件版本和宿主版本。
"@
Set-Content -LiteralPath $PreviewNotesFile -Value $PreviewNotes -Encoding utf8
try {
# The user requested one rolling preview. Only this dedicated tag
# may be force-moved; branches and formal release tags remain immutable.
git @GitArguments tag --force $PreviewTag $CommitSha
if ($LASTEXITCODE -ne 0) {
throw "更新本地 preview 标签失败。"
}
git @GitArguments push origin "refs/tags/$PreviewTag" --force
if ($LASTEXITCODE -ne 0) {
throw "更新远端 preview 标签失败。"
}
& gh release view $PreviewTag *> $null
$PreviewReleaseExists = $LASTEXITCODE -eq 0
if ($PreviewReleaseExists) {
& gh release upload $PreviewTag $EmbyZip $JellyfinZip --clobber
if ($LASTEXITCODE -ne 0) {
throw "覆盖滚动预发布附件失败;请检查仓库是否启用了 Release 不可变保护。"
}
& gh release edit $PreviewTag --title "预发布版" --notes-file $PreviewNotesFile --prerelease --draft=false --latest=false
} else {
& gh release create $PreviewTag $EmbyZip $JellyfinZip --title "预发布版" --verify-tag --notes-file $PreviewNotesFile --prerelease --latest=false
}
if ($LASTEXITCODE -ne 0) {
throw "创建或更新滚动预发布 Release 失败。"
}
} finally {
if (Test-Path -LiteralPath $PreviewNotesFile) {
Remove-Item -LiteralPath $PreviewNotesFile -Force
}
}
Write-Host "滚动预发布已更新:不要修改 manifest-stable.json。" -ForegroundColor Yellow
} else {
Write-Host "Creating stable GitHub Release v$Version and uploading assets..." -ForegroundColor Yellow
$ReleaseArguments = @(
"release", "create", "v$Version",
$EmbyZip,
$JellyfinZip,
"--title", "v$Version",
"--target", "master",
"--latest"
)
if ($Notes) {
$ReleaseArguments += @("--notes", $Notes, "--generate-notes")
} else {
$ReleaseArguments += "--generate-notes"
}
& gh @ReleaseArguments
if ($LASTEXITCODE -ne 0) {
throw "创建正式 GitHub Release 失败。"
}
& gh release view $PreviewTag *> $null
if ($LASTEXITCODE -eq 0) {
& gh release edit $PreviewTag --draft
if ($LASTEXITCODE -ne 0) {
throw "正式版已创建,但隐藏滚动预发布 Release 失败。"
}
}
Write-Host "正式版 GitHub Release 已创建,请继续更新 MeiamSubtitles.Release 的稳定清单。" -ForegroundColor Yellow
}
Write-Host "=== GitHub Release finished successfully! ===" -ForegroundColor Green
}

View File

@@ -0,0 +1,22 @@
<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="System.Text.Encoding.CodePages" Version="8.0.0" />
<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,145 @@
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");
}
[Fact]
public void ConvertsGb18030SrtToUtf8ForPreview()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var content = "1\r\n00:00:01,000 --> 00:00:02,000\r\n中文字幕\r\n";
var data = Encoding.GetEncoding(54936).GetBytes(content);
var normalized = SubtitleContentValidator.ValidateAndConvertToUtf8(
data,
"srt",
"application/octet-stream");
Assert.Equal(content, Encoding.UTF8.GetString(normalized));
}
[Fact]
public void PreservesCompleteUtf8SubtitleWhenNormalizing()
{
var content = "1\r\n00:00:01,000 --> 00:00:20,000\r\n" + new string('中', 3000) + "\r\n";
var normalized = SubtitleContentValidator.ValidateAndConvertToUtf8(
Encoding.UTF8.GetBytes(content),
"srt",
"application/octet-stream");
Assert.Equal(content, Encoding.UTF8.GetString(normalized));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ConvertsUtf16SrtToUtf8ForPreview(bool bigEndian)
{
var encoding = new UnicodeEncoding(bigEndian, true);
var content = "1\r\n00:00:01,000 --> 00:00:02,000\r\n中文字幕\r\n";
var data = encoding.GetPreamble().Concat(encoding.GetBytes(content)).ToArray();
var normalized = SubtitleContentValidator.ValidateAndConvertToUtf8(
data,
"srt",
"application/octet-stream");
Assert.Equal(content, Encoding.UTF8.GetString(normalized));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ConvertsUtf16WithoutBomToUtf8ForPreview(bool bigEndian)
{
var encoding = new UnicodeEncoding(bigEndian, false);
var content = "1\r\n00:00:01,000 --> 00:00:02,000\r\n中文字幕\r\n";
var normalized = SubtitleContentValidator.ValidateAndConvertToUtf8(
encoding.GetBytes(content),
"srt",
"application/octet-stream");
Assert.Equal(content, Encoding.UTF8.GetString(normalized));
}
[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);
}
}