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 : IAudioPlaybackGateway { private readonly HttpClient _httpClient; private readonly MiAirXOptions _options; private readonly SemaphoreSlim _loginLock = new(1, 1); private string? _sessionToken; public MiAirXClient(HttpClient httpClient, IOptions 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) && (!_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) { try { await SendCommandCoreAsync(path, payload, cancellationToken); } catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) { throw new TimeoutException("MiAirX 请求超时,未确认音箱操作完成", exception); } } private async Task SendCommandCoreAsync(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 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}"); } } }