下载完成事件:触发后入库并启动 Jellyfin 轮询匹配
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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.Id(0 表示尚未完成插入)
|
||||
/// </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 _);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
243
FATrace.OEMApp/Services/JellyfinMonitorService.cs
Normal file
243
FATrace.OEMApp/Services/JellyfinMonitorService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user