18 Commits

Author SHA1 Message Date
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
Meiam
58448f73db release: v1.0.14.0 2026-06-11 11:17:13 +08:00
Meiam
423021ee32 适配 Jellyfin 10.11.11 2026-06-11 11:02:38 +08:00
Meiam
74bba07091 新增: 迅雷字幕元数据搜索选项移植到 Jellyfin
任务摘要:将 PR #133 的元数据搜索功能从 Emby.MeiamSub.Thunder 移植到 Jellyfin.MeiamSub.Thunder。新增 EnableUseMetadata 配置项和 configPage.html 配置页面,修改 Plugin.cs 注册 IHasWebPages,ThunderProvider.cs 添加元数据搜索逻辑。同步更新开发协议、任务清单和归档索引。
2026-02-11 18:20:21 +08:00
Meiam
eb3268de44 更新:忽略文件 2026-02-11 17:13:53 +08:00
91270
856a6777c7 Merge pull request #133 from Mayfly777w/master
迅雷字幕提供额外匹配选项
2026-01-11 23:21:02 +08:00
Mayfly777w
60ff15c57a 迅雷字幕提供额外匹配选项
迅雷字幕提供额外匹配选项,可以以元数据中的系列名和季集信息来作为匹配对象
影视/电影做了特殊处理,可以正常匹配
2026-01-01 22:25:42 +08:00
Meiam
0761c11a69 新增(Jellyfin): 为 Shooter 和 Thunder 插件添加图标并配置编译输出 2025-12-23 13:49:52 +08:00
Meiam
72ca0c5c39 docs: 恢复 README.md 中被误删的广告位内容 2025-12-22 20:10:56 +08:00
Meiam
2c02096298 docs: 深度美化 README.md 排版并更新特性说明 2025-12-22 20:08:26 +08:00
Meiam
2e622cda22 chore: 从版本库中移除 GEMINI.md (保留本地文件) 2025-12-22 19:37:48 +08:00
39 changed files with 2174 additions and 327 deletions

8
.gitignore vendored
View File

@@ -361,10 +361,12 @@ MigrationBackup/
# Fody - auto-generated XML schema
FodyWeavers.xsd
/GEMINI.md
/TODO.md
/TestServer
TestServer/
.agent/
GEMINI.md
*.bat
*.md

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,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyVersion>1.0.16.0</AssemblyVersion>
<FileVersion>1.0.16.0</FileVersion>
<Version>1.0.16.0</Version>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<!-- Emby 4.9 provides SharpCompress 0.33.0 at runtime. Upgrading the compile-time
reference would make the plugin request an assembly version the host does not provide. -->
<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>
<PackageReference Include="MediaBrowser.Common" Version="4.9.1.90" />
<PackageReference Include="MediaBrowser.Server.Core" Version="4.9.1.90" />
<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">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyVersion>1.0.13.0</AssemblyVersion>
<FileVersion>1.0.13.0</FileVersion>
<Version>1.0.13.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">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyVersion>1.0.13.0</AssemblyVersion>
<FileVersion>1.0.13.0</FileVersion>
<Version>1.0.13.0</Version>
<AssemblyVersion>1.0.16.0</AssemblyVersion>
<FileVersion>1.0.16.0</FileVersion>
<Version>1.0.16.0</Version>
</PropertyGroup>
<PropertyGroup>
@@ -34,7 +34,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>

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,9 +1,13 @@
using MediaBrowser.Common;
using Emby.Web.GenericEdit;
using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Model.Drawing;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using System;
using System.ComponentModel;
using System.IO;
namespace Emby.MeiamSub.Thunder
@@ -12,10 +16,10 @@ namespace Emby.MeiamSub.Thunder
/// <summary>
/// 插件入口
/// </summary>
public class Plugin : BasePlugin, IHasThumbImage
public class Plugin : BasePluginSimpleUI<PluginConfiguration>, IHasThumbImage
{
public Plugin(IApplicationPaths applicationPaths)
public Plugin(IApplicationPaths applicationPaths, IApplicationHost applicationHost) : base(applicationHost)
{
Instance = this;
}
@@ -40,6 +44,10 @@ namespace Emby.MeiamSub.Thunder
/// </summary>
public ImageFormat ThumbImageFormat => ImageFormat.Gif;
/// <summary>
/// 获取插件选项
/// </summary>
public PluginConfiguration Options => this.GetOptions();
public static Plugin Instance { get; private set; }
@@ -64,4 +72,21 @@ namespace Emby.MeiamSub.Thunder
return stream;
}
}
/// <summary>
/// 插件配置类
/// </summary>
public class PluginConfiguration : EditableOptionsBase
{
public override string EditorTitle => "MeiamSub Thunder Options";
[Description("勾选此项后,使用元数据中的剧集名称和季集编号搜索字幕")]
public bool EnableUseMetadata { get; set; }
public PluginConfiguration()
{
// 默认值
EnableUseMetadata = false;
}
}
}

View File

@@ -1,16 +1,20 @@
using Emby.MeiamSub.Thunder.Model;
using MediaBrowser.Common;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Base;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Model.Logging;
using MediaBrowser.Model.Providers;
using MediaBrowser.Model.Serialization;
using MediaBrowser.Model.System;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
@@ -34,6 +38,9 @@ namespace Emby.MeiamSub.Thunder
protected readonly ILogger _logger;
private readonly IJsonSerializer _jsonSerializer;
private readonly IHttpClient _httpClient;
private readonly IServiceRoot _serviceRoot;
private Plugin MainPlugin { get; set; }
public int Order => 100;
@@ -46,11 +53,14 @@ namespace Emby.MeiamSub.Thunder
#endregion
#region 构造函数
public ThunderProvider(ILogManager logManager, IJsonSerializer jsonSerializer, IHttpClient httpClient)
public ThunderProvider(ILogManager logManager, IJsonSerializer jsonSerializer, IHttpClient httpClient, IApplicationHost applicationHost)
{
_logger = logManager.GetLogger(GetType().Name);
_jsonSerializer = jsonSerializer;
_httpClient = httpClient;
_serviceRoot = new ServiceRoot(applicationHost);
MainPlugin = _serviceRoot.GetService<IApplicationHost>().Plugins.OfType<Plugin>().FirstOrDefault();
_logger.Info("{0} Init", new object[1] { Name });
}
#endregion
@@ -68,7 +78,7 @@ namespace Emby.MeiamSub.Thunder
{
_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;
}
@@ -78,8 +88,32 @@ 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
// 备注:如果勾选采用元数据中的剧集名来作为字幕搜索匹配,则使用,否则默认用文件名
string MovieName;
if (MainPlugin.Options.EnableUseMetadata)
{
if (request.ContentType == VideoContentType.Episode)
{
MovieName = $"{request.SeriesName} S{request.ParentIndexNumber}E{request.IndexNumber}";
}
else if (request.ContentType == VideoContentType.Movie)
{
MovieName = request.Name;
}
else
{
MovieName = Path.GetFileName(request.MediaPath);
}
}
else
{
MovieName = Path.GetFileName(request.MediaPath);
}
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 增加异常处理
@@ -88,7 +122,7 @@ namespace Emby.MeiamSub.Thunder
{
var language = NormalizeLanguage(request.Language);
_logger.Info("{0} Search | Target -> {1} | Language -> {2}", Name, Path.GetFileName(request.MediaPath), language);
_logger.Info("{0} Search | Target -> {1} | Language -> {2}", Name, MovieName, language);
if (language != "chi")
{
@@ -97,7 +131,7 @@ namespace Emby.MeiamSub.Thunder
}
var stopWatch = Stopwatch.StartNew();
var cid = await GetCidByFileAsync(request.MediaPath);
var cid = await GetCidByFileAsync(request.MediaPath, cancellationToken);
stopWatch.Stop();
_logger.Info("{0} Search | FileHash -> {1} (Took {2}ms)", new object[3] { Name, cid, stopWatch.ElapsedMilliseconds });
@@ -105,14 +139,14 @@ namespace Emby.MeiamSub.Thunder
HttpRequestOptions options = new HttpRequestOptions
{
Url = $"https://api-shoulei-ssl.xunlei.com/oracle/subtitle?name={Path.GetFileName(request.MediaPath)}",
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)
{
@@ -120,9 +154,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>();
@@ -135,7 +176,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
})),
@@ -143,8 +184,8 @@ namespace Emby.MeiamSub.Thunder
Language = request.Language,
Author = "Meiam ",
ProviderName = $"{Name}",
Format = item.Ext,
Comment = $"Format : {item.Ext}",
Format = NormalizeFormat(item.Ext),
Comment = $"Format : {NormalizeFormat(item.Ext)}",
IsHashMatch = cid == item.Cid,
});
}
@@ -180,7 +221,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>
@@ -188,7 +229,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
@@ -218,13 +259,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();
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),
};
}
}
@@ -233,7 +281,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
@@ -281,6 +329,44 @@ 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;
}
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 Thunder.");
}
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) ||
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 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>
@@ -315,11 +401,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))
{
@@ -332,16 +427,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);
}
@@ -354,6 +449,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

@@ -1,68 +0,0 @@
# MeiamSubtitles 项目文档
## 项目概览
**MeiamSubtitles** 是一套为 **Emby** 和 **Jellyfin** 媒体服务器开发的 C# 字幕插件。它支持从 **迅雷影音** 和 **射手网** 自动下载中文字幕,并利用文件哈希匹配技术确保字幕的精确度。
## 项目结构
解决方案 `MeiamSubtitles.sln` 包含以下核心项目:
* **Emby 插件:**
* `Emby.MeiamSub.Shooter`: 射手网字幕提供程序 (目标框架: `netstandard2.1`)。
* `Emby.MeiamSub.Thunder`: 迅雷看看字幕提供程序 (目标框架: `netstandard2.1`)。
* **Jellyfin 插件:**
* `Jellyfin.MeiamSub.Shooter`: 射手网字幕提供程序 (目标框架: `net9.0`)。
* `Jellyfin.MeiamSub.Thunder`: 迅雷看看字幕提供程序 (目标框架: `net9.0`)。
* *注: Jellyfin 插件遵循现代 .NET 架构,通过 `PluginServiceRegistrator` 使用依赖注入来管理服务 (如 `IHttpClientFactory`)。*
* **开发工具:**
* `Emby.MeiamSub.DevTool`: 控制台应用程序,用于开发调试,特别是验证各平台字幕匹配所需的哈希计算逻辑。
## 编译与开发
### 环境要求
* .NET 9 SDK (用于编译 Jellyfin 插件)
* .NET Framework / .NET Core (支持 .NET Standard 2.1)
### 编译命令
在根目录下执行以下命令编译整个解决方案:
```bash
dotnet build MeiamSubtitles.sln
```
### 编译产出
项目配置了 `PostBuild` 事件,编译后的 DLL 文件会自动复制到:
1. 各项目目录下的 `..\Release`。
2. 解决方案根目录下的 `Release` 或 `Debug` 文件夹。
### 开发调试工具
可以使用 `Emby.MeiamSub.DevTool` 在本地测试哈希算法:
```bash
cd Emby.MeiamSub.DevTool
dotnet run
```
*注:测试时需在 `Program.cs` 中修改对应的视频文件路径。*
## 安装指南
### 手动安装 (Emby/通用)
1. 编译项目或下载已发布的版本。
2. 将生成的 `.dll` 文件复制到服务器的插件目录:
* **Windows:** `Emby-Server\Programdata\Plugins\` 或 `Emby-Server\System\Plugins\`
* **Linux:** `/opt/emby-server/system/plugins` 或 `/var/lib/emby/plugins`
* **群晖 (Synology):** `/var/packages/EmbyServer/var/plugins`
3. 重启 Emby 服务。
### Jellyfin 安装
Jellyfin 支持通过插件存储库安装:
* **存储库 URL:** `https://github.com/91270/MeiamSubtitles.Release/raw/main/Plugin/manifest-stable.json`
* 在 **控制台 -> 插件 -> 存储库** 中添加此链接。
## 核心技术
* **C# / .NET:** 核心开发语言 (Jellyfin: .NET 9, Emby: .NET Standard 2.1)。
* **依赖注入 (Jellyfin)**: 使用 `IHttpClientFactory` 管理 HTTP 请求,符合 Jellyfin 现代插件架构。
* **异步编程**: 核心 I/O 操作 (文件哈希) 采用异步模式 (`ReadExactlyAsync`),防止阻塞服务器线程。
* **依赖库版本**:
* **Emby**: `MediaBrowser.Server.Core` (v4.9.1.90)
* **Jellyfin**: `Jellyfin.Controller` (v10.11.5)
* **哈希算法:** 实现了射手网和迅雷特定的 MD5/SHA1 哈希计算逻辑,用于视频内容的精确匹配。

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,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Version>1.0.16.0</Version>
<AssemblyVersion>1.0.16.0</AssemblyVersion>
<FileVersion>1.0.16.0</FileVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<OutputPath>..\Release</OutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.11.11" />
<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.11.0.0",
"timestamp": "2026-08-07T07:11:29.0000000Z",
"version": "1.0.16.0",
"status": "Active",
"imagePath": "thumb.png"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

View File

@@ -1,10 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Version>1.0.13.0</Version>
<AssemblyVersion>1.0.13.0</AssemblyVersion>
<FileVersion>1.0.13.0</FileVersion>
<Version>1.0.16.0</Version>
<AssemblyVersion>1.0.16.0</AssemblyVersion>
<FileVersion>1.0.16.0</FileVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
@@ -12,11 +12,17 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.11.5" />
<PackageReference Include="Jellyfin.Controller" Version="10.11.11" />
</ItemGroup>
<ItemGroup>
<None Update="thumb.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Exec Command="Copy $(TargetDir)$(TargetFileName) $(SolutionDir)$(ConfigurationName)\$(TargetFileName) /y&#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": "2025-12-22T10:00:00.0000000Z",
"version": "1.0.13.0",
"status": "Active",
"timestamp": "2026-08-07T07:09:27.0000000Z",
"version": "1.0.16.0",
"status": "Deprecated",
"imagePath": "thumb.png"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -2,7 +2,24 @@ using MediaBrowser.Model.Plugins;
namespace Jellyfin.MeiamSub.Thunder.Configuration
{
/// <summary>
/// 插件配置类
/// <para>修改人: Meiam (移植自 PR #133 by Mayfly777w)</para>
/// <para>修改时间: 2026-02-11</para>
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// 是否使用元数据中的剧集名称和季集编号搜索字幕
/// <para>启用后,剧集使用 "{SeriesName} S{Season}E{Episode}" 格式搜索</para>
/// <para>电影使用元数据中的影片名称搜索</para>
/// <para>默认关闭,使用文件名搜索</para>
/// </summary>
public bool EnableUseMetadata { get; set; }
public PluginConfiguration()
{
EnableUseMetadata = false;
}
}
}

View File

@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<title>MeiamSub.Thunder</title>
</head>
<body>
<div id="MeiamSubThunderConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
data-require="emby-checkbox">
<div data-role="content">
<div class="content-primary">
<form id="MeiamSubThunderConfigForm">
<div class="verticalSection">
<h2 class="sectionTitle">迅雷字幕搜索设置</h2>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableUseMetadata" name="EnableUseMetadata" type="checkbox"
is="emby-checkbox" />
<span>使用元数据搜索字幕</span>
</label>
<div class="fieldDescription">
勾选此项后,使用元数据中的剧集名称和季集编号搜索字幕,而非文件名。
<br />
剧集将使用 "剧集名 S01E01" 格式;电影将使用影片名称。
</div>
</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>保存</span>
</button>
</div>
</form>
</div>
</div>
<script type="text/javascript">
var MeiamSubThunderConfig = {
pluginUniqueId: 'E4CE9DA9-EF00-417C-96F2-861C512D45EB'
};
document.querySelector('#MeiamSubThunderConfigPage')
.addEventListener('pageshow', function () {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(MeiamSubThunderConfig.pluginUniqueId).then(function (config) {
document.querySelector('#EnableUseMetadata').checked = config.EnableUseMetadata;
Dashboard.hideLoadingMsg();
});
});
document.querySelector('#MeiamSubThunderConfigForm')
.addEventListener('submit', function (e) {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(MeiamSubThunderConfig.pluginUniqueId).then(function (config) {
config.EnableUseMetadata = document.querySelector('#EnableUseMetadata').checked;
ApiClient.updatePluginConfiguration(MeiamSubThunderConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
e.preventDefault();
return false;
});
</script>
</div>
</body>
</html>

View File

@@ -3,8 +3,8 @@
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Version>1.0.13.0</Version>
<AssemblyVersion>1.0.13.0</AssemblyVersion>
<Version>1.0.16.0</Version>
<AssemblyVersion>1.0.16.0</AssemblyVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
@@ -12,11 +12,21 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.11.5" />
<PackageReference Include="Jellyfin.Controller" Version="10.11.11" />
</ItemGroup>
<ItemGroup>
<None Update="thumb.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</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,17 +1,19 @@
using Jellyfin.MeiamSub.Thunder.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.Thunder
{
/// <summary>
/// 插件入口
/// <para>修改人: Meiam</para>
/// <para>修改时间: 2025-12-22</para>
/// <para>修改人: Meiam (移植自 PR #133 by Mayfly777w)</para>
/// <para>修改时间: 2026-02-11</para>
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
/// <summary>
/// 插件ID
@@ -36,5 +38,21 @@ namespace Jellyfin.MeiamSub.Thunder
}
public static Plugin Instance { get; private set; }
/// <summary>
/// 获取插件配置页面列表
/// </summary>
/// <returns>配置页面信息</returns>
public IEnumerable<PluginPageInfo> GetPages()
{
return new[]
{
new PluginPageInfo
{
Name = Name,
EmbeddedResourcePath = GetType().Namespace + ".Configuration.configPage.html"
}
};
}
}
}

View File

@@ -68,9 +68,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,31 +79,49 @@ 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
// 修改时间: 2025-12-22
// 备注: 增加极致探测日志
_logger.LogInformation("DEBUG: Entering SearchSubtitlesAsync (Thunder)");
// 修改人: Meiam (移植自 PR #133 by Mayfly777w)
// 修改时间: 2026-02-11
// 备注: 增加元数据搜索选项,可以以元数据中的系列名和季集信息来作为匹配对象
try
{
if (request == null)
{
_logger.LogInformation("DEBUG: Request is null");
_logger.LogInformation("{Provider} Search | Request is null", Name);
return Array.Empty<RemoteSubtitleInfo>();
}
var language = NormalizeLanguage(request.Language);
var fileName = string.Empty;
if (!string.IsNullOrEmpty(request.MediaPath))
// 根据配置决定搜索名称:使用元数据 或 文件名
string movieName;
if (Plugin.Instance?.Configuration?.EnableUseMetadata == true)
{
fileName = Path.GetFileName(request.MediaPath);
if (request.ContentType == VideoContentType.Episode)
{
movieName = $"{request.SeriesName} S{request.ParentIndexNumber}E{request.IndexNumber}";
}
else if (request.ContentType == VideoContentType.Movie)
{
movieName = request.Name;
}
else
{
movieName = !string.IsNullOrEmpty(request.MediaPath)
? Path.GetFileName(request.MediaPath)
: string.Empty;
}
}
else
{
movieName = !string.IsNullOrEmpty(request.MediaPath)
? Path.GetFileName(request.MediaPath)
: string.Empty;
}
_logger.LogInformation(Name + " Search | Target -> " + fileName + " | Language -> " + language);
_logger.LogInformation(Name + " Search | Target -> " + movieName + " | Language -> " + language);
if (language != "chi")
{
@@ -119,7 +136,7 @@ namespace Jellyfin.MeiamSub.Thunder
}
var stopWatch = Stopwatch.StartNew();
var cid = await GetCidByFileAsync(request.MediaPath);
var cid = await GetCidByFileAsync(request.MediaPath, cancellationToken);
stopWatch.Stop();
_logger.LogInformation(Name + " Search | FileHash -> " + cid + " (Took " + stopWatch.ElapsedMilliseconds + "ms)");
@@ -127,14 +144,14 @@ namespace Jellyfin.MeiamSub.Thunder
using var options = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri($"https://api-shoulei-ssl.xunlei.com/oracle/subtitle?name={Path.GetFileName(request.MediaPath)}")
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)
{
@@ -142,9 +159,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>();
@@ -157,15 +181,15 @@ 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}",
Format = NormalizeFormat(item.Ext),
Comment = $"Format : {NormalizeFormat(item.Ext)}",
IsHashMatch = cid == item.Cid,
});
}
@@ -200,7 +224,7 @@ namespace Jellyfin.MeiamSub.Thunder
{
_logger.LogInformation($"{Name} DownloadSub | Request -> {id}");
return await DownloadSubAsync(id);
return await DownloadSubAsync(id, cancellationToken);
}
/// <summary>
@@ -208,7 +232,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
@@ -233,19 +257,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);
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,
};
}
@@ -255,7 +283,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
@@ -303,6 +331,44 @@ 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;
}
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 Thunder.");
}
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) ||
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 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>
@@ -337,11 +403,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))
{
@@ -351,16 +426,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);
}
@@ -375,4 +450,4 @@ namespace Jellyfin.MeiamSub.Thunder
}
#endregion
}
}
}

View File

@@ -5,8 +5,8 @@
"name": "MeiamSub.Thunder",
"owner": "Meiam",
"targetAbi": "10.11.0.0",
"timestamp": "2025-12-22T10:00:00.0000000Z",
"version": "1.0.13.0",
"timestamp": "2026-08-07T07:11:29.0000000Z",
"version": "1.0.16.0",
"status": "Active",
"imagePath": "thumb.png"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

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,12 @@ 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
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -27,18 +27,18 @@ 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
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

176
README.md
View File

@@ -1,133 +1,109 @@
# MeiamSubtitles
Emby & Jellyfin 中文字幕插件,支持 **迅雷影音**、**射手网** 字幕自动下载与精准 Hash 匹配。
# 🎬 MeiamSubtitles
[![.NET Status](https://img.shields.io/badge/.NET-Standard%202.1%20%7C%209.0-blueviolet.svg)](#)
[![Platform](https://img.shields.io/badge/Platform-Linux%20%7C%20Win%20%7C%20OSX-brightgreen.svg)](#)
[![LICENSE](https://img.shields.io/badge/license-Apache%202-blue)](#)
[![Star](https://img.shields.io/github/stars/91270/Emby.MeiamSub?label=Star%20this%20repo)](https://github.com/91270/Emby.MeiamSub)
[![Fork](https://img.shields.io/github/forks/91270/Emby.MeiamSub?label=Fork%20this%20repo)](https://github.com/91270/Emby.MeiamSub/fork)
[![博客](https://img.shields.io/badge/博客-Meiam's%20Home-brightgreen.svg)](https://www.592.la/)
**MeiamSubtitles** 是一款专为 **Emby** 和 **Jellyfin** 媒体服务器打造的中文字幕下载插件。它集成了 **迅雷影音** 与 **射手网** 的强大搜索能力,支持精准的视频哈希(Hash)匹配,让您的媒体库自动补全高质量字幕。
&nbsp;
---
## 给个星星! ⭐️
<p align="left">
<img src="https://img.shields.io/badge/.NET-Standard%202.1%20%7C%209.0-blueviolet.svg" alt=".NET Status">
<img src="https://img.shields.io/badge/Platform-Linux%20%7C%20Win%20%7C%20OSX-brightgreen.svg" alt="Platform">
<img src="https://img.shields.io/badge/license-Apache%202-blue" alt="LICENSE">
<a href="https://github.com/91270/Emby.MeiamSub"><img src="https://img.shields.io/github/stars/91270/Emby.MeiamSub?label=Star%20this%20repo" alt="Star"></a>
<a href="https://www.592.la/"><img src="https://img.shields.io/badge/博客-Meiam's%20Home-brightgreen.svg" alt="博客"></a>
</p>
如果你喜欢这个项目或者它帮助你, 请给 Star~(辛苦咯)
## 📣 广告时间
如果你能赞助稳定 Google Drive 团队盘用于媒体库插件测试, 请于我联系 91270#QQ.COM
> **搬瓦工 $99 年付**:建站神器重出江湖,THE PLAN V1 传家宝套餐,18机房随意切换。
> **循环优惠码**:`NODESEEK2026` (6.77%)
> **[直达通道 (传家宝套餐)](https://bwh88.net/aff.php?aff=117&pid=87)**
&nbsp;
## ✨ 核心特性
## 广告时间 📣
- **🚀 精准匹配**: 支持迅雷看看 (CID) 和射手网 (Hash) 双重校验逻辑,确保字幕与视频内容完美同步。
- **⚡ 极致性能**: 核心采样算法全面采用**异步 I/O (Async/Await)** 模式,在大规模媒体库扫描时不会阻塞服务器线程。
- **🌐 广泛兼容**: 深度适配 **Jellyfin 10.11+** 及 **Emby v4.9+**,支持 `zho`、`chi` 等多种国际化语言代码映射。
- **🛡️ 稳定可靠**: 针对射手网 API 的老化问题增加了防御性校验,能有效处理乱码返回,保证系统长效稳定。
- **📝 详尽日志**: 记录哈希计算耗时与接口原始响应,让问题排查不再是黑盒。
搬瓦工 $99 年付, 建站神器重出江湖,THE PLAN V1 传家宝套餐,18机房随意切换
## 📦 项目组件说明
循环优惠码:BWHCCNCXVV(6.77%)
| 组件名称 | 适用平台 | 目标框架 | 说明 |
| :--- | :--- | :--- | :--- |
| **Emby.MeiamSub.Thunder** | Emby | .NET Standard 2.1 | 迅雷看看字幕插件 |
| **Emby.MeiamSub.Assrt** | Emby | .NET Standard 2.1 | Assrt 字幕插件 |
| **Jellyfin.MeiamSub.Thunder** | Jellyfin | .NET 9.0 | 迅雷看看字幕插件 (现代 DI 架构) |
| **Jellyfin.MeiamSub.Assrt** | Jellyfin | .NET 9.0 | Assrt 字幕插件 |
| **Emby.MeiamSub.DevTool** | 开发调试 | .NET 8.0 | 哈希算法测试与 API 模拟工具 |
[直达通道(传家宝套餐)](https://bwh88.net/aff.php?aff=117&pid=87)
> **插件状态:** 当前仅发布 Assrt 和 Thunder。Shooter 的旧版哈希接口已很少返回有效字幕,自 v1.0.16.0 起停止编译和发布。
&nbsp;
---
## 功能介绍
## 🚀 快速安装
- [x] **迅雷影音**: 支持通过文件 Hash (CID) 精准匹配字幕。
- [x] **射手网**: 支持通过文件 Hash 精准匹配字幕。
- [x] **高性能**: 核心哈希计算采用异步 I/O (Async/Await) 模式,避免阻塞服务器线程。
- [x] **稳定性**: 内置重试机制与异常处理,Jellyfin 版本采用现代化的依赖注入架构。
### 第一步:获取插件
前往 [GitHub Releases](https://github.com/91270/Emby.MeiamSub/releases) 下载最新版本的发布包。
## 项目说明
> **🔔 推荐建议**:在媒体库设置中**不勾选**本插件作为默认自动下载器。建议仅在手动“搜索字幕”时使用,以获得更精准的人工筛选体验。
| # | 模块功能 | 项目名称 | 说明 |
|---|---|---|---|
| 1 | Emby 插件 | `Emby.MeiamSub.Thunder` | 迅雷看看字幕插件 (.NET Standard 2.1) |
| 2 | Emby 插件 | `Emby.MeiamSub.Shooter` | 射手影音字幕插件 (.NET Standard 2.1) |
| 3 | Jellyfin 插件 | `Jellyfin.MeiamSub.Thunder` | 迅雷看看字幕插件 (.NET 9.0) |
| 4 | Jellyfin 插件 | `Jellyfin.MeiamSub.Shooter` | 射手影音字幕插件 (.NET 9.0) |
| 5 | 开发工具 | `Emby.MeiamSub.DevTool` | 哈希算法测试与调试工具 |
### 第二步:部署插件
## 使用插件
#### 🔹 方式 A:Jellyfin 存储库安装 (强烈推荐)
Jellyfin 用户可直接添加官方存储库,实现一键安装与自动更新:
1. 控制台 -> **插件** -> **存储库** -> 点击“添加”。
2. 输入名称 `MeiamSub` 和 URL:
`https://github.com/91270/MeiamSubtitles.Release/raw/main/Plugin/manifest-stable.json`
3. 在“目录”中找到插件并安装,重启服务即可。
首先下载已编译好的插件 [Release 下载](https://github.com/91270/Emby.MeiamSub/releases)。
#### 🔹 方式 B:手动安装 (Emby/通用)
将下载的 `.dll` 文件(Jellyfin 用户请下载 `.zip` 并解压完整目录)放入服务器的 `plugins` 文件夹:
**注意**:建议在媒体库设置中**不勾选**本插件作为默认下载器,仅在手动“编辑字幕”或“搜索字幕”时使用,以获得最佳体验。
- **Windows**: `AppData\Local\jellyfin\plugins` 或 `Emby-Server\programdata\plugins`
- **Linux/Docker**: `/config/plugins` 或 `/var/lib/emby/plugins`
- **群晖/威联通**: 对应套件安装目录下的 `plugins` 文件夹
## 常见问题排查 (FAQ)
---
如果您在搜索字幕时遇到“未找到结果”,请按以下步骤排查:
## ❓ 常见问题排查 (FAQ)
### 1. 语言代码识别
- **现象**: 在 Jellyfin 10.11+ 中搜索不到字幕。
- **原因**: 新版 Jellyfin 可能会传递三位字母代码 (如 `zho`)。
- **解决**: 本插件最新版已修复该问题,支持 `zho`、`chi`、`zh-CN` 等多种映射。请确保您使用的是最新版本的 DLL。
<details>
<summary><b>1. 为什么在 Jellyfin 10.11+ 中搜不到字幕?</b></summary>
新版 Jellyfin 采用了三位字母的语言代码(如 <code>zho</code>)。请确保您已升级至本插件的 <b>v1.0.13.0</b> 或更高版本,该版本已完美解决语言映射兼容性。
</details>
### 2. 射手网 API 状态
- **现象**: 部分电影能搜到,部分完全搜不到。
- **原因**: 射手网 API 年久失修,对于无资源或 Hash 不匹配的文件,API 可能返回异常内容(如乱码)。
- **表现**: 插件已增加防御性校验,若检测到 API 返回异常,会自动过滤并返回空结果以保证系统稳定。
<details>
<summary><b>2. 为什么射手网有时候返回结果为空?</b></summary>
由于射手网 API 维护状态不佳,对于部分冷门资源或 Hash 不匹配的文件,API 可能会返回非法数据。插件目前已增加防御逻辑,会自动忽略这些无效返回以保护服务器稳定。
</details>
### 3. 如何反馈问题
如果遇到确定有字幕但搜不到的情况,请查看服务器日志并提供以下关键信息:
- 搜索开始时的 `Target` 文件名。
- 接口返回的 `ResponseBody` 内容。
- 计算得到的 `FileHash` 值。
<details>
<summary><b>3. 安装本插件后会影响 Open Subtitles 吗?</b></summary>
不会。本插件已将优先级 (Order) 调整为 100(低优先级),并在代码层面优化了并发逻辑,确保官方插件能优先获取请求机会。
</details>
## Jellyfin 安装 (推荐)
<details>
<summary><b>4. 如何提供有效的错误反馈?</b></summary>
如果确定有字幕但搜不到,请在 Issue 中提供日志里的 <code>Target</code> 文件名、计算出的 <code>FileHash</code> 以及 <code>ResponseBody</code> 内容。
</details>
Jellyfin 用户可以通过添加插件存储库实现一键安装和自动更新:
---
1. 打开 Jellyfin 控制台 -> **插件** -> **存储库**。
2. 点击添加,输入名称 (如 MeiamSub) 和以下 URL:
```
https://github.com/91270/MeiamSubtitles.Release/raw/main/Plugin/manifest-stable.json
```
3. 保存后在插件目录中找到 **MeiamSub.Thunder** 和 **MeiamSub.Shooter** 进行安装。
4. 重启 Jellyfin 服务。
## 🤝 贡献与感谢
### 手动安装 (Emby / 通用)
欢迎通过提交 Issue 或 Pull Request 来完善本项目。
将下载的 `.dll` 文件复制到服务器的插件目录,然后重启服务。
- **开发守则**: 遵循异步命名规范,详情请参阅 [.docs/开发协议.md](.docs/开发协议.md)。
- **任务进展**: 查看 [.docs/任务清单.md](.docs/任务清单.md) 了解当前计划。
- **致谢**: 感谢 [Emby.Subtitle.Subscene](https://github.com/nRafinia/Emby.Subtitle.Subscene) 提供的灵感与参考。
#### Windows
```bash
# 路径可能因安装方式不同而异
Emby-Server\Programdata\Plugins\
# 或
Emby-Server\System\Plugins\
```
---
#### Linux / Docker
```bash
# 常见路径
/opt/emby-server/system/plugins
# 或
/var/lib/emby/plugins
```
## ⭐️ 给个星星
#### 群晖 (Synology)
如果你喜欢这个项目,请给一个 **Star**!这对我非常重要。
```bash
/var/packages/EmbyServer/var/plugins
# 或
/var/packages/EmbyServer/target/system/plugins
```
如果你有稳定的 Google Drive 团队盘资源可供媒体库插件测试,欢迎联系:`91270#QQ.COM`
#### 威联通 (QNAP)
```bash
# 其中`CACHEDEV{num}_DATA`的名称取决于你的qpkg安装位置
/share/CACHEDEV1_DATA/.qpkg/EmbyServer/programdata/plugins
/share/CACHEDEV1_DATA/.qpkg/EmbyServer/system/plugins
```
&nbsp;
## 贡献
欢迎提交 Issue 反馈问题,或提交 Pull Request 贡献代码。
* **开发分支**: `master`
* **代码风格**: 请遵循现有的 C# 代码风格,异步方法请使用 `Async` 后缀。
## 致谢
[Emby.Subtitle.Subscene](https://github.com/nRafinia/Emby.Subtitle.Subscene)
---
*Powered by Meiam*

153
publish.ps1 Normal file
View File

@@ -0,0 +1,153 @@
param (
[Parameter(Mandatory=$true)]
[string]$Version,
[Parameter(Mandatory=$false)]
[string]$JellyfinVersion,
[Parameter(Mandatory=$false)]
[string]$EmbyVersion,
[Parameter(Mandatory=$false)]
[string]$Notes,
[Parameter(Mandatory=$false)]
[switch]$PublishToGitHub
)
$ErrorActionPreference = "Stop"
Write-Host "=== Start Release Process, Target Version: $Version ===" -ForegroundColor Cyan
# 1. Get current UTC timestamp
$Timestamp = [DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.0000000Z")
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"
$AssrtCsproj = "Jellyfin.MeiamSub.Assrt\Jellyfin.MeiamSub.Assrt.csproj"
$AssrtMeta = "Jellyfin.MeiamSub.Assrt\meta.json"
$EmbyThunderCsproj = "Emby.MeiamSub.Thunder\Emby.MeiamSub.Thunder.csproj"
$EmbyAssrtCsproj = "Emby.MeiamSub.Assrt\Emby.MeiamSub.Assrt.csproj"
# Update NuGet package version if specified
if ($JellyfinVersion) {
Write-Host "Upgrading Jellyfin.Controller NuGet dependency to $JellyfinVersion..." -ForegroundColor Yellow
(Get-Content $ThunderCsproj) -replace '<PackageReference Include="Jellyfin.Controller" Version="[^"]+" />', "<PackageReference Include=`"Jellyfin.Controller`" Version=`"$JellyfinVersion`" />" | Set-Content $ThunderCsproj
(Get-Content $AssrtCsproj) -replace '<PackageReference Include="Jellyfin.Controller" Version="[^"]+" />', "<PackageReference Include=`"Jellyfin.Controller`" Version=`"$JellyfinVersion`" />" | Set-Content $AssrtCsproj
}
if ($EmbyVersion) {
Write-Host "Upgrading MediaBrowser NuGet dependencies to $EmbyVersion..." -ForegroundColor Yellow
(Get-Content $EmbyThunderCsproj) -replace '<PackageReference Include="MediaBrowser.Common" Version="[^"]+" />', "<PackageReference Include=`"MediaBrowser.Common`" Version=`"$EmbyVersion`" />" `
-replace '<PackageReference Include="MediaBrowser.Server.Core" Version="[^"]+" />', "<PackageReference Include=`"MediaBrowser.Server.Core`" Version=`"$EmbyVersion`" />" | Set-Content $EmbyThunderCsproj
(Get-Content $EmbyAssrtCsproj) -replace '<PackageReference Include="MediaBrowser.Common" Version="[^"]+" />', "<PackageReference Include=`"MediaBrowser.Common`" Version=`"$EmbyVersion`" />" `
-replace '<PackageReference Include="MediaBrowser.Server.Core" Version="[^"]+" />', "<PackageReference Include=`"MediaBrowser.Server.Core`" Version=`"$EmbyVersion`" />" | Set-Content $EmbyAssrtCsproj
}
Write-Host "Updating version and timestamp in project files..." -ForegroundColor Yellow
# Update Jellyfin Thunder
(Get-Content $ThunderCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" | Set-Content $ThunderCsproj
(Get-Content $ThunderMeta) -replace '"version":\s*"[\d\.]+"', "`"version`": `"$Version`"" `
-replace '"timestamp":\s*"[^"]+"', "`"timestamp`": `"$Timestamp`"" | Set-Content $ThunderMeta
# Update Jellyfin Assrt
(Get-Content $AssrtCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" `
-replace '<FileVersion>[\d\.]+</FileVersion>', "<FileVersion>$Version</FileVersion>" | Set-Content $AssrtCsproj
(Get-Content $AssrtMeta) -replace '"version":\s*"[\d\.]+"', "`"version`": `"$Version`"" `
-replace '"timestamp":\s*"[^"]+"', "`"timestamp`": `"$Timestamp`"" | Set-Content $AssrtMeta
# Update Emby Thunder
(Get-Content $EmbyThunderCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" `
-replace '<FileVersion>[\d\.]+</FileVersion>', "<FileVersion>$Version</FileVersion>" | Set-Content $EmbyThunderCsproj
# Update Emby Assrt
(Get-Content $EmbyAssrtCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" `
-replace '<FileVersion>[\d\.]+</FileVersion>', "<FileVersion>$Version</FileVersion>" | Set-Content $EmbyAssrtCsproj
Write-Host "Versions and timestamps updated successfully!" -ForegroundColor Green
# 2. Compile solution
Write-Host "Compiling solution in Release mode..." -ForegroundColor Yellow
dotnet build MeiamSubtitles.sln -c Release
# 3. Create zip archives
Write-Host "Packaging zip archives..." -ForegroundColor Yellow
$TempDir = "Release\temp_pack"
$EmbyPackDir = "$TempDir\Emby"
$JellyfinThunderPackDir = "$TempDir\Jellyfin\Jellyfin.MeiamSub.Thunder_$Version"
$JellyfinAssrtPackDir = "$TempDir\Jellyfin\Jellyfin.MeiamSub.Assrt_$Version"
# Clean temp directories
if (Test-Path $TempDir) { Remove-Item -Recurse -Force $TempDir }
New-Item -ItemType Directory -Force -Path $EmbyPackDir | Out-Null
New-Item -ItemType Directory -Force -Path $JellyfinThunderPackDir | Out-Null
New-Item -ItemType Directory -Force -Path $JellyfinAssrtPackDir | Out-Null
# Copy Emby DLLs
Copy-Item Release\Emby.MeiamSub.Thunder.dll $EmbyPackDir\
Copy-Item Release\Emby.MeiamSub.Assrt.dll $EmbyPackDir\
# Copy Jellyfin Thunder artifacts
Copy-Item Release\Jellyfin.MeiamSub.Thunder.dll $JellyfinThunderPackDir\
Copy-Item Jellyfin.MeiamSub.Thunder\meta.json $JellyfinThunderPackDir\
Copy-Item Jellyfin.MeiamSub.Thunder\thumb.png $JellyfinThunderPackDir\
# Copy Jellyfin Assrt artifacts
Copy-Item Release\Jellyfin.MeiamSub.Assrt.dll $JellyfinAssrtPackDir\
Copy-Item Release\dependencies\Jellyfin\* $JellyfinAssrtPackDir\
Copy-Item Jellyfin.MeiamSub.Assrt\meta.json $JellyfinAssrtPackDir\
Copy-Item Jellyfin.MeiamSub.Assrt\thumb.png $JellyfinAssrtPackDir\
# Archive target paths
$EmbyZip = "Release\Emby_v$Version.zip"
$JellyfinZip = "Release\Jellyfin_v$Version.zip"
if (Test-Path $EmbyZip) { Remove-Item -Force $EmbyZip }
if (Test-Path $JellyfinZip) { Remove-Item -Force $JellyfinZip }
# Compress
Compress-Archive -Path "$EmbyPackDir\*" -DestinationPath $EmbyZip -Force
Compress-Archive -Path "$TempDir\Jellyfin\*" -DestinationPath $JellyfinZip -Force
# Cleanup temp
Remove-Item -Recurse -Force $TempDir
Write-Host "=== Packaging Finished successfully! ===" -ForegroundColor Green
Write-Host "Emby Package: $EmbyZip" -ForegroundColor Gray
Write-Host "Jellyfin Package: $JellyfinZip" -ForegroundColor Gray
# 4. Publish to GitHub Release if specified
if ($PublishToGitHub) {
Write-Host "=== Start GitHub Release process ===" -ForegroundColor Cyan
Write-Host "Staging and committing release changes..." -ForegroundColor Yellow
git add .
git commit -m "release: v$Version"
Write-Host "Pushing changes to remote repository..." -ForegroundColor Yellow
git push origin master
Write-Host "Creating GitHub Release v$Version and uploading assets..." -ForegroundColor Yellow
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
}