下载完成事件:触发后入库并启动 Jellyfin 轮询匹配

This commit is contained in:
2025-10-10 22:27:36 +08:00
parent 9036f967fc
commit bc39e38ac4
4 changed files with 328 additions and 17 deletions

View File

@@ -1,4 +1,4 @@
using FreeSql.DataAnnotations;
using FreeSql.DataAnnotations;
namespace FATrace.Model
{
@@ -23,13 +23,13 @@ namespace FATrace.Model
/// <summary>
/// 原料名称
/// </summary>
[Column(Name = "InBagCode", IsNullable = false, StringLength = 100)]
[Column(Name = "RawName", IsNullable = false, StringLength = 100)]
public string? RawName{ get; set; }
/// <summary>
/// 视频链接
/// </summary>
[Column(Name = "VideoUrl", IsNullable = false, StringLength = 100)]
[Column(Name = "VideoUrl", IsNullable = false, StringLength = 256)]
public string? VideoUrl{ get; set; }
/// <summary>

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8" ?>
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<appSettings>
@@ -15,6 +15,12 @@
<add key="NVRVideoSavePath" value="D:\" />
<add key="InsCheckPort" value="COM10" />
<add key="InsCheckRate" value="9600" />
<!-- Jellyfin 配置 -->
<add key="JellyfinBaseUrl" value="http://192.168.0.30:8096" />
<add key="JellyfinApiKey" value="" />
<add key="JellyfinParentId" value="" />
<add key="JellyfinPollIntervalMs" value="5000" />
<add key="JellyfinPollMaxWaitMs" value="600000" />
</appSettings>
</configuration>

View File

@@ -5,6 +5,10 @@ using FATrace.Model;
using LibVLCSharp.Shared;
using ReaLTaiizor.Forms;
using System.ComponentModel;
using FATrace.OEMApp.Services;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Concurrent;
namespace FATrace.OEMApp
{
@@ -88,6 +92,12 @@ namespace FATrace.OEMApp
/// </summary>
public string CurrentVideoPath { get; set; }
/// <summary>
/// 按文件名/路径去重,避免同一下载完成事件被重复处理
/// key: 文件名或路径(小写) value: 对应插入的 OEMRawUse.Id0 表示尚未完成插入)
/// </summary>
private readonly ConcurrentDictionary<string, long> _downloadProcessingKeys = new();
#endregion
@@ -132,6 +142,8 @@ namespace FATrace.OEMApp
{
HkCameraClient.NVRLoadVideoProcessEventHandler -= HkCameraClient_NVRLoadVideoProcessEventHandler;
HkCameraClient.NVRLoadVideoProcessEventHandler += HkCameraClient_NVRLoadVideoProcessEventHandler;
HkCameraClient.NVRLoadVideoCompleteEventHandler -= HkCameraClient_NVRLoadVideoCompleteEventHandler;
HkCameraClient.NVRLoadVideoCompleteEventHandler += HkCameraClient_NVRLoadVideoCompleteEventHandler;
// 进度轮询已在库方法内部自动启动,这里无需再次调用
HkCameraClient.StartDownloadProgressMonitor();
}
@@ -444,23 +456,73 @@ namespace FATrace.OEMApp
}
/// <summary>
/// Handles the event triggered when the NVR (Network Video Recorder) completes loading a video.
/// Video下载完成
/// 下载完成事件:触发后入库并启动 Jellyfin 轮询匹配
/// </summary>
/// <param name="sender">The source of the event. This can be <see langword="null"/> if the event is not raised by a specific object.</param>
/// <param name="e">A string containing information about the completed video load operation.</param>
/// <param name="sender"></param>
/// <param name="e"></param>
private void HkCameraClient_NVRLoadVideoCompleteEventHandler(object? sender, string e)
{
//先保存当前的信息
var returnValues = FSqlContext.FDb.Insert<OEMRawUse>(new OEMRawUse()
{
InBagCode=CurInBagCode,
RawName=CurInBagRawName,
User= CurUserName,
UrlState=false,
VideoUrl="",//暂时无法获取URL因为服务器还没有扫描到文件信息
}).ExecuteInserted();
// 计算用于去重的 key文件路径或文件名小写
var localNameOrPath = (!string.IsNullOrWhiteSpace(e) && (e.Contains("\\") || e.Contains("/") || e.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase)))
? e
: this.CurrentVideoPath;
var key = (System.IO.Path.GetFileName(localNameOrPath) ?? localNameOrPath).ToLowerInvariant();
// 若同一 key 已在处理中,则忽略本次回调
if (!_downloadProcessingKeys.TryAdd(key, 0))
{
System.Diagnostics.Debug.WriteLine($"[NVRLoadVideoComplete] 正在处理相同文件,忽略: {key}");
return;
}
// 先保存当前的信息,记录主键 Id
var rawUse = new OEMRawUse()
{
InBagCode = CurInBagCode,
RawName = CurInBagRawName,
User = CurUserName,
UrlState = false,
VideoUrl = string.Empty
};
long rawUseId = 0;
try
{
rawUseId = FSqlContext.FDb.Insert<OEMRawUse>(rawUse).ExecuteIdentity();
_downloadProcessingKeys[key] = rawUseId;
}
catch (Exception ex)
{
_downloadProcessingKeys.TryRemove(key, out _);
System.Diagnostics.Debug.WriteLine($"[NVRLoadVideoComplete] 插入 OEMRawUse 失败: {ex.Message}");
return;
}
// 后台执行,避免阻塞 UI 线程
Task.Run(async () =>
{
try
{
var monitor = new JellyfinMonitorService();
await monitor.MonitorAndUpdateAfterDownloadAsync(
oemRawUseId: rawUseId,
videoLocalPathOrName: localNameOrPath,
code: CurInBagCode,
rawName: CurInBagRawName,
userName: CurUserName,
cancellationToken: CancellationToken.None
).ConfigureAwait(false);
}
catch (Exception ex1)
{
System.Diagnostics.Debug.WriteLine($"[JellyfinMonitor] 异常: {ex1.Message}");
}
finally
{
// 移除去重 key允许后续相同文件再次处理如需重试
_downloadProcessingKeys.TryRemove(key, out _);
}
});
}
}
}

View File

@@ -0,0 +1,243 @@
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();
}
}
}