mirror of
https://github.com/91270/MeiamSubtitles.git
synced 2026-11-06 15:27:47 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c309f44ee | ||
|
|
d9407006df | ||
|
|
786cb82868 | ||
|
|
38a46d2f53 | ||
|
|
220fe5459d | ||
|
|
66ed8ff49c | ||
|
|
c4adc97bbf | ||
|
|
ba40c107cf |
427
Emby.MeiamSub.Assrt/AssrtProvider.cs
Normal file
427
Emby.MeiamSub.Assrt/AssrtProvider.cs
Normal file
@@ -0,0 +1,427 @@
|
||||
using Emby.MeiamSub.Assrt.Model;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Controller.Subtitles;
|
||||
using MediaBrowser.Model.Logging;
|
||||
using MediaBrowser.Model.Providers;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using SharpCompress.Archives;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Emby.MeiamSub.Assrt
|
||||
{
|
||||
/// <summary>
|
||||
/// Assrt (伪射手) 字幕提供程序 (Emby 适配版)
|
||||
/// </summary>
|
||||
public class AssrtProvider : ISubtitleProvider, IHasOrder
|
||||
{
|
||||
private const string ApiBaseUrl = "https://api.assrt.net/v1";
|
||||
|
||||
public const string ASS = "ass";
|
||||
public const string SSA = "ssa";
|
||||
public const string SRT = "srt";
|
||||
|
||||
protected readonly ILogger _logger;
|
||||
private readonly IJsonSerializer _jsonSerializer;
|
||||
private readonly IHttpClient _httpClient;
|
||||
|
||||
public int Order => 90; // 优先级高于老旧源
|
||||
|
||||
public string Name => "MeiamSub.Assrt";
|
||||
|
||||
public IEnumerable<VideoContentType> SupportedMediaTypes => new[] { VideoContentType.Movie, VideoContentType.Episode };
|
||||
|
||||
public AssrtProvider(ILogManager logManager, IJsonSerializer jsonSerializer, IHttpClient httpClient)
|
||||
{
|
||||
_logger = logManager.GetLogger(GetType().Name);
|
||||
_jsonSerializer = jsonSerializer;
|
||||
_httpClient = httpClient;
|
||||
_logger.Info($"{Name} Init");
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<RemoteSubtitleInfo>> Search(SubtitleSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.Info($"{Name} Search | Received request for: {(request?.MediaPath ?? "NULL")}");
|
||||
|
||||
try
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
var config = Plugin.Instance?.Options;
|
||||
if (config == null || string.IsNullOrWhiteSpace(config.AssrtApiToken))
|
||||
{
|
||||
_logger.Warn($"{Name} Search | AssrtApiToken is empty, skip search. Please configure the token in plugin settings.");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
var language = NormalizeLanguage(request.Language);
|
||||
if (language != "chi")
|
||||
{
|
||||
_logger.Info($"{Name} Search | Language '{request.Language}' not supported, skip search.");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
string queryText;
|
||||
if (config.EnableUseMetadata)
|
||||
{
|
||||
if (request.ContentType == VideoContentType.Episode)
|
||||
{
|
||||
queryText = $"{request.SeriesName} S{request.ParentIndexNumber:D2}E{request.IndexNumber:D2}";
|
||||
}
|
||||
else if (request.ContentType == VideoContentType.Movie)
|
||||
{
|
||||
queryText = request.Name;
|
||||
}
|
||||
else
|
||||
{
|
||||
queryText = !string.IsNullOrEmpty(request.MediaPath)
|
||||
? Path.GetFileNameWithoutExtension(request.MediaPath)
|
||||
: string.Empty;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
queryText = !string.IsNullOrEmpty(request.MediaPath)
|
||||
? Path.GetFileNameWithoutExtension(request.MediaPath)
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(queryText))
|
||||
{
|
||||
_logger.Warn($"{Name} Search | Query text is empty, skip search.");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
_logger.Info($"{Name} Search | Target -> '{queryText}' | Language -> '{language}'");
|
||||
|
||||
var searchUrl = $"{ApiBaseUrl}/sub/search?token={Uri.EscapeDataString(config.AssrtApiToken)}&q={Uri.EscapeDataString(queryText)}&is_file=1&cnt=15";
|
||||
var maskedSearchUrl = $"{ApiBaseUrl}/sub/search?token={MaskToken(config.AssrtApiToken)}&q={Uri.EscapeDataString(queryText)}&is_file=1&cnt=15";
|
||||
_logger.Info($"{Name} Search | Request Url -> {maskedSearchUrl}");
|
||||
|
||||
var options = new HttpRequestOptions
|
||||
{
|
||||
Url = searchUrl,
|
||||
UserAgent = Name,
|
||||
TimeoutMs = 20000,
|
||||
AcceptHeader = "application/json"
|
||||
};
|
||||
|
||||
var response = await _httpClient.GetResponse(options);
|
||||
if (response.StatusCode != HttpStatusCode.OK)
|
||||
{
|
||||
_logger.Error($"{Name} Search | API responded with status: {response.StatusCode}");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
string jsonString;
|
||||
using (var reader = new StreamReader(response.Content, Encoding.UTF8))
|
||||
{
|
||||
jsonString = await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
_logger.Info("{0} Search | Response -> {1}", Name, response.StatusCode);
|
||||
|
||||
var searchResponse = _jsonSerializer.DeserializeFromString<AssrtSearchResponse>(jsonString);
|
||||
|
||||
if (searchResponse == null || searchResponse.status != 0 || searchResponse.sub?.subs == null || !searchResponse.sub.subs.Any())
|
||||
{
|
||||
_logger.Warn($"{Name} Search | No subtitles found or API error. Request -> Target: '{queryText}', Language: '{language}', Url: '{maskedSearchUrl}' | Response -> Status: {searchResponse?.status}, Content: '{jsonString}'");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
var remoteSubtitles = new List<RemoteSubtitleInfo>();
|
||||
|
||||
foreach (var item in searchResponse.sub.subs)
|
||||
{
|
||||
if (item.id == null) continue;
|
||||
|
||||
var subtitleId = item.id.ToString();
|
||||
var format = ExtractFormat(item.subtype) ?? ExtractFormat(item.native_name) ?? SRT;
|
||||
|
||||
var langDesc = item.lang?.desc ?? "中文";
|
||||
|
||||
var downloadInfo = new AssrtDownloadInfo
|
||||
{
|
||||
Id = subtitleId,
|
||||
Format = format,
|
||||
Language = request.Language
|
||||
};
|
||||
|
||||
var encodedId = Base64Encode(_jsonSerializer.SerializeToString(downloadInfo));
|
||||
|
||||
remoteSubtitles.Add(new RemoteSubtitleInfo
|
||||
{
|
||||
Id = encodedId,
|
||||
Name = $"[MEIAMSUB] {item.native_name ?? item.videoname ?? subtitleId} | {langDesc} | 伪射手",
|
||||
Author = "Assrt",
|
||||
ProviderName = Name,
|
||||
Format = format,
|
||||
Comment = string.IsNullOrEmpty(item.videoname) ? $"格式: {format}" : $"适配视频: {item.videoname} (格式: {format})",
|
||||
IsHashMatch = false
|
||||
});
|
||||
}
|
||||
|
||||
_logger.Info($"{Name} Search | Summary -> Found {remoteSubtitles.Count} subtitles.");
|
||||
return remoteSubtitles;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"{Name} Search | Exception occurred: [{ex.GetType().Name}] {ex.Message}");
|
||||
}
|
||||
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
public async Task<SubtitleResponse> GetSubtitles(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.Info($"{Name} GetSubtitles | Requesting ID: {id}");
|
||||
AssrtDownloadInfo downloadInfo = null;
|
||||
|
||||
try
|
||||
{
|
||||
var decodedString = Base64Decode(id);
|
||||
downloadInfo = _jsonSerializer.DeserializeFromString<AssrtDownloadInfo>(decodedString);
|
||||
if (downloadInfo == null || string.IsNullOrEmpty(downloadInfo.Id))
|
||||
{
|
||||
return new SubtitleResponse { Language = "chi", Format = SRT, Stream = new MemoryStream() };
|
||||
}
|
||||
|
||||
var config = Plugin.Instance?.Options;
|
||||
if (config == null || string.IsNullOrWhiteSpace(config.AssrtApiToken))
|
||||
{
|
||||
_logger.Error($"{Name} GetSubtitles | AssrtApiToken is not configured.");
|
||||
return new SubtitleResponse { Language = downloadInfo.Language, Format = downloadInfo.Format, Stream = new MemoryStream() };
|
||||
}
|
||||
|
||||
// 1. 请求详情接口获取下载地址
|
||||
var detailUrl = $"{ApiBaseUrl}/sub/detail?token={Uri.EscapeDataString(config.AssrtApiToken)}&id={Uri.EscapeDataString(downloadInfo.Id)}";
|
||||
|
||||
var detailOptions = new HttpRequestOptions
|
||||
{
|
||||
Url = detailUrl,
|
||||
UserAgent = Name,
|
||||
TimeoutMs = 20000,
|
||||
AcceptHeader = "application/json"
|
||||
};
|
||||
|
||||
var detailResponseMsg = await _httpClient.GetResponse(detailOptions);
|
||||
if (detailResponseMsg.StatusCode != HttpStatusCode.OK)
|
||||
{
|
||||
_logger.Error($"{Name} GetSubtitles | Failed to fetch details. Status: {detailResponseMsg.StatusCode}");
|
||||
return new SubtitleResponse { Language = downloadInfo.Language, Format = downloadInfo.Format, Stream = new MemoryStream() };
|
||||
}
|
||||
|
||||
string detailJson;
|
||||
using (var reader = new StreamReader(detailResponseMsg.Content, Encoding.UTF8))
|
||||
{
|
||||
detailJson = await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
var detailResponse = _jsonSerializer.DeserializeFromString<AssrtDetailResponse>(detailJson);
|
||||
|
||||
if (detailResponse == null || detailResponse.status != 0)
|
||||
{
|
||||
_logger.Error($"{Name} GetSubtitles | Detail API error. Status: {detailResponse?.status}, Response: {detailJson}");
|
||||
return new SubtitleResponse { Language = downloadInfo.Language, Format = downloadInfo.Format, Stream = new MemoryStream() };
|
||||
}
|
||||
|
||||
var targetSub = detailResponse?.sub?.subs?.FirstOrDefault();
|
||||
if (targetSub == null || string.IsNullOrEmpty(targetSub.url))
|
||||
{
|
||||
_logger.Error($"{Name} GetSubtitles | Subtitle download URL not found in API response.");
|
||||
return new SubtitleResponse { Language = downloadInfo.Language, Format = downloadInfo.Format, Stream = new MemoryStream() };
|
||||
}
|
||||
|
||||
var downloadUrl = targetSub.url;
|
||||
var downloadFilename = targetSub.filename;
|
||||
|
||||
_logger.Info($"{Name} GetSubtitles | Downloading subtitle from: {downloadUrl}");
|
||||
|
||||
// 2. 下载字幕流 (手动接管重定向以确保每次重定向请求均带有正确的防盗链 Headers)
|
||||
var handler = new HttpClientHandler { AllowAutoRedirect = false };
|
||||
using var cleanHttpClient = new HttpClient(handler);
|
||||
|
||||
var currentUrl = downloadUrl;
|
||||
HttpResponseMessage downloadResponse = null;
|
||||
int redirectCount = 0;
|
||||
|
||||
while (redirectCount < 5)
|
||||
{
|
||||
var requestMsg = new HttpRequestMessage(HttpMethod.Get, currentUrl);
|
||||
requestMsg.Version = HttpVersion.Version11;
|
||||
requestMsg.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
|
||||
requestMsg.Headers.Add("Referer", "https://assrt.net/");
|
||||
|
||||
downloadResponse = await cleanHttpClient.SendAsync(requestMsg, cancellationToken);
|
||||
|
||||
if (downloadResponse.StatusCode == HttpStatusCode.Redirect ||
|
||||
downloadResponse.StatusCode == HttpStatusCode.MovedPermanently ||
|
||||
downloadResponse.StatusCode == HttpStatusCode.Found ||
|
||||
downloadResponse.StatusCode == HttpStatusCode.SeeOther ||
|
||||
(int)downloadResponse.StatusCode == 307 ||
|
||||
(int)downloadResponse.StatusCode == 308)
|
||||
{
|
||||
var location = downloadResponse.Headers.Location;
|
||||
if (location != null)
|
||||
{
|
||||
if (!location.IsAbsoluteUri)
|
||||
{
|
||||
location = new Uri(new Uri(currentUrl), location);
|
||||
}
|
||||
currentUrl = location.AbsoluteUri;
|
||||
redirectCount++;
|
||||
_logger.Info($"{Name} GetSubtitles | Redirecting to -> {currentUrl}");
|
||||
downloadResponse.Dispose();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (downloadResponse == null || !downloadResponse.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.Error($"{Name} GetSubtitles | Failed to download subtitle file. Status: {downloadResponse?.StatusCode}");
|
||||
return new SubtitleResponse { Language = downloadInfo.Language, Format = downloadInfo.Format, Stream = new MemoryStream() };
|
||||
}
|
||||
|
||||
var responseStream = await downloadResponse.Content.ReadAsStreamAsync();
|
||||
|
||||
// 3. 下载原始文件;若为压缩包,则从 ZIP/RAR/7z/TAR 等归档中提取字幕。
|
||||
var memoryStream = new MemoryStream();
|
||||
await responseStream.CopyToAsync(memoryStream);
|
||||
memoryStream.Position = 0;
|
||||
|
||||
var directFormat = ExtractFormat(downloadFilename);
|
||||
if (directFormat == null)
|
||||
{
|
||||
_logger.Info($"{Name} GetSubtitles | Archive detected, extracting: {downloadFilename}");
|
||||
try
|
||||
{
|
||||
using (var archive = ArchiveFactory.Open(memoryStream))
|
||||
{
|
||||
var entry = archive.Entries.FirstOrDefault(e =>
|
||||
!e.IsDirectory && ExtractFormat(e.Key) != null);
|
||||
|
||||
if (entry != null)
|
||||
{
|
||||
var extractedStream = new MemoryStream();
|
||||
using (var entryStream = entry.OpenEntryStream())
|
||||
{
|
||||
await entryStream.CopyToAsync(extractedStream);
|
||||
}
|
||||
extractedStream.Position = 0;
|
||||
var extFormat = ExtractFormat(entry.Key) ?? downloadInfo.Format;
|
||||
|
||||
_logger.Info($"{Name} GetSubtitles | Extracted file: '{entry.Key}' with format: {extFormat}");
|
||||
|
||||
return new SubtitleResponse
|
||||
{
|
||||
Language = downloadInfo.Language,
|
||||
IsForced = false,
|
||||
Format = extFormat,
|
||||
Stream = extractedStream
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Warn($"{Name} GetSubtitles | Archive contains no supported srt/ass/ssa subtitle.");
|
||||
return new SubtitleResponse { Language = downloadInfo.Language, Format = downloadInfo.Format, Stream = new MemoryStream() };
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"{Name} GetSubtitles | Failed to extract archive '{downloadFilename}': {ex.Message}");
|
||||
return new SubtitleResponse { Language = downloadInfo.Language, Format = downloadInfo.Format, Stream = new MemoryStream() };
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 非压缩的字幕文件直接返回。
|
||||
memoryStream.Position = 0;
|
||||
return new SubtitleResponse
|
||||
{
|
||||
Language = downloadInfo.Language,
|
||||
IsForced = false,
|
||||
Format = directFormat ?? downloadInfo.Format,
|
||||
Stream = memoryStream
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"{Name} GetSubtitles | Exception: {ex.Message}");
|
||||
}
|
||||
|
||||
return new SubtitleResponse { Language = downloadInfo?.Language ?? "chi", Format = downloadInfo?.Format ?? SRT, Stream = new MemoryStream() };
|
||||
}
|
||||
|
||||
#region 辅助工具函数
|
||||
|
||||
public static string Base64Encode(string plainText)
|
||||
{
|
||||
var plainTextBytes = Encoding.UTF8.GetBytes(plainText);
|
||||
return Convert.ToBase64String(plainTextBytes);
|
||||
}
|
||||
|
||||
public static string Base64Decode(string base64EncodedData)
|
||||
{
|
||||
var base64EncodedBytes = Convert.FromBase64String(base64EncodedData);
|
||||
return Encoding.UTF8.GetString(base64EncodedBytes);
|
||||
}
|
||||
|
||||
protected string ExtractFormat(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
text = text.ToLower();
|
||||
if (text.Contains(ASS)) return ASS;
|
||||
if (text.Contains(SSA)) return SSA;
|
||||
if (text.Contains(SRT)) return SRT;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeLanguage(string language)
|
||||
{
|
||||
if (string.IsNullOrEmpty(language)) return language;
|
||||
|
||||
if (language.Equals("zh-CN", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zh-TW", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zh-HK", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zh", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zho", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("chi", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "chi";
|
||||
}
|
||||
if (language.Equals("en", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("eng", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "eng";
|
||||
}
|
||||
return language;
|
||||
}
|
||||
|
||||
private static string MaskToken(string token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token)) return string.Empty;
|
||||
if (token.Length <= 8) return "***";
|
||||
return token.Substring(0, 4) + "***" + token.Substring(token.Length - 4);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
38
Emby.MeiamSub.Assrt/Emby.MeiamSub.Assrt.csproj
Normal file
38
Emby.MeiamSub.Assrt/Emby.MeiamSub.Assrt.csproj
Normal file
@@ -0,0 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyVersion>1.0.16.0</AssemblyVersion>
|
||||
<FileVersion>1.0.16.0</FileVersion>
|
||||
<Version>1.0.16.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<!-- Emby 4.9 provides SharpCompress 0.33.0 at runtime. Upgrading the compile-time
|
||||
reference would make the plugin request an assembly version the host does not provide. -->
|
||||
<NoWarn>$(NoWarn);NU1902</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<OutputPath>..\Release</OutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="Thumb.png" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Thumb.png" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MediaBrowser.Common" Version="4.9.1.90" />
|
||||
<PackageReference Include="MediaBrowser.Server.Core" Version="4.9.1.90" />
|
||||
<PackageReference Include="SharpCompress" Version="0.33.0" ExcludeAssets="runtime" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
57
Emby.MeiamSub.Assrt/Model/AssrtModels.cs
Normal file
57
Emby.MeiamSub.Assrt/Model/AssrtModels.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Emby.MeiamSub.Assrt.Model
|
||||
{
|
||||
public class AssrtSearchResponse
|
||||
{
|
||||
public int status { get; set; }
|
||||
public AssrtSearchSubInfo sub { get; set; }
|
||||
}
|
||||
|
||||
public class AssrtSearchSubInfo
|
||||
{
|
||||
public string keyword { get; set; }
|
||||
public string result { get; set; }
|
||||
public List<AssrtSearchItem> subs { get; set; }
|
||||
}
|
||||
|
||||
public class AssrtSearchItem
|
||||
{
|
||||
public object id { get; set; }
|
||||
public string native_name { get; set; }
|
||||
public string videoname { get; set; }
|
||||
public string subtype { get; set; }
|
||||
public AssrtLanguageInfo lang { get; set; }
|
||||
}
|
||||
|
||||
public class AssrtLanguageInfo
|
||||
{
|
||||
public string desc { get; set; }
|
||||
public int lang { get; set; }
|
||||
}
|
||||
|
||||
public class AssrtDetailResponse
|
||||
{
|
||||
public int status { get; set; }
|
||||
public AssrtDetailSubInfo sub { get; set; }
|
||||
}
|
||||
|
||||
public class AssrtDetailSubInfo
|
||||
{
|
||||
public List<AssrtDetailItem> subs { get; set; }
|
||||
}
|
||||
|
||||
public class AssrtDetailItem
|
||||
{
|
||||
public object id { get; set; }
|
||||
public string filename { get; set; }
|
||||
public string url { get; set; }
|
||||
}
|
||||
|
||||
public class AssrtDownloadInfo
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Format { get; set; }
|
||||
public string Language { get; set; }
|
||||
}
|
||||
}
|
||||
92
Emby.MeiamSub.Assrt/Plugin.cs
Normal file
92
Emby.MeiamSub.Assrt/Plugin.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using Emby.Web.GenericEdit;
|
||||
using MediaBrowser.Common;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using MediaBrowser.Model.Drawing;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
|
||||
namespace Emby.MeiamSub.Assrt
|
||||
{
|
||||
/// <summary>
|
||||
/// Assrt 插件入口
|
||||
/// </summary>
|
||||
public class Plugin : BasePluginSimpleUI<PluginConfiguration>, IHasThumbImage
|
||||
{
|
||||
public Plugin(IApplicationPaths applicationPaths, IApplicationHost applicationHost) : base(applicationHost)
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 插件ID
|
||||
/// </summary>
|
||||
public override Guid Id => new Guid("D276A8D0-4B17-4EEF-94B6-61D646DE5A22");
|
||||
|
||||
/// <summary>
|
||||
/// 插件名称
|
||||
/// </summary>
|
||||
public override string Name => "MeiamSub.Assrt";
|
||||
|
||||
/// <summary>
|
||||
/// 插件描述
|
||||
/// </summary>
|
||||
public override string Description => "Download subtitles from Assrt (伪射手网)";
|
||||
|
||||
/// <summary>
|
||||
/// 缩略图格式化类型
|
||||
/// </summary>
|
||||
public ImageFormat ThumbImageFormat => ImageFormat.Gif;
|
||||
|
||||
/// <summary>
|
||||
/// 获取插件选项
|
||||
/// </summary>
|
||||
public PluginConfiguration Options => this.GetOptions();
|
||||
|
||||
public static Plugin Instance { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取插件缩略图资源流
|
||||
/// </summary>
|
||||
/// <returns>图片资源流,若不存在则返回 null</returns>
|
||||
public Stream GetThumbImage()
|
||||
{
|
||||
var type = GetType();
|
||||
var resourceName = $"{type.Namespace}.Thumb.png";
|
||||
var stream = type.Assembly.GetManifestResourceStream(resourceName);
|
||||
|
||||
if (stream == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 插件配置类
|
||||
/// </summary>
|
||||
public class PluginConfiguration : EditableOptionsBase
|
||||
{
|
||||
public override string EditorTitle => "MeiamSub Assrt Options";
|
||||
|
||||
[DisplayName("Assrt API Token")]
|
||||
[Description("请输入您在 assrt.net 注册获取的 32 位 API Token")]
|
||||
public string AssrtApiToken { get; set; }
|
||||
|
||||
[DisplayName("使用元数据搜索字幕")]
|
||||
[Description("勾选此项后,使用元数据中的剧集名称和季集编号搜索字幕,而非文件名。建议开启。")]
|
||||
public bool EnableUseMetadata { get; set; }
|
||||
|
||||
public PluginConfiguration()
|
||||
{
|
||||
AssrtApiToken = string.Empty;
|
||||
EnableUseMetadata = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
Emby.MeiamSub.Assrt/Thumb.png
Normal file
BIN
Emby.MeiamSub.Assrt/Thumb.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 256 KiB |
@@ -1,9 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyVersion>1.0.14.0</AssemblyVersion>
|
||||
<FileVersion>1.0.14.0</FileVersion>
|
||||
<Version>1.0.14.0</Version>
|
||||
<AssemblyVersion>1.0.16.0</AssemblyVersion>
|
||||
<FileVersion>1.0.16.0</FileVersion>
|
||||
<Version>1.0.16.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
@@ -28,7 +28,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
|
||||
<Exec Command="Copy $(TargetDir)$(TargetFileName) $(SolutionDir)$(ConfigurationName)\$(TargetFileName) /y
" />
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Model.Drawing;
|
||||
using System;
|
||||
@@ -31,7 +31,7 @@ namespace Emby.MeiamSub.Shooter
|
||||
/// <summary>
|
||||
/// 插件描述
|
||||
/// </summary>
|
||||
public override string Description => "Download subtitles from Shooter";
|
||||
public override string Description => "Deprecated: the legacy Shooter API rarely returns usable results. Use Assrt or Thunder instead.";
|
||||
|
||||
/// <summary>
|
||||
/// 缩略图格式化类型
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyVersion>1.0.14.0</AssemblyVersion>
|
||||
<FileVersion>1.0.14.0</FileVersion>
|
||||
<Version>1.0.14.0</Version>
|
||||
<AssemblyVersion>1.0.16.0</AssemblyVersion>
|
||||
<FileVersion>1.0.16.0</FileVersion>
|
||||
<Version>1.0.16.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
@@ -34,7 +34,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
|
||||
<Exec Command="Copy $(TargetDir)$(TargetFileName) $(SolutionDir)$(ConfigurationName)\$(TargetFileName) /y
" />
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
|
||||
</Target>
|
||||
|
||||
|
||||
|
||||
@@ -17,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;
|
||||
|
||||
@@ -78,7 +78,7 @@ namespace Emby.MeiamSub.Thunder
|
||||
{
|
||||
_logger.Info("{0} Search | SubtitleSearchRequest -> {1}", new object[2] { Name, _jsonSerializer.SerializeToString(request) });
|
||||
|
||||
var subtitles = await SearchSubtitlesAsync(request);
|
||||
var subtitles = await SearchSubtitlesAsync(request, cancellationToken);
|
||||
|
||||
return subtitles;
|
||||
}
|
||||
@@ -88,7 +88,7 @@ namespace Emby.MeiamSub.Thunder
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <returns></returns>
|
||||
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request)
|
||||
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 修改人:Mayfly777w
|
||||
// 修改时间:2026-01-01
|
||||
@@ -131,7 +131,7 @@ namespace Emby.MeiamSub.Thunder
|
||||
}
|
||||
|
||||
var stopWatch = Stopwatch.StartNew();
|
||||
var cid = await GetCidByFileAsync(request.MediaPath);
|
||||
var cid = await GetCidByFileAsync(request.MediaPath, cancellationToken);
|
||||
stopWatch.Stop();
|
||||
|
||||
_logger.Info("{0} Search | FileHash -> {1} (Took {2}ms)", new object[3] { Name, cid, stopWatch.ElapsedMilliseconds });
|
||||
@@ -139,14 +139,14 @@ namespace Emby.MeiamSub.Thunder
|
||||
|
||||
HttpRequestOptions options = new HttpRequestOptions
|
||||
{
|
||||
Url = $"https://api-shoulei-ssl.xunlei.com/oracle/subtitle?name={MovieName}",
|
||||
Url = $"https://api-shoulei-ssl.xunlei.com/oracle/subtitle?name={Uri.EscapeDataString(MovieName)}",
|
||||
UserAgent = $"{Name}",
|
||||
TimeoutMs = 30000,
|
||||
AcceptHeader = "*/*",
|
||||
};
|
||||
var response = await _httpClient.GetResponse(options);
|
||||
|
||||
_logger.Info("{0} Search | Response -> {1}", new object[2] { Name, _jsonSerializer.SerializeToString(response) });
|
||||
_logger.Info("{0} Search | Response -> {1}", new object[2] { Name, response.StatusCode });
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
@@ -154,9 +154,16 @@ namespace Emby.MeiamSub.Thunder
|
||||
|
||||
if (subtitleResponse.Code == 0)
|
||||
{
|
||||
_logger.Info("{0} Search | Response -> {1}", new object[2] { Name, _jsonSerializer.SerializeToString(subtitleResponse) });
|
||||
|
||||
var subtitles = subtitleResponse.Data.Where(m => !string.IsNullOrEmpty(m.Name));
|
||||
var subtitles = (subtitleResponse.Data ?? new List<SublistItem>())
|
||||
.Where(m => !string.IsNullOrWhiteSpace(m.Name) &&
|
||||
!string.IsNullOrWhiteSpace(m.Url) &&
|
||||
NormalizeFormat(m.Ext) != null)
|
||||
.GroupBy(m => m.Url, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(m => m.First())
|
||||
.OrderByDescending(m => !string.IsNullOrEmpty(cid) && string.Equals(cid, m.Cid, StringComparison.OrdinalIgnoreCase))
|
||||
.ThenByDescending(m => m.Langs?.IndexOf("简体", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
.Take(20)
|
||||
.ToList();
|
||||
|
||||
var remoteSubtitles = new List<RemoteSubtitleInfo>();
|
||||
|
||||
@@ -169,7 +176,7 @@ namespace Emby.MeiamSub.Thunder
|
||||
Id = Base64Encode(_jsonSerializer.SerializeToString(new DownloadSubInfo
|
||||
{
|
||||
Url = item.Url,
|
||||
Format = item.Ext,
|
||||
Format = NormalizeFormat(item.Ext),
|
||||
Language = request.Language,
|
||||
IsForced = request.IsForced
|
||||
})),
|
||||
@@ -177,8 +184,8 @@ namespace Emby.MeiamSub.Thunder
|
||||
Language = request.Language,
|
||||
Author = "Meiam ",
|
||||
ProviderName = $"{Name}",
|
||||
Format = item.Ext,
|
||||
Comment = $"Format : {item.Ext}",
|
||||
Format = NormalizeFormat(item.Ext),
|
||||
Comment = $"Format : {NormalizeFormat(item.Ext)}",
|
||||
IsHashMatch = cid == item.Cid,
|
||||
});
|
||||
}
|
||||
@@ -214,7 +221,7 @@ namespace Emby.MeiamSub.Thunder
|
||||
{
|
||||
_logger.Info("{0} DownloadSub | Request -> {1}", new object[2] { Name, id });
|
||||
|
||||
return await DownloadSubAsync(id);
|
||||
return await DownloadSubAsync(id, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -222,7 +229,7 @@ namespace Emby.MeiamSub.Thunder
|
||||
/// </summary>
|
||||
/// <param name="info"></param>
|
||||
/// <returns></returns>
|
||||
private async Task<SubtitleResponse> DownloadSubAsync(string info)
|
||||
private async Task<SubtitleResponse> DownloadSubAsync(string info, CancellationToken cancellationToken)
|
||||
{
|
||||
// 修改人: Meiam
|
||||
// 修改时间: 2025-12-22
|
||||
@@ -252,13 +259,20 @@ namespace Emby.MeiamSub.Thunder
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
var format = NormalizeFormat(downloadSub.Format)
|
||||
?? throw new InvalidDataException($"Unsupported subtitle format: {downloadSub.Format}");
|
||||
var stream = new MemoryStream();
|
||||
await response.Content.CopyToAsync(stream, 81920, cancellationToken);
|
||||
var data = stream.ToArray();
|
||||
stream.Dispose();
|
||||
ValidateSubtitleData(data, format, response.ContentType);
|
||||
|
||||
return new SubtitleResponse()
|
||||
{
|
||||
Language = downloadSub.Language,
|
||||
IsForced = false,
|
||||
Format = downloadSub.Format,
|
||||
Stream = response.Content,
|
||||
IsForced = downloadSub.IsForced ?? false,
|
||||
Format = format,
|
||||
Stream = new MemoryStream(data, writable: false),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -267,7 +281,7 @@ namespace Emby.MeiamSub.Thunder
|
||||
_logger.Error("{0} DownloadSub | Error -> {1}", Name, ex.Message);
|
||||
}
|
||||
|
||||
return new SubtitleResponse();
|
||||
throw new InvalidDataException($"{Name} subtitle download failed.");
|
||||
|
||||
}
|
||||
#endregion
|
||||
@@ -315,6 +329,44 @@ namespace Emby.MeiamSub.Thunder
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeFormat(string format)
|
||||
{
|
||||
var value = format?.Trim().TrimStart('.').ToLowerInvariant();
|
||||
return value == SRT || value == ASS || value == SSA ? value : null;
|
||||
}
|
||||
|
||||
private static void ValidateSubtitleData(byte[] data, string format, string mediaType)
|
||||
{
|
||||
if (data == null || data.Length < 8)
|
||||
{
|
||||
throw new InvalidDataException("Subtitle response is empty.");
|
||||
}
|
||||
|
||||
if (data[0] == (byte)'P' && data[1] == (byte)'K' ||
|
||||
data.Length >= 7 && Encoding.ASCII.GetString(data, 0, 7) == "Rar!\u001a\u0007")
|
||||
{
|
||||
throw new InvalidDataException("Compressed subtitle responses are not supported by Thunder.");
|
||||
}
|
||||
|
||||
var sample = Encoding.UTF8.GetString(data, 0, Math.Min(data.Length, 4096)).TrimStart('\uFEFF', ' ', '\r', '\n', '\t');
|
||||
if (sample.StartsWith("<", StringComparison.Ordinal) ||
|
||||
sample.StartsWith("{", StringComparison.Ordinal) ||
|
||||
sample.StartsWith("[", StringComparison.Ordinal) ||
|
||||
mediaType?.IndexOf("html", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
mediaType?.IndexOf("json", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
throw new InvalidDataException("Thunder returned an error document instead of a subtitle.");
|
||||
}
|
||||
|
||||
var valid = format == SRT
|
||||
? sample.IndexOf("-->", StringComparison.Ordinal) >= 0
|
||||
: sample.IndexOf("[Script Info]", StringComparison.OrdinalIgnoreCase) >= 0 || sample.IndexOf("Dialogue:", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
if (!valid)
|
||||
{
|
||||
throw new InvalidDataException($"Downloaded content is not a valid {format} subtitle.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 规范化语言代码
|
||||
/// </summary>
|
||||
@@ -349,11 +401,20 @@ namespace Emby.MeiamSub.Thunder
|
||||
/// </summary>
|
||||
/// <param name="filePath">文件路径</param>
|
||||
/// <returns>计算得到的 CID 字符串</returns>
|
||||
private async Task<string> GetCidByFileAsync(string filePath)
|
||||
private async Task<string> GetCidByFileAsync(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
// 修改人: Meiam
|
||||
// 修改时间: 2025-12-22
|
||||
// 备注: 改造为异步方法,优化 I/O 性能,使用 SHA1.Create() 替代旧 API,并增加 using 语句释放资源
|
||||
// 修改时间: 2026-06-11
|
||||
// 备注: 增加网络路径拦截,避免 FileStream 打开非本地路径抛出异常
|
||||
if (string.IsNullOrEmpty(filePath) ||
|
||||
filePath.Contains("://") ||
|
||||
filePath.StartsWith("smb://", StringComparison.OrdinalIgnoreCase) ||
|
||||
filePath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||
filePath.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.Warn("Network path detected, skip hash calculation: {0}", filePath);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous))
|
||||
{
|
||||
@@ -366,16 +427,16 @@ namespace Emby.MeiamSub.Thunder
|
||||
var buffer = new byte[0xf000];
|
||||
if (fileSize < 0xf000)
|
||||
{
|
||||
await stream.ReadAsync(buffer, 0, (int)fileSize);
|
||||
await ReadFullAsync(stream, buffer, 0, (int)fileSize, cancellationToken);
|
||||
buffer = sha1.ComputeHash(buffer, 0, (int)fileSize);
|
||||
}
|
||||
else
|
||||
{
|
||||
await stream.ReadAsync(buffer, 0, 0x5000);
|
||||
await ReadFullAsync(stream, buffer, 0, 0x5000, cancellationToken);
|
||||
stream.Seek(fileSize / 3, SeekOrigin.Begin);
|
||||
await stream.ReadAsync(buffer, 0x5000, 0x5000);
|
||||
await ReadFullAsync(stream, buffer, 0x5000, 0x5000, cancellationToken);
|
||||
stream.Seek(fileSize - 0x5000, SeekOrigin.Begin);
|
||||
await stream.ReadAsync(buffer, 0xa000, 0x5000);
|
||||
await ReadFullAsync(stream, buffer, 0xa000, 0x5000, cancellationToken);
|
||||
|
||||
buffer = sha1.ComputeHash(buffer, 0, 0xf000);
|
||||
}
|
||||
@@ -388,6 +449,21 @@ namespace Emby.MeiamSub.Thunder
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReadFullAsync(Stream stream, byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
{
|
||||
while (count > 0)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer, offset, count, cancellationToken);
|
||||
if (read == 0)
|
||||
{
|
||||
throw new EndOfStreamException();
|
||||
}
|
||||
|
||||
offset += read;
|
||||
count -= read;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
404
Jellyfin.MeiamSub.Assrt/AssrtProvider.cs
Normal file
404
Jellyfin.MeiamSub.Assrt/AssrtProvider.cs
Normal file
@@ -0,0 +1,404 @@
|
||||
using Jellyfin.MeiamSub.Assrt.Model;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Controller.Subtitles;
|
||||
using MediaBrowser.Model.Providers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using SharpCompress.Archives;
|
||||
|
||||
namespace Jellyfin.MeiamSub.Assrt
|
||||
{
|
||||
/// <summary>
|
||||
/// Assrt (伪射手) 字幕提供程序
|
||||
/// </summary>
|
||||
public class AssrtProvider : ISubtitleProvider, IHasOrder
|
||||
{
|
||||
private const string ApiBaseUrl = "https://api.assrt.net/v1";
|
||||
|
||||
public const string ASS = "ass";
|
||||
public const string SSA = "ssa";
|
||||
public const string SRT = "srt";
|
||||
|
||||
private readonly ILogger<AssrtProvider> _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public int Order => 90; // 优先级高于旧版
|
||||
|
||||
public string Name => "MeiamSub.Assrt";
|
||||
|
||||
public IEnumerable<VideoContentType> SupportedMediaTypes => new[] { VideoContentType.Movie, VideoContentType.Episode };
|
||||
|
||||
public AssrtProvider(ILogger<AssrtProvider> logger, IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger.LogInformation($"{Name} Init");
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<RemoteSubtitleInfo>> Search(SubtitleSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation($"{Name} Search | Received request for: {(request?.MediaPath ?? "NULL")}");
|
||||
|
||||
try
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config == null || string.IsNullOrWhiteSpace(config.AssrtApiToken))
|
||||
{
|
||||
_logger.LogWarning($"{Name} Search | AssrtApiToken is empty, skip search. Please configure the token in plugin settings.");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
var language = NormalizeLanguage(request.Language);
|
||||
if (language != "chi")
|
||||
{
|
||||
_logger.LogInformation($"{Name} Search | Language '{request.Language}' not supported, skip search.");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
string queryText;
|
||||
if (config.EnableUseMetadata)
|
||||
{
|
||||
if (request.ContentType == VideoContentType.Episode)
|
||||
{
|
||||
queryText = $"{request.SeriesName} S{request.ParentIndexNumber:D2}E{request.IndexNumber:D2}";
|
||||
}
|
||||
else if (request.ContentType == VideoContentType.Movie)
|
||||
{
|
||||
queryText = request.Name;
|
||||
}
|
||||
else
|
||||
{
|
||||
queryText = !string.IsNullOrEmpty(request.MediaPath)
|
||||
? Path.GetFileNameWithoutExtension(request.MediaPath)
|
||||
: string.Empty;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
queryText = !string.IsNullOrEmpty(request.MediaPath)
|
||||
? Path.GetFileNameWithoutExtension(request.MediaPath)
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(queryText))
|
||||
{
|
||||
_logger.LogWarning($"{Name} Search | Query text is empty, skip search.");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
_logger.LogInformation($"{Name} Search | Target -> '{queryText}' | Language -> '{language}'");
|
||||
|
||||
using var httpClient = _httpClientFactory.CreateClient(Name);
|
||||
|
||||
// 拼接搜索请求,is_file=1 用于对文件名检索进行关键词降噪
|
||||
var searchUrl = $"{ApiBaseUrl}/sub/search?token={Uri.EscapeDataString(config.AssrtApiToken)}&q={Uri.EscapeDataString(queryText)}&is_file=1&cnt=15";
|
||||
|
||||
// 隐藏敏感 Token 信息以便记录日志
|
||||
var maskedSearchUrl = $"{ApiBaseUrl}/sub/search?token={MaskToken(config.AssrtApiToken)}&q={Uri.EscapeDataString(queryText)}&is_file=1&cnt=15";
|
||||
_logger.LogInformation($"{Name} Search | Request Url -> {maskedSearchUrl}");
|
||||
|
||||
var response = await httpClient.GetAsync(searchUrl, cancellationToken);
|
||||
var jsonString = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("{Provider} Search | Response -> {StatusCode}", Name, response.StatusCode);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError($"{Name} Search | API responded with status: {response.StatusCode}");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
var searchResponse = JsonSerializer.Deserialize<AssrtSearchResponse>(jsonString);
|
||||
|
||||
if (searchResponse == null || searchResponse.Status != 0 || searchResponse.Sub?.List == null || !searchResponse.Sub.List.Any())
|
||||
{
|
||||
_logger.LogWarning($"{Name} Search | No subtitles found or API error. Request -> Target: '{queryText}', Language: '{language}', Url: '{maskedSearchUrl}' | Response -> Status: {searchResponse?.Status}, Content: '{jsonString}'");
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
var remoteSubtitles = new List<RemoteSubtitleInfo>();
|
||||
|
||||
foreach (var item in searchResponse.Sub.List)
|
||||
{
|
||||
if (item.Id == null) continue;
|
||||
|
||||
var subtitleId = item.Id.ToString();
|
||||
var format = ExtractFormat(item.Subtype) ?? ExtractFormat(item.Name) ?? SRT;
|
||||
|
||||
var langDesc = item.Lang?.Desc ?? "中文";
|
||||
|
||||
var downloadInfo = new AssrtDownloadInfo
|
||||
{
|
||||
Id = subtitleId,
|
||||
Format = format,
|
||||
Language = request.Language,
|
||||
TwoLetterISOLanguageName = request.TwoLetterISOLanguageName
|
||||
};
|
||||
|
||||
var encodedId = Base64Encode(JsonSerializer.Serialize(downloadInfo));
|
||||
|
||||
remoteSubtitles.Add(new RemoteSubtitleInfo
|
||||
{
|
||||
Id = encodedId,
|
||||
Name = $"[MEIAMSUB] {item.Name ?? item.VideoName ?? subtitleId} | {langDesc} | 伪射手",
|
||||
Author = "Assrt",
|
||||
ProviderName = Name,
|
||||
Format = format,
|
||||
Comment = string.IsNullOrEmpty(item.VideoName) ? $"格式: {format}" : $"适配视频: {item.VideoName} (格式: {format})",
|
||||
IsHashMatch = false // Assrt.net 接口主要是文本搜索,返回 false 以提示用户是名字检索匹配
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation($"{Name} Search | Summary -> Found {remoteSubtitles.Count} subtitles.");
|
||||
return remoteSubtitles;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{Name} Search | Exception occurred: {ex.Message}");
|
||||
}
|
||||
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
public async Task<SubtitleResponse> GetSubtitles(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation($"{Name} GetSubtitles | Requesting ID: {id}");
|
||||
AssrtDownloadInfo downloadInfo = null;
|
||||
|
||||
try
|
||||
{
|
||||
var decodedString = Base64Decode(id);
|
||||
downloadInfo = JsonSerializer.Deserialize<AssrtDownloadInfo>(decodedString);
|
||||
if (downloadInfo == null || string.IsNullOrEmpty(downloadInfo.Id))
|
||||
{
|
||||
throw new ArgumentException($"{Name} GetSubtitles | 无法解析字幕下载信息,ID 为空。");
|
||||
}
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config == null || string.IsNullOrWhiteSpace(config.AssrtApiToken))
|
||||
{
|
||||
_logger.LogError($"{Name} GetSubtitles | AssrtApiToken is not configured.");
|
||||
throw new InvalidOperationException($"{Name} GetSubtitles | AssrtApiToken 未配置,请在插件设置中填写 Token。");
|
||||
}
|
||||
|
||||
using var httpClient = _httpClientFactory.CreateClient(Name);
|
||||
|
||||
// 1. 请求详情接口获取真实的下载地址
|
||||
var detailUrl = $"{ApiBaseUrl}/sub/detail?token={Uri.EscapeDataString(config.AssrtApiToken)}&id={Uri.EscapeDataString(downloadInfo.Id)}";
|
||||
var detailResponseMsg = await httpClient.GetAsync(detailUrl, cancellationToken);
|
||||
if (!detailResponseMsg.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError($"{Name} GetSubtitles | Failed to fetch details. Status: {detailResponseMsg.StatusCode}");
|
||||
throw new HttpRequestException($"{Name} GetSubtitles | 获取字幕详情失败,HTTP 状态码: {detailResponseMsg.StatusCode}");
|
||||
}
|
||||
|
||||
var detailJson = await detailResponseMsg.Content.ReadAsStringAsync(cancellationToken);
|
||||
var detailResponse = JsonSerializer.Deserialize<AssrtDetailResponse>(detailJson);
|
||||
|
||||
if (detailResponse == null || detailResponse.Status != 0)
|
||||
{
|
||||
_logger.LogError($"{Name} GetSubtitles | Detail API error. Status: {detailResponse?.Status}, Response: {detailJson}");
|
||||
throw new InvalidOperationException($"{Name} GetSubtitles | Assrt API returned error status: {detailResponse?.Status}");
|
||||
}
|
||||
|
||||
var targetSub = detailResponse?.Sub?.Subs?.FirstOrDefault();
|
||||
if (targetSub == null || string.IsNullOrEmpty(targetSub.Url))
|
||||
{
|
||||
_logger.LogError($"{Name} GetSubtitles | Subtitle download URL not found in API response.");
|
||||
throw new InvalidOperationException($"{Name} GetSubtitles | API 响应中未找到字幕下载地址。");
|
||||
}
|
||||
|
||||
var downloadUrl = targetSub.Url;
|
||||
var downloadFilename = targetSub.Filename;
|
||||
|
||||
_logger.LogInformation($"{Name} GetSubtitles | Downloading subtitle from: {downloadUrl}");
|
||||
|
||||
// 2. 下载字幕流 (手动接管重定向以确保每次重定向请求均带有正确的防盗链 Headers)
|
||||
var handler = new HttpClientHandler { AllowAutoRedirect = false };
|
||||
using var cleanHttpClient = new HttpClient(handler);
|
||||
|
||||
var currentUrl = downloadUrl;
|
||||
|
||||
HttpResponseMessage downloadResponse = null;
|
||||
int redirectCount = 0;
|
||||
|
||||
while (redirectCount < 5)
|
||||
{
|
||||
var requestMsg = new HttpRequestMessage(HttpMethod.Get, currentUrl);
|
||||
requestMsg.Version = HttpVersion.Version11;
|
||||
requestMsg.Headers.ConnectionClose = true; // 强制关闭长连接,防范服务器 Keep-Alive 超时关闭
|
||||
requestMsg.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
|
||||
requestMsg.Headers.Add("Referer", "https://assrt.net/");
|
||||
|
||||
downloadResponse = await cleanHttpClient.SendAsync(requestMsg, cancellationToken);
|
||||
|
||||
if (downloadResponse.StatusCode == HttpStatusCode.Redirect ||
|
||||
downloadResponse.StatusCode == HttpStatusCode.MovedPermanently ||
|
||||
downloadResponse.StatusCode == HttpStatusCode.Found ||
|
||||
downloadResponse.StatusCode == HttpStatusCode.SeeOther ||
|
||||
(int)downloadResponse.StatusCode == 307 ||
|
||||
(int)downloadResponse.StatusCode == 308)
|
||||
{
|
||||
var location = downloadResponse.Headers.Location;
|
||||
if (location != null)
|
||||
{
|
||||
if (!location.IsAbsoluteUri)
|
||||
{
|
||||
location = new Uri(new Uri(currentUrl), location);
|
||||
}
|
||||
currentUrl = location.AbsoluteUri;
|
||||
redirectCount++;
|
||||
_logger.LogInformation($"{Name} GetSubtitles | Redirecting to -> {currentUrl}");
|
||||
downloadResponse.Dispose();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (downloadResponse == null || !downloadResponse.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError($"{Name} GetSubtitles | Failed to download subtitle file. Status: {downloadResponse?.StatusCode}");
|
||||
throw new HttpRequestException($"{Name} GetSubtitles | 字幕文件下载失败,HTTP 状态码: {downloadResponse?.StatusCode}");
|
||||
}
|
||||
|
||||
var responseStream = await downloadResponse.Content.ReadAsStreamAsync(cancellationToken);
|
||||
|
||||
// 3. 下载原始文件;若为压缩包,则从 ZIP/RAR/7z/TAR 等归档中提取字幕。
|
||||
var memoryStream = new MemoryStream();
|
||||
await responseStream.CopyToAsync(memoryStream, cancellationToken);
|
||||
memoryStream.Position = 0;
|
||||
|
||||
var directFormat = ExtractFormat(downloadFilename);
|
||||
if (directFormat == null)
|
||||
{
|
||||
_logger.LogInformation($"{Name} GetSubtitles | Archive detected, extracting: {downloadFilename}");
|
||||
try
|
||||
{
|
||||
using var archive = ArchiveFactory.OpenArchive(memoryStream);
|
||||
var entry = archive.Entries.FirstOrDefault(e =>
|
||||
!e.IsDirectory && ExtractFormat(e.Key) != null);
|
||||
|
||||
if (entry != null)
|
||||
{
|
||||
var extractedStream = new MemoryStream();
|
||||
using (var entryStream = entry.OpenEntryStream())
|
||||
{
|
||||
await entryStream.CopyToAsync(extractedStream, cancellationToken);
|
||||
}
|
||||
extractedStream.Position = 0;
|
||||
var extFormat = ExtractFormat(entry.Key) ?? downloadInfo.Format;
|
||||
|
||||
_logger.LogInformation($"{Name} GetSubtitles | Extracted file: '{entry.Key}' with format: {extFormat}");
|
||||
|
||||
return new SubtitleResponse
|
||||
{
|
||||
Language = downloadInfo.Language,
|
||||
IsForced = false,
|
||||
Format = extFormat,
|
||||
Stream = extractedStream
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"{Name} GetSubtitles | Archive contains no supported srt/ass/ssa subtitle.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{Name} GetSubtitles | Failed to extract archive '{downloadFilename}': {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 非压缩的字幕文件直接返回。
|
||||
memoryStream.Position = 0;
|
||||
return new SubtitleResponse
|
||||
{
|
||||
Language = downloadInfo.Language,
|
||||
IsForced = false,
|
||||
Format = directFormat ?? downloadInfo.Format,
|
||||
Stream = memoryStream
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{Name} GetSubtitles | Exception: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
#region 辅助工具函数
|
||||
|
||||
public static string Base64Encode(string plainText)
|
||||
{
|
||||
var plainTextBytes = Encoding.UTF8.GetBytes(plainText);
|
||||
return Convert.ToBase64String(plainTextBytes);
|
||||
}
|
||||
|
||||
public static string Base64Decode(string base64EncodedData)
|
||||
{
|
||||
var base64EncodedBytes = Convert.FromBase64String(base64EncodedData);
|
||||
return Encoding.UTF8.GetString(base64EncodedBytes);
|
||||
}
|
||||
|
||||
protected string ExtractFormat(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
text = text.ToLower();
|
||||
if (text.Contains(ASS)) return ASS;
|
||||
if (text.Contains(SSA)) return SSA;
|
||||
if (text.Contains(SRT)) return SRT;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeLanguage(string language)
|
||||
{
|
||||
if (string.IsNullOrEmpty(language)) return language;
|
||||
|
||||
if (language.Equals("zh-CN", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zh-TW", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zh-HK", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zh", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("zho", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("chi", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "chi";
|
||||
}
|
||||
if (language.Equals("en", StringComparison.OrdinalIgnoreCase) ||
|
||||
language.Equals("eng", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "eng";
|
||||
}
|
||||
return language;
|
||||
}
|
||||
|
||||
private static string MaskToken(string token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token)) return string.Empty;
|
||||
if (token.Length <= 8) return "***";
|
||||
return token.Substring(0, 4) + "***" + token.Substring(token.Length - 4);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
26
Jellyfin.MeiamSub.Assrt/Configuration/PluginConfiguration.cs
Normal file
26
Jellyfin.MeiamSub.Assrt/Configuration/PluginConfiguration.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace Jellyfin.MeiamSub.Assrt.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// 插件配置类
|
||||
/// </summary>
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Assrt API 鉴权 Token
|
||||
/// </summary>
|
||||
public string AssrtApiToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否使用元数据中的剧集名称和季集编号搜索字幕
|
||||
/// </summary>
|
||||
public bool EnableUseMetadata { get; set; }
|
||||
|
||||
public PluginConfiguration()
|
||||
{
|
||||
AssrtApiToken = string.Empty;
|
||||
EnableUseMetadata = true; // 默认为 true,因为 Assrt 是基于文本搜索,元数据匹配非常合适
|
||||
}
|
||||
}
|
||||
}
|
||||
76
Jellyfin.MeiamSub.Assrt/Configuration/configPage.html
Normal file
76
Jellyfin.MeiamSub.Assrt/Configuration/configPage.html
Normal file
@@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>MeiamSub.Assrt</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="MeiamSubAssrtConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
|
||||
data-require="emby-checkbox,emby-input">
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<form id="MeiamSubAssrtConfigForm">
|
||||
<div class="verticalSection">
|
||||
<h2 class="sectionTitle">伪射手字幕搜索设置 (Assrt)</h2>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel" for="AssrtApiToken">Assrt API Token</label>
|
||||
<input id="AssrtApiToken" name="AssrtApiToken" type="text" is="emby-input" placeholder="请输入 Assrt.net API Token" />
|
||||
<div class="fieldDescription">
|
||||
请在 <a href="https://assrt.net" target="_blank">assrt.net</a> 注册并获取你的 32 位 API Token。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="EnableUseMetadata" name="EnableUseMetadata" type="checkbox"
|
||||
is="emby-checkbox" />
|
||||
<span>使用元数据搜索字幕</span>
|
||||
</label>
|
||||
<div class="fieldDescription">
|
||||
勾选此项后,使用元数据中的剧集名称和季集编号搜索字幕,而非文件名。
|
||||
<br />
|
||||
剧集将使用 "剧集名 S01E01" 格式;电影将使用影片名称。建议开启以提升模糊检索成功率。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||
<span>保存</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
var MeiamSubAssrtConfig = {
|
||||
pluginUniqueId: 'E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0'
|
||||
};
|
||||
|
||||
document.querySelector('#MeiamSubAssrtConfigPage')
|
||||
.addEventListener('pageshow', function () {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(MeiamSubAssrtConfig.pluginUniqueId).then(function (config) {
|
||||
document.querySelector('#AssrtApiToken').value = config.AssrtApiToken || '';
|
||||
document.querySelector('#EnableUseMetadata').checked = config.EnableUseMetadata;
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector('#MeiamSubAssrtConfigForm')
|
||||
.addEventListener('submit', function (e) {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(MeiamSubAssrtConfig.pluginUniqueId).then(function (config) {
|
||||
config.AssrtApiToken = document.querySelector('#AssrtApiToken').value;
|
||||
config.EnableUseMetadata = document.querySelector('#EnableUseMetadata').checked;
|
||||
ApiClient.updatePluginConfiguration(MeiamSubAssrtConfig.pluginUniqueId, config).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
});
|
||||
});
|
||||
e.preventDefault();
|
||||
return false;
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
35
Jellyfin.MeiamSub.Assrt/Jellyfin.MeiamSub.Assrt.csproj
Normal file
35
Jellyfin.MeiamSub.Assrt/Jellyfin.MeiamSub.Assrt.csproj
Normal file
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Version>1.0.16.0</Version>
|
||||
<AssemblyVersion>1.0.16.0</AssemblyVersion>
|
||||
<FileVersion>1.0.16.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<OutputPath>..\Release</OutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.11.11" />
|
||||
<PackageReference Include="SharpCompress" Version="0.50.1" GeneratePathProperty="true" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="thumb.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
|
||||
<Copy SourceFiles="$(PkgSharpCompress)\lib\net8.0\SharpCompress.dll" DestinationFolder="$(SolutionDir)$(ConfigurationName)\dependencies\Jellyfin\" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
98
Jellyfin.MeiamSub.Assrt/Model/AssrtModels.cs
Normal file
98
Jellyfin.MeiamSub.Assrt/Model/AssrtModels.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.MeiamSub.Assrt.Model
|
||||
{
|
||||
/// <summary>
|
||||
/// 字幕搜索响应根结构
|
||||
/// </summary>
|
||||
public class AssrtSearchResponse
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public int Status { get; set; }
|
||||
|
||||
[JsonPropertyName("sub")]
|
||||
public AssrtSearchSubInfo Sub { get; set; }
|
||||
}
|
||||
|
||||
public class AssrtSearchSubInfo
|
||||
{
|
||||
[JsonPropertyName("keyword")]
|
||||
public string Keyword { get; set; }
|
||||
|
||||
[JsonPropertyName("result")]
|
||||
public string Result { get; set; }
|
||||
|
||||
[JsonPropertyName("subs")]
|
||||
public List<AssrtSearchItem> List { get; set; }
|
||||
}
|
||||
|
||||
public class AssrtSearchItem
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public object Id { get; set; } // 可以是数字也可以是字符串,用 object 接收再 ToString()
|
||||
|
||||
[JsonPropertyName("native_name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonPropertyName("videoname")]
|
||||
public string VideoName { get; set; }
|
||||
|
||||
[JsonPropertyName("subtype")]
|
||||
public string Subtype { get; set; }
|
||||
|
||||
[JsonPropertyName("lang")]
|
||||
public AssrtLanguageInfo Lang { get; set; }
|
||||
}
|
||||
|
||||
public class AssrtLanguageInfo
|
||||
{
|
||||
[JsonPropertyName("desc")]
|
||||
public string Desc { get; set; }
|
||||
|
||||
[JsonPropertyName("lang")]
|
||||
public int Lang { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 字幕详情响应根结构
|
||||
/// </summary>
|
||||
public class AssrtDetailResponse
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public int Status { get; set; }
|
||||
|
||||
[JsonPropertyName("sub")]
|
||||
public AssrtDetailSubInfo Sub { get; set; }
|
||||
}
|
||||
|
||||
public class AssrtDetailSubInfo
|
||||
{
|
||||
[JsonPropertyName("subs")]
|
||||
public List<AssrtDetailItem> Subs { get; set; }
|
||||
}
|
||||
|
||||
public class AssrtDetailItem
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public object Id { get; set; }
|
||||
|
||||
[JsonPropertyName("filename")]
|
||||
public string Filename { get; set; }
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; set; }
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 插件内部传递的下载参数
|
||||
/// </summary>
|
||||
public class AssrtDownloadInfo
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Format { get; set; }
|
||||
public string Language { get; set; }
|
||||
public string TwoLetterISOLanguageName { get; set; }
|
||||
}
|
||||
}
|
||||
55
Jellyfin.MeiamSub.Assrt/Plugin.cs
Normal file
55
Jellyfin.MeiamSub.Assrt/Plugin.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using Jellyfin.MeiamSub.Assrt.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Jellyfin.MeiamSub.Assrt
|
||||
{
|
||||
/// <summary>
|
||||
/// Assrt 插件入口
|
||||
/// </summary>
|
||||
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
/// <summary>
|
||||
/// 插件ID
|
||||
/// </summary>
|
||||
public override Guid Id => new Guid("E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0");
|
||||
|
||||
/// <summary>
|
||||
/// 插件名称
|
||||
/// </summary>
|
||||
public override string Name => "MeiamSub.Assrt";
|
||||
|
||||
/// <summary>
|
||||
/// 插件描述
|
||||
/// </summary>
|
||||
public override string Description => "Download subtitles from Assrt (伪射手网)";
|
||||
|
||||
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
public static Plugin Instance { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取插件配置页面列表
|
||||
/// </summary>
|
||||
/// <returns>配置页面信息</returns>
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = Name,
|
||||
EmbeddedResourcePath = GetType().Namespace + ".Configuration.configPage.html"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
26
Jellyfin.MeiamSub.Assrt/PluginServiceRegistrator.cs
Normal file
26
Jellyfin.MeiamSub.Assrt/PluginServiceRegistrator.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using MediaBrowser.Controller.Subtitles;
|
||||
using MediaBrowser.Controller;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System;
|
||||
|
||||
namespace Jellyfin.MeiamSub.Assrt
|
||||
{
|
||||
/// <summary>
|
||||
/// 插件服务注册器
|
||||
/// </summary>
|
||||
public class PluginServiceRegistrator : IPluginServiceRegistrator
|
||||
{
|
||||
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
|
||||
{
|
||||
serviceCollection.AddHttpClient("MeiamSub.Assrt", client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(20); // 适当的超时时间
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "MeiamSub.Assrt/1.0.14.0 (Jellyfin)");
|
||||
client.DefaultRequestHeaders.Add("Accept", "*/*");
|
||||
});
|
||||
|
||||
serviceCollection.AddSingleton<ISubtitleProvider, AssrtProvider>();
|
||||
}
|
||||
}
|
||||
}
|
||||
12
Jellyfin.MeiamSub.Assrt/meta.json
Normal file
12
Jellyfin.MeiamSub.Assrt/meta.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"category": "Subtitles",
|
||||
"description": "Download subtitles from Assrt (伪射手网)",
|
||||
"guid": "E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0",
|
||||
"name": "MeiamSub.Assrt",
|
||||
"owner": "Meiam",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"timestamp": "2026-08-07T07:11:29.0000000Z",
|
||||
"version": "1.0.16.0",
|
||||
"status": "Active",
|
||||
"imagePath": "thumb.png"
|
||||
}
|
||||
BIN
Jellyfin.MeiamSub.Assrt/thumb.png
Normal file
BIN
Jellyfin.MeiamSub.Assrt/thumb.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 255 KiB |
@@ -2,9 +2,9 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Version>1.0.14.0</Version>
|
||||
<AssemblyVersion>1.0.14.0</AssemblyVersion>
|
||||
<FileVersion>1.0.14.0</FileVersion>
|
||||
<Version>1.0.16.0</Version>
|
||||
<AssemblyVersion>1.0.16.0</AssemblyVersion>
|
||||
<FileVersion>1.0.16.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
@@ -22,7 +22,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
|
||||
<Exec Command="Copy $(TargetDir)$(TargetFileName) $(SolutionDir)$(ConfigurationName)\$(TargetFileName) /y
" />
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
{
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"category": "Subtitles",
|
||||
"description": "Download subtitles from Shooter",
|
||||
"description": "Deprecated: the legacy Shooter API rarely returns usable results. Use Assrt or Thunder instead.",
|
||||
"guid": "038D37A2-7A1E-4C01-9B6D-AA215D29AB4C",
|
||||
"name": "MeiamSub.Shooter",
|
||||
"owner": "Meiam",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"timestamp": "2026-06-11T03:17:06.0000000Z",
|
||||
"version": "1.0.14.0",
|
||||
"status": "Active",
|
||||
"timestamp": "2026-08-07T07:09:27.0000000Z",
|
||||
"version": "1.0.16.0",
|
||||
"status": "Deprecated",
|
||||
"imagePath": "thumb.png"
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Version>1.0.14.0</Version>
|
||||
<AssemblyVersion>1.0.14.0</AssemblyVersion>
|
||||
<Version>1.0.16.0</Version>
|
||||
<AssemblyVersion>1.0.16.0</AssemblyVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
@@ -26,7 +26,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
|
||||
<Exec Command="Copy $(TargetDir)$(TargetFileName) $(SolutionDir)$(ConfigurationName)\$(TargetFileName) /y
" />
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace Jellyfin.MeiamSub.Thunder.Model
|
||||
public string Url { get; set; }
|
||||
public string Ext { get; set; }
|
||||
public string Name { get; set; }
|
||||
public int Duration { get; set; }
|
||||
public long Duration { get; set; }
|
||||
public string[] Languages { get; set; }
|
||||
|
||||
public string Langs => Languages != null ? string.Join(",", Languages) : string.Empty;
|
||||
|
||||
@@ -68,9 +68,8 @@ namespace Jellyfin.MeiamSub.Thunder
|
||||
/// <returns>远程字幕信息列表</returns>
|
||||
public async Task<IEnumerable<RemoteSubtitleInfo>> Search(SubtitleSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("DEBUG: Received Search request for " + (request?.MediaPath ?? "NULL"));
|
||||
|
||||
var subtitles = await SearchSubtitlesAsync(request);
|
||||
var subtitles = await SearchSubtitlesAsync(request, cancellationToken);
|
||||
|
||||
return subtitles;
|
||||
}
|
||||
@@ -80,19 +79,17 @@ namespace Jellyfin.MeiamSub.Thunder
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <returns></returns>
|
||||
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request)
|
||||
private async Task<IEnumerable<RemoteSubtitleInfo>> SearchSubtitlesAsync(SubtitleSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 修改人: Meiam (移植自 PR #133 by Mayfly777w)
|
||||
// 修改时间: 2026-02-11
|
||||
// 备注: 增加元数据搜索选项,可以以元数据中的系列名和季集信息来作为匹配对象
|
||||
|
||||
_logger.LogInformation("DEBUG: Entering SearchSubtitlesAsync (Thunder)");
|
||||
|
||||
try
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
_logger.LogInformation("DEBUG: Request is null");
|
||||
_logger.LogInformation("{Provider} Search | Request is null", Name);
|
||||
return Array.Empty<RemoteSubtitleInfo>();
|
||||
}
|
||||
|
||||
@@ -139,7 +136,7 @@ namespace Jellyfin.MeiamSub.Thunder
|
||||
}
|
||||
|
||||
var stopWatch = Stopwatch.StartNew();
|
||||
var cid = await GetCidByFileAsync(request.MediaPath);
|
||||
var cid = await GetCidByFileAsync(request.MediaPath, cancellationToken);
|
||||
stopWatch.Stop();
|
||||
|
||||
_logger.LogInformation(Name + " Search | FileHash -> " + cid + " (Took " + stopWatch.ElapsedMilliseconds + "ms)");
|
||||
@@ -147,14 +144,14 @@ namespace Jellyfin.MeiamSub.Thunder
|
||||
using var options = new HttpRequestMessage
|
||||
{
|
||||
Method = HttpMethod.Get,
|
||||
RequestUri = new Uri($"https://api-shoulei-ssl.xunlei.com/oracle/subtitle?name={movieName}")
|
||||
RequestUri = new Uri($"https://api-shoulei-ssl.xunlei.com/oracle/subtitle?name={Uri.EscapeDataString(movieName)}")
|
||||
};
|
||||
|
||||
using var httpClient = _httpClientFactory.CreateClient(Name);
|
||||
|
||||
var response = await httpClient.SendAsync(options);
|
||||
using var response = await httpClient.SendAsync(options, cancellationToken);
|
||||
|
||||
_logger.LogInformation($"{Name} Search | Response -> {JsonSerializer.Serialize(response)}");
|
||||
_logger.LogInformation("{Provider} Search | Response -> {StatusCode}", Name, response.StatusCode);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
@@ -162,9 +159,16 @@ namespace Jellyfin.MeiamSub.Thunder
|
||||
|
||||
if (subtitleResponse != null)
|
||||
{
|
||||
_logger.LogInformation($"{Name} Search | Response -> {JsonSerializer.Serialize(subtitleResponse)}");
|
||||
|
||||
var subtitles = subtitleResponse.Data.Where(m => !string.IsNullOrEmpty(m.Name));
|
||||
var subtitles = (subtitleResponse.Data ?? new List<SublistItem>())
|
||||
.Where(m => !string.IsNullOrWhiteSpace(m.Name) &&
|
||||
!string.IsNullOrWhiteSpace(m.Url) &&
|
||||
NormalizeFormat(m.Ext) != null)
|
||||
.GroupBy(m => m.Url, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(m => m.First())
|
||||
.OrderByDescending(m => !string.IsNullOrEmpty(cid) && string.Equals(cid, m.Cid, StringComparison.OrdinalIgnoreCase))
|
||||
.ThenByDescending(m => m.Langs?.Contains("简体", StringComparison.OrdinalIgnoreCase) == true)
|
||||
.Take(20)
|
||||
.ToList();
|
||||
|
||||
var remoteSubtitles = new List<RemoteSubtitleInfo>();
|
||||
|
||||
@@ -177,15 +181,15 @@ namespace Jellyfin.MeiamSub.Thunder
|
||||
Id = Base64Encode(JsonSerializer.Serialize(new DownloadSubInfo
|
||||
{
|
||||
Url = item.Url,
|
||||
Format = item.Ext,
|
||||
Format = NormalizeFormat(item.Ext),
|
||||
Language = request.Language,
|
||||
TwoLetterISOLanguageName = request.TwoLetterISOLanguageName,
|
||||
})),
|
||||
Name = $"[MEIAMSUB] {item.Name} | {(item.Langs == string.Empty ? "未知" : item.Langs)} | 迅雷",
|
||||
Author = "Meiam ",
|
||||
ProviderName = $"{Name}",
|
||||
Format = item.Ext,
|
||||
Comment = $"Format : {item.Ext}",
|
||||
Format = NormalizeFormat(item.Ext),
|
||||
Comment = $"Format : {NormalizeFormat(item.Ext)}",
|
||||
IsHashMatch = cid == item.Cid,
|
||||
});
|
||||
}
|
||||
@@ -220,7 +224,7 @@ namespace Jellyfin.MeiamSub.Thunder
|
||||
{
|
||||
_logger.LogInformation($"{Name} DownloadSub | Request -> {id}");
|
||||
|
||||
return await DownloadSubAsync(id);
|
||||
return await DownloadSubAsync(id, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -228,7 +232,7 @@ namespace Jellyfin.MeiamSub.Thunder
|
||||
/// </summary>
|
||||
/// <param name="info"></param>
|
||||
/// <returns></returns>
|
||||
private async Task<SubtitleResponse> DownloadSubAsync(string info)
|
||||
private async Task<SubtitleResponse> DownloadSubAsync(string info, CancellationToken cancellationToken)
|
||||
{
|
||||
// 修改人: Meiam
|
||||
// 修改时间: 2025-12-22
|
||||
@@ -253,19 +257,23 @@ namespace Jellyfin.MeiamSub.Thunder
|
||||
|
||||
using var httpClient = _httpClientFactory.CreateClient(Name);
|
||||
|
||||
var response = await httpClient.SendAsync(options);
|
||||
using var response = await httpClient.SendAsync(options, cancellationToken);
|
||||
|
||||
_logger.LogInformation($"{Name} DownloadSub | Response -> {response.StatusCode}");
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
var stream = await response.Content.ReadAsStreamAsync();
|
||||
var format = NormalizeFormat(downloadSub.Format)
|
||||
?? throw new InvalidDataException($"Unsupported subtitle format: {downloadSub.Format}");
|
||||
var data = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
ValidateSubtitleData(data, format, response.Content.Headers.ContentType?.MediaType);
|
||||
var stream = new MemoryStream(data, writable: false);
|
||||
|
||||
return new SubtitleResponse()
|
||||
{
|
||||
Language = downloadSub.Language,
|
||||
IsForced = false,
|
||||
Format = downloadSub.Format,
|
||||
Format = format,
|
||||
Stream = stream,
|
||||
};
|
||||
}
|
||||
@@ -275,7 +283,7 @@ namespace Jellyfin.MeiamSub.Thunder
|
||||
_logger.LogError(ex, "{Provider} DownloadSub | Exception -> [{Type}] {Message}", Name, ex.GetType().Name, ex.Message);
|
||||
}
|
||||
|
||||
return new SubtitleResponse();
|
||||
throw new InvalidDataException($"{Name} subtitle download failed.");
|
||||
|
||||
}
|
||||
#endregion
|
||||
@@ -323,6 +331,44 @@ namespace Jellyfin.MeiamSub.Thunder
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeFormat(string format)
|
||||
{
|
||||
var value = format?.Trim().TrimStart('.').ToLowerInvariant();
|
||||
return value == SRT || value == ASS || value == SSA ? value : null;
|
||||
}
|
||||
|
||||
private static void ValidateSubtitleData(byte[] data, string format, string mediaType)
|
||||
{
|
||||
if (data == null || data.Length < 8)
|
||||
{
|
||||
throw new InvalidDataException("Subtitle response is empty.");
|
||||
}
|
||||
|
||||
if (data[0] == (byte)'P' && data[1] == (byte)'K' ||
|
||||
data.Length >= 7 && Encoding.ASCII.GetString(data, 0, 7) == "Rar!\u001a\u0007")
|
||||
{
|
||||
throw new InvalidDataException("Compressed subtitle responses are not supported by Thunder.");
|
||||
}
|
||||
|
||||
var sample = Encoding.UTF8.GetString(data, 0, Math.Min(data.Length, 4096)).TrimStart('\uFEFF', ' ', '\r', '\n', '\t');
|
||||
if (sample.StartsWith("<", StringComparison.Ordinal) ||
|
||||
sample.StartsWith("{", StringComparison.Ordinal) ||
|
||||
sample.StartsWith("[", StringComparison.Ordinal) ||
|
||||
mediaType?.IndexOf("html", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
mediaType?.IndexOf("json", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
throw new InvalidDataException("Thunder returned an error document instead of a subtitle.");
|
||||
}
|
||||
|
||||
var valid = format == SRT
|
||||
? sample.Contains("-->", StringComparison.Ordinal)
|
||||
: sample.Contains("[Script Info]", StringComparison.OrdinalIgnoreCase) || sample.Contains("Dialogue:", StringComparison.OrdinalIgnoreCase);
|
||||
if (!valid)
|
||||
{
|
||||
throw new InvalidDataException($"Downloaded content is not a valid {format} subtitle.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 规范化语言代码
|
||||
/// </summary>
|
||||
@@ -357,11 +403,20 @@ namespace Jellyfin.MeiamSub.Thunder
|
||||
/// </summary>
|
||||
/// <param name="filePath">文件路径</param>
|
||||
/// <returns>计算得到的 CID 字符串</returns>
|
||||
private async Task<string> GetCidByFileAsync(string filePath)
|
||||
private async Task<string> GetCidByFileAsync(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
// 修改人: Meiam
|
||||
// 修改时间: 2025-12-22
|
||||
// 备注: 改造为异步方法,优化 I/O 性能,使用 SHA1.Create() 替代旧 API,并增加 using 语句释放资源
|
||||
// 修改时间: 2026-06-11
|
||||
// 备注: 增加网络路径拦截,避免 FileStream 打开非本地路径抛出异常
|
||||
if (string.IsNullOrEmpty(filePath) ||
|
||||
filePath.Contains("://") ||
|
||||
filePath.StartsWith("smb://", StringComparison.OrdinalIgnoreCase) ||
|
||||
filePath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||
filePath.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning("Network path detected, skip hash calculation: {Path}", filePath);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous))
|
||||
{
|
||||
@@ -371,16 +426,16 @@ namespace Jellyfin.MeiamSub.Thunder
|
||||
var buffer = new byte[0xf000];
|
||||
if (fileSize < 0xf000)
|
||||
{
|
||||
await stream.ReadExactlyAsync(buffer, 0, (int)fileSize);
|
||||
await stream.ReadExactlyAsync(buffer, 0, (int)fileSize, cancellationToken);
|
||||
buffer = sha1.ComputeHash(buffer, 0, (int)fileSize);
|
||||
}
|
||||
else
|
||||
{
|
||||
await stream.ReadExactlyAsync(buffer, 0, 0x5000);
|
||||
await stream.ReadExactlyAsync(buffer, 0, 0x5000, cancellationToken);
|
||||
stream.Seek(fileSize / 3, SeekOrigin.Begin);
|
||||
await stream.ReadExactlyAsync(buffer, 0x5000, 0x5000);
|
||||
await stream.ReadExactlyAsync(buffer, 0x5000, 0x5000, cancellationToken);
|
||||
stream.Seek(fileSize - 0x5000, SeekOrigin.Begin);
|
||||
await stream.ReadExactlyAsync(buffer, 0xa000, 0x5000);
|
||||
await stream.ReadExactlyAsync(buffer, 0xa000, 0x5000, cancellationToken);
|
||||
|
||||
buffer = sha1.ComputeHash(buffer, 0, 0xf000);
|
||||
}
|
||||
@@ -395,4 +450,4 @@ namespace Jellyfin.MeiamSub.Thunder
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"name": "MeiamSub.Thunder",
|
||||
"owner": "Meiam",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"timestamp": "2026-06-11T03:17:06.0000000Z",
|
||||
"version": "1.0.14.0",
|
||||
"timestamp": "2026-08-07T07:11:29.0000000Z",
|
||||
"version": "1.0.16.0",
|
||||
"status": "Active",
|
||||
"imagePath": "thumb.png"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.10.34916.146
|
||||
@@ -7,12 +7,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Emby.MeiamSub.DevTool", "Em
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.MeiamSub.Thunder", "Jellyfin.MeiamSub.Thunder\Jellyfin.MeiamSub.Thunder.csproj", "{4676AA1B-CC6C-42DC-BD69-6A293BAE8823}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.MeiamSub.Shooter", "Jellyfin.MeiamSub.Shooter\Jellyfin.MeiamSub.Shooter.csproj", "{8F77E155-9A91-4882-82E8-E8D69FECD246}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Emby.MeiamSub.Shooter", "Emby.MeiamSub.Shooter\Emby.MeiamSub.Shooter.csproj", "{F2636BCB-111D-4F22-AA06-8852E96D05C4}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Emby.MeiamSub.Thunder", "Emby.MeiamSub.Thunder\Emby.MeiamSub.Thunder.csproj", "{96F4C65C-11B1-46F4-B343-115168688C2D}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.MeiamSub.Assrt", "Jellyfin.MeiamSub.Assrt\Jellyfin.MeiamSub.Assrt.csproj", "{E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Emby.MeiamSub.Assrt", "Emby.MeiamSub.Assrt\Emby.MeiamSub.Assrt.csproj", "{D276A8D0-4B17-4EEF-94B6-61D646DE5A22}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -27,18 +27,18 @@ Global
|
||||
{4676AA1B-CC6C-42DC-BD69-6A293BAE8823}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4676AA1B-CC6C-42DC-BD69-6A293BAE8823}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4676AA1B-CC6C-42DC-BD69-6A293BAE8823}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{8F77E155-9A91-4882-82E8-E8D69FECD246}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{8F77E155-9A91-4882-82E8-E8D69FECD246}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8F77E155-9A91-4882-82E8-E8D69FECD246}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8F77E155-9A91-4882-82E8-E8D69FECD246}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F2636BCB-111D-4F22-AA06-8852E96D05C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F2636BCB-111D-4F22-AA06-8852E96D05C4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F2636BCB-111D-4F22-AA06-8852E96D05C4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F2636BCB-111D-4F22-AA06-8852E96D05C4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{96F4C65C-11B1-46F4-B343-115168688C2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{96F4C65C-11B1-46F4-B343-115168688C2D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{96F4C65C-11B1-46F4-B343-115168688C2D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{96F4C65C-11B1-46F4-B343-115168688C2D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D276A8D0-4B17-4EEF-94B6-61D646DE5A22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D276A8D0-4B17-4EEF-94B6-61D646DE5A22}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D276A8D0-4B17-4EEF-94B6-61D646DE5A22}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D276A8D0-4B17-4EEF-94B6-61D646DE5A22}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
## 📣 广告时间
|
||||
|
||||
> **搬瓦工 $99 年付**:建站神器重出江湖,THE PLAN V1 传家宝套餐,18机房随意切换。
|
||||
> **循环优惠码**:`BWHCCNCXVV` (6.77%)
|
||||
> **循环优惠码**:`NODESEEK2026` (6.77%)
|
||||
> **[直达通道 (传家宝套餐)](https://bwh88.net/aff.php?aff=117&pid=87)**
|
||||
|
||||
## ✨ 核心特性
|
||||
@@ -31,11 +31,13 @@
|
||||
| 组件名称 | 适用平台 | 目标框架 | 说明 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Emby.MeiamSub.Thunder** | Emby | .NET Standard 2.1 | 迅雷看看字幕插件 |
|
||||
| **Emby.MeiamSub.Shooter** | Emby | .NET Standard 2.1 | 射手影音字幕插件 |
|
||||
| **Emby.MeiamSub.Assrt** | Emby | .NET Standard 2.1 | Assrt 字幕插件 |
|
||||
| **Jellyfin.MeiamSub.Thunder** | Jellyfin | .NET 9.0 | 迅雷看看字幕插件 (现代 DI 架构) |
|
||||
| **Jellyfin.MeiamSub.Shooter** | Jellyfin | .NET 9.0 | 射手影音字幕插件 (现代 DI 架构) |
|
||||
| **Jellyfin.MeiamSub.Assrt** | Jellyfin | .NET 9.0 | Assrt 字幕插件 |
|
||||
| **Emby.MeiamSub.DevTool** | 开发调试 | .NET 8.0 | 哈希算法测试与 API 模拟工具 |
|
||||
|
||||
> **插件状态:** 当前仅发布 Assrt 和 Thunder。Shooter 的旧版哈希接口已很少返回有效字幕,自 v1.0.16.0 起停止编译和发布。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速安装
|
||||
|
||||
64
publish.ps1
64
publish.ps1
@@ -8,6 +8,9 @@ param (
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$EmbyVersion,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Notes,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$PublishToGitHub
|
||||
)
|
||||
@@ -23,24 +26,24 @@ Write-Host "Current package timestamp (UTC): $Timestamp" -ForegroundColor Gray
|
||||
# Define paths
|
||||
$ThunderCsproj = "Jellyfin.MeiamSub.Thunder\Jellyfin.MeiamSub.Thunder.csproj"
|
||||
$ThunderMeta = "Jellyfin.MeiamSub.Thunder\meta.json"
|
||||
$ShooterCsproj = "Jellyfin.MeiamSub.Shooter\Jellyfin.MeiamSub.Shooter.csproj"
|
||||
$ShooterMeta = "Jellyfin.MeiamSub.Shooter\meta.json"
|
||||
$AssrtCsproj = "Jellyfin.MeiamSub.Assrt\Jellyfin.MeiamSub.Assrt.csproj"
|
||||
$AssrtMeta = "Jellyfin.MeiamSub.Assrt\meta.json"
|
||||
$EmbyThunderCsproj = "Emby.MeiamSub.Thunder\Emby.MeiamSub.Thunder.csproj"
|
||||
$EmbyShooterCsproj = "Emby.MeiamSub.Shooter\Emby.MeiamSub.Shooter.csproj"
|
||||
$EmbyAssrtCsproj = "Emby.MeiamSub.Assrt\Emby.MeiamSub.Assrt.csproj"
|
||||
|
||||
# Update NuGet package version if specified
|
||||
if ($JellyfinVersion) {
|
||||
Write-Host "Upgrading Jellyfin.Controller NuGet dependency to $JellyfinVersion..." -ForegroundColor Yellow
|
||||
(Get-Content $ThunderCsproj) -replace '<PackageReference Include="Jellyfin.Controller" Version="[^"]+" />', "<PackageReference Include=`"Jellyfin.Controller`" Version=`"$JellyfinVersion`" />" | Set-Content $ThunderCsproj
|
||||
(Get-Content $ShooterCsproj) -replace '<PackageReference Include="Jellyfin.Controller" Version="[^"]+" />', "<PackageReference Include=`"Jellyfin.Controller`" Version=`"$JellyfinVersion`" />" | Set-Content $ShooterCsproj
|
||||
(Get-Content $AssrtCsproj) -replace '<PackageReference Include="Jellyfin.Controller" Version="[^"]+" />', "<PackageReference Include=`"Jellyfin.Controller`" Version=`"$JellyfinVersion`" />" | Set-Content $AssrtCsproj
|
||||
}
|
||||
|
||||
if ($EmbyVersion) {
|
||||
Write-Host "Upgrading MediaBrowser NuGet dependencies to $EmbyVersion..." -ForegroundColor Yellow
|
||||
(Get-Content $EmbyThunderCsproj) -replace '<PackageReference Include="MediaBrowser.Common" Version="[^"]+" />', "<PackageReference Include=`"MediaBrowser.Common`" Version=`"$EmbyVersion`" />" `
|
||||
-replace '<PackageReference Include="MediaBrowser.Server.Core" Version="[^"]+" />', "<PackageReference Include=`"MediaBrowser.Server.Core`" Version=`"$EmbyVersion`" />" | Set-Content $EmbyThunderCsproj
|
||||
(Get-Content $EmbyShooterCsproj) -replace '<PackageReference Include="MediaBrowser.Common" Version="[^"]+" />', "<PackageReference Include=`"MediaBrowser.Common`" Version=`"$EmbyVersion`" />" `
|
||||
-replace '<PackageReference Include="MediaBrowser.Server.Core" Version="[^"]+" />', "<PackageReference Include=`"MediaBrowser.Server.Core`" Version=`"$EmbyVersion`" />" | Set-Content $EmbyShooterCsproj
|
||||
(Get-Content $EmbyAssrtCsproj) -replace '<PackageReference Include="MediaBrowser.Common" Version="[^"]+" />', "<PackageReference Include=`"MediaBrowser.Common`" Version=`"$EmbyVersion`" />" `
|
||||
-replace '<PackageReference Include="MediaBrowser.Server.Core" Version="[^"]+" />', "<PackageReference Include=`"MediaBrowser.Server.Core`" Version=`"$EmbyVersion`" />" | Set-Content $EmbyAssrtCsproj
|
||||
}
|
||||
|
||||
Write-Host "Updating version and timestamp in project files..." -ForegroundColor Yellow
|
||||
@@ -52,23 +55,23 @@ Write-Host "Updating version and timestamp in project files..." -ForegroundColor
|
||||
(Get-Content $ThunderMeta) -replace '"version":\s*"[\d\.]+"', "`"version`": `"$Version`"" `
|
||||
-replace '"timestamp":\s*"[^"]+"', "`"timestamp`": `"$Timestamp`"" | Set-Content $ThunderMeta
|
||||
|
||||
# Update Jellyfin Shooter
|
||||
(Get-Content $ShooterCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
|
||||
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" `
|
||||
-replace '<FileVersion>[\d\.]+</FileVersion>', "<FileVersion>$Version</FileVersion>" | Set-Content $ShooterCsproj
|
||||
# Update Jellyfin Assrt
|
||||
(Get-Content $AssrtCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
|
||||
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" `
|
||||
-replace '<FileVersion>[\d\.]+</FileVersion>', "<FileVersion>$Version</FileVersion>" | Set-Content $AssrtCsproj
|
||||
|
||||
(Get-Content $ShooterMeta) -replace '"version":\s*"[\d\.]+"', "`"version`": `"$Version`"" `
|
||||
-replace '"timestamp":\s*"[^"]+"', "`"timestamp`": `"$Timestamp`"" | Set-Content $ShooterMeta
|
||||
(Get-Content $AssrtMeta) -replace '"version":\s*"[\d\.]+"', "`"version`": `"$Version`"" `
|
||||
-replace '"timestamp":\s*"[^"]+"', "`"timestamp`": `"$Timestamp`"" | Set-Content $AssrtMeta
|
||||
|
||||
# Update Emby Thunder
|
||||
(Get-Content $EmbyThunderCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
|
||||
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" `
|
||||
-replace '<FileVersion>[\d\.]+</FileVersion>', "<FileVersion>$Version</FileVersion>" | Set-Content $EmbyThunderCsproj
|
||||
|
||||
# Update Emby Shooter
|
||||
(Get-Content $EmbyShooterCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
|
||||
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" `
|
||||
-replace '<FileVersion>[\d\.]+</FileVersion>', "<FileVersion>$Version</FileVersion>" | Set-Content $EmbyShooterCsproj
|
||||
# Update Emby Assrt
|
||||
(Get-Content $EmbyAssrtCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
|
||||
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" `
|
||||
-replace '<FileVersion>[\d\.]+</FileVersion>', "<FileVersion>$Version</FileVersion>" | Set-Content $EmbyAssrtCsproj
|
||||
|
||||
Write-Host "Versions and timestamps updated successfully!" -ForegroundColor Green
|
||||
|
||||
@@ -80,29 +83,30 @@ dotnet build MeiamSubtitles.sln -c Release
|
||||
Write-Host "Packaging zip archives..." -ForegroundColor Yellow
|
||||
$TempDir = "Release\temp_pack"
|
||||
$EmbyPackDir = "$TempDir\Emby"
|
||||
$JellyfinShooterPackDir = "$TempDir\Jellyfin\Jellyfin.MeiamSub.Shooter_$Version"
|
||||
$JellyfinThunderPackDir = "$TempDir\Jellyfin\Jellyfin.MeiamSub.Thunder_$Version"
|
||||
$JellyfinAssrtPackDir = "$TempDir\Jellyfin\Jellyfin.MeiamSub.Assrt_$Version"
|
||||
|
||||
# Clean temp directories
|
||||
if (Test-Path $TempDir) { Remove-Item -Recurse -Force $TempDir }
|
||||
New-Item -ItemType Directory -Force -Path $EmbyPackDir | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $JellyfinShooterPackDir | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $JellyfinThunderPackDir | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $JellyfinAssrtPackDir | Out-Null
|
||||
|
||||
# Copy Emby DLLs
|
||||
Copy-Item Release\Emby.MeiamSub.Shooter.dll $EmbyPackDir\
|
||||
Copy-Item Release\Emby.MeiamSub.Thunder.dll $EmbyPackDir\
|
||||
|
||||
# Copy Jellyfin Shooter artifacts
|
||||
Copy-Item Release\Jellyfin.MeiamSub.Shooter.dll $JellyfinShooterPackDir\
|
||||
Copy-Item Jellyfin.MeiamSub.Shooter\meta.json $JellyfinShooterPackDir\
|
||||
Copy-Item Jellyfin.MeiamSub.Shooter\thumb.png $JellyfinShooterPackDir\
|
||||
Copy-Item Release\Emby.MeiamSub.Assrt.dll $EmbyPackDir\
|
||||
|
||||
# Copy Jellyfin Thunder artifacts
|
||||
Copy-Item Release\Jellyfin.MeiamSub.Thunder.dll $JellyfinThunderPackDir\
|
||||
Copy-Item Jellyfin.MeiamSub.Thunder\meta.json $JellyfinThunderPackDir\
|
||||
Copy-Item Jellyfin.MeiamSub.Thunder\thumb.png $JellyfinThunderPackDir\
|
||||
|
||||
# Copy Jellyfin Assrt artifacts
|
||||
Copy-Item Release\Jellyfin.MeiamSub.Assrt.dll $JellyfinAssrtPackDir\
|
||||
Copy-Item Release\dependencies\Jellyfin\* $JellyfinAssrtPackDir\
|
||||
Copy-Item Jellyfin.MeiamSub.Assrt\meta.json $JellyfinAssrtPackDir\
|
||||
Copy-Item Jellyfin.MeiamSub.Assrt\thumb.png $JellyfinAssrtPackDir\
|
||||
|
||||
# Archive target paths
|
||||
$EmbyZip = "Release\Emby_v$Version.zip"
|
||||
$JellyfinZip = "Release\Jellyfin_v$Version.zip"
|
||||
@@ -133,7 +137,17 @@ if ($PublishToGitHub) {
|
||||
git push origin master
|
||||
|
||||
Write-Host "Creating GitHub Release v$Version and uploading assets..." -ForegroundColor Yellow
|
||||
gh release create "v$Version" $EmbyZip $JellyfinZip --title "v$Version" --generate-notes
|
||||
if ($Notes) {
|
||||
$AutoNotes = gh release generate-notes --tag "v$Version"
|
||||
$NotesContent = "## Release Notes`n`n$Notes`n`n" + $AutoNotes
|
||||
$NotesFile = "Release\temp_notes.md"
|
||||
Set-Content -Path $NotesFile -Value $NotesContent
|
||||
|
||||
gh release create "v$Version" $EmbyZip $JellyfinZip --title "v$Version" --notes-file $NotesFile
|
||||
if (Test-Path $NotesFile) { Remove-Item -Force $NotesFile }
|
||||
} else {
|
||||
gh release create "v$Version" $EmbyZip $JellyfinZip --title "v$Version" --generate-notes
|
||||
}
|
||||
|
||||
Write-Host "=== GitHub Release finished successfully! ===" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user