dev_lx #14

Merged
LiuXin merged 6 commits from dev_lx into main 4 months ago
  1. 9
      WeiCloud.Fusion/AlarmService/Alarm.DomainService/DahAlarm/DahuaGeneralCtlService.cs
  2. 10
      WeiCloud.Fusion/Common.SharedService/Common.Shared.Application/DaHua/RequestDto/DahuaVideoQueryDto.cs
  3. 6
      WeiCloud.Fusion/Common.SharedService/Common.Shared.DomainService/DaHTokenService/TokenProviderService.cs
  4. 16
      WeiCloud.Fusion/VideoService/Video.API/Controllers/DaHua/VideoManageController.cs
  5. 1
      WeiCloud.Fusion/VideoService/Video.API/Program.cs
  6. 24
      WeiCloud.Fusion/VideoService/Video.API/appsettings.json
  7. 86
      WeiCloud.Fusion/VideoService/Video.DomainService/Dahvision/DahuaGeneralCtlService.cs
  8. 8
      WeiCloud.Fusion/VideoService/Video.DomainService/Dahvision/IDahuaGeneralCtlService.cs
  9. 8
      WeiCloud.Fusion/VideoService/Video.DomainService/Dahvision/IRootVideoPlaybackService.cs
  10. 16
      WeiCloud.Fusion/VideoService/Video.DomainService/Dahvision/RootVideoPlaybackService.cs
  11. 26
      WeiCloud.Fusion/VideoService/Video.DomainService/Dahvision/UrlHostReplacer.cs
  12. 32
      WeiCloud.Fusion/WeiCloud.Fusion.sln

@ -122,7 +122,7 @@ namespace Alarm.DomainService.DahAlarm
var clientId = _configuration["DahuaAuth:ClientId"]; var clientId = _configuration["DahuaAuth:ClientId"];
var token = await _tokenProviderService.GetTokenAsync(clientId!); var token = await _tokenProviderService.GetTokenAsync(clientId!);
if (_tokenProviderService.IsTokenValid(token)) if (!_tokenProviderService.IsTokenValid(token))
{ {
_logger.LogWarning("新增报警事件订阅:token无效"); _logger.LogWarning("新增报警事件订阅:token无效");
return new DaHApiResult<object> { Success = false, Code = "1009", Msg = "token无效" }; return new DaHApiResult<object> { Success = false, Code = "1009", Msg = "token无效" };
@ -206,7 +206,7 @@ namespace Alarm.DomainService.DahAlarm
{ {
//拼接物联平台标准的mqtt消息格式 //拼接物联平台标准的mqtt消息格式
var payload = "[{\"taglabel\":\"" + dto.Info.DeviceCode + ".alart." + dto.Info.DeviceName + "\",\"value\":\"" + dto.Info.AlarmStat + "\",\"time\":\"" + DateTimeOffset.UtcNow.ToUnixTimeSeconds() + "\"}]"; var payload = "[{\"taglabel\":\"" + dto.Info.DeviceCode + ".alart." + dto.Info.DeviceName + "\",\"value\":\"" + dto.Info.AlarmStat + "\",\"time\":\"" + DateTimeOffset.UtcNow.ToUnixTimeSeconds() + "\"}]";
//var payload = "[{\"taglabel\":\"残卫测试报警按钮.alarmStat\",\"value\":\"" + dto.Info.AlarmStat + "\",\"time\":\"" + DateTimeOffset.UtcNow.ToUnixTimeSeconds() + "\"}]";
await _mqttClient.EnsureConnectedAsync(mqttHostIp, mqttHostPort, mqttUsername, mqttPassword, topicName, mqttClientId); await _mqttClient.EnsureConnectedAsync(mqttHostIp, mqttHostPort, mqttUsername, mqttPassword, topicName, mqttClientId);
await _mqttClientService.PublishAsync(topicName, payload); await _mqttClientService.PublishAsync(topicName, payload);
@ -232,7 +232,7 @@ namespace Alarm.DomainService.DahAlarm
{ {
var clientId = _configuration["DahuaAuth:ClientId"]; var clientId = _configuration["DahuaAuth:ClientId"];
var token = await _tokenProviderService.GetTokenAsync(clientId!); var token = await _tokenProviderService.GetTokenAsync(clientId!);
if (_tokenProviderService.IsTokenValid(token)) if (!_tokenProviderService.IsTokenValid(token))
{ {
_logger.LogWarning("取消订阅某个报警事件:token无效"); _logger.LogWarning("取消订阅某个报警事件:token无效");
return false; return false;
@ -279,11 +279,12 @@ namespace Alarm.DomainService.DahAlarm
{ {
var clientId = _configuration["DahuaAuth:ClientId"]; var clientId = _configuration["DahuaAuth:ClientId"];
var token = await _tokenProviderService.GetTokenAsync(clientId!); var token = await _tokenProviderService.GetTokenAsync(clientId!);
if (_tokenProviderService.IsTokenValid(token)) if (!_tokenProviderService.IsTokenValid(token))
{ {
_logger.LogWarning("获取事件列表:token无效"); _logger.LogWarning("获取事件列表:token无效");
return new DaHApiResult<SubscriptionMapDto> { Success = false, Code = "1009", Msg = "token无效" }; return new DaHApiResult<SubscriptionMapDto> { Success = false, Code = "1009", Msg = "token无效" };
} }
// var url = $"https://{_configuration["DahuaAuth:Host"]}/evo-apigw/evo-brm/1.0.0/device/1000014";
var url = $"https://{_configuration["DahuaAuth:Host"]}/evo-apigw/evo-event/1.0.0/subscribe/subscribe-list?monitorType=url&category={name}"; var url = $"https://{_configuration["DahuaAuth:Host"]}/evo-apigw/evo-event/1.0.0/subscribe/subscribe-list?monitorType=url&category={name}";
try try

@ -270,6 +270,11 @@ namespace Common.Shared.Application.DaHua
/// </summary> /// </summary>
[JsonPropertyName("data")] [JsonPropertyName("data")]
public StreamRtspRequestData Data { get; set; } public StreamRtspRequestData Data { get; set; }
///// <summary>
///// 如果多个icc平台的话,决定是哪个平台的回放
///// </summary>
//public string? IpAddress { get; set; }
} }
/// <summary> /// <summary>
@ -398,5 +403,10 @@ namespace Common.Shared.Application.DaHua
/// </summary> /// </summary>
[JsonPropertyName("recordType")] [JsonPropertyName("recordType")]
public required string RecordType { get; set; } public required string RecordType { get; set; }
/// <summary>
/// 如果多个icc平台的话,决定是哪个平台的回放
/// </summary>
public string? IpAddress { get; set; }
} }
} }

@ -309,11 +309,11 @@ namespace Common.Shared.DomainService
public bool IsTokenValid(string token) public bool IsTokenValid(string token)
{ {
// 避免 NullReferenceException // 避免 NullReferenceException
if (string.IsNullOrWhiteSpace(token)) if (string.IsNullOrWhiteSpace(token) && token.Length < 10)
return true; return false;
// 统一写法,后续改条件只改这里 // 统一写法,后续改条件只改这里
return token.Length < 10; return true;
} }
#region RES加密 #region RES加密

@ -37,9 +37,9 @@ namespace Video.API.Controllers.DaHua
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
[HttpPost("playback/dh")] [HttpPost("playback/dh")]
public async Task<ApiResult<UrlDataDto>> StartAndPlaybackDH(PlaybackReqDto dto) public async Task<ApiResult<UrlDataDto>> StartAndPlaybackDH([FromBody] PlaybackReqDto dto, [FromQuery] string? ipaddress)
{ {
return await _rootVideoPlaybackService.GetDaHRecordVideoUrl(dto); return await _rootVideoPlaybackService.GetDaHRecordVideoUrl(dto, ipaddress);
} }
/// <summary> /// <summary>
@ -48,9 +48,9 @@ namespace Video.API.Controllers.DaHua
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
[HttpPost("rtspplayback/dh")] [HttpPost("rtspplayback/dh")]
public async Task<ApiResult<UrlDataDto>> RtspPlaybackByTime(RtspPlayBackReqDto dto) public async Task<ApiResult<UrlDataDto>> RtspPlaybackByTime([FromBody] RtspPlayBackReqDto dto, [FromQuery] string? ipaddress)
{ {
return await _rootVideoPlaybackService.RtspPlaybackByTime(dto); return await _rootVideoPlaybackService.RtspPlaybackByTime(dto, ipaddress);
} }
/// <summary> /// <summary>
@ -59,9 +59,9 @@ namespace Video.API.Controllers.DaHua
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
[HttpPost("realtime/dh")] [HttpPost("realtime/dh")]
public async Task<ApiResult<UrlDataDto>> GetRealtimeUrl(StreamReqDto dto) public async Task<ApiResult<UrlDataDto>> GetRealtimeUrl([FromBody] StreamReqDto dto, [FromQuery] string? ipaddress)
{ {
return await _rootVideoPlaybackService.GetRealtimeUrl(dto); return await _rootVideoPlaybackService.GetRealtimeUrl(dto, ipaddress);
} }
/// <summary> /// <summary>
@ -70,9 +70,9 @@ namespace Video.API.Controllers.DaHua
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
[HttpPost("rtspstart/dh")] [HttpPost("rtspstart/dh")]
public async Task<ApiResult<UrlDataDto>> RtspStartVideoUrl(StreamRtspReqDto dto) public async Task<ApiResult<UrlDataDto>> RtspStartVideoUrl([FromBody] StreamRtspReqDto dto, [FromQuery] string? ipaddress)
{ {
return await _rootVideoPlaybackService.RtspStartVideoUrl(dto); return await _rootVideoPlaybackService.RtspStartVideoUrl(dto, ipaddress);
} }
/// <summary> /// <summary>

@ -1,5 +1,6 @@
using Autofac; using Autofac;
using Autofac.Extensions.DependencyInjection; using Autofac.Extensions.DependencyInjection;
using Common.Shared.Application.DaHua;
using Common.Shared.DomainService; using Common.Shared.DomainService;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using NLog; using NLog;

@ -22,19 +22,29 @@
"RedisConnection": "v4.weienergy.cn:6380,allowadmin=true,password=Zrhredis#2019,defaultDatabase=10", "RedisConnection": "v4.weienergy.cn:6380,allowadmin=true,password=Zrhredis#2019,defaultDatabase=10",
"DBConnection": "server=v4.weienergy.cn;uid=root;pwd=Zrhdb#2019;port=3307;database=WeiCloudDB.Cap;default command timeout=100;CharSet=utf8;SslMode=None;allowPublicKeyRetrieval=true" "DBConnection": "server=v4.weienergy.cn;uid=root;pwd=Zrhdb#2019;port=3307;database=WeiCloudDB.Cap;default command timeout=100;CharSet=utf8;SslMode=None;allowPublicKeyRetrieval=true"
}, },
"VideoOpen": "1", //01 "VideoOpen": "1", //01
// //
"DahuaAuth": { "DahuaAuth": {
"Host": "demo.weienergy.cn:15214", "Host": "demo.weienergy.cn:15214",
"ClientId": "taiyanggong", "ClientId": "taiyanggong",
"RealRootHost": "192.168.21.18:9100", //RTSPicc
"RealReplaceHost": "demo.weienergy.cn:15210", //rtps
"TimeRootHost": "192.168.21.18:9320", //RTSPicc
"TimeReplaceHost": "demo.weienergy.cn:15211", //rtps
"ClientSecret": "6d6c78f8-3d4c-4e76-ab6b-827942a7b725", "ClientSecret": "6d6c78f8-3d4c-4e76-ab6b-827942a7b725",
"Username": "system", "Username": "system",
"Password": "Admin123" "Password": "Admin123",
"Real": {
"DefaultRoot": "192.168.21.18:9100",
"DefaultReplace": "demo.weienergy.cn:15210",
"Overrides": {
"192.168.21.18:9100": "demo.weienergy.cn:15210",
"10.20.30.40:9100": "demo.weienergy.cn:15210"
}
},
"Time": {
"DefaultRoot": "192.168.21.18:9320",
"DefaultReplace": "demo.weienergy.cn:15211",
"Overrides": {
"192.168.21.18:9320": "demo.weienergy.cn:15211"
}
}
} }
} }

@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using Video.DomainService.Dahvision;
namespace Video.DomainService namespace Video.DomainService
{ {
@ -37,7 +38,7 @@ namespace Video.DomainService
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
/// <exception cref="NotImplementedException"></exception> /// <exception cref="NotImplementedException"></exception>
public async Task<DaHApiResult<UrlDataDto>> RecordVideoUrl(PlaybackReqDto dto) public async Task<DaHApiResult<UrlDataDto>> RecordVideoUrl(PlaybackReqDto dto, string? ipaddress)
{ {
// 1) 参数校验 + 早退 // 1) 参数校验 + 早退
if (dto == null || string.IsNullOrWhiteSpace(dto.Data.ChannelId)) if (dto == null || string.IsNullOrWhiteSpace(dto.Data.ChannelId))
@ -48,20 +49,23 @@ namespace Video.DomainService
try try
{ {
// 2) Token:优先入参,其次缓存/获取(建议返回完整的 "Bearer xxx")
var clientId = _configuration["DahuaAuth:ClientId"]; var clientId = _configuration["DahuaAuth:ClientId"];
var token = await _tokenProviderService.GetTokenAsync(clientId!); var token = await _tokenProviderService.GetTokenAsync(clientId!);
if (_tokenProviderService.IsTokenValid(token)) if (!_tokenProviderService.IsTokenValid(token))
{ {
_logger.LogWarning("hls等录像回放:token无效"); _logger.LogWarning("hls等录像回放:token无效");
return new DaHApiResult<UrlDataDto> { Success = false, Code = "1009", Msg = "token无效" }; return new DaHApiResult<UrlDataDto> { Success = false, Code = "1009", Msg = "token无效" };
} }
var url = $"https://{_configuration["DahuaAuth:Host"]}/evo-apigw/admin/API/video/stream/record"; var url = $"https://{_configuration["DahuaAuth:Host"]}/evo-apigw/admin/API/video/stream/record";
if (ipaddress != null)
{
url = $"https://{ipaddress}/evo-apigw/admin/API/video/stream/record";
}
// 3) 构造请求(把 dto 放进 Body),并用 SendAsync 发送,才能带上头 // 3) 构造请求(把 dto 放进 Body),并用 SendAsync 发送,才能带上头
using var req = new HttpRequestMessage(HttpMethod.Post, url) using var req = new HttpRequestMessage(HttpMethod.Post, url)
{ {
Content = JsonContent.Create(dto) // 关键:别再丢 body 了 Content = JsonContent.Create(dto.Data) // 关键:别再丢 body 了
}; };
req.Headers.TryAddWithoutValidation("Authorization", token); req.Headers.TryAddWithoutValidation("Authorization", token);
@ -80,7 +84,12 @@ namespace Video.DomainService
_logger.LogWarning("录像请求业务失败: {Body}", body); _logger.LogWarning("录像请求业务失败: {Body}", body);
return new DaHApiResult<UrlDataDto> { Success = false, Code = "1008", Msg = "录像请求失败" }; return new DaHApiResult<UrlDataDto> { Success = false, Code = "1008", Msg = "录像请求失败" };
} }
result.Data!.Url = result.Data.Url + "?token=" + token; result.Data!.Url = UrlHostReplacer.ReplaceHost(
result.Data.Url,
ipaddress,
_configuration,
"Time"
) + "?token=" + result.Data.Token;
return result; return result;
} }
catch (Exception ex) catch (Exception ex)
@ -107,7 +116,7 @@ namespace Video.DomainService
var clientId = _configuration["DahuaAuth:ClientId"]; var clientId = _configuration["DahuaAuth:ClientId"];
var token = await _tokenProviderService.GetTokenAsync(clientId!); var token = await _tokenProviderService.GetTokenAsync(clientId!);
if (_tokenProviderService.IsTokenValid(token)) if (!_tokenProviderService.IsTokenValid(token))
{ {
_logger.LogWarning("查询普通录像信息列表:token无效"); _logger.LogWarning("查询普通录像信息列表:token无效");
return new DaHApiResult<RecordsResDto> { Success = false, Code = "1009", Msg = "token无效" }; return new DaHApiResult<RecordsResDto> { Success = false, Code = "1009", Msg = "token无效" };
@ -116,7 +125,7 @@ namespace Video.DomainService
using var req = new HttpRequestMessage(HttpMethod.Post, url) using var req = new HttpRequestMessage(HttpMethod.Post, url)
{ {
Content = JsonContent.Create(dto) // 关键:把 dto 放进请求体 Content = JsonContent.Create(dto.Data) // 关键:把 dto 放进请求体
}; };
req.Headers.TryAddWithoutValidation("Authorization", token); req.Headers.TryAddWithoutValidation("Authorization", token);
@ -164,7 +173,7 @@ namespace Video.DomainService
var clientId = _configuration["DahuaAuth:ClientId"]; var clientId = _configuration["DahuaAuth:ClientId"];
var token = await _tokenProviderService.GetTokenAsync(clientId!); var token = await _tokenProviderService.GetTokenAsync(clientId!);
if (_tokenProviderService.IsTokenValid(token)) if (!_tokenProviderService.IsTokenValid(token))
{ {
_logger.LogWarning("通道分页查询失败:token无效"); _logger.LogWarning("通道分页查询失败:token无效");
return new DaHApiResult<PageInfoDto> { Success = false, Code = "1009", Msg = "token无效" }; return new DaHApiResult<PageInfoDto> { Success = false, Code = "1009", Msg = "token无效" };
@ -211,7 +220,7 @@ namespace Video.DomainService
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
/// <exception cref="NotImplementedException"></exception> /// <exception cref="NotImplementedException"></exception>
public async Task<DaHApiResult<UrlDataDto>> RealtimeStreamUrl(StreamReqDto dto) public async Task<DaHApiResult<UrlDataDto>> RealtimeStreamUrl(StreamReqDto dto, string? ipaddress)
{ {
// 1) 参数校验 + 早退 // 1) 参数校验 + 早退
if (dto == null || dto.Data == null) if (dto == null || dto.Data == null)
@ -225,7 +234,7 @@ namespace Video.DomainService
// 2) Token:优先用入参;否则走缓存/获取(建议返回已带前缀的 "Bearer xxx") // 2) Token:优先用入参;否则走缓存/获取(建议返回已带前缀的 "Bearer xxx")
var clientId = _configuration["DahuaAuth:ClientId"]; var clientId = _configuration["DahuaAuth:ClientId"];
var token = await _tokenProviderService.GetTokenAsync(clientId!); var token = await _tokenProviderService.GetTokenAsync(clientId!);
if (_tokenProviderService.IsTokenValid(token)) if (!_tokenProviderService.IsTokenValid(token))
{ {
_logger.LogWarning(" HLS实时流请求失败:token无效"); _logger.LogWarning(" HLS实时流请求失败:token无效");
return new DaHApiResult<UrlDataDto> { Success = false, Code = "1009", Msg = "token无效" }; return new DaHApiResult<UrlDataDto> { Success = false, Code = "1009", Msg = "token无效" };
@ -233,10 +242,15 @@ namespace Video.DomainService
var url = $"https://{_configuration["DahuaAuth:Host"]}/evo-apigw/admin/API/video/stream/realtime"; var url = $"https://{_configuration["DahuaAuth:Host"]}/evo-apigw/admin/API/video/stream/realtime";
if (ipaddress != null)
{
url = $"https://{ipaddress}/evo-apigw/admin/API/video/stream/realtime";
}
// 3) 用 HttpRequestMessage 发送,才能带上自定义头 // 3) 用 HttpRequestMessage 发送,才能带上自定义头
using var req = new HttpRequestMessage(HttpMethod.Post, url) using var req = new HttpRequestMessage(HttpMethod.Post, url)
{ {
Content = JsonContent.Create(dto) // 等价 PostAsJsonAsync,但不会丢头 Content = JsonContent.Create(dto.Data) // 等价 PostAsJsonAsync,但不会丢头
}; };
req.Headers.TryAddWithoutValidation("Authorization", token); req.Headers.TryAddWithoutValidation("Authorization", token);
@ -255,7 +269,12 @@ namespace Video.DomainService
_logger.LogWarning("实时流请求业务失败: {Body}", body); _logger.LogWarning("实时流请求业务失败: {Body}", body);
return new DaHApiResult<UrlDataDto> { Success = false, Code = "1010", Msg = "实时流请求失败" }; return new DaHApiResult<UrlDataDto> { Success = false, Code = "1010", Msg = "实时流请求失败" };
} }
result.Data!.Url = result.Data.Url + "?token=" + token; result.Data!.Url = UrlHostReplacer.ReplaceHost(
result.Data.Url,
ipaddress,
_configuration,
"Real"
) + "?token=" + result.Data.Token;
return result; return result;
} }
catch (Exception ex) catch (Exception ex)
@ -318,12 +337,12 @@ namespace Video.DomainService
/// <summary> /// <summary>
/// rtsp录像回放 /// rtsp录像回放
/// (播放命令:ffplay -rtsp_transport tcp -i "rtsp://demo.weienergy.cn:15211/playback/pu/3?token=3")强制走tcp /// (播放命令:ffplay -rtsp_transport tcp -i "rtsp://demo.weienergy.cn:15210/dss/monitor/param/cameraid=1000021%24104%26substream=1?token=430")强制走tcp
/// </summary> /// </summary>
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
/// <exception cref="NotImplementedException"></exception> /// <exception cref="NotImplementedException"></exception>
public async Task<DaHApiResult<UrlDataDto>> RtspPlaybackByTime(RtspPlayBackReqDto dto) public async Task<DaHApiResult<UrlDataDto>> RtspPlaybackByTime(RtspPlayBackReqDto dto, string? ipaddress)
{ {
// 参数校验 + 早退 // 参数校验 + 早退
if (dto == null || string.IsNullOrWhiteSpace(dto.Data.ChannelId)) if (dto == null || string.IsNullOrWhiteSpace(dto.Data.ChannelId))
@ -335,17 +354,21 @@ namespace Video.DomainService
// 先用缓存里的 token,不足5分钟过期再刷新(按你之前的口径来) // 先用缓存里的 token,不足5分钟过期再刷新(按你之前的口径来)
var clientId = _configuration["DahuaAuth:ClientId"]; var clientId = _configuration["DahuaAuth:ClientId"];
var token = await _tokenProviderService.GetTokenAsync(clientId!); var token = await _tokenProviderService.GetTokenAsync(clientId!);
if (_tokenProviderService.IsTokenValid(token)) if (!_tokenProviderService.IsTokenValid(token))
{ {
_logger.LogWarning("rtsp录像回放:token无效"); _logger.LogWarning("rtsp录像回放:token无效");
return new DaHApiResult<UrlDataDto> { Success = false, Code = "1009", Msg = "token无效" }; return new DaHApiResult<UrlDataDto> { Success = false, Code = "1009", Msg = "token无效" };
} }
var url = $"https://{_configuration["DahuaAuth:Host"]}/evo-apigw/admin/API/SS/Playback/StartPlaybackByTime"; var url = $"https://{_configuration["DahuaAuth:Host"]}/evo-apigw/admin/API/SS/Playback/StartPlaybackByTime";
if (ipaddress != null)
{
url = $"https://{ipaddress}/evo-apigw/admin/API/SS/Playback/StartPlaybackByTime";
}
using var req = new HttpRequestMessage(HttpMethod.Post, url) using var req = new HttpRequestMessage(HttpMethod.Post, url)
{ {
Content = JsonContent.Create(dto) // 关键:把 dto 放进请求体 Content = JsonContent.Create(dto.Data) // 关键:把 dto 放进请求体
}; };
req.Headers.TryAddWithoutValidation("Authorization", token); req.Headers.TryAddWithoutValidation("Authorization", token);
@ -366,7 +389,12 @@ namespace Video.DomainService
_logger.LogWarning("录像请求业务失败: {Body}", body); _logger.LogWarning("录像请求业务失败: {Body}", body);
return new DaHApiResult<UrlDataDto> { Success = false, Code = "1008", Msg = "录像请求失败" }; return new DaHApiResult<UrlDataDto> { Success = false, Code = "1008", Msg = "录像请求失败" };
} }
result.Data!.Url = result.Data.Url.Replace(_configuration["DahuaAuth:TimeRootHost"], _configuration["DahuaAuth:TimeReplaceHost"]) + "?token=" + result.Data.Token; result.Data!.Url = UrlHostReplacer.ReplaceHost(
result.Data.Url,
ipaddress,
_configuration,
"Time"
) + "?token=" + result.Data.Token;
return result; return result;
} }
catch (Exception ex) catch (Exception ex)
@ -383,7 +411,7 @@ namespace Video.DomainService
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
/// <exception cref="NotImplementedException"></exception> /// <exception cref="NotImplementedException"></exception>
public async Task<DaHApiResult<UrlDataDto>> RtspStartVideoUrl(StreamRtspReqDto dto) public async Task<DaHApiResult<UrlDataDto>> RtspStartVideoUrl(StreamRtspReqDto dto, string? ipaddress)
{ {
if (dto == null || dto.Data == null) if (dto == null || dto.Data == null)
{ {
@ -393,13 +421,17 @@ namespace Video.DomainService
var clientId = _configuration["DahuaAuth:ClientId"]; var clientId = _configuration["DahuaAuth:ClientId"];
var token = await _tokenProviderService.GetTokenAsync(clientId!); var token = await _tokenProviderService.GetTokenAsync(clientId!);
if (_tokenProviderService.IsTokenValid(token)) if (!_tokenProviderService.IsTokenValid(token))
{ {
_logger.LogWarning("rtsp实时预览接口方式:token无效"); _logger.LogWarning("rtsp实时预览接口方式:token无效");
return new DaHApiResult<UrlDataDto> { Success = false, Code = "1009", Msg = "token无效" }; return new DaHApiResult<UrlDataDto> { Success = false, Code = "1009", Msg = "token无效" };
} }
var url = $"https://{_configuration["DahuaAuth:Host"]}/evo-apigw/admin/API/MTS/Video/StartVideo"; var url = $"https://{_configuration["DahuaAuth:Host"]}/evo-apigw/admin/API/MTS/Video/StartVideo";
if (ipaddress != null)
{
url = $"https://{ipaddress}/evo-apigw/admin/API/MTS/Video/StartVideo";
}
using var req = new HttpRequestMessage(HttpMethod.Post, url) using var req = new HttpRequestMessage(HttpMethod.Post, url)
{ {
@ -424,7 +456,12 @@ namespace Video.DomainService
_logger.LogWarning("实时流请求业务失败: {Body}", body); _logger.LogWarning("实时流请求业务失败: {Body}", body);
return new DaHApiResult<UrlDataDto> { Success = false, Code = "1010", Msg = "实时流请求失败" }; return new DaHApiResult<UrlDataDto> { Success = false, Code = "1010", Msg = "实时流请求失败" };
} }
result.Data!.Url = result.Data.Url.Replace(_configuration["DahuaAuth:RealRootHost"], _configuration["DahuaAuth:RealReplaceHost"]) + "?token=" + result.Data!.Token; result.Data!.Url = UrlHostReplacer.ReplaceHost(
result.Data.Url,
ipaddress,
_configuration,
"Real"
) + "?token=" + result.Data.Token;
return result; return result;
} }
catch (Exception ex) catch (Exception ex)
@ -444,13 +481,18 @@ namespace Video.DomainService
{ {
var clientId = _configuration["DahuaAuth:ClientId"]; var clientId = _configuration["DahuaAuth:ClientId"];
var token = await _tokenProviderService.GetTokenAsync(clientId!); var token = await _tokenProviderService.GetTokenAsync(clientId!);
if (_tokenProviderService.IsTokenValid(token)) if (!_tokenProviderService.IsTokenValid(token))
{ {
_logger.LogWarning("下载:token无效"); _logger.LogWarning("下载:token无效");
return "下载地址无效"; return "下载地址无效";
} }
return _configuration["DahuaAuth:Host"] + $"/evo-apigw/evo-httpnode/vod/cam/download.mp4?vcuid={dto.Vcuid}&subtype={dto.Subtype}&starttime={dto.StartTime}endtime={dto.EndTime}&videoType={dto.VideoType}&token={token}&recordType={dto.RecordType}"; var url = _configuration["DahuaAuth:Host"];
if (dto.IpAddress != null)
{
url = dto.IpAddress;
}
return url + $"/evo-apigw/evo-httpnode/vod/cam/download.mp4?vcuid={dto.Vcuid}&subtype={dto.Subtype}&starttime={dto.StartTime}endtime={dto.EndTime}&videoType={dto.VideoType}&token={token}&recordType={dto.RecordType}";
} }
} }
} }

@ -19,14 +19,14 @@ namespace Video.DomainService
/// </summary> /// </summary>
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
Task<DaHApiResult<UrlDataDto>> RecordVideoUrl(PlaybackReqDto dto); Task<DaHApiResult<UrlDataDto>> RecordVideoUrl(PlaybackReqDto dto, string? ipaddress);
/// <summary> /// <summary>
/// Rtsp录像回放 /// Rtsp录像回放
/// </summary> /// </summary>
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
Task<DaHApiResult<UrlDataDto>> RtspPlaybackByTime(RtspPlayBackReqDto dto); Task<DaHApiResult<UrlDataDto>> RtspPlaybackByTime(RtspPlayBackReqDto dto, string? ipaddress);
/// <summary> /// <summary>
/// 设备通道分页查询,需要用于HlsRecordVideo /// 设备通道分页查询,需要用于HlsRecordVideo
@ -41,14 +41,14 @@ namespace Video.DomainService
/// </summary> /// </summary>
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
Task<DaHApiResult<UrlDataDto>> RealtimeStreamUrl(StreamReqDto dto); Task<DaHApiResult<UrlDataDto>> RealtimeStreamUrl(StreamReqDto dto, string? ipaddress);
/// <summary> /// <summary>
/// rtsp实时预览接口方式 /// rtsp实时预览接口方式
/// </summary> /// </summary>
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
Task<DaHApiResult<UrlDataDto>> RtspStartVideoUrl(StreamRtspReqDto dto); Task<DaHApiResult<UrlDataDto>> RtspStartVideoUrl(StreamRtspReqDto dto, string? ipaddress);
/// <summary> /// <summary>
/// 注销认证信息 /// 注销认证信息

@ -10,28 +10,28 @@ namespace Video.DomainService
/// </summary> /// </summary>
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
Task<ApiResult<UrlDataDto>> GetDaHRecordVideoUrl(PlaybackReqDto dto); Task<ApiResult<UrlDataDto>> GetDaHRecordVideoUrl(PlaybackReqDto dto, string? ipaddress);
/// <summary> /// <summary>
/// 大华的实时视频 /// 大华的实时视频
/// </summary> /// </summary>
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
Task<ApiResult<UrlDataDto>> GetRealtimeUrl(StreamReqDto dto); Task<ApiResult<UrlDataDto>> GetRealtimeUrl(StreamReqDto dto, string? ipaddress);
/// <summary> /// <summary>
/// rtsp实时预览接口方式 /// rtsp实时预览接口方式
/// </summary> /// </summary>
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
Task<ApiResult<UrlDataDto>> RtspStartVideoUrl(StreamRtspReqDto dto); Task<ApiResult<UrlDataDto>> RtspStartVideoUrl(StreamRtspReqDto dto, string? ipaddress);
/// <summary> /// <summary>
/// rtsp录像回放 /// rtsp录像回放
/// </summary> /// </summary>
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
Task<ApiResult<UrlDataDto>> RtspPlaybackByTime(RtspPlayBackReqDto dto); Task<ApiResult<UrlDataDto>> RtspPlaybackByTime(RtspPlayBackReqDto dto, string? ipaddress);
/// <summary> /// <summary>
/// 大华设备通道分页查询 /// 大华设备通道分页查询

@ -35,11 +35,11 @@ namespace Video.DomainService
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
/// <exception cref="NotImplementedException"></exception> /// <exception cref="NotImplementedException"></exception>
public async Task<ApiResult<UrlDataDto>> GetDaHRecordVideoUrl(PlaybackReqDto dto) public async Task<ApiResult<UrlDataDto>> GetDaHRecordVideoUrl(PlaybackReqDto dto, string? ipaddress)
{ {
ApiResult<UrlDataDto> result = new ApiResult<UrlDataDto>() { Code = 200, Msg = "接口调用成功" }; ApiResult<UrlDataDto> result = new ApiResult<UrlDataDto>() { Code = 200, Msg = "接口调用成功" };
var urlReult = await _dahuaGeneralCtlService.RecordVideoUrl(dto); var urlReult = await _dahuaGeneralCtlService.RecordVideoUrl(dto, ipaddress);
if (!urlReult.Success) if (!urlReult.Success)
{ {
result.Code = 500; result.Code = 500;
@ -58,10 +58,10 @@ namespace Video.DomainService
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
/// <exception cref="NotImplementedException"></exception> /// <exception cref="NotImplementedException"></exception>
public async Task<ApiResult<UrlDataDto>> GetRealtimeUrl(StreamReqDto dto) public async Task<ApiResult<UrlDataDto>> GetRealtimeUrl(StreamReqDto dto, string? ipaddress)
{ {
ApiResult<UrlDataDto> result = new ApiResult<UrlDataDto>() { Code = 200, Msg = "接口调用成功" }; ApiResult<UrlDataDto> result = new ApiResult<UrlDataDto>() { Code = 200, Msg = "接口调用成功" };
var urlReult = await _dahuaGeneralCtlService.RealtimeStreamUrl(dto); var urlReult = await _dahuaGeneralCtlService.RealtimeStreamUrl(dto, ipaddress);
if (!urlReult.Success) if (!urlReult.Success)
{ {
result.Code = 500; result.Code = 500;
@ -123,10 +123,10 @@ namespace Video.DomainService
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
/// <exception cref="NotImplementedException"></exception> /// <exception cref="NotImplementedException"></exception>
public async Task<ApiResult<UrlDataDto>> RtspStartVideoUrl(StreamRtspReqDto dto) public async Task<ApiResult<UrlDataDto>> RtspStartVideoUrl(StreamRtspReqDto dto, string? ipaddress)
{ {
ApiResult<UrlDataDto> result = new ApiResult<UrlDataDto>() { Code = 200, Msg = "接口调用成功" }; ApiResult<UrlDataDto> result = new ApiResult<UrlDataDto>() { Code = 200, Msg = "接口调用成功" };
var urlReult = await _dahuaGeneralCtlService.RtspStartVideoUrl(dto); var urlReult = await _dahuaGeneralCtlService.RtspStartVideoUrl(dto, ipaddress);
if (!urlReult.Success) if (!urlReult.Success)
{ {
result.Code = 500; result.Code = 500;
@ -142,11 +142,11 @@ namespace Video.DomainService
/// </summary> /// </summary>
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
public async Task<ApiResult<UrlDataDto>> RtspPlaybackByTime(RtspPlayBackReqDto dto) public async Task<ApiResult<UrlDataDto>> RtspPlaybackByTime(RtspPlayBackReqDto dto, string? ipaddress)
{ {
ApiResult<UrlDataDto> result = new ApiResult<UrlDataDto>() { Code = 200, Msg = "接口调用成功" }; ApiResult<UrlDataDto> result = new ApiResult<UrlDataDto>() { Code = 200, Msg = "接口调用成功" };
var urlReult = await _dahuaGeneralCtlService.RtspPlaybackByTime(dto); var urlReult = await _dahuaGeneralCtlService.RtspPlaybackByTime(dto, ipaddress);
if (!urlReult.Success) if (!urlReult.Success)
{ {
result.Code = 500; result.Code = 500;

@ -0,0 +1,26 @@
using Microsoft.Extensions.Configuration;
namespace Video.DomainService.Dahvision
{
public static class UrlHostReplacer
{
public static string ReplaceHost(string url, string? ipAddress, IConfiguration config, string sectionName)
{
var section = config.GetSection($"DahuaAuth:{sectionName}");
var defaultRoot = section["DefaultRoot"]!;
var defaultReplace = section["DefaultReplace"]!;
// 1) 确定 fromHost
var fromHost = string.IsNullOrWhiteSpace(ipAddress) ? defaultRoot : ipAddress;
// 2) 尝试在 Overrides 找对应的 value
var overrides = section.GetSection("Overrides").Get<Dictionary<string, string>>() ?? new();
var toHost = overrides.TryGetValue(fromHost, out var mapped)
? mapped
: defaultReplace;
// 3) 字符串替换
return url.Replace(fromHost, toHost, StringComparison.OrdinalIgnoreCase);
}
}
}

@ -43,10 +43,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ParkingLotEntity", "Parking
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ParkLotInfoService", "ParkLotInfoService\ParkLotInfoService.csproj", "{19E28D5D-C144-4FF5-B71D-D81DA3494AD9}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ParkLotInfoService", "ParkLotInfoService\ParkLotInfoService.csproj", "{19E28D5D-C144-4FF5-B71D-D81DA3494AD9}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WeiCloud.Core", "WeiCloud.Core\WeiCloud.Core.csproj", "{B5C2EFBB-8991-48CB-95B6-77C0770D245D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WeiCloud.Core", "WeiCloud.Core\WeiCloud.Core.csproj", "{40B0D902-553C-C52F-71A2-56FB176FCCBD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Video.Application", "VideoService\Video.Application\Video.Application.csproj", "{9F2BD2C5-6496-419D-B87A-4F481E963C4D}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Video.Application", "VideoService\Video.Application\Video.Application.csproj", "{9F2BD2C5-6496-419D-B87A-4F481E963C4D}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AlarmService", "AlarmService", "{18791734-CA81-482D-964A-CA6D0F308B8E}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AlarmService", "AlarmService", "{18791734-CA81-482D-964A-CA6D0F308B8E}"
@ -65,6 +61,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Alarm.Application", "AlarmS
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Shared.API", "Common.SharedService\Common.Shared.API\Common.Shared.API.csproj", "{1ACFAAE8-C86D-4582-B0B4-542B74970737}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Shared.API", "Common.SharedService\Common.Shared.API\Common.Shared.API.csproj", "{1ACFAAE8-C86D-4582-B0B4-542B74970737}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WeiCloud.Core", "WeiCloud.Core\WeiCloud.Core.csproj", "{40B0D902-553C-C52F-71A2-56FB176FCCBD}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -119,10 +117,14 @@ Global
{D97C471C-3190-4F8E-A916-7A056A65EDCE}.Debug|Any CPU.Build.0 = Debug|Any CPU {D97C471C-3190-4F8E-A916-7A056A65EDCE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D97C471C-3190-4F8E-A916-7A056A65EDCE}.Release|Any CPU.ActiveCfg = Release|Any CPU {D97C471C-3190-4F8E-A916-7A056A65EDCE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D97C471C-3190-4F8E-A916-7A056A65EDCE}.Release|Any CPU.Build.0 = Release|Any CPU {D97C471C-3190-4F8E-A916-7A056A65EDCE}.Release|Any CPU.Build.0 = Release|Any CPU
{40B0D902-553C-C52F-71A2-56FB176FCCBD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F5AA3CCF-ED0C-40E5-AE10-CDFDC89443F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{40B0D902-553C-C52F-71A2-56FB176FCCBD}.Debug|Any CPU.Build.0 = Debug|Any CPU {F5AA3CCF-ED0C-40E5-AE10-CDFDC89443F0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{40B0D902-553C-C52F-71A2-56FB176FCCBD}.Release|Any CPU.ActiveCfg = Release|Any CPU {F5AA3CCF-ED0C-40E5-AE10-CDFDC89443F0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{40B0D902-553C-C52F-71A2-56FB176FCCBD}.Release|Any CPU.Build.0 = Release|Any CPU {F5AA3CCF-ED0C-40E5-AE10-CDFDC89443F0}.Release|Any CPU.Build.0 = Release|Any CPU
{19E28D5D-C144-4FF5-B71D-D81DA3494AD9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{19E28D5D-C144-4FF5-B71D-D81DA3494AD9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{19E28D5D-C144-4FF5-B71D-D81DA3494AD9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{19E28D5D-C144-4FF5-B71D-D81DA3494AD9}.Release|Any CPU.Build.0 = Release|Any CPU
{9F2BD2C5-6496-419D-B87A-4F481E963C4D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9F2BD2C5-6496-419D-B87A-4F481E963C4D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9F2BD2C5-6496-419D-B87A-4F481E963C4D}.Debug|Any CPU.Build.0 = Debug|Any CPU {9F2BD2C5-6496-419D-B87A-4F481E963C4D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9F2BD2C5-6496-419D-B87A-4F481E963C4D}.Release|Any CPU.ActiveCfg = Release|Any CPU {9F2BD2C5-6496-419D-B87A-4F481E963C4D}.Release|Any CPU.ActiveCfg = Release|Any CPU
@ -151,6 +153,10 @@ Global
{1ACFAAE8-C86D-4582-B0B4-542B74970737}.Debug|Any CPU.Build.0 = Debug|Any CPU {1ACFAAE8-C86D-4582-B0B4-542B74970737}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1ACFAAE8-C86D-4582-B0B4-542B74970737}.Release|Any CPU.ActiveCfg = Release|Any CPU {1ACFAAE8-C86D-4582-B0B4-542B74970737}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1ACFAAE8-C86D-4582-B0B4-542B74970737}.Release|Any CPU.Build.0 = Release|Any CPU {1ACFAAE8-C86D-4582-B0B4-542B74970737}.Release|Any CPU.Build.0 = Release|Any CPU
{40B0D902-553C-C52F-71A2-56FB176FCCBD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{40B0D902-553C-C52F-71A2-56FB176FCCBD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{40B0D902-553C-C52F-71A2-56FB176FCCBD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{40B0D902-553C-C52F-71A2-56FB176FCCBD}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@ -168,6 +174,16 @@ Global
{6CBD9E97-4FEF-4DA2-ADFB-21B4D9DB366F} = {19A25984-FFA8-49BE-A710-6F269A406C61} {6CBD9E97-4FEF-4DA2-ADFB-21B4D9DB366F} = {19A25984-FFA8-49BE-A710-6F269A406C61}
{058C0CAB-5956-4811-8340-86919DDB2845} = {19A25984-FFA8-49BE-A710-6F269A406C61} {058C0CAB-5956-4811-8340-86919DDB2845} = {19A25984-FFA8-49BE-A710-6F269A406C61}
{D97C471C-3190-4F8E-A916-7A056A65EDCE} = {0A3134C8-219C-4674-B152-1FA6561E4217} {D97C471C-3190-4F8E-A916-7A056A65EDCE} = {0A3134C8-219C-4674-B152-1FA6561E4217}
{F5AA3CCF-ED0C-40E5-AE10-CDFDC89443F0} = {0A3134C8-219C-4674-B152-1FA6561E4217}
{19E28D5D-C144-4FF5-B71D-D81DA3494AD9} = {0A3134C8-219C-4674-B152-1FA6561E4217}
{9F2BD2C5-6496-419D-B87A-4F481E963C4D} = {19A25984-FFA8-49BE-A710-6F269A406C61}
{2677EAF0-9F7F-4969-B8B1-3006F35EB93E} = {18791734-CA81-482D-964A-CA6D0F308B8E}
{9A5FBAFF-EBE8-3156-5547-FB3ED1DEB545} = {80F3B34B-C334-44D2-A861-31FD403AD57D}
{C2757FC0-54A9-BBD3-2E23-55F2F3912BA4} = {80F3B34B-C334-44D2-A861-31FD403AD57D}
{B6DDF83D-591E-38B6-2902-1624BE8AE9B9} = {18791734-CA81-482D-964A-CA6D0F308B8E}
{4B2C6EBE-E719-9F40-ADE6-C82DA632E554} = {18791734-CA81-482D-964A-CA6D0F308B8E}
{1ACFAAE8-C86D-4582-B0B4-542B74970737} = {80F3B34B-C334-44D2-A861-31FD403AD57D}
{40B0D902-553C-C52F-71A2-56FB176FCCBD} = {44DAA396-C724-480A-A2BC-9A33D29E8FEA}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {379A56DA-D3F0-4E7E-8FF7-DA8E20015BF3} SolutionGuid = {379A56DA-D3F0-4E7E-8FF7-DA8E20015BF3}

Loading…
Cancel
Save