OEM初版
This commit is contained in:
245
FATrace.OEMApp/Services/JellyfinClient.cs
Normal file
245
FATrace.OEMApp/Services/JellyfinClient.cs
Normal file
@@ -0,0 +1,245 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user