新增: Assrt(伪射手)字幕提供程序,支持 Emby/Jellyfin 双平台

This commit is contained in:
Meiam
2026-06-15 15:19:15 +08:00
parent c4adc97bbf
commit 66ed8ff49c
22 changed files with 1392 additions and 7 deletions

View File

@@ -0,0 +1,429 @@
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.IO.Compression;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
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($"{Name} Search | Response Status -> {response.StatusCode} | ResponseBody -> {jsonString}");
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.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.name} | {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);
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() };
}
_logger.Info($"{Name} GetSubtitles | Downloading subtitle from: {targetSub.url}");
// 2. 下载字幕流 (手动接管重定向以确保每次重定向请求均带有正确的防盗链 Headers)
var handler = new HttpClientHandler { AllowAutoRedirect = false };
using var cleanHttpClient = new HttpClient(handler);
var currentUrl = targetSub.url;
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 压缩包并提取
var memoryStream = new MemoryStream();
await responseStream.CopyToAsync(memoryStream);
memoryStream.Position = 0;
bool isZip = false;
if (memoryStream.Length > 4)
{
byte[] zipHeader = new byte[4];
memoryStream.Read(zipHeader, 0, 4);
if (zipHeader[0] == 0x50 && zipHeader[1] == 0x4B && zipHeader[2] == 0x03 && zipHeader[3] == 0x04)
{
isZip = true;
}
memoryStream.Position = 0;
}
if (isZip || targetSub.filename?.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) == true)
{
_logger.Info($"{Name} GetSubtitles | ZIP file detected, extracting...");
try
{
using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read, true))
{
var entry = archive.Entries.FirstOrDefault(e =>
e.FullName.EndsWith(".srt", StringComparison.OrdinalIgnoreCase) ||
e.FullName.EndsWith(".ass", StringComparison.OrdinalIgnoreCase) ||
e.FullName.EndsWith(".ssa", StringComparison.OrdinalIgnoreCase));
if (entry != null)
{
var extractedStream = new MemoryStream();
using (var entryStream = entry.Open())
{
await entryStream.CopyToAsync(extractedStream);
}
extractedStream.Position = 0;
var extFormat = ExtractFormat(entry.FullName) ?? downloadInfo.Format;
_logger.Info($"{Name} GetSubtitles | Extracted file: '{entry.FullName}' with format: {extFormat}");
return new SubtitleResponse
{
Language = downloadInfo.Language,
IsForced = false,
Format = extFormat,
Stream = extractedStream
};
}
else
{
_logger.Warn($"{Name} GetSubtitles | No srt/ass/ssa file found inside the ZIP package.");
}
}
}
catch (Exception ex)
{
_logger.Error($"{Name} GetSubtitles | Failed to extract ZIP archive: {ex.Message}");
}
}
// 4. 不是 ZIP 或者解压失败时,默认返回原流
memoryStream.Position = 0;
return new SubtitleResponse
{
Language = downloadInfo.Language,
IsForced = false,
Format = downloadInfo.Format,
Stream = memoryStream
};
}
catch (Exception ex)
{
_logger.Error($"{Name} GetSubtitles | Exception: {ex.Message}");
}
return new SubtitleResponse { Language = downloadInfo?.Language ?? "chi", Format = downloadInfo?.Format ?? SRT, Stream = new MemoryStream() };
}
#region 辅助工具函数
public static string Base64Encode(string plainText)
{
var plainTextBytes = Encoding.UTF8.GetBytes(plainText);
return Convert.ToBase64String(plainTextBytes);
}
public static string Base64Decode(string base64EncodedData)
{
var base64EncodedBytes = Convert.FromBase64String(base64EncodedData);
return Encoding.UTF8.GetString(base64EncodedBytes);
}
protected string ExtractFormat(string text)
{
if (string.IsNullOrEmpty(text))
{
return null;
}
text = text.ToLower();
if (text.Contains(ASS)) return ASS;
if (text.Contains(SSA)) return SSA;
if (text.Contains(SRT)) return SRT;
return null;
}
private static string NormalizeLanguage(string language)
{
if (string.IsNullOrEmpty(language)) return language;
if (language.Equals("zh-CN", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zh-TW", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zh-HK", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zh", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zho", StringComparison.OrdinalIgnoreCase) ||
language.Equals("chi", StringComparison.OrdinalIgnoreCase))
{
return "chi";
}
if (language.Equals("en", StringComparison.OrdinalIgnoreCase) ||
language.Equals("eng", StringComparison.OrdinalIgnoreCase))
{
return "eng";
}
return language;
}
private static string MaskToken(string token)
{
if (string.IsNullOrEmpty(token)) return string.Empty;
if (token.Length <= 8) return "***";
return token.Substring(0, 4) + "***" + token.Substring(token.Length - 4);
}
#endregion
}
}

View File

@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyVersion>1.0.14.0</AssemblyVersion>
<FileVersion>1.0.14.0</FileVersion>
<Version>1.0.14.0</Version>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
</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" />
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
</Target>
</Project>

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -28,7 +28,7 @@
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Exec Command="Copy $(TargetDir)$(TargetFileName) $(SolutionDir)$(ConfigurationName)\$(TargetFileName) /y&#xD;&#xA;" />
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
</Target>
</Project>

View File

@@ -1,4 +1,4 @@
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Drawing;
using System;

View File

@@ -34,7 +34,7 @@
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Exec Command="Copy $(TargetDir)$(TargetFileName) $(SolutionDir)$(ConfigurationName)\$(TargetFileName) /y&#xD;&#xA;" />
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
</Target>

View File

@@ -0,0 +1,409 @@
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.IO.Compression;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
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($"{Name} Search | Response Status -> {response.StatusCode} | ResponseBody -> {jsonString}");
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} | {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);
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 响应中未找到字幕下载地址。");
}
_logger.LogInformation($"{Name} GetSubtitles | Downloading subtitle from: {targetSub.Url}");
// 2. 下载字幕流 (手动接管重定向以确保每次重定向请求均带有正确的防盗链 Headers)
var handler = new HttpClientHandler { AllowAutoRedirect = false };
using var cleanHttpClient = new HttpClient(handler);
var currentUrl = targetSub.Url;
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 压缩包
// 如果是 ZIP,解压出里面第一个符合格式要求的字幕流
var memoryStream = new MemoryStream();
await responseStream.CopyToAsync(memoryStream, cancellationToken);
memoryStream.Position = 0;
// 判断 ZIP 标志 (PK.. / 0x50 0x4B)
bool isZip = false;
if (memoryStream.Length > 4)
{
byte[] zipHeader = new byte[4];
await memoryStream.ReadExactlyAsync(zipHeader, 0, 4, cancellationToken);
if (zipHeader[0] == 0x50 && zipHeader[1] == 0x4B && zipHeader[2] == 0x03 && zipHeader[3] == 0x04)
{
isZip = true;
}
memoryStream.Position = 0;
}
if (isZip || targetSub.Filename?.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) == true)
{
_logger.LogInformation($"{Name} GetSubtitles | ZIP file detected, extracting...");
try
{
using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read, true);
var entry = archive.Entries.FirstOrDefault(e =>
e.FullName.EndsWith(".srt", StringComparison.OrdinalIgnoreCase) ||
e.FullName.EndsWith(".ass", StringComparison.OrdinalIgnoreCase) ||
e.FullName.EndsWith(".ssa", StringComparison.OrdinalIgnoreCase));
if (entry != null)
{
var extractedStream = new MemoryStream();
using (var entryStream = entry.Open())
{
await entryStream.CopyToAsync(extractedStream, cancellationToken);
}
extractedStream.Position = 0;
var extFormat = ExtractFormat(entry.FullName) ?? downloadInfo.Format;
_logger.LogInformation($"{Name} GetSubtitles | Extracted file: '{entry.FullName}' with format: {extFormat}");
return new SubtitleResponse
{
Language = downloadInfo.Language,
IsForced = false,
Format = extFormat,
Stream = extractedStream
};
}
else
{
_logger.LogWarning($"{Name} GetSubtitles | No srt/ass/ssa file found inside the ZIP package.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, $"{Name} GetSubtitles | Failed to extract ZIP archive: {ex.Message}");
}
}
// 4. 不是 ZIP 或者解压失败时,默认返回原流
memoryStream.Position = 0;
return new SubtitleResponse
{
Language = downloadInfo.Language,
IsForced = false,
Format = downloadInfo.Format,
Stream = memoryStream
};
}
catch (Exception ex)
{
_logger.LogError(ex, $"{Name} GetSubtitles | Exception: {ex.Message}");
throw;
}
}
#region 辅助工具函数
public static string Base64Encode(string plainText)
{
var plainTextBytes = Encoding.UTF8.GetBytes(plainText);
return Convert.ToBase64String(plainTextBytes);
}
public static string Base64Decode(string base64EncodedData)
{
var base64EncodedBytes = Convert.FromBase64String(base64EncodedData);
return Encoding.UTF8.GetString(base64EncodedBytes);
}
protected string ExtractFormat(string text)
{
if (string.IsNullOrEmpty(text))
{
return null;
}
text = text.ToLower();
if (text.Contains(ASS)) return ASS;
if (text.Contains(SSA)) return SSA;
if (text.Contains(SRT)) return SRT;
return null;
}
private static string NormalizeLanguage(string language)
{
if (string.IsNullOrEmpty(language)) return language;
if (language.Equals("zh-CN", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zh-TW", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zh-HK", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zh", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zho", StringComparison.OrdinalIgnoreCase) ||
language.Equals("chi", StringComparison.OrdinalIgnoreCase))
{
return "chi";
}
if (language.Equals("en", StringComparison.OrdinalIgnoreCase) ||
language.Equals("eng", StringComparison.OrdinalIgnoreCase))
{
return "eng";
}
return language;
}
private static string MaskToken(string token)
{
if (string.IsNullOrEmpty(token)) return string.Empty;
if (token.Length <= 8) return "***";
return token.Substring(0, 4) + "***" + token.Substring(token.Length - 4);
}
#endregion
}
}

View File

@@ -0,0 +1,26 @@
using MediaBrowser.Model.Plugins;
namespace Jellyfin.MeiamSub.Assrt.Configuration
{
/// <summary>
/// 插件配置类
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// Assrt API 鉴权 Token
/// </summary>
public string AssrtApiToken { get; set; }
/// <summary>
/// 是否使用元数据中的剧集名称和季集编号搜索字幕
/// </summary>
public bool EnableUseMetadata { get; set; }
public PluginConfiguration()
{
AssrtApiToken = string.Empty;
EnableUseMetadata = true; // 默认为 true,因为 Assrt 是基于文本搜索,元数据匹配非常合适
}
}
}

View File

@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<title>MeiamSub.Assrt</title>
</head>
<body>
<div id="MeiamSubAssrtConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
data-require="emby-checkbox,emby-input">
<div data-role="content">
<div class="content-primary">
<form id="MeiamSubAssrtConfigForm">
<div class="verticalSection">
<h2 class="sectionTitle">伪射手字幕搜索设置 (Assrt)</h2>
<div class="inputContainer">
<label class="inputLabel" for="AssrtApiToken">Assrt API Token</label>
<input id="AssrtApiToken" name="AssrtApiToken" type="text" is="emby-input" placeholder="请输入 Assrt.net API Token" />
<div class="fieldDescription">
请在 <a href="https://assrt.net" target="_blank">assrt.net</a> 注册并获取你的 32 位 API Token。
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableUseMetadata" name="EnableUseMetadata" type="checkbox"
is="emby-checkbox" />
<span>使用元数据搜索字幕</span>
</label>
<div class="fieldDescription">
勾选此项后,使用元数据中的剧集名称和季集编号搜索字幕,而非文件名。
<br />
剧集将使用 "剧集名 S01E01" 格式;电影将使用影片名称。建议开启以提升模糊检索成功率。
</div>
</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>保存</span>
</button>
</div>
</form>
</div>
</div>
<script type="text/javascript">
var MeiamSubAssrtConfig = {
pluginUniqueId: 'E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0'
};
document.querySelector('#MeiamSubAssrtConfigPage')
.addEventListener('pageshow', function () {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(MeiamSubAssrtConfig.pluginUniqueId).then(function (config) {
document.querySelector('#AssrtApiToken').value = config.AssrtApiToken || '';
document.querySelector('#EnableUseMetadata').checked = config.EnableUseMetadata;
Dashboard.hideLoadingMsg();
});
});
document.querySelector('#MeiamSubAssrtConfigForm')
.addEventListener('submit', function (e) {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(MeiamSubAssrtConfig.pluginUniqueId).then(function (config) {
config.AssrtApiToken = document.querySelector('#AssrtApiToken').value;
config.EnableUseMetadata = document.querySelector('#EnableUseMetadata').checked;
ApiClient.updatePluginConfiguration(MeiamSubAssrtConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
e.preventDefault();
return false;
});
</script>
</div>
</body>
</html>

View File

@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Version>1.0.14.0</Version>
<AssemblyVersion>1.0.14.0</AssemblyVersion>
<FileVersion>1.0.14.0</FileVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<OutputPath>..\Release</OutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.11.11" />
</ItemGroup>
<ItemGroup>
<None Update="thumb.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Configuration\configPage.html" />
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
</Target>
</Project>

View File

@@ -0,0 +1,97 @@
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("name")]
public string Name { get; set; }
[JsonPropertyName("videoname")]
public string VideoName { get; set; }
[JsonPropertyName("subtype")]
public string Subtype { get; set; }
[JsonPropertyName("lang")]
public AssrtLanguageInfo Lang { get; set; }
}
public class AssrtLanguageInfo
{
[JsonPropertyName("desc")]
public string Desc { get; set; }
[JsonPropertyName("lang")]
public int Lang { get; set; }
}
/// <summary>
/// 字幕详情响应根结构
/// </summary>
public class AssrtDetailResponse
{
[JsonPropertyName("status")]
public int Status { get; set; }
[JsonPropertyName("sub")]
public AssrtDetailSubInfo Sub { get; set; }
}
public class AssrtDetailSubInfo
{
[JsonPropertyName("subs")]
public List<AssrtDetailItem> Subs { get; set; }
}
public class AssrtDetailItem
{
[JsonPropertyName("id")]
public object Id { get; set; }
[JsonPropertyName("filename")]
public string Filename { get; set; }
[JsonPropertyName("url")]
public string Url { get; set; }
}
/// <summary>
/// 插件内部传递的下载参数
/// </summary>
public class AssrtDownloadInfo
{
public string Id { get; set; }
public string Format { get; set; }
public string Language { get; set; }
public string TwoLetterISOLanguageName { get; set; }
}
}

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
{
"category": "Subtitles",
"description": "Download subtitles from Assrt (伪射手网)",
"guid": "E693CDAB-8E8C-4D12-BBDF-3FE564CD61F0",
"name": "MeiamSub.Assrt",
"owner": "Meiam",
"targetAbi": "10.11.0.0",
"timestamp": "2026-06-11T03:59:00.0000000Z",
"version": "1.0.14.0",
"status": "Active",
"imagePath": "thumb.png"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -22,7 +22,7 @@
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Exec Command="Copy $(TargetDir)$(TargetFileName) $(SolutionDir)$(ConfigurationName)\$(TargetFileName) /y&#xD;&#xA;" />
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
</Target>
</Project>

View File

@@ -1,4 +1,4 @@
using Jellyfin.MeiamSub.Shooter.Configuration;
using Jellyfin.MeiamSub.Shooter.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Serialization;

View File

@@ -26,7 +26,7 @@
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Exec Command="Copy $(TargetDir)$(TargetFileName) $(SolutionDir)$(ConfigurationName)\$(TargetFileName) /y&#xD;&#xA;" />
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(SolutionDir)$(ConfigurationName)\" SkipUnchangedFiles="false" />
</Target>
</Project>

View File

@@ -1,4 +1,4 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.10.34916.146
@@ -13,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Emby.MeiamSub.Shooter", "Em
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
@@ -39,6 +43,14 @@ Global
{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

View File

@@ -28,14 +28,18 @@ $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) {
@@ -44,6 +48,8 @@ if ($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
@@ -63,6 +69,14 @@ Write-Host "Updating version and timestamp in project files..." -ForegroundColor
(Get-Content $ShooterMeta) -replace '"version":\s*"[\d\.]+"', "`"version`": `"$Version`"" `
-replace '"timestamp":\s*"[^"]+"', "`"timestamp`": `"$Timestamp`"" | Set-Content $ShooterMeta
# Update Jellyfin Assrt
(Get-Content $AssrtCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" `
-replace '<FileVersion>[\d\.]+</FileVersion>', "<FileVersion>$Version</FileVersion>" | Set-Content $AssrtCsproj
(Get-Content $AssrtMeta) -replace '"version":\s*"[\d\.]+"', "`"version`": `"$Version`"" `
-replace '"timestamp":\s*"[^"]+"', "`"timestamp`": `"$Timestamp`"" | Set-Content $AssrtMeta
# Update Emby Thunder
(Get-Content $EmbyThunderCsproj) -replace '<Version>[\d\.]+</Version>', "<Version>$Version</Version>" `
-replace '<AssemblyVersion>[\d\.]+</AssemblyVersion>', "<AssemblyVersion>$Version</AssemblyVersion>" `
@@ -73,6 +87,11 @@ Write-Host "Updating version and timestamp in project files..." -ForegroundColor
-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
# 2. Compile solution
@@ -85,16 +104,19 @@ $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-Item Release\Emby.MeiamSub.Assrt.dll $EmbyPackDir\
# Copy Jellyfin Shooter artifacts
Copy-Item Release\Jellyfin.MeiamSub.Shooter.dll $JellyfinShooterPackDir\
@@ -106,6 +128,11 @@ 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 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"