17 Commits

Author SHA1 Message Date
Meiam
eb44362717 release: v1.0.17.0 2026-09-16 16:18:27 +08:00
Meiam
9455450cf2 fix: broaden Emby and Jellyfin compatibility 2026-09-16 16:10:15 +08:00
91270
c473839f4d Merge pull request #150 from 91270/codex/pr-149-takeover
修复 Thunder 对合法 ASS/SSA 与 UTF-16 字幕的误拒绝,并加强错误响应校验。
2026-08-31 10:16:02 +08:00
Meiam
46bc076b8f fix: strengthen Thunder subtitle validation 2026-08-31 10:12:47 +08:00
yjt
7ec1583bd0 fix: accept valid Thunder subtitle encodings 2026-08-23 18:19:13 +08:00
Meiam
bf6304df96 文档: 更新搬瓦工套餐与优惠码 2026-08-10 15:47:28 +08:00
Meiam
87a5c361b8 修复: 媒体文件不可读时回退名称搜索 2026-08-07 15:41:33 +08:00
Meiam
dba34a6231 文档: 调整 README 徽章展示 2026-08-07 15:31:33 +08:00
Meiam
1fe2a70221 文档: 完善安装配置与故障排查说明 2026-08-07 15:30:07 +08:00
Meiam
5c309f44ee 发布: 停止 Shooter 并发布 1.0.16.0 2026-08-07 15:14:09 +08:00
Meiam
d9407006df 发布: 提升字幕下载可靠性并弃用 Shooter 2026-08-07 14:22:31 +08:00
Meiam
786cb82868 更新: 替换 Assrt 插件图片 2026-08-07 12:20:32 +08:00
Meiam
38a46d2f53 修复: 支持 Assrt 压缩字幕下载 2026-08-07 11:56:46 +08:00
Meiam
220fe5459d 文档: 更新 README 中的搬瓦工优惠码 2026-07-07 17:38:37 +08:00
Meiam
66ed8ff49c 新增: Assrt(伪射手)字幕提供程序,支持 Emby/Jellyfin 双平台 2026-06-15 15:19:15 +08:00
Meiam
c4adc97bbf fix: ignore network paths when computing video hash to prevent crashes (#136) 2026-06-11 11:43:51 +08:00
Meiam
ba40c107cf chore: support custom notes in release script 2026-06-11 11:19:44 +08:00
35 changed files with 2320 additions and 246 deletions

View 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
}
}

View File

@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyVersion>1.0.17.0</AssemblyVersion>
<FileVersion>1.0.17.0</FileVersion>
<Version>1.0.17.0</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>

View 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; }
}
}

View 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;
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

View File

@@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyVersion>1.0.14.0</AssemblyVersion>
<FileVersion>1.0.14.0</FileVersion>
<Version>1.0.14.0</Version>
<AssemblyVersion>1.0.16.0</AssemblyVersion>
<FileVersion>1.0.16.0</FileVersion>
<Version>1.0.16.0</Version>
</PropertyGroup>
<PropertyGroup>
@@ -28,7 +28,7 @@
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Exec Command="Copy $(TargetDir)$(TargetFileName) $(SolutionDir)$(ConfigurationName)\$(TargetFileName) /y&#xD;&#xA;" />
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
</Target>
</Project>

View File

@@ -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>
/// 缩略图格式化类型

View File

@@ -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;
@@ -9,6 +9,7 @@ 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;
@@ -67,7 +68,7 @@ namespace Emby.MeiamSub.Shooter
{
_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;
}
@@ -77,7 +78,7 @@ 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)
{
// 修改人: Meiam
// 修改时间: 2025-12-22
@@ -95,10 +96,20 @@ namespace Emby.MeiamSub.Shooter
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);
var hash = await ComputeFileHashAsync(fileInfo, cancellationToken);
stopWatch.Stop();
_logger.Info("{0} Search | FileHash -> {1} (Took {2}ms)", new object[3] { Name, hash, stopWatch.ElapsedMilliseconds });
@@ -123,7 +134,8 @@ namespace Emby.MeiamSub.Shooter
var response = await _httpClient.Post(options);
_logger.Info("{0} Search | Response -> {1}", new object[2] { Name, _jsonSerializer.SerializeToString(response) });
_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"))
{
@@ -137,8 +149,6 @@ namespace Emby.MeiamSub.Shooter
responseBody = await reader.ReadToEndAsync();
}
_logger.Info("{0} Search | ResponseBody -> {1}", new object[2] { Name, responseBody });
if (string.IsNullOrEmpty(responseBody) || !responseBody.Trim().StartsWith("["))
{
_logger.Info("{0} Search | Summary -> API returned invalid content (likely no subtitles found or API error).", Name);
@@ -149,20 +159,19 @@ namespace Emby.MeiamSub.Shooter
if (subtitleResponse != null)
{
_logger.Info("{0} Search | Response -> {1}", new object[2] { Name, _jsonSerializer.SerializeToString(subtitleResponse) });
var remoteSubtitles = new List<RemoteSubtitleInfo>();
foreach (var subFileInfo in subtitleResponse)
{
foreach (var subFile in subFileInfo.Files)
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 = subFile.Ext,
Format = NormalizeFormat(subFile.Ext),
Language = request.Language,
IsForced = request.IsForced
})),
@@ -170,7 +179,7 @@ namespace Emby.MeiamSub.Shooter
Language = request.Language,
Author = "Meiam ",
ProviderName = $"{Name}",
Format = subFile.Ext,
Format = NormalizeFormat(subFile.Ext),
Comment = $"Format : {ExtractFormat(subFile.Ext)}",
IsHashMatch = true
});
@@ -206,7 +215,7 @@ namespace Emby.MeiamSub.Shooter
{
_logger.Info("{0} DownloadSub | Request -> {1}", new object[2] { Name, id });
return await DownloadSubAsync(id);
return await DownloadSubAsync(id, cancellationToken);
}
/// <summary>
@@ -214,7 +223,7 @@ 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)
{
// 修改人: Meiam
// 修改时间: 2025-12-22
@@ -246,13 +255,20 @@ namespace Emby.MeiamSub.Shooter
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 = false,
Format = downloadSub.Format,
Stream = response.Content,
IsForced = downloadSub.IsForced ?? false,
Format = format,
Stream = new MemoryStream(data, writable: false),
};
}
}
@@ -261,7 +277,7 @@ namespace Emby.MeiamSub.Shooter
_logger.Error("{0} DownloadSub | Error -> {1}", Name, ex.Message);
}
return new SubtitleResponse();
throw new InvalidDataException($"{Name} subtitle download failed.");
}
#endregion
@@ -309,6 +325,41 @@ namespace Emby.MeiamSub.Shooter
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.");
}
}
/// <summary>
/// 规范化语言代码
/// </summary>
@@ -343,7 +394,7 @@ namespace Emby.MeiamSub.Shooter
/// </summary>
/// <param name="fileInfo">文件信息对象</param>
/// <returns>计算得到的文件 Hash 字符串,如果文件过小或不存在则返回空字符串</returns>
public static async Task<string> ComputeFileHashAsync(FileInfo fileInfo)
public static async Task<string> ComputeFileHashAsync(FileInfo fileInfo, CancellationToken cancellationToken)
{
// 修改人: Meiam
// 修改时间: 2025-12-22
@@ -369,7 +420,7 @@ namespace Emby.MeiamSub.Shooter
for (int i = 0; i < 4; ++i)
{
fs.Seek(offset[i], SeekOrigin.Begin);
await fs.ReadAsync(bBuf, 0, 4 * 1024);
await ReadFullAsync(fs, bBuf, 0, 4 * 1024, cancellationToken);
using (MD5 md5Hash = MD5.Create())
{
@@ -393,6 +444,21 @@ namespace Emby.MeiamSub.Shooter
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
}
}

View File

@@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyVersion>1.0.14.0</AssemblyVersion>
<FileVersion>1.0.14.0</FileVersion>
<Version>1.0.14.0</Version>
<AssemblyVersion>1.0.17.0</AssemblyVersion>
<FileVersion>1.0.17.0</FileVersion>
<Version>1.0.17.0</Version>
</PropertyGroup>
<PropertyGroup>
@@ -24,8 +24,9 @@
<ItemGroup>
<PackageReference Include="MediaBrowser.Common" Version="4.9.1.90" />
<PackageReference Include="MediaBrowser.Server.Core" Version="4.9.1.90" />
<!-- 使用旧版宿主可解析的 SDK,避免插件要求 4.9.1.90 程序集。 -->
<PackageReference Include="MediaBrowser.Common" Version="4.8.10" />
<PackageReference Include="MediaBrowser.Server.Core" Version="4.8.10" />
</ItemGroup>
@@ -33,8 +34,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&#xD;&#xA;" />
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
</Target>

View File

@@ -17,7 +17,7 @@ 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;

View File

@@ -1,4 +1,5 @@
using Emby.MeiamSub.Thunder.Model;
using MeiamSubtitles.Shared;
using MediaBrowser.Common;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Base;
@@ -76,9 +77,15 @@ namespace Emby.MeiamSub.Thunder
/// <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;
}
@@ -88,7 +95,7 @@ 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)
{
// 修改人:Mayfly777w
// 修改时间:2026-01-01
@@ -130,23 +137,37 @@ namespace Emby.MeiamSub.Thunder
return Array.Empty<RemoteSubtitleInfo>();
}
var cid = string.Empty;
var stopWatch = Stopwatch.StartNew();
var cid = await GetCidByFileAsync(request.MediaPath);
stopWatch.Stop();
_logger.Info("{0} Search | FileHash -> {1} (Took {2}ms)", new object[3] { Name, cid, stopWatch.ElapsedMilliseconds });
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={MovieName}",
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, _jsonSerializer.SerializeToString(response) });
_logger.Info("{0} Search | Response -> {1}", new object[2] { Name, response.StatusCode });
if (response.StatusCode == HttpStatusCode.OK)
{
@@ -154,9 +175,16 @@ namespace Emby.MeiamSub.Thunder
if (subtitleResponse.Code == 0)
{
_logger.Info("{0} Search | Response -> {1}", new object[2] { Name, _jsonSerializer.SerializeToString(subtitleResponse) });
var subtitles = subtitleResponse.Data.Where(m => !string.IsNullOrEmpty(m.Name));
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>();
@@ -169,7 +197,7 @@ namespace Emby.MeiamSub.Thunder
Id = Base64Encode(_jsonSerializer.SerializeToString(new DownloadSubInfo
{
Url = item.Url,
Format = item.Ext,
Format = NormalizeFormat(item.Ext),
Language = request.Language,
IsForced = request.IsForced
})),
@@ -177,9 +205,9 @@ namespace Emby.MeiamSub.Thunder
Language = request.Language,
Author = "Meiam ",
ProviderName = $"{Name}",
Format = item.Ext,
Comment = $"Format : {item.Ext}",
IsHashMatch = cid == item.Cid,
Format = NormalizeFormat(item.Ext),
Comment = $"Format : {NormalizeFormat(item.Ext)}",
IsHashMatch = !string.IsNullOrEmpty(cid) && string.Equals(cid, item.Cid, StringComparison.OrdinalIgnoreCase),
});
}
}
@@ -214,7 +242,7 @@ namespace Emby.MeiamSub.Thunder
{
_logger.Info("{0} DownloadSub | Request -> {1}", new object[2] { Name, id });
return await DownloadSubAsync(id);
return await DownloadSubAsync(id, cancellationToken);
}
/// <summary>
@@ -222,7 +250,7 @@ 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)
{
// 修改人: Meiam
// 修改时间: 2025-12-22
@@ -252,13 +280,20 @@ namespace Emby.MeiamSub.Thunder
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();
SubtitleContentValidator.Validate(data, format, response.ContentType);
return new SubtitleResponse()
{
Language = downloadSub.Language,
IsForced = false,
Format = downloadSub.Format,
Stream = response.Content,
IsForced = downloadSub.IsForced ?? false,
Format = format,
Stream = new MemoryStream(data, writable: false),
};
}
}
@@ -267,7 +302,7 @@ namespace Emby.MeiamSub.Thunder
_logger.Error("{0} DownloadSub | Error -> {1}", Name, ex.Message);
}
return new SubtitleResponse();
throw new InvalidDataException($"{Name} subtitle download failed.");
}
#endregion
@@ -315,6 +350,12 @@ namespace Emby.MeiamSub.Thunder
return null;
}
private static string NormalizeFormat(string format)
{
var value = format?.Trim().TrimStart('.').ToLowerInvariant();
return value == SRT || value == ASS || value == SSA ? value : null;
}
/// <summary>
/// 规范化语言代码
/// </summary>
@@ -349,11 +390,20 @@ namespace Emby.MeiamSub.Thunder
/// </summary>
/// <param name="filePath">文件路径</param>
/// <returns>计算得到的 CID 字符串</returns>
private async Task<string> GetCidByFileAsync(string filePath)
private async Task<string> GetCidByFileAsync(string filePath, CancellationToken cancellationToken)
{
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 改造为异步方法,优化 I/O 性能,使用 SHA1.Create() 替代旧 API,并增加 using 语句释放资源
// 修改时间: 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))
{
@@ -366,16 +416,16 @@ namespace Emby.MeiamSub.Thunder
var buffer = new byte[0xf000];
if (fileSize < 0xf000)
{
await stream.ReadAsync(buffer, 0, (int)fileSize);
await ReadFullAsync(stream, buffer, 0, (int)fileSize, cancellationToken);
buffer = sha1.ComputeHash(buffer, 0, (int)fileSize);
}
else
{
await stream.ReadAsync(buffer, 0, 0x5000);
await ReadFullAsync(stream, buffer, 0, 0x5000, cancellationToken);
stream.Seek(fileSize / 3, SeekOrigin.Begin);
await stream.ReadAsync(buffer, 0x5000, 0x5000);
await ReadFullAsync(stream, buffer, 0x5000, 0x5000, cancellationToken);
stream.Seek(fileSize - 0x5000, SeekOrigin.Begin);
await stream.ReadAsync(buffer, 0xa000, 0x5000);
await ReadFullAsync(stream, buffer, 0xa000, 0x5000, cancellationToken);
buffer = sha1.ComputeHash(buffer, 0, 0xf000);
}
@@ -388,6 +438,21 @@ namespace Emby.MeiamSub.Thunder
}
}
}
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
}
}

View 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
}
}

View 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 是基于文本搜索,元数据匹配非常合适
}
}
}

View 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>

View File

@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Version>1.0.17.0</Version>
<AssemblyVersion>1.0.17.0</AssemblyVersion>
<FileVersion>1.0.17.0</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>

View 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; }
}
}

View 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"
}
};
}
}
}

View 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>();
}
}
}

View 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-16T08:13:02.0000000Z",
"version": "1.0.17.0",
"status": "Active",
"imagePath": "thumb.png"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

View File

@@ -2,9 +2,9 @@
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Version>1.0.14.0</Version>
<AssemblyVersion>1.0.14.0</AssemblyVersion>
<FileVersion>1.0.14.0</FileVersion>
<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'">
@@ -22,7 +22,7 @@
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Exec Command="Copy $(TargetDir)$(TargetFileName) $(SolutionDir)$(ConfigurationName)\$(TargetFileName) /y&#xD;&#xA;" />
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
</Target>
</Project>

View File

@@ -1,4 +1,4 @@
using Jellyfin.MeiamSub.Shooter.Configuration;
using Jellyfin.MeiamSub.Shooter.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Serialization;
@@ -27,7 +27,7 @@ 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)

View File

@@ -1,4 +1,4 @@
using Jellyfin.MeiamSub.Shooter.Model;
using Jellyfin.MeiamSub.Shooter.Model;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
@@ -73,7 +73,7 @@ namespace Jellyfin.MeiamSub.Shooter
{
_logger.LogInformation("DEBUG: Received Search request for " + (request?.MediaPath ?? "NULL"));
var subtitles = await SearchSubtitlesAsync(request);
var subtitles = await SearchSubtitlesAsync(request, cancellationToken);
return subtitles;
}
@@ -83,7 +83,7 @@ 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)
{
// 修改人: Meiam
// 修改时间: 2025-12-22
@@ -121,12 +121,21 @@ namespace Jellyfin.MeiamSub.Shooter
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);
var hash = await ComputeFileHashAsync(fileInfo, cancellationToken);
stopWatch.Stop();
_logger.LogInformation(Name + " Search | FileHash -> " + hash + " (Took " + stopWatch.ElapsedMilliseconds + "ms)");
@@ -147,13 +156,14 @@ namespace Jellyfin.MeiamSub.Shooter
using var httpClient = _httpClientFactory.CreateClient(Name);
// 发送 POST 请求
var response = await httpClient.PostAsync(ApiUrl, content);
using var response = await httpClient.PostAsync(ApiUrl, content, cancellationToken);
_logger.LogInformation($"{Name} Search | Response -> {JsonSerializer.Serialize(response)}");
_logger.LogInformation("{Provider} Search | Response -> {StatusCode} | Content-Type -> {ContentType}",
Name, response.StatusCode, response.Content.Headers.ContentType?.MediaType ?? "unknown");
// 处理响应
if (response.IsSuccessStatusCode && response.Content.Headers.Any(m => m.Value.Contains("application/json; charset=utf-8")))
if (response.IsSuccessStatusCode && response.Content.Headers.ContentType?.MediaType?.Equals("application/json", StringComparison.OrdinalIgnoreCase) == true)
{
@@ -161,8 +171,6 @@ namespace Jellyfin.MeiamSub.Shooter
_logger.LogInformation($"{Name} Search | ResponseBody -> {responseBody} ");
if (string.IsNullOrEmpty(responseBody) || !responseBody.Trim().StartsWith("["))
@@ -191,8 +199,6 @@ namespace Jellyfin.MeiamSub.Shooter
var subtitles = JsonSerializer.Deserialize<List<SubtitleResponseRoot>>(responseBody);
_logger.LogInformation($"{Name} Search | Response -> {JsonSerializer.Serialize(subtitles)}");
if (subtitles != null)
{
@@ -200,21 +206,22 @@ namespace Jellyfin.MeiamSub.Shooter
foreach (var subFileInfo in subtitles)
{
foreach (var subFile in subFileInfo.Files)
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 = subFile.Ext,
Format = NormalizeFormat(subFile.Ext),
Language = request.Language,
TwoLetterISOLanguageName = request.TwoLetterISOLanguageName,
})),
Name = $"[MEIAMSUB] {Path.GetFileName(request.MediaPath)} | {request.TwoLetterISOLanguageName} | 射手",
Author = "Meiam ",
ProviderName = $"{Name}",
Format = subFile.Ext,
Format = NormalizeFormat(subFile.Ext),
Comment = $"Format : {ExtractFormat(subFile.Ext)}",
IsHashMatch = true
});
@@ -251,7 +258,7 @@ namespace Jellyfin.MeiamSub.Shooter
{
_logger.LogInformation($"{Name} DownloadSub | Request -> {id}");
return await DownloadSubAsync(id);
return await DownloadSubAsync(id, cancellationToken);
}
/// <summary>
@@ -259,7 +266,7 @@ 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)
{
// 修改人: Meiam
// 修改时间: 2025-12-22
@@ -284,19 +291,23 @@ namespace Jellyfin.MeiamSub.Shooter
using var httpClient = _httpClientFactory.CreateClient(Name);
var response = await httpClient.SendAsync(options);
using var response = await httpClient.SendAsync(options, cancellationToken);
_logger.LogInformation($"{Name} DownloadSub | Response -> {response.StatusCode}");
if (response.StatusCode == HttpStatusCode.OK)
{
var stream = await response.Content.ReadAsStreamAsync();
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 = downloadSub.Format,
Format = format,
Stream = stream,
};
}
@@ -306,7 +317,7 @@ namespace Jellyfin.MeiamSub.Shooter
_logger.LogError(ex, "{0} DownloadSub | Error -> {1}", Name, ex.Message);
}
return new SubtitleResponse();
throw new InvalidDataException($"{Name} subtitle download failed.");
}
#endregion
@@ -355,6 +366,41 @@ namespace Jellyfin.MeiamSub.Shooter
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.");
}
}
/// <summary>
/// 规范化语言代码
/// </summary>
@@ -389,7 +435,7 @@ namespace Jellyfin.MeiamSub.Shooter
/// </summary>
/// <param name="fileInfo">文件信息对象</param>
/// <returns>计算得到的文件 Hash 字符串,如果文件过小或不存在则返回空字符串</returns>
public static async Task<string> ComputeFileHashAsync(FileInfo fileInfo)
public static async Task<string> ComputeFileHashAsync(FileInfo fileInfo, CancellationToken cancellationToken)
{
// 修改人: Meiam
// 修改时间: 2025-12-22
@@ -415,7 +461,7 @@ namespace Jellyfin.MeiamSub.Shooter
for (int i = 0; i < 4; ++i)
{
fs.Seek(offset[i], SeekOrigin.Begin);
await fs.ReadExactlyAsync(bBuf, 0, 4 * 1024);
await fs.ReadExactlyAsync(bBuf, 0, 4 * 1024, cancellationToken);
using (MD5 md5Hash = MD5.Create())
{

View File

@@ -1,12 +1,12 @@
{
"category": "Subtitles",
"description": "Download subtitles from Shooter",
"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-06-11T03:17:06.0000000Z",
"version": "1.0.14.0",
"status": "Active",
"timestamp": "2026-08-07T07:09:27.0000000Z",
"version": "1.0.16.0",
"status": "Deprecated",
"imagePath": "thumb.png"
}

View File

@@ -2,9 +2,9 @@
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Version>1.0.14.0</Version>
<AssemblyVersion>1.0.14.0</AssemblyVersion>
<TargetFramework>net8.0</TargetFramework>
<Version>1.0.17.0</Version>
<AssemblyVersion>1.0.17.0</AssemblyVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
@@ -12,7 +12,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.11.11" />
<!-- 使用已验证的最低兼容基线,保持同一 DLL 可运行于 Jellyfin 10.10.7、10.11.11 和 12.1。 -->
<PackageReference Include="Jellyfin.Controller" Version="10.10.7" />
</ItemGroup>
<ItemGroup>
@@ -21,12 +22,16 @@
</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&#xD;&#xA;" />
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
</Target>
</Project>

View File

@@ -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;

View File

@@ -1,4 +1,5 @@
using Jellyfin.MeiamSub.Thunder.Model;
using MeiamSubtitles.Shared;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Model.Providers;
@@ -68,9 +69,8 @@ namespace Jellyfin.MeiamSub.Thunder
/// <returns>远程字幕信息列表</returns>
public async Task<IEnumerable<RemoteSubtitleInfo>> Search(SubtitleSearchRequest request, CancellationToken cancellationToken)
{
_logger.LogInformation("DEBUG: Received Search request for " + (request?.MediaPath ?? "NULL"));
var subtitles = await SearchSubtitlesAsync(request);
var subtitles = await SearchSubtitlesAsync(request, cancellationToken);
return subtitles;
}
@@ -80,19 +80,17 @@ namespace Jellyfin.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)
{
// 修改人: Meiam (移植自 PR #133 by Mayfly777w)
// 修改时间: 2026-02-11
// 备注: 增加元数据搜索选项,可以以元数据中的系列名和季集信息来作为匹配对象
_logger.LogInformation("DEBUG: Entering SearchSubtitlesAsync (Thunder)");
try
{
if (request == null)
{
_logger.LogInformation("DEBUG: Request is null");
_logger.LogInformation("{Provider} Search | Request is null", Name);
return Array.Empty<RemoteSubtitleInfo>();
}
@@ -138,23 +136,39 @@ namespace Jellyfin.MeiamSub.Thunder
return Array.Empty<RemoteSubtitleInfo>();
}
var cid = string.Empty;
var stopWatch = Stopwatch.StartNew();
var cid = await GetCidByFileAsync(request.MediaPath);
stopWatch.Stop();
_logger.LogInformation(Name + " Search | FileHash -> " + cid + " (Took " + stopWatch.ElapsedMilliseconds + "ms)");
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={movieName}")
RequestUri = new Uri($"https://api-shoulei-ssl.xunlei.com/oracle/subtitle?name={Uri.EscapeDataString(movieName)}")
};
using var httpClient = _httpClientFactory.CreateClient(Name);
var response = await httpClient.SendAsync(options);
using var response = await httpClient.SendAsync(options, cancellationToken);
_logger.LogInformation($"{Name} Search | Response -> {JsonSerializer.Serialize(response)}");
_logger.LogInformation("{Provider} Search | Response -> {StatusCode}", Name, response.StatusCode);
if (response.StatusCode == HttpStatusCode.OK)
{
@@ -162,9 +176,16 @@ namespace Jellyfin.MeiamSub.Thunder
if (subtitleResponse != null)
{
_logger.LogInformation($"{Name} Search | Response -> {JsonSerializer.Serialize(subtitleResponse)}");
var subtitles = subtitleResponse.Data.Where(m => !string.IsNullOrEmpty(m.Name));
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>();
@@ -177,16 +198,16 @@ namespace Jellyfin.MeiamSub.Thunder
Id = Base64Encode(JsonSerializer.Serialize(new DownloadSubInfo
{
Url = item.Url,
Format = item.Ext,
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 = item.Ext,
Comment = $"Format : {item.Ext}",
IsHashMatch = cid == item.Cid,
Format = NormalizeFormat(item.Ext),
Comment = $"Format : {NormalizeFormat(item.Ext)}",
IsHashMatch = !string.IsNullOrEmpty(cid) && string.Equals(cid, item.Cid, StringComparison.OrdinalIgnoreCase),
});
}
}
@@ -220,7 +241,7 @@ namespace Jellyfin.MeiamSub.Thunder
{
_logger.LogInformation($"{Name} DownloadSub | Request -> {id}");
return await DownloadSubAsync(id);
return await DownloadSubAsync(id, cancellationToken);
}
/// <summary>
@@ -228,7 +249,7 @@ 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)
{
// 修改人: Meiam
// 修改时间: 2025-12-22
@@ -253,19 +274,23 @@ namespace Jellyfin.MeiamSub.Thunder
using var httpClient = _httpClientFactory.CreateClient(Name);
var response = await httpClient.SendAsync(options);
using var response = await httpClient.SendAsync(options, cancellationToken);
_logger.LogInformation($"{Name} DownloadSub | Response -> {response.StatusCode}");
if (response.StatusCode == HttpStatusCode.OK)
{
var stream = await response.Content.ReadAsStreamAsync();
var format = NormalizeFormat(downloadSub.Format)
?? throw new InvalidDataException($"Unsupported subtitle format: {downloadSub.Format}");
var data = await response.Content.ReadAsByteArrayAsync(cancellationToken);
SubtitleContentValidator.Validate(data, format, response.Content.Headers.ContentType?.MediaType);
var stream = new MemoryStream(data, writable: false);
return new SubtitleResponse()
{
Language = downloadSub.Language,
IsForced = false,
Format = downloadSub.Format,
Format = format,
Stream = stream,
};
}
@@ -275,7 +300,7 @@ namespace Jellyfin.MeiamSub.Thunder
_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
@@ -323,6 +348,12 @@ namespace Jellyfin.MeiamSub.Thunder
return null;
}
private static string NormalizeFormat(string format)
{
var value = format?.Trim().TrimStart('.').ToLowerInvariant();
return value == SRT || value == ASS || value == SSA ? value : null;
}
/// <summary>
/// 规范化语言代码
/// </summary>
@@ -357,11 +388,20 @@ namespace Jellyfin.MeiamSub.Thunder
/// </summary>
/// <param name="filePath">文件路径</param>
/// <returns>计算得到的 CID 字符串</returns>
private async Task<string> GetCidByFileAsync(string filePath)
private async Task<string> GetCidByFileAsync(string filePath, CancellationToken cancellationToken)
{
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 改造为异步方法,优化 I/O 性能,使用 SHA1.Create() 替代旧 API,并增加 using 语句释放资源
// 修改时间: 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))
{
@@ -371,16 +411,16 @@ namespace Jellyfin.MeiamSub.Thunder
var buffer = new byte[0xf000];
if (fileSize < 0xf000)
{
await stream.ReadExactlyAsync(buffer, 0, (int)fileSize);
await stream.ReadExactlyAsync(buffer, 0, (int)fileSize, cancellationToken);
buffer = sha1.ComputeHash(buffer, 0, (int)fileSize);
}
else
{
await stream.ReadExactlyAsync(buffer, 0, 0x5000);
await stream.ReadExactlyAsync(buffer, 0, 0x5000, cancellationToken);
stream.Seek(fileSize / 3, SeekOrigin.Begin);
await stream.ReadExactlyAsync(buffer, 0x5000, 0x5000);
await stream.ReadExactlyAsync(buffer, 0x5000, 0x5000, cancellationToken);
stream.Seek(fileSize - 0x5000, SeekOrigin.Begin);
await stream.ReadExactlyAsync(buffer, 0xa000, 0x5000);
await stream.ReadExactlyAsync(buffer, 0xa000, 0x5000, cancellationToken);
buffer = sha1.ComputeHash(buffer, 0, 0xf000);
}
@@ -395,4 +435,4 @@ namespace Jellyfin.MeiamSub.Thunder
}
#endregion
}
}
}

View File

@@ -4,9 +4,9 @@
"guid": "E4CE9DA9-EF00-417C-96F2-861C512D45EB",
"name": "MeiamSub.Thunder",
"owner": "Meiam",
"targetAbi": "10.11.0.0",
"timestamp": "2026-06-11T03:17:06.0000000Z",
"version": "1.0.14.0",
"targetAbi": "10.10.7.0",
"timestamp": "2026-09-16T08:13:02.0000000Z",
"version": "1.0.17.0",
"status": "Active",
"imagePath": "thumb.png"
}

201
LICENSE Normal file
View 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.

View File

@@ -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

200
README.md
View File

@@ -1,107 +1,165 @@
# 🎬 MeiamSubtitles
# MeiamSubtitles
**MeiamSubtitles** 是一款专为 **Emby** 和 **Jellyfin** 媒体服务器打造的中文字幕下载插件。它集成了 **迅雷影音** 与 **射手网** 的强大搜索能力,支持精准的视频哈希(Hash)匹配,让您的媒体库自动补全高质量字幕。
---
**MeiamSubtitles** 是一款专为 **Emby** 和 **Jellyfin** 媒体服务器打造的中文字幕下载插件。它集成了 **[Assrt(伪射手网)](https://assrt.net/)** 与 **迅雷影音** 的字幕搜索能力,支持影片名称与视频特征(CID)匹配,帮助您的媒体库便捷地搜索和补全中文字幕。
<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%209.0-blueviolet.svg" alt=".NET Status">
<img src="https://img.shields.io/badge/Platform-Linux%20%7C%20Win%20%7C%20OSX-brightgreen.svg" alt="Platform">
<img src="https://img.shields.io/badge/license-Apache%202-blue" alt="LICENSE">
<a href="https://github.com/91270/Emby.MeiamSub"><img src="https://img.shields.io/github/stars/91270/Emby.MeiamSub?label=Star%20this%20repo" alt="Star"></a>
<a href="#插件与兼容性"><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 起停止编译、维护和发布。
## 📣 广告时间
> **搬瓦工 $99 年付**:建站神器重出江湖,THE PLAN V1 传家宝套餐,18机房随意切换。
> **循环优惠码**:`BWHCCNCXVV` (6.77%)
> **[直达通道 (传家宝套餐)](https://bwh88.net/aff.php?aff=117&pid=87)**
> **搬瓦工 $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)**
## ✨ 核心特性
## 功能
- **🚀 精准匹配**: 支持迅雷看看 (CID) 和射手网 (Hash) 双重校验逻辑,确保字幕与视频内容完美同步。
- **⚡ 极致性能**: 核心采样算法全面采用**异步 I/O (Async/Await)** 模式,在大规模媒体库扫描时不会阻塞服务器线程。
- **🌐 广泛兼容**: 深度适配 **Jellyfin 10.11+** 及 **Emby v4.9+**,支持 `zho`、`chi` 等多种国际化语言代码映射。
- **🛡️ 稳定可靠**: 针对射手网 API 的老化问题增加了防御性校验,能有效处理乱码返回,保证系统长效稳定。
- **📝 详尽日志**: 记录哈希计算耗时与接口原始响应,让问题排查不再是黑盒。
- 同时支持 Emby 和 Jellyfin。
- 支持 `zho`、`chi`、`zh-CN` 等中文语言代码。
- 使用异步文件读取计算视频特征,适用于本地及网络媒体库。
- 对搜索结果进行格式校验、去重和排序。
- 下载前校验响应内容,避免将空响应或错误页面保存为字幕。
- Assrt 支持从压缩包中选择并提取 `.srt`、`.ass`、`.ssa` 字幕。
- 日志隐藏 Assrt Token,且不记录完整接口响应。
## 📦 项目组件说明
## 插件与兼容性
| 组件名称 | 适用平台 | 目标框架 | 说明 |
| :--- | :--- | :--- | :--- |
| **Emby.MeiamSub.Thunder** | Emby | .NET Standard 2.1 | 迅雷看看字幕插件 |
| **Emby.MeiamSub.Shooter** | Emby | .NET Standard 2.1 | 射手影音字幕插件 |
| **Jellyfin.MeiamSub.Thunder** | Jellyfin | .NET 9.0 | 迅雷看看字幕插件 (现代 DI 架构) |
| **Jellyfin.MeiamSub.Shooter** | Jellyfin | .NET 9.0 | 射手影音字幕插件 (现代 DI 架构) |
| **Emby.MeiamSub.DevTool** | 开发调试 | .NET 8.0 | 哈希算法测试与 API 模拟工具 |
| 插件 | 平台 | 目标框架 | 字幕源 |
| --- | --- | --- | --- |
| `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 |
---
| 平台 | 兼容版本 |
| --- | --- |
| Emby | 4.8.10.0 至 4.10.0.40 |
| Jellyfin | 10.10.7 至 12.1 |
## 🚀 快速安装
## 安装
### 第一步:获取插件
前往 [GitHub Releases](https://github.com/91270/Emby.MeiamSub/releases) 下载最新版本的发布包。
### Jellyfin 存储库
> **🔔 推荐建议**:在媒体库设置中**不勾选**本插件作为默认自动下载器。建议仅在手动“搜索字幕”时使用,以获得更精准的人工筛选体验。
推荐通过插件存储库安装,以便接收后续更新。
### 第二步:部署插件
1. 打开 Jellyfin 控制台的“插件 → 存储库”。
2. 添加存储库名称 `MeiamSub`。
3. 填写以下地址:
#### 🔹 方式 A:Jellyfin 存储库安装 (强烈推荐)
Jellyfin 用户可直接添加官方存储库,实现一键安装与自动更新:
1. 控制台 -> **插件** -> **存储库** -> 点击“添加”。
2. 输入名称 `MeiamSub` 和 URL:
`https://github.com/91270/MeiamSubtitles.Release/raw/main/Plugin/manifest-stable.json`
3. 在“目录”中找到插件并安装,重启服务即可。
```text
https://github.com/91270/MeiamSubtitles.Release/raw/main/Plugin/manifest-stable.json
```
#### 🔹 方式 B:手动安装 (Emby/通用)
将下载的 `.dll` 文件(Jellyfin 用户请下载 `.zip` 并解压完整目录)放入服务器的 `plugins` 文件夹:
4. 在“目录”中分别安装 `MeiamSub.Assrt` 或 `MeiamSub.Thunder`。
5. 重启 Jellyfin。
- **Windows**: `AppData\Local\jellyfin\plugins` 或 `Emby-Server\programdata\plugins`
- **Linux/Docker**: `/config/plugins` 或 `/var/lib/emby/plugins`
- **群晖/威联通**: 对应套件安装目录下的 `plugins` 文件夹
### 手动安装
---
从 [Releases](https://github.com/91270/MeiamSubtitles/releases/latest) 下载与平台对应的压缩包。
## ❓ 常见问题排查 (FAQ)
#### Emby
<details>
<summary><b>1. 为什么在 Jellyfin 10.11+ 中搜不到字幕?</b></summary>
新版 Jellyfin 采用了三位字母的语言代码(如 <code>zho</code>)。请确保您已升级至本插件的 <b>v1.0.13.0</b> 或更高版本,该版本已完美解决语言映射兼容性。
</details>
1. 解压 `Emby_v*.zip`。
2. 将需要的插件 DLL 放入 Emby 的 `plugins` 目录。
3. 不要额外复制 `SharpCompress.dll`;Assrt 复用 Emby 自带版本。
4. 重启 Emby。
<details>
<summary><b>2. 为什么射手网有时候返回结果为空?</b></summary>
由于射手网 API 维护状态不佳,对于部分冷门资源或 Hash 不匹配的文件,API 可能会返回非法数据。插件目前已增加防御逻辑,会自动忽略这些无效返回以保护服务器稳定。
</details>
#### Jellyfin
<details>
<summary><b>3. 安装本插件后会影响 Open Subtitles 吗?</b></summary>
不会。本插件已将优先级 (Order) 调整为 100(低优先级),并在代码层面优化了并发逻辑,确保官方插件能优先获取请求机会。
</details>
1. 解压 `Jellyfin_v*.zip`。
2. 将需要的整个插件目录复制到 Jellyfin 的 `plugins` 目录。
3. Assrt 目录中的 `SharpCompress.dll` 是 Jellyfin 所需依赖,请勿单独删除。
4. 重启 Jellyfin。
<details>
<summary><b>4. 如何提供有效的错误反馈?</b></summary>
如果确定有字幕但搜不到,请在 Issue 中提供日志里的 <code>Target</code> 文件名、计算出的 <code>FileHash</code> 以及 <code>ResponseBody</code> 内容。
</details>
### 从旧版本升级
---
- 升级到 v1.0.16.0 或更高版本后,删除旧的 Shooter DLL 和插件目录;Shooter 已停止维护和发布。
- 同一个插件不要同时保留多个版本,否则服务器可能重复加载或启动失败。
- Emby Assrt 只需保留插件 DLL,不要额外复制 `SharpCompress.dll`。
- Jellyfin Assrt 必须保留发布包中同目录的 `SharpCompress.dll`。
## 🤝 贡献与感谢
常见插件目录:
欢迎通过提交 Issue 或 Pull Request 来完善本项目。
| 环境 | 目录示例 |
| --- | --- |
| Jellyfin Windows | `%LOCALAPPDATA%\jellyfin\plugins` |
| Jellyfin Linux | `/var/lib/jellyfin/plugins` |
| Emby Windows | `%APPDATA%\Emby-Server\programdata\plugins` |
| Docker | 容器映射的 `/config/plugins` |
- **开发守则**: 遵循异步命名规范,详情请参阅 [.docs/开发协议.md](.docs/开发协议.md)。
- **任务进展**: 查看 [.docs/任务清单.md](.docs/任务清单.md) 了解当前计划。
- **致谢**: 感谢 [Emby.Subtitle.Subscene](https://github.com/nRafinia/Emby.Subtitle.Subscene) 提供的灵感与参考。
## 配置
---
### Assrt
## ⭐️ 给个星星
1. 从 [Assrt](https://assrt.net/) 获取 API Token。
2. 打开插件配置页,将 Token 填入 `Assrt API Token`。
3. 在媒体库的字幕下载器中启用 `MeiamSub.Assrt`。
如果你喜欢这个项目,请给一个 **Star**!这对我非常重要。
未配置 Token 时,Assrt 会跳过搜索并在日志中给出提示。
如果你有稳定的 Google Drive 团队盘资源可供媒体库插件测试,欢迎联系:`91270#QQ.COM`
### Thunder
---
*Powered by Meiam*
Thunder 默认使用视频文件计算 CID。Jellyfin 版本还可在插件设置中启用元数据搜索,用影片名称辅助匹配。
## 自动下载说明
建议先通过“搜索字幕”手动确认匹配效果,再决定是否启用媒体库自动下载。
- 同一媒体库的字幕下载语言只应添加一次。例如只保留一个 `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)

View File

@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace MeiamSubtitles.Shared
{
internal static class SubtitleContentValidator
{
public static void Validate(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;
}
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)
{
var length = Math.Min(data.Length, 4096);
if (data.Length >= 2)
{
if (data[0] == 0xFF && data[1] == 0xFE)
{
yield return DecodeSample(data, length, Encoding.Unicode);
yield break;
}
if (data[0] == 0xFE && data[1] == 0xFF)
{
yield return DecodeSample(data, length, Encoding.BigEndianUnicode);
yield break;
}
}
if (data.Length >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF)
{
yield return DecodeSample(data, length, Encoding.UTF8);
yield break;
}
// Thunder may return UTF-16 subtitles without a BOM. Try the common
// single-byte/UTF-8 representation and both UTF-16 byte orders, then
// accept only a candidate that contains the expected subtitle syntax.
yield return DecodeSample(data, length, new UTF8Encoding(false, false));
yield return DecodeSample(data, length, Encoding.Unicode);
yield return DecodeSample(data, length, Encoding.BigEndianUnicode);
}
private static string DecodeSample(byte[] data, int length, Encoding encoding)
{
return encoding.GetString(data, 0, length)
.TrimStart('\uFEFF', ' ', '\r', '\n', '\t');
}
}
}

View File

@@ -8,11 +8,16 @@ param (
[Parameter(Mandatory=$false)]
[string]$EmbyVersion,
[Parameter(Mandatory=$false)]
[string]$Notes,
[Parameter(Mandatory=$false)]
[switch]$PublishToGitHub
)
$ErrorActionPreference = "Stop"
$SupportedEmbySdkVersion = "4.8.10"
$SupportedJellyfinSdkVersion = "10.10.7"
Write-Host "=== Start Release Process, Target Version: $Version ===" -ForegroundColor Cyan
@@ -23,24 +28,32 @@ Write-Host "Current package timestamp (UTC): $Timestamp" -ForegroundColor Gray
# Define paths
$ThunderCsproj = "Jellyfin.MeiamSub.Thunder\Jellyfin.MeiamSub.Thunder.csproj"
$ThunderMeta = "Jellyfin.MeiamSub.Thunder\meta.json"
$ShooterCsproj = "Jellyfin.MeiamSub.Shooter\Jellyfin.MeiamSub.Shooter.csproj"
$ShooterMeta = "Jellyfin.MeiamSub.Shooter\meta.json"
$AssrtCsproj = "Jellyfin.MeiamSub.Assrt\Jellyfin.MeiamSub.Assrt.csproj"
$AssrtMeta = "Jellyfin.MeiamSub.Assrt\meta.json"
$EmbyThunderCsproj = "Emby.MeiamSub.Thunder\Emby.MeiamSub.Thunder.csproj"
$EmbyShooterCsproj = "Emby.MeiamSub.Shooter\Emby.MeiamSub.Shooter.csproj"
$EmbyAssrtCsproj = "Emby.MeiamSub.Assrt\Emby.MeiamSub.Assrt.csproj"
# 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 $ShooterCsproj) -replace '<PackageReference Include="Jellyfin.Controller" Version="[^"]+" />', "<PackageReference Include=`"Jellyfin.Controller`" Version=`"$JellyfinVersion`" />" | Set-Content $ShooterCsproj
(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 $EmbyShooterCsproj) -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 $EmbyShooterCsproj
(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
@@ -52,23 +65,23 @@ Write-Host "Updating version and timestamp in project files..." -ForegroundColor
(Get-Content $ThunderMeta) -replace '"version":\s*"[\d\.]+"', "`"version`": `"$Version`"" `
-replace '"timestamp":\s*"[^"]+"', "`"timestamp`": `"$Timestamp`"" | Set-Content $ThunderMeta
# Update Jellyfin Shooter
(Get-Content $ShooterCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" `
-replace '<FileVersion>[\d\.]+</FileVersion>', "<FileVersion>$Version</FileVersion>" | Set-Content $ShooterCsproj
# 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 $ShooterMeta) -replace '"version":\s*"[\d\.]+"', "`"version`": `"$Version`"" `
-replace '"timestamp":\s*"[^"]+"', "`"timestamp`": `"$Timestamp`"" | Set-Content $ShooterMeta
(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 Shooter
(Get-Content $EmbyShooterCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" `
-replace '<FileVersion>[\d\.]+</FileVersion>', "<FileVersion>$Version</FileVersion>" | Set-Content $EmbyShooterCsproj
# 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
@@ -80,29 +93,30 @@ dotnet build MeiamSubtitles.sln -c Release
Write-Host "Packaging zip archives..." -ForegroundColor Yellow
$TempDir = "Release\temp_pack"
$EmbyPackDir = "$TempDir\Emby"
$JellyfinShooterPackDir = "$TempDir\Jellyfin\Jellyfin.MeiamSub.Shooter_$Version"
$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 $JellyfinShooterPackDir | 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.Shooter.dll $EmbyPackDir\
Copy-Item Release\Emby.MeiamSub.Thunder.dll $EmbyPackDir\
# Copy Jellyfin Shooter artifacts
Copy-Item Release\Jellyfin.MeiamSub.Shooter.dll $JellyfinShooterPackDir\
Copy-Item Jellyfin.MeiamSub.Shooter\meta.json $JellyfinShooterPackDir\
Copy-Item Jellyfin.MeiamSub.Shooter\thumb.png $JellyfinShooterPackDir\
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
$EmbyZip = "Release\Emby_v$Version.zip"
$JellyfinZip = "Release\Jellyfin_v$Version.zip"
@@ -133,7 +147,17 @@ if ($PublishToGitHub) {
git push origin master
Write-Host "Creating GitHub Release v$Version and uploading assets..." -ForegroundColor Yellow
gh release create "v$Version" $EmbyZip $JellyfinZip --title "v$Version" --generate-notes
if ($Notes) {
$AutoNotes = gh release generate-notes --tag "v$Version"
$NotesContent = "## Release Notes`n`n$Notes`n`n" + $AutoNotes
$NotesFile = "Release\temp_notes.md"
Set-Content -Path $NotesFile -Value $NotesContent
gh release create "v$Version" $EmbyZip $JellyfinZip --title "v$Version" --notes-file $NotesFile
if (Test-Path $NotesFile) { Remove-Item -Force $NotesFile }
} else {
gh release create "v$Version" $EmbyZip $JellyfinZip --title "v$Version" --generate-notes
}
Write-Host "=== GitHub Release finished successfully! ===" -ForegroundColor Green
}

View File

@@ -0,0 +1,21 @@
<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="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>

View File

@@ -0,0 +1,84 @@
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");
}
[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);
}
}