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