138 lines
6.6 KiB
C#
138 lines
6.6 KiB
C#
using Microsoft.Extensions.Options;
|
||
using System.Net;
|
||
using System.Net.Http.Headers;
|
||
using System.Net.Http.Json;
|
||
using System.Text.Json;
|
||
|
||
namespace Evergreen.Health.Application.Health.Integrations.MiAirX;
|
||
|
||
public sealed class MiAirXClient
|
||
{
|
||
private readonly HttpClient _httpClient;
|
||
private readonly MiAirXOptions _options;
|
||
private readonly SemaphoreSlim _loginLock = new(1, 1);
|
||
private string? _sessionToken;
|
||
|
||
public MiAirXClient(HttpClient httpClient, IOptions<MiAirXOptions> options)
|
||
{
|
||
_httpClient = httpClient;
|
||
_options = options.Value;
|
||
_httpClient.Timeout = TimeSpan.FromSeconds(Math.Clamp(_options.TimeoutSeconds, 3, 60));
|
||
}
|
||
|
||
public bool IsConfigured => _options.Enabled &&
|
||
!string.IsNullOrWhiteSpace(_options.BaseUrl) &&
|
||
!string.IsNullOrWhiteSpace(_options.DeviceId) &&
|
||
!string.IsNullOrWhiteSpace(_options.MusicUrl) &&
|
||
(!_options.RequireWebPassword || !string.IsNullOrWhiteSpace(_options.WebPassword));
|
||
|
||
public Task PlayAsync(CancellationToken cancellationToken = default)
|
||
=> PlayUrlAsync(_options.MusicUrl, cancellationToken);
|
||
|
||
public Task PlayUrlAsync(string musicUrl, CancellationToken cancellationToken = default)
|
||
{
|
||
if (!Uri.TryCreate(musicUrl, UriKind.Absolute, out var url) ||
|
||
url.Scheme is not ("http" or "https"))
|
||
throw new InvalidOperationException("MiAirX 音频 URL 未配置或格式无效");
|
||
return SendCommandAsync("api/play", new { did = _options.DeviceId, url = musicUrl }, cancellationToken);
|
||
}
|
||
|
||
public Task StopAsync(CancellationToken cancellationToken = default)
|
||
=> SendCommandAsync("api/stop", new { did = _options.DeviceId }, cancellationToken);
|
||
|
||
public Task SetVolumeAsync(int volume, CancellationToken cancellationToken = default)
|
||
{
|
||
if (volume is < 0 or > 100)
|
||
throw new InvalidOperationException("音箱音量应在 0 到 100 之间");
|
||
return SendCommandAsync("api/volume", new { did = _options.DeviceId, volume }, cancellationToken);
|
||
}
|
||
|
||
private async Task SendCommandAsync(string path, object payload, CancellationToken cancellationToken)
|
||
{
|
||
var baseUri = GetBaseUri();
|
||
if (!string.IsNullOrWhiteSpace(_options.WebPassword))
|
||
await EnsureLoginAsync(baseUri, cancellationToken);
|
||
|
||
using var response = await SendOnceAsync(baseUri, path, payload, cancellationToken);
|
||
if (response.StatusCode == HttpStatusCode.Unauthorized && !string.IsNullOrWhiteSpace(_options.WebPassword))
|
||
{
|
||
_sessionToken = null;
|
||
await EnsureLoginAsync(baseUri, cancellationToken);
|
||
using var retry = await SendOnceAsync(baseUri, path, payload, cancellationToken);
|
||
await EnsureCommandSucceededAsync(retry, cancellationToken);
|
||
return;
|
||
}
|
||
|
||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||
throw new InvalidOperationException("MiAirX 要求登录,请在后端配置 MiAirX 管理密码");
|
||
await EnsureCommandSucceededAsync(response, cancellationToken);
|
||
}
|
||
|
||
private async Task<HttpResponseMessage> SendOnceAsync(Uri baseUri, string path, object payload, CancellationToken cancellationToken)
|
||
{
|
||
using var request = new HttpRequestMessage(HttpMethod.Post, new Uri(baseUri, path))
|
||
{
|
||
Content = JsonContent.Create(payload)
|
||
};
|
||
if (!string.IsNullOrWhiteSpace(_sessionToken))
|
||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _sessionToken);
|
||
return await _httpClient.SendAsync(request, cancellationToken);
|
||
}
|
||
|
||
private async Task EnsureLoginAsync(Uri baseUri, CancellationToken cancellationToken)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(_sessionToken)) return;
|
||
await _loginLock.WaitAsync(cancellationToken);
|
||
try
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(_sessionToken)) return;
|
||
using var response = await _httpClient.PostAsJsonAsync(new Uri(baseUri, "api/auth/login"),
|
||
new { password = _options.WebPassword }, cancellationToken);
|
||
if (!response.IsSuccessStatusCode)
|
||
throw new InvalidOperationException("MiAirX 登录失败,请检查管理密码");
|
||
|
||
if (!response.Headers.TryGetValues("Set-Cookie", out var cookies))
|
||
throw new InvalidOperationException("MiAirX 登录成功但没有返回会话凭据");
|
||
var tokenCookie = cookies.Select(cookie => cookie.Split(';', 2)[0])
|
||
.FirstOrDefault(cookie => cookie.StartsWith("miairx_token=", StringComparison.Ordinal));
|
||
_sessionToken = tokenCookie?["miairx_token=".Length..];
|
||
if (string.IsNullOrWhiteSpace(_sessionToken))
|
||
throw new InvalidOperationException("MiAirX 登录成功但会话凭据无效");
|
||
}
|
||
finally
|
||
{
|
||
_loginLock.Release();
|
||
}
|
||
}
|
||
|
||
private Uri GetBaseUri()
|
||
{
|
||
if (!_options.Enabled) throw new InvalidOperationException("MiAirX 接入未启用");
|
||
if (string.IsNullOrWhiteSpace(_options.DeviceId)) throw new InvalidOperationException("MiAirX 音箱 DID 未配置");
|
||
if (_options.RequireWebPassword && string.IsNullOrWhiteSpace(_options.WebPassword))
|
||
throw new InvalidOperationException("MiAirX 管理密码未配置");
|
||
if (!Uri.TryCreate(_options.BaseUrl, UriKind.Absolute, out var baseUri) ||
|
||
baseUri.Scheme is not ("http" or "https"))
|
||
throw new InvalidOperationException("MiAirX 服务地址未配置或格式无效");
|
||
return new Uri(baseUri.ToString().TrimEnd('/') + "/");
|
||
}
|
||
|
||
private static async Task EnsureCommandSucceededAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||
{
|
||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||
throw new InvalidOperationException("MiAirX 登录已失效,请检查管理密码");
|
||
if (!response.IsSuccessStatusCode)
|
||
throw new HttpRequestException($"MiAirX 返回 HTTP {(int)response.StatusCode}");
|
||
|
||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken),
|
||
cancellationToken: cancellationToken);
|
||
if (!document.RootElement.TryGetProperty("success", out var success) || success.ValueKind != JsonValueKind.True)
|
||
{
|
||
var error = document.RootElement.TryGetProperty("error", out var errorValue)
|
||
? errorValue.GetString() : null;
|
||
var detail = string.IsNullOrWhiteSpace(error) ? "" : $":{error[..Math.Min(error.Length, 120)]}";
|
||
throw new InvalidOperationException($"MiAirX 未能完成指令{detail}");
|
||
}
|
||
}
|
||
}
|