using System; using System.Diagnostics; using System.Net; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FATrace.OEMApp.Model.Jellyfin; using NLog; using RestSharp; namespace FATrace.OEMApp.Services { /// /// Jellyfin 客户端配置选项 /// public sealed class JellyfinClientOptions { /// /// Jellyfin 基础地址,例如:http://192.168.0.30:8096 /// public required string BaseUrl { get; init; } /// /// API 密钥(建议使用 Jellyfin 后台创建的密钥)。 /// public required string ApiKey { get; init; } /// /// 超时时间,毫秒。默认 10000 毫秒。 /// public int TimeoutMs { get; init; } = 10000; /// /// 是否将 api_key 作为查询参数传递。默认 true。 /// 若为 false,则通过请求头 X-Emby-Token 传递。 /// public bool UseQueryApiKey { get; init; } = true; /// /// 自定义 User-Agent。若为空将使用默认。 /// public string? UserAgent { get; init; } } /// /// Jellyfin HTTP 异常 /// public sealed class JellyfinHttpException : Exception { public HttpStatusCode StatusCode { get; } public string? ResponseContent { get; } public JellyfinHttpException(string message, HttpStatusCode statusCode, string? content, Exception? inner = null) : base(message, inner) { StatusCode = statusCode; ResponseContent = content; } } /// /// Jellyfin API 客户端封装(基于 RestSharp) /// public sealed class JellyfinClient : IJellyfinClient, IDisposable { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private readonly RestClient _client; private readonly JellyfinClientOptions _options; private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true }; public JellyfinClient(JellyfinClientOptions options) { _options = options ?? throw new ArgumentNullException(nameof(options)); if (string.IsNullOrWhiteSpace(_options.BaseUrl)) throw new ArgumentException("BaseUrl 不能为空", nameof(options)); if (string.IsNullOrWhiteSpace(_options.ApiKey)) throw new ArgumentException("ApiKey 不能为空", nameof(options)); if (!Uri.TryCreate(_options.BaseUrl, UriKind.Absolute, out var baseUri)) { throw new ArgumentException($"无效的 BaseUrl: {_options.BaseUrl}", nameof(options)); } var restOptions = new RestClientOptions(baseUri) { MaxTimeout = _options.TimeoutMs, ThrowOnAnyError = false, // UseUnsafeSerializer = false // 保持默认 System.Text.Json }; _client = new RestClient(restOptions); // 默认头 _client.AddDefaultHeader("Accept", "application/json"); _client.AddDefaultHeader("Accept-Charset", "utf-8"); if (!string.IsNullOrWhiteSpace(_options.UserAgent)) { _client.AddDefaultHeader("User-Agent", _options.UserAgent); } // 通过 Header 传递 Token(若未使用 Query) if (!_options.UseQueryApiKey) { _client.AddDefaultHeader("X-Emby-Token", _options.ApiKey); } } /// /// 获取 Jellyfin Items 列表。 /// /// 父级目录/库 ID /// 排序字段(默认 DateCreated) /// 排序方向(Ascending/Descending,默认 Descending) /// 限制返回数量,默认 10 /// 包含的类型(例如 Movie, Series),默认 Movie /// 取消令牌 public async Task GetItemsAsync( string parentId, string sortBy = "DateCreated", string sortOrder = "Descending", int? limit = 10, string includeItemTypes = "Movie", CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(parentId)) throw new ArgumentException("parentId 不能为空", nameof(parentId)); var request = new RestRequest("Items", Method.Get); request.AddQueryParameter("ParentId", parentId); if (!string.IsNullOrWhiteSpace(sortBy)) request.AddQueryParameter("SortBy", sortBy); if (!string.IsNullOrWhiteSpace(sortOrder)) request.AddQueryParameter("SortOrder", sortOrder); if (limit.HasValue && limit.Value > 0) request.AddQueryParameter("Limit", limit.Value.ToString()); if (!string.IsNullOrWhiteSpace(includeItemTypes)) request.AddQueryParameter("IncludeItemTypes", includeItemTypes); if (_options.UseQueryApiKey) { request.AddQueryParameter("api_key", _options.ApiKey); } return await ExecuteAndDeserializeAsync(request, cancellationToken).ConfigureAwait(false); } /// /// 仅返回 Items 列表的便捷方法。 /// public async Task> GetItemsOnlyAsync( string parentId, string sortBy = "DateCreated", string sortOrder = "Descending", int? limit = 10, string includeItemTypes = "Movie", CancellationToken cancellationToken = default) { var resp = await GetItemsAsync(parentId, sortBy, sortOrder, limit, includeItemTypes, cancellationToken).ConfigureAwait(false); return resp.Items ?? new System.Collections.Generic.List(); } private async Task ExecuteAndDeserializeAsync(RestRequest request, CancellationToken ct) { var sw = Stopwatch.StartNew(); try { Logger.Info("Jellyfin 请求: {Method} {Resource}", request.Method, request.Resource); var response = await _client.ExecuteAsync(request, ct).ConfigureAwait(false); sw.Stop(); if (!response.IsSuccessful) { Logger.Warn("Jellyfin 响应失败: Status={StatusCode}, Elapsed={Elapsed}ms, ContentLength={Length}", (int)response.StatusCode, sw.ElapsedMilliseconds, response.Content?.Length ?? 0); throw new JellyfinHttpException( $"请求失败,HTTP {(int)response.StatusCode} {response.StatusDescription}", response.StatusCode, response.Content, response.ErrorException); } if (string.IsNullOrWhiteSpace(response.Content)) { throw new JellyfinHttpException("响应内容为空", response.StatusCode, null); } var result = JsonSerializer.Deserialize(response.Content, _jsonOptions); if (result == null) { throw new JellyfinHttpException("响应反序列化失败,返回结果为 null", response.StatusCode, response.Content); } Logger.Debug("Jellyfin 调用成功: {Method} {Resource}, Elapsed={Elapsed}ms", request.Method, request.Resource, sw.ElapsedMilliseconds); return result; } catch (OperationCanceledException) { sw.Stop(); Logger.Warn("Jellyfin 请求被取消: {Resource}, Elapsed={Elapsed}ms", request.Resource, sw.ElapsedMilliseconds); throw; } catch (JellyfinHttpException) { // 已包含上下文,直接抛出 throw; } catch (Exception ex) { sw.Stop(); Logger.Error(ex, "Jellyfin 请求异常: {Resource}, Elapsed={Elapsed}ms", request.Resource, sw.ElapsedMilliseconds); throw new JellyfinHttpException("请求执行异常", HttpStatusCode.InternalServerError, null, ex); } } public void Dispose() { // RestClient 在新版本基于 HttpClient,通常无需显式释放。 // 如需自定义 HttpMessageHandler,可在此释放。 } } /// /// Jellyfin 客户端接口,便于后续依赖注入与单元测试。 /// public interface IJellyfinClient { Task GetItemsAsync( string parentId, string sortBy = "DateCreated", string sortOrder = "Descending", int? limit = 10, string includeItemTypes = "Movie", CancellationToken cancellationToken = default); Task> GetItemsOnlyAsync( string parentId, string sortBy = "DateCreated", string sortOrder = "Descending", int? limit = 10, string includeItemTypes = "Movie", CancellationToken cancellationToken = default); } }