diff --git a/FATrace.Model/OEMRawUse.cs b/FATrace.Model/OEMRawUse.cs
index 3fa0e92..1661b66 100644
--- a/FATrace.Model/OEMRawUse.cs
+++ b/FATrace.Model/OEMRawUse.cs
@@ -1,4 +1,4 @@
-using FreeSql.DataAnnotations;
+using FreeSql.DataAnnotations;
namespace FATrace.Model
{
@@ -23,13 +23,13 @@ namespace FATrace.Model
///
/// 原料名称
///
- [Column(Name = "InBagCode", IsNullable = false, StringLength = 100)]
+ [Column(Name = "RawName", IsNullable = false, StringLength = 100)]
public string? RawName{ get; set; }
///
/// 视频链接
///
- [Column(Name = "VideoUrl", IsNullable = false, StringLength = 100)]
+ [Column(Name = "VideoUrl", IsNullable = false, StringLength = 256)]
public string? VideoUrl{ get; set; }
///
diff --git a/FATrace.OEMApp/App.config b/FATrace.OEMApp/App.config
index 794f590..13db503 100644
--- a/FATrace.OEMApp/App.config
+++ b/FATrace.OEMApp/App.config
@@ -1,4 +1,4 @@
-
+
@@ -15,6 +15,12 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/FATrace.OEMApp/MainApp.cs b/FATrace.OEMApp/MainApp.cs
index df5aa45..11fc400 100644
--- a/FATrace.OEMApp/MainApp.cs
+++ b/FATrace.OEMApp/MainApp.cs
@@ -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
///
public string CurrentVideoPath { get; set; }
+ ///
+ /// 按文件名/路径去重,避免同一下载完成事件被重复处理
+ /// key: 文件名或路径(小写) value: 对应插入的 OEMRawUse.Id(0 表示尚未完成插入)
+ ///
+ private readonly ConcurrentDictionary _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
}
///
- /// Handles the event triggered when the NVR (Network Video Recorder) completes loading a video.
- /// Video下载完成
+ /// 下载完成事件:触发后入库并启动 Jellyfin 轮询匹配
///
- /// The source of the event. This can be if the event is not raised by a specific object.
- /// A string containing information about the completed video load operation.
+ ///
+ ///
private void HkCameraClient_NVRLoadVideoCompleteEventHandler(object? sender, string e)
{
- //先保存当前的信息
- var returnValues = FSqlContext.FDb.Insert(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(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 _);
+ }
+ });
}
+
}
}
diff --git a/FATrace.OEMApp/Services/JellyfinMonitorService.cs b/FATrace.OEMApp/Services/JellyfinMonitorService.cs
new file mode 100644
index 0000000..98f708a
--- /dev/null
+++ b/FATrace.OEMApp/Services/JellyfinMonitorService.cs
@@ -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
+{
+ ///
+ /// 负责在 Jellyfin 中轮询查找新导入的视频,并在找到后更新数据库
+ ///
+ 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);
+ }
+
+ ///
+ /// 根据下载完成的本地文件名(或路径)轮询 Jellyfin,找到后构建播放 URL,并更新 OEMRawUse 与其一对一的 VideoAction。
+ ///
+ /// 需要更新的 OEMRawUse Id
+ /// 本地视频文件路径或文件名
+ /// 业务条码(用于 VideoAction.Code)
+ /// 原料名称(业务字段)
+ /// 用户名称
+ /// 取消令牌
+ 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().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(action).ExecuteIdentity();
+
+ // 更新 OEMRawUse
+ db.Update()
+ .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 BuildNameCandidates(string? name)
+ {
+ var set = new HashSet(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 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();
+ }
+ }
+}