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 { /// /// 迅雷看看字幕提供程序 /// 负责与迅雷 API 进行交互,通过 CID (Content ID) 匹配并下载字幕。 /// 修改人: Meiam /// 修改时间: 2025-12-22 /// public class ThunderProvider : ISubtitleProvider, IHasOrder { #region 变量声明 public const string ASS = "ass"; public const string SSA = "ssa"; public const string SRT = "srt"; private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; private static readonly JsonSerializerOptions _deserializeOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; public int Order => 100; public string Name => "MeiamSub.Thunder"; /// /// 支持电影、剧集 /// public IEnumerable SupportedMediaTypes => new[] { VideoContentType.Movie, VideoContentType.Episode }; #endregion #region 构造函数 public ThunderProvider(ILogger logger, IHttpClientFactory httpClientFactory) { _logger = logger; _httpClientFactory = httpClientFactory; _logger.LogInformation($"{Name} Init"); } #endregion #region 查询字幕 /// /// 搜索字幕 (ISubtitleProvider 接口实现) /// 根据媒体信息请求字幕列表。 /// /// 包含媒体路径、语言等信息的搜索请求对象 /// 取消令牌 /// 远程字幕信息列表 public async Task> Search(SubtitleSearchRequest request, CancellationToken cancellationToken) { _logger.LogInformation("DEBUG: Received Search request for " + (request?.MediaPath ?? "NULL")); var subtitles = await SearchSubtitlesAsync(request); return subtitles; } /// /// 查询字幕 /// /// /// private async Task> 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(); } 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(); } if (string.IsNullOrEmpty(request.MediaPath)) { _logger.LogInformation(Name + " Search | Summary -> MediaPath is empty, skip search."); return Array.Empty(); } 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(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(); 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(); } #endregion #region 下载字幕 /// /// 获取字幕内容 (ISubtitleProvider 接口实现) /// 根据字幕 ID 下载具体的字幕文件流。 /// /// 字幕唯一标识符 (Base64 编码的 JSON 数据) /// 取消令牌 /// 包含字幕流的响应对象 public async Task GetSubtitles(string id, CancellationToken cancellationToken) { _logger.LogInformation($"{Name} DownloadSub | Request -> {id}"); return await DownloadSubAsync(id); } /// /// 下载字幕 /// /// /// private async Task DownloadSubAsync(string info) { // 修改人: Meiam // 修改时间: 2025-12-22 // 备注: 增加异常处理 try { var downloadSub = JsonSerializer.Deserialize(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 内部方法 /// /// Base64 加密 /// /// 明文 /// public static string Base64Encode(string plainText) { var plainTextBytes = Encoding.UTF8.GetBytes(plainText); return Convert.ToBase64String(plainTextBytes); } /// /// Base64 解密 /// /// /// public static string Base64Decode(string base64EncodedData) { var base64EncodedBytes = Convert.FromBase64String(base64EncodedData); return Encoding.UTF8.GetString(base64EncodedBytes); } /// /// 提取格式化字幕类型 /// /// /// protected string ExtractFormat(string text) { if (string.IsNullOrEmpty(text)) { return null; } text = text.ToLower(); if (text.Contains(ASS)) return ASS; if (text.Contains(SSA)) return SSA; if (text.Contains(SRT)) return SRT; return null; } /// /// 规范化语言代码 /// /// /// private static string NormalizeLanguage(string language) { if (string.IsNullOrEmpty(language)) return language; if (language.Equals("zh-CN", StringComparison.OrdinalIgnoreCase) || language.Equals("zh-TW", StringComparison.OrdinalIgnoreCase) || language.Equals("zh-HK", StringComparison.OrdinalIgnoreCase) || language.Equals("zh", StringComparison.OrdinalIgnoreCase) || language.Equals("zho", StringComparison.OrdinalIgnoreCase) || language.Equals("chi", StringComparison.OrdinalIgnoreCase)) { return "chi"; } if (language.Equals("en", StringComparison.OrdinalIgnoreCase) || language.Equals("eng", StringComparison.OrdinalIgnoreCase)) { return "eng"; } return language; } /// /// 异步计算文件 CID (迅雷专用算法) /// 修改人: Meiam /// 修改时间: 2025-12-22 /// 备注: 采用异步 I/O 读取文件特定位置的数据块进行 SHA1 计算。 /// /// 文件路径 /// 计算得到的 CID 字符串 private async Task 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 } }