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