Files
FATrace/FATrace.OEMApp/Services/JellyfinClient.cs
2025-10-10 17:54:53 +08:00

246 lines
9.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}