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