35 Commits

Author SHA1 Message Date
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
27 changed files with 1575 additions and 799 deletions

9
.gitignore vendored
View File

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

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.9.0</AssemblyVersion>
<FileVersion>1.0.9.0</FileVersion>
<Version>1.0.9</Version>
<AssemblyVersion>1.0.13.0</AssemblyVersion>
<FileVersion>1.0.13.0</FileVersion>
<Version>1.0.13.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">
<Exec Command="Copy $(TargetDir)$(TargetFileName) $(SolutionDir)$(ConfigurationName)\$(TargetFileName) /y&#xD;&#xA;" />
</Target>
</Project>

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>
@@ -31,14 +38,31 @@ namespace Emby.MeiamSub.Shooter
/// </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

@@ -7,8 +7,8 @@ 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;
using System.Security.Cryptography;
using System.Text;
@@ -19,7 +19,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,40 +31,41 @@ 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 => 1;
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($"{Name} Init");
_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.Info($"{Name} Search | SubtitleSearchRequest -> { _jsonSerializer.SerializeToString(request) }");
_logger.Info("{0} Search | SubtitleSearchRequest -> {1}", new object[2] { Name, _jsonSerializer.SerializeToString(request) });
var subtitles = await SearchSubtitlesAsync(request);
@@ -75,85 +79,116 @@ namespace Emby.MeiamSub.Shooter
/// <returns></returns>
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request)
{
if(request.Language == "zh-CN" || request.Language == "zh-TW" || request.Language == "zh-HK"){
request.Language = "chi";
}
if(request.Language == "en"){
request.Language = "eng";
}
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);
_logger.Info($"{Name} Search | FileHash -> { hash }");
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",request.Language == "chi" ? "chn" : "eng"}
});
_logger.Info($"{Name} Search | Request -> { _jsonSerializer.SerializeToString(options) }");
var response = await _httpClient.Post(options);
_logger.Info($"{Name} 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.Info($"{Name} 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>();
FileInfo fileInfo = new FileInfo(request.MediaPath);
foreach(var subFileInfo in subtitleResponse)
var stopWatch = Stopwatch.StartNew();
var hash = await ComputeFileHashAsync(fileInfo);
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}", new object[2] { Name, _jsonSerializer.SerializeToString(response) });
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,
IsForced = request.IsForced
})),
Name = $"[MEIAMSUB] { Path.GetFileName(request.MediaPath) } | {request.Language} | 射手",
Author = "Meiam ",
ProviderName = $"{Name}",
Format = subFile.Ext,
Comment = $"Format : { ExtractFormat(subFile.Ext)}",
IsHashMatch = true
});
}
responseBody = await reader.ReadToEndAsync();
}
_logger.Info($"{Name} Search | Summary -> Get { remoteSubtitleInfos.Count } Subtitles");
_logger.Info("{0} Search | ResponseBody -> {1}", new object[2] { Name, responseBody });
return remoteSubtitleInfos;
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>();
}
var subtitleResponse = _jsonSerializer.DeserializeFromString<List<SubtitleResponseRoot>>(responseBody);
if (subtitleResponse != null)
{
_logger.Info("{0} Search | Response -> {1}", new object[2] { Name, _jsonSerializer.SerializeToString(subtitleResponse) });
var remoteSubtitles = new List<RemoteSubtitleInfo>();
foreach (var subFileInfo in subtitleResponse)
{
foreach (var subFile in subFileInfo.Files)
{
remoteSubtitles.Add(new RemoteSubtitleInfo()
{
Id = Base64Encode(_jsonSerializer.SerializeToString(new DownloadSubInfo
{
Url = subFile.Link,
Format = subFile.Ext,
Language = request.Language,
IsForced = request.IsForced
})),
Name = $"[MEIAMSUB] {Path.GetFileName(request.MediaPath)} | {request.Language} | 射手",
Language = request.Language,
Author = "Meiam ",
ProviderName = $"{Name}",
Format = 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.Info($"{Name} Search | Summary -> Get 0 Subtitles");
_logger.Info("{0} Search | Summary -> Get 0 Subtitles", new object[1] { Name });
return Array.Empty<RemoteSubtitleInfo>();
}
@@ -161,14 +196,15 @@ 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)
{
_logger.Info($"{Name} DownloadSub | Request -> {id}");
_logger.Info("{0} DownloadSub | Request -> {1}", new object[2] { Name, id });
return await DownloadSubAsync(id);
}
@@ -180,36 +216,49 @@ namespace Emby.MeiamSub.Shooter
/// <returns></returns>
private async Task<SubtitleResponse> DownloadSubAsync(string info)
{
var downloadSub = _jsonSerializer.DeserializeFromString<DownloadSubInfo>(Base64Decode(info));
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 增加异常处理
if (downloadSub == null)
try
{
return new SubtitleResponse();
}
var downloadSub = _jsonSerializer.DeserializeFromString<DownloadSubInfo>(Base64Decode(info));
_logger.Info($"{Name} DownloadSub | Url -> { downloadSub.Url } | Format -> { downloadSub.Format } | Language -> { downloadSub.Language } ");
var response = await _httpClient.GetResponse(new HttpRequestOptions
{
Url = downloadSub.Url,
UserAgent = $"{Name}",
TimeoutMs = 30000,
AcceptHeader = "*/*",
});
_logger.Info($"{Name} 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)
{
return new SubtitleResponse()
{
Language = downloadSub.Language,
IsForced = false,
Format = downloadSub.Format,
Stream = response.Content,
};
}
}
catch (Exception ex)
{
_logger.Error("{0} DownloadSub | Error -> {1}", Name, ex.Message);
}
return new SubtitleResponse();
@@ -247,27 +296,59 @@ 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;
}
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;
}
/// <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)
{
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 改造为异步方法,优化 I/O 性能并增加 using 语句释放资源
string ret = "";
if (!fileInfo.Exists || fileInfo.Length < 8 * 1024)
@@ -275,40 +356,41 @@ 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 fs.ReadAsync(bBuf, 0, 4 * 1024);
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;
}
#endregion

View File

@@ -1,15 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyVersion>1.0.9.0</AssemblyVersion>
<FileVersion>1.0.9.0</FileVersion>
<Version>1.0.9</Version>
<AssemblyVersion>1.0.13.0</AssemblyVersion>
<FileVersion>1.0.13.0</FileVersion>
<Version>1.0.13.0</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,20 @@
<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>
<ItemGroup>
<Folder Include="Configuration\" />
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Exec Command="Copy $(TargetDir)$(TargetFileName) $(SolutionDir)$(ConfigurationName)\$(TargetFileName) /y&#xD;&#xA;" />
</Target>
</Project>

View File

@@ -2,39 +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; }
}
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 int 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,5 +1,8 @@
using MediaBrowser.Common.Plugins;
using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Drawing;
using MediaBrowser.Model.Serialization;
using System;
using System.IO;
@@ -11,6 +14,12 @@ namespace Emby.MeiamSub.Thunder
/// </summary>
public class Plugin : BasePlugin, IHasThumbImage
{
public Plugin(IApplicationPaths applicationPaths)
{
Instance = this;
}
/// <summary>
/// 插件ID
/// </summary>
@@ -31,14 +40,28 @@ namespace Emby.MeiamSub.Thunder
/// </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)
{
return null;
}
return stream;
}
}
}

View File

@@ -1,4 +1,4 @@
using Emby.MeiamSub.Thunder.Model;
using Emby.MeiamSub.Thunder.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;
@@ -14,12 +15,14 @@ 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,40 +31,42 @@ 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;
public int Order => 1;
public int Order => 100;
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)
{
_logger = logger;
_logger = logManager.GetLogger(GetType().Name);
_jsonSerializer = jsonSerializer;
_httpClient = httpClient;
_logger.Info($"{Name} Init");
_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.Info($"{Name} Search | SubtitleSearchRequest -> { _jsonSerializer.SerializeToString(request) }");
_logger.Info("{0} Search | SubtitleSearchRequest -> {1}", new object[2] { Name, _jsonSerializer.SerializeToString(request) });
var subtitles = await SearchSubtitlesAsync(request);
@@ -75,65 +80,89 @@ namespace Emby.MeiamSub.Thunder
/// <returns></returns>
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request)
{
if(request.Language == "zh-CN" || request.Language == "zh-TW" || request.Language == "zh-HK"){
request.Language = "chi";
}
if (request.Language != "chi")
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 增加异常处理
try
{
return Array.Empty<RemoteSubtitleInfo>();
}
var language = NormalizeLanguage(request.Language);
var cid = GetCidByFile(request.MediaPath);
_logger.Info("{0} Search | Target -> {1} | Language -> {2}", Name, Path.GetFileName(request.MediaPath), language);
_logger.Info($"{Name} Search | FileHash -> { cid }");
var response = await _httpClient.GetResponse(new HttpRequestOptions
{
//Url = $"http://sub.xmp.sandai.net:8000/subxl/{cid}.json",
Url = $"http://subtitle.kankan.xunlei.com:8000/subxl/{cid}.json",
UserAgent = $"{Name}",
TimeoutMs = 30000,
AcceptHeader = "*/*",
});
_logger.Info($"{Name} Search | Response -> { _jsonSerializer.SerializeToString(response) }");
if (response.StatusCode == HttpStatusCode.OK)
{
var subtitleResponse = _jsonSerializer.DeserializeFromStream<SubtitleResponseRoot>(response.Content);
if (subtitleResponse != null)
if (language != "chi")
{
_logger.Info($"{Name} 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 stopWatch = Stopwatch.StartNew();
var cid = await GetCidByFileAsync(request.MediaPath);
stopWatch.Stop();
if (subtitles.Count() > 0)
_logger.Info("{0} Search | FileHash -> {1} (Took {2}ms)", new object[3] { Name, cid, stopWatch.ElapsedMilliseconds });
HttpRequestOptions options = new HttpRequestOptions
{
Url = $"https://api-shoulei-ssl.xunlei.com/oracle/subtitle?name={Path.GetFileName(request.MediaPath)}",
UserAgent = $"{Name}",
TimeoutMs = 30000,
AcceptHeader = "*/*",
};
var response = await _httpClient.GetResponse(options);
_logger.Info("{0} Search | Response -> {1}", new object[2] { Name, _jsonSerializer.SerializeToString(response) });
if (response.StatusCode == HttpStatusCode.OK)
{
var subtitleResponse = _jsonSerializer.DeserializeFromStream<SubtitleResponseRoot>(response.Content);
if (subtitleResponse.Code == 0)
{
_logger.Info($"{Name} Search | Summary -> Get { subtitles.Count() } Subtitles");
_logger.Info("{0} Search | Response -> {1}", new object[2] { Name, _jsonSerializer.SerializeToString(subtitleResponse) });
return subtitles.Select(m => new RemoteSubtitleInfo()
var subtitles = subtitleResponse.Data.Where(m => !string.IsNullOrEmpty(m.Name));
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,
IsForced = request.IsForced
})),
Name = $"[MEIAMSUB] { Path.GetFileName(request.MediaPath) } | {m.language} | 迅雷",
Author = "Meiam ",
CommunityRating = Convert.ToSingle(m.rate),
ProviderName = $"{Name}",
Format = ExtractFormat(m.sname),
Comment = $"Format : { ExtractFormat(m.sname)} - Rate : { m.rate }",
IsHashMatch = true
}).OrderByDescending(m => m.CommunityRating);
remoteSubtitles.Add(new RemoteSubtitleInfo()
{
Id = Base64Encode(_jsonSerializer.SerializeToString(new DownloadSubInfo
{
Url = item.Url,
Format = 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 = item.Ext,
Comment = $"Format : {item.Ext}",
IsHashMatch = cid == item.Cid,
});
}
}
_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.Info($"{Name} Search | Summary -> Get 0 Subtitles");
return Array.Empty<RemoteSubtitleInfo>();
}
@@ -141,14 +170,15 @@ 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)
{
_logger.Info($"{Name} DownloadSub | Request -> {id}");
_logger.Info("{0} DownloadSub | Request -> {1}", new object[2] { Name, id });
return await DownloadSubAsync(id);
}
@@ -160,35 +190,47 @@ namespace Emby.MeiamSub.Thunder
/// <returns></returns>
private async Task<SubtitleResponse> DownloadSubAsync(string info)
{
var downloadSub = _jsonSerializer.DeserializeFromString<DownloadSubInfo>(Base64Decode(info));
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 增加异常处理
if (downloadSub == null)
try
{
return new SubtitleResponse();
}
var downloadSub = _jsonSerializer.DeserializeFromString<DownloadSubInfo>(Base64Decode(info));
_logger.Info($"{Name} DownloadSub | Url -> { downloadSub.Url } | Format -> { downloadSub.Format } | Language -> { downloadSub.Language } ");
var response = await _httpClient.GetResponse(new HttpRequestOptions
{
Url = downloadSub.Url,
UserAgent = $"{Name}",
TimeoutMs = 30000,
AcceptHeader = "*/*",
});
_logger.Info($"{Name} 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)
{
return new SubtitleResponse()
{
Language = downloadSub.Language,
IsForced = false,
Format = downloadSub.Format,
Stream = response.Content,
};
}
}
catch (Exception ex)
{
_logger.Error("{0} DownloadSub | Error -> {1}", Name, ex.Message);
}
return new SubtitleResponse();
@@ -226,53 +268,91 @@ 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;
}
/// <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)
{
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 改造为异步方法,优化 I/O 性能,使用 SHA1.Create() 替代旧 API并增加 using 语句释放资源
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 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;
}
}
return result;
}
#endregion
}

68
GEMINI.md Normal file
View File

@@ -0,0 +1,68 @@
# MeiamSubtitles 项目文档
## 项目概览
**MeiamSubtitles** 是一套为 **Emby****Jellyfin** 媒体服务器开发的 C# 字幕插件。它支持从 **迅雷影音****射手网** 自动下载中文字幕,并利用文件哈希匹配技术确保字幕的精确度。
## 项目结构
解决方案 `MeiamSubtitles.sln` 包含以下核心项目:
* **Emby 插件:**
* `Emby.MeiamSub.Shooter`: 射手网字幕提供程序 (目标框架: `netstandard2.1`)。
* `Emby.MeiamSub.Thunder`: 迅雷看看字幕提供程序 (目标框架: `netstandard2.1`)。
* **Jellyfin 插件:**
* `Jellyfin.MeiamSub.Shooter`: 射手网字幕提供程序 (目标框架: `net9.0`)。
* `Jellyfin.MeiamSub.Thunder`: 迅雷看看字幕提供程序 (目标框架: `net9.0`)。
* *注: Jellyfin 插件遵循现代 .NET 架构,通过 `PluginServiceRegistrator` 使用依赖注入来管理服务 (如 `IHttpClientFactory`)。*
* **开发工具:**
* `Emby.MeiamSub.DevTool`: 控制台应用程序,用于开发调试,特别是验证各平台字幕匹配所需的哈希计算逻辑。
## 编译与开发
### 环境要求
* .NET 9 SDK (用于编译 Jellyfin 插件)
* .NET Framework / .NET Core (支持 .NET Standard 2.1)
### 编译命令
在根目录下执行以下命令编译整个解决方案:
```bash
dotnet build MeiamSubtitles.sln
```
### 编译产出
项目配置了 `PostBuild` 事件,编译后的 DLL 文件会自动复制到:
1. 各项目目录下的 `..\Release`
2. 解决方案根目录下的 `Release``Debug` 文件夹。
### 开发调试工具
可以使用 `Emby.MeiamSub.DevTool` 在本地测试哈希算法:
```bash
cd Emby.MeiamSub.DevTool
dotnet run
```
*注:测试时需在 `Program.cs` 中修改对应的视频文件路径。*
## 安装指南
### 手动安装 (Emby/通用)
1. 编译项目或下载已发布的版本。
2. 将生成的 `.dll` 文件复制到服务器的插件目录:
* **Windows:** `Emby-Server\Programdata\Plugins\``Emby-Server\System\Plugins\`
* **Linux:** `/opt/emby-server/system/plugins``/var/lib/emby/plugins`
* **群晖 (Synology):** `/var/packages/EmbyServer/var/plugins`
3. 重启 Emby 服务。
### Jellyfin 安装
Jellyfin 支持通过插件存储库安装:
* **存储库 URL:** `https://github.com/91270/MeiamSubtitles.Release/raw/main/Plugin/manifest-stable.json`
***控制台 -> 插件 -> 存储库** 中添加此链接。
## 核心技术
* **C# / .NET:** 核心开发语言 (Jellyfin: .NET 9, Emby: .NET Standard 2.1)。
* **依赖注入 (Jellyfin)**: 使用 `IHttpClientFactory` 管理 HTTP 请求,符合 Jellyfin 现代插件架构。
* **异步编程**: 核心 I/O 操作 (文件哈希) 采用异步模式 (`ReadExactlyAsync`),防止阻塞服务器线程。
* **依赖库版本**:
* **Emby**: `MediaBrowser.Server.Core` (v4.9.1.90)
* **Jellyfin**: `Jellyfin.Controller` (v10.11.5)
* **哈希算法:** 实现了射手网和迅雷特定的 MD5/SHA1 哈希计算逻辑,用于视频内容的精确匹配。

View File

@@ -1,12 +1,8 @@
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Plugins;
namespace Jellyfin.MeiamSub.Shooter.Configuration
{
/// <summary>
/// Plugin configuration.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
}
}

View File

@@ -1,10 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<Version>1.0.9</Version>
<AssemblyVersion>1.0.9.0</AssemblyVersion>
<FileVersion>1.0.9.0</FileVersion>
<TargetFramework>net9.0</TargetFramework>
<Version>1.0.13.0</Version>
<AssemblyVersion>1.0.13.0</AssemblyVersion>
<FileVersion>1.0.13.0</FileVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
@@ -12,7 +12,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.7.0" />
<PackageReference Include="Jellyfin.Controller" Version="10.11.5" />
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Exec Command="Copy $(TargetDir)$(TargetFileName) $(SolutionDir)$(ConfigurationName)\$(TargetFileName) /y&#xD;&#xA;" />
</Target>
</Project>

View File

@@ -7,7 +7,7 @@ namespace Jellyfin.MeiamSub.Shooter.Model
{
public string Desc { get; set; }
public int Delay { get; set; }
public SubFileInfo[] Files { get; set; }
public List<SubFileInfo> Files { get; set; }
}
public class SubFileInfo

View File

@@ -1,16 +1,16 @@
using Jellyfin.MeiamSub.Shooter.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Drawing;
using MediaBrowser.Model.Serialization;
using System;
using System.IO;
namespace Jellyfin.MeiamSub.Shooter
{
/// <summary>
/// 插件入口
/// <para>修改人: Meiam</para>
/// <para>修改时间: 2025-12-22</para>
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>
{
@@ -30,7 +30,7 @@ namespace Jellyfin.MeiamSub.Shooter
public override string Description => "Download subtitles from Shooter";
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}

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

@@ -1,10 +1,13 @@
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;
@@ -16,11 +19,15 @@ 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
{
@@ -30,22 +37,25 @@ namespace Jellyfin.MeiamSub.Shooter
public const string SRT = "srt";
private readonly ILogger<ShooterProvider> _logger;
private static readonly HttpClient _httpClient = new HttpClient();
private readonly IHttpClientFactory _httpClientFactory;
private const string ApiUrl = "https://www.shooter.cn/api/subapi.php";
public int Order => 100;
public int Order => 1;
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<ShooterProvider> logger)
public ShooterProvider(ILogger<ShooterProvider> logger, IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClient.Timeout = TimeSpan.FromSeconds(30);
_httpClientFactory = httpClientFactory;
_logger.LogInformation($"{Name} Init");
}
#endregion
@@ -53,14 +63,15 @@ namespace Jellyfin.MeiamSub.Shooter
#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.LogInformation($"{Name} Search | SubtitleSearchRequest -> { JsonSerializer.Serialize(request) }");
_logger.LogInformation("DEBUG: Received Search request for " + (request?.MediaPath ?? "NULL"));
var subtitles = await SearchSubtitlesAsync(request);
@@ -74,92 +85,168 @@ namespace Jellyfin.MeiamSub.Shooter
/// <returns></returns>
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request)
{
if (request.Language != "chi" && request.Language != "eng")
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 增加极致探测日志,定位方法中断的具体位置
_logger.LogInformation("DEBUG: Entering SearchSubtitlesAsync");
try
{
return Array.Empty<RemoteSubtitleInfo>();
}
FileInfo fileInfo = new(request.MediaPath);
var hash = ComputeFileHash(fileInfo);
_logger.LogInformation($"{Name} Search | FileHash -> { hash }");
var content = new Dictionary<string, string>
{
{ "filehash", hash},
{ "pathinfo", request.MediaPath},
{ "format", "json"},
{ "lang", request.Language == "chi" ? "chn" : "eng"}
};
HttpRequestMessage requestMessage = new HttpRequestMessage();
requestMessage.Method = HttpMethod.Post;
requestMessage.RequestUri = new Uri($"https://www.shooter.cn/api/subapi.php");
requestMessage.Content = new FormUrlEncodedContent(content);
requestMessage.Headers.Add("User-Agent", $"{Name}");
requestMessage.Headers.Add("Accept-Encoding", $"gzip, deflate, br");
requestMessage.Headers.Add("Accept", $"*/*");
var response = await _httpClient.SendAsync(requestMessage);
_logger.LogInformation($"{Name} Search | Response -> { JsonSerializer.Serialize(response) }");
if (response.StatusCode == HttpStatusCode.OK && response.Content.Headers.Any(m => m.Value.Contains("application/json; charset=utf-8")))
{
var subtitleResponse = JsonSerializer.Deserialize<List<SubtitleResponseRoot>>(await response.Content.ReadAsStringAsync());
_logger.LogInformation($"{Name} Search | Response -> { JsonSerializer.Serialize(subtitleResponse) }");
if (subtitleResponse != null)
if (request == null)
{
var remoteSubtitleInfos = new List<RemoteSubtitleInfo>();
_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>();
}
FileInfo fileInfo = new FileInfo(request.MediaPath);
_logger.LogInformation("DEBUG: Starting hash calculation for " + fileName);
var stopWatch = Stopwatch.StartNew();
var hash = await ComputeFileHashAsync(fileInfo);
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 请求
var response = await httpClient.PostAsync(ApiUrl, content);
_logger.LogInformation($"{Name} Search | Response -> {JsonSerializer.Serialize(response)}");
// 处理响应
if (response.IsSuccessStatusCode && response.Content.Headers.Any(m => m.Value.Contains("application/json; charset=utf-8")))
{
var responseBody = await response.Content.ReadAsStringAsync();
_logger.LogInformation($"{Name} Search | ResponseBody -> {responseBody} ");
if (string.IsNullOrEmpty(responseBody) || !responseBody.Trim().StartsWith("["))
foreach (var subFileInfo in subtitleResponse)
{
foreach (var subFile in subFileInfo.Files)
{
remoteSubtitleInfos.Add(new RemoteSubtitleInfo()
{
Id = Base64Encode(JsonSerializer.Serialize(new DownloadSubInfo
{
Url = subFile.Link,
Format = subFile.Ext,
Language = request.Language,
TwoLetterISOLanguageName = request.TwoLetterISOLanguageName,
})),
Name = $"[MEIAMSUB] { Path.GetFileName(request.MediaPath) } | {request.TwoLetterISOLanguageName} | 射手",
Author = "Meiam ",
ProviderName = $"{Name}",
Format = subFile.Ext,
Comment = $"Format : { ExtractFormat(subFile.Ext)}",
IsHashMatch = true
});
}
_logger.LogInformation($"{Name} Search | Summary -> API returned invalid content (likely no subtitles found or API error).");
return Array.Empty<RemoteSubtitleInfo>();
}
_logger.LogInformation($"{Name} Search | Summary -> Get { remoteSubtitleInfos.Count } Subtitles");
return remoteSubtitleInfos;
var subtitles = JsonSerializer.Deserialize<List<SubtitleResponseRoot>>(responseBody);
_logger.LogInformation($"{Name} Search | Response -> {JsonSerializer.Serialize(subtitles)}");
if (subtitles != null)
{
var remoteSubtitles = new List<RemoteSubtitleInfo>();
foreach (var subFileInfo in subtitles)
{
foreach (var subFile in subFileInfo.Files)
{
remoteSubtitles.Add(new RemoteSubtitleInfo()
{
Id = Base64Encode(JsonSerializer.Serialize(new DownloadSubInfo
{
Url = subFile.Link,
Format = subFile.Ext,
Language = request.Language,
TwoLetterISOLanguageName = request.TwoLetterISOLanguageName,
})),
Name = $"[MEIAMSUB] {Path.GetFileName(request.MediaPath)} | {request.TwoLetterISOLanguageName} | 射手",
Author = "Meiam ",
ProviderName = $"{Name}",
Format = 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"></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)
{
_logger.LogInformation($"{Name} DownloadSub | Request -> {id}");
@@ -174,41 +261,49 @@ namespace Jellyfin.MeiamSub.Shooter
/// <returns></returns>
private async Task<SubtitleResponse> DownloadSubAsync(string info)
{
var downloadSub = JsonSerializer.Deserialize<DownloadSubInfo>(Base64Decode(info));
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 增加异常处理
if (downloadSub == null)
try
{
return new SubtitleResponse();
}
var downloadSub = JsonSerializer.Deserialize<DownloadSubInfo>(Base64Decode(info));
_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),
Headers =
{
UserAgent = { new ProductInfoHeaderValue(new ProductHeaderValue($"{Name}")) },
Accept = { new MediaTypeWithQualityHeaderValue("*/*") }
}
};
var response = await _httpClient.SendAsync(options);
_logger.LogInformation($"{Name} DownloadSub | Response -> { response.StatusCode }");
if (response.StatusCode == HttpStatusCode.OK)
{
var stream = await response.Content.ReadAsStreamAsync();
return new SubtitleResponse()
if (downloadSub == null)
{
Language = downloadSub.Language,
IsForced = false,
Format = downloadSub.Format,
Stream = stream,
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);
var response = await httpClient.SendAsync(options);
_logger.LogInformation($"{Name} DownloadSub | Response -> {response.StatusCode}");
if (response.StatusCode == HttpStatusCode.OK)
{
var stream = await response.Content.ReadAsStreamAsync();
return new SubtitleResponse()
{
Language = downloadSub.Language,
IsForced = false,
Format = downloadSub.Format,
Stream = stream,
};
}
}
catch (Exception ex)
{
_logger.LogError(ex, "{0} DownloadSub | Error -> {1}", Name, ex.Message);
}
return new SubtitleResponse();
@@ -218,6 +313,7 @@ namespace Jellyfin.MeiamSub.Shooter
#region
/// <summary>
/// Base64 加密
/// </summary>
@@ -246,27 +342,59 @@ namespace Jellyfin.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;
}
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;
}
/// <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)
{
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 改造为异步方法,优化 I/O 性能并增加 using 语句释放资源
string ret = "";
if (!fileInfo.Exists || fileInfo.Length < 8 * 1024)
@@ -274,42 +402,44 @@ namespace Jellyfin.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 fs.ReadExactlyAsync(bBuf, 0, 4 * 1024);
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;
}
#endregion
}
}

View File

@@ -0,0 +1,12 @@
{
"category": "Subtitles",
"description": "Download subtitles from Shooter",
"guid": "038D37A2-7A1E-4C01-9B6D-AA215D29AB4C",
"name": "MeiamSub.Shooter",
"owner": "Meiam",
"targetAbi": "10.11.0.0",
"timestamp": "2025-12-22T10:00:00.0000000Z",
"version": "1.0.13.0",
"status": "Active",
"imagePath": "thumb.png"
}

View File

@@ -1,12 +1,8 @@
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Plugins;
namespace Jellyfin.MeiamSub.Thunder.Configuration
{
/// <summary>
/// Plugin configuration.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
}
}

View File

@@ -2,11 +2,9 @@
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net5.0</TargetFramework>
<ApplicationIcon />
<StartupObject />
<Version>1.0.9</Version>
<AssemblyVersion>1.0.9.0</AssemblyVersion>
<TargetFramework>net9.0</TargetFramework>
<Version>1.0.13.0</Version>
<AssemblyVersion>1.0.13.0</AssemblyVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
@@ -14,7 +12,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.7.0" />
<PackageReference Include="Jellyfin.Controller" Version="10.11.5" />
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Exec Command="Copy $(TargetDir)$(TargetFileName) $(SolutionDir)$(ConfigurationName)\$(TargetFileName) /y&#xD;&#xA;" />
</Target>
</Project>

View File

@@ -2,39 +2,28 @@
namespace Jellyfin.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; }
}
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 int 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,7 +1,6 @@
using Jellyfin.MeiamSub.Thunder.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Drawing;
using MediaBrowser.Model.Serialization;
using System;
@@ -9,6 +8,8 @@ namespace Jellyfin.MeiamSub.Thunder
{
/// <summary>
/// 插件入口
/// <para>修改人: Meiam</para>
/// <para>修改时间: 2025-12-22</para>
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>
{

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

@@ -1,10 +1,11 @@
using Jellyfin.MeiamSub.Thunder.Model;
using Jellyfin.MeiamSub.Thunder.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.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
@@ -19,7 +20,10 @@ 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
{
@@ -29,22 +33,26 @@ namespace Jellyfin.MeiamSub.Thunder
public const string SRT = "srt";
private readonly ILogger<ThunderProvider> _logger;
private static readonly HttpClient _httpClient = new HttpClient();
private readonly IHttpClientFactory _httpClientFactory;
private static readonly JsonSerializerOptions _deserializeOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
public int Order => 1;
public int Order => 100;
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<ThunderProvider> logger)
public ThunderProvider(ILogger<ThunderProvider> logger, IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClient.Timeout = TimeSpan.FromSeconds(30);
_httpClientFactory = httpClientFactory;
_logger.LogInformation($"{Name} Init");
}
#endregion
@@ -52,19 +60,20 @@ namespace Jellyfin.MeiamSub.Thunder
#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($"{Name} Search | SubtitleSearchRequest -> { JsonSerializer.Serialize(request) }");
var subtitles = await SearchSubtitlesAsync(request);
return subtitles;
}
/// <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);
return subtitles;
}
/// <summary>
/// 查询字幕
@@ -73,65 +82,105 @@ namespace Jellyfin.MeiamSub.Thunder
/// <returns></returns>
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request)
{
if (request.Language != "chi")
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 增加极致探测日志
_logger.LogInformation("DEBUG: Entering SearchSubtitlesAsync (Thunder)");
try
{
return Array.Empty<RemoteSubtitleInfo>();
}
var cid = GetCidByFile(request.MediaPath);
_logger.LogInformation($"{Name} Search | FileHash -> { cid }");
using var options = new HttpRequestMessage
{
Method = HttpMethod.Get,
//RequestUri = new Uri($"http://sub.xmp.sandai.net:8000/subxl/{cid}.json"),
RequestUri = new Uri($"http://subtitle.kankan.xunlei.com:8000/subxl/{cid}.json"),
Headers =
{
UserAgent = { new ProductInfoHeaderValue(new ProductHeaderValue($"{Name}")) },
Accept = { new MediaTypeWithQualityHeaderValue("*/*") },
}
};
var response = await _httpClient.SendAsync(options);
_logger.LogInformation($"{Name} Search | Response -> { JsonSerializer.Serialize(response) }");
if (response.StatusCode == HttpStatusCode.OK)
{
var subtitleResponse = JsonSerializer.Deserialize<SubtitleResponseRoot>(await response.Content.ReadAsStringAsync());
if (subtitleResponse != null)
if (request == null)
{
_logger.LogInformation($"{Name} Search | Response -> { JsonSerializer.Serialize(subtitleResponse) }");
_logger.LogInformation("DEBUG: Request is null");
return Array.Empty<RemoteSubtitleInfo>();
}
var subtitles = subtitleResponse.sublist.Where(m => !string.IsNullOrEmpty(m.sname));
var language = NormalizeLanguage(request.Language);
var fileName = string.Empty;
if (subtitles.Count() > 0)
if (!string.IsNullOrEmpty(request.MediaPath))
{
fileName = Path.GetFileName(request.MediaPath);
}
_logger.LogInformation(Name + " Search | Target -> " + fileName + " | 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 stopWatch = Stopwatch.StartNew();
var cid = await GetCidByFileAsync(request.MediaPath);
stopWatch.Stop();
_logger.LogInformation(Name + " Search | FileHash -> " + cid + " (Took " + stopWatch.ElapsedMilliseconds + "ms)");
using var options = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri($"https://api-shoulei-ssl.xunlei.com/oracle/subtitle?name={Path.GetFileName(request.MediaPath)}")
};
using var httpClient = _httpClientFactory.CreateClient(Name);
var response = await httpClient.SendAsync(options);
_logger.LogInformation($"{Name} Search | Response -> {JsonSerializer.Serialize(response)}");
if (response.StatusCode == HttpStatusCode.OK)
{
var subtitleResponse = JsonSerializer.Deserialize<SubtitleResponseRoot>(await response.Content.ReadAsStringAsync(), _deserializeOptions);
if (subtitleResponse != null)
{
_logger.LogInformation($"{Name} Search | Summary -> Get { subtitles.Count() } Subtitles");
_logger.LogInformation($"{Name} Search | Response -> {JsonSerializer.Serialize(subtitleResponse)}");
return subtitles.Select(m => new RemoteSubtitleInfo()
var subtitles = subtitleResponse.Data.Where(m => !string.IsNullOrEmpty(m.Name));
var remoteSubtitles = new List<RemoteSubtitleInfo>();
if (subtitles.Count() > 0)
{
Id = Base64Encode(JsonSerializer.Serialize(new DownloadSubInfo
foreach (var item in subtitles)
{
Url = m.surl,
Format = ExtractFormat(m.sname),
Language = request.Language,
TwoLetterISOLanguageName = request.TwoLetterISOLanguageName,
})),
Name = $"[MEIAMSUB] { Path.GetFileName(request.MediaPath) } | {request.TwoLetterISOLanguageName} | 迅雷",
Author = "Meiam ",
CommunityRating = Convert.ToSingle(m.rate),
ProviderName = $"{Name}",
Format = ExtractFormat(m.sname),
Comment = $"Format : { ExtractFormat(m.sname)} - Rate : { m.rate }",
IsHashMatch = true
}).OrderByDescending(m => m.CommunityRating);
remoteSubtitles.Add(new RemoteSubtitleInfo()
{
Id = Base64Encode(JsonSerializer.Serialize(new DownloadSubInfo
{
Url = item.Url,
Format = item.Ext,
Language = request.Language,
TwoLetterISOLanguageName = request.TwoLetterISOLanguageName,
})),
Name = $"[MEIAMSUB] {item.Name} | {(item.Langs == string.Empty ? "" : item.Langs)} | 迅雷",
Author = "Meiam ",
ProviderName = $"{Name}",
Format = item.Ext,
Comment = $"Format : {item.Ext}",
IsHashMatch = cid == item.Cid,
});
}
}
_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");
@@ -141,11 +190,12 @@ namespace Jellyfin.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)
{
_logger.LogInformation($"{Name} DownloadSub | Request -> {id}");
@@ -160,41 +210,49 @@ namespace Jellyfin.MeiamSub.Thunder
/// <returns></returns>
private async Task<SubtitleResponse> DownloadSubAsync(string info)
{
var downloadSub = JsonSerializer.Deserialize<DownloadSubInfo>(Base64Decode(info));
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 增加异常处理
if (downloadSub == null)
try
{
return new SubtitleResponse();
}
var downloadSub = JsonSerializer.Deserialize<DownloadSubInfo>(Base64Decode(info));
_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),
Headers =
{
UserAgent = { new ProductInfoHeaderValue(new ProductHeaderValue($"{Name}")) },
Accept = { new MediaTypeWithQualityHeaderValue("*/*") }
}
};
var response = await _httpClient.SendAsync(options);
_logger.LogInformation($"{Name} DownloadSub | Response -> { response.StatusCode }");
if (response.StatusCode == HttpStatusCode.OK)
{
var stream = await response.Content.ReadAsStreamAsync();
return new SubtitleResponse()
if (downloadSub == null)
{
Language = downloadSub.Language,
IsForced = false,
Format = downloadSub.Format,
Stream = stream,
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);
var response = await httpClient.SendAsync(options);
_logger.LogInformation($"{Name} DownloadSub | Response -> {response.StatusCode}");
if (response.StatusCode == HttpStatusCode.OK)
{
var stream = await response.Content.ReadAsStreamAsync();
return new SubtitleResponse()
{
Language = downloadSub.Language,
IsForced = false,
Format = downloadSub.Format,
Stream = stream,
};
}
}
catch (Exception ex)
{
_logger.LogError(ex, "{Provider} DownloadSub | Exception -> [{Type}] {Message}", Name, ex.GetType().Name, ex.Message);
}
return new SubtitleResponse();
@@ -232,54 +290,89 @@ namespace Jellyfin.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;
}
/// <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)
{
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 改造为异步方法,优化 I/O 性能,使用 SHA1.Create() 替代旧 API并增加 using 语句释放资源
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);
buffer = sha1.ComputeHash(buffer, 0, (int)fileSize);
}
else
{
await stream.ReadExactlyAsync(buffer, 0, 0x5000);
stream.Seek(fileSize / 3, SeekOrigin.Begin);
await stream.ReadExactlyAsync(buffer, 0x5000, 0x5000);
stream.Seek(fileSize - 0x5000, SeekOrigin.Begin);
await stream.ReadExactlyAsync(buffer, 0xa000, 0x5000);
buffer = sha1.ComputeHash(buffer, 0, 0xf000);
}
var result = "";
foreach (var i in buffer)
{
result += string.Format("{0:X2}", i);
}
return result;
}
}
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.11.0.0",
"timestamp": "2025-12-22T10:00:00.0000000Z",
"version": "1.0.13.0",
"status": "Active",
"imagePath": "thumb.png"
}

View File

@@ -1,17 +1,17 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.31424.327
# Visual Studio Version 17
VisualStudioVersion = 17.10.34916.146
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("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Emby.MeiamSub.Shooter", "Emby.MeiamSub.Shooter\Emby.MeiamSub.Shooter.csproj", "{0F502AEB-0FF4-44FA-8391-13AD61FC5490}"
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}") = "Jellyfin.MeiamSub.Shooter", "Jellyfin.MeiamSub.Shooter\Jellyfin.MeiamSub.Shooter.csproj", "{8F77E155-9A91-4882-82E8-E8D69FECD246}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.MeiamSub.Shooter", "Jellyfin.MeiamSub.Shooter\Jellyfin.MeiamSub.Shooter.csproj", "{8F77E155-9A91-4882-82E8-E8D69FECD246}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Emby.MeiamSub.Shooter", "Emby.MeiamSub.Shooter\Emby.MeiamSub.Shooter.csproj", "{F2636BCB-111D-4F22-AA06-8852E96D05C4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Emby.MeiamSub.Thunder", "Emby.MeiamSub.Thunder\Emby.MeiamSub.Thunder.csproj", "{96F4C65C-11B1-46F4-B343-115168688C2D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -19,18 +19,10 @@ Global
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
{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
@@ -39,6 +31,14 @@ Global
{8F77E155-9A91-4882-82E8-E8D69FECD246}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8F77E155-9A91-4882-82E8-E8D69FECD246}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8F77E155-9A91-4882-82E8-E8D69FECD246}.Release|Any CPU.Build.0 = Release|Any CPU
{F2636BCB-111D-4F22-AA06-8852E96D05C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F2636BCB-111D-4F22-AA06-8852E96D05C4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F2636BCB-111D-4F22-AA06-8852E96D05C4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F2636BCB-111D-4F22-AA06-8852E96D05C4}.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
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

123
README.md
View File

@@ -1,86 +1,133 @@
# MeiamSubtitles
Emby Jellyfin 中文字幕插件 ,支持 迅雷影音、射手网、 精准匹配,自动下载
Emby & Jellyfin 中文字幕插件,支持 **迅雷影音**、**射手网** 字幕自动下载与精准 Hash 匹配。
[![.NET CORE](https://img.shields.io/badge/.NET%20Core-3.1-d.svg)](#)
[![.NET Status](https://img.shields.io/badge/.NET-Standard%202.1%20%7C%209.0-blueviolet.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/)
&nbsp;
## 给个星星! ⭐️
如果你喜欢这个项目或者它帮助你, 请给 Star~(辛苦咯)
如果你能赞助稳定 Google Drive 团队盘用于媒体库插件测试, 请于我联系 91270#QQ.COM
&nbsp;
## 广告时间 📣
搬瓦工 $99 年付, 建站神器重出江湖THE PLAN V1 传家宝套餐18机房随意切换
循环优惠码BWHCCNCXVV6.77%
[直达通道(传家宝套餐)](https://bwh88.net/aff.php?aff=117&pid=87)
&nbsp;
## 功能介绍
- [x] 支持 迅雷看看 字幕下载 Hash匹配
- [x] 支持 射手影音 字幕下载 Hash匹配
- [x] **迅雷影音**: 支持通过文件 Hash (CID) 精准匹配字幕。
- [x] **射手网**: 支持通过文件 Hash 精准匹配字幕。
- [x] **高性能**: 核心哈希计算采用异步 I/O (Async/Await) 模式,避免阻塞服务器线程。
- [x] **稳定性**: 内置重试机制与异常处理Jellyfin 版本采用现代化的依赖注入架构。
## 项目说明
| # | 模块功能 | 项目文件 | 说明
|---|-------------------------------|-------------------------------|-------------------------------
| 1 | 开发程序 | Emby.MeiamSub.DevTool | 项目开发测试调试使用
| 2 | 字幕插件 | Emby.MeiamSub.Thunder | 迅雷看看字幕插件 - Emby
| 3 | 字幕插件 | Emby.MeiamSub.Shooter | 射手影音字幕插件 - Emby
| 3 | 字幕插件 | Jellyfin.MeiamSub.Shooter | 迅雷看看字幕插件 - Jellyfin
| 3 | 字幕插件 | Jellyfin.MeiamSub.Thunder | 射手影音字幕插件 - Jellyfin
| # | 模块功能 | 项目名称 | 说明 |
|---|---|---|---|
| 1 | Emby 插件 | `Emby.MeiamSub.Thunder` | 迅雷看看字幕插件 (.NET Standard 2.1) |
| 2 | Emby 插件 | `Emby.MeiamSub.Shooter` | 射手影音字幕插件 (.NET Standard 2.1) |
| 3 | Jellyfin 插件 | `Jellyfin.MeiamSub.Thunder` | 迅雷看看字幕插件 (.NET 9.0) |
| 4 | Jellyfin 插件 | `Jellyfin.MeiamSub.Shooter` | 射手影音字幕插件 (.NET 9.0) |
| 5 | 开发工具 | `Emby.MeiamSub.DevTool` | 哈希算法测试与调试工具 |
## 使用插件
首先下载已编译好的插件 [LINK](https://github.com/91270/Emby.MeiamSub/releases)
首先下载已编译好的插件 [Release 下载](https://github.com/91270/Emby.MeiamSub/releases)
### WINDOWS
**注意**:建议在媒体库设置中**不勾选**本插件作为默认下载器,仅在手动“编辑字幕”或“搜索字幕”时使用,以获得最佳体验。
## 常见问题排查 (FAQ)
如果您在搜索字幕时遇到“未找到结果”,请按以下步骤排查:
### 1. 语言代码识别
- **现象**: 在 Jellyfin 10.11+ 中搜索不到字幕。
- **原因**: 新版 Jellyfin 可能会传递三位字母代码 (如 `zho`)。
- **解决**: 本插件最新版已修复该问题,支持 `zho``chi``zh-CN` 等多种映射。请确保您使用的是最新版本的 DLL。
### 2. 射手网 API 状态
- **现象**: 部分电影能搜到,部分完全搜不到。
- **原因**: 射手网 API 年久失修,对于无资源或 Hash 不匹配的文件API 可能返回异常内容(如乱码)。
- **表现**: 插件已增加防御性校验,若检测到 API 返回异常,会自动过滤并返回空结果以保证系统稳定。
### 3. 如何反馈问题
如果遇到确定有字幕但搜不到的情况,请查看服务器日志并提供以下关键信息:
- 搜索开始时的 `Target` 文件名。
- 接口返回的 `ResponseBody` 内容。
- 计算得到的 `FileHash` 值。
## Jellyfin 安装 (推荐)
Jellyfin 用户可以通过添加插件存储库实现一键安装和自动更新:
1. 打开 Jellyfin 控制台 -> **插件** -> **存储库**
2. 点击添加,输入名称 (如 MeiamSub) 和以下 URL
```
https://github.com/91270/MeiamSubtitles.Release/raw/main/Plugin/manifest-stable.json
```
3. 保存后在插件目录中找到 **MeiamSub.Thunder** 和 **MeiamSub.Shooter** 进行安装。
4. 重启 Jellyfin 服务。
### 手动安装 (Emby / 通用)
将下载的 `.dll` 文件复制到服务器的插件目录,然后重启服务。
#### Windows
```bash
复制插件文件到 Emby-Server\Programdata\Plugins\
复制插件文件到 Emby-Server\System\Plugins\
重启服务
# 路径可能因安装方式不同而异
Emby-Server\Programdata\Plugins\
# 或
Emby-Server\System\Plugins\
```
### LINUX
#### Linux / Docker
```bash
复制插件文件到 /opt/emby-server/system/plugins
复制插件文件到 /var/lib/emby/plugins
重启服务
# 常见路径
/opt/emby-server/system/plugins
# 或
/var/lib/emby/plugins
```
&nbsp;
#### 群晖 (Synology)
### 群晖
```bash
复制插件文件到 /var/packages/EmbyServer/var/plugins
复制插件文件到 /var/packages/EmbyServer/target/system/plugins
重启服务
/var/packages/EmbyServer/var/plugins
# 或
/var/packages/EmbyServer/target/system/plugins
```
### 威联通
#### 威联通 (QNAP)
```bash
# 其中`CACHEDEV{num}_DATA`的名称取决于你的qpkg安装位置
复制插件文件到 /share/CACHEDEV1_DATA/.qpkg/EmbyServer/programdata/plugins
复制插件文件到 /share/CACHEDEV1_DATA/.qpkg/EmbyServer/system/plugins
重启服务
/share/CACHEDEV1_DATA/.qpkg/EmbyServer/programdata/plugins
/share/CACHEDEV1_DATA/.qpkg/EmbyServer/system/plugins
```
&nbsp;
## 贡献
贡献的最简单的方法之一就是是参与讨论和讨论问题issue。你也可以通过提交 Pull Request 代码变更作出贡献。
欢迎提交 Issue 反馈问题,或提交 Pull Request 贡献代码
* **开发分支**: `master`
* **代码风格**: 请遵循现有的 C# 代码风格,异步方法请使用 `Async` 后缀。
## 致谢
[Emby.Subtitle.Subscene](https://github.com/nRafinia/Emby.Subtitle.Subscene)
[Emby.Subtitle.Subscene](https://github.com/nRafinia/Emby.Subtitle.Subscene)