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(); + } + } +}