mirror of
https://github.com/91270/MeiamSubtitles.git
synced 2026-02-03 02:24:16 +08:00
Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
856a6777c7 | ||
|
|
60ff15c57a | ||
|
|
0761c11a69 | ||
|
|
72ca0c5c39 | ||
|
|
2c02096298 | ||
|
|
2e622cda22 | ||
|
|
df8ca3cbd7 | ||
|
|
2b4068e8a9 | ||
|
|
35ea11883f | ||
|
|
8f79853e00 | ||
|
|
e7d1629166 | ||
|
|
b88712ead1 | ||
|
|
01c6911855 | ||
|
|
68f4130a30 | ||
|
|
41c62c7016 | ||
|
|
96f93818d0 | ||
|
|
f48fc0910b | ||
|
|
6743851405 | ||
|
|
08fbdfb034 | ||
|
|
c833ca4eaf | ||
|
|
5cd133bc07 | ||
|
|
fe089c6cb5 | ||
|
|
5fa9433908 | ||
|
|
7e960e028c | ||
|
|
ffbe5ce39b | ||
|
|
8000919485 | ||
|
|
8095bb9fe4 | ||
|
|
2f80603f99 | ||
|
|
bb8135590f | ||
|
|
f82702f844 | ||
|
|
ded87375e1 | ||
|
|
706971992d | ||
|
|
bbcfd01580 | ||
|
|
008383cf1e | ||
|
|
a6feb50e45 | ||
|
|
0ef2a92705 | ||
|
|
af2d333106 | ||
|
|
630147e853 | ||
|
|
d05afc5ffd | ||
|
|
237839efea | ||
|
|
0e5a5d1b0d | ||
|
|
8bebe5ba5b | ||
|
|
0c5a3656c8 | ||
|
|
339b05b763 | ||
|
|
54b13d647d | ||
|
|
2e06131f8d | ||
|
|
8f69a7ffca | ||
|
|
bf178f7cef | ||
|
|
39aafddd23 | ||
|
|
f8854e2a85 | ||
|
|
d63de08b8e | ||
|
|
ac28e193e3 | ||
|
|
f796c05f99 | ||
|
|
ddeb8e0c14 | ||
|
|
7a0fb35fc5 | ||
|
|
74e576de93 | ||
|
|
bc72ca4ab0 | ||
|
|
2d4940156d | ||
|
|
7da60776cb | ||
|
|
03ce0a0dda | ||
|
|
492294941f | ||
|
|
18e00d72e2 |
BIN
.gitignore
vendored
BIN
.gitignore
vendored
Binary file not shown.
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyVersion>1.0.2.0</AssemblyVersion>
|
||||
<FileVersion>1.0.2.0</FileVersion>
|
||||
<Version>1.0.2</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.6.0.50" />
|
||||
<PackageReference Include="MediaBrowser.Server.Core" Version="4.6.0.50" />
|
||||
<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
" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,39 +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 => 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);
|
||||
|
||||
@@ -74,77 +79,116 @@ namespace Emby.MeiamSub.Shooter
|
||||
/// <returns></returns>
|
||||
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request)
|
||||
{
|
||||
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>();
|
||||
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,
|
||||
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");
|
||||
_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.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,17 +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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
@@ -174,33 +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
|
||||
// 备注: 增加异常处理
|
||||
|
||||
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)
|
||||
{
|
||||
|
||||
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();
|
||||
@@ -238,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)
|
||||
@@ -266,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
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyVersion>1.0.2.0</AssemblyVersion>
|
||||
<FileVersion>1.0.2.0</FileVersion>
|
||||
<Version>1.0.2</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.6.0.50" />
|
||||
<PackageReference Include="MediaBrowser.Server.Core" Version="4.6.0.50" />
|
||||
<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
" />
|
||||
</Target>
|
||||
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 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; }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
using Emby.MeiamSub.Thunder.Model;
|
||||
using Emby.MeiamSub.Thunder.Model;
|
||||
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,39 +35,48 @@ 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) }");
|
||||
_logger.Info("{0} Search | SubtitleSearchRequest -> {1}", new object[2] { Name, _jsonSerializer.SerializeToString(request) });
|
||||
|
||||
var subtitles = await SearchSubtitlesAsync(request);
|
||||
|
||||
@@ -74,59 +90,113 @@ namespace Emby.MeiamSub.Thunder
|
||||
/// <returns></returns>
|
||||
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request)
|
||||
{
|
||||
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 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={MovieName}",
|
||||
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.Debug($"MeiamSub.Thunder 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,
|
||||
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 = 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.Debug($"MeiamSub.Thunder Search | Summary -> Get 0 Subtitles");
|
||||
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
@@ -134,17 +204,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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
@@ -156,30 +224,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
|
||||
// 备注: 增加异常处理
|
||||
|
||||
_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)
|
||||
{
|
||||
|
||||
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();
|
||||
@@ -217,53 +302,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace Jellyfin.MeiamSub.Shooter.Configuration
|
||||
{
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
}
|
||||
}
|
||||
28
Jellyfin.MeiamSub.Shooter/Jellyfin.MeiamSub.Shooter.csproj
Normal file
28
Jellyfin.MeiamSub.Shooter/Jellyfin.MeiamSub.Shooter.csproj
Normal file
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<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'">
|
||||
<OutputPath>..\Release</OutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.11.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="thumb.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
|
||||
<Exec Command="Copy $(TargetDir)$(TargetFileName) $(SolutionDir)$(ConfigurationName)\$(TargetFileName) /y
" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
15
Jellyfin.MeiamSub.Shooter/Model/DownloadSubInfo.cs
Normal file
15
Jellyfin.MeiamSub.Shooter/Model/DownloadSubInfo.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
19
Jellyfin.MeiamSub.Shooter/Model/SubtitleResponseRoot.cs
Normal file
19
Jellyfin.MeiamSub.Shooter/Model/SubtitleResponseRoot.cs
Normal 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; }
|
||||
}
|
||||
|
||||
}
|
||||
41
Jellyfin.MeiamSub.Shooter/Plugin.cs
Normal file
41
Jellyfin.MeiamSub.Shooter/Plugin.cs
Normal 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 => "Download subtitles from Shooter";
|
||||
|
||||
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
public static Plugin Instance { get; private set; }
|
||||
|
||||
}
|
||||
}
|
||||
38
Jellyfin.MeiamSub.Shooter/PluginServiceRegistrator.cs
Normal file
38
Jellyfin.MeiamSub.Shooter/PluginServiceRegistrator.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
445
Jellyfin.MeiamSub.Shooter/ShooterProvider.cs
Normal file
445
Jellyfin.MeiamSub.Shooter/ShooterProvider.cs
Normal file
@@ -0,0 +1,445 @@
|
||||
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);
|
||||
|
||||
return subtitles;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询字幕
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <returns></returns>
|
||||
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request)
|
||||
{
|
||||
// 修改人: 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>();
|
||||
}
|
||||
|
||||
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("["))
|
||||
|
||||
|
||||
|
||||
{
|
||||
|
||||
|
||||
|
||||
_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);
|
||||
|
||||
_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">字幕唯一标识符 (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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 下载字幕
|
||||
/// </summary>
|
||||
/// <param name="info"></param>
|
||||
/// <returns></returns>
|
||||
private async Task<SubtitleResponse> DownloadSubAsync(string info)
|
||||
{
|
||||
// 修改人: 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);
|
||||
|
||||
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();
|
||||
|
||||
}
|
||||
#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;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
// 修改人: 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);
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
12
Jellyfin.MeiamSub.Shooter/meta.json
Normal file
12
Jellyfin.MeiamSub.Shooter/meta.json
Normal 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"
|
||||
}
|
||||
BIN
Jellyfin.MeiamSub.Shooter/thumb.png
Normal file
BIN
Jellyfin.MeiamSub.Shooter/thumb.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,8 @@
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace Jellyfin.MeiamSub.Thunder.Configuration
|
||||
{
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
}
|
||||
}
|
||||
28
Jellyfin.MeiamSub.Thunder/Jellyfin.MeiamSub.Thunder.csproj
Normal file
28
Jellyfin.MeiamSub.Thunder/Jellyfin.MeiamSub.Thunder.csproj
Normal file
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Version>1.0.13.0</Version>
|
||||
<AssemblyVersion>1.0.13.0</AssemblyVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<OutputPath>..\Release</OutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.11.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="thumb.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
|
||||
<Exec Command="Copy $(TargetDir)$(TargetFileName) $(SolutionDir)$(ConfigurationName)\$(TargetFileName) /y
" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
14
Jellyfin.MeiamSub.Thunder/Model/DownloadSubInfo.cs
Normal file
14
Jellyfin.MeiamSub.Thunder/Model/DownloadSubInfo.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
29
Jellyfin.MeiamSub.Thunder/Model/SubtitleResponseRoot.cs
Normal file
29
Jellyfin.MeiamSub.Thunder/Model/SubtitleResponseRoot.cs
Normal 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 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; }
|
||||
}
|
||||
}
|
||||
40
Jellyfin.MeiamSub.Thunder/Plugin.cs
Normal file
40
Jellyfin.MeiamSub.Thunder/Plugin.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using Jellyfin.MeiamSub.Thunder.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
using System;
|
||||
|
||||
namespace Jellyfin.MeiamSub.Thunder
|
||||
{
|
||||
/// <summary>
|
||||
/// 插件入口
|
||||
/// <para>修改人: Meiam</para>
|
||||
/// <para>修改时间: 2025-12-22</para>
|
||||
/// </summary>
|
||||
public class Plugin : BasePlugin<PluginConfiguration>
|
||||
{
|
||||
/// <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; }
|
||||
}
|
||||
}
|
||||
38
Jellyfin.MeiamSub.Thunder/PluginServiceRegistrator.cs
Normal file
38
Jellyfin.MeiamSub.Thunder/PluginServiceRegistrator.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
378
Jellyfin.MeiamSub.Thunder/ThunderProvider.cs
Normal file
378
Jellyfin.MeiamSub.Thunder/ThunderProvider.cs
Normal file
@@ -0,0 +1,378 @@
|
||||
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;
|
||||
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)
|
||||
{
|
||||
_logger.LogInformation("DEBUG: Received Search request for " + (request?.MediaPath ?? "NULL"));
|
||||
|
||||
var subtitles = await SearchSubtitlesAsync(request);
|
||||
|
||||
return subtitles;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询字幕
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <returns></returns>
|
||||
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request)
|
||||
{
|
||||
// 修改人: Meiam
|
||||
// 修改时间: 2025-12-22
|
||||
// 备注: 增加极致探测日志
|
||||
|
||||
_logger.LogInformation("DEBUG: Entering SearchSubtitlesAsync (Thunder)");
|
||||
|
||||
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")
|
||||
{
|
||||
_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 | Response -> {JsonSerializer.Serialize(subtitleResponse)}");
|
||||
|
||||
var subtitles = subtitleResponse.Data.Where(m => !string.IsNullOrEmpty(m.Name));
|
||||
|
||||
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 = 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");
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 下载字幕
|
||||
/// </summary>
|
||||
/// <param name="info"></param>
|
||||
/// <returns></returns>
|
||||
private async Task<SubtitleResponse> DownloadSubAsync(string info)
|
||||
{
|
||||
// 修改人: 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);
|
||||
|
||||
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();
|
||||
|
||||
}
|
||||
#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;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
// 修改人: 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
12
Jellyfin.MeiamSub.Thunder/meta.json
Normal file
12
Jellyfin.MeiamSub.Thunder/meta.json
Normal 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"
|
||||
}
|
||||
BIN
Jellyfin.MeiamSub.Thunder/thumb.png
Normal file
BIN
Jellyfin.MeiamSub.Thunder/thumb.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
49
MeiamSubtitles.sln
Normal file
49
MeiamSubtitles.sln
Normal file
@@ -0,0 +1,49 @@
|
||||
|
||||
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("{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
|
||||
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
|
||||
{8F77E155-9A91-4882-82E8-E8D69FECD246}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{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
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {AC5A2964-C7C8-419B-A8DD-63424233E6AA}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
120
README.md
120
README.md
@@ -1,66 +1,106 @@
|
||||
# Emby.MeiamSub
|
||||
Emby 中文字幕插件 ,支持 迅雷影音、射手网、 精准匹配,自动下载
|
||||
# 🎬 MeiamSubtitles
|
||||
|
||||
**MeiamSubtitles** 是一款专为 **Emby** 和 **Jellyfin** 媒体服务器打造的中文字幕下载插件。它集成了 **迅雷影音** 与 **射手网** 的强大搜索能力,支持精准的视频哈希(Hash)匹配,让您的媒体库自动补全高质量字幕。
|
||||
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](https://github.com/91270/Emby.MeiamSub)
|
||||
[](https://github.com/91270/Emby.MeiamSub/fork)
|
||||
[](https://www.592.la/)
|
||||
---
|
||||
|
||||
<p align="left">
|
||||
<img src="https://img.shields.io/badge/.NET-Standard%202.1%20%7C%209.0-blueviolet.svg" alt=".NET Status">
|
||||
<img src="https://img.shields.io/badge/Platform-Linux%20%7C%20Win%20%7C%20OSX-brightgreen.svg" alt="Platform">
|
||||
<img src="https://img.shields.io/badge/license-Apache%202-blue" alt="LICENSE">
|
||||
<a href="https://github.com/91270/Emby.MeiamSub"><img src="https://img.shields.io/github/stars/91270/Emby.MeiamSub?label=Star%20this%20repo" alt="Star"></a>
|
||||
<a href="https://www.592.la/"><img src="https://img.shields.io/badge/博客-Meiam's%20Home-brightgreen.svg" alt="博客"></a>
|
||||
</p>
|
||||
|
||||
## 📣 广告时间
|
||||
|
||||
|
||||
> **搬瓦工 $99 年付**:建站神器重出江湖,THE PLAN V1 传家宝套餐,18机房随意切换。
|
||||
> **循环优惠码**:`BWHCCNCXVV` (6.77%)
|
||||
> **[直达通道 (传家宝套餐)](https://bwh88.net/aff.php?aff=117&pid=87)**
|
||||
|
||||
## 给个星星! ⭐️
|
||||
如果你喜欢这个项目或者它帮助你, 请给 Star~(辛苦咯)
|
||||
## ✨ 核心特性
|
||||
|
||||
- **🚀 精准匹配**: 支持迅雷看看 (CID) 和射手网 (Hash) 双重校验逻辑,确保字幕与视频内容完美同步。
|
||||
- **⚡ 极致性能**: 核心采样算法全面采用**异步 I/O (Async/Await)** 模式,在大规模媒体库扫描时不会阻塞服务器线程。
|
||||
- **🌐 广泛兼容**: 深度适配 **Jellyfin 10.11+** 及 **Emby v4.9+**,支持 `zho`、`chi` 等多种国际化语言代码映射。
|
||||
- **🛡️ 稳定可靠**: 针对射手网 API 的老化问题增加了防御性校验,能有效处理乱码返回,保证系统长效稳定。
|
||||
- **📝 详尽日志**: 记录哈希计算耗时与接口原始响应,让问题排查不再是黑盒。
|
||||
|
||||
|
||||
## 📦 项目组件说明
|
||||
|
||||
## 功能介绍
|
||||
| 组件名称 | 适用平台 | 目标框架 | 说明 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Emby.MeiamSub.Thunder** | Emby | .NET Standard 2.1 | 迅雷看看字幕插件 |
|
||||
| **Emby.MeiamSub.Shooter** | Emby | .NET Standard 2.1 | 射手影音字幕插件 |
|
||||
| **Jellyfin.MeiamSub.Thunder** | Jellyfin | .NET 9.0 | 迅雷看看字幕插件 (现代 DI 架构) |
|
||||
| **Jellyfin.MeiamSub.Shooter** | Jellyfin | .NET 9.0 | 射手影音字幕插件 (现代 DI 架构) |
|
||||
| **Emby.MeiamSub.DevTool** | 开发调试 | .NET 8.0 | 哈希算法测试与 API 模拟工具 |
|
||||
|
||||
---
|
||||
|
||||
- [x] 支持 迅雷看看 字幕下载 Hash匹配
|
||||
- [x] 支持 射手影音 字幕下载 Hash匹配
|
||||
## 🚀 快速安装
|
||||
|
||||
### 第一步:获取插件
|
||||
前往 [GitHub Releases](https://github.com/91270/Emby.MeiamSub/releases) 下载最新版本的发布包。
|
||||
|
||||
## 项目说明
|
||||
> **🔔 推荐建议**:在媒体库设置中**不勾选**本插件作为默认自动下载器。建议仅在手动“搜索字幕”时使用,以获得更精准的人工筛选体验。
|
||||
|
||||
| # | 模块功能 | 项目文件 | 说明
|
||||
|---|-------------------------------|-------------------------------|-------------------------------
|
||||
| 1 | 开发程序 | Emby.MeiamSub.DevTool | 项目开发测试调试使用
|
||||
| 2 | 字幕插件 | Emby.MeiamSub.Thunder | 迅雷看看字幕插件
|
||||
| 3 | 字幕插件 | Emby.MeiamSub.Shooter | 射手影音字幕插件
|
||||
### 第二步:部署插件
|
||||
|
||||
#### 🔹 方式 A:Jellyfin 存储库安装 (强烈推荐)
|
||||
Jellyfin 用户可直接添加官方存储库,实现一键安装与自动更新:
|
||||
1. 控制台 -> **插件** -> **存储库** -> 点击“添加”。
|
||||
2. 输入名称 `MeiamSub` 和 URL:
|
||||
`https://github.com/91270/MeiamSubtitles.Release/raw/main/Plugin/manifest-stable.json`
|
||||
3. 在“目录”中找到插件并安装,重启服务即可。
|
||||
|
||||
#### 🔹 方式 B:手动安装 (Emby/通用)
|
||||
将下载的 `.dll` 文件(Jellyfin 用户请下载 `.zip` 并解压完整目录)放入服务器的 `plugins` 文件夹:
|
||||
|
||||
## 使用插件
|
||||
- **Windows**: `AppData\Local\jellyfin\plugins` 或 `Emby-Server\programdata\plugins`
|
||||
- **Linux/Docker**: `/config/plugins` 或 `/var/lib/emby/plugins`
|
||||
- **群晖/威联通**: 对应套件安装目录下的 `plugins` 文件夹
|
||||
|
||||
首先下载已编译好的插件 [LINK](https://github.com/91270/Emby.MeiamSub/releases)
|
||||
---
|
||||
|
||||
### WINDOWS
|
||||
```bash
|
||||
复制插件文件到 Emby-Server\Programdata\Plugins\
|
||||
复制插件文件到 Emby-Server\System\Plugins\
|
||||
重启服务
|
||||
```
|
||||
## ❓ 常见问题排查 (FAQ)
|
||||
|
||||
### LINUX
|
||||
```bash
|
||||
复制插件文件到 /opt/emby-server/system/plugins
|
||||
复制插件文件到 /var/lib/emby/plugins
|
||||
重启服务
|
||||
```
|
||||
<details>
|
||||
<summary><b>1. 为什么在 Jellyfin 10.11+ 中搜不到字幕?</b></summary>
|
||||
新版 Jellyfin 采用了三位字母的语言代码(如 <code>zho</code>)。请确保您已升级至本插件的 <b>v1.0.13.0</b> 或更高版本,该版本已完美解决语言映射兼容性。
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>2. 为什么射手网有时候返回结果为空?</b></summary>
|
||||
由于射手网 API 维护状态不佳,对于部分冷门资源或 Hash 不匹配的文件,API 可能会返回非法数据。插件目前已增加防御逻辑,会自动忽略这些无效返回以保护服务器稳定。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>3. 安装本插件后会影响 Open Subtitles 吗?</b></summary>
|
||||
不会。本插件已将优先级 (Order) 调整为 100(低优先级),并在代码层面优化了并发逻辑,确保官方插件能优先获取请求机会。
|
||||
</details>
|
||||
|
||||
## 贡献
|
||||
<details>
|
||||
<summary><b>4. 如何提供有效的错误反馈?</b></summary>
|
||||
如果确定有字幕但搜不到,请在 Issue 中提供日志里的 <code>Target</code> 文件名、计算出的 <code>FileHash</code> 以及 <code>ResponseBody</code> 内容。
|
||||
</details>
|
||||
|
||||
贡献的最简单的方法之一就是是参与讨论和讨论问题(issue)。你也可以通过提交的 Pull Request 代码变更作出贡献。
|
||||
---
|
||||
|
||||
## 致谢
|
||||
## 🤝 贡献与感谢
|
||||
|
||||
[Emby.Subtitle.Subscene](https://github.com/nRafinia/Emby.Subtitle.Subscene)
|
||||
欢迎通过提交 Issue 或 Pull Request 来完善本项目。
|
||||
|
||||
- **开发守则**: 遵循异步命名规范,所有修改请标注 `修改人: Meiam`。
|
||||
- **致谢**: 感谢 [Emby.Subtitle.Subscene](https://github.com/nRafinia/Emby.Subtitle.Subscene) 提供的灵感与参考。
|
||||
|
||||
---
|
||||
|
||||
## ⭐️ 给个星星
|
||||
|
||||
如果你喜欢这个项目,请给一个 **Star**!这对我非常重要。
|
||||
|
||||
如果你有稳定的 Google Drive 团队盘资源可供媒体库插件测试,欢迎联系:`91270#QQ.COM`
|
||||
|
||||
---
|
||||
*Powered by Meiam*
|
||||
|
||||
Reference in New Issue
Block a user