mirror of
https://github.com/91270/MeiamSubtitles.git
synced 2026-11-04 14:34:12 +08:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5176b552e2 | ||
|
|
66a132ceb3 | ||
|
|
c40d83eb8d | ||
|
|
f811632e8a | ||
|
|
eb44362717 | ||
|
|
9455450cf2 | ||
|
|
c473839f4d | ||
|
|
46bc076b8f | ||
|
|
7ec1583bd0 | ||
|
|
bf6304df96 | ||
|
|
87a5c361b8 | ||
|
|
dba34a6231 | ||
|
|
1fe2a70221 | ||
|
|
5c309f44ee | ||
|
|
d9407006df | ||
|
|
786cb82868 | ||
|
|
38a46d2f53 | ||
|
|
220fe5459d | ||
|
|
66ed8ff49c | ||
|
|
c4adc97bbf | ||
|
|
ba40c107cf | ||
|
|
58448f73db | ||
|
|
423021ee32 | ||
|
|
74bba07091 | ||
|
|
eb3268de44 | ||
|
|
856a6777c7 | ||
|
|
60ff15c57a | ||
|
|
0761c11a69 | ||
|
|
72ca0c5c39 | ||
|
|
2c02096298 | ||
|
|
2e622cda22 | ||
|
|
df8ca3cbd7 | ||
|
|
2b4068e8a9 | ||
|
|
35ea11883f | ||
|
|
8f79853e00 | ||
|
|
e7d1629166 | ||
|
|
b88712ead1 | ||
|
|
01c6911855 | ||
|
|
68f4130a30 | ||
|
|
41c62c7016 | ||
|
|
96f93818d0 | ||
|
|
f48fc0910b | ||
|
|
6743851405 | ||
|
|
08fbdfb034 | ||
|
|
c833ca4eaf | ||
|
|
5cd133bc07 | ||
|
|
fe089c6cb5 |
11
.gitignore
vendored
11
.gitignore
vendored
@@ -360,4 +360,13 @@ MigrationBackup/
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
FodyWeavers.xsd
|
||||
|
||||
TestServer/
|
||||
|
||||
.agent/
|
||||
|
||||
GEMINI.md
|
||||
|
||||
*.bat
|
||||
*.md
|
||||
|
||||
427
Emby.MeiamSub.Assrt/AssrtProvider.cs
Normal file
427
Emby.MeiamSub.Assrt/AssrtProvider.cs
Normal file
@@ -0,0 +1,427 @@
|
||||
using Emby.MeiamSub.Assrt.Model;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Controller.Subtitles;
|
||||
using MediaBrowser.Model.Logging;
|
||||
using MediaBrowser.Model.Providers;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using SharpCompress.Archives;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Emby.MeiamSub.Assrt
|
||||
{
|
||||
/// <summary>
|
||||
/// Assrt (伪射手) 字幕提供程序 (Emby 适配版)
|
||||
/// </summary>
|
||||
public class AssrtProvider : ISubtitleProvider, IHasOrder
|
||||
{
|
||||
private const string ApiBaseUrl = "https://api.assrt.net/v1";
|
||||
|
||||
public const string ASS = "ass";
|
||||
public const string SSA = "ssa";
|
||||
public const string SRT = "srt";
|
||||
|
||||
protected readonly ILogger _logger;
|
||||
private readonly IJsonSerializer _jsonSerializer;
|
||||
private readonly IHttpClient _httpClient;
|
||||
|
||||
public int Order => 90; // 优先级高于老旧源
|
||||
|
||||
public string Name => "MeiamSub.Assrt";
|
||||
|
||||
public IEnumerable<VideoContentType> SupportedMediaTypes => new[] { VideoContentType.Movie, VideoContentType.Episode };
|
||||
|
||||
public AssrtProvider(ILogManager logManager, IJsonSerializer jsonSerializer, IHttpClient httpClient)
|
||||
{
|
||||
_logger = logManager.GetLogger(GetType().Name);
|
||||
_jsonSerializer = jsonSerializer;
|
||||
_httpClient = httpClient;
|
||||
_logger.Info($"{Name} Init");
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<RemoteSubtitleInfo>> Search(SubtitleSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.Info($"{Name} Search | Received request for: {(request?.MediaPath ?? "NULL")}");
|
||||
|
||||
try
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
var config = Plugin.Instance?.Options;
|
||||
if (config == null || string.IsNullOrWhiteSpace(config.AssrtApiToken))
|
||||
{
|
||||
_logger.Warn($"{Name} Search | AssrtApiToken is empty, skip search. Please configure the token in plugin settings.");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
var language = NormalizeLanguage(request.Language);
|
||||
if (language != "chi")
|
||||
{
|
||||
_logger.Info($"{Name} Search | Language '{request.Language}' not supported, skip search.");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
string queryText;
|
||||
if (config.EnableUseMetadata)
|
||||
{
|
||||
if (request.ContentType == VideoContentType.Episode)
|
||||
{
|
||||
queryText = $"{request.SeriesName} S{request.ParentIndexNumber:D2}E{request.IndexNumber:D2}";
|
||||
}
|
||||
else if (request.ContentType == VideoContentType.Movie)
|
||||
{
|
||||
queryText = request.Name;
|
||||
}
|
||||
else
|
||||
{
|
||||
queryText = !string.IsNullOrEmpty(request.MediaPath)
|
||||
? Path.GetFileNameWithoutExtension(request.MediaPath)
|
||||
: string.Empty;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
queryText = !string.IsNullOrEmpty(request.MediaPath)
|
||||
? Path.GetFileNameWithoutExtension(request.MediaPath)
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(queryText))
|
||||
{
|
||||
_logger.Warn($"{Name} Search | Query text is empty, skip search.");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
_logger.Info($"{Name} Search | Target -> '{queryText}' | Language -> '{language}'");
|
||||
|
||||
var searchUrl = $"{ApiBaseUrl}/sub/search?token={Uri.EscapeDataString(config.AssrtApiToken)}&q={Uri.EscapeDataString(queryText)}&is_file=1&cnt=15";
|
||||
var maskedSearchUrl = $"{ApiBaseUrl}/sub/search?token={MaskToken(config.AssrtApiToken)}&q={Uri.EscapeDataString(queryText)}&is_file=1&cnt=15";
|
||||
_logger.Info($"{Name} Search | Request Url -> {maskedSearchUrl}");
|
||||
|
||||
var options = new HttpRequestOptions
|
||||
{
|
||||
Url = searchUrl,
|
||||
UserAgent = Name,
|
||||
TimeoutMs = 20000,
|
||||
AcceptHeader = "application/json"
|
||||
};
|
||||
|
||||
var response = await _httpClient.GetResponse(options);
|
||||
if (response.StatusCode != HttpStatusCode.OK)
|
||||
{
|
||||
_logger.Error($"{Name} Search | API responded with status: {response.StatusCode}");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
string jsonString;
|
||||
using (var reader = new StreamReader(response.Content, Encoding.UTF8))
|
||||
{
|
||||
jsonString = await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
_logger.Info("{0} Search | Response -> {1}", Name, response.StatusCode);
|
||||
|
||||
var searchResponse = _jsonSerializer.DeserializeFromString<AssrtSearchResponse>(jsonString);
|
||||
|
||||
if (searchResponse == null || searchResponse.status != 0 || searchResponse.sub?.subs == null || !searchResponse.sub.subs.Any())
|
||||
{
|
||||
_logger.Warn($"{Name} Search | No subtitles found or API error. Request -> Target: '{queryText}', Language: '{language}', Url: '{maskedSearchUrl}' | Response -> Status: {searchResponse?.status}, Content: '{jsonString}'");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
var remoteSubtitles = new List<RemoteSubtitleInfo>();
|
||||
|
||||
foreach (var item in searchResponse.sub.subs)
|
||||
{
|
||||
if (item.id == null) continue;
|
||||
|
||||
var subtitleId = item.id.ToString();
|
||||
var format = ExtractFormat(item.subtype) ?? ExtractFormat(item.native_name) ?? SRT;
|
||||
|
||||
var langDesc = item.lang?.desc ?? "中文";
|
||||
|
||||
var downloadInfo = new AssrtDownloadInfo
|
||||
{
|
||||
Id = subtitleId,
|
||||
Format = format,
|
||||
Language = request.Language
|
||||
};
|
||||
|
||||
var encodedId = Base64Encode(_jsonSerializer.SerializeToString(downloadInfo));
|
||||
|
||||
remoteSubtitles.Add(new RemoteSubtitleInfo
|
||||
{
|
||||
Id = encodedId,
|
||||
Name = $"[MEIAMSUB] {item.native_name ?? item.videoname ?? subtitleId} | {langDesc} | 伪射手",
|
||||
Author = "Assrt",
|
||||
ProviderName = Name,
|
||||
Format = format,
|
||||
Comment = string.IsNullOrEmpty(item.videoname) ? $"格式: {format}" : $"适配视频: {item.videoname} (格式: {format})",
|
||||
IsHashMatch = false
|
||||
});
|
||||
}
|
||||
|
||||
_logger.Info($"{Name} Search | Summary -> Found {remoteSubtitles.Count} subtitles.");
|
||||
return remoteSubtitles;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"{Name} Search | Exception occurred: [{ex.GetType().Name}] {ex.Message}");
|
||||
}
|
||||
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
public async Task<SubtitleResponse> GetSubtitles(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.Info($"{Name} GetSubtitles | Requesting ID: {id}");
|
||||
AssrtDownloadInfo downloadInfo = null;
|
||||
|
||||
try
|
||||
{
|
||||
var decodedString = Base64Decode(id);
|
||||
downloadInfo = _jsonSerializer.DeserializeFromString<AssrtDownloadInfo>(decodedString);
|
||||
if (downloadInfo == null || string.IsNullOrEmpty(downloadInfo.Id))
|
||||
{
|
||||
return new SubtitleResponse { Language = "chi", Format = SRT, Stream = new MemoryStream() };
|
||||
}
|
||||
|
||||
var config = Plugin.Instance?.Options;
|
||||
if (config == null || string.IsNullOrWhiteSpace(config.AssrtApiToken))
|
||||
{
|
||||
_logger.Error($"{Name} GetSubtitles | AssrtApiToken is not configured.");
|
||||
return new SubtitleResponse { Language = downloadInfo.Language, Format = downloadInfo.Format, Stream = new MemoryStream() };
|
||||
}
|
||||
|
||||
// 1. 请求详情接口获取下载地址
|
||||
var detailUrl = $"{ApiBaseUrl}/sub/detail?token={Uri.EscapeDataString(config.AssrtApiToken)}&id={Uri.EscapeDataString(downloadInfo.Id)}";
|
||||
|
||||
var detailOptions = new HttpRequestOptions
|
||||
{
|
||||
Url = detailUrl,
|
||||
UserAgent = Name,
|
||||
TimeoutMs = 20000,
|
||||
AcceptHeader = "application/json"
|
||||
};
|
||||
|
||||
var detailResponseMsg = await _httpClient.GetResponse(detailOptions);
|
||||
if (detailResponseMsg.StatusCode != HttpStatusCode.OK)
|
||||
{
|
||||
_logger.Error($"{Name} GetSubtitles | Failed to fetch details. Status: {detailResponseMsg.StatusCode}");
|
||||
return new SubtitleResponse { Language = downloadInfo.Language, Format = downloadInfo.Format, Stream = new MemoryStream() };
|
||||
}
|
||||
|
||||
string detailJson;
|
||||
using (var reader = new StreamReader(detailResponseMsg.Content, Encoding.UTF8))
|
||||
{
|
||||
detailJson = await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
var detailResponse = _jsonSerializer.DeserializeFromString<AssrtDetailResponse>(detailJson);
|
||||
|
||||
if (detailResponse == null || detailResponse.status != 0)
|
||||
{
|
||||
_logger.Error($"{Name} GetSubtitles | Detail API error. Status: {detailResponse?.status}, Response: {detailJson}");
|
||||
return new SubtitleResponse { Language = downloadInfo.Language, Format = downloadInfo.Format, Stream = new MemoryStream() };
|
||||
}
|
||||
|
||||
var targetSub = detailResponse?.sub?.subs?.FirstOrDefault();
|
||||
if (targetSub == null || string.IsNullOrEmpty(targetSub.url))
|
||||
{
|
||||
_logger.Error($"{Name} GetSubtitles | Subtitle download URL not found in API response.");
|
||||
return new SubtitleResponse { Language = downloadInfo.Language, Format = downloadInfo.Format, Stream = new MemoryStream() };
|
||||
}
|
||||
|
||||
var downloadUrl = targetSub.url;
|
||||
var downloadFilename = targetSub.filename;
|
||||
|
||||
_logger.Info($"{Name} GetSubtitles | Downloading subtitle from: {downloadUrl}");
|
||||
|
||||
// 2. 下载字幕流 (手动接管重定向以确保每次重定向请求均带有正确的防盗链 Headers)
|
||||
var handler = new HttpClientHandler { AllowAutoRedirect = false };
|
||||
using var cleanHttpClient = new HttpClient(handler);
|
||||
|
||||
var currentUrl = downloadUrl;
|
||||
HttpResponseMessage downloadResponse = null;
|
||||
int redirectCount = 0;
|
||||
|
||||
while (redirectCount < 5)
|
||||
{
|
||||
var requestMsg = new HttpRequestMessage(HttpMethod.Get, currentUrl);
|
||||
requestMsg.Version = HttpVersion.Version11;
|
||||
requestMsg.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
|
||||
requestMsg.Headers.Add("Referer", "https://assrt.net/");
|
||||
|
||||
downloadResponse = await cleanHttpClient.SendAsync(requestMsg, cancellationToken);
|
||||
|
||||
if (downloadResponse.StatusCode == HttpStatusCode.Redirect ||
|
||||
downloadResponse.StatusCode == HttpStatusCode.MovedPermanently ||
|
||||
downloadResponse.StatusCode == HttpStatusCode.Found ||
|
||||
downloadResponse.StatusCode == HttpStatusCode.SeeOther ||
|
||||
(int)downloadResponse.StatusCode == 307 ||
|
||||
(int)downloadResponse.StatusCode == 308)
|
||||
{
|
||||
var location = downloadResponse.Headers.Location;
|
||||
if (location != null)
|
||||
{
|
||||
if (!location.IsAbsoluteUri)
|
||||
{
|
||||
location = new Uri(new Uri(currentUrl), location);
|
||||
}
|
||||
currentUrl = location.AbsoluteUri;
|
||||
redirectCount++;
|
||||
_logger.Info($"{Name} GetSubtitles | Redirecting to -> {currentUrl}");
|
||||
downloadResponse.Dispose();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (downloadResponse == null || !downloadResponse.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.Error($"{Name} GetSubtitles | Failed to download subtitle file. Status: {downloadResponse?.StatusCode}");
|
||||
return new SubtitleResponse { Language = downloadInfo.Language, Format = downloadInfo.Format, Stream = new MemoryStream() };
|
||||
}
|
||||
|
||||
var responseStream = await downloadResponse.Content.ReadAsStreamAsync();
|
||||
|
||||
// 3. 下载原始文件;若为压缩包,则从 ZIP/RAR/7z/TAR 等归档中提取字幕。
|
||||
var memoryStream = new MemoryStream();
|
||||
await responseStream.CopyToAsync(memoryStream);
|
||||
memoryStream.Position = 0;
|
||||
|
||||
var directFormat = ExtractFormat(downloadFilename);
|
||||
if (directFormat == null)
|
||||
{
|
||||
_logger.Info($"{Name} GetSubtitles | Archive detected, extracting: {downloadFilename}");
|
||||
try
|
||||
{
|
||||
using (var archive = ArchiveFactory.Open(memoryStream))
|
||||
{
|
||||
var entry = archive.Entries.FirstOrDefault(e =>
|
||||
!e.IsDirectory && ExtractFormat(e.Key) != null);
|
||||
|
||||
if (entry != null)
|
||||
{
|
||||
var extractedStream = new MemoryStream();
|
||||
using (var entryStream = entry.OpenEntryStream())
|
||||
{
|
||||
await entryStream.CopyToAsync(extractedStream);
|
||||
}
|
||||
extractedStream.Position = 0;
|
||||
var extFormat = ExtractFormat(entry.Key) ?? downloadInfo.Format;
|
||||
|
||||
_logger.Info($"{Name} GetSubtitles | Extracted file: '{entry.Key}' with format: {extFormat}");
|
||||
|
||||
return new SubtitleResponse
|
||||
{
|
||||
Language = downloadInfo.Language,
|
||||
IsForced = false,
|
||||
Format = extFormat,
|
||||
Stream = extractedStream
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Warn($"{Name} GetSubtitles | Archive contains no supported srt/ass/ssa subtitle.");
|
||||
return new SubtitleResponse { Language = downloadInfo.Language, Format = downloadInfo.Format, Stream = new MemoryStream() };
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"{Name} GetSubtitles | Failed to extract archive '{downloadFilename}': {ex.Message}");
|
||||
return new SubtitleResponse { Language = downloadInfo.Language, Format = downloadInfo.Format, Stream = new MemoryStream() };
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 非压缩的字幕文件直接返回。
|
||||
memoryStream.Position = 0;
|
||||
return new SubtitleResponse
|
||||
{
|
||||
Language = downloadInfo.Language,
|
||||
IsForced = false,
|
||||
Format = directFormat ?? downloadInfo.Format,
|
||||
Stream = memoryStream
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"{Name} GetSubtitles | Exception: {ex.Message}");
|
||||
}
|
||||
|
||||
return new SubtitleResponse { Language = downloadInfo?.Language ?? "chi", Format = downloadInfo?.Format ?? SRT, Stream = new MemoryStream() };
|
||||
}
|
||||
|
||||
#region 辅助工具函数
|
||||
|
||||
public static string Base64Encode(string plainText)
|
||||
{
|
||||
var plainTextBytes = Encoding.UTF8.GetBytes(plainText);
|
||||
return Convert.ToBase64String(plainTextBytes);
|
||||
}
|
||||
|
||||
public static string Base64Decode(string base64EncodedData)
|
||||
{
|
||||
var base64EncodedBytes = Convert.FromBase64String(base64EncodedData);
|
||||
return Encoding.UTF8.GetString(base64EncodedBytes);
|
||||
}
|
||||
|
||||
protected string ExtractFormat(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
text = text.ToLower();
|
||||
if (text.Contains(ASS)) return ASS;
|
||||
if (text.Contains(SSA)) return SSA;
|
||||
if (text.Contains(SRT)) return SRT;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeLanguage(string language)
|
||||
{
|
||||
if (string.IsNullOrEmpty(language)) return language;
|
||||
|
||||
if (language.Equals("zh-CN", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zh-TW", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zh-HK", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zh", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zho", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("chi", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "chi";
|
||||
}
|
||||
if (language.Equals("en", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("eng", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "eng";
|
||||
}
|
||||
return language;
|
||||
}
|
||||
|
||||
private static string MaskToken(string token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token)) return string.Empty;
|
||||
if (token.Length <= 8) return "***";
|
||||
return token.Substring(0, 4) + "***" + token.Substring(token.Length - 4);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
39
Emby.MeiamSub.Assrt/Emby.MeiamSub.Assrt.csproj
Normal file
39
Emby.MeiamSub.Assrt/Emby.MeiamSub.Assrt.csproj
Normal file
@@ -0,0 +1,39 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyVersion>1.0.17.1</AssemblyVersion>
|
||||
<FileVersion>1.0.17.1</FileVersion>
|
||||
<Version>1.0.17.1</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<!-- 已验证 Emby 4.8.10.0、4.9.5.0 和 4.10.0.40 均提供 SharpCompress 0.33.0。
|
||||
保持与宿主程序集版本一致,避免插件加载或解压失败。 -->
|
||||
<NoWarn>$(NoWarn);NU1902</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<OutputPath>..\Release</OutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="Thumb.png" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Thumb.png" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- 使用旧版宿主可解析的 SDK,避免插件要求 4.9.1.90 程序集。 -->
|
||||
<PackageReference Include="MediaBrowser.Common" Version="4.8.10" />
|
||||
<PackageReference Include="MediaBrowser.Server.Core" Version="4.8.10" />
|
||||
<PackageReference Include="SharpCompress" Version="0.33.0" ExcludeAssets="runtime" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
57
Emby.MeiamSub.Assrt/Model/AssrtModels.cs
Normal file
57
Emby.MeiamSub.Assrt/Model/AssrtModels.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Emby.MeiamSub.Assrt.Model
|
||||
{
|
||||
public class AssrtSearchResponse
|
||||
{
|
||||
public int status { get; set; }
|
||||
public AssrtSearchSubInfo sub { get; set; }
|
||||
}
|
||||
|
||||
public class AssrtSearchSubInfo
|
||||
{
|
||||
public string keyword { get; set; }
|
||||
public string result { get; set; }
|
||||
public List<AssrtSearchItem> subs { get; set; }
|
||||
}
|
||||
|
||||
public class AssrtSearchItem
|
||||
{
|
||||
public object id { get; set; }
|
||||
public string native_name { get; set; }
|
||||
public string videoname { get; set; }
|
||||
public string subtype { get; set; }
|
||||
public AssrtLanguageInfo lang { get; set; }
|
||||
}
|
||||
|
||||
public class AssrtLanguageInfo
|
||||
{
|
||||
public string desc { get; set; }
|
||||
public int lang { get; set; }
|
||||
}
|
||||
|
||||
public class AssrtDetailResponse
|
||||
{
|
||||
public int status { get; set; }
|
||||
public AssrtDetailSubInfo sub { get; set; }
|
||||
}
|
||||
|
||||
public class AssrtDetailSubInfo
|
||||
{
|
||||
public List<AssrtDetailItem> subs { get; set; }
|
||||
}
|
||||
|
||||
public class AssrtDetailItem
|
||||
{
|
||||
public object id { get; set; }
|
||||
public string filename { get; set; }
|
||||
public string url { get; set; }
|
||||
}
|
||||
|
||||
public class AssrtDownloadInfo
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Format { get; set; }
|
||||
public string Language { get; set; }
|
||||
}
|
||||
}
|
||||
92
Emby.MeiamSub.Assrt/Plugin.cs
Normal file
92
Emby.MeiamSub.Assrt/Plugin.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using Emby.Web.GenericEdit;
|
||||
using MediaBrowser.Common;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using MediaBrowser.Model.Drawing;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
|
||||
namespace Emby.MeiamSub.Assrt
|
||||
{
|
||||
/// <summary>
|
||||
/// Assrt 插件入口
|
||||
/// </summary>
|
||||
public class Plugin : BasePluginSimpleUI<PluginConfiguration>, IHasThumbImage
|
||||
{
|
||||
public Plugin(IApplicationPaths applicationPaths, IApplicationHost applicationHost) : base(applicationHost)
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 插件ID
|
||||
/// </summary>
|
||||
public override Guid Id => new Guid("D276A8D0-4B17-4EEF-94B6-61D646DE5A22");
|
||||
|
||||
/// <summary>
|
||||
/// 插件名称
|
||||
/// </summary>
|
||||
public override string Name => "MeiamSub.Assrt";
|
||||
|
||||
/// <summary>
|
||||
/// 插件描述
|
||||
/// </summary>
|
||||
public override string Description => "Download subtitles from Assrt (伪射手网)";
|
||||
|
||||
/// <summary>
|
||||
/// 缩略图格式化类型
|
||||
/// </summary>
|
||||
public ImageFormat ThumbImageFormat => ImageFormat.Gif;
|
||||
|
||||
/// <summary>
|
||||
/// 获取插件选项
|
||||
/// </summary>
|
||||
public PluginConfiguration Options => this.GetOptions();
|
||||
|
||||
public static Plugin Instance { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取插件缩略图资源流
|
||||
/// </summary>
|
||||
/// <returns>图片资源流,若不存在则返回 null</returns>
|
||||
public Stream GetThumbImage()
|
||||
{
|
||||
var type = GetType();
|
||||
var resourceName = $"{type.Namespace}.Thumb.png";
|
||||
var stream = type.Assembly.GetManifestResourceStream(resourceName);
|
||||
|
||||
if (stream == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 插件配置类
|
||||
/// </summary>
|
||||
public class PluginConfiguration : EditableOptionsBase
|
||||
{
|
||||
public override string EditorTitle => "MeiamSub Assrt Options";
|
||||
|
||||
[DisplayName("Assrt API Token")]
|
||||
[Description("请输入您在 assrt.net 注册获取的 32 位 API Token")]
|
||||
public string AssrtApiToken { get; set; }
|
||||
|
||||
[DisplayName("使用元数据搜索字幕")]
|
||||
[Description("勾选此项后,使用元数据中的剧集名称和季集编号搜索字幕,而非文件名。建议开启。")]
|
||||
public bool EnableUseMetadata { get; set; }
|
||||
|
||||
public PluginConfiguration()
|
||||
{
|
||||
AssrtApiToken = string.Empty;
|
||||
EnableUseMetadata = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
Emby.MeiamSub.Assrt/Thumb.png
Normal file
BIN
Emby.MeiamSub.Assrt/Thumb.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 256 KiB |
@@ -1,96 +1,219 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.Intrinsics.Arm;
|
||||
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 = SHA1.Create();
|
||||
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,9 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyVersion>1.0.11.0</AssemblyVersion>
|
||||
<FileVersion>1.0.11.0</FileVersion>
|
||||
<Version>1.0.11</Version>
|
||||
<AssemblyVersion>1.0.16.0</AssemblyVersion>
|
||||
<FileVersion>1.0.16.0</FileVersion>
|
||||
<Version>1.0.16.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
@@ -23,12 +23,12 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MediaBrowser.Common" Version="4.8.10" />
|
||||
<PackageReference Include="MediaBrowser.Server.Core" Version="4.8.10" />
|
||||
<PackageReference Include="MediaBrowser.Common" Version="4.9.1.90" />
|
||||
<PackageReference Include="MediaBrowser.Server.Core" Version="4.9.1.90" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
|
||||
<Exec Command="Copy $(TargetDir)$(TargetFileName) $(SolutionDir)$(ConfigurationName)\$(TargetFileName) /y
" />
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Model.Drawing;
|
||||
using System;
|
||||
@@ -31,7 +31,7 @@ namespace Emby.MeiamSub.Shooter
|
||||
/// <summary>
|
||||
/// 插件描述
|
||||
/// </summary>
|
||||
public override string Description => "Download subtitles from Shooter";
|
||||
public override string Description => "Deprecated: the legacy Shooter API rarely returns usable results. Use Assrt or Thunder instead.";
|
||||
|
||||
/// <summary>
|
||||
/// 缩略图格式化类型
|
||||
@@ -42,13 +42,27 @@ namespace Emby.MeiamSub.Shooter
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Emby.MeiamSub.Shooter.Model;
|
||||
using Emby.MeiamSub.Shooter.Model;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Controller.Subtitles;
|
||||
@@ -7,7 +7,9 @@ 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;
|
||||
@@ -18,7 +20,10 @@ using System.Web;
|
||||
namespace Emby.MeiamSub.Shooter
|
||||
{
|
||||
/// <summary>
|
||||
/// 迅雷字幕组件
|
||||
/// 射手网字幕提供程序
|
||||
/// 负责与射手网 API 进行交互,通过文件哈希匹配并下载字幕。
|
||||
/// <para>修改人: Meiam</para>
|
||||
/// <para>修改时间: 2025-12-22</para>
|
||||
/// </summary>
|
||||
public class ShooterProvider : ISubtitleProvider, IHasOrder
|
||||
{
|
||||
@@ -31,17 +36,17 @@ namespace Emby.MeiamSub.Shooter
|
||||
private readonly IJsonSerializer _jsonSerializer;
|
||||
private readonly IHttpClient _httpClient;
|
||||
|
||||
public int Order => 1;
|
||||
public int Order => 100;
|
||||
public string Name => "MeiamSub.Shooter";
|
||||
|
||||
/// <summary>
|
||||
/// 支持电影、剧集
|
||||
/// </summary>
|
||||
public IEnumerable<VideoContentType> SupportedMediaTypes => new List<VideoContentType>() { VideoContentType.Movie, VideoContentType.Episode };
|
||||
public IEnumerable<VideoContentType> SupportedMediaTypes => new[] { VideoContentType.Movie, VideoContentType.Episode };
|
||||
#endregion
|
||||
|
||||
#region 构造函数
|
||||
public ShooterProvider(ILogManager logManager, IJsonSerializer jsonSerializer,IHttpClient httpClient)
|
||||
public ShooterProvider(ILogManager logManager, IJsonSerializer jsonSerializer, IHttpClient httpClient)
|
||||
{
|
||||
_logger = logManager.GetLogger(GetType().Name);
|
||||
_jsonSerializer = jsonSerializer;
|
||||
@@ -53,16 +58,17 @@ namespace Emby.MeiamSub.Shooter
|
||||
#region 查询字幕
|
||||
|
||||
/// <summary>
|
||||
/// 查询请求
|
||||
/// 搜索字幕 (ISubtitleProvider 接口实现)
|
||||
/// 根据媒体信息请求字幕列表。
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
/// <param name="request">包含媒体路径、语言等信息的搜索请求对象</param>
|
||||
/// <param name="cancellationToken">取消令牌</param>
|
||||
/// <returns>远程字幕信息列表</returns>
|
||||
public async Task<IEnumerable<RemoteSubtitleInfo>> Search(SubtitleSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.Info("{0} Search | SubtitleSearchRequest -> {1}", new object[2] { Name , _jsonSerializer.SerializeToString(request) });
|
||||
_logger.Info("{0} Search | SubtitleSearchRequest -> {1}", new object[2] { Name, _jsonSerializer.SerializeToString(request) });
|
||||
|
||||
var subtitles = await SearchSubtitlesAsync(request);
|
||||
var subtitles = await SearchSubtitlesAsync(request, cancellationToken);
|
||||
|
||||
return subtitles;
|
||||
}
|
||||
@@ -72,84 +78,123 @@ namespace Emby.MeiamSub.Shooter
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <returns></returns>
|
||||
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request)
|
||||
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if(request.Language == "zh-CN" || request.Language == "zh-TW" || request.Language == "zh-HK"){
|
||||
request.Language = "chi";
|
||||
}
|
||||
if(request.Language == "en"){
|
||||
request.Language = "eng";
|
||||
}
|
||||
if (request.Language != "chi" && request.Language != "eng")
|
||||
// 修改人: Meiam
|
||||
// 修改时间: 2025-12-22
|
||||
// 备注: 增加异常处理,确保单个插件错误不影响系统整体运行
|
||||
|
||||
try
|
||||
{
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
var language = NormalizeLanguage(request.Language);
|
||||
|
||||
FileInfo fileInfo = new FileInfo(request.MediaPath);
|
||||
_logger.Info("{0} Search | Target -> {1} | Language -> {2}", Name, Path.GetFileName(request.MediaPath), language);
|
||||
|
||||
var hash = ComputeFileHash(fileInfo);
|
||||
|
||||
_logger.Info("{0} Search | FileHash -> {1}", new object[2] { Name, hash });
|
||||
|
||||
HttpRequestOptions options = new HttpRequestOptions
|
||||
{
|
||||
Url = $"https://www.shooter.cn/api/subapi.php",
|
||||
UserAgent = $"{Name}",
|
||||
TimeoutMs = 30000,
|
||||
AcceptHeader = "*/*",
|
||||
};
|
||||
|
||||
options.SetPostData(new Dictionary<string, string>
|
||||
{
|
||||
{ "filehash", HttpUtility.UrlEncode(hash)},
|
||||
{ "pathinfo", HttpUtility.UrlEncode(request.MediaPath)},
|
||||
{ "format", "json"},
|
||||
{ "lang",request.Language == "chi" ? "chn" : "eng"}
|
||||
});
|
||||
|
||||
_logger.Info("{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"))
|
||||
{
|
||||
var subtitleResponse = _jsonSerializer.DeserializeFromStream<List<SubtitleResponseRoot>>(response.Content);
|
||||
|
||||
if (subtitleResponse != null)
|
||||
if (language != "chi" && language != "eng")
|
||||
{
|
||||
_logger.Info("{0} Search | Response -> {1}", new object[2] { Name, _jsonSerializer.SerializeToString(subtitleResponse) });
|
||||
|
||||
var remoteSubtitleInfos = new List<RemoteSubtitleInfo>();
|
||||
|
||||
foreach(var subFileInfo in subtitleResponse)
|
||||
{
|
||||
foreach(var subFile in subFileInfo.Files)
|
||||
{
|
||||
remoteSubtitleInfos.Add(new RemoteSubtitleInfo()
|
||||
{
|
||||
Id = Base64Encode(_jsonSerializer.SerializeToString(new DownloadSubInfo
|
||||
{
|
||||
Url = subFile.Link,
|
||||
Format = subFile.Ext,
|
||||
Language = request.Language,
|
||||
IsForced = request.IsForced
|
||||
})),
|
||||
Name = $"[MEIAMSUB] { Path.GetFileName(request.MediaPath) } | {request.Language} | 射手",
|
||||
Author = "Meiam ",
|
||||
ProviderName = $"{Name}",
|
||||
Format = subFile.Ext,
|
||||
Comment = $"Format : { ExtractFormat(subFile.Ext)}",
|
||||
IsHashMatch = true
|
||||
});
|
||||
}
|
||||
}
|
||||
_logger.Info("{0} Search | Summary -> Get {1} Subtitles", new object[2] { Name, remoteSubtitleInfos.Count });
|
||||
|
||||
|
||||
return remoteSubtitleInfos;
|
||||
_logger.Info("{0} Search | Summary -> Language not supported, skip search.", Name);
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(request.MediaPath) ||
|
||||
request.MediaPath.Contains("://") ||
|
||||
request.MediaPath.StartsWith("smb://", StringComparison.OrdinalIgnoreCase) ||
|
||||
request.MediaPath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||
request.MediaPath.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.Warn("{0} Search | Network path detected, skip hash-based search: {1}", Name, request.MediaPath);
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
FileInfo fileInfo = new FileInfo(request.MediaPath);
|
||||
|
||||
var stopWatch = Stopwatch.StartNew();
|
||||
var hash = await ComputeFileHashAsync(fileInfo, cancellationToken);
|
||||
stopWatch.Stop();
|
||||
|
||||
_logger.Info("{0} Search | FileHash -> {1} (Took {2}ms)", new object[3] { Name, hash, stopWatch.ElapsedMilliseconds });
|
||||
|
||||
HttpRequestOptions options = new HttpRequestOptions
|
||||
{
|
||||
Url = $"https://www.shooter.cn/api/subapi.php",
|
||||
UserAgent = $"{Name}",
|
||||
TimeoutMs = 30000,
|
||||
AcceptHeader = "*/*",
|
||||
};
|
||||
|
||||
options.SetPostData(new Dictionary<string, string>
|
||||
{
|
||||
{ "filehash", HttpUtility.UrlEncode(hash)},
|
||||
{ "pathinfo", HttpUtility.UrlEncode(request.MediaPath)},
|
||||
{ "format", "json"},
|
||||
{ "lang", language == "chi" ? "chn" : "eng"}
|
||||
});
|
||||
|
||||
_logger.Info("{0} Search | Request -> {1}", new object[2] { Name, _jsonSerializer.SerializeToString(options) });
|
||||
|
||||
var response = await _httpClient.Post(options);
|
||||
|
||||
_logger.Info("{0} Search | Response -> {1} | Content-Type -> {2}",
|
||||
new object[3] { Name, response.StatusCode, response.ContentType ?? "unknown" });
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.OK && response.ContentType.Contains("application/json"))
|
||||
{
|
||||
// 修改人: Meiam
|
||||
// 修改时间: 2025-12-22
|
||||
// 备注: 增加对射手网 API 返回非法内容(如乱码)的校验
|
||||
|
||||
string responseBody;
|
||||
using (var reader = new StreamReader(response.Content, Encoding.UTF8))
|
||||
{
|
||||
responseBody = await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var remoteSubtitles = new List<RemoteSubtitleInfo>();
|
||||
|
||||
foreach (var subFileInfo in subtitleResponse)
|
||||
{
|
||||
foreach (var subFile in subFileInfo.Files.Where(m =>
|
||||
!string.IsNullOrWhiteSpace(m.Link) && NormalizeFormat(m.Ext) != null))
|
||||
{
|
||||
remoteSubtitles.Add(new RemoteSubtitleInfo()
|
||||
{
|
||||
Id = Base64Encode(_jsonSerializer.SerializeToString(new DownloadSubInfo
|
||||
{
|
||||
Url = subFile.Link,
|
||||
Format = NormalizeFormat(subFile.Ext),
|
||||
Language = request.Language,
|
||||
IsForced = request.IsForced
|
||||
})),
|
||||
Name = $"[MEIAMSUB] {Path.GetFileName(request.MediaPath)} | {request.Language} | 射手",
|
||||
Language = request.Language,
|
||||
Author = "Meiam ",
|
||||
ProviderName = $"{Name}",
|
||||
Format = NormalizeFormat(subFile.Ext),
|
||||
Comment = $"Format : {ExtractFormat(subFile.Ext)}",
|
||||
IsHashMatch = true
|
||||
});
|
||||
}
|
||||
}
|
||||
_logger.Info("{0} Search | Summary -> Get {1} Subtitles", new object[2] { Name, remoteSubtitles.Count });
|
||||
|
||||
|
||||
return remoteSubtitles;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("{0} Search | Exception -> [{1}] {2}", Name, ex.GetType().Name, ex.Message);
|
||||
}
|
||||
|
||||
_logger.Info("{0} Search | Summary -> Get 0 Subtitles", new object[1] { Name });
|
||||
@@ -160,16 +205,17 @@ namespace Emby.MeiamSub.Shooter
|
||||
|
||||
#region 下载字幕
|
||||
/// <summary>
|
||||
/// 下载请求
|
||||
/// 获取字幕内容 (ISubtitleProvider 接口实现)
|
||||
/// 根据字幕 ID 下载具体的字幕文件流。
|
||||
/// </summary>
|
||||
/// <param name="id"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
/// <param name="id">字幕唯一标识符 (Base64 编码的 JSON 数据)</param>
|
||||
/// <param name="cancellationToken">取消令牌</param>
|
||||
/// <returns>包含字幕流的响应对象</returns>
|
||||
public async Task<SubtitleResponse> GetSubtitles(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.Info("{0} DownloadSub | Request -> {1}", new object[2] { Name, id });
|
||||
|
||||
return await DownloadSubAsync(id);
|
||||
return await DownloadSubAsync(id, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -177,43 +223,61 @@ namespace Emby.MeiamSub.Shooter
|
||||
/// </summary>
|
||||
/// <param name="info"></param>
|
||||
/// <returns></returns>
|
||||
private async Task<SubtitleResponse> DownloadSubAsync(string info)
|
||||
private async Task<SubtitleResponse> DownloadSubAsync(string info, CancellationToken cancellationToken)
|
||||
{
|
||||
var downloadSub = _jsonSerializer.DeserializeFromString<DownloadSubInfo>(Base64Decode(info));
|
||||
// 修改人: Meiam
|
||||
// 修改时间: 2025-12-22
|
||||
// 备注: 增加异常处理
|
||||
|
||||
if (downloadSub == null)
|
||||
try
|
||||
{
|
||||
return new SubtitleResponse();
|
||||
}
|
||||
var downloadSub = _jsonSerializer.DeserializeFromString<DownloadSubInfo>(Base64Decode(info));
|
||||
|
||||
_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()
|
||||
if (downloadSub == null)
|
||||
{
|
||||
Language = downloadSub.Language,
|
||||
IsForced = false,
|
||||
Format = downloadSub.Format,
|
||||
Stream = response.Content,
|
||||
};
|
||||
return new SubtitleResponse();
|
||||
}
|
||||
|
||||
_logger.Info($"{0} DownloadSub | Url -> {1} | Format -> {2} | Language -> {3} ",
|
||||
new object[4] { Name, downloadSub.Url, downloadSub.Format, downloadSub.Language });
|
||||
|
||||
var response = await _httpClient.GetResponse(new HttpRequestOptions
|
||||
{
|
||||
Url = downloadSub.Url,
|
||||
UserAgent = $"{Name}",
|
||||
TimeoutMs = 30000,
|
||||
AcceptHeader = "*/*",
|
||||
});
|
||||
|
||||
|
||||
_logger.Info("{0} DownloadSub | Request -> {1}", new object[2] { Name, response.StatusCode });
|
||||
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
var format = NormalizeFormat(downloadSub.Format)
|
||||
?? throw new InvalidDataException($"Unsupported subtitle format: {downloadSub.Format}");
|
||||
var stream = new MemoryStream();
|
||||
await response.Content.CopyToAsync(stream, 81920, cancellationToken);
|
||||
var data = stream.ToArray();
|
||||
stream.Dispose();
|
||||
ValidateSubtitleData(data, format, response.ContentType);
|
||||
|
||||
return new SubtitleResponse()
|
||||
{
|
||||
Language = downloadSub.Language,
|
||||
IsForced = downloadSub.IsForced ?? false,
|
||||
Format = format,
|
||||
Stream = new MemoryStream(data, writable: false),
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("{0} DownloadSub | Error -> {1}", Name, ex.Message);
|
||||
}
|
||||
|
||||
return new SubtitleResponse();
|
||||
throw new InvalidDataException($"{Name} subtitle download failed.");
|
||||
|
||||
}
|
||||
#endregion
|
||||
@@ -248,27 +312,94 @@ namespace Emby.MeiamSub.Shooter
|
||||
/// <returns></returns>
|
||||
protected string ExtractFormat(string text)
|
||||
{
|
||||
|
||||
string result = null;
|
||||
|
||||
if (text != null)
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
text = text.ToLower();
|
||||
if (text.Contains(ASS)) result = ASS;
|
||||
else if (text.Contains(SSA)) result = SSA;
|
||||
else if (text.Contains(SRT)) result = SRT;
|
||||
else result = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
text = text.ToLower();
|
||||
if (text.Contains(ASS)) return ASS;
|
||||
if (text.Contains(SSA)) return SSA;
|
||||
if (text.Contains(SRT)) return SRT;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeFormat(string format)
|
||||
{
|
||||
var value = format?.Trim().TrimStart('.').ToLowerInvariant();
|
||||
return value == SRT || value == ASS || value == SSA ? value : null;
|
||||
}
|
||||
|
||||
private static void ValidateSubtitleData(byte[] data, string format, string mediaType)
|
||||
{
|
||||
if (data == null || data.Length < 8)
|
||||
{
|
||||
throw new InvalidDataException("Subtitle response is empty.");
|
||||
}
|
||||
|
||||
if (data[0] == (byte)'P' && data[1] == (byte)'K' ||
|
||||
data.Length >= 7 && Encoding.ASCII.GetString(data, 0, 7) == "Rar!\u001a\u0007")
|
||||
{
|
||||
throw new InvalidDataException("Compressed subtitle responses are not supported by Shooter.");
|
||||
}
|
||||
|
||||
var sample = Encoding.UTF8.GetString(data, 0, Math.Min(data.Length, 4096)).TrimStart('\uFEFF', ' ', '\r', '\n', '\t');
|
||||
if (sample.StartsWith("<", StringComparison.Ordinal) || sample.StartsWith("{", StringComparison.Ordinal) ||
|
||||
sample.StartsWith("[", StringComparison.Ordinal) && format == SRT ||
|
||||
mediaType?.IndexOf("html", StringComparison.OrdinalIgnoreCase) >= 0 || mediaType?.IndexOf("json", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
throw new InvalidDataException("Shooter returned an error document instead of a subtitle.");
|
||||
}
|
||||
|
||||
var valid = format == SRT ? sample.IndexOf("-->", StringComparison.Ordinal) >= 0 :
|
||||
sample.IndexOf("[Script Info]", StringComparison.OrdinalIgnoreCase) >= 0 || sample.IndexOf("Dialogue:", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
if (!valid)
|
||||
{
|
||||
throw new InvalidDataException($"Downloaded content is not a valid {format} subtitle.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取文件 Hash (射手)
|
||||
/// 规范化语言代码
|
||||
/// </summary>
|
||||
/// <param name="filePath"></param>
|
||||
/// <param name="language"></param>
|
||||
/// <returns></returns>
|
||||
public static string ComputeFileHash(FileInfo fileInfo)
|
||||
private static string NormalizeLanguage(string language)
|
||||
{
|
||||
if (string.IsNullOrEmpty(language)) return language;
|
||||
|
||||
if (language.Equals("zh-CN", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zh-TW", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zh-HK", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zh", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zho", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("chi", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "chi";
|
||||
}
|
||||
if (language.Equals("en", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("eng", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "eng";
|
||||
}
|
||||
return language;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步计算文件 Hash (射手网专用算法)
|
||||
/// <para>修改人: Meiam</para>
|
||||
/// <para>修改时间: 2025-12-22</para>
|
||||
/// <para>备注: 采用异步 I/O 读取文件特定位置的 4KB 数据块进行 MD5 计算。</para>
|
||||
/// </summary>
|
||||
/// <param name="fileInfo">文件信息对象</param>
|
||||
/// <returns>计算得到的文件 Hash 字符串,如果文件过小或不存在则返回空字符串</returns>
|
||||
public static async Task<string> ComputeFileHashAsync(FileInfo fileInfo, CancellationToken cancellationToken)
|
||||
{
|
||||
// 修改人: Meiam
|
||||
// 修改时间: 2025-12-22
|
||||
// 备注: 改造为异步方法,优化 I/O 性能并增加 using 语句释放资源
|
||||
|
||||
string ret = "";
|
||||
|
||||
if (!fileInfo.Exists || fileInfo.Length < 8 * 1024)
|
||||
@@ -276,42 +407,58 @@ namespace Emby.MeiamSub.Shooter
|
||||
return ret;
|
||||
}
|
||||
|
||||
FileStream fs = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read);
|
||||
|
||||
long[] offset = new long[4];
|
||||
offset[3] = fileInfo.Length - 8 * 1024;
|
||||
offset[2] = fileInfo.Length / 3;
|
||||
offset[1] = fileInfo.Length / 3 * 2;
|
||||
offset[0] = 4 * 1024;
|
||||
|
||||
byte[] bBuf = new byte[1024 * 4];
|
||||
|
||||
for (int i = 0; i < 4; ++i)
|
||||
using (FileStream fs = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous))
|
||||
{
|
||||
fs.Seek(offset[i], 0);
|
||||
fs.Read(bBuf, 0, 4 * 1024);
|
||||
long[] offset = new long[4];
|
||||
offset[3] = fileInfo.Length - 8 * 1024;
|
||||
offset[2] = fileInfo.Length / 3;
|
||||
offset[1] = fileInfo.Length / 3 * 2;
|
||||
offset[0] = 4 * 1024;
|
||||
|
||||
MD5 md5Hash = MD5.Create();
|
||||
byte[] data = md5Hash.ComputeHash(bBuf);
|
||||
StringBuilder sBuilder = new StringBuilder();
|
||||
byte[] bBuf = new byte[1024 * 4];
|
||||
|
||||
for (int j = 0; j < data.Length; j++)
|
||||
for (int i = 0; i < 4; ++i)
|
||||
{
|
||||
sBuilder.Append(data[j].ToString("x2"));
|
||||
}
|
||||
fs.Seek(offset[i], SeekOrigin.Begin);
|
||||
await ReadFullAsync(fs, bBuf, 0, 4 * 1024, cancellationToken);
|
||||
|
||||
if (!string.IsNullOrEmpty(ret))
|
||||
{
|
||||
ret += ";";
|
||||
}
|
||||
using (MD5 md5Hash = MD5.Create())
|
||||
{
|
||||
byte[] data = md5Hash.ComputeHash(bBuf);
|
||||
StringBuilder sBuilder = new StringBuilder();
|
||||
|
||||
ret += sBuilder.ToString();
|
||||
for (int j = 0; j < data.Length; j++)
|
||||
{
|
||||
sBuilder.Append(data[j].ToString("x2"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(ret))
|
||||
{
|
||||
ret += ";";
|
||||
}
|
||||
|
||||
ret += sBuilder.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.Close();
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
private static async Task ReadFullAsync(Stream stream, byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
{
|
||||
while (count > 0)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer, offset, count, cancellationToken);
|
||||
if (read == 0)
|
||||
{
|
||||
throw new EndOfStreamException();
|
||||
}
|
||||
|
||||
offset += read;
|
||||
count -= read;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyVersion>1.0.11.0</AssemblyVersion>
|
||||
<FileVersion>1.0.11.0</FileVersion>
|
||||
<Version>1.0.11</Version>
|
||||
<AssemblyVersion>1.0.17.1</AssemblyVersion>
|
||||
<FileVersion>1.0.17.1</FileVersion>
|
||||
<Version>1.0.17.1</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
@@ -24,8 +24,10 @@
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<!-- 使用旧版宿主可解析的 SDK,避免插件要求 4.9.1.90 程序集。 -->
|
||||
<PackageReference Include="MediaBrowser.Common" Version="4.8.10" />
|
||||
<PackageReference Include="MediaBrowser.Server.Core" Version="4.8.10" />
|
||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -33,8 +35,12 @@
|
||||
<Folder Include="Configuration\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Shared\SubtitleContentValidator.cs" Link="Shared\SubtitleContentValidator.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
|
||||
<Exec Command="Copy $(TargetDir)$(TargetFileName) $(SolutionDir)$(ConfigurationName)\$(TargetFileName) /y
" />
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
|
||||
</Target>
|
||||
|
||||
|
||||
|
||||
@@ -17,10 +17,10 @@ namespace Emby.MeiamSub.Thunder.Model
|
||||
public string Url { get; set; }
|
||||
public string Ext { get; set; }
|
||||
public string Name { get; set; }
|
||||
public int Duration { get; set; }
|
||||
public long Duration { get; set; }
|
||||
public string[] Languages { get; set; }
|
||||
|
||||
public string Langs=> Languages != null ? string.Join(",", Languages) : string.Empty;
|
||||
public string Langs => Languages != null ? string.Join(",", Languages) : string.Empty;
|
||||
|
||||
public int Source { get; set; }
|
||||
public int Score { get; set; }
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
using MediaBrowser.Common;
|
||||
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
|
||||
@@ -12,10 +16,10 @@ namespace Emby.MeiamSub.Thunder
|
||||
/// <summary>
|
||||
/// 插件入口
|
||||
/// </summary>
|
||||
public class Plugin : BasePlugin, IHasThumbImage
|
||||
public class Plugin : BasePluginSimpleUI<PluginConfiguration>, IHasThumbImage
|
||||
{
|
||||
|
||||
public Plugin(IApplicationPaths applicationPaths)
|
||||
public Plugin(IApplicationPaths applicationPaths, IApplicationHost applicationHost) : base(applicationHost)
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
@@ -40,17 +44,49 @@ namespace Emby.MeiamSub.Thunder
|
||||
/// </summary>
|
||||
public ImageFormat ThumbImageFormat => ImageFormat.Gif;
|
||||
|
||||
/// <summary>
|
||||
/// 获取插件选项
|
||||
/// </summary>
|
||||
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></returns>
|
||||
/// <returns>图片资源流,若不存在则返回 null</returns>
|
||||
public Stream GetThumbImage()
|
||||
{
|
||||
var type = GetType();
|
||||
return type.Assembly.GetManifestResourceStream(type.Namespace + ".Thumb.png");
|
||||
var resourceName = $"{type.Namespace}.Thumb.png";
|
||||
var stream = type.Assembly.GetManifestResourceStream(resourceName);
|
||||
|
||||
if (stream == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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,33 @@
|
||||
using Emby.MeiamSub.Thunder.Model;
|
||||
using Emby.MeiamSub.Thunder.Model;
|
||||
using MeiamSubtitles.Shared;
|
||||
using MediaBrowser.Common;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Base;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Controller.Subtitles;
|
||||
using MediaBrowser.Model.Logging;
|
||||
using MediaBrowser.Model.Providers;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
using MediaBrowser.Model.System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using static System.Net.WebRequestMethods;
|
||||
|
||||
namespace Emby.MeiamSub.Thunder
|
||||
{
|
||||
/// <summary>
|
||||
/// 迅雷字幕组件
|
||||
/// 迅雷看看字幕提供程序
|
||||
/// 负责与迅雷 API 进行交互,通过 CID (Content ID) 匹配并下载字幕。
|
||||
/// <para>修改人: Meiam</para>
|
||||
/// <para>修改时间: 2025-12-22</para>
|
||||
/// </summary>
|
||||
public class ThunderProvider : ISubtitleProvider, IHasOrder
|
||||
{
|
||||
@@ -31,23 +39,29 @@ namespace Emby.MeiamSub.Thunder
|
||||
protected readonly ILogger _logger;
|
||||
private readonly IJsonSerializer _jsonSerializer;
|
||||
private readonly IHttpClient _httpClient;
|
||||
private readonly IServiceRoot _serviceRoot;
|
||||
|
||||
public int Order => 1;
|
||||
private Plugin MainPlugin { get; set; }
|
||||
|
||||
public int Order => 100;
|
||||
|
||||
public string Name => "MeiamSub.Thunder";
|
||||
|
||||
/// <summary>
|
||||
/// 支持电影、剧集
|
||||
/// </summary>
|
||||
public IEnumerable<VideoContentType> SupportedMediaTypes => new List<VideoContentType>() { VideoContentType.Movie, VideoContentType.Episode };
|
||||
public IEnumerable<VideoContentType> SupportedMediaTypes => new[] { VideoContentType.Movie, VideoContentType.Episode };
|
||||
#endregion
|
||||
|
||||
#region 构造函数
|
||||
public ThunderProvider(ILogManager logManager, IJsonSerializer jsonSerializer, IHttpClient httpClient)
|
||||
public ThunderProvider(ILogManager logManager, IJsonSerializer jsonSerializer, IHttpClient httpClient, IApplicationHost applicationHost)
|
||||
{
|
||||
_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
|
||||
@@ -55,16 +69,23 @@ namespace Emby.MeiamSub.Thunder
|
||||
#region 查询字幕
|
||||
|
||||
/// <summary>
|
||||
/// 查询请求
|
||||
/// 搜索字幕 (ISubtitleProvider 接口实现)
|
||||
/// 根据媒体信息请求字幕列表。
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
/// <param name="request">包含媒体路径、语言等信息的搜索请求对象</param>
|
||||
/// <param name="cancellationToken">取消令牌</param>
|
||||
/// <returns>远程字幕信息列表</returns>
|
||||
public async Task<IEnumerable<RemoteSubtitleInfo>> Search(SubtitleSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.Info("{0} Search | SubtitleSearchRequest -> {1}", new object[2] { Name, _jsonSerializer.SerializeToString(request) });
|
||||
if (request == null)
|
||||
{
|
||||
_logger.Info("{0} Search | Request is null, skip search.", Name);
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
var subtitles = await SearchSubtitlesAsync(request);
|
||||
_logger.Info("{0} Search | ContentType -> {1} | Language -> {2}", Name, request.ContentType, request.Language);
|
||||
|
||||
var subtitles = await SearchSubtitlesAsync(request, cancellationToken);
|
||||
|
||||
return subtitles;
|
||||
}
|
||||
@@ -74,71 +95,132 @@ namespace Emby.MeiamSub.Thunder
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <returns></returns>
|
||||
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request)
|
||||
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if(request.Language == "zh-CN" || request.Language == "zh-TW" || request.Language == "zh-HK"){
|
||||
request.Language = "chi";
|
||||
}
|
||||
if (request.Language != "chi")
|
||||
// 修改人:Mayfly777w
|
||||
// 修改时间:2026-01-01
|
||||
// 备注:如果勾选采用元数据中的剧集名来作为字幕搜索匹配,则使用,否则默认用文件名
|
||||
string MovieName;
|
||||
if (MainPlugin.Options.EnableUseMetadata)
|
||||
{
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
var cid = GetCidByFile(request.MediaPath);
|
||||
|
||||
_logger.Info("{0} Search | FileHash -> {1}", new object[2] { Name, cid });
|
||||
|
||||
|
||||
HttpRequestOptions options = new HttpRequestOptions
|
||||
{
|
||||
Url = $"https://api-shoulei-ssl.xunlei.com/oracle/subtitle?&name={Path.GetFileName(request.MediaPath)}",
|
||||
UserAgent = $"{Name}",
|
||||
TimeoutMs = 30000,
|
||||
AcceptHeader = "*/*",
|
||||
};
|
||||
var response = await _httpClient.GetResponse(options);
|
||||
|
||||
_logger.Info("{0} Search | Response -> {1}", new object[2] { Name, _jsonSerializer.SerializeToString(response) });
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
var subtitleResponse = _jsonSerializer.DeserializeFromStream<SubtitleResponseRoot>(response.Content);
|
||||
|
||||
if (subtitleResponse.Code == 0)
|
||||
if (request.ContentType == VideoContentType.Episode)
|
||||
{
|
||||
_logger.Info("{0} Search | Response -> {1}", new object[2] { Name, _jsonSerializer.SerializeToString(subtitleResponse) });
|
||||
|
||||
var subtitles = subtitleResponse.Data.Where(m => !string.IsNullOrEmpty(m.Name));
|
||||
|
||||
var remoteSubtitleInfos = new List<RemoteSubtitleInfo>();
|
||||
|
||||
if (subtitles.Count() > 0)
|
||||
{
|
||||
foreach (var item in subtitles)
|
||||
{
|
||||
remoteSubtitleInfos.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)} | 迅雷",
|
||||
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, remoteSubtitleInfos.Count });
|
||||
|
||||
return remoteSubtitleInfos;
|
||||
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);
|
||||
}
|
||||
|
||||
// 修改人: Meiam
|
||||
// 修改时间: 2025-12-22
|
||||
// 备注: 增加异常处理
|
||||
|
||||
try
|
||||
{
|
||||
var language = NormalizeLanguage(request.Language);
|
||||
|
||||
_logger.Info("{0} Search | Target -> {1} | Language -> {2}", Name, MovieName, language);
|
||||
|
||||
if (language != "chi")
|
||||
{
|
||||
_logger.Info("{0} Search | Summary -> Language not supported, skip search.", Name);
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
var cid = string.Empty;
|
||||
var stopWatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
cid = await GetCidByFileAsync(request.MediaPath, cancellationToken);
|
||||
_logger.Info("{0} Search | FileHash -> {1} (Took {2}ms)", Name, cid, stopWatch.ElapsedMilliseconds);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
|
||||
{
|
||||
_logger.Warn("{0} Search | File hash unavailable ({1}: {2}), continue with name search.", Name, ex.GetType().Name, ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopWatch.Stop();
|
||||
}
|
||||
|
||||
|
||||
HttpRequestOptions options = new HttpRequestOptions
|
||||
{
|
||||
Url = $"https://api-shoulei-ssl.xunlei.com/oracle/subtitle?name={Uri.EscapeDataString(MovieName)}",
|
||||
UserAgent = $"{Name}",
|
||||
TimeoutMs = 30000,
|
||||
AcceptHeader = "*/*",
|
||||
};
|
||||
var response = await _httpClient.GetResponse(options);
|
||||
|
||||
_logger.Info("{0} Search | Response -> {1}", new object[2] { Name, response.StatusCode });
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
var subtitleResponse = _jsonSerializer.DeserializeFromStream<SubtitleResponseRoot>(response.Content);
|
||||
|
||||
if (subtitleResponse.Code == 0)
|
||||
{
|
||||
var subtitles = (subtitleResponse.Data ?? new List<SublistItem>())
|
||||
.Where(m => !string.IsNullOrWhiteSpace(m.Name) &&
|
||||
!string.IsNullOrWhiteSpace(m.Url) &&
|
||||
NormalizeFormat(m.Ext) != null)
|
||||
.GroupBy(m => m.Url, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(m => m.First())
|
||||
.OrderByDescending(m => !string.IsNullOrEmpty(cid) && string.Equals(cid, m.Cid, StringComparison.OrdinalIgnoreCase))
|
||||
.ThenByDescending(m => m.Langs?.IndexOf("简体", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
.Take(20)
|
||||
.ToList();
|
||||
|
||||
var remoteSubtitles = new List<RemoteSubtitleInfo>();
|
||||
|
||||
if (subtitles.Count() > 0)
|
||||
{
|
||||
foreach (var item in subtitles)
|
||||
{
|
||||
remoteSubtitles.Add(new RemoteSubtitleInfo()
|
||||
{
|
||||
Id = Base64Encode(_jsonSerializer.SerializeToString(new DownloadSubInfo
|
||||
{
|
||||
Url = item.Url,
|
||||
Format = NormalizeFormat(item.Ext),
|
||||
Language = request.Language,
|
||||
IsForced = request.IsForced
|
||||
})),
|
||||
Name = $"[MEIAMSUB] {item.Name} | {(item.Langs == string.Empty ? "未知" : item.Langs)} | 迅雷",
|
||||
Language = request.Language,
|
||||
Author = "Meiam ",
|
||||
ProviderName = $"{Name}",
|
||||
Format = NormalizeFormat(item.Ext),
|
||||
Comment = $"Format : {NormalizeFormat(item.Ext)}",
|
||||
IsHashMatch = !string.IsNullOrEmpty(cid) && string.Equals(cid, item.Cid, StringComparison.OrdinalIgnoreCase),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Info("{0} Search | Summary -> Get {1} Subtitles", new object[2] { Name, remoteSubtitles.Count });
|
||||
|
||||
return remoteSubtitles;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("{0} Search | Exception -> [{1}] {2}", Name, ex.GetType().Name, ex.Message);
|
||||
}
|
||||
|
||||
_logger.Info("{0} Search | Summary -> Get 0 Subtitles", new object[1] { Name });
|
||||
@@ -150,16 +232,17 @@ namespace Emby.MeiamSub.Thunder
|
||||
|
||||
#region 下载字幕
|
||||
/// <summary>
|
||||
/// 下载请求
|
||||
/// 获取字幕内容 (ISubtitleProvider 接口实现)
|
||||
/// 根据字幕 ID 下载具体的字幕文件流。
|
||||
/// </summary>
|
||||
/// <param name="id"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
/// <param name="id">字幕唯一标识符 (Base64 编码的 JSON 数据)</param>
|
||||
/// <param name="cancellationToken">取消令牌</param>
|
||||
/// <returns>包含字幕流的响应对象</returns>
|
||||
public async Task<SubtitleResponse> GetSubtitles(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.Info("{0} DownloadSub | Request -> {1}", new object[2] { Name, id });
|
||||
_logger.Info("{0} DownloadSub | Request -> {1}", new object[2] { Name, id });
|
||||
|
||||
return await DownloadSubAsync(id);
|
||||
return await DownloadSubAsync(id, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -167,41 +250,59 @@ namespace Emby.MeiamSub.Thunder
|
||||
/// </summary>
|
||||
/// <param name="info"></param>
|
||||
/// <returns></returns>
|
||||
private async Task<SubtitleResponse> DownloadSubAsync(string info)
|
||||
private async Task<SubtitleResponse> DownloadSubAsync(string info, CancellationToken cancellationToken)
|
||||
{
|
||||
var downloadSub = _jsonSerializer.DeserializeFromString<DownloadSubInfo>(Base64Decode(info));
|
||||
// 修改人: Meiam
|
||||
// 修改时间: 2025-12-22
|
||||
// 备注: 增加异常处理
|
||||
|
||||
if (downloadSub == null)
|
||||
try
|
||||
{
|
||||
return new SubtitleResponse();
|
||||
}
|
||||
var downloadSub = _jsonSerializer.DeserializeFromString<DownloadSubInfo>(Base64Decode(info));
|
||||
|
||||
_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()
|
||||
if (downloadSub == null)
|
||||
{
|
||||
Language = downloadSub.Language,
|
||||
IsForced = false,
|
||||
Format = downloadSub.Format,
|
||||
Stream = response.Content,
|
||||
};
|
||||
return new SubtitleResponse();
|
||||
}
|
||||
|
||||
_logger.Info("{0} DownloadSub | Url -> {1} | Format -> {2} | Language -> {3} ",
|
||||
new object[4] { Name, downloadSub.Url, downloadSub.Format, downloadSub.Language });
|
||||
|
||||
var response = await _httpClient.GetResponse(new HttpRequestOptions
|
||||
{
|
||||
Url = downloadSub.Url,
|
||||
UserAgent = $"{Name}",
|
||||
TimeoutMs = 30000,
|
||||
AcceptHeader = "*/*",
|
||||
});
|
||||
|
||||
_logger.Info("{0} DownloadSub | Response -> {1}", new object[2] { Name, response.StatusCode });
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
var format = NormalizeFormat(downloadSub.Format)
|
||||
?? throw new InvalidDataException($"Unsupported subtitle format: {downloadSub.Format}");
|
||||
var stream = new MemoryStream();
|
||||
await response.Content.CopyToAsync(stream, 81920, cancellationToken);
|
||||
var data = stream.ToArray();
|
||||
stream.Dispose();
|
||||
var utf8Data = SubtitleContentValidator.ValidateAndConvertToUtf8(data, format, response.ContentType);
|
||||
|
||||
return new SubtitleResponse()
|
||||
{
|
||||
Language = downloadSub.Language,
|
||||
IsForced = downloadSub.IsForced ?? false,
|
||||
Format = format,
|
||||
Stream = new MemoryStream(utf8Data, writable: false),
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("{0} DownloadSub | Error -> {1}", Name, ex.Message);
|
||||
}
|
||||
|
||||
return new SubtitleResponse();
|
||||
throw new InvalidDataException($"{Name} subtitle download failed.");
|
||||
|
||||
}
|
||||
#endregion
|
||||
@@ -236,53 +337,121 @@ namespace Emby.MeiamSub.Thunder
|
||||
/// <returns></returns>
|
||||
protected string ExtractFormat(string text)
|
||||
{
|
||||
|
||||
string result = null;
|
||||
|
||||
if (text != null)
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
text = text.ToLower();
|
||||
if (text.Contains(ASS)) result = ASS;
|
||||
else if (text.Contains(SSA)) result = SSA;
|
||||
else if (text.Contains(SRT)) result = SRT;
|
||||
else result = null;
|
||||
return null;
|
||||
}
|
||||
return result;
|
||||
|
||||
text = text.ToLower();
|
||||
if (text.Contains(ASS)) return ASS;
|
||||
if (text.Contains(SSA)) return SSA;
|
||||
if (text.Contains(SRT)) return SRT;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeFormat(string format)
|
||||
{
|
||||
var value = format?.Trim().TrimStart('.').ToLowerInvariant();
|
||||
return value == SRT || value == ASS || value == SSA ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取文件 CID (迅雷)
|
||||
/// 规范化语言代码
|
||||
/// </summary>
|
||||
/// <param name="filePath"></param>
|
||||
/// <param name="language"></param>
|
||||
/// <returns></returns>
|
||||
private string GetCidByFile(string filePath)
|
||||
private static string NormalizeLanguage(string language)
|
||||
{
|
||||
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
|
||||
var reader = new BinaryReader(stream);
|
||||
var fileSize = new FileInfo(filePath).Length;
|
||||
var SHA1 = new SHA1CryptoServiceProvider();
|
||||
var buffer = new byte[0xf000];
|
||||
if (fileSize < 0xf000)
|
||||
{
|
||||
reader.Read(buffer, 0, (int)fileSize);
|
||||
buffer = SHA1.ComputeHash(buffer, 0, (int)fileSize);
|
||||
}
|
||||
else
|
||||
{
|
||||
reader.Read(buffer, 0, 0x5000);
|
||||
stream.Seek(fileSize / 3, SeekOrigin.Begin);
|
||||
reader.Read(buffer, 0x5000, 0x5000);
|
||||
stream.Seek(fileSize - 0x5000, SeekOrigin.Begin);
|
||||
reader.Read(buffer, 0xa000, 0x5000);
|
||||
if (string.IsNullOrEmpty(language)) return language;
|
||||
|
||||
buffer = SHA1.ComputeHash(buffer, 0, 0xf000);
|
||||
}
|
||||
var result = "";
|
||||
foreach (var i in buffer)
|
||||
if (language.Equals("zh-CN", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zh-TW", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zh-HK", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zh", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zho", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("chi", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result += string.Format("{0:X2}", i);
|
||||
return "chi";
|
||||
}
|
||||
if (language.Equals("en", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("eng", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "eng";
|
||||
}
|
||||
return language;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步计算文件 CID (迅雷专用算法)
|
||||
/// <para>修改人: Meiam</para>
|
||||
/// <para>修改时间: 2025-12-22</para>
|
||||
/// <para>备注: 采用异步 I/O 读取文件特定位置的数据块进行 SHA1 计算。</para>
|
||||
/// </summary>
|
||||
/// <param name="filePath">文件路径</param>
|
||||
/// <returns>计算得到的 CID 字符串</returns>
|
||||
private async Task<string> GetCidByFileAsync(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
// 修改人: Meiam
|
||||
// 修改时间: 2026-06-11
|
||||
// 备注: 增加网络路径拦截,避免 FileStream 打开非本地路径抛出异常
|
||||
if (string.IsNullOrEmpty(filePath) ||
|
||||
filePath.Contains("://") ||
|
||||
filePath.StartsWith("smb://", StringComparison.OrdinalIgnoreCase) ||
|
||||
filePath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||
filePath.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.Warn("Network path detected, skip hash calculation: {0}", filePath);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous))
|
||||
{
|
||||
// 使用 BinaryReader 配合 BaseStream 需要小心,因为 BinaryReader 本身不支持异步 Read
|
||||
// 这里我们直接操作 stream 进行异步读取,不再使用 BinaryReader,因为只是读取字节数组
|
||||
|
||||
var fileSize = new FileInfo(filePath).Length;
|
||||
using (var sha1 = SHA1.Create())
|
||||
{
|
||||
var buffer = new byte[0xf000];
|
||||
if (fileSize < 0xf000)
|
||||
{
|
||||
await ReadFullAsync(stream, buffer, 0, (int)fileSize, cancellationToken);
|
||||
buffer = sha1.ComputeHash(buffer, 0, (int)fileSize);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ReadFullAsync(stream, buffer, 0, 0x5000, cancellationToken);
|
||||
stream.Seek(fileSize / 3, SeekOrigin.Begin);
|
||||
await ReadFullAsync(stream, buffer, 0x5000, 0x5000, cancellationToken);
|
||||
stream.Seek(fileSize - 0x5000, SeekOrigin.Begin);
|
||||
await ReadFullAsync(stream, buffer, 0xa000, 0x5000, cancellationToken);
|
||||
|
||||
buffer = sha1.ComputeHash(buffer, 0, 0xf000);
|
||||
}
|
||||
var result = "";
|
||||
foreach (var i in buffer)
|
||||
{
|
||||
result += string.Format("{0:X2}", i);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReadFullAsync(Stream stream, byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
{
|
||||
while (count > 0)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer, offset, count, cancellationToken);
|
||||
if (read == 0)
|
||||
{
|
||||
throw new EndOfStreamException();
|
||||
}
|
||||
|
||||
offset += read;
|
||||
count -= read;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
404
Jellyfin.MeiamSub.Assrt/AssrtProvider.cs
Normal file
404
Jellyfin.MeiamSub.Assrt/AssrtProvider.cs
Normal file
@@ -0,0 +1,404 @@
|
||||
using Jellyfin.MeiamSub.Assrt.Model;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Controller.Subtitles;
|
||||
using MediaBrowser.Model.Providers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using SharpCompress.Archives;
|
||||
|
||||
namespace Jellyfin.MeiamSub.Assrt
|
||||
{
|
||||
/// <summary>
|
||||
/// Assrt (伪射手) 字幕提供程序
|
||||
/// </summary>
|
||||
public class AssrtProvider : ISubtitleProvider, IHasOrder
|
||||
{
|
||||
private const string ApiBaseUrl = "https://api.assrt.net/v1";
|
||||
|
||||
public const string ASS = "ass";
|
||||
public const string SSA = "ssa";
|
||||
public const string SRT = "srt";
|
||||
|
||||
private readonly ILogger<AssrtProvider> _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public int Order => 90; // 优先级高于旧版
|
||||
|
||||
public string Name => "MeiamSub.Assrt";
|
||||
|
||||
public IEnumerable<VideoContentType> SupportedMediaTypes => new[] { VideoContentType.Movie, VideoContentType.Episode };
|
||||
|
||||
public AssrtProvider(ILogger<AssrtProvider> logger, IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger.LogInformation($"{Name} Init");
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<RemoteSubtitleInfo>> Search(SubtitleSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation($"{Name} Search | Received request for: {(request?.MediaPath ?? "NULL")}");
|
||||
|
||||
try
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config == null || string.IsNullOrWhiteSpace(config.AssrtApiToken))
|
||||
{
|
||||
_logger.LogWarning($"{Name} Search | AssrtApiToken is empty, skip search. Please configure the token in plugin settings.");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
var language = NormalizeLanguage(request.Language);
|
||||
if (language != "chi")
|
||||
{
|
||||
_logger.LogInformation($"{Name} Search | Language '{request.Language}' not supported, skip search.");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
string queryText;
|
||||
if (config.EnableUseMetadata)
|
||||
{
|
||||
if (request.ContentType == VideoContentType.Episode)
|
||||
{
|
||||
queryText = $"{request.SeriesName} S{request.ParentIndexNumber:D2}E{request.IndexNumber:D2}";
|
||||
}
|
||||
else if (request.ContentType == VideoContentType.Movie)
|
||||
{
|
||||
queryText = request.Name;
|
||||
}
|
||||
else
|
||||
{
|
||||
queryText = !string.IsNullOrEmpty(request.MediaPath)
|
||||
? Path.GetFileNameWithoutExtension(request.MediaPath)
|
||||
: string.Empty;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
queryText = !string.IsNullOrEmpty(request.MediaPath)
|
||||
? Path.GetFileNameWithoutExtension(request.MediaPath)
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(queryText))
|
||||
{
|
||||
_logger.LogWarning($"{Name} Search | Query text is empty, skip search.");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
_logger.LogInformation($"{Name} Search | Target -> '{queryText}' | Language -> '{language}'");
|
||||
|
||||
using var httpClient = _httpClientFactory.CreateClient(Name);
|
||||
|
||||
// 拼接搜索请求,is_file=1 用于对文件名检索进行关键词降噪
|
||||
var searchUrl = $"{ApiBaseUrl}/sub/search?token={Uri.EscapeDataString(config.AssrtApiToken)}&q={Uri.EscapeDataString(queryText)}&is_file=1&cnt=15";
|
||||
|
||||
// 隐藏敏感 Token 信息以便记录日志
|
||||
var maskedSearchUrl = $"{ApiBaseUrl}/sub/search?token={MaskToken(config.AssrtApiToken)}&q={Uri.EscapeDataString(queryText)}&is_file=1&cnt=15";
|
||||
_logger.LogInformation($"{Name} Search | Request Url -> {maskedSearchUrl}");
|
||||
|
||||
var response = await httpClient.GetAsync(searchUrl, cancellationToken);
|
||||
var jsonString = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("{Provider} Search | Response -> {StatusCode}", Name, response.StatusCode);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError($"{Name} Search | API responded with status: {response.StatusCode}");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
var searchResponse = JsonSerializer.Deserialize<AssrtSearchResponse>(jsonString);
|
||||
|
||||
if (searchResponse == null || searchResponse.Status != 0 || searchResponse.Sub?.List == null || !searchResponse.Sub.List.Any())
|
||||
{
|
||||
_logger.LogWarning($"{Name} Search | No subtitles found or API error. Request -> Target: '{queryText}', Language: '{language}', Url: '{maskedSearchUrl}' | Response -> Status: {searchResponse?.Status}, Content: '{jsonString}'");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
var remoteSubtitles = new List<RemoteSubtitleInfo>();
|
||||
|
||||
foreach (var item in searchResponse.Sub.List)
|
||||
{
|
||||
if (item.Id == null) continue;
|
||||
|
||||
var subtitleId = item.Id.ToString();
|
||||
var format = ExtractFormat(item.Subtype) ?? ExtractFormat(item.Name) ?? SRT;
|
||||
|
||||
var langDesc = item.Lang?.Desc ?? "中文";
|
||||
|
||||
var downloadInfo = new AssrtDownloadInfo
|
||||
{
|
||||
Id = subtitleId,
|
||||
Format = format,
|
||||
Language = request.Language,
|
||||
TwoLetterISOLanguageName = request.TwoLetterISOLanguageName
|
||||
};
|
||||
|
||||
var encodedId = Base64Encode(JsonSerializer.Serialize(downloadInfo));
|
||||
|
||||
remoteSubtitles.Add(new RemoteSubtitleInfo
|
||||
{
|
||||
Id = encodedId,
|
||||
Name = $"[MEIAMSUB] {item.Name ?? item.VideoName ?? subtitleId} | {langDesc} | 伪射手",
|
||||
Author = "Assrt",
|
||||
ProviderName = Name,
|
||||
Format = format,
|
||||
Comment = string.IsNullOrEmpty(item.VideoName) ? $"格式: {format}" : $"适配视频: {item.VideoName} (格式: {format})",
|
||||
IsHashMatch = false // Assrt.net 接口主要是文本搜索,返回 false 以提示用户是名字检索匹配
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation($"{Name} Search | Summary -> Found {remoteSubtitles.Count} subtitles.");
|
||||
return remoteSubtitles;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{Name} Search | Exception occurred: {ex.Message}");
|
||||
}
|
||||
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
public async Task<SubtitleResponse> GetSubtitles(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation($"{Name} GetSubtitles | Requesting ID: {id}");
|
||||
AssrtDownloadInfo downloadInfo = null;
|
||||
|
||||
try
|
||||
{
|
||||
var decodedString = Base64Decode(id);
|
||||
downloadInfo = JsonSerializer.Deserialize<AssrtDownloadInfo>(decodedString);
|
||||
if (downloadInfo == null || string.IsNullOrEmpty(downloadInfo.Id))
|
||||
{
|
||||
throw new ArgumentException($"{Name} GetSubtitles | 无法解析字幕下载信息,ID 为空。");
|
||||
}
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config == null || string.IsNullOrWhiteSpace(config.AssrtApiToken))
|
||||
{
|
||||
_logger.LogError($"{Name} GetSubtitles | AssrtApiToken is not configured.");
|
||||
throw new InvalidOperationException($"{Name} GetSubtitles | AssrtApiToken 未配置,请在插件设置中填写 Token。");
|
||||
}
|
||||
|
||||
using var httpClient = _httpClientFactory.CreateClient(Name);
|
||||
|
||||
// 1. 请求详情接口获取真实的下载地址
|
||||
var detailUrl = $"{ApiBaseUrl}/sub/detail?token={Uri.EscapeDataString(config.AssrtApiToken)}&id={Uri.EscapeDataString(downloadInfo.Id)}";
|
||||
var detailResponseMsg = await httpClient.GetAsync(detailUrl, cancellationToken);
|
||||
if (!detailResponseMsg.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError($"{Name} GetSubtitles | Failed to fetch details. Status: {detailResponseMsg.StatusCode}");
|
||||
throw new HttpRequestException($"{Name} GetSubtitles | 获取字幕详情失败,HTTP 状态码: {detailResponseMsg.StatusCode}");
|
||||
}
|
||||
|
||||
var detailJson = await detailResponseMsg.Content.ReadAsStringAsync(cancellationToken);
|
||||
var detailResponse = JsonSerializer.Deserialize<AssrtDetailResponse>(detailJson);
|
||||
|
||||
if (detailResponse == null || detailResponse.Status != 0)
|
||||
{
|
||||
_logger.LogError($"{Name} GetSubtitles | Detail API error. Status: {detailResponse?.Status}, Response: {detailJson}");
|
||||
throw new InvalidOperationException($"{Name} GetSubtitles | Assrt API returned error status: {detailResponse?.Status}");
|
||||
}
|
||||
|
||||
var targetSub = detailResponse?.Sub?.Subs?.FirstOrDefault();
|
||||
if (targetSub == null || string.IsNullOrEmpty(targetSub.Url))
|
||||
{
|
||||
_logger.LogError($"{Name} GetSubtitles | Subtitle download URL not found in API response.");
|
||||
throw new InvalidOperationException($"{Name} GetSubtitles | API 响应中未找到字幕下载地址。");
|
||||
}
|
||||
|
||||
var downloadUrl = targetSub.Url;
|
||||
var downloadFilename = targetSub.Filename;
|
||||
|
||||
_logger.LogInformation($"{Name} GetSubtitles | Downloading subtitle from: {downloadUrl}");
|
||||
|
||||
// 2. 下载字幕流 (手动接管重定向以确保每次重定向请求均带有正确的防盗链 Headers)
|
||||
var handler = new HttpClientHandler { AllowAutoRedirect = false };
|
||||
using var cleanHttpClient = new HttpClient(handler);
|
||||
|
||||
var currentUrl = downloadUrl;
|
||||
|
||||
HttpResponseMessage downloadResponse = null;
|
||||
int redirectCount = 0;
|
||||
|
||||
while (redirectCount < 5)
|
||||
{
|
||||
var requestMsg = new HttpRequestMessage(HttpMethod.Get, currentUrl);
|
||||
requestMsg.Version = HttpVersion.Version11;
|
||||
requestMsg.Headers.ConnectionClose = true; // 强制关闭长连接,防范服务器 Keep-Alive 超时关闭
|
||||
requestMsg.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
|
||||
requestMsg.Headers.Add("Referer", "https://assrt.net/");
|
||||
|
||||
downloadResponse = await cleanHttpClient.SendAsync(requestMsg, cancellationToken);
|
||||
|
||||
if (downloadResponse.StatusCode == HttpStatusCode.Redirect ||
|
||||
downloadResponse.StatusCode == HttpStatusCode.MovedPermanently ||
|
||||
downloadResponse.StatusCode == HttpStatusCode.Found ||
|
||||
downloadResponse.StatusCode == HttpStatusCode.SeeOther ||
|
||||
(int)downloadResponse.StatusCode == 307 ||
|
||||
(int)downloadResponse.StatusCode == 308)
|
||||
{
|
||||
var location = downloadResponse.Headers.Location;
|
||||
if (location != null)
|
||||
{
|
||||
if (!location.IsAbsoluteUri)
|
||||
{
|
||||
location = new Uri(new Uri(currentUrl), location);
|
||||
}
|
||||
currentUrl = location.AbsoluteUri;
|
||||
redirectCount++;
|
||||
_logger.LogInformation($"{Name} GetSubtitles | Redirecting to -> {currentUrl}");
|
||||
downloadResponse.Dispose();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (downloadResponse == null || !downloadResponse.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError($"{Name} GetSubtitles | Failed to download subtitle file. Status: {downloadResponse?.StatusCode}");
|
||||
throw new HttpRequestException($"{Name} GetSubtitles | 字幕文件下载失败,HTTP 状态码: {downloadResponse?.StatusCode}");
|
||||
}
|
||||
|
||||
var responseStream = await downloadResponse.Content.ReadAsStreamAsync(cancellationToken);
|
||||
|
||||
// 3. 下载原始文件;若为压缩包,则从 ZIP/RAR/7z/TAR 等归档中提取字幕。
|
||||
var memoryStream = new MemoryStream();
|
||||
await responseStream.CopyToAsync(memoryStream, cancellationToken);
|
||||
memoryStream.Position = 0;
|
||||
|
||||
var directFormat = ExtractFormat(downloadFilename);
|
||||
if (directFormat == null)
|
||||
{
|
||||
_logger.LogInformation($"{Name} GetSubtitles | Archive detected, extracting: {downloadFilename}");
|
||||
try
|
||||
{
|
||||
using var archive = ArchiveFactory.OpenArchive(memoryStream);
|
||||
var entry = archive.Entries.FirstOrDefault(e =>
|
||||
!e.IsDirectory && ExtractFormat(e.Key) != null);
|
||||
|
||||
if (entry != null)
|
||||
{
|
||||
var extractedStream = new MemoryStream();
|
||||
using (var entryStream = entry.OpenEntryStream())
|
||||
{
|
||||
await entryStream.CopyToAsync(extractedStream, cancellationToken);
|
||||
}
|
||||
extractedStream.Position = 0;
|
||||
var extFormat = ExtractFormat(entry.Key) ?? downloadInfo.Format;
|
||||
|
||||
_logger.LogInformation($"{Name} GetSubtitles | Extracted file: '{entry.Key}' with format: {extFormat}");
|
||||
|
||||
return new SubtitleResponse
|
||||
{
|
||||
Language = downloadInfo.Language,
|
||||
IsForced = false,
|
||||
Format = extFormat,
|
||||
Stream = extractedStream
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"{Name} GetSubtitles | Archive contains no supported srt/ass/ssa subtitle.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{Name} GetSubtitles | Failed to extract archive '{downloadFilename}': {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 非压缩的字幕文件直接返回。
|
||||
memoryStream.Position = 0;
|
||||
return new SubtitleResponse
|
||||
{
|
||||
Language = downloadInfo.Language,
|
||||
IsForced = false,
|
||||
Format = directFormat ?? downloadInfo.Format,
|
||||
Stream = memoryStream
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{Name} GetSubtitles | Exception: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
#region 辅助工具函数
|
||||
|
||||
public static string Base64Encode(string plainText)
|
||||
{
|
||||
var plainTextBytes = Encoding.UTF8.GetBytes(plainText);
|
||||
return Convert.ToBase64String(plainTextBytes);
|
||||
}
|
||||
|
||||
public static string Base64Decode(string base64EncodedData)
|
||||
{
|
||||
var base64EncodedBytes = Convert.FromBase64String(base64EncodedData);
|
||||
return Encoding.UTF8.GetString(base64EncodedBytes);
|
||||
}
|
||||
|
||||
protected string ExtractFormat(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
text = text.ToLower();
|
||||
if (text.Contains(ASS)) return ASS;
|
||||
if (text.Contains(SSA)) return SSA;
|
||||
if (text.Contains(SRT)) return SRT;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeLanguage(string language)
|
||||
{
|
||||
if (string.IsNullOrEmpty(language)) return language;
|
||||
|
||||
if (language.Equals("zh-CN", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zh-TW", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zh-HK", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zh", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zho", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("chi", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "chi";
|
||||
}
|
||||
if (language.Equals("en", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("eng", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "eng";
|
||||
}
|
||||
return language;
|
||||
}
|
||||
|
||||
private static string MaskToken(string token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token)) return string.Empty;
|
||||
if (token.Length <= 8) return "***";
|
||||
return token.Substring(0, 4) + "***" + token.Substring(token.Length - 4);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
26
Jellyfin.MeiamSub.Assrt/Configuration/PluginConfiguration.cs
Normal file
26
Jellyfin.MeiamSub.Assrt/Configuration/PluginConfiguration.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace Jellyfin.MeiamSub.Assrt.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// 插件配置类
|
||||
/// </summary>
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Assrt API 鉴权 Token
|
||||
/// </summary>
|
||||
public string AssrtApiToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否使用元数据中的剧集名称和季集编号搜索字幕
|
||||
/// </summary>
|
||||
public bool EnableUseMetadata { get; set; }
|
||||
|
||||
public PluginConfiguration()
|
||||
{
|
||||
AssrtApiToken = string.Empty;
|
||||
EnableUseMetadata = true; // 默认为 true,因为 Assrt 是基于文本搜索,元数据匹配非常合适
|
||||
}
|
||||
}
|
||||
}
|
||||
76
Jellyfin.MeiamSub.Assrt/Configuration/configPage.html
Normal file
76
Jellyfin.MeiamSub.Assrt/Configuration/configPage.html
Normal file
@@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>MeiamSub.Assrt</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="MeiamSubAssrtConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
|
||||
data-require="emby-checkbox,emby-input">
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<form id="MeiamSubAssrtConfigForm">
|
||||
<div class="verticalSection">
|
||||
<h2 class="sectionTitle">伪射手字幕搜索设置 (Assrt)</h2>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel" for="AssrtApiToken">Assrt API Token</label>
|
||||
<input id="AssrtApiToken" name="AssrtApiToken" type="text" is="emby-input" placeholder="请输入 Assrt.net API Token" />
|
||||
<div class="fieldDescription">
|
||||
请在 <a href="https://assrt.net" target="_blank">assrt.net</a> 注册并获取你的 32 位 API Token。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="EnableUseMetadata" name="EnableUseMetadata" type="checkbox"
|
||||
is="emby-checkbox" />
|
||||
<span>使用元数据搜索字幕</span>
|
||||
</label>
|
||||
<div class="fieldDescription">
|
||||
勾选此项后,使用元数据中的剧集名称和季集编号搜索字幕,而非文件名。
|
||||
<br />
|
||||
剧集将使用 "剧集名 S01E01" 格式;电影将使用影片名称。建议开启以提升模糊检索成功率。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||
<span>保存</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
var MeiamSubAssrtConfig = {
|
||||
pluginUniqueId: 'E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0'
|
||||
};
|
||||
|
||||
document.querySelector('#MeiamSubAssrtConfigPage')
|
||||
.addEventListener('pageshow', function () {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(MeiamSubAssrtConfig.pluginUniqueId).then(function (config) {
|
||||
document.querySelector('#AssrtApiToken').value = config.AssrtApiToken || '';
|
||||
document.querySelector('#EnableUseMetadata').checked = config.EnableUseMetadata;
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector('#MeiamSubAssrtConfigForm')
|
||||
.addEventListener('submit', function (e) {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(MeiamSubAssrtConfig.pluginUniqueId).then(function (config) {
|
||||
config.AssrtApiToken = document.querySelector('#AssrtApiToken').value;
|
||||
config.EnableUseMetadata = document.querySelector('#EnableUseMetadata').checked;
|
||||
ApiClient.updatePluginConfiguration(MeiamSubAssrtConfig.pluginUniqueId, config).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
});
|
||||
});
|
||||
e.preventDefault();
|
||||
return false;
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
36
Jellyfin.MeiamSub.Assrt/Jellyfin.MeiamSub.Assrt.csproj
Normal file
36
Jellyfin.MeiamSub.Assrt/Jellyfin.MeiamSub.Assrt.csproj
Normal file
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Version>1.0.17.1</Version>
|
||||
<AssemblyVersion>1.0.17.1</AssemblyVersion>
|
||||
<FileVersion>1.0.17.1</FileVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<OutputPath>..\Release</OutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- 使用已验证的最低兼容基线,保持同一 DLL 可运行于 Jellyfin 10.10.7、10.11.11 和 12.1。 -->
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.10.7" />
|
||||
<PackageReference Include="SharpCompress" Version="0.50.1" GeneratePathProperty="true" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="thumb.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
|
||||
<Copy SourceFiles="$(PkgSharpCompress)\lib\net8.0\SharpCompress.dll" DestinationFolder="$(SolutionDir)$(ConfigurationName)\dependencies\Jellyfin\" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
98
Jellyfin.MeiamSub.Assrt/Model/AssrtModels.cs
Normal file
98
Jellyfin.MeiamSub.Assrt/Model/AssrtModels.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.MeiamSub.Assrt.Model
|
||||
{
|
||||
/// <summary>
|
||||
/// 字幕搜索响应根结构
|
||||
/// </summary>
|
||||
public class AssrtSearchResponse
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public int Status { get; set; }
|
||||
|
||||
[JsonPropertyName("sub")]
|
||||
public AssrtSearchSubInfo Sub { get; set; }
|
||||
}
|
||||
|
||||
public class AssrtSearchSubInfo
|
||||
{
|
||||
[JsonPropertyName("keyword")]
|
||||
public string Keyword { get; set; }
|
||||
|
||||
[JsonPropertyName("result")]
|
||||
public string Result { get; set; }
|
||||
|
||||
[JsonPropertyName("subs")]
|
||||
public List<AssrtSearchItem> List { get; set; }
|
||||
}
|
||||
|
||||
public class AssrtSearchItem
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public object Id { get; set; } // 可以是数字也可以是字符串,用 object 接收再 ToString()
|
||||
|
||||
[JsonPropertyName("native_name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonPropertyName("videoname")]
|
||||
public string VideoName { get; set; }
|
||||
|
||||
[JsonPropertyName("subtype")]
|
||||
public string Subtype { get; set; }
|
||||
|
||||
[JsonPropertyName("lang")]
|
||||
public AssrtLanguageInfo Lang { get; set; }
|
||||
}
|
||||
|
||||
public class AssrtLanguageInfo
|
||||
{
|
||||
[JsonPropertyName("desc")]
|
||||
public string Desc { get; set; }
|
||||
|
||||
[JsonPropertyName("lang")]
|
||||
public int Lang { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 字幕详情响应根结构
|
||||
/// </summary>
|
||||
public class AssrtDetailResponse
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public int Status { get; set; }
|
||||
|
||||
[JsonPropertyName("sub")]
|
||||
public AssrtDetailSubInfo Sub { get; set; }
|
||||
}
|
||||
|
||||
public class AssrtDetailSubInfo
|
||||
{
|
||||
[JsonPropertyName("subs")]
|
||||
public List<AssrtDetailItem> Subs { get; set; }
|
||||
}
|
||||
|
||||
public class AssrtDetailItem
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public object Id { get; set; }
|
||||
|
||||
[JsonPropertyName("filename")]
|
||||
public string Filename { get; set; }
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; set; }
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 插件内部传递的下载参数
|
||||
/// </summary>
|
||||
public class AssrtDownloadInfo
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Format { get; set; }
|
||||
public string Language { get; set; }
|
||||
public string TwoLetterISOLanguageName { get; set; }
|
||||
}
|
||||
}
|
||||
55
Jellyfin.MeiamSub.Assrt/Plugin.cs
Normal file
55
Jellyfin.MeiamSub.Assrt/Plugin.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using Jellyfin.MeiamSub.Assrt.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Jellyfin.MeiamSub.Assrt
|
||||
{
|
||||
/// <summary>
|
||||
/// Assrt 插件入口
|
||||
/// </summary>
|
||||
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
/// <summary>
|
||||
/// 插件ID
|
||||
/// </summary>
|
||||
public override Guid Id => new Guid("E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0");
|
||||
|
||||
/// <summary>
|
||||
/// 插件名称
|
||||
/// </summary>
|
||||
public override string Name => "MeiamSub.Assrt";
|
||||
|
||||
/// <summary>
|
||||
/// 插件描述
|
||||
/// </summary>
|
||||
public override string Description => "Download subtitles from Assrt (伪射手网)";
|
||||
|
||||
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
public static Plugin Instance { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取插件配置页面列表
|
||||
/// </summary>
|
||||
/// <returns>配置页面信息</returns>
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = Name,
|
||||
EmbeddedResourcePath = GetType().Namespace + ".Configuration.configPage.html"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
26
Jellyfin.MeiamSub.Assrt/PluginServiceRegistrator.cs
Normal file
26
Jellyfin.MeiamSub.Assrt/PluginServiceRegistrator.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using MediaBrowser.Controller.Subtitles;
|
||||
using MediaBrowser.Controller;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System;
|
||||
|
||||
namespace Jellyfin.MeiamSub.Assrt
|
||||
{
|
||||
/// <summary>
|
||||
/// 插件服务注册器
|
||||
/// </summary>
|
||||
public class PluginServiceRegistrator : IPluginServiceRegistrator
|
||||
{
|
||||
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
|
||||
{
|
||||
serviceCollection.AddHttpClient("MeiamSub.Assrt", client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(20); // 适当的超时时间
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "MeiamSub.Assrt/1.0.14.0 (Jellyfin)");
|
||||
client.DefaultRequestHeaders.Add("Accept", "*/*");
|
||||
});
|
||||
|
||||
serviceCollection.AddSingleton<ISubtitleProvider, AssrtProvider>();
|
||||
}
|
||||
}
|
||||
}
|
||||
12
Jellyfin.MeiamSub.Assrt/meta.json
Normal file
12
Jellyfin.MeiamSub.Assrt/meta.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"category": "Subtitles",
|
||||
"description": "Download subtitles from Assrt (伪射手网)",
|
||||
"guid": "E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0",
|
||||
"name": "MeiamSub.Assrt",
|
||||
"owner": "Meiam",
|
||||
"targetAbi": "10.10.7.0",
|
||||
"timestamp": "2026-09-17T03:17:33.0000000Z",
|
||||
"version": "1.0.17.1",
|
||||
"status": "Active",
|
||||
"imagePath": "thumb.png"
|
||||
}
|
||||
BIN
Jellyfin.MeiamSub.Assrt/thumb.png
Normal file
BIN
Jellyfin.MeiamSub.Assrt/thumb.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 255 KiB |
@@ -1,12 +1,8 @@
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace Jellyfin.MeiamSub.Shooter.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Plugin configuration.
|
||||
/// </summary>
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Version>1.0.11</Version>
|
||||
<AssemblyVersion>1.0.11.0</AssemblyVersion>
|
||||
<FileVersion>1.0.11.0</FileVersion>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Version>1.0.16.0</Version>
|
||||
<AssemblyVersion>1.0.16.0</AssemblyVersion>
|
||||
<FileVersion>1.0.16.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
@@ -12,11 +12,17 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.10.3" />
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.11.11" />
|
||||
</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
" />
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
using Jellyfin.MeiamSub.Shooter.Configuration;
|
||||
using Jellyfin.MeiamSub.Shooter.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Model.Drawing;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace Jellyfin.MeiamSub.Shooter
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// 插件入口
|
||||
/// <para>修改人: Meiam</para>
|
||||
/// <para>修改时间: 2025-12-22</para>
|
||||
/// </summary>
|
||||
public class Plugin : BasePlugin<PluginConfiguration>
|
||||
{
|
||||
@@ -27,10 +27,10 @@ namespace Jellyfin.MeiamSub.Shooter
|
||||
/// <summary>
|
||||
/// 插件描述
|
||||
/// </summary>
|
||||
public override string Description => "Download subtitles from Shooter";
|
||||
public override string Description => "Deprecated: the legacy Shooter API rarely returns usable results. Use Assrt or Thunder instead.";
|
||||
|
||||
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
@@ -10,10 +10,28 @@ 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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using Jellyfin.Data.Entities.Libraries;
|
||||
using Jellyfin.MeiamSub.Shooter.Model;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
@@ -8,6 +7,7 @@ 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;
|
||||
@@ -24,7 +24,10 @@ 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
|
||||
{
|
||||
@@ -34,26 +37,25 @@ namespace Jellyfin.MeiamSub.Shooter
|
||||
public const string SRT = "srt";
|
||||
|
||||
private readonly ILogger<ShooterProvider> _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
private static readonly HttpClient _httpClient = new HttpClient();
|
||||
private const string ApiUrl = "https://www.shooter.cn/api/subapi.php";
|
||||
|
||||
private string apiUrl => "https://www.shooter.cn/api/subapi.php";
|
||||
|
||||
public int Order => 1;
|
||||
public int Order => 100;
|
||||
|
||||
public string Name => "MeiamSub.Shooter";
|
||||
|
||||
/// <summary>
|
||||
/// 支持电影、剧集
|
||||
/// </summary>
|
||||
public IEnumerable<VideoContentType> SupportedMediaTypes => new List<VideoContentType>() { VideoContentType.Movie, VideoContentType.Episode };
|
||||
public IEnumerable<VideoContentType> SupportedMediaTypes => new[] { VideoContentType.Movie, VideoContentType.Episode };
|
||||
#endregion
|
||||
|
||||
#region 构造函数
|
||||
public ShooterProvider(ILogger<ShooterProvider> logger)
|
||||
public ShooterProvider(ILogger<ShooterProvider> logger, IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClient.Timeout = TimeSpan.FromSeconds(30);
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger.LogInformation($"{Name} Init");
|
||||
}
|
||||
#endregion
|
||||
@@ -61,16 +63,17 @@ namespace Jellyfin.MeiamSub.Shooter
|
||||
#region 查询字幕
|
||||
|
||||
/// <summary>
|
||||
/// 查询请求
|
||||
/// 搜索字幕 (ISubtitleProvider 接口实现)
|
||||
/// 根据媒体信息请求字幕列表。
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
/// <param name="request">包含媒体路径、语言等信息的搜索请求对象</param>
|
||||
/// <param name="cancellationToken">取消令牌</param>
|
||||
/// <returns>远程字幕信息列表</returns>
|
||||
public async Task<IEnumerable<RemoteSubtitleInfo>> Search(SubtitleSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation($"{Name} Search | SubtitleSearchRequest -> { JsonSerializer.Serialize(request) }");
|
||||
_logger.LogInformation("DEBUG: Received Search request for " + (request?.MediaPath ?? "NULL"));
|
||||
|
||||
var subtitles = await SearchSubtitlesAsync(request);
|
||||
var subtitles = await SearchSubtitlesAsync(request, cancellationToken);
|
||||
|
||||
return subtitles;
|
||||
}
|
||||
@@ -80,81 +83,161 @@ namespace Jellyfin.MeiamSub.Shooter
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <returns></returns>
|
||||
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request)
|
||||
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Language != "chi" && request.Language != "eng")
|
||||
// 修改人: Meiam
|
||||
// 修改时间: 2025-12-22
|
||||
// 备注: 增加极致探测日志,定位方法中断的具体位置
|
||||
|
||||
_logger.LogInformation("DEBUG: Entering SearchSubtitlesAsync");
|
||||
|
||||
try
|
||||
{
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
FileInfo fileInfo = new(request.MediaPath);
|
||||
|
||||
var hash = ComputeFileHash(fileInfo);
|
||||
|
||||
_logger.LogInformation($"{Name} Search | FileHash -> { hash }");
|
||||
|
||||
var formData = new Dictionary<string, string>
|
||||
{
|
||||
{ "filehash", hash},
|
||||
{ "pathinfo", request.MediaPath},
|
||||
{ "format", "json"},
|
||||
{ "lang", request.Language == "chi" ? "chn" : "eng"}
|
||||
};
|
||||
|
||||
var content = new FormUrlEncodedContent(formData);
|
||||
|
||||
// 设置请求头
|
||||
content.Headers.ContentType = new MediaTypeHeaderValue("application/x-www-form-urlencoded");
|
||||
|
||||
// 发送 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 } ");
|
||||
|
||||
var subtitles = JsonSerializer.Deserialize<List<SubtitleResponseRoot>>(responseBody);
|
||||
|
||||
_logger.LogInformation($"{Name} Search | Response -> {JsonSerializer.Serialize(subtitles)}");
|
||||
|
||||
if (subtitles != null)
|
||||
if (request == 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;
|
||||
_logger.LogInformation("DEBUG: Request is null");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
var language = NormalizeLanguage(request.Language);
|
||||
var fileName = string.Empty;
|
||||
|
||||
if (!string.IsNullOrEmpty(request.MediaPath))
|
||||
{
|
||||
fileName = Path.GetFileName(request.MediaPath);
|
||||
}
|
||||
|
||||
_logger.LogInformation(Name + " Search | Target -> " + fileName + " | Language -> " + language);
|
||||
|
||||
if (language != "chi" && language != "eng")
|
||||
{
|
||||
_logger.LogInformation(Name + " Search | Summary -> Language not supported, skip search.");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(request.MediaPath))
|
||||
{
|
||||
_logger.LogInformation(Name + " Search | Summary -> MediaPath is empty, skip search.");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
if (request.MediaPath.Contains("://") ||
|
||||
request.MediaPath.StartsWith("smb://", StringComparison.OrdinalIgnoreCase) ||
|
||||
request.MediaPath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||
request.MediaPath.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning(Name + " Search | Network path detected, skip hash-based Shooter search: {Path}", request.MediaPath);
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
FileInfo fileInfo = new FileInfo(request.MediaPath);
|
||||
|
||||
_logger.LogInformation("DEBUG: Starting hash calculation for " + fileName);
|
||||
|
||||
var stopWatch = Stopwatch.StartNew();
|
||||
var hash = await ComputeFileHashAsync(fileInfo, cancellationToken);
|
||||
stopWatch.Stop();
|
||||
|
||||
_logger.LogInformation(Name + " Search | FileHash -> " + hash + " (Took " + stopWatch.ElapsedMilliseconds + "ms)");
|
||||
|
||||
var formData = new Dictionary<string, string>
|
||||
{
|
||||
{ "filehash", hash},
|
||||
{ "pathinfo", request.MediaPath},
|
||||
{ "format", "json"},
|
||||
{ "lang", language == "chi" ? "chn" : "eng"}
|
||||
};
|
||||
|
||||
var content = new FormUrlEncodedContent(formData);
|
||||
|
||||
// 设置请求头
|
||||
content.Headers.ContentType = new MediaTypeHeaderValue("application/x-www-form-urlencoded");
|
||||
|
||||
using var httpClient = _httpClientFactory.CreateClient(Name);
|
||||
|
||||
// 发送 POST 请求
|
||||
using var response = await httpClient.PostAsync(ApiUrl, content, cancellationToken);
|
||||
|
||||
_logger.LogInformation("{Provider} Search | Response -> {StatusCode} | Content-Type -> {ContentType}",
|
||||
Name, response.StatusCode, response.Content.Headers.ContentType?.MediaType ?? "unknown");
|
||||
|
||||
// 处理响应
|
||||
|
||||
if (response.IsSuccessStatusCode && response.Content.Headers.ContentType?.MediaType?.Equals("application/json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
|
||||
{
|
||||
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (string.IsNullOrEmpty(responseBody) || !responseBody.Trim().StartsWith("["))
|
||||
|
||||
|
||||
|
||||
{
|
||||
|
||||
|
||||
|
||||
_logger.LogInformation($"{Name} Search | Summary -> API returned invalid content (likely no subtitles found or API error).");
|
||||
|
||||
|
||||
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
var subtitles = JsonSerializer.Deserialize<List<SubtitleResponseRoot>>(responseBody);
|
||||
|
||||
if (subtitles != null)
|
||||
{
|
||||
|
||||
var remoteSubtitles = new List<RemoteSubtitleInfo>();
|
||||
|
||||
foreach (var subFileInfo in subtitles)
|
||||
{
|
||||
foreach (var subFile in subFileInfo.Files.Where(m =>
|
||||
!string.IsNullOrWhiteSpace(m.Link) && NormalizeFormat(m.Ext) != null))
|
||||
{
|
||||
remoteSubtitles.Add(new RemoteSubtitleInfo()
|
||||
{
|
||||
Id = Base64Encode(JsonSerializer.Serialize(new DownloadSubInfo
|
||||
{
|
||||
Url = subFile.Link,
|
||||
Format = NormalizeFormat(subFile.Ext),
|
||||
Language = request.Language,
|
||||
TwoLetterISOLanguageName = request.TwoLetterISOLanguageName,
|
||||
})),
|
||||
Name = $"[MEIAMSUB] {Path.GetFileName(request.MediaPath)} | {request.TwoLetterISOLanguageName} | 射手",
|
||||
Author = "Meiam ",
|
||||
ProviderName = $"{Name}",
|
||||
Format = NormalizeFormat(subFile.Ext),
|
||||
Comment = $"Format : {ExtractFormat(subFile.Ext)}",
|
||||
IsHashMatch = true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation($"{Name} Search | Summary -> Get {remoteSubtitles.Count} Subtitles");
|
||||
|
||||
return remoteSubtitles;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{Name} Search | Exception -> {ex.Message}");
|
||||
}
|
||||
|
||||
_logger.LogInformation($"{Name} Search | Summary -> Get 0 Subtitles");
|
||||
@@ -165,16 +248,17 @@ namespace Jellyfin.MeiamSub.Shooter
|
||||
|
||||
#region 下载字幕
|
||||
/// <summary>
|
||||
/// 下载请求
|
||||
/// 获取字幕内容 (ISubtitleProvider 接口实现)
|
||||
/// 根据字幕 ID 下载具体的字幕文件流。
|
||||
/// </summary>
|
||||
/// <param name="id"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
/// <param name="id">字幕唯一标识符 (Base64 编码的 JSON 数据)</param>
|
||||
/// <param name="cancellationToken">取消令牌</param>
|
||||
/// <returns>包含字幕流的响应对象</returns>
|
||||
public async Task<SubtitleResponse> GetSubtitles(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation($"{Name} DownloadSub | Request -> {id}");
|
||||
|
||||
return await DownloadSubAsync(id);
|
||||
return await DownloadSubAsync(id, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -182,46 +266,58 @@ namespace Jellyfin.MeiamSub.Shooter
|
||||
/// </summary>
|
||||
/// <param name="info"></param>
|
||||
/// <returns></returns>
|
||||
private async Task<SubtitleResponse> DownloadSubAsync(string info)
|
||||
private async Task<SubtitleResponse> DownloadSubAsync(string info, CancellationToken cancellationToken)
|
||||
{
|
||||
var downloadSub = JsonSerializer.Deserialize<DownloadSubInfo>(Base64Decode(info));
|
||||
// 修改人: Meiam
|
||||
// 修改时间: 2025-12-22
|
||||
// 备注: 增加异常处理
|
||||
|
||||
if (downloadSub == null)
|
||||
try
|
||||
{
|
||||
return new SubtitleResponse();
|
||||
}
|
||||
var downloadSub = JsonSerializer.Deserialize<DownloadSubInfo>(Base64Decode(info));
|
||||
|
||||
_logger.LogInformation($"{Name} DownloadSub | Url -> { downloadSub.Url } | Format -> { downloadSub.Format } | Language -> { downloadSub.Language } ");
|
||||
|
||||
using var options = new HttpRequestMessage
|
||||
{
|
||||
Method = HttpMethod.Get,
|
||||
RequestUri = new Uri(downloadSub.Url),
|
||||
Headers =
|
||||
{
|
||||
UserAgent = { new ProductInfoHeaderValue(new ProductHeaderValue($"{Name}")) },
|
||||
Accept = { new MediaTypeWithQualityHeaderValue("*/*") }
|
||||
}
|
||||
};
|
||||
|
||||
var response = await _httpClient.SendAsync(options);
|
||||
|
||||
_logger.LogInformation($"{Name} DownloadSub | Response -> { response.StatusCode }");
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
var stream = await response.Content.ReadAsStreamAsync();
|
||||
|
||||
return new SubtitleResponse()
|
||||
if (downloadSub == null)
|
||||
{
|
||||
Language = downloadSub.Language,
|
||||
IsForced = false,
|
||||
Format = downloadSub.Format,
|
||||
Stream = stream,
|
||||
return new SubtitleResponse();
|
||||
}
|
||||
|
||||
_logger.LogInformation($"{Name} DownloadSub | Url -> {downloadSub.Url} | Format -> {downloadSub.Format} | Language -> {downloadSub.Language} ");
|
||||
|
||||
using var options = new HttpRequestMessage
|
||||
{
|
||||
Method = HttpMethod.Get,
|
||||
RequestUri = new Uri(downloadSub.Url)
|
||||
};
|
||||
|
||||
using var httpClient = _httpClientFactory.CreateClient(Name);
|
||||
|
||||
using var response = await httpClient.SendAsync(options, cancellationToken);
|
||||
|
||||
_logger.LogInformation($"{Name} DownloadSub | Response -> {response.StatusCode}");
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
var format = NormalizeFormat(downloadSub.Format)
|
||||
?? throw new InvalidDataException($"Unsupported subtitle format: {downloadSub.Format}");
|
||||
var data = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
ValidateSubtitleData(data, format, response.Content.Headers.ContentType?.MediaType);
|
||||
var stream = new MemoryStream(data, writable: false);
|
||||
|
||||
return new SubtitleResponse()
|
||||
{
|
||||
Language = downloadSub.Language,
|
||||
IsForced = false,
|
||||
Format = format,
|
||||
Stream = stream,
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "{0} DownloadSub | Error -> {1}", Name, ex.Message);
|
||||
}
|
||||
|
||||
return new SubtitleResponse();
|
||||
throw new InvalidDataException($"{Name} subtitle download failed.");
|
||||
|
||||
}
|
||||
#endregion
|
||||
@@ -257,27 +353,94 @@ namespace Jellyfin.MeiamSub.Shooter
|
||||
/// <returns></returns>
|
||||
protected string ExtractFormat(string text)
|
||||
{
|
||||
|
||||
string result = null;
|
||||
|
||||
if (text != null)
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
text = text.ToLower();
|
||||
if (text.Contains(ASS)) result = ASS;
|
||||
else if (text.Contains(SSA)) result = SSA;
|
||||
else if (text.Contains(SRT)) result = SRT;
|
||||
else result = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
text = text.ToLower();
|
||||
if (text.Contains(ASS)) return ASS;
|
||||
if (text.Contains(SSA)) return SSA;
|
||||
if (text.Contains(SRT)) return SRT;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeFormat(string format)
|
||||
{
|
||||
var value = format?.Trim().TrimStart('.').ToLowerInvariant();
|
||||
return value == SRT || value == ASS || value == SSA ? value : null;
|
||||
}
|
||||
|
||||
private static void ValidateSubtitleData(byte[] data, string format, string mediaType)
|
||||
{
|
||||
if (data == null || data.Length < 8)
|
||||
{
|
||||
throw new InvalidDataException("Subtitle response is empty.");
|
||||
}
|
||||
|
||||
if (data[0] == (byte)'P' && data[1] == (byte)'K' ||
|
||||
data.Length >= 7 && Encoding.ASCII.GetString(data, 0, 7) == "Rar!\u001a\u0007")
|
||||
{
|
||||
throw new InvalidDataException("Compressed subtitle responses are not supported by Shooter.");
|
||||
}
|
||||
|
||||
var sample = Encoding.UTF8.GetString(data, 0, Math.Min(data.Length, 4096)).TrimStart('\uFEFF', ' ', '\r', '\n', '\t');
|
||||
if (sample.StartsWith("<", StringComparison.Ordinal) || sample.StartsWith("{", StringComparison.Ordinal) ||
|
||||
sample.StartsWith("[", StringComparison.Ordinal) && format == SRT ||
|
||||
mediaType?.IndexOf("html", StringComparison.OrdinalIgnoreCase) >= 0 || mediaType?.IndexOf("json", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
throw new InvalidDataException("Shooter returned an error document instead of a subtitle.");
|
||||
}
|
||||
|
||||
var valid = format == SRT ? sample.Contains("-->", StringComparison.Ordinal) :
|
||||
sample.Contains("[Script Info]", StringComparison.OrdinalIgnoreCase) || sample.Contains("Dialogue:", StringComparison.OrdinalIgnoreCase);
|
||||
if (!valid)
|
||||
{
|
||||
throw new InvalidDataException($"Downloaded content is not a valid {format} subtitle.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取文件 Hash (射手)
|
||||
/// 规范化语言代码
|
||||
/// </summary>
|
||||
/// <param name="filePath"></param>
|
||||
/// <param name="language"></param>
|
||||
/// <returns></returns>
|
||||
public static string ComputeFileHash(FileInfo fileInfo)
|
||||
private static string NormalizeLanguage(string language)
|
||||
{
|
||||
if (string.IsNullOrEmpty(language)) return language;
|
||||
|
||||
if (language.Equals("zh-CN", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zh-TW", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zh-HK", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zh", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zho", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("chi", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "chi";
|
||||
}
|
||||
if (language.Equals("en", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("eng", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "eng";
|
||||
}
|
||||
return language;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步计算文件 Hash (射手网专用算法)
|
||||
/// <para>修改人: Meiam</para>
|
||||
/// <para>修改时间: 2025-12-22</para>
|
||||
/// <para>备注: 采用异步 I/O 读取文件特定位置的 4KB 数据块进行 MD5 计算。</para>
|
||||
/// </summary>
|
||||
/// <param name="fileInfo">文件信息对象</param>
|
||||
/// <returns>计算得到的文件 Hash 字符串,如果文件过小或不存在则返回空字符串</returns>
|
||||
public static async Task<string> ComputeFileHashAsync(FileInfo fileInfo, CancellationToken cancellationToken)
|
||||
{
|
||||
// 修改人: Meiam
|
||||
// 修改时间: 2025-12-22
|
||||
// 备注: 改造为异步方法,优化 I/O 性能并增加 using 语句释放资源
|
||||
|
||||
string ret = "";
|
||||
|
||||
if (!fileInfo.Exists || fileInfo.Length < 8 * 1024)
|
||||
@@ -285,47 +448,44 @@ namespace Jellyfin.MeiamSub.Shooter
|
||||
return ret;
|
||||
}
|
||||
|
||||
FileStream fs = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read);
|
||||
|
||||
long[] offset = new long[4];
|
||||
offset[3] = fileInfo.Length - 8 * 1024;
|
||||
offset[2] = fileInfo.Length / 3;
|
||||
offset[1] = fileInfo.Length / 3 * 2;
|
||||
offset[0] = 4 * 1024;
|
||||
|
||||
byte[] bBuf = new byte[1024 * 4];
|
||||
|
||||
for (int i = 0; i < 4; ++i)
|
||||
using (FileStream fs = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous))
|
||||
{
|
||||
fs.Seek(offset[i], 0);
|
||||
fs.Read(bBuf, 0, 4 * 1024);
|
||||
long[] offset = new long[4];
|
||||
offset[3] = fileInfo.Length - 8 * 1024;
|
||||
offset[2] = fileInfo.Length / 3;
|
||||
offset[1] = fileInfo.Length / 3 * 2;
|
||||
offset[0] = 4 * 1024;
|
||||
|
||||
MD5 md5Hash = MD5.Create();
|
||||
byte[] data = md5Hash.ComputeHash(bBuf);
|
||||
StringBuilder sBuilder = new StringBuilder();
|
||||
byte[] bBuf = new byte[1024 * 4];
|
||||
|
||||
for (int j = 0; j < data.Length; j++)
|
||||
for (int i = 0; i < 4; ++i)
|
||||
{
|
||||
sBuilder.Append(data[j].ToString("x2"));
|
||||
}
|
||||
fs.Seek(offset[i], SeekOrigin.Begin);
|
||||
await fs.ReadExactlyAsync(bBuf, 0, 4 * 1024, cancellationToken);
|
||||
|
||||
if (!string.IsNullOrEmpty(ret))
|
||||
{
|
||||
ret += ";";
|
||||
}
|
||||
using (MD5 md5Hash = MD5.Create())
|
||||
{
|
||||
byte[] data = md5Hash.ComputeHash(bBuf);
|
||||
StringBuilder sBuilder = new StringBuilder();
|
||||
|
||||
ret += sBuilder.ToString();
|
||||
for (int j = 0; j < data.Length; j++)
|
||||
{
|
||||
sBuilder.Append(data[j].ToString("x2"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(ret))
|
||||
{
|
||||
ret += ";";
|
||||
}
|
||||
|
||||
ret += sBuilder.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.Close();
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
public Task<ItemUpdateType> FetchAsync(Movie item, MetadataRefreshOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
12
Jellyfin.MeiamSub.Shooter/meta.json
Normal file
12
Jellyfin.MeiamSub.Shooter/meta.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"category": "Subtitles",
|
||||
"description": "Deprecated: the legacy Shooter API rarely returns usable results. Use Assrt or Thunder instead.",
|
||||
"guid": "038D37A2-7A1E-4C01-9B6D-AA215D29AB4C",
|
||||
"name": "MeiamSub.Shooter",
|
||||
"owner": "Meiam",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"timestamp": "2026-08-07T07:09:27.0000000Z",
|
||||
"version": "1.0.16.0",
|
||||
"status": "Deprecated",
|
||||
"imagePath": "thumb.png"
|
||||
}
|
||||
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 |
@@ -1,12 +1,25 @@
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace Jellyfin.MeiamSub.Thunder.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Plugin configuration.
|
||||
/// 插件配置类
|
||||
/// <para>修改人: Meiam (移植自 PR #133 by Mayfly777w)</para>
|
||||
/// <para>修改时间: 2026-02-11</para>
|
||||
/// </summary>
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否使用元数据中的剧集名称和季集编号搜索字幕
|
||||
/// <para>启用后,剧集使用 "{SeriesName} S{Season}E{Episode}" 格式搜索</para>
|
||||
/// <para>电影使用元数据中的影片名称搜索</para>
|
||||
/// <para>默认关闭,使用文件名搜索</para>
|
||||
/// </summary>
|
||||
public bool EnableUseMetadata { get; set; }
|
||||
|
||||
public PluginConfiguration()
|
||||
{
|
||||
EnableUseMetadata = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
65
Jellyfin.MeiamSub.Thunder/Configuration/configPage.html
Normal file
65
Jellyfin.MeiamSub.Thunder/Configuration/configPage.html
Normal file
@@ -0,0 +1,65 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>MeiamSub.Thunder</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="MeiamSubThunderConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
|
||||
data-require="emby-checkbox">
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<form id="MeiamSubThunderConfigForm">
|
||||
<div class="verticalSection">
|
||||
<h2 class="sectionTitle">迅雷字幕搜索设置</h2>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="EnableUseMetadata" name="EnableUseMetadata" type="checkbox"
|
||||
is="emby-checkbox" />
|
||||
<span>使用元数据搜索字幕</span>
|
||||
</label>
|
||||
<div class="fieldDescription">
|
||||
勾选此项后,使用元数据中的剧集名称和季集编号搜索字幕,而非文件名。
|
||||
<br />
|
||||
剧集将使用 "剧集名 S01E01" 格式;电影将使用影片名称。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||
<span>保存</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
var MeiamSubThunderConfig = {
|
||||
pluginUniqueId: 'E4CE9DA9-EF00-417C-96F2-861C512D45EB'
|
||||
};
|
||||
|
||||
document.querySelector('#MeiamSubThunderConfigPage')
|
||||
.addEventListener('pageshow', function () {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(MeiamSubThunderConfig.pluginUniqueId).then(function (config) {
|
||||
document.querySelector('#EnableUseMetadata').checked = config.EnableUseMetadata;
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector('#MeiamSubThunderConfigForm')
|
||||
.addEventListener('submit', function (e) {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(MeiamSubThunderConfig.pluginUniqueId).then(function (config) {
|
||||
config.EnableUseMetadata = document.querySelector('#EnableUseMetadata').checked;
|
||||
ApiClient.updatePluginConfiguration(MeiamSubThunderConfig.pluginUniqueId, config).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
});
|
||||
});
|
||||
e.preventDefault();
|
||||
return false;
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,10 +3,8 @@
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ApplicationIcon />
|
||||
<StartupObject />
|
||||
<Version>1.0.11</Version>
|
||||
<AssemblyVersion>1.0.11.0</AssemblyVersion>
|
||||
<Version>1.0.17.1</Version>
|
||||
<AssemblyVersion>1.0.17.1</AssemblyVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
@@ -14,11 +12,27 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.10.3" />
|
||||
<!-- 使用已验证的最低兼容基线,保持同一 DLL 可运行于 Jellyfin 10.10.7、10.11.11 和 12.1。 -->
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.10.7" />
|
||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="thumb.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Shared\SubtitleContentValidator.cs" Link="Shared\SubtitleContentValidator.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
|
||||
<Exec Command="Copy $(TargetDir)$(TargetFileName) $(SolutionDir)$(ConfigurationName)\$(TargetFileName) /y
" />
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace Jellyfin.MeiamSub.Thunder.Model
|
||||
public string Url { get; set; }
|
||||
public string Ext { get; set; }
|
||||
public string Name { get; set; }
|
||||
public int Duration { get; set; }
|
||||
public long Duration { get; set; }
|
||||
public string[] Languages { get; set; }
|
||||
|
||||
public string Langs => Languages != null ? string.Join(",", Languages) : string.Empty;
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
using Jellyfin.MeiamSub.Thunder.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Model.Drawing;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Jellyfin.MeiamSub.Thunder
|
||||
{
|
||||
/// <summary>
|
||||
/// 插件入口
|
||||
/// <para>修改人: Meiam (移植自 PR #133 by Mayfly777w)</para>
|
||||
/// <para>修改时间: 2026-02-11</para>
|
||||
/// </summary>
|
||||
public class Plugin : BasePlugin<PluginConfiguration>
|
||||
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
/// <summary>
|
||||
/// 插件ID
|
||||
@@ -35,5 +38,21 @@ namespace Jellyfin.MeiamSub.Thunder
|
||||
}
|
||||
|
||||
public static Plugin Instance { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取插件配置页面列表
|
||||
/// </summary>
|
||||
/// <returns>配置页面信息</returns>
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = Name,
|
||||
EmbeddedResourcePath = GetType().Namespace + ".Configuration.configPage.html"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,28 @@ 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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using Jellyfin.MeiamSub.Thunder.Model;
|
||||
using Jellyfin.MeiamSub.Thunder.Model;
|
||||
using MeiamSubtitles.Shared;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Controller.Subtitles;
|
||||
using MediaBrowser.Model.Providers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
@@ -19,7 +21,10 @@ using System.Threading.Tasks;
|
||||
namespace Jellyfin.MeiamSub.Thunder
|
||||
{
|
||||
/// <summary>
|
||||
/// 迅雷字幕组件
|
||||
/// 迅雷看看字幕提供程序
|
||||
/// 负责与迅雷 API 进行交互,通过 CID (Content ID) 匹配并下载字幕。
|
||||
/// <para>修改人: Meiam</para>
|
||||
/// <para>修改时间: 2025-12-22</para>
|
||||
/// </summary>
|
||||
public class ThunderProvider : ISubtitleProvider, IHasOrder
|
||||
{
|
||||
@@ -29,22 +34,26 @@ namespace Jellyfin.MeiamSub.Thunder
|
||||
public const string SRT = "srt";
|
||||
|
||||
private readonly ILogger<ThunderProvider> _logger;
|
||||
private static readonly HttpClient _httpClient = new HttpClient();
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private static readonly JsonSerializerOptions _deserializeOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
};
|
||||
|
||||
public int Order => 1;
|
||||
public int Order => 100;
|
||||
public string Name => "MeiamSub.Thunder";
|
||||
|
||||
/// <summary>
|
||||
/// 支持电影、剧集
|
||||
/// </summary>
|
||||
public IEnumerable<VideoContentType> SupportedMediaTypes => new List<VideoContentType>() { VideoContentType.Movie, VideoContentType.Episode };
|
||||
public IEnumerable<VideoContentType> SupportedMediaTypes => new[] { VideoContentType.Movie, VideoContentType.Episode };
|
||||
#endregion
|
||||
|
||||
#region 构造函数
|
||||
public ThunderProvider(ILogger<ThunderProvider> logger)
|
||||
public ThunderProvider(ILogger<ThunderProvider> logger, IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClient.Timeout = TimeSpan.FromSeconds(30);
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger.LogInformation($"{Name} Init");
|
||||
}
|
||||
#endregion
|
||||
@@ -52,90 +61,166 @@ namespace Jellyfin.MeiamSub.Thunder
|
||||
#region 查询字幕
|
||||
|
||||
/// <summary>
|
||||
/// 查询请求
|
||||
/// 搜索字幕 (ISubtitleProvider 接口实现)
|
||||
/// 根据媒体信息请求字幕列表。
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IEnumerable<RemoteSubtitleInfo>> Search(SubtitleSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation($"{Name} Search | SubtitleSearchRequest -> { JsonSerializer.Serialize(request) }");
|
||||
|
||||
var subtitles = await SearchSubtitlesAsync(request);
|
||||
|
||||
return subtitles;
|
||||
}
|
||||
/// <param name="request">包含媒体路径、语言等信息的搜索请求对象</param>
|
||||
/// <param name="cancellationToken">取消令牌</param>
|
||||
/// <returns>远程字幕信息列表</returns>
|
||||
public async Task<IEnumerable<RemoteSubtitleInfo>> Search(SubtitleSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
var subtitles = await SearchSubtitlesAsync(request, cancellationToken);
|
||||
|
||||
return subtitles;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询字幕
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <returns></returns>
|
||||
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request)
|
||||
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Language != "chi")
|
||||
// 修改人: Meiam (移植自 PR #133 by Mayfly777w)
|
||||
// 修改时间: 2026-02-11
|
||||
// 备注: 增加元数据搜索选项,可以以元数据中的系列名和季集信息来作为匹配对象
|
||||
|
||||
try
|
||||
{
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
var cid = GetCidByFile(request.MediaPath);
|
||||
|
||||
_logger.LogInformation($"{Name} Search | FileHash -> { cid }");
|
||||
|
||||
using var options = new HttpRequestMessage
|
||||
{
|
||||
Method = HttpMethod.Get,
|
||||
RequestUri = new Uri($"https://api-shoulei-ssl.xunlei.com/oracle/subtitle?&name={Path.GetFileName(request.MediaPath)}"),
|
||||
Headers =
|
||||
{
|
||||
UserAgent = { new ProductInfoHeaderValue(new ProductHeaderValue($"{Name}")) },
|
||||
Accept = { new MediaTypeWithQualityHeaderValue("*/*") },
|
||||
}
|
||||
};
|
||||
|
||||
var response = await _httpClient.SendAsync(options);
|
||||
|
||||
_logger.LogInformation($"{Name} Search | Response -> { JsonSerializer.Serialize(response) }");
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
var subtitleResponse = JsonSerializer.Deserialize<SubtitleResponseRoot>(await response.Content.ReadAsStringAsync());
|
||||
|
||||
if (subtitleResponse != null)
|
||||
if (request == null)
|
||||
{
|
||||
_logger.LogInformation($"{Name} Search | Response -> { JsonSerializer.Serialize(subtitleResponse) }");
|
||||
|
||||
var subtitles = subtitleResponse.Data.Where(m => !string.IsNullOrEmpty(m.Name));
|
||||
|
||||
var remoteSubtitleInfos = new List<RemoteSubtitleInfo>();
|
||||
|
||||
if (subtitles.Count() > 0)
|
||||
{
|
||||
foreach (var item in subtitles)
|
||||
{
|
||||
remoteSubtitleInfos.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 remoteSubtitleInfos;
|
||||
_logger.LogInformation("{Provider} Search | Request is null", Name);
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
var language = NormalizeLanguage(request.Language);
|
||||
|
||||
// 根据配置决定搜索名称:使用元数据 或 文件名
|
||||
string movieName;
|
||||
if (Plugin.Instance?.Configuration?.EnableUseMetadata == true)
|
||||
{
|
||||
if (request.ContentType == VideoContentType.Episode)
|
||||
{
|
||||
movieName = $"{request.SeriesName} S{request.ParentIndexNumber}E{request.IndexNumber}";
|
||||
}
|
||||
else if (request.ContentType == VideoContentType.Movie)
|
||||
{
|
||||
movieName = request.Name;
|
||||
}
|
||||
else
|
||||
{
|
||||
movieName = !string.IsNullOrEmpty(request.MediaPath)
|
||||
? Path.GetFileName(request.MediaPath)
|
||||
: string.Empty;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
movieName = !string.IsNullOrEmpty(request.MediaPath)
|
||||
? Path.GetFileName(request.MediaPath)
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
_logger.LogInformation(Name + " Search | Target -> " + movieName + " | Language -> " + language);
|
||||
|
||||
if (language != "chi")
|
||||
{
|
||||
_logger.LogInformation(Name + " Search | Summary -> Language not supported, skip search.");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(request.MediaPath))
|
||||
{
|
||||
_logger.LogInformation(Name + " Search | Summary -> MediaPath is empty, skip search.");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
var cid = string.Empty;
|
||||
var stopWatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
cid = await GetCidByFileAsync(request.MediaPath, cancellationToken);
|
||||
_logger.LogInformation("{Provider} Search | FileHash -> {FileHash} (Took {ElapsedMilliseconds}ms)",
|
||||
Name, cid, stopWatch.ElapsedMilliseconds);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogWarning("{Provider} Search | File hash unavailable ({ExceptionType}: {Message}), continue with name search.",
|
||||
Name, ex.GetType().Name, ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopWatch.Stop();
|
||||
}
|
||||
|
||||
using var options = new HttpRequestMessage
|
||||
{
|
||||
Method = HttpMethod.Get,
|
||||
RequestUri = new Uri($"https://api-shoulei-ssl.xunlei.com/oracle/subtitle?name={Uri.EscapeDataString(movieName)}")
|
||||
};
|
||||
|
||||
using var httpClient = _httpClientFactory.CreateClient(Name);
|
||||
|
||||
using var response = await httpClient.SendAsync(options, cancellationToken);
|
||||
|
||||
_logger.LogInformation("{Provider} Search | Response -> {StatusCode}", Name, response.StatusCode);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
var subtitleResponse = JsonSerializer.Deserialize<SubtitleResponseRoot>(await response.Content.ReadAsStringAsync(), _deserializeOptions);
|
||||
|
||||
if (subtitleResponse != null)
|
||||
{
|
||||
var subtitles = (subtitleResponse.Data ?? new List<SublistItem>())
|
||||
.Where(m => !string.IsNullOrWhiteSpace(m.Name) &&
|
||||
!string.IsNullOrWhiteSpace(m.Url) &&
|
||||
NormalizeFormat(m.Ext) != null)
|
||||
.GroupBy(m => m.Url, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(m => m.First())
|
||||
.OrderByDescending(m => !string.IsNullOrEmpty(cid) && string.Equals(cid, m.Cid, StringComparison.OrdinalIgnoreCase))
|
||||
.ThenByDescending(m => m.Langs?.Contains("简体", StringComparison.OrdinalIgnoreCase) == true)
|
||||
.Take(20)
|
||||
.ToList();
|
||||
|
||||
var remoteSubtitles = new List<RemoteSubtitleInfo>();
|
||||
|
||||
if (subtitles.Count() > 0)
|
||||
{
|
||||
foreach (var item in subtitles)
|
||||
{
|
||||
remoteSubtitles.Add(new RemoteSubtitleInfo()
|
||||
{
|
||||
Id = Base64Encode(JsonSerializer.Serialize(new DownloadSubInfo
|
||||
{
|
||||
Url = item.Url,
|
||||
Format = NormalizeFormat(item.Ext),
|
||||
Language = request.Language,
|
||||
TwoLetterISOLanguageName = request.TwoLetterISOLanguageName,
|
||||
})),
|
||||
Name = $"[MEIAMSUB] {item.Name} | {(item.Langs == string.Empty ? "未知" : item.Langs)} | 迅雷",
|
||||
Author = "Meiam ",
|
||||
ProviderName = $"{Name}",
|
||||
Format = NormalizeFormat(item.Ext),
|
||||
Comment = $"Format : {NormalizeFormat(item.Ext)}",
|
||||
IsHashMatch = !string.IsNullOrEmpty(cid) && string.Equals(cid, item.Cid, StringComparison.OrdinalIgnoreCase),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation($"{Name} Search | Summary -> Get {subtitles.Count()} Subtitles");
|
||||
|
||||
return remoteSubtitles;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{Name} Search | Exception -> {ex.Message}");
|
||||
}
|
||||
|
||||
_logger.LogInformation($"{Name} Search | Summary -> Get 0 Subtitles");
|
||||
@@ -146,16 +231,17 @@ namespace Jellyfin.MeiamSub.Thunder
|
||||
|
||||
#region 下载字幕
|
||||
/// <summary>
|
||||
/// 下载请求
|
||||
/// 获取字幕内容 (ISubtitleProvider 接口实现)
|
||||
/// 根据字幕 ID 下载具体的字幕文件流。
|
||||
/// </summary>
|
||||
/// <param name="id"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
/// <param name="id">字幕唯一标识符 (Base64 编码的 JSON 数据)</param>
|
||||
/// <param name="cancellationToken">取消令牌</param>
|
||||
/// <returns>包含字幕流的响应对象</returns>
|
||||
public async Task<SubtitleResponse> GetSubtitles(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation($"{Name} DownloadSub | Request -> {id}");
|
||||
|
||||
return await DownloadSubAsync(id);
|
||||
return await DownloadSubAsync(id, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -163,46 +249,61 @@ namespace Jellyfin.MeiamSub.Thunder
|
||||
/// </summary>
|
||||
/// <param name="info"></param>
|
||||
/// <returns></returns>
|
||||
private async Task<SubtitleResponse> DownloadSubAsync(string info)
|
||||
private async Task<SubtitleResponse> DownloadSubAsync(string info, CancellationToken cancellationToken)
|
||||
{
|
||||
var downloadSub = JsonSerializer.Deserialize<DownloadSubInfo>(Base64Decode(info));
|
||||
// 修改人: Meiam
|
||||
// 修改时间: 2025-12-22
|
||||
// 备注: 增加异常处理
|
||||
|
||||
if (downloadSub == null)
|
||||
try
|
||||
{
|
||||
return new SubtitleResponse();
|
||||
}
|
||||
var downloadSub = JsonSerializer.Deserialize<DownloadSubInfo>(Base64Decode(info));
|
||||
|
||||
_logger.LogInformation($"{Name} DownloadSub | Url -> { downloadSub.Url } | Format -> { downloadSub.Format } | Language -> { downloadSub.Language } ");
|
||||
|
||||
using var options = new HttpRequestMessage
|
||||
{
|
||||
Method = HttpMethod.Get,
|
||||
RequestUri = new Uri(downloadSub.Url),
|
||||
Headers =
|
||||
{
|
||||
UserAgent = { new ProductInfoHeaderValue(new ProductHeaderValue($"{Name}")) },
|
||||
Accept = { new MediaTypeWithQualityHeaderValue("*/*") }
|
||||
}
|
||||
};
|
||||
|
||||
var response = await _httpClient.SendAsync(options);
|
||||
|
||||
_logger.LogInformation($"{Name} DownloadSub | Response -> { response.StatusCode }");
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
var stream = await response.Content.ReadAsStreamAsync();
|
||||
|
||||
return new SubtitleResponse()
|
||||
if (downloadSub == null)
|
||||
{
|
||||
Language = downloadSub.Language,
|
||||
IsForced = false,
|
||||
Format = downloadSub.Format,
|
||||
Stream = stream,
|
||||
return new SubtitleResponse();
|
||||
}
|
||||
|
||||
_logger.LogInformation($"{Name} DownloadSub | Url -> {downloadSub.Url} | Format -> {downloadSub.Format} | Language -> {downloadSub.Language} ");
|
||||
|
||||
using var options = new HttpRequestMessage
|
||||
{
|
||||
Method = HttpMethod.Get,
|
||||
RequestUri = new Uri(downloadSub.Url)
|
||||
};
|
||||
|
||||
using var httpClient = _httpClientFactory.CreateClient(Name);
|
||||
|
||||
using var response = await httpClient.SendAsync(options, cancellationToken);
|
||||
|
||||
_logger.LogInformation($"{Name} DownloadSub | Response -> {response.StatusCode}");
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
var format = NormalizeFormat(downloadSub.Format)
|
||||
?? throw new InvalidDataException($"Unsupported subtitle format: {downloadSub.Format}");
|
||||
var data = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
var utf8Data = SubtitleContentValidator.ValidateAndConvertToUtf8(
|
||||
data,
|
||||
format,
|
||||
response.Content.Headers.ContentType?.MediaType);
|
||||
var stream = new MemoryStream(utf8Data, writable: false);
|
||||
|
||||
return new SubtitleResponse()
|
||||
{
|
||||
Language = downloadSub.Language,
|
||||
IsForced = false,
|
||||
Format = format,
|
||||
Stream = stream,
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "{Provider} DownloadSub | Exception -> [{Type}] {Message}", Name, ex.GetType().Name, ex.Message);
|
||||
}
|
||||
|
||||
return new SubtitleResponse();
|
||||
throw new InvalidDataException($"{Name} subtitle download failed.");
|
||||
|
||||
}
|
||||
#endregion
|
||||
@@ -237,53 +338,103 @@ namespace Jellyfin.MeiamSub.Thunder
|
||||
/// <returns></returns>
|
||||
protected string ExtractFormat(string text)
|
||||
{
|
||||
|
||||
string result = null;
|
||||
|
||||
if (text != null)
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
text = text.ToLower();
|
||||
if (text.Contains(ASS)) result = ASS;
|
||||
else if (text.Contains(SSA)) result = SSA;
|
||||
else if (text.Contains(SRT)) result = SRT;
|
||||
else result = null;
|
||||
return null;
|
||||
}
|
||||
return result;
|
||||
|
||||
text = text.ToLower();
|
||||
if (text.Contains(ASS)) return ASS;
|
||||
if (text.Contains(SSA)) return SSA;
|
||||
if (text.Contains(SRT)) return SRT;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeFormat(string format)
|
||||
{
|
||||
var value = format?.Trim().TrimStart('.').ToLowerInvariant();
|
||||
return value == SRT || value == ASS || value == SSA ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取文件 CID (迅雷)
|
||||
/// 规范化语言代码
|
||||
/// </summary>
|
||||
/// <param name="filePath"></param>
|
||||
/// <param name="language"></param>
|
||||
/// <returns></returns>
|
||||
private string GetCidByFile(string filePath)
|
||||
private static string NormalizeLanguage(string language)
|
||||
{
|
||||
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
|
||||
var reader = new BinaryReader(stream);
|
||||
var fileSize = new FileInfo(filePath).Length;
|
||||
var sha1 = SHA1.Create();
|
||||
var buffer = new byte[0xf000];
|
||||
if (fileSize < 0xf000)
|
||||
{
|
||||
reader.Read(buffer, 0, (int)fileSize);
|
||||
buffer = sha1.ComputeHash(buffer, 0, (int)fileSize);
|
||||
}
|
||||
else
|
||||
{
|
||||
reader.Read(buffer, 0, 0x5000);
|
||||
stream.Seek(fileSize / 3, SeekOrigin.Begin);
|
||||
reader.Read(buffer, 0x5000, 0x5000);
|
||||
stream.Seek(fileSize - 0x5000, SeekOrigin.Begin);
|
||||
reader.Read(buffer, 0xa000, 0x5000);
|
||||
if (string.IsNullOrEmpty(language)) return language;
|
||||
|
||||
buffer = sha1.ComputeHash(buffer, 0, 0xf000);
|
||||
}
|
||||
var result = "";
|
||||
foreach (var i in buffer)
|
||||
if (language.Equals("zh-CN", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zh-TW", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zh-HK", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zh", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zho", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("chi", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result += string.Format("{0:X2}", i);
|
||||
return "chi";
|
||||
}
|
||||
if (language.Equals("en", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("eng", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "eng";
|
||||
}
|
||||
return language;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步计算文件 CID (迅雷专用算法)
|
||||
/// <para>修改人: Meiam</para>
|
||||
/// <para>修改时间: 2025-12-22</para>
|
||||
/// <para>备注: 采用异步 I/O 读取文件特定位置的数据块进行 SHA1 计算。</para>
|
||||
/// </summary>
|
||||
/// <param name="filePath">文件路径</param>
|
||||
/// <returns>计算得到的 CID 字符串</returns>
|
||||
private async Task<string> GetCidByFileAsync(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
// 修改人: Meiam
|
||||
// 修改时间: 2026-06-11
|
||||
// 备注: 增加网络路径拦截,避免 FileStream 打开非本地路径抛出异常
|
||||
if (string.IsNullOrEmpty(filePath) ||
|
||||
filePath.Contains("://") ||
|
||||
filePath.StartsWith("smb://", StringComparison.OrdinalIgnoreCase) ||
|
||||
filePath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||
filePath.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning("Network path detected, skip hash calculation: {Path}", filePath);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous))
|
||||
{
|
||||
var fileSize = new FileInfo(filePath).Length;
|
||||
using (var sha1 = SHA1.Create())
|
||||
{
|
||||
var buffer = new byte[0xf000];
|
||||
if (fileSize < 0xf000)
|
||||
{
|
||||
await stream.ReadExactlyAsync(buffer, 0, (int)fileSize, cancellationToken);
|
||||
buffer = sha1.ComputeHash(buffer, 0, (int)fileSize);
|
||||
}
|
||||
else
|
||||
{
|
||||
await stream.ReadExactlyAsync(buffer, 0, 0x5000, cancellationToken);
|
||||
stream.Seek(fileSize / 3, SeekOrigin.Begin);
|
||||
await stream.ReadExactlyAsync(buffer, 0x5000, 0x5000, cancellationToken);
|
||||
stream.Seek(fileSize - 0x5000, SeekOrigin.Begin);
|
||||
await stream.ReadExactlyAsync(buffer, 0xa000, 0x5000, cancellationToken);
|
||||
|
||||
buffer = sha1.ComputeHash(buffer, 0, 0xf000);
|
||||
}
|
||||
var result = "";
|
||||
foreach (var i in buffer)
|
||||
{
|
||||
result += string.Format("{0:X2}", i);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
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.10.7.0",
|
||||
"timestamp": "2026-09-17T03:17:33.0000000Z",
|
||||
"version": "1.0.17.1",
|
||||
"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 |
201
LICENSE
Normal file
201
LICENSE
Normal file
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.10.34916.146
|
||||
@@ -7,12 +7,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Emby.MeiamSub.DevTool", "Em
|
||||
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
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.MeiamSub.Assrt", "Jellyfin.MeiamSub.Assrt\Jellyfin.MeiamSub.Assrt.csproj", "{E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Emby.MeiamSub.Assrt", "Emby.MeiamSub.Assrt\Emby.MeiamSub.Assrt.csproj", "{D276A8D0-4B17-4EEF-94B6-61D646DE5A22}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MeiamSubtitles.Tests", "tests\MeiamSubtitles.Tests\MeiamSubtitles.Tests.csproj", "{B8A8BFC1-8A0D-48C5-89AC-4DDDC2DDC3C9}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -27,18 +29,22 @@ Global
|
||||
{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
|
||||
{E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D276A8D0-4B17-4EEF-94B6-61D646DE5A22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D276A8D0-4B17-4EEF-94B6-61D646DE5A22}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D276A8D0-4B17-4EEF-94B6-61D646DE5A22}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D276A8D0-4B17-4EEF-94B6-61D646DE5A22}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B8A8BFC1-8A0D-48C5-89AC-4DDDC2DDC3C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B8A8BFC1-8A0D-48C5-89AC-4DDDC2DDC3C9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B8A8BFC1-8A0D-48C5-89AC-4DDDC2DDC3C9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B8A8BFC1-8A0D-48C5-89AC-4DDDC2DDC3C9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
203
README.md
203
README.md
@@ -1,105 +1,174 @@
|
||||
# MeiamSubtitles
|
||||
Emby Jellyfin 中文字幕插件 ,支持 迅雷影音、射手网、 精准匹配,自动下载
|
||||
|
||||
**MeiamSubtitles** 是一款专为 **Emby** 和 **Jellyfin** 媒体服务器打造的中文字幕下载插件。它集成了 **[Assrt(伪射手网)](https://assrt.net/)** 与 **迅雷影音** 的字幕搜索能力,支持影片名称与视频特征(CID)匹配,帮助您的媒体库便捷地搜索和补全中文字幕。
|
||||
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](https://github.com/91270/Emby.MeiamSub)
|
||||
[](https://github.com/91270/Emby.MeiamSub/fork)
|
||||
[](https://www.592.la/)
|
||||
<p align="left">
|
||||
<a href="https://github.com/91270/MeiamSubtitles/releases/latest"><img src="https://img.shields.io/github/v/release/91270/MeiamSubtitles" alt="Release"></a>
|
||||
<img src="https://img.shields.io/badge/.NET-Standard%202.1%20%7C%208.0-blueviolet.svg" alt=".NET Status">
|
||||
<a href="#插件与兼容性"><img src="https://img.shields.io/badge/Platform-Linux%20%7C%20Windows%20%7C%20macOS-brightgreen.svg" alt="Platform"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a>
|
||||
<a href="https://www.592.la/"><img src="https://img.shields.io/badge/博客-Meiam's%20Home-brightgreen.svg" alt="博客"></a>
|
||||
</p>
|
||||
|
||||
当前维护两个字幕源:
|
||||
|
||||
- **[Assrt(伪射手网)](https://assrt.net/)**:按影片名称搜索,支持下载并解析 ZIP、RAR 等压缩字幕;需要配置 Assrt API Token。
|
||||
- **Thunder**:根据视频文件计算 CID,优先匹配对应文件的字幕;无需额外配置。
|
||||
|
||||
|
||||
> Shooter 的旧版接口已很少返回有效字幕,自 v1.0.16.0 起停止编译、维护和发布。
|
||||
|
||||
## 给个星星! ⭐️
|
||||
## 📣 广告时间
|
||||
|
||||
如果你喜欢这个项目或者它帮助你, 请给 Star~(辛苦咯)
|
||||
> **搬瓦工 $99 年付**:建站神器重出江湖,THE PLAN V1 传家宝套餐,18 机房随意切换。
|
||||
>
|
||||
> **配置**:2 核 / 2G / 40G SSD / 1TB 流量 / 2.5Gbps,支持 CN2 GIA 等大陆优化线路。
|
||||
>
|
||||
> **当前推荐循环优惠码**:`BWHNCXNVXV`(最高约 6.81%,实际优惠以结账页验证结果为准)
|
||||
>
|
||||
> **[直达通道(传家宝套餐)](https://bwh88.net/aff.php?aff=117&pid=87)**
|
||||
|
||||
如果你能赞助稳定 Google Drive 团队盘用于媒体库插件测试, 请于我联系 91270#QQ.COM
|
||||
## 功能
|
||||
|
||||
|
||||
- 同时支持 Emby 和 Jellyfin。
|
||||
- 支持 `zho`、`chi`、`zh-CN` 等中文语言代码。
|
||||
- 使用异步文件读取计算视频特征,适用于本地及网络媒体库。
|
||||
- 对搜索结果进行格式校验、去重和排序。
|
||||
- 下载前校验响应内容,避免将空响应或错误页面保存为字幕。
|
||||
- Assrt 支持从压缩包中选择并提取 `.srt`、`.ass`、`.ssa` 字幕。
|
||||
- 日志隐藏 Assrt Token,且不记录完整接口响应。
|
||||
|
||||
## 广告时间 📣
|
||||
## 插件与兼容性
|
||||
|
||||
搬瓦工 $99 年付, 建站神器重出江湖,THE PLAN V1 传家宝套餐,18机房随意切换
|
||||
| 插件 | 平台 | 目标框架 | 字幕源 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Emby.MeiamSub.Assrt` | Emby(版本限制见下文) | .NET Standard 2.1 | [Assrt.net](https://assrt.net/) |
|
||||
| `Emby.MeiamSub.Thunder` | Emby(版本限制见下文) | .NET Standard 2.1 | Thunder XMP |
|
||||
| `Jellyfin.MeiamSub.Assrt` | Jellyfin(版本限制见下文) | .NET 8.0 | [Assrt.net](https://assrt.net/) |
|
||||
| `Jellyfin.MeiamSub.Thunder` | Jellyfin(版本限制见下文) | .NET 8.0 | Thunder XMP |
|
||||
|
||||
循环优惠码:BWHCCNCXVV(6.77%)
|
||||
| 平台 | 兼容版本 |
|
||||
| --- | --- |
|
||||
| Emby | 4.8.10.0 至 4.10.0.40 |
|
||||
| Jellyfin | 10.10.7 至 12.1 |
|
||||
|
||||
[直达通道(传家宝套餐)](https://bwh88.net/aff.php?aff=117&pid=147)
|
||||
## 版本渠道
|
||||
|
||||
|
||||
- **正式版**:版本号末位为 `0`,例如 `1.0.18.0`。正式版会发布到 GitHub Releases,并同步到 Jellyfin 稳定插件仓库。
|
||||
- **预发布版**:GitHub 只保留一个滚动更新的“预发布版”,固定使用 `preview` 标签和 `Emby_preview.zip`、`Jellyfin_preview.zip` 两个附件。
|
||||
- 每次更新预发布版时,插件内部版本末位继续递增,例如 `1.0.17.1`、`1.0.17.2`,用于识别用户实际安装的测试批次;旧测试包会被新包替换。
|
||||
- 预发布版不会写入稳定插件仓库,测试用户需要从 GitHub 的 `preview` Release 手动下载安装。
|
||||
- 正式版版本号必须高于同一轮全部预发布版。例如 `1.0.17.1`、`1.0.17.2` 验证完成后发布 `1.0.18.0`。
|
||||
- 正式版发布后,滚动预发布 Release 会转为草稿保留;开始下一轮测试时再更新并重新公开。
|
||||
|
||||
## 功能介绍
|
||||
## 安装
|
||||
|
||||
### Jellyfin 存储库
|
||||
|
||||
- [x] 支持 迅雷看看 字幕下载 Hash匹配
|
||||
- [x] 支持 射手影音 字幕下载 Hash匹配
|
||||
推荐通过插件存储库安装,以便接收后续更新。
|
||||
|
||||
1. 打开 Jellyfin 控制台的“插件 → 存储库”。
|
||||
2. 添加存储库名称 `MeiamSub`。
|
||||
3. 填写以下地址:
|
||||
|
||||
## 项目说明
|
||||
```text
|
||||
https://github.com/91270/MeiamSubtitles.Release/raw/main/Plugin/manifest-stable.json
|
||||
```
|
||||
|
||||
| # | 模块功能 | 项目文件 | 说明
|
||||
|---|-------------------------------|-------------------------------|-------------------------------
|
||||
| 1 | 开发程序 | Emby.MeiamSub.DevTool | 项目开发测试调试使用
|
||||
| 2 | 字幕插件 | Emby.MeiamSub.Thunder | 迅雷看看字幕插件 - Emby
|
||||
| 3 | 字幕插件 | Emby.MeiamSub.Shooter | 射手影音字幕插件 - Emby
|
||||
| 4 | 字幕插件 | Jellyfin.MeiamSub.Shooter | 迅雷看看字幕插件 - Jellyfin
|
||||
| 5 | 字幕插件 | Jellyfin.MeiamSub.Thunder | 射手影音字幕插件 - Jellyfin
|
||||
4. 在“目录”中分别安装 `MeiamSub.Assrt` 或 `MeiamSub.Thunder`。
|
||||
5. 重启 Jellyfin。
|
||||
|
||||
### 手动安装
|
||||
|
||||
从 [Releases](https://github.com/91270/MeiamSubtitles/releases/latest) 下载与平台对应的压缩包。
|
||||
|
||||
## 使用插件
|
||||
#### Emby
|
||||
|
||||
首先下载已编译好的插件 [LINK](https://github.com/91270/Emby.MeiamSub/releases) ,由于 Jellyfin 自身 [BUG](https://github.com/jellyfin/jellyfin/issues/12434) , 会重复下载字幕 。
|
||||
1. 解压 `Emby_v*.zip`。
|
||||
2. 将需要的插件 DLL 放入 Emby 的 `plugins` 目录。
|
||||
3. 不要额外复制 `SharpCompress.dll`;Assrt 复用 Emby 自带版本。
|
||||
4. 重启 Emby。
|
||||
|
||||
建议:媒体库不勾选本插件, 自动扫描缺失字幕时不使用该插件,但不影响手动查找字幕
|
||||
#### Jellyfin
|
||||
|
||||
### WINDOWS
|
||||
```bash
|
||||
复制插件文件到 Emby-Server\Programdata\Plugins\
|
||||
复制插件文件到 Emby-Server\System\Plugins\
|
||||
重启服务
|
||||
```
|
||||
1. 解压 `Jellyfin_v*.zip`。
|
||||
2. 将需要的整个插件目录复制到 Jellyfin 的 `plugins` 目录。
|
||||
3. Assrt 目录中的 `SharpCompress.dll` 是 Jellyfin 所需依赖,请勿单独删除。
|
||||
4. 重启 Jellyfin。
|
||||
|
||||
### LINUX
|
||||
```bash
|
||||
复制插件文件到 /opt/emby-server/system/plugins
|
||||
复制插件文件到 /var/lib/emby/plugins
|
||||
重启服务
|
||||
```
|
||||
### 从旧版本升级
|
||||
|
||||
|
||||
- 升级到 v1.0.16.0 或更高版本后,删除旧的 Shooter DLL 和插件目录;Shooter 已停止维护和发布。
|
||||
- 同一个插件不要同时保留多个版本,否则服务器可能重复加载或启动失败。
|
||||
- Emby Assrt 只需保留插件 DLL,不要额外复制 `SharpCompress.dll`。
|
||||
- Jellyfin Assrt 必须保留发布包中同目录的 `SharpCompress.dll`。
|
||||
|
||||
### 群晖
|
||||
```bash
|
||||
复制插件文件到 /var/packages/EmbyServer/var/plugins
|
||||
复制插件文件到 /var/packages/EmbyServer/target/system/plugins
|
||||
重启服务
|
||||
```
|
||||
常见插件目录:
|
||||
|
||||
### 威联通
|
||||
```bash
|
||||
# 其中`CACHEDEV{num}_DATA`的名称取决于你的qpkg安装位置
|
||||
复制插件文件到 /share/CACHEDEV1_DATA/.qpkg/EmbyServer/programdata/plugins
|
||||
复制插件文件到 /share/CACHEDEV1_DATA/.qpkg/EmbyServer/system/plugins
|
||||
重启服务
|
||||
```
|
||||
| 环境 | 目录示例 |
|
||||
| --- | --- |
|
||||
| Jellyfin Windows | `%LOCALAPPDATA%\jellyfin\plugins` |
|
||||
| Jellyfin Linux | `/var/lib/jellyfin/plugins` |
|
||||
| Emby Windows | `%APPDATA%\Emby-Server\programdata\plugins` |
|
||||
| Docker | 容器映射的 `/config/plugins` |
|
||||
|
||||
## 配置
|
||||
|
||||
### Jellyfin 可通过存储库安装、更新插件
|
||||
```bash
|
||||
# 通过 控制台 -> 插件 -> 存储库 添加存储库 URL , 即可通过插件目录查看并安装插件
|
||||
https://github.com/91270/MeiamSubtitles.Release/raw/main/Plugin/manifest-stable.json
|
||||
```
|
||||
### Assrt
|
||||
|
||||
|
||||
1. 从 [Assrt](https://assrt.net/) 获取 API Token。
|
||||
2. 打开插件配置页,将 Token 填入 `Assrt API Token`。
|
||||
3. 在媒体库的字幕下载器中启用 `MeiamSub.Assrt`。
|
||||
|
||||
## 贡献
|
||||
未配置 Token 时,Assrt 会跳过搜索并在日志中给出提示。
|
||||
|
||||
贡献的最简单的方法之一就是是参与讨论和讨论问题(issue)。你也可以通过提交的 Pull Request 代码变更作出贡献。
|
||||
### Thunder
|
||||
|
||||
## 致谢
|
||||
Thunder 默认使用视频文件计算 CID。Jellyfin 版本还可在插件设置中启用元数据搜索,用影片名称辅助匹配。
|
||||
|
||||
[Emby.Subtitle.Subscene](https://github.com/nRafinia/Emby.Subtitle.Subscene)
|
||||
## 自动下载说明
|
||||
|
||||
建议先通过“搜索字幕”手动确认匹配效果,再决定是否启用媒体库自动下载。
|
||||
|
||||
- 同一媒体库的字幕下载语言只应添加一次。例如只保留一个 `zho`,重复添加会让服务器发起多次下载请求。
|
||||
- Emby 与 Jellyfin 如果共用媒体目录,会采用不同的字幕文件命名方式,因此目录中可能同时出现 `.zh-CN.*` 和 `.zho.*` 文件。
|
||||
- `SearchAllProviders` 表示同时搜索多个字幕源,不代表下载全部搜索结果。
|
||||
- 自动下载行为由 Emby/Jellyfin 调度;插件只响应服务器发出的搜索与下载请求。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 搜不到字幕
|
||||
|
||||
- 确认字幕语言已选择中文,并使用最新版插件。
|
||||
- Assrt 用户请确认 Token 已填写且有效。
|
||||
- Thunder 依赖视频文件内容计算 CID,请确认服务器能够读取媒体文件。
|
||||
- 某些影片或版本可能没有对应资源,可尝试启用元数据搜索或手动选择其他结果。
|
||||
|
||||
### 自动生成了多个字幕文件
|
||||
|
||||
首先检查媒体库的“字幕下载语言”是否存在重复项。服务器会为每个语言项分别执行一次下载,产生 `.0`、`.1` 等后缀。
|
||||
|
||||
手动连续下载不同的搜索结果时,服务器也会为避免覆盖已有字幕而添加数字后缀。这属于 Emby/Jellyfin 的文件保存行为,不表示插件正在循环下载。
|
||||
|
||||
### Assrt 提示找不到 SharpCompress
|
||||
|
||||
- Emby:仅安装插件 DLL,不要携带其他版本的 `SharpCompress.dll`。
|
||||
- Jellyfin:确保 Assrt 插件目录中包含发布包附带的 `SharpCompress.dll`。
|
||||
|
||||
### 如何反馈问题
|
||||
|
||||
请在 [Issues](https://github.com/91270/MeiamSubtitles/issues) 中提供:
|
||||
|
||||
- Emby/Jellyfin 版本与操作系统;
|
||||
- 插件名称和版本;
|
||||
- 媒体文件名、容器格式及问题复现步骤;
|
||||
- 对应时间段的服务器日志。
|
||||
|
||||
提交日志前请删除 Token、媒体路径和其他隐私信息。
|
||||
|
||||
## 支持项目
|
||||
|
||||
欢迎提交 Issue、Pull Request,或者为项目点一个 Star。
|
||||
|
||||
## License
|
||||
|
||||
[Apache License 2.0](LICENSE)
|
||||
|
||||
142
Shared/SubtitleContentValidator.cs
Normal file
142
Shared/SubtitleContentValidator.cs
Normal file
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace MeiamSubtitles.Shared
|
||||
{
|
||||
internal static class SubtitleContentValidator
|
||||
{
|
||||
private static readonly Encoding StrictUtf8 = new UTF8Encoding(false, true);
|
||||
private static readonly Encoding StrictUtf16Le = new UnicodeEncoding(false, true, true);
|
||||
private static readonly Encoding StrictUtf16Be = new UnicodeEncoding(true, true, true);
|
||||
|
||||
static SubtitleContentValidator()
|
||||
{
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
}
|
||||
|
||||
public static void Validate(byte[] data, string format, string mediaType)
|
||||
{
|
||||
DecodeAndValidate(data, format, mediaType);
|
||||
}
|
||||
|
||||
public static byte[] ValidateAndConvertToUtf8(byte[] data, string format, string mediaType)
|
||||
{
|
||||
var text = DecodeAndValidate(data, format, mediaType);
|
||||
return Encoding.UTF8.GetBytes(text);
|
||||
}
|
||||
|
||||
private static string DecodeAndValidate(byte[] data, string format, string mediaType)
|
||||
{
|
||||
if (data == null || data.Length < 8)
|
||||
{
|
||||
throw new InvalidDataException("Subtitle response is empty.");
|
||||
}
|
||||
|
||||
if ((data[0] == (byte)'P' && data[1] == (byte)'K') ||
|
||||
(data.Length >= 7 && Encoding.ASCII.GetString(data, 0, 7) == "Rar!\u001a\u0007"))
|
||||
{
|
||||
throw new InvalidDataException("Compressed subtitle responses are not supported by Thunder.");
|
||||
}
|
||||
|
||||
if (mediaType?.IndexOf("html", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
mediaType?.IndexOf("json", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
throw new InvalidDataException("Thunder returned an error document instead of a subtitle.");
|
||||
}
|
||||
|
||||
var errorDocument = false;
|
||||
foreach (var sample in DecodeSamples(data))
|
||||
{
|
||||
if (sample.StartsWith("<", StringComparison.Ordinal) ||
|
||||
sample.StartsWith("{", StringComparison.Ordinal))
|
||||
{
|
||||
errorDocument = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsValidSubtitle(sample, format))
|
||||
{
|
||||
return sample;
|
||||
}
|
||||
|
||||
if (sample.StartsWith("[", StringComparison.Ordinal))
|
||||
{
|
||||
errorDocument = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (errorDocument)
|
||||
{
|
||||
throw new InvalidDataException("Thunder returned an error document instead of a subtitle.");
|
||||
}
|
||||
|
||||
throw new InvalidDataException($"Downloaded content is not a valid {format} subtitle.");
|
||||
}
|
||||
|
||||
private static bool IsValidSubtitle(string sample, string format)
|
||||
{
|
||||
return format == "srt"
|
||||
? sample.IndexOf("-->", StringComparison.Ordinal) >= 0
|
||||
: sample.IndexOf("[Script Info]", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
sample.IndexOf("Dialogue:", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> DecodeSamples(byte[] data)
|
||||
{
|
||||
// Decode the complete subtitle because the selected text is returned to
|
||||
// the host after validation and must not be truncated to a probe window.
|
||||
var length = data.Length;
|
||||
|
||||
if (data.Length >= 2)
|
||||
{
|
||||
if (data[0] == 0xFF && data[1] == 0xFE)
|
||||
{
|
||||
yield return DecodeSample(data, length, StrictUtf16Le);
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (data[0] == 0xFE && data[1] == 0xFF)
|
||||
{
|
||||
yield return DecodeSample(data, length, StrictUtf16Be);
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.Length >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF)
|
||||
{
|
||||
yield return DecodeSample(data, length, StrictUtf8);
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Thunder may return UTF-16 or GB18030 subtitles without a BOM. Decode
|
||||
// strictly so invalid UTF-8 cannot hide the actual legacy encoding.
|
||||
foreach (var encoding in new[] { StrictUtf8, StrictUtf16Le, StrictUtf16Be, GetGb18030Encoding() })
|
||||
{
|
||||
string sample;
|
||||
try
|
||||
{
|
||||
sample = DecodeSample(data, length, encoding);
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return sample;
|
||||
}
|
||||
}
|
||||
|
||||
private static Encoding GetGb18030Encoding()
|
||||
{
|
||||
return Encoding.GetEncoding(54936, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback);
|
||||
}
|
||||
|
||||
private static string DecodeSample(byte[] data, int length, Encoding encoding)
|
||||
{
|
||||
return encoding.GetString(data, 0, length)
|
||||
.TrimStart('\uFEFF', ' ', '\r', '\n', '\t');
|
||||
}
|
||||
}
|
||||
}
|
||||
360
publish.ps1
Normal file
360
publish.ps1
Normal file
@@ -0,0 +1,360 @@
|
||||
param (
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Version,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("Stable", "Preview")]
|
||||
[string]$Channel = "Stable",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$JellyfinVersion,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$EmbyVersion,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Notes,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$PublishToGitHub,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$ValidateOnly
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$SupportedEmbySdkVersion = "4.8.10"
|
||||
$SupportedJellyfinSdkVersion = "10.10.7"
|
||||
$RepositoryRoot = (Resolve-Path $PSScriptRoot).Path.Replace('\', '/')
|
||||
$GitArguments = @("-c", "safe.directory=$RepositoryRoot")
|
||||
$PreviewTag = "preview"
|
||||
|
||||
# Define paths
|
||||
$ThunderCsproj = "Jellyfin.MeiamSub.Thunder\Jellyfin.MeiamSub.Thunder.csproj"
|
||||
$ThunderMeta = "Jellyfin.MeiamSub.Thunder\meta.json"
|
||||
$AssrtCsproj = "Jellyfin.MeiamSub.Assrt\Jellyfin.MeiamSub.Assrt.csproj"
|
||||
$AssrtMeta = "Jellyfin.MeiamSub.Assrt\meta.json"
|
||||
$EmbyThunderCsproj = "Emby.MeiamSub.Thunder\Emby.MeiamSub.Thunder.csproj"
|
||||
$EmbyAssrtCsproj = "Emby.MeiamSub.Assrt\Emby.MeiamSub.Assrt.csproj"
|
||||
|
||||
if ($Version -notmatch '^\d+\.\d+\.\d+\.\d+$') {
|
||||
throw "版本号必须使用四段数字格式,例如 1.0.18.0 或 1.0.17.1。"
|
||||
}
|
||||
|
||||
$VersionParts = $Version.Split('.') | ForEach-Object { [int]$_ }
|
||||
$Revision = $VersionParts[3]
|
||||
|
||||
if ($Channel -eq "Stable" -and $Revision -ne 0) {
|
||||
throw "正式版版本号末位必须为 0,例如 1.0.18.0。"
|
||||
}
|
||||
|
||||
if ($Channel -eq "Preview" -and $Revision -eq 0) {
|
||||
throw "预发布版版本号末位必须大于 0,例如 1.0.17.1。"
|
||||
}
|
||||
|
||||
if ($Channel -eq "Preview" -and $PublishToGitHub -and [string]::IsNullOrWhiteSpace($Notes)) {
|
||||
throw "发布滚动预发布版时必须通过 -Notes 说明本轮测试重点。"
|
||||
}
|
||||
|
||||
$CurrentVersionMatch = [regex]::Match((Get-Content -LiteralPath $ThunderCsproj -Raw), '<Version>([\d\.]+)</Version>')
|
||||
if (-not $CurrentVersionMatch.Success) {
|
||||
throw "无法读取当前插件版本。"
|
||||
}
|
||||
$CurrentVersion = [version]$CurrentVersionMatch.Groups[1].Value
|
||||
if ([version]$Version -le $CurrentVersion) {
|
||||
throw "目标版本 $Version 必须高于当前版本 $CurrentVersion。"
|
||||
}
|
||||
|
||||
$WorkingTreeChanges = @(git @GitArguments status --porcelain)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "无法检查 Git 工作树状态。"
|
||||
}
|
||||
|
||||
if ($WorkingTreeChanges.Count -gt 0) {
|
||||
throw "发布前工作树必须保持干净,请先提交或处理现有变更。"
|
||||
}
|
||||
|
||||
if ($Channel -eq "Stable") {
|
||||
$ExistingLocalTag = git @GitArguments tag --list "v$Version"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "无法检查本地 Git 标签。"
|
||||
}
|
||||
if ($ExistingLocalTag) {
|
||||
throw "本地标签 v$Version 已存在,禁止覆盖已经使用的正式版本。"
|
||||
}
|
||||
|
||||
if ($PublishToGitHub) {
|
||||
git @GitArguments ls-remote --exit-code --tags origin "refs/tags/v$Version" | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
throw "远端标签 v$Version 已存在,禁止覆盖已经公开的正式版本。"
|
||||
}
|
||||
if ($LASTEXITCODE -ne 2) {
|
||||
throw "无法检查远端标签,请检查网络或 Git 认证。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ChannelLabel = if ($Channel -eq "Preview") { "预发布" } else { "正式发布" }
|
||||
Write-Host "=== 开始${ChannelLabel}流程,目标版本:$Version ===" -ForegroundColor Cyan
|
||||
|
||||
if ($ValidateOnly) {
|
||||
Write-Host "发布参数、工作树和标签检查通过。" -ForegroundColor Green
|
||||
return
|
||||
}
|
||||
|
||||
# 1. Get current UTC timestamp
|
||||
$Timestamp = [DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.0000000Z")
|
||||
Write-Host "Current package timestamp (UTC): $Timestamp" -ForegroundColor Gray
|
||||
|
||||
# Update NuGet package version if specified
|
||||
if ($JellyfinVersion -and $JellyfinVersion -ne $SupportedJellyfinSdkVersion) {
|
||||
throw "Jellyfin SDK must remain at $SupportedJellyfinSdkVersion for the verified 10.10.7-12.1 compatibility range. Update the compatibility matrix before changing it."
|
||||
}
|
||||
|
||||
if ($JellyfinVersion) {
|
||||
Write-Host "Upgrading Jellyfin.Controller NuGet dependency to $JellyfinVersion..." -ForegroundColor Yellow
|
||||
(Get-Content $ThunderCsproj) -replace '<PackageReference Include="Jellyfin.Controller" Version="[^"]+" />', "<PackageReference Include=`"Jellyfin.Controller`" Version=`"$JellyfinVersion`" />" | Set-Content $ThunderCsproj
|
||||
(Get-Content $AssrtCsproj) -replace '<PackageReference Include="Jellyfin.Controller" Version="[^"]+" />', "<PackageReference Include=`"Jellyfin.Controller`" Version=`"$JellyfinVersion`" />" | Set-Content $AssrtCsproj
|
||||
}
|
||||
|
||||
if ($EmbyVersion -and $EmbyVersion -ne $SupportedEmbySdkVersion) {
|
||||
throw "Emby SDK must remain at $SupportedEmbySdkVersion for the verified 4.8.10.0-4.10.0.40 compatibility range. Update the compatibility matrix before changing it."
|
||||
}
|
||||
|
||||
if ($EmbyVersion) {
|
||||
Write-Host "Upgrading MediaBrowser NuGet dependencies to $EmbyVersion..." -ForegroundColor Yellow
|
||||
(Get-Content $EmbyThunderCsproj) -replace '<PackageReference Include="MediaBrowser.Common" Version="[^"]+" />', "<PackageReference Include=`"MediaBrowser.Common`" Version=`"$EmbyVersion`" />" `
|
||||
-replace '<PackageReference Include="MediaBrowser.Server.Core" Version="[^"]+" />', "<PackageReference Include=`"MediaBrowser.Server.Core`" Version=`"$EmbyVersion`" />" | Set-Content $EmbyThunderCsproj
|
||||
(Get-Content $EmbyAssrtCsproj) -replace '<PackageReference Include="MediaBrowser.Common" Version="[^"]+" />', "<PackageReference Include=`"MediaBrowser.Common`" Version=`"$EmbyVersion`" />" `
|
||||
-replace '<PackageReference Include="MediaBrowser.Server.Core" Version="[^"]+" />', "<PackageReference Include=`"MediaBrowser.Server.Core`" Version=`"$EmbyVersion`" />" | Set-Content $EmbyAssrtCsproj
|
||||
}
|
||||
|
||||
Write-Host "Updating version and timestamp in project files..." -ForegroundColor Yellow
|
||||
|
||||
# Update Jellyfin Thunder
|
||||
(Get-Content $ThunderCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
|
||||
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" | Set-Content $ThunderCsproj
|
||||
|
||||
(Get-Content $ThunderMeta) -replace '"version":\s*"[\d\.]+"', "`"version`": `"$Version`"" `
|
||||
-replace '"timestamp":\s*"[^"]+"', "`"timestamp`": `"$Timestamp`"" | Set-Content $ThunderMeta
|
||||
|
||||
# Update Jellyfin Assrt
|
||||
(Get-Content $AssrtCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
|
||||
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" `
|
||||
-replace '<FileVersion>[\d\.]+</FileVersion>', "<FileVersion>$Version</FileVersion>" | Set-Content $AssrtCsproj
|
||||
|
||||
(Get-Content $AssrtMeta) -replace '"version":\s*"[\d\.]+"', "`"version`": `"$Version`"" `
|
||||
-replace '"timestamp":\s*"[^"]+"', "`"timestamp`": `"$Timestamp`"" | Set-Content $AssrtMeta
|
||||
|
||||
# Update Emby Thunder
|
||||
(Get-Content $EmbyThunderCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
|
||||
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" `
|
||||
-replace '<FileVersion>[\d\.]+</FileVersion>', "<FileVersion>$Version</FileVersion>" | Set-Content $EmbyThunderCsproj
|
||||
|
||||
# Update Emby Assrt
|
||||
(Get-Content $EmbyAssrtCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
|
||||
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" `
|
||||
-replace '<FileVersion>[\d\.]+</FileVersion>', "<FileVersion>$Version</FileVersion>" | Set-Content $EmbyAssrtCsproj
|
||||
|
||||
Write-Host "Versions and timestamps updated successfully!" -ForegroundColor Green
|
||||
|
||||
# 2. Compile solution
|
||||
Write-Host "Compiling solution in Release mode..." -ForegroundColor Yellow
|
||||
dotnet build MeiamSubtitles.sln -c Release
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "解决方案编译失败。"
|
||||
}
|
||||
|
||||
Write-Host "Running automated tests..." -ForegroundColor Yellow
|
||||
dotnet test tests\MeiamSubtitles.Tests\MeiamSubtitles.Tests.csproj -c Release --no-restore
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "自动测试失败。"
|
||||
}
|
||||
|
||||
# 3. Create zip archives
|
||||
Write-Host "Packaging zip archives..." -ForegroundColor Yellow
|
||||
$TempDir = "Release\temp_pack"
|
||||
$EmbyPackDir = "$TempDir\Emby"
|
||||
$JellyfinThunderPackDir = "$TempDir\Jellyfin\Jellyfin.MeiamSub.Thunder_$Version"
|
||||
$JellyfinAssrtPackDir = "$TempDir\Jellyfin\Jellyfin.MeiamSub.Assrt_$Version"
|
||||
|
||||
# Clean temp directories
|
||||
if (Test-Path $TempDir) { Remove-Item -Recurse -Force $TempDir }
|
||||
New-Item -ItemType Directory -Force -Path $EmbyPackDir | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $JellyfinThunderPackDir | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $JellyfinAssrtPackDir | Out-Null
|
||||
|
||||
# Copy Emby DLLs
|
||||
Copy-Item Release\Emby.MeiamSub.Thunder.dll $EmbyPackDir\
|
||||
Copy-Item Release\Emby.MeiamSub.Assrt.dll $EmbyPackDir\
|
||||
|
||||
# Copy Jellyfin Thunder artifacts
|
||||
Copy-Item Release\Jellyfin.MeiamSub.Thunder.dll $JellyfinThunderPackDir\
|
||||
Copy-Item Jellyfin.MeiamSub.Thunder\meta.json $JellyfinThunderPackDir\
|
||||
Copy-Item Jellyfin.MeiamSub.Thunder\thumb.png $JellyfinThunderPackDir\
|
||||
|
||||
# Copy Jellyfin Assrt artifacts
|
||||
Copy-Item Release\Jellyfin.MeiamSub.Assrt.dll $JellyfinAssrtPackDir\
|
||||
Copy-Item Release\dependencies\Jellyfin\* $JellyfinAssrtPackDir\
|
||||
Copy-Item Jellyfin.MeiamSub.Assrt\meta.json $JellyfinAssrtPackDir\
|
||||
Copy-Item Jellyfin.MeiamSub.Assrt\thumb.png $JellyfinAssrtPackDir\
|
||||
|
||||
# Archive target paths. Preview assets use stable names because the single
|
||||
# rolling preview release replaces them on every test iteration.
|
||||
if ($Channel -eq "Preview") {
|
||||
$EmbyZip = "Release\Emby_preview.zip"
|
||||
$JellyfinZip = "Release\Jellyfin_preview.zip"
|
||||
} else {
|
||||
$EmbyZip = "Release\Emby_v$Version.zip"
|
||||
$JellyfinZip = "Release\Jellyfin_v$Version.zip"
|
||||
}
|
||||
|
||||
if (Test-Path $EmbyZip) { Remove-Item -Force $EmbyZip }
|
||||
if (Test-Path $JellyfinZip) { Remove-Item -Force $JellyfinZip }
|
||||
|
||||
# Compress
|
||||
Compress-Archive -Path "$EmbyPackDir\*" -DestinationPath $EmbyZip -Force
|
||||
Compress-Archive -Path "$TempDir\Jellyfin\*" -DestinationPath $JellyfinZip -Force
|
||||
|
||||
# Cleanup temp
|
||||
Remove-Item -Recurse -Force $TempDir
|
||||
|
||||
Write-Host "=== Packaging Finished successfully! ===" -ForegroundColor Green
|
||||
Write-Host "Emby Package: $EmbyZip" -ForegroundColor Gray
|
||||
Write-Host "Jellyfin Package: $JellyfinZip" -ForegroundColor Gray
|
||||
|
||||
# 4. Publish to GitHub Release if specified
|
||||
if ($PublishToGitHub) {
|
||||
Write-Host "=== Start GitHub Release process ===" -ForegroundColor Cyan
|
||||
|
||||
Write-Host "Staging and committing release changes..." -ForegroundColor Yellow
|
||||
$ReleaseMetadataFiles = @(
|
||||
$ThunderCsproj,
|
||||
$ThunderMeta,
|
||||
$AssrtCsproj,
|
||||
$AssrtMeta,
|
||||
$EmbyThunderCsproj,
|
||||
$EmbyAssrtCsproj
|
||||
)
|
||||
git @GitArguments add -- $ReleaseMetadataFiles
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "暂存发布版本文件失败。"
|
||||
}
|
||||
|
||||
git @GitArguments diff --cached --check
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "暂存内容存在空白错误。"
|
||||
}
|
||||
|
||||
$CommitMessage = if ($Channel -eq "Preview") {
|
||||
"发布: 更新滚动预发布版至 v$Version"
|
||||
} else {
|
||||
"发布: v$Version"
|
||||
}
|
||||
git @GitArguments commit -m $CommitMessage
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "创建发布提交失败。"
|
||||
}
|
||||
|
||||
Write-Host "Pushing changes to remote repository..." -ForegroundColor Yellow
|
||||
git @GitArguments push origin master
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "推送发布提交失败。"
|
||||
}
|
||||
|
||||
if ($Channel -eq "Preview") {
|
||||
Write-Host "Updating rolling GitHub preview release..." -ForegroundColor Yellow
|
||||
$CommitSha = git @GitArguments rev-parse HEAD
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "无法读取预发布提交哈希。"
|
||||
}
|
||||
|
||||
$EmbyHash = (Get-FileHash -LiteralPath $EmbyZip -Algorithm SHA256).Hash
|
||||
$JellyfinHash = (Get-FileHash -LiteralPath $JellyfinZip -Algorithm SHA256).Hash
|
||||
$PreviewNotesFile = "Release\preview-notes.md"
|
||||
$PreviewNotes = @"
|
||||
## 当前测试版本
|
||||
|
||||
- 插件版本:$Version
|
||||
- 源码提交:$CommitSha
|
||||
- Emby 包 SHA-256:$EmbyHash
|
||||
- Jellyfin 包 SHA-256:$JellyfinHash
|
||||
|
||||
## 本轮测试重点
|
||||
|
||||
$Notes
|
||||
|
||||
## 安装说明
|
||||
|
||||
停止 Emby 或 Jellyfin,移除旧版 Thunder DLL 或插件目录,再安装本页最新测试包并重启。反馈问题时请注明插件版本和宿主版本。
|
||||
"@
|
||||
Set-Content -LiteralPath $PreviewNotesFile -Value $PreviewNotes -Encoding utf8
|
||||
|
||||
try {
|
||||
# The user requested one rolling preview. Only this dedicated tag
|
||||
# may be force-moved; branches and formal release tags remain immutable.
|
||||
git @GitArguments tag --force $PreviewTag $CommitSha
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "更新本地 preview 标签失败。"
|
||||
}
|
||||
git @GitArguments push origin "refs/tags/$PreviewTag" --force
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "更新远端 preview 标签失败。"
|
||||
}
|
||||
|
||||
& gh release view $PreviewTag *> $null
|
||||
$PreviewReleaseExists = $LASTEXITCODE -eq 0
|
||||
|
||||
if ($PreviewReleaseExists) {
|
||||
& gh release upload $PreviewTag $EmbyZip $JellyfinZip --clobber
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "覆盖滚动预发布附件失败;请检查仓库是否启用了 Release 不可变保护。"
|
||||
}
|
||||
& gh release edit $PreviewTag --title "预发布版" --notes-file $PreviewNotesFile --prerelease --draft=false --latest=false
|
||||
} else {
|
||||
& gh release create $PreviewTag $EmbyZip $JellyfinZip --title "预发布版" --verify-tag --notes-file $PreviewNotesFile --prerelease --latest=false
|
||||
}
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "创建或更新滚动预发布 Release 失败。"
|
||||
}
|
||||
} finally {
|
||||
if (Test-Path -LiteralPath $PreviewNotesFile) {
|
||||
Remove-Item -LiteralPath $PreviewNotesFile -Force
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "滚动预发布已更新:不要修改 manifest-stable.json。" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Creating stable GitHub Release v$Version and uploading assets..." -ForegroundColor Yellow
|
||||
$ReleaseArguments = @(
|
||||
"release", "create", "v$Version",
|
||||
$EmbyZip,
|
||||
$JellyfinZip,
|
||||
"--title", "v$Version",
|
||||
"--target", "master",
|
||||
"--latest"
|
||||
)
|
||||
if ($Notes) {
|
||||
$ReleaseArguments += @("--notes", $Notes, "--generate-notes")
|
||||
} else {
|
||||
$ReleaseArguments += "--generate-notes"
|
||||
}
|
||||
& gh @ReleaseArguments
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "创建正式 GitHub Release 失败。"
|
||||
}
|
||||
|
||||
& gh release view $PreviewTag *> $null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
& gh release edit $PreviewTag --draft
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "正式版已创建,但隐藏滚动预发布 Release 失败。"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "正式版 GitHub Release 已创建,请继续更新 MeiamSubtitles.Release 的稳定清单。" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host "=== GitHub Release finished successfully! ===" -ForegroundColor Green
|
||||
}
|
||||
22
tests/MeiamSubtitles.Tests/MeiamSubtitles.Tests.csproj
Normal file
22
tests/MeiamSubtitles.Tests/MeiamSubtitles.Tests.csproj
Normal file
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="8.0.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\..\Shared\SubtitleContentValidator.cs" Link="Shared\SubtitleContentValidator.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
145
tests/MeiamSubtitles.Tests/SubtitleContentValidatorTests.cs
Normal file
145
tests/MeiamSubtitles.Tests/SubtitleContentValidatorTests.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using MeiamSubtitles.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace MeiamSubtitles.Tests;
|
||||
|
||||
public class SubtitleContentValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void AcceptsAssWithScriptInfoHeader()
|
||||
{
|
||||
var data = Encoding.UTF8.GetBytes("[Script Info]\r\nScriptType: v4.00+\r\n[Events]\r\nDialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,Hello");
|
||||
|
||||
SubtitleContentValidator.Validate(data, "ass", "application/octet-stream");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AcceptsUtf16LittleEndianSrtWithoutBom()
|
||||
{
|
||||
var data = Encoding.Unicode.GetBytes("1\r\n00:00:01,000 --> 00:00:02,000\r\nHello\r\n");
|
||||
|
||||
SubtitleContentValidator.Validate(data, "srt", "application/octet-stream");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AcceptsUtf16LittleEndianSrtWithoutBomWithLongChineseCue()
|
||||
{
|
||||
var content = "1\r\n00:00:01,000 --> 00:00:20,000\r\n" + new string('中', 300) + "\r\n";
|
||||
var data = Encoding.Unicode.GetBytes(content);
|
||||
|
||||
SubtitleContentValidator.Validate(data, "srt", "application/octet-stream");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertsGb18030SrtToUtf8ForPreview()
|
||||
{
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
var content = "1\r\n00:00:01,000 --> 00:00:02,000\r\n中文字幕\r\n";
|
||||
var data = Encoding.GetEncoding(54936).GetBytes(content);
|
||||
|
||||
var normalized = SubtitleContentValidator.ValidateAndConvertToUtf8(
|
||||
data,
|
||||
"srt",
|
||||
"application/octet-stream");
|
||||
|
||||
Assert.Equal(content, Encoding.UTF8.GetString(normalized));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreservesCompleteUtf8SubtitleWhenNormalizing()
|
||||
{
|
||||
var content = "1\r\n00:00:01,000 --> 00:00:20,000\r\n" + new string('中', 3000) + "\r\n";
|
||||
|
||||
var normalized = SubtitleContentValidator.ValidateAndConvertToUtf8(
|
||||
Encoding.UTF8.GetBytes(content),
|
||||
"srt",
|
||||
"application/octet-stream");
|
||||
|
||||
Assert.Equal(content, Encoding.UTF8.GetString(normalized));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void ConvertsUtf16SrtToUtf8ForPreview(bool bigEndian)
|
||||
{
|
||||
var encoding = new UnicodeEncoding(bigEndian, true);
|
||||
var content = "1\r\n00:00:01,000 --> 00:00:02,000\r\n中文字幕\r\n";
|
||||
var data = encoding.GetPreamble().Concat(encoding.GetBytes(content)).ToArray();
|
||||
|
||||
var normalized = SubtitleContentValidator.ValidateAndConvertToUtf8(
|
||||
data,
|
||||
"srt",
|
||||
"application/octet-stream");
|
||||
|
||||
Assert.Equal(content, Encoding.UTF8.GetString(normalized));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void ConvertsUtf16WithoutBomToUtf8ForPreview(bool bigEndian)
|
||||
{
|
||||
var encoding = new UnicodeEncoding(bigEndian, false);
|
||||
var content = "1\r\n00:00:01,000 --> 00:00:02,000\r\n中文字幕\r\n";
|
||||
|
||||
var normalized = SubtitleContentValidator.ValidateAndConvertToUtf8(
|
||||
encoding.GetBytes(content),
|
||||
"srt",
|
||||
"application/octet-stream");
|
||||
|
||||
Assert.Equal(content, Encoding.UTF8.GetString(normalized));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(true, true)]
|
||||
public void AcceptsUtf16SrtInEitherByteOrder(bool bigEndian, bool withBom)
|
||||
{
|
||||
var encoding = new UnicodeEncoding(bigEndian, withBom);
|
||||
var content = "1\r\n00:00:01,000 --> 00:00:02,000\r\n你好\r\n";
|
||||
var body = encoding.GetBytes(content);
|
||||
var data = withBom ? encoding.GetPreamble().Concat(body).ToArray() : body;
|
||||
|
||||
SubtitleContentValidator.Validate(data, "srt", "application/octet-stream");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("<html><body>temporary error</body></html>", "application/octet-stream")]
|
||||
[InlineData("{\"error\":\"temporary error\"}", "application/json")]
|
||||
[InlineData("[\"temporary error\"]", "application/octet-stream")]
|
||||
public void RejectsErrorDocuments(string content, string mediaType)
|
||||
{
|
||||
var exception = Assert.Throws<InvalidDataException>(() =>
|
||||
SubtitleContentValidator.Validate(Encoding.UTF8.GetBytes(content), "srt", mediaType));
|
||||
|
||||
Assert.Contains("error document", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsCompressedSubtitleResponse()
|
||||
{
|
||||
var data = new byte[] { (byte)'P', (byte)'K', 3, 4, 5, 6, 7, 8 };
|
||||
|
||||
var exception = Assert.Throws<InvalidDataException>(() =>
|
||||
SubtitleContentValidator.Validate(data, "srt", "application/octet-stream"));
|
||||
|
||||
Assert.Contains("Compressed", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("<html><!-- service unavailable --><body>Error</body></html>", "srt")]
|
||||
[InlineData("{\"error\":\"download --> failed\"}", "srt")]
|
||||
[InlineData("<html><body>Dialogue: download failed</body></html>", "ass")]
|
||||
public void RejectsErrorDocumentsThatContainSubtitleMarkers(string content, string format)
|
||||
{
|
||||
var exception = Assert.Throws<InvalidDataException>(() =>
|
||||
SubtitleContentValidator.Validate(Encoding.UTF8.GetBytes(content), format, "application/octet-stream"));
|
||||
|
||||
Assert.Contains("error document", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user