244 lines
9.9 KiB
C#
244 lines
9.9 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Text;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using FATrace.Com;
|
||
using FATrace.Model;
|
||
using NLog;
|
||
using FATrace.OEMApp;
|
||
|
||
namespace FATrace.OEMApp.Services
|
||
{
|
||
/// <summary>
|
||
/// 负责在 Jellyfin 中轮询查找新导入的视频,并在找到后更新数据库
|
||
/// </summary>
|
||
public sealed class JellyfinMonitorService
|
||
{
|
||
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
|
||
|
||
private readonly TimeSpan _pollInterval;
|
||
private readonly TimeSpan _maxWait;
|
||
|
||
public JellyfinMonitorService(TimeSpan? pollInterval = null, TimeSpan? maxWait = null)
|
||
{
|
||
// 支持从配置读取,也支持构造时传入覆盖
|
||
var cfgPoll = ConfigHelper.GetIntOrDefault("JellyfinPollIntervalMs", 5000);
|
||
var cfgMaxWait = ConfigHelper.GetIntOrDefault("JellyfinPollMaxWaitMs", 10 * 60 * 1000); // 10分钟
|
||
_pollInterval = pollInterval ?? TimeSpan.FromMilliseconds(cfgPoll);
|
||
_maxWait = maxWait ?? TimeSpan.FromMilliseconds(cfgMaxWait);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 根据下载完成的本地文件名(或路径)轮询 Jellyfin,找到后构建播放 URL,并更新 OEMRawUse 与其一对一的 VideoAction。
|
||
/// </summary>
|
||
/// <param name="oemRawUseId">需要更新的 OEMRawUse Id</param>
|
||
/// <param name="videoLocalPathOrName">本地视频文件路径或文件名</param>
|
||
/// <param name="code">业务条码(用于 VideoAction.Code)</param>
|
||
/// <param name="rawName">原料名称(业务字段)</param>
|
||
/// <param name="userName">用户名称</param>
|
||
/// <param name="cancellationToken">取消令牌</param>
|
||
public async Task MonitorAndUpdateAfterDownloadAsync(
|
||
long oemRawUseId,
|
||
string videoLocalPathOrName,
|
||
string? code,
|
||
string? rawName,
|
||
string? userName,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(videoLocalPathOrName))
|
||
{
|
||
Logger.Warn("[JellyfinMonitor] videoLocalPathOrName 为空,取消轮询。");
|
||
return;
|
||
}
|
||
|
||
// 读取 Jellyfin 配置,并进行健壮性校验
|
||
string? baseUrl = null, apiKey = null, parentId = null;
|
||
try { baseUrl = ConfigHelper.GetStringOrDefault("JellyfinBaseUrl", string.Empty); } catch { }
|
||
try { apiKey = ConfigHelper.GetStringOrDefault("JellyfinApiKey", string.Empty); } catch { }
|
||
try { parentId = ConfigHelper.GetStringOrDefault("JellyfinParentId", string.Empty); } catch { }
|
||
|
||
if (string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(parentId))
|
||
{
|
||
Logger.Warn("[JellyfinMonitor] 缺少必要配置,跳过轮询: BaseUrl={Base}, ApiKeySet={ApiKeySet}, ParentIdSet={Parent}",
|
||
baseUrl, !string.IsNullOrWhiteSpace(apiKey), !string.IsNullOrWhiteSpace(parentId));
|
||
return;
|
||
}
|
||
|
||
var jfClient = new JellyfinClient(new JellyfinClientOptions
|
||
{
|
||
BaseUrl = baseUrl,
|
||
ApiKey = apiKey,
|
||
UseQueryApiKey = true,
|
||
TimeoutMs = 10000,
|
||
UserAgent = "FATrace.OEMApp/1.0"
|
||
});
|
||
|
||
// 准备待匹配的名称候选
|
||
var localName = Path.GetFileNameWithoutExtension(videoLocalPathOrName);
|
||
var candidates = BuildNameCandidates(localName);
|
||
var deadline = DateTime.UtcNow + _maxWait;
|
||
|
||
Logger.Info("[JellyfinMonitor] 开始轮询,等待 Jellyfin 索引文件: {LocalName}", localName);
|
||
|
||
string? foundId = null;
|
||
string? foundName = null;
|
||
|
||
while (DateTime.UtcNow < deadline && !cancellationToken.IsCancellationRequested)
|
||
{
|
||
try
|
||
{
|
||
var items = await jfClient.GetItemsOnlyAsync(parentId, sortBy: "DateCreated", sortOrder: "Descending", limit: 30, includeItemTypes: "Video,Movie", cancellationToken).ConfigureAwait(false);
|
||
var match = items.FirstOrDefault(it => !string.IsNullOrWhiteSpace(it.Name) && NameMatches(candidates, it.Name));
|
||
if (match != null && !string.IsNullOrWhiteSpace(match.Id))
|
||
{
|
||
foundId = match.Id;
|
||
foundName = match.Name;
|
||
Logger.Info("[JellyfinMonitor] 找到匹配视频: {Name}, Id={Id}", foundName, foundId);
|
||
break;
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.Warn(ex, "[JellyfinMonitor] 轮询 Jellyfin 失败,将在 {Delay} 后重试", _pollInterval);
|
||
}
|
||
|
||
await Task.Delay(_pollInterval, cancellationToken).ConfigureAwait(false);
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(foundId))
|
||
{
|
||
Logger.Warn("[JellyfinMonitor] 超时未找到匹配视频: {LocalName}", localName);
|
||
return;
|
||
}
|
||
|
||
// 构建播放 URL(使用 query 传递 api_key)
|
||
var playUrl = BuildStreamUrl(baseUrl, foundId, apiKey);
|
||
|
||
// 插入或更新 VideoAction,并更新 OEMRawUse 绑定与 URL 字段(使用 ADO 事务,兼容当前 FreeSql 版本)
|
||
var db = FSqlContext.FDb;
|
||
var ado = db.Ado;
|
||
|
||
// 先获取目标 RawUse
|
||
var oem = await db.Select<OEMRawUse>().Where(x => x.Id == oemRawUseId).FirstAsync().ConfigureAwait(false);
|
||
if (oem == null)
|
||
{
|
||
Logger.Warn("[JellyfinMonitor] 未找到 OEMRawUse 记录,Id={Id}", oemRawUseId);
|
||
return;
|
||
}
|
||
|
||
// 创建 VideoAction 记录
|
||
var action = new VideoAction
|
||
{
|
||
Code = code,
|
||
User = userName,
|
||
VideoFilePath = GetLocalPathIfExists(videoLocalPathOrName),
|
||
VideoName = foundName ?? localName,
|
||
StartTime = DateTime.Now,
|
||
EndTime = DateTime.Now,
|
||
CreateTime = DateTime.Now
|
||
};
|
||
try
|
||
{
|
||
db.Ado.Transaction(() =>
|
||
{
|
||
var actionId = (long)db.Insert<VideoAction>(action).ExecuteIdentity();
|
||
|
||
// 更新 OEMRawUse
|
||
db.Update<OEMRawUse>()
|
||
.Set(a => new OEMRawUse
|
||
{
|
||
UrlState = true,
|
||
VideoUrl = playUrl,
|
||
VideoActionId = actionId,
|
||
RawName = rawName
|
||
})
|
||
.Where(a => a.Id == oemRawUseId)
|
||
.ExecuteAffrows();
|
||
});
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.Error(ex, "[JellyfinMonitor] 更新数据库失败(插入 VideoAction 或更新 OEMRawUse)");
|
||
return;
|
||
}
|
||
|
||
Logger.Info("[JellyfinMonitor] 已更新 OEMRawUse(Id={Id}):UrlState=true, VideoUrl={Url}", oemRawUseId, playUrl);
|
||
}
|
||
|
||
private static string? GetLocalPathIfExists(string input)
|
||
{
|
||
try
|
||
{
|
||
if (string.IsNullOrWhiteSpace(input)) return null;
|
||
if (File.Exists(input)) return input;
|
||
var path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, input);
|
||
return File.Exists(path) ? path : input; // 若无法确认存在,原样存储
|
||
}
|
||
catch
|
||
{
|
||
return input;
|
||
}
|
||
}
|
||
|
||
private static string BuildStreamUrl(string baseUrl, string itemId, string apiKey)
|
||
{
|
||
baseUrl = baseUrl?.TrimEnd('/') ?? string.Empty;
|
||
var sb = new StringBuilder();
|
||
sb.Append(baseUrl)
|
||
.Append("/Videos/")
|
||
.Append(itemId)
|
||
.Append("/stream.mp4")
|
||
.Append("?api_key=")
|
||
.Append(Uri.EscapeDataString(apiKey));
|
||
return sb.ToString();
|
||
}
|
||
|
||
private static HashSet<string> BuildNameCandidates(string? name)
|
||
{
|
||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
if (string.IsNullOrWhiteSpace(name)) return set;
|
||
|
||
var n = name.Trim();
|
||
set.Add(n);
|
||
set.Add(n.Replace('_', ' '));
|
||
set.Add(n.Replace(' ', '_'));
|
||
set.Add(RemoveNonAlphaNumeric(n));
|
||
set.Add(RemoveNonAlphaNumeric(n.Replace('_', ' ')));
|
||
set.Add(RemoveNonAlphaNumeric(n.Replace(' ', '_')));
|
||
|
||
return set;
|
||
}
|
||
|
||
private static bool NameMatches(HashSet<string> candidates, string? remoteName)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(remoteName)) return false;
|
||
if (candidates.Contains(remoteName)) return true;
|
||
|
||
var normalized = RemoveNonAlphaNumeric(remoteName);
|
||
if (candidates.Contains(normalized)) return true;
|
||
|
||
// 近似匹配:任意候选作为子串
|
||
foreach (var c in candidates)
|
||
{
|
||
if (string.IsNullOrEmpty(c)) continue;
|
||
if (remoteName.IndexOf(c, StringComparison.OrdinalIgnoreCase) >= 0) return true;
|
||
if (normalized.IndexOf(RemoveNonAlphaNumeric(c), StringComparison.OrdinalIgnoreCase) >= 0) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
private static string RemoveNonAlphaNumeric(string s)
|
||
{
|
||
var sb = new StringBuilder(s.Length);
|
||
foreach (var ch in s)
|
||
{
|
||
if (char.IsLetterOrDigit(ch)) sb.Append(char.ToUpperInvariant(ch));
|
||
}
|
||
return sb.ToString();
|
||
}
|
||
}
|
||
}
|