246 lines
9.7 KiB
C#
246 lines
9.7 KiB
C#
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
|
||
{
|
||
/// <summary>
|
||
/// Jellyfin 客户端配置选项
|
||
/// </summary>
|
||
public sealed class JellyfinClientOptions
|
||
{
|
||
/// <summary>
|
||
/// Jellyfin 基础地址,例如:http://192.168.0.30:8096
|
||
/// </summary>
|
||
public required string BaseUrl { get; init; }
|
||
|
||
/// <summary>
|
||
/// API 密钥(建议使用 Jellyfin 后台创建的密钥)。
|
||
/// </summary>
|
||
public required string ApiKey { get; init; }
|
||
|
||
/// <summary>
|
||
/// 超时时间,毫秒。默认 10000 毫秒。
|
||
/// </summary>
|
||
public int TimeoutMs { get; init; } = 10000;
|
||
|
||
/// <summary>
|
||
/// 是否将 api_key 作为查询参数传递。默认 true。
|
||
/// 若为 false,则通过请求头 X-Emby-Token 传递。
|
||
/// </summary>
|
||
public bool UseQueryApiKey { get; init; } = true;
|
||
|
||
/// <summary>
|
||
/// 自定义 User-Agent。若为空将使用默认。
|
||
/// </summary>
|
||
public string? UserAgent { get; init; }
|
||
}
|
||
|
||
/// <summary>
|
||
/// Jellyfin HTTP 异常
|
||
/// </summary>
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Jellyfin API 客户端封装(基于 RestSharp)
|
||
/// </summary>
|
||
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);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取 Jellyfin Items 列表。
|
||
/// </summary>
|
||
/// <param name="parentId">父级目录/库 ID</param>
|
||
/// <param name="sortBy">排序字段(默认 DateCreated)</param>
|
||
/// <param name="sortOrder">排序方向(Ascending/Descending,默认 Descending)</param>
|
||
/// <param name="limit">限制返回数量,默认 10</param>
|
||
/// <param name="includeItemTypes">包含的类型(例如 Movie, Series),默认 Movie</param>
|
||
/// <param name="cancellationToken">取消令牌</param>
|
||
public async Task<JellyfinItemsResponse> 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<JellyfinItemsResponse>(request, cancellationToken).ConfigureAwait(false);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 仅返回 Items 列表的便捷方法。
|
||
/// </summary>
|
||
public async Task<System.Collections.Generic.IReadOnlyList<JellyfinItem>> 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<JellyfinItem>();
|
||
}
|
||
|
||
private async Task<T> ExecuteAndDeserializeAsync<T>(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<T>(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,可在此释放。
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Jellyfin 客户端接口,便于后续依赖注入与单元测试。
|
||
/// </summary>
|
||
public interface IJellyfinClient
|
||
{
|
||
Task<JellyfinItemsResponse> GetItemsAsync(
|
||
string parentId,
|
||
string sortBy = "DateCreated",
|
||
string sortOrder = "Descending",
|
||
int? limit = 10,
|
||
string includeItemTypes = "Movie",
|
||
CancellationToken cancellationToken = default);
|
||
|
||
Task<System.Collections.Generic.IReadOnlyList<JellyfinItem>> GetItemsOnlyAsync(
|
||
string parentId,
|
||
string sortBy = "DateCreated",
|
||
string sortOrder = "Descending",
|
||
int? limit = 10,
|
||
string includeItemTypes = "Movie",
|
||
CancellationToken cancellationToken = default);
|
||
}
|
||
}
|