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