27 Commits

Author SHA1 Message Date
Meiam
eb44362717 release: v1.0.17.0 2026-09-16 16:18:27 +08:00
Meiam
9455450cf2 fix: broaden Emby and Jellyfin compatibility 2026-09-16 16:10:15 +08:00
91270
c473839f4d Merge pull request #150 from 91270/codex/pr-149-takeover
修复 Thunder 对合法 ASS/SSA 与 UTF-16 字幕的误拒绝,并加强错误响应校验。
2026-08-31 10:16:02 +08:00
Meiam
46bc076b8f fix: strengthen Thunder subtitle validation 2026-08-31 10:12:47 +08:00
yjt
7ec1583bd0 fix: accept valid Thunder subtitle encodings 2026-08-23 18:19:13 +08:00
Meiam
bf6304df96 文档: 更新搬瓦工套餐与优惠码 2026-08-10 15:47:28 +08:00
Meiam
87a5c361b8 修复: 媒体文件不可读时回退名称搜索 2026-08-07 15:41:33 +08:00
Meiam
dba34a6231 文档: 调整 README 徽章展示 2026-08-07 15:31:33 +08:00
Meiam
1fe2a70221 文档: 完善安装配置与故障排查说明 2026-08-07 15:30:07 +08:00
Meiam
5c309f44ee 发布: 停止 Shooter 并发布 1.0.16.0 2026-08-07 15:14:09 +08:00
Meiam
d9407006df 发布: 提升字幕下载可靠性并弃用 Shooter 2026-08-07 14:22:31 +08:00
Meiam
786cb82868 更新: 替换 Assrt 插件图片 2026-08-07 12:20:32 +08:00
Meiam
38a46d2f53 修复: 支持 Assrt 压缩字幕下载 2026-08-07 11:56:46 +08:00
Meiam
220fe5459d 文档: 更新 README 中的搬瓦工优惠码 2026-07-07 17:38:37 +08:00
Meiam
66ed8ff49c 新增: Assrt(伪射手)字幕提供程序,支持 Emby/Jellyfin 双平台 2026-06-15 15:19:15 +08:00
Meiam
c4adc97bbf fix: ignore network paths when computing video hash to prevent crashes (#136) 2026-06-11 11:43:51 +08:00
Meiam
ba40c107cf chore: support custom notes in release script 2026-06-11 11:19:44 +08:00
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
43 changed files with 2655 additions and 339 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,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyVersion>1.0.17.0</AssemblyVersion>
<FileVersion>1.0.17.0</FileVersion>
<Version>1.0.17.0</Version>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<!-- 已验证 Emby 4.8.10.0、4.9.5.0 和 4.10.0.40 均提供 SharpCompress 0.33.0。
保持与宿主程序集版本一致,避免插件加载或解压失败。 -->
<NoWarn>$(NoWarn);NU1902</NoWarn>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<OutputPath>..\Release</OutputPath>
</PropertyGroup>
<ItemGroup>
<None Remove="Thumb.png" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Thumb.png" />
</ItemGroup>
<ItemGroup>
<!-- 使用旧版宿主可解析的 SDK,避免插件要求 4.9.1.90 程序集。 -->
<PackageReference Include="MediaBrowser.Common" Version="4.8.10" />
<PackageReference Include="MediaBrowser.Server.Core" Version="4.8.10" />
<PackageReference Include="SharpCompress" Version="0.33.0" ExcludeAssets="runtime" PrivateAssets="all" />
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
</Target>
</Project>

View File

@@ -0,0 +1,57 @@
using System.Collections.Generic;
namespace Emby.MeiamSub.Assrt.Model
{
public class AssrtSearchResponse
{
public int status { get; set; }
public AssrtSearchSubInfo sub { get; set; }
}
public class AssrtSearchSubInfo
{
public string keyword { get; set; }
public string result { get; set; }
public List<AssrtSearchItem> subs { get; set; }
}
public class AssrtSearchItem
{
public object id { get; set; }
public string native_name { get; set; }
public string videoname { get; set; }
public string subtype { get; set; }
public AssrtLanguageInfo lang { get; set; }
}
public class AssrtLanguageInfo
{
public string desc { get; set; }
public int lang { get; set; }
}
public class AssrtDetailResponse
{
public int status { get; set; }
public AssrtDetailSubInfo sub { get; set; }
}
public class AssrtDetailSubInfo
{
public List<AssrtDetailItem> subs { get; set; }
}
public class AssrtDetailItem
{
public object id { get; set; }
public string filename { get; set; }
public string url { get; set; }
}
public class AssrtDownloadInfo
{
public string Id { get; set; }
public string Format { get; set; }
public string Language { get; set; }
}
}

View File

@@ -0,0 +1,92 @@
using Emby.Web.GenericEdit;
using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Model.Drawing;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using System;
using System.ComponentModel;
using System.IO;
namespace Emby.MeiamSub.Assrt
{
/// <summary>
/// Assrt 插件入口
/// </summary>
public class Plugin : BasePluginSimpleUI<PluginConfiguration>, IHasThumbImage
{
public Plugin(IApplicationPaths applicationPaths, IApplicationHost applicationHost) : base(applicationHost)
{
Instance = this;
}
/// <summary>
/// 插件ID
/// </summary>
public override Guid Id => new Guid("D276A8D0-4B17-4EEF-94B6-61D646DE5A22");
/// <summary>
/// 插件名称
/// </summary>
public override string Name => "MeiamSub.Assrt";
/// <summary>
/// 插件描述
/// </summary>
public override string Description => "Download subtitles from Assrt (伪射手网)";
/// <summary>
/// 缩略图格式化类型
/// </summary>
public ImageFormat ThumbImageFormat => ImageFormat.Gif;
/// <summary>
/// 获取插件选项
/// </summary>
public PluginConfiguration Options => this.GetOptions();
public static Plugin Instance { get; private set; }
/// <summary>
/// 获取插件缩略图资源流
/// </summary>
/// <returns>图片资源流,若不存在则返回 null</returns>
public Stream GetThumbImage()
{
var type = GetType();
var resourceName = $"{type.Namespace}.Thumb.png";
var stream = type.Assembly.GetManifestResourceStream(resourceName);
if (stream == null)
{
return null;
}
return stream;
}
}
/// <summary>
/// 插件配置类
/// </summary>
public class PluginConfiguration : EditableOptionsBase
{
public override string EditorTitle => "MeiamSub Assrt Options";
[DisplayName("Assrt API Token")]
[Description("请输入您在 assrt.net 注册获取的 32 位 API Token")]
public string AssrtApiToken { get; set; }
[DisplayName("使用元数据搜索字幕")]
[Description("勾选此项后,使用元数据中的剧集名称和季集编号搜索字幕,而非文件名。建议开启。")]
public bool EnableUseMetadata { get; set; }
public PluginConfiguration()
{
AssrtApiToken = string.Empty;
EnableUseMetadata = true;
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

View File

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

View File

@@ -17,7 +17,7 @@ namespace Emby.MeiamSub.Thunder.Model
public string Url { get; set; }
public string Ext { get; set; }
public string Name { get; set; }
public int Duration { get; set; }
public long Duration { get; set; }
public string[] Languages { get; set; }
public string Langs => Languages != null ? string.Join(",", Languages) : string.Empty;

View File

@@ -1,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,21 @@
using Emby.MeiamSub.Thunder.Model;
using MeiamSubtitles.Shared;
using MediaBrowser.Common;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Base;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Model.Logging;
using MediaBrowser.Model.Providers;
using MediaBrowser.Model.Serialization;
using MediaBrowser.Model.System;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
@@ -34,6 +39,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 +54,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
@@ -66,9 +77,15 @@ namespace Emby.MeiamSub.Thunder
/// <returns>远程字幕信息列表</returns>
public async Task<IEnumerable<RemoteSubtitleInfo>> Search(SubtitleSearchRequest request, CancellationToken cancellationToken)
{
_logger.Info("{0} Search | SubtitleSearchRequest -> {1}", new object[2] { Name, _jsonSerializer.SerializeToString(request) });
if (request == null)
{
_logger.Info("{0} Search | Request is null, skip search.", Name);
return Array.Empty<RemoteSubtitleInfo>();
}
var subtitles = await SearchSubtitlesAsync(request);
_logger.Info("{0} Search | ContentType -> {1} | Language -> {2}", Name, request.ContentType, request.Language);
var subtitles = await SearchSubtitlesAsync(request, cancellationToken);
return subtitles;
}
@@ -78,8 +95,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 +129,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")
{
@@ -96,23 +137,37 @@ namespace Emby.MeiamSub.Thunder
return Array.Empty<RemoteSubtitleInfo>();
}
var cid = string.Empty;
var stopWatch = Stopwatch.StartNew();
var cid = await GetCidByFileAsync(request.MediaPath);
stopWatch.Stop();
_logger.Info("{0} Search | FileHash -> {1} (Took {2}ms)", new object[3] { Name, cid, stopWatch.ElapsedMilliseconds });
try
{
cid = await GetCidByFileAsync(request.MediaPath, cancellationToken);
_logger.Info("{0} Search | FileHash -> {1} (Took {2}ms)", Name, cid, stopWatch.ElapsedMilliseconds);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
{
_logger.Warn("{0} Search | File hash unavailable ({1}: {2}), continue with name search.", Name, ex.GetType().Name, ex.Message);
}
finally
{
stopWatch.Stop();
}
HttpRequestOptions options = new HttpRequestOptions
{
Url = $"https://api-shoulei-ssl.xunlei.com/oracle/subtitle?name={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 +175,16 @@ namespace Emby.MeiamSub.Thunder
if (subtitleResponse.Code == 0)
{
_logger.Info("{0} Search | Response -> {1}", new object[2] { Name, _jsonSerializer.SerializeToString(subtitleResponse) });
var subtitles = subtitleResponse.Data.Where(m => !string.IsNullOrEmpty(m.Name));
var subtitles = (subtitleResponse.Data ?? new List<SublistItem>())
.Where(m => !string.IsNullOrWhiteSpace(m.Name) &&
!string.IsNullOrWhiteSpace(m.Url) &&
NormalizeFormat(m.Ext) != null)
.GroupBy(m => m.Url, StringComparer.OrdinalIgnoreCase)
.Select(m => m.First())
.OrderByDescending(m => !string.IsNullOrEmpty(cid) && string.Equals(cid, m.Cid, StringComparison.OrdinalIgnoreCase))
.ThenByDescending(m => m.Langs?.IndexOf("简体", StringComparison.OrdinalIgnoreCase) >= 0)
.Take(20)
.ToList();
var remoteSubtitles = new List<RemoteSubtitleInfo>();
@@ -135,7 +197,7 @@ namespace Emby.MeiamSub.Thunder
Id = Base64Encode(_jsonSerializer.SerializeToString(new DownloadSubInfo
{
Url = item.Url,
Format = item.Ext,
Format = NormalizeFormat(item.Ext),
Language = request.Language,
IsForced = request.IsForced
})),
@@ -143,9 +205,9 @@ namespace Emby.MeiamSub.Thunder
Language = request.Language,
Author = "Meiam ",
ProviderName = $"{Name}",
Format = item.Ext,
Comment = $"Format : {item.Ext}",
IsHashMatch = cid == item.Cid,
Format = NormalizeFormat(item.Ext),
Comment = $"Format : {NormalizeFormat(item.Ext)}",
IsHashMatch = !string.IsNullOrEmpty(cid) && string.Equals(cid, item.Cid, StringComparison.OrdinalIgnoreCase),
});
}
}
@@ -180,7 +242,7 @@ namespace Emby.MeiamSub.Thunder
{
_logger.Info("{0} DownloadSub | Request -> {1}", new object[2] { Name, id });
return await DownloadSubAsync(id);
return await DownloadSubAsync(id, cancellationToken);
}
/// <summary>
@@ -188,7 +250,7 @@ namespace Emby.MeiamSub.Thunder
/// </summary>
/// <param name="info"></param>
/// <returns></returns>
private async Task<SubtitleResponse> DownloadSubAsync(string info)
private async Task<SubtitleResponse> DownloadSubAsync(string info, CancellationToken cancellationToken)
{
// 修改人: Meiam
// 修改时间: 2025-12-22
@@ -218,13 +280,20 @@ namespace Emby.MeiamSub.Thunder
if (response.StatusCode == HttpStatusCode.OK)
{
var format = NormalizeFormat(downloadSub.Format)
?? throw new InvalidDataException($"Unsupported subtitle format: {downloadSub.Format}");
var stream = new MemoryStream();
await response.Content.CopyToAsync(stream, 81920, cancellationToken);
var data = stream.ToArray();
stream.Dispose();
SubtitleContentValidator.Validate(data, format, response.ContentType);
return new SubtitleResponse()
{
Language = downloadSub.Language,
IsForced = false,
Format = downloadSub.Format,
Stream = response.Content,
IsForced = downloadSub.IsForced ?? false,
Format = format,
Stream = new MemoryStream(data, writable: false),
};
}
}
@@ -233,7 +302,7 @@ namespace Emby.MeiamSub.Thunder
_logger.Error("{0} DownloadSub | Error -> {1}", Name, ex.Message);
}
return new SubtitleResponse();
throw new InvalidDataException($"{Name} subtitle download failed.");
}
#endregion
@@ -281,6 +350,12 @@ namespace Emby.MeiamSub.Thunder
return null;
}
private static string NormalizeFormat(string format)
{
var value = format?.Trim().TrimStart('.').ToLowerInvariant();
return value == SRT || value == ASS || value == SSA ? value : null;
}
/// <summary>
/// 规范化语言代码
/// </summary>
@@ -315,11 +390,20 @@ namespace Emby.MeiamSub.Thunder
/// </summary>
/// <param name="filePath">文件路径</param>
/// <returns>计算得到的 CID 字符串</returns>
private async Task<string> GetCidByFileAsync(string filePath)
private async Task<string> GetCidByFileAsync(string filePath, CancellationToken cancellationToken)
{
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 改造为异步方法,优化 I/O 性能,使用 SHA1.Create() 替代旧 API,并增加 using 语句释放资源
// 修改时间: 2026-06-11
// 备注: 增加网络路径拦截,避免 FileStream 打开非本地路径抛出异常
if (string.IsNullOrEmpty(filePath) ||
filePath.Contains("://") ||
filePath.StartsWith("smb://", StringComparison.OrdinalIgnoreCase) ||
filePath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
filePath.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
_logger.Warn("Network path detected, skip hash calculation: {0}", filePath);
return string.Empty;
}
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous))
{
@@ -332,16 +416,16 @@ namespace Emby.MeiamSub.Thunder
var buffer = new byte[0xf000];
if (fileSize < 0xf000)
{
await stream.ReadAsync(buffer, 0, (int)fileSize);
await ReadFullAsync(stream, buffer, 0, (int)fileSize, cancellationToken);
buffer = sha1.ComputeHash(buffer, 0, (int)fileSize);
}
else
{
await stream.ReadAsync(buffer, 0, 0x5000);
await ReadFullAsync(stream, buffer, 0, 0x5000, cancellationToken);
stream.Seek(fileSize / 3, SeekOrigin.Begin);
await stream.ReadAsync(buffer, 0x5000, 0x5000);
await ReadFullAsync(stream, buffer, 0x5000, 0x5000, cancellationToken);
stream.Seek(fileSize - 0x5000, SeekOrigin.Begin);
await stream.ReadAsync(buffer, 0xa000, 0x5000);
await ReadFullAsync(stream, buffer, 0xa000, 0x5000, cancellationToken);
buffer = sha1.ComputeHash(buffer, 0, 0xf000);
}
@@ -354,6 +438,21 @@ namespace Emby.MeiamSub.Thunder
}
}
}
private static async Task ReadFullAsync(Stream stream, byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
while (count > 0)
{
var read = await stream.ReadAsync(buffer, offset, count, cancellationToken);
if (read == 0)
{
throw new EndOfStreamException();
}
offset += read;
count -= read;
}
}
#endregion
}
}

View File

@@ -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,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Version>1.0.17.0</Version>
<AssemblyVersion>1.0.17.0</AssemblyVersion>
<FileVersion>1.0.17.0</FileVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<OutputPath>..\Release</OutputPath>
</PropertyGroup>
<ItemGroup>
<!-- 使用已验证的最低兼容基线,保持同一 DLL 可运行于 Jellyfin 10.10.7、10.11.11 和 12.1。 -->
<PackageReference Include="Jellyfin.Controller" Version="10.10.7" />
<PackageReference Include="SharpCompress" Version="0.50.1" GeneratePathProperty="true" />
</ItemGroup>
<ItemGroup>
<None Update="thumb.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Configuration\configPage.html" />
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
<Copy SourceFiles="$(PkgSharpCompress)\lib\net8.0\SharpCompress.dll" DestinationFolder="$(SolutionDir)$(ConfigurationName)\dependencies\Jellyfin\" SkipUnchangedFiles="true" />
</Target>
</Project>

View File

@@ -0,0 +1,98 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.MeiamSub.Assrt.Model
{
/// <summary>
/// 字幕搜索响应根结构
/// </summary>
public class AssrtSearchResponse
{
[JsonPropertyName("status")]
public int Status { get; set; }
[JsonPropertyName("sub")]
public AssrtSearchSubInfo Sub { get; set; }
}
public class AssrtSearchSubInfo
{
[JsonPropertyName("keyword")]
public string Keyword { get; set; }
[JsonPropertyName("result")]
public string Result { get; set; }
[JsonPropertyName("subs")]
public List<AssrtSearchItem> List { get; set; }
}
public class AssrtSearchItem
{
[JsonPropertyName("id")]
public object Id { get; set; } // 可以是数字也可以是字符串,用 object 接收再 ToString()
[JsonPropertyName("native_name")]
public string Name { get; set; }
[JsonPropertyName("videoname")]
public string VideoName { get; set; }
[JsonPropertyName("subtype")]
public string Subtype { get; set; }
[JsonPropertyName("lang")]
public AssrtLanguageInfo Lang { get; set; }
}
public class AssrtLanguageInfo
{
[JsonPropertyName("desc")]
public string Desc { get; set; }
[JsonPropertyName("lang")]
public int Lang { get; set; }
}
/// <summary>
/// 字幕详情响应根结构
/// </summary>
public class AssrtDetailResponse
{
[JsonPropertyName("status")]
public int Status { get; set; }
[JsonPropertyName("sub")]
public AssrtDetailSubInfo Sub { get; set; }
}
public class AssrtDetailSubInfo
{
[JsonPropertyName("subs")]
public List<AssrtDetailItem> Subs { get; set; }
}
public class AssrtDetailItem
{
[JsonPropertyName("id")]
public object Id { get; set; }
[JsonPropertyName("filename")]
public string Filename { get; set; }
[JsonPropertyName("url")]
public string Url { get; set; }
}
/// <summary>
/// 插件内部传递的下载参数
/// </summary>
public class AssrtDownloadInfo
{
public string Id { get; set; }
public string Format { get; set; }
public string Language { get; set; }
public string TwoLetterISOLanguageName { get; set; }
}
}

View File

@@ -0,0 +1,55 @@
using Jellyfin.MeiamSub.Assrt.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using System;
using System.Collections.Generic;
namespace Jellyfin.MeiamSub.Assrt
{
/// <summary>
/// Assrt 插件入口
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
/// <summary>
/// 插件ID
/// </summary>
public override Guid Id => new Guid("E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0");
/// <summary>
/// 插件名称
/// </summary>
public override string Name => "MeiamSub.Assrt";
/// <summary>
/// 插件描述
/// </summary>
public override string Description => "Download subtitles from Assrt (伪射手网)";
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
public static Plugin Instance { get; private set; }
/// <summary>
/// 获取插件配置页面列表
/// </summary>
/// <returns>配置页面信息</returns>
public IEnumerable<PluginPageInfo> GetPages()
{
return new[]
{
new PluginPageInfo
{
Name = Name,
EmbeddedResourcePath = GetType().Namespace + ".Configuration.configPage.html"
}
};
}
}
}

View File

@@ -0,0 +1,26 @@
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Controller;
using Microsoft.Extensions.DependencyInjection;
using System;
namespace Jellyfin.MeiamSub.Assrt
{
/// <summary>
/// 插件服务注册器
/// </summary>
public class PluginServiceRegistrator : IPluginServiceRegistrator
{
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
serviceCollection.AddHttpClient("MeiamSub.Assrt", client =>
{
client.Timeout = TimeSpan.FromSeconds(20); // 适当的超时时间
client.DefaultRequestHeaders.Add("User-Agent", "MeiamSub.Assrt/1.0.14.0 (Jellyfin)");
client.DefaultRequestHeaders.Add("Accept", "*/*");
});
serviceCollection.AddSingleton<ISubtitleProvider, AssrtProvider>();
}
}
}

View File

@@ -0,0 +1,12 @@
{
"category": "Subtitles",
"description": "Download subtitles from Assrt (伪射手网)",
"guid": "E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0",
"name": "MeiamSub.Assrt",
"owner": "Meiam",
"targetAbi": "10.10.7.0",
"timestamp": "2026-09-16T08:13:02.0000000Z",
"version": "1.0.17.0",
"status": "Active",
"imagePath": "thumb.png"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

View File

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

@@ -2,9 +2,9 @@
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Version>1.0.13.0</Version>
<AssemblyVersion>1.0.13.0</AssemblyVersion>
<TargetFramework>net8.0</TargetFramework>
<Version>1.0.17.0</Version>
<AssemblyVersion>1.0.17.0</AssemblyVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
@@ -12,11 +12,26 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.11.5" />
<!-- 使用已验证的最低兼容基线,保持同一 DLL 可运行于 Jellyfin 10.10.7、10.11.11 和 12.1。 -->
<PackageReference Include="Jellyfin.Controller" Version="10.10.7" />
</ItemGroup>
<ItemGroup>
<None Update="thumb.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<Compile Include="..\Shared\SubtitleContentValidator.cs" Link="Shared\SubtitleContentValidator.cs" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Configuration\configPage.html" />
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Exec Command="Copy $(TargetDir)$(TargetFileName) $(SolutionDir)$(ConfigurationName)\$(TargetFileName) /y&#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

@@ -1,4 +1,5 @@
using Jellyfin.MeiamSub.Thunder.Model;
using MeiamSubtitles.Shared;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Model.Providers;
@@ -68,9 +69,8 @@ namespace Jellyfin.MeiamSub.Thunder
/// <returns>远程字幕信息列表</returns>
public async Task<IEnumerable<RemoteSubtitleInfo>> Search(SubtitleSearchRequest request, CancellationToken cancellationToken)
{
_logger.LogInformation("DEBUG: Received Search request for " + (request?.MediaPath ?? "NULL"));
var subtitles = await SearchSubtitlesAsync(request);
var subtitles = await SearchSubtitlesAsync(request, cancellationToken);
return subtitles;
}
@@ -80,31 +80,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")
{
@@ -118,23 +136,39 @@ namespace Jellyfin.MeiamSub.Thunder
return Array.Empty<RemoteSubtitleInfo>();
}
var cid = string.Empty;
var stopWatch = Stopwatch.StartNew();
var cid = await GetCidByFileAsync(request.MediaPath);
stopWatch.Stop();
_logger.LogInformation(Name + " Search | FileHash -> " + cid + " (Took " + stopWatch.ElapsedMilliseconds + "ms)");
try
{
cid = await GetCidByFileAsync(request.MediaPath, cancellationToken);
_logger.LogInformation("{Provider} Search | FileHash -> {FileHash} (Took {ElapsedMilliseconds}ms)",
Name, cid, stopWatch.ElapsedMilliseconds);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
{
_logger.LogWarning("{Provider} Search | File hash unavailable ({ExceptionType}: {Message}), continue with name search.",
Name, ex.GetType().Name, ex.Message);
}
finally
{
stopWatch.Stop();
}
using var options = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri($"https://api-shoulei-ssl.xunlei.com/oracle/subtitle?name={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 +176,16 @@ namespace Jellyfin.MeiamSub.Thunder
if (subtitleResponse != null)
{
_logger.LogInformation($"{Name} Search | Response -> {JsonSerializer.Serialize(subtitleResponse)}");
var subtitles = subtitleResponse.Data.Where(m => !string.IsNullOrEmpty(m.Name));
var subtitles = (subtitleResponse.Data ?? new List<SublistItem>())
.Where(m => !string.IsNullOrWhiteSpace(m.Name) &&
!string.IsNullOrWhiteSpace(m.Url) &&
NormalizeFormat(m.Ext) != null)
.GroupBy(m => m.Url, StringComparer.OrdinalIgnoreCase)
.Select(m => m.First())
.OrderByDescending(m => !string.IsNullOrEmpty(cid) && string.Equals(cid, m.Cid, StringComparison.OrdinalIgnoreCase))
.ThenByDescending(m => m.Langs?.Contains("简体", StringComparison.OrdinalIgnoreCase) == true)
.Take(20)
.ToList();
var remoteSubtitles = new List<RemoteSubtitleInfo>();
@@ -157,16 +198,16 @@ namespace Jellyfin.MeiamSub.Thunder
Id = Base64Encode(JsonSerializer.Serialize(new DownloadSubInfo
{
Url = item.Url,
Format = item.Ext,
Format = NormalizeFormat(item.Ext),
Language = request.Language,
TwoLetterISOLanguageName = request.TwoLetterISOLanguageName,
})),
Name = $"[MEIAMSUB] {item.Name} | {(item.Langs == string.Empty ? "未知" : item.Langs)} | 迅雷",
Author = "Meiam ",
ProviderName = $"{Name}",
Format = item.Ext,
Comment = $"Format : {item.Ext}",
IsHashMatch = cid == item.Cid,
Format = NormalizeFormat(item.Ext),
Comment = $"Format : {NormalizeFormat(item.Ext)}",
IsHashMatch = !string.IsNullOrEmpty(cid) && string.Equals(cid, item.Cid, StringComparison.OrdinalIgnoreCase),
});
}
}
@@ -200,7 +241,7 @@ namespace Jellyfin.MeiamSub.Thunder
{
_logger.LogInformation($"{Name} DownloadSub | Request -> {id}");
return await DownloadSubAsync(id);
return await DownloadSubAsync(id, cancellationToken);
}
/// <summary>
@@ -208,7 +249,7 @@ namespace Jellyfin.MeiamSub.Thunder
/// </summary>
/// <param name="info"></param>
/// <returns></returns>
private async Task<SubtitleResponse> DownloadSubAsync(string info)
private async Task<SubtitleResponse> DownloadSubAsync(string info, CancellationToken cancellationToken)
{
// 修改人: Meiam
// 修改时间: 2025-12-22
@@ -233,19 +274,23 @@ namespace Jellyfin.MeiamSub.Thunder
using var httpClient = _httpClientFactory.CreateClient(Name);
var response = await httpClient.SendAsync(options);
using var response = await httpClient.SendAsync(options, cancellationToken);
_logger.LogInformation($"{Name} DownloadSub | Response -> {response.StatusCode}");
if (response.StatusCode == HttpStatusCode.OK)
{
var stream = await response.Content.ReadAsStreamAsync();
var format = NormalizeFormat(downloadSub.Format)
?? throw new InvalidDataException($"Unsupported subtitle format: {downloadSub.Format}");
var data = await response.Content.ReadAsByteArrayAsync(cancellationToken);
SubtitleContentValidator.Validate(data, format, response.Content.Headers.ContentType?.MediaType);
var stream = new MemoryStream(data, writable: false);
return new SubtitleResponse()
{
Language = downloadSub.Language,
IsForced = false,
Format = downloadSub.Format,
Format = format,
Stream = stream,
};
}
@@ -255,7 +300,7 @@ namespace Jellyfin.MeiamSub.Thunder
_logger.LogError(ex, "{Provider} DownloadSub | Exception -> [{Type}] {Message}", Name, ex.GetType().Name, ex.Message);
}
return new SubtitleResponse();
throw new InvalidDataException($"{Name} subtitle download failed.");
}
#endregion
@@ -303,6 +348,12 @@ namespace Jellyfin.MeiamSub.Thunder
return null;
}
private static string NormalizeFormat(string format)
{
var value = format?.Trim().TrimStart('.').ToLowerInvariant();
return value == SRT || value == ASS || value == SSA ? value : null;
}
/// <summary>
/// 规范化语言代码
/// </summary>
@@ -337,11 +388,20 @@ namespace Jellyfin.MeiamSub.Thunder
/// </summary>
/// <param name="filePath">文件路径</param>
/// <returns>计算得到的 CID 字符串</returns>
private async Task<string> GetCidByFileAsync(string filePath)
private async Task<string> GetCidByFileAsync(string filePath, CancellationToken cancellationToken)
{
// 修改人: Meiam
// 修改时间: 2025-12-22
// 备注: 改造为异步方法,优化 I/O 性能,使用 SHA1.Create() 替代旧 API,并增加 using 语句释放资源
// 修改时间: 2026-06-11
// 备注: 增加网络路径拦截,避免 FileStream 打开非本地路径抛出异常
if (string.IsNullOrEmpty(filePath) ||
filePath.Contains("://") ||
filePath.StartsWith("smb://", StringComparison.OrdinalIgnoreCase) ||
filePath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
filePath.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("Network path detected, skip hash calculation: {Path}", filePath);
return string.Empty;
}
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous))
{
@@ -351,16 +411,16 @@ namespace Jellyfin.MeiamSub.Thunder
var buffer = new byte[0xf000];
if (fileSize < 0xf000)
{
await stream.ReadExactlyAsync(buffer, 0, (int)fileSize);
await stream.ReadExactlyAsync(buffer, 0, (int)fileSize, cancellationToken);
buffer = sha1.ComputeHash(buffer, 0, (int)fileSize);
}
else
{
await stream.ReadExactlyAsync(buffer, 0, 0x5000);
await stream.ReadExactlyAsync(buffer, 0, 0x5000, cancellationToken);
stream.Seek(fileSize / 3, SeekOrigin.Begin);
await stream.ReadExactlyAsync(buffer, 0x5000, 0x5000);
await stream.ReadExactlyAsync(buffer, 0x5000, 0x5000, cancellationToken);
stream.Seek(fileSize - 0x5000, SeekOrigin.Begin);
await stream.ReadExactlyAsync(buffer, 0xa000, 0x5000);
await stream.ReadExactlyAsync(buffer, 0xa000, 0x5000, cancellationToken);
buffer = sha1.ComputeHash(buffer, 0, 0xf000);
}
@@ -375,4 +435,4 @@ namespace Jellyfin.MeiamSub.Thunder
}
#endregion
}
}
}

View File

@@ -4,9 +4,9 @@
"guid": "E4CE9DA9-EF00-417C-96F2-861C512D45EB",
"name": "MeiamSub.Thunder",
"owner": "Meiam",
"targetAbi": "10.11.0.0",
"timestamp": "2025-12-22T10:00:00.0000000Z",
"version": "1.0.13.0",
"targetAbi": "10.10.7.0",
"timestamp": "2026-09-16T08:13:02.0000000Z",
"version": "1.0.17.0",
"status": "Active",
"imagePath": "thumb.png"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

201
LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -1,4 +1,4 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.10.34916.146
@@ -7,12 +7,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Emby.MeiamSub.DevTool", "Em
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.MeiamSub.Thunder", "Jellyfin.MeiamSub.Thunder\Jellyfin.MeiamSub.Thunder.csproj", "{4676AA1B-CC6C-42DC-BD69-6A293BAE8823}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.MeiamSub.Shooter", "Jellyfin.MeiamSub.Shooter\Jellyfin.MeiamSub.Shooter.csproj", "{8F77E155-9A91-4882-82E8-E8D69FECD246}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Emby.MeiamSub.Shooter", "Emby.MeiamSub.Shooter\Emby.MeiamSub.Shooter.csproj", "{F2636BCB-111D-4F22-AA06-8852E96D05C4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Emby.MeiamSub.Thunder", "Emby.MeiamSub.Thunder\Emby.MeiamSub.Thunder.csproj", "{96F4C65C-11B1-46F4-B343-115168688C2D}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.MeiamSub.Assrt", "Jellyfin.MeiamSub.Assrt\Jellyfin.MeiamSub.Assrt.csproj", "{E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Emby.MeiamSub.Assrt", "Emby.MeiamSub.Assrt\Emby.MeiamSub.Assrt.csproj", "{D276A8D0-4B17-4EEF-94B6-61D646DE5A22}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MeiamSubtitles.Tests", "tests\MeiamSubtitles.Tests\MeiamSubtitles.Tests.csproj", "{B8A8BFC1-8A0D-48C5-89AC-4DDDC2DDC3C9}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -27,18 +29,22 @@ Global
{4676AA1B-CC6C-42DC-BD69-6A293BAE8823}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4676AA1B-CC6C-42DC-BD69-6A293BAE8823}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4676AA1B-CC6C-42DC-BD69-6A293BAE8823}.Release|Any CPU.Build.0 = Release|Any CPU
{8F77E155-9A91-4882-82E8-E8D69FECD246}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8F77E155-9A91-4882-82E8-E8D69FECD246}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8F77E155-9A91-4882-82E8-E8D69FECD246}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8F77E155-9A91-4882-82E8-E8D69FECD246}.Release|Any CPU.Build.0 = Release|Any CPU
{F2636BCB-111D-4F22-AA06-8852E96D05C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F2636BCB-111D-4F22-AA06-8852E96D05C4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F2636BCB-111D-4F22-AA06-8852E96D05C4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F2636BCB-111D-4F22-AA06-8852E96D05C4}.Release|Any CPU.Build.0 = Release|Any CPU
{96F4C65C-11B1-46F4-B343-115168688C2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{96F4C65C-11B1-46F4-B343-115168688C2D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{96F4C65C-11B1-46F4-B343-115168688C2D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{96F4C65C-11B1-46F4-B343-115168688C2D}.Release|Any CPU.Build.0 = Release|Any CPU
{E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0}.Release|Any CPU.Build.0 = Release|Any CPU
{D276A8D0-4B17-4EEF-94B6-61D646DE5A22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D276A8D0-4B17-4EEF-94B6-61D646DE5A22}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D276A8D0-4B17-4EEF-94B6-61D646DE5A22}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D276A8D0-4B17-4EEF-94B6-61D646DE5A22}.Release|Any CPU.Build.0 = Release|Any CPU
{B8A8BFC1-8A0D-48C5-89AC-4DDDC2DDC3C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B8A8BFC1-8A0D-48C5-89AC-4DDDC2DDC3C9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B8A8BFC1-8A0D-48C5-89AC-4DDDC2DDC3C9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B8A8BFC1-8A0D-48C5-89AC-4DDDC2DDC3C9}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

230
README.md
View File

@@ -1,133 +1,165 @@
# MeiamSubtitles
Emby & Jellyfin 中文字幕插件,支持 **迅雷影音**、**射手网** 字幕自动下载与精准 Hash 匹配。
[![.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** 媒体服务器打造的中文字幕下载插件。它集成了 **[Assrt(伪射手网)](https://assrt.net/)** 与 **迅雷影音** 的字幕搜索能力,支持影片名称与视频特征(CID)匹配,帮助您的媒体库便捷地搜索和补全中文字幕。
&nbsp;
<p align="left">
<a href="https://github.com/91270/MeiamSubtitles/releases/latest"><img src="https://img.shields.io/github/v/release/91270/MeiamSubtitles" alt="Release"></a>
<img src="https://img.shields.io/badge/.NET-Standard%202.1%20%7C%209.0-blueviolet.svg" alt=".NET Status">
<a href="#插件与兼容性"><img src="https://img.shields.io/badge/Platform-Linux%20%7C%20Windows%20%7C%20macOS-brightgreen.svg" alt="Platform"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a>
<a href="https://www.592.la/"><img src="https://img.shields.io/badge/博客-Meiam's%20Home-brightgreen.svg" alt="博客"></a>
</p>
## 给个星星! ⭐️
当前维护两个字幕源:
如果你喜欢这个项目或者它帮助你, 请给 Star~(辛苦咯)
- **[Assrt(伪射手网)](https://assrt.net/)**:按影片名称搜索,支持下载并解析 ZIP、RAR 等压缩字幕;需要配置 Assrt API Token。
- **Thunder**:根据视频文件计算 CID,优先匹配对应文件的字幕;无需额外配置。
如果你能赞助稳定 Google Drive 团队盘用于媒体库插件测试, 请于我联系 91270#QQ.COM
> Shooter 的旧版接口已很少返回有效字幕,自 v1.0.16.0 起停止编译、维护和发布。
&nbsp;
## 📣 广告时间
## 广告时间 📣
> **搬瓦工 $99 年付**:建站神器重出江湖,THE PLAN V1 传家宝套餐,18 机房随意切换。
>
> **配置**:2 核 / 2G / 40G SSD / 1TB 流量 / 2.5Gbps,支持 CN2 GIA 等大陆优化线路。
>
> **当前推荐循环优惠码**:`BWHNCXNVXV`(最高约 6.81%,实际优惠以结账页验证结果为准)
>
> **[直达通道(传家宝套餐)](https://bwh88.net/aff.php?aff=117&pid=87)**
搬瓦工 $99 年付, 建站神器重出江湖,THE PLAN V1 传家宝套餐,18机房随意切换
## 功能
循环优惠码:BWHCCNCXVV(6.77%)
- 同时支持 Emby 和 Jellyfin。
- 支持 `zho`、`chi`、`zh-CN` 等中文语言代码。
- 使用异步文件读取计算视频特征,适用于本地及网络媒体库。
- 对搜索结果进行格式校验、去重和排序。
- 下载前校验响应内容,避免将空响应或错误页面保存为字幕。
- Assrt 支持从压缩包中选择并提取 `.srt`、`.ass`、`.ssa` 字幕。
- 日志隐藏 Assrt Token,且不记录完整接口响应。
[直达通道(传家宝套餐)](https://bwh88.net/aff.php?aff=117&pid=87)
## 插件与兼容性
&nbsp;
| 插件 | 平台 | 目标框架 | 字幕源 |
| --- | --- | --- | --- |
| `Emby.MeiamSub.Assrt` | Emby(版本限制见下文) | .NET Standard 2.1 | [Assrt.net](https://assrt.net/) |
| `Emby.MeiamSub.Thunder` | Emby(版本限制见下文) | .NET Standard 2.1 | Thunder XMP |
| `Jellyfin.MeiamSub.Assrt` | Jellyfin(版本限制见下文) | .NET 8.0 | [Assrt.net](https://assrt.net/) |
| `Jellyfin.MeiamSub.Thunder` | Jellyfin(版本限制见下文) | .NET 8.0 | Thunder XMP |
## 功能介绍
| 平台 | 兼容版本 |
| --- | --- |
| Emby | 4.8.10.0 至 4.10.0.40 |
| Jellyfin | 10.10.7 至 12.1 |
- [x] **迅雷影音**: 支持通过文件 Hash (CID) 精准匹配字幕。
- [x] **射手网**: 支持通过文件 Hash 精准匹配字幕。
- [x] **高性能**: 核心哈希计算采用异步 I/O (Async/Await) 模式,避免阻塞服务器线程。
- [x] **稳定性**: 内置重试机制与异常处理,Jellyfin 版本采用现代化的依赖注入架构。
## 安装
## 项目说明
### Jellyfin 存储库
| # | 模块功能 | 项目名称 | 说明 |
|---|---|---|---|
| 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` | 哈希算法测试与调试工具 |
推荐通过插件存储库安装,以便接收后续更新。
## 使用插件
1. 打开 Jellyfin 控制台的“插件 → 存储库”。
2. 添加存储库名称 `MeiamSub`。
3. 填写以下地址:
首先下载已编译好的插件 [Release 下载](https://github.com/91270/Emby.MeiamSub/releases)。
**注意**:建议在媒体库设置中**不勾选**本插件作为默认下载器,仅在手动“编辑字幕”或“搜索字幕”时使用,以获得最佳体验。
## 常见问题排查 (FAQ)
如果您在搜索字幕时遇到“未找到结果”,请按以下步骤排查:
### 1. 语言代码识别
- **现象**: 在 Jellyfin 10.11+ 中搜索不到字幕。
- **原因**: 新版 Jellyfin 可能会传递三位字母代码 (如 `zho`)。
- **解决**: 本插件最新版已修复该问题,支持 `zho`、`chi`、`zh-CN` 等多种映射。请确保您使用的是最新版本的 DLL。
### 2. 射手网 API 状态
- **现象**: 部分电影能搜到,部分完全搜不到。
- **原因**: 射手网 API 年久失修,对于无资源或 Hash 不匹配的文件,API 可能返回异常内容(如乱码)。
- **表现**: 插件已增加防御性校验,若检测到 API 返回异常,会自动过滤并返回空结果以保证系统稳定。
### 3. 如何反馈问题
如果遇到确定有字幕但搜不到的情况,请查看服务器日志并提供以下关键信息:
- 搜索开始时的 `Target` 文件名。
- 接口返回的 `ResponseBody` 内容。
- 计算得到的 `FileHash` 值。
## Jellyfin 安装 (推荐)
Jellyfin 用户可以通过添加插件存储库实现一键安装和自动更新:
1. 打开 Jellyfin 控制台 -> **插件** -> **存储库**。
2. 点击添加,输入名称 (如 MeiamSub) 和以下 URL:
```
```text
https://github.com/91270/MeiamSubtitles.Release/raw/main/Plugin/manifest-stable.json
```
3. 保存后在插件目录中找到 **MeiamSub.Thunder** 和 **MeiamSub.Shooter** 进行安装。
4. 重启 Jellyfin 服务。
### 手动安装 (Emby / 通用)
4. 在“目录”中分别安装 `MeiamSub.Assrt` 或 `MeiamSub.Thunder`。
5. 重启 Jellyfin。
将下载的 `.dll` 文件复制到服务器的插件目录,然后重启服务。
### 手动安装
#### Windows
```bash
# 路径可能因安装方式不同而异
Emby-Server\Programdata\Plugins\
# 或
Emby-Server\System\Plugins\
```
从 [Releases](https://github.com/91270/MeiamSubtitles/releases/latest) 下载与平台对应的压缩包。
#### Linux / Docker
```bash
# 常见路径
/opt/emby-server/system/plugins
# 或
/var/lib/emby/plugins
```
#### Emby
#### 群晖 (Synology)
1. 解压 `Emby_v*.zip`。
2. 将需要的插件 DLL 放入 Emby 的 `plugins` 目录。
3. 不要额外复制 `SharpCompress.dll`;Assrt 复用 Emby 自带版本。
4. 重启 Emby。
```bash
/var/packages/EmbyServer/var/plugins
# 或
/var/packages/EmbyServer/target/system/plugins
```
#### Jellyfin
#### 威联通 (QNAP)
1. 解压 `Jellyfin_v*.zip`。
2. 将需要的整个插件目录复制到 Jellyfin 的 `plugins` 目录。
3. Assrt 目录中的 `SharpCompress.dll` 是 Jellyfin 所需依赖,请勿单独删除。
4. 重启 Jellyfin。
```bash
# 其中`CACHEDEV{num}_DATA`的名称取决于你的qpkg安装位置
/share/CACHEDEV1_DATA/.qpkg/EmbyServer/programdata/plugins
/share/CACHEDEV1_DATA/.qpkg/EmbyServer/system/plugins
```
### 从旧版本升级
&nbsp;
- 升级到 v1.0.16.0 或更高版本后,删除旧的 Shooter DLL 和插件目录;Shooter 已停止维护和发布。
- 同一个插件不要同时保留多个版本,否则服务器可能重复加载或启动失败。
- Emby Assrt 只需保留插件 DLL,不要额外复制 `SharpCompress.dll`。
- Jellyfin Assrt 必须保留发布包中同目录的 `SharpCompress.dll`。
## 贡献
常见插件目录:
欢迎提交 Issue 反馈问题,或提交 Pull Request 贡献代码。
| 环境 | 目录示例 |
| --- | --- |
| Jellyfin Windows | `%LOCALAPPDATA%\jellyfin\plugins` |
| Jellyfin Linux | `/var/lib/jellyfin/plugins` |
| Emby Windows | `%APPDATA%\Emby-Server\programdata\plugins` |
| Docker | 容器映射的 `/config/plugins` |
* **开发分支**: `master`
* **代码风格**: 请遵循现有的 C# 代码风格,异步方法请使用 `Async` 后缀。
## 配置
## 致谢
### Assrt
[Emby.Subtitle.Subscene](https://github.com/nRafinia/Emby.Subtitle.Subscene)
1. 从 [Assrt](https://assrt.net/) 获取 API Token。
2. 打开插件配置页,将 Token 填入 `Assrt API Token`。
3. 在媒体库的字幕下载器中启用 `MeiamSub.Assrt`。
未配置 Token 时,Assrt 会跳过搜索并在日志中给出提示。
### Thunder
Thunder 默认使用视频文件计算 CID。Jellyfin 版本还可在插件设置中启用元数据搜索,用影片名称辅助匹配。
## 自动下载说明
建议先通过“搜索字幕”手动确认匹配效果,再决定是否启用媒体库自动下载。
- 同一媒体库的字幕下载语言只应添加一次。例如只保留一个 `zho`,重复添加会让服务器发起多次下载请求。
- Emby 与 Jellyfin 如果共用媒体目录,会采用不同的字幕文件命名方式,因此目录中可能同时出现 `.zh-CN.*` 和 `.zho.*` 文件。
- `SearchAllProviders` 表示同时搜索多个字幕源,不代表下载全部搜索结果。
- 自动下载行为由 Emby/Jellyfin 调度;插件只响应服务器发出的搜索与下载请求。
## 常见问题
### 搜不到字幕
- 确认字幕语言已选择中文,并使用最新版插件。
- Assrt 用户请确认 Token 已填写且有效。
- Thunder 依赖视频文件内容计算 CID,请确认服务器能够读取媒体文件。
- 某些影片或版本可能没有对应资源,可尝试启用元数据搜索或手动选择其他结果。
### 自动生成了多个字幕文件
首先检查媒体库的“字幕下载语言”是否存在重复项。服务器会为每个语言项分别执行一次下载,产生 `.0`、`.1` 等后缀。
手动连续下载不同的搜索结果时,服务器也会为避免覆盖已有字幕而添加数字后缀。这属于 Emby/Jellyfin 的文件保存行为,不表示插件正在循环下载。
### Assrt 提示找不到 SharpCompress
- Emby:仅安装插件 DLL,不要携带其他版本的 `SharpCompress.dll`。
- Jellyfin:确保 Assrt 插件目录中包含发布包附带的 `SharpCompress.dll`。
### 如何反馈问题
请在 [Issues](https://github.com/91270/MeiamSubtitles/issues) 中提供:
- Emby/Jellyfin 版本与操作系统;
- 插件名称和版本;
- 媒体文件名、容器格式及问题复现步骤;
- 对应时间段的服务器日志。
提交日志前请删除 Token、媒体路径和其他隐私信息。
## 支持项目
欢迎提交 Issue、Pull Request,或者为项目点一个 Star。
## License
[Apache License 2.0](LICENSE)

View File

@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace MeiamSubtitles.Shared
{
internal static class SubtitleContentValidator
{
public static void Validate(byte[] data, string format, string mediaType)
{
if (data == null || data.Length < 8)
{
throw new InvalidDataException("Subtitle response is empty.");
}
if ((data[0] == (byte)'P' && data[1] == (byte)'K') ||
(data.Length >= 7 && Encoding.ASCII.GetString(data, 0, 7) == "Rar!\u001a\u0007"))
{
throw new InvalidDataException("Compressed subtitle responses are not supported by Thunder.");
}
if (mediaType?.IndexOf("html", StringComparison.OrdinalIgnoreCase) >= 0 ||
mediaType?.IndexOf("json", StringComparison.OrdinalIgnoreCase) >= 0)
{
throw new InvalidDataException("Thunder returned an error document instead of a subtitle.");
}
var errorDocument = false;
foreach (var sample in DecodeSamples(data))
{
if (sample.StartsWith("<", StringComparison.Ordinal) ||
sample.StartsWith("{", StringComparison.Ordinal))
{
errorDocument = true;
continue;
}
if (IsValidSubtitle(sample, format))
{
return;
}
if (sample.StartsWith("[", StringComparison.Ordinal))
{
errorDocument = true;
}
}
if (errorDocument)
{
throw new InvalidDataException("Thunder returned an error document instead of a subtitle.");
}
throw new InvalidDataException($"Downloaded content is not a valid {format} subtitle.");
}
private static bool IsValidSubtitle(string sample, string format)
{
return format == "srt"
? sample.IndexOf("-->", StringComparison.Ordinal) >= 0
: sample.IndexOf("[Script Info]", StringComparison.OrdinalIgnoreCase) >= 0 ||
sample.IndexOf("Dialogue:", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static IEnumerable<string> DecodeSamples(byte[] data)
{
var length = Math.Min(data.Length, 4096);
if (data.Length >= 2)
{
if (data[0] == 0xFF && data[1] == 0xFE)
{
yield return DecodeSample(data, length, Encoding.Unicode);
yield break;
}
if (data[0] == 0xFE && data[1] == 0xFF)
{
yield return DecodeSample(data, length, Encoding.BigEndianUnicode);
yield break;
}
}
if (data.Length >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF)
{
yield return DecodeSample(data, length, Encoding.UTF8);
yield break;
}
// Thunder may return UTF-16 subtitles without a BOM. Try the common
// single-byte/UTF-8 representation and both UTF-16 byte orders, then
// accept only a candidate that contains the expected subtitle syntax.
yield return DecodeSample(data, length, new UTF8Encoding(false, false));
yield return DecodeSample(data, length, Encoding.Unicode);
yield return DecodeSample(data, length, Encoding.BigEndianUnicode);
}
private static string DecodeSample(byte[] data, int length, Encoding encoding)
{
return encoding.GetString(data, 0, length)
.TrimStart('\uFEFF', ' ', '\r', '\n', '\t');
}
}
}

163
publish.ps1 Normal file
View File

@@ -0,0 +1,163 @@
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"
$SupportedEmbySdkVersion = "4.8.10"
$SupportedJellyfinSdkVersion = "10.10.7"
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 -and $JellyfinVersion -ne $SupportedJellyfinSdkVersion) {
throw "Jellyfin SDK must remain at $SupportedJellyfinSdkVersion for the verified 10.10.7-12.1 compatibility range. Update the compatibility matrix before changing it."
}
if ($JellyfinVersion) {
Write-Host "Upgrading Jellyfin.Controller NuGet dependency to $JellyfinVersion..." -ForegroundColor Yellow
(Get-Content $ThunderCsproj) -replace '<PackageReference Include="Jellyfin.Controller" Version="[^"]+" />', "<PackageReference Include=`"Jellyfin.Controller`" Version=`"$JellyfinVersion`" />" | Set-Content $ThunderCsproj
(Get-Content $AssrtCsproj) -replace '<PackageReference Include="Jellyfin.Controller" Version="[^"]+" />', "<PackageReference Include=`"Jellyfin.Controller`" Version=`"$JellyfinVersion`" />" | Set-Content $AssrtCsproj
}
if ($EmbyVersion -and $EmbyVersion -ne $SupportedEmbySdkVersion) {
throw "Emby SDK must remain at $SupportedEmbySdkVersion for the verified 4.8.10.0-4.10.0.40 compatibility range. Update the compatibility matrix before changing it."
}
if ($EmbyVersion) {
Write-Host "Upgrading MediaBrowser NuGet dependencies to $EmbyVersion..." -ForegroundColor Yellow
(Get-Content $EmbyThunderCsproj) -replace '<PackageReference Include="MediaBrowser.Common" Version="[^"]+" />', "<PackageReference Include=`"MediaBrowser.Common`" Version=`"$EmbyVersion`" />" `
-replace '<PackageReference Include="MediaBrowser.Server.Core" Version="[^"]+" />', "<PackageReference Include=`"MediaBrowser.Server.Core`" Version=`"$EmbyVersion`" />" | Set-Content $EmbyThunderCsproj
(Get-Content $EmbyAssrtCsproj) -replace '<PackageReference Include="MediaBrowser.Common" Version="[^"]+" />', "<PackageReference Include=`"MediaBrowser.Common`" Version=`"$EmbyVersion`" />" `
-replace '<PackageReference Include="MediaBrowser.Server.Core" Version="[^"]+" />', "<PackageReference Include=`"MediaBrowser.Server.Core`" Version=`"$EmbyVersion`" />" | Set-Content $EmbyAssrtCsproj
}
Write-Host "Updating version and timestamp in project files..." -ForegroundColor Yellow
# Update Jellyfin Thunder
(Get-Content $ThunderCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" | Set-Content $ThunderCsproj
(Get-Content $ThunderMeta) -replace '"version":\s*"[\d\.]+"', "`"version`": `"$Version`"" `
-replace '"timestamp":\s*"[^"]+"', "`"timestamp`": `"$Timestamp`"" | Set-Content $ThunderMeta
# Update Jellyfin Assrt
(Get-Content $AssrtCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" `
-replace '<FileVersion>[\d\.]+</FileVersion>', "<FileVersion>$Version</FileVersion>" | Set-Content $AssrtCsproj
(Get-Content $AssrtMeta) -replace '"version":\s*"[\d\.]+"', "`"version`": `"$Version`"" `
-replace '"timestamp":\s*"[^"]+"', "`"timestamp`": `"$Timestamp`"" | Set-Content $AssrtMeta
# Update Emby Thunder
(Get-Content $EmbyThunderCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" `
-replace '<FileVersion>[\d\.]+</FileVersion>', "<FileVersion>$Version</FileVersion>" | Set-Content $EmbyThunderCsproj
# Update Emby Assrt
(Get-Content $EmbyAssrtCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" `
-replace '<FileVersion>[\d\.]+</FileVersion>', "<FileVersion>$Version</FileVersion>" | Set-Content $EmbyAssrtCsproj
Write-Host "Versions and timestamps updated successfully!" -ForegroundColor Green
# 2. Compile solution
Write-Host "Compiling solution in Release mode..." -ForegroundColor Yellow
dotnet build MeiamSubtitles.sln -c Release
# 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
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\..\Shared\SubtitleContentValidator.cs" Link="Shared\SubtitleContentValidator.cs" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,84 @@
using System.IO;
using System.Text;
using MeiamSubtitles.Shared;
using Xunit;
namespace MeiamSubtitles.Tests;
public class SubtitleContentValidatorTests
{
[Fact]
public void AcceptsAssWithScriptInfoHeader()
{
var data = Encoding.UTF8.GetBytes("[Script Info]\r\nScriptType: v4.00+\r\n[Events]\r\nDialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,Hello");
SubtitleContentValidator.Validate(data, "ass", "application/octet-stream");
}
[Fact]
public void AcceptsUtf16LittleEndianSrtWithoutBom()
{
var data = Encoding.Unicode.GetBytes("1\r\n00:00:01,000 --> 00:00:02,000\r\nHello\r\n");
SubtitleContentValidator.Validate(data, "srt", "application/octet-stream");
}
[Fact]
public void AcceptsUtf16LittleEndianSrtWithoutBomWithLongChineseCue()
{
var content = "1\r\n00:00:01,000 --> 00:00:20,000\r\n" + new string('中', 300) + "\r\n";
var data = Encoding.Unicode.GetBytes(content);
SubtitleContentValidator.Validate(data, "srt", "application/octet-stream");
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public void AcceptsUtf16SrtInEitherByteOrder(bool bigEndian, bool withBom)
{
var encoding = new UnicodeEncoding(bigEndian, withBom);
var content = "1\r\n00:00:01,000 --> 00:00:02,000\r\n你好\r\n";
var body = encoding.GetBytes(content);
var data = withBom ? encoding.GetPreamble().Concat(body).ToArray() : body;
SubtitleContentValidator.Validate(data, "srt", "application/octet-stream");
}
[Theory]
[InlineData("<html><body>temporary error</body></html>", "application/octet-stream")]
[InlineData("{\"error\":\"temporary error\"}", "application/json")]
[InlineData("[\"temporary error\"]", "application/octet-stream")]
public void RejectsErrorDocuments(string content, string mediaType)
{
var exception = Assert.Throws<InvalidDataException>(() =>
SubtitleContentValidator.Validate(Encoding.UTF8.GetBytes(content), "srt", mediaType));
Assert.Contains("error document", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void RejectsCompressedSubtitleResponse()
{
var data = new byte[] { (byte)'P', (byte)'K', 3, 4, 5, 6, 7, 8 };
var exception = Assert.Throws<InvalidDataException>(() =>
SubtitleContentValidator.Validate(data, "srt", "application/octet-stream"));
Assert.Contains("Compressed", exception.Message, StringComparison.Ordinal);
}
[Theory]
[InlineData("<html><!-- service unavailable --><body>Error</body></html>", "srt")]
[InlineData("{\"error\":\"download --> failed\"}", "srt")]
[InlineData("<html><body>Dialogue: download failed</body></html>", "ass")]
public void RejectsErrorDocumentsThatContainSubtitleMarkers(string content, string format)
{
var exception = Assert.Throws<InvalidDataException>(() =>
SubtitleContentValidator.Validate(Encoding.UTF8.GetBytes(content), format, "application/octet-stream"));
Assert.Contains("error document", exception.Message, StringComparison.OrdinalIgnoreCase);
}
}