重构多wvp国标级联机制
This commit is contained in:
@@ -6,7 +6,6 @@ import com.genersoft.iot.vmp.media.zlm.dto.MediaServerItem;
|
||||
import com.genersoft.iot.vmp.media.zlm.dto.ServerKeepaliveData;
|
||||
import com.genersoft.iot.vmp.service.bean.MediaServerLoad;
|
||||
import com.genersoft.iot.vmp.service.bean.SSRCInfo;
|
||||
import com.genersoft.iot.vmp.vmanager.bean.RecordFile;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -97,4 +96,5 @@ public interface IMediaServerService {
|
||||
|
||||
List<MediaServerItem> getAllWithAssistPort();
|
||||
|
||||
MediaServerItem getMediaServerByAppAndStream(String app, String stream);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import com.genersoft.iot.vmp.utils.DateUtil;
|
||||
import com.genersoft.iot.vmp.utils.JsonUtil;
|
||||
import com.genersoft.iot.vmp.utils.redis.RedisUtil;
|
||||
import com.genersoft.iot.vmp.vmanager.bean.ErrorCode;
|
||||
import com.genersoft.iot.vmp.vmanager.bean.RecordFile;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
@@ -36,19 +35,15 @@ import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.TransactionStatus;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
/**
|
||||
* 媒体服务器节点管理
|
||||
@@ -751,6 +746,22 @@ public class MediaServerServiceImpl implements IMediaServerService {
|
||||
|
||||
@Override
|
||||
public List<MediaServerItem> getAllWithAssistPort() {
|
||||
|
||||
return mediaServerMapper.queryAllWithAssistPort();
|
||||
}
|
||||
|
||||
@Override
|
||||
public MediaServerItem getMediaServerByAppAndStream(String app, String stream) {
|
||||
List<MediaServerItem> mediaServerItemList = getAllOnline();
|
||||
if (mediaServerItemList.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
for (MediaServerItem mediaServerItem : mediaServerItemList) {
|
||||
Boolean streamReady = zlmServerFactory.isStreamReady(mediaServerItem, app, stream);
|
||||
if (streamReady) {
|
||||
return mediaServerItem;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.genersoft.iot.vmp.service.redisMsg;
|
||||
|
||||
import com.genersoft.iot.vmp.common.CommonCallback;
|
||||
import com.genersoft.iot.vmp.gb28181.bean.SendRtpItem;
|
||||
import com.genersoft.iot.vmp.vmanager.bean.WVPResult;
|
||||
|
||||
public interface IRedisRpcService {
|
||||
|
||||
SendRtpItem getSendRtpItem(SendRtpItem sendRtpItem);
|
||||
|
||||
WVPResult startSendRtp(SendRtpItem sendRtpItem);
|
||||
|
||||
void waitePushStreamOnline(SendRtpItem sendRtpItem, CommonCallback<SendRtpItem> callback);
|
||||
|
||||
WVPResult stopSendRtp(SendRtpItem sendRtpItem);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package com.genersoft.iot.vmp.service.redisMsg;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.genersoft.iot.vmp.conf.UserSetting;
|
||||
import com.genersoft.iot.vmp.gb28181.bean.GbStream;
|
||||
import com.genersoft.iot.vmp.gb28181.bean.HandlerCatchData;
|
||||
import com.genersoft.iot.vmp.gb28181.bean.SendRtpItem;
|
||||
import com.genersoft.iot.vmp.media.zlm.ZLMRESTfulUtils;
|
||||
import com.genersoft.iot.vmp.media.zlm.ZLMServerFactory;
|
||||
import com.genersoft.iot.vmp.media.zlm.ZlmHttpHookSubscribe;
|
||||
import com.genersoft.iot.vmp.media.zlm.dto.*;
|
||||
import com.genersoft.iot.vmp.media.zlm.dto.hook.OnStreamChangedHookParam;
|
||||
import com.genersoft.iot.vmp.service.IMediaServerService;
|
||||
import com.genersoft.iot.vmp.service.IStreamProxyService;
|
||||
import com.genersoft.iot.vmp.service.IStreamPushService;
|
||||
import com.genersoft.iot.vmp.service.bean.MessageForPushChannelResponse;
|
||||
import com.genersoft.iot.vmp.storager.IRedisCatchStorage;
|
||||
import com.genersoft.iot.vmp.storager.IVideoManagerStorage;
|
||||
import com.genersoft.iot.vmp.storager.dao.GbStreamMapper;
|
||||
import com.genersoft.iot.vmp.storager.dao.PlatformGbStreamMapper;
|
||||
import com.genersoft.iot.vmp.storager.dao.StreamPushMapper;
|
||||
import com.genersoft.iot.vmp.utils.DateUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.data.redis.connection.Message;
|
||||
import org.springframework.data.redis.connection.MessageListener;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.text.ParseException;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
|
||||
/**
|
||||
* @author lin
|
||||
*/
|
||||
@Component
|
||||
public class RedisPlatformPushStreamOnlineLister implements MessageListener {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger("RedisPlatformPushStreamOnlineLister");
|
||||
|
||||
private final ConcurrentLinkedQueue<Message> taskQueue = new ConcurrentLinkedQueue<>();
|
||||
|
||||
@Qualifier("taskExecutor")
|
||||
@Autowired
|
||||
private ThreadPoolTaskExecutor taskExecutor;
|
||||
|
||||
/**
|
||||
* 通过redis消息接收流上线的通知,如果本机由对这个流的监听,则回调
|
||||
*/
|
||||
@Override
|
||||
public void onMessage(Message message, byte[] pattern) {
|
||||
boolean isEmpty = taskQueue.isEmpty();
|
||||
taskQueue.offer(message);
|
||||
if (isEmpty) {
|
||||
taskExecutor.execute(() -> {
|
||||
while (!taskQueue.isEmpty()) {
|
||||
Message msg = taskQueue.poll();
|
||||
SendRtpItem sendRtpItem = JSON.parseObject(new String(msg.getBody()), SendRtpItem.class);
|
||||
sendStreamEvent(sendRtpItem);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private final Map<String, ChannelOnlineEvent> channelOnPublishEvents = new ConcurrentHashMap<>();
|
||||
|
||||
public void sendStreamEvent(SendRtpItem sendRtpItem) {
|
||||
// 查看推流状态
|
||||
ChannelOnlineEvent channelOnlineEventLister = getChannelOnlineEventLister(sendRtpItem.getApp(), sendRtpItem.getStream());
|
||||
if (channelOnlineEventLister != null) {
|
||||
try {
|
||||
channelOnlineEventLister.run(sendRtpItem);
|
||||
} catch (ParseException e) {
|
||||
logger.error("sendStreamEvent: ", e);
|
||||
}
|
||||
removedChannelOnlineEventLister(sendRtpItem.getApp(), sendRtpItem.getStream());
|
||||
}
|
||||
}
|
||||
|
||||
public void addChannelOnlineEventLister(String app, String stream, ChannelOnlineEvent callback) {
|
||||
this.channelOnPublishEvents.put(app + "_" + stream, callback);
|
||||
}
|
||||
|
||||
public void removedChannelOnlineEventLister(String app, String stream) {
|
||||
this.channelOnPublishEvents.remove(app + "_" + stream);
|
||||
}
|
||||
|
||||
public ChannelOnlineEvent getChannelOnlineEventLister(String app, String stream) {
|
||||
return this.channelOnPublishEvents.get(app + "_" + stream);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package com.genersoft.iot.vmp.service.redisMsg;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.genersoft.iot.vmp.conf.UserSetting;
|
||||
import com.genersoft.iot.vmp.gb28181.bean.SendRtpItem;
|
||||
import com.genersoft.iot.vmp.media.zlm.ZLMServerFactory;
|
||||
import com.genersoft.iot.vmp.media.zlm.ZlmHttpHookSubscribe;
|
||||
import com.genersoft.iot.vmp.media.zlm.dto.HookSubscribeFactory;
|
||||
import com.genersoft.iot.vmp.media.zlm.dto.HookSubscribeForStreamChange;
|
||||
import com.genersoft.iot.vmp.media.zlm.dto.MediaServerItem;
|
||||
import com.genersoft.iot.vmp.media.zlm.dto.hook.HookParam;
|
||||
import com.genersoft.iot.vmp.service.IMediaServerService;
|
||||
import com.genersoft.iot.vmp.service.bean.MessageForPushChannel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.data.redis.connection.Message;
|
||||
import org.springframework.data.redis.connection.MessageListener;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
|
||||
/**
|
||||
* 收到消息后开始给上级发流
|
||||
* @author lin
|
||||
*/
|
||||
@Component
|
||||
public class RedisPlatformStartSendRtpListener implements MessageListener {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(RedisPlatformStartSendRtpListener.class);
|
||||
|
||||
private ConcurrentLinkedQueue<Message> taskQueue = new ConcurrentLinkedQueue<>();
|
||||
|
||||
@Autowired
|
||||
private ZLMServerFactory zlmServerFactory;
|
||||
|
||||
@Autowired
|
||||
private IMediaServerService mediaServerService;
|
||||
|
||||
@Qualifier("taskExecutor")
|
||||
@Autowired
|
||||
private ThreadPoolTaskExecutor taskExecutor;
|
||||
|
||||
|
||||
@Override
|
||||
public void onMessage(Message message, byte[] bytes) {
|
||||
logger.info("[REDIS消息-收到上级等到设备推流的redis消息]: {}", new String(message.getBody()));
|
||||
boolean isEmpty = taskQueue.isEmpty();
|
||||
taskQueue.offer(message);
|
||||
if (isEmpty) {
|
||||
taskExecutor.execute(() -> {
|
||||
while (!taskQueue.isEmpty()) {
|
||||
Message msg = taskQueue.poll();
|
||||
try {
|
||||
SendRtpItem sendRtpItem = JSON.parseObject(new String(msg.getBody()), SendRtpItem.class);
|
||||
sendRtpItem.getMediaServerId();
|
||||
MediaServerItem mediaServer = mediaServerService.getOne(sendRtpItem.getMediaServerId());
|
||||
if (mediaServer == null) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> sendRtpParam = getSendRtpParam(sendRtpItem);
|
||||
sendRtp(sendRtpItem, mediaServer, sendRtpParam);
|
||||
|
||||
}catch (Exception e) {
|
||||
logger.warn("[REDIS消息-请求推流结果] 发现未处理的异常, \r\n{}", JSON.toJSONString(message));
|
||||
logger.error("[REDIS消息-请求推流结果] 异常内容: ", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> getSendRtpParam(SendRtpItem sendRtpItem) {
|
||||
String isUdp = sendRtpItem.isTcp() ? "0" : "1";
|
||||
Map<String, Object> param = new HashMap<>(12);
|
||||
param.put("vhost","__defaultVhost__");
|
||||
param.put("app",sendRtpItem.getApp());
|
||||
param.put("stream",sendRtpItem.getStream());
|
||||
param.put("ssrc", sendRtpItem.getSsrc());
|
||||
param.put("dst_url",sendRtpItem.getIp());
|
||||
param.put("dst_port", sendRtpItem.getPort());
|
||||
param.put("src_port", sendRtpItem.getLocalPort());
|
||||
param.put("pt", sendRtpItem.getPt());
|
||||
param.put("use_ps", sendRtpItem.isUsePs() ? "1" : "0");
|
||||
param.put("only_audio", sendRtpItem.isOnlyAudio() ? "1" : "0");
|
||||
param.put("is_udp", isUdp);
|
||||
if (!sendRtpItem.isTcp()) {
|
||||
// udp模式下开启rtcp保活
|
||||
param.put("udp_rtcp_timeout", sendRtpItem.isRtcp()? "1":"0");
|
||||
}
|
||||
return param;
|
||||
}
|
||||
|
||||
private JSONObject sendRtp(SendRtpItem sendRtpItem, MediaServerItem mediaInfo, Map<String, Object> param){
|
||||
JSONObject startSendRtpStreamResult = null;
|
||||
if (sendRtpItem.getLocalPort() != 0) {
|
||||
if (sendRtpItem.isTcpActive()) {
|
||||
startSendRtpStreamResult = zlmServerFactory.startSendRtpPassive(mediaInfo, param);
|
||||
}else {
|
||||
param.put("dst_url", sendRtpItem.getIp());
|
||||
param.put("dst_port", sendRtpItem.getPort());
|
||||
startSendRtpStreamResult = zlmServerFactory.startSendRtpStream(mediaInfo, param);
|
||||
}
|
||||
}else {
|
||||
if (sendRtpItem.isTcpActive()) {
|
||||
startSendRtpStreamResult = zlmServerFactory.startSendRtpPassive(mediaInfo, param);
|
||||
}else {
|
||||
param.put("dst_url", sendRtpItem.getIp());
|
||||
param.put("dst_port", sendRtpItem.getPort());
|
||||
startSendRtpStreamResult = zlmServerFactory.startSendRtpStream(mediaInfo, param);
|
||||
}
|
||||
}
|
||||
return startSendRtpStreamResult;
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package com.genersoft.iot.vmp.service.redisMsg;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.genersoft.iot.vmp.conf.UserSetting;
|
||||
import com.genersoft.iot.vmp.gb28181.bean.SendRtpItem;
|
||||
import com.genersoft.iot.vmp.gb28181.session.SSRCFactory;
|
||||
import com.genersoft.iot.vmp.media.zlm.ZlmHttpHookSubscribe;
|
||||
import com.genersoft.iot.vmp.media.zlm.dto.HookSubscribeFactory;
|
||||
import com.genersoft.iot.vmp.media.zlm.dto.HookSubscribeForStreamChange;
|
||||
import com.genersoft.iot.vmp.media.zlm.dto.MediaServerItem;
|
||||
import com.genersoft.iot.vmp.media.zlm.dto.hook.HookParam;
|
||||
import com.genersoft.iot.vmp.service.bean.MessageForPushChannel;
|
||||
import com.genersoft.iot.vmp.storager.IRedisCatchStorage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.data.redis.connection.Message;
|
||||
import org.springframework.data.redis.connection.MessageListener;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
|
||||
/**
|
||||
* 上级等到设备推流的redis消息
|
||||
* @author lin
|
||||
*/
|
||||
@Component
|
||||
public class RedisPlatformWaitPushStreamOnlineListener implements MessageListener {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(RedisPlatformWaitPushStreamOnlineListener.class);
|
||||
|
||||
private ConcurrentLinkedQueue<Message> taskQueue = new ConcurrentLinkedQueue<>();
|
||||
|
||||
@Autowired
|
||||
private UserSetting userSetting;
|
||||
|
||||
@Autowired
|
||||
private IRedisCatchStorage redisCatchStorage;
|
||||
|
||||
@Autowired
|
||||
private ZlmHttpHookSubscribe hookSubscribe;
|
||||
|
||||
@Autowired
|
||||
private RedisPlatformPushStreamOnlineLister redisPlatformPushStreamOnlineLister;
|
||||
|
||||
@Autowired
|
||||
private SSRCFactory ssrcFactory;
|
||||
|
||||
@Qualifier("taskExecutor")
|
||||
@Autowired
|
||||
private ThreadPoolTaskExecutor taskExecutor;
|
||||
|
||||
|
||||
/**
|
||||
* 当上级点播时,这里负责监听等到流上线,流上线后如果是在当前服务则直接回调,如果是其他wvp,则由redis消息进行通知
|
||||
*/
|
||||
@Override
|
||||
public void onMessage(Message message, byte[] bytes) {
|
||||
logger.info("[REDIS消息-收到上级等到设备推流的redis消息]: {}", new String(message.getBody()));
|
||||
boolean isEmpty = taskQueue.isEmpty();
|
||||
taskQueue.offer(message);
|
||||
if (isEmpty) {
|
||||
taskExecutor.execute(() -> {
|
||||
while (!taskQueue.isEmpty()) {
|
||||
Message msg = taskQueue.poll();
|
||||
try {
|
||||
MessageForPushChannel messageForPushChannel = JSON.parseObject(new String(msg.getBody()), MessageForPushChannel.class);
|
||||
if (messageForPushChannel == null
|
||||
|| ObjectUtils.isEmpty(messageForPushChannel.getApp())
|
||||
|| ObjectUtils.isEmpty(messageForPushChannel.getStream())
|
||||
|| userSetting.getServerId().equals(messageForPushChannel.getServerId())){
|
||||
continue;
|
||||
}
|
||||
|
||||
// 监听流上线。 流上线直接发送sendRtpItem消息给实际的信令处理者
|
||||
HookSubscribeForStreamChange hook = HookSubscribeFactory.on_stream_changed(
|
||||
messageForPushChannel.getApp(), messageForPushChannel.getStream(), true, "rtsp",
|
||||
null);
|
||||
hookSubscribe.addSubscribe(hook, (MediaServerItem mediaServerItemInUse, HookParam hookParam) -> {
|
||||
// 读取redis中的上级点播信息,生成sendRtpItm发送出去
|
||||
SendRtpItem sendRtpItem = redisCatchStorage.getWaiteSendRtpItem(messageForPushChannel.getApp(), messageForPushChannel.getStream());
|
||||
if (sendRtpItem.getSsrc() == null) {
|
||||
// 上级平台点播时不使用上级平台指定的ssrc,使用自定义的ssrc,参考国标文档-点播外域设备媒体流SSRC处理方式
|
||||
String ssrc = "Play".equalsIgnoreCase(sendRtpItem.getSessionName()) ? ssrcFactory.getPlaySsrc(mediaServerItemInUse.getId()) : ssrcFactory.getPlayBackSsrc(mediaServerItemInUse.getId());
|
||||
sendRtpItem.setSsrc(ssrc);
|
||||
sendRtpItem.setMediaServerId(mediaServerItemInUse.getId());
|
||||
sendRtpItem.setLocalIp(mediaServerItemInUse.getSdpIp());
|
||||
redisPlatformPushStreamOnlineLister.sendStreamEvent(sendRtpItem);
|
||||
// 通知其他wvp, 由RedisPlatformPushStreamOnlineLister接收此监听。
|
||||
redisCatchStorage.sendPushStreamOnline(sendRtpItem);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
}catch (Exception e) {
|
||||
logger.warn("[REDIS消息-请求推流结果] 发现未处理的异常, \r\n{}", JSON.toJSONString(message));
|
||||
logger.error("[REDIS消息-请求推流结果] 异常内容: ", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package com.genersoft.iot.vmp.service.redisMsg;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.genersoft.iot.vmp.conf.UserSetting;
|
||||
import com.genersoft.iot.vmp.gb28181.bean.ParentPlatform;
|
||||
import com.genersoft.iot.vmp.gb28181.bean.SendRtpItem;
|
||||
import com.genersoft.iot.vmp.gb28181.transmit.cmd.ISIPCommanderForPlatform;
|
||||
import com.genersoft.iot.vmp.media.zlm.ZLMServerFactory;
|
||||
import com.genersoft.iot.vmp.media.zlm.dto.StreamPushItem;
|
||||
import com.genersoft.iot.vmp.service.IMediaServerService;
|
||||
import com.genersoft.iot.vmp.service.IStreamPushService;
|
||||
import com.genersoft.iot.vmp.service.bean.MessageForPushChannel;
|
||||
import com.genersoft.iot.vmp.service.bean.MessageForPushChannelResponse;
|
||||
import com.genersoft.iot.vmp.storager.IRedisCatchStorage;
|
||||
import com.genersoft.iot.vmp.storager.IVideoManagerStorage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.connection.Message;
|
||||
import org.springframework.data.redis.connection.MessageListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.sip.InvalidArgumentException;
|
||||
import javax.sip.SipException;
|
||||
import java.text.ParseException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 接收redis发送的结束推流请求
|
||||
* @author lin
|
||||
*/
|
||||
@Component
|
||||
public class RedisPushStreamCloseResponseListener implements MessageListener {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(RedisPushStreamCloseResponseListener.class);
|
||||
|
||||
@Autowired
|
||||
private IStreamPushService streamPushService;
|
||||
|
||||
@Autowired
|
||||
private IRedisCatchStorage redisCatchStorage;
|
||||
|
||||
@Autowired
|
||||
private IVideoManagerStorage storager;
|
||||
|
||||
@Autowired
|
||||
private ISIPCommanderForPlatform commanderFroPlatform;
|
||||
|
||||
@Autowired
|
||||
private UserSetting userSetting;
|
||||
|
||||
@Autowired
|
||||
private IMediaServerService mediaServerService;
|
||||
|
||||
@Autowired
|
||||
private ZLMServerFactory zlmServerFactory;
|
||||
|
||||
|
||||
private Map<String, PushStreamResponseEvent> responseEvents = new ConcurrentHashMap<>();
|
||||
|
||||
public interface PushStreamResponseEvent{
|
||||
void run(MessageForPushChannelResponse response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(Message message, byte[] bytes) {
|
||||
logger.info("[REDIS消息-推流结束]: {}", new String(message.getBody()));
|
||||
MessageForPushChannel pushChannel = JSON.parseObject(message.getBody(), MessageForPushChannel.class);
|
||||
StreamPushItem push = streamPushService.getPush(pushChannel.getApp(), pushChannel.getStream());
|
||||
if (push != null) {
|
||||
List<SendRtpItem> sendRtpItems = redisCatchStorage.querySendRTPServerByChannelId(
|
||||
push.getGbId());
|
||||
if (!sendRtpItems.isEmpty()) {
|
||||
for (SendRtpItem sendRtpItem : sendRtpItems) {
|
||||
ParentPlatform parentPlatform = storager.queryParentPlatByServerGBId(sendRtpItem.getPlatformId());
|
||||
if (parentPlatform != null) {
|
||||
redisCatchStorage.deleteSendRTPServer(sendRtpItem.getPlatformId(), sendRtpItem.getChannelId(), sendRtpItem.getCallId(), sendRtpItem.getStream());
|
||||
try {
|
||||
commanderFroPlatform.streamByeCmd(parentPlatform, sendRtpItem);
|
||||
} catch (SipException | InvalidArgumentException | ParseException e) {
|
||||
logger.error("[命令发送失败] 国标级联 发送BYE: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void addEvent(String app, String stream, PushStreamResponseEvent callback) {
|
||||
responseEvents.put(app + stream, callback);
|
||||
}
|
||||
|
||||
public void removeEvent(String app, String stream) {
|
||||
responseEvents.remove(app + stream);
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package com.genersoft.iot.vmp.service.redisMsg;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.genersoft.iot.vmp.service.bean.MessageForPushChannelResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.data.redis.connection.Message;
|
||||
import org.springframework.data.redis.connection.MessageListener;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
|
||||
/**
|
||||
* 接收redis返回的推流结果
|
||||
* @author lin
|
||||
*/
|
||||
@Component
|
||||
public class RedisPushStreamResponseListener implements MessageListener {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(RedisPushStreamResponseListener.class);
|
||||
|
||||
private ConcurrentLinkedQueue<Message> taskQueue = new ConcurrentLinkedQueue<>();
|
||||
|
||||
@Qualifier("taskExecutor")
|
||||
@Autowired
|
||||
private ThreadPoolTaskExecutor taskExecutor;
|
||||
|
||||
|
||||
private Map<String, PushStreamResponseEvent> responseEvents = new ConcurrentHashMap<>();
|
||||
|
||||
public interface PushStreamResponseEvent{
|
||||
void run(MessageForPushChannelResponse response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(Message message, byte[] bytes) {
|
||||
logger.info("[REDIS消息-请求推流结果]: {}", new String(message.getBody()));
|
||||
boolean isEmpty = taskQueue.isEmpty();
|
||||
taskQueue.offer(message);
|
||||
if (isEmpty) {
|
||||
taskExecutor.execute(() -> {
|
||||
while (!taskQueue.isEmpty()) {
|
||||
Message msg = taskQueue.poll();
|
||||
try {
|
||||
MessageForPushChannelResponse response = JSON.parseObject(new String(msg.getBody()), MessageForPushChannelResponse.class);
|
||||
if (response == null || ObjectUtils.isEmpty(response.getApp()) || ObjectUtils.isEmpty(response.getStream())){
|
||||
logger.info("[REDIS消息-请求推流结果]:参数不全");
|
||||
continue;
|
||||
}
|
||||
// 查看正在等待的invite消息
|
||||
if (responseEvents.get(response.getApp() + response.getStream()) != null) {
|
||||
responseEvents.get(response.getApp() + response.getStream()).run(response);
|
||||
}
|
||||
}catch (Exception e) {
|
||||
logger.warn("[REDIS消息-请求推流结果] 发现未处理的异常, \r\n{}", JSON.toJSONString(message));
|
||||
logger.error("[REDIS消息-请求推流结果] 异常内容: ", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void addEvent(String app, String stream, PushStreamResponseEvent callback) {
|
||||
responseEvents.put(app + stream, callback);
|
||||
}
|
||||
|
||||
public void removeEvent(String app, String stream) {
|
||||
responseEvents.remove(app + stream);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.genersoft.iot.vmp.service.redisMsg.control;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.genersoft.iot.vmp.conf.UserSetting;
|
||||
import com.genersoft.iot.vmp.conf.redis.RedisRpcConfig;
|
||||
import com.genersoft.iot.vmp.conf.redis.bean.RedisRpcMessage;
|
||||
import com.genersoft.iot.vmp.conf.redis.bean.RedisRpcRequest;
|
||||
import com.genersoft.iot.vmp.conf.redis.bean.RedisRpcResponse;
|
||||
import com.genersoft.iot.vmp.gb28181.bean.SendRtpItem;
|
||||
import com.genersoft.iot.vmp.gb28181.session.SSRCFactory;
|
||||
import com.genersoft.iot.vmp.media.zlm.SendRtpPortManager;
|
||||
import com.genersoft.iot.vmp.media.zlm.ZLMServerFactory;
|
||||
import com.genersoft.iot.vmp.media.zlm.ZlmHttpHookSubscribe;
|
||||
import com.genersoft.iot.vmp.media.zlm.dto.HookSubscribeFactory;
|
||||
import com.genersoft.iot.vmp.media.zlm.dto.HookSubscribeForStreamChange;
|
||||
import com.genersoft.iot.vmp.media.zlm.dto.MediaServerItem;
|
||||
import com.genersoft.iot.vmp.media.zlm.dto.hook.HookParam;
|
||||
import com.genersoft.iot.vmp.service.IMediaServerService;
|
||||
import com.genersoft.iot.vmp.storager.IRedisCatchStorage;
|
||||
import com.genersoft.iot.vmp.vmanager.bean.WVPResult;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 其他wvp发起的rpc调用,这里的方法被 RedisRpcConfig 通过反射寻找对应的方法名称调用
|
||||
*/
|
||||
@Component
|
||||
public class RedisRpcController {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(RedisRpcController.class);
|
||||
|
||||
@Autowired
|
||||
private SSRCFactory ssrcFactory;
|
||||
|
||||
@Autowired
|
||||
private IMediaServerService mediaServerService;
|
||||
|
||||
@Autowired
|
||||
private SendRtpPortManager sendRtpPortManager;
|
||||
|
||||
@Autowired
|
||||
private IRedisCatchStorage redisCatchStorage;
|
||||
|
||||
@Autowired
|
||||
private UserSetting userSetting;
|
||||
|
||||
@Autowired
|
||||
private ZlmHttpHookSubscribe hookSubscribe;
|
||||
|
||||
@Autowired
|
||||
private ZLMServerFactory zlmServerFactory;
|
||||
|
||||
|
||||
@Autowired
|
||||
private RedisTemplate<Object, Object> redisTemplate;
|
||||
|
||||
|
||||
/**
|
||||
* 获取发流的信息
|
||||
*/
|
||||
public RedisRpcResponse getSendRtpItem(RedisRpcRequest request) {
|
||||
SendRtpItem sendRtpItem = JSON.parseObject(request.getParam().toString(), SendRtpItem.class);
|
||||
logger.info("[redis-rpc] 获取发流的信息: {}/{}", sendRtpItem.getApp(), sendRtpItem.getStream() );
|
||||
// 查询本级是否有这个流
|
||||
MediaServerItem mediaServerItem = mediaServerService.getMediaServerByAppAndStream(sendRtpItem.getApp(), sendRtpItem.getStream());
|
||||
if (mediaServerItem == null) {
|
||||
RedisRpcResponse response = request.getResponse();
|
||||
response.setStatusCode(200);
|
||||
}
|
||||
// 自平台内容
|
||||
int localPort = sendRtpPortManager.getNextPort(mediaServerItem);
|
||||
if (localPort == 0) {
|
||||
logger.info("[redis-rpc] getSendRtpItem->服务器端口资源不足" );
|
||||
RedisRpcResponse response = request.getResponse();
|
||||
response.setStatusCode(200);
|
||||
}
|
||||
// 写入redis, 超时时回复
|
||||
sendRtpItem.setStatus(1);
|
||||
sendRtpItem.setServerId(userSetting.getServerId());
|
||||
sendRtpItem.setLocalIp(mediaServerItem.getSdpIp());
|
||||
if (sendRtpItem.getSsrc() == null) {
|
||||
// 上级平台点播时不使用上级平台指定的ssrc,使用自定义的ssrc,参考国标文档-点播外域设备媒体流SSRC处理方式
|
||||
String ssrc = "Play".equalsIgnoreCase(sendRtpItem.getSessionName()) ? ssrcFactory.getPlaySsrc(mediaServerItem.getId()) : ssrcFactory.getPlayBackSsrc(mediaServerItem.getId());
|
||||
sendRtpItem.setSsrc(ssrc);
|
||||
}
|
||||
redisCatchStorage.updateSendRTPSever(sendRtpItem);
|
||||
RedisRpcResponse response = request.getResponse();
|
||||
response.setStatusCode(200);
|
||||
response.setBody(sendRtpItem);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听流上线
|
||||
*/
|
||||
public RedisRpcResponse waitePushStreamOnline(RedisRpcRequest request) {
|
||||
SendRtpItem sendRtpItem = JSON.parseObject(request.getParam().toString(), SendRtpItem.class);
|
||||
logger.info("[redis-rpc] 监听流上线: {}/{}", sendRtpItem.getApp(), sendRtpItem.getStream() );
|
||||
// 查询本级是否有这个流
|
||||
MediaServerItem mediaServerItem = mediaServerService.getMediaServerByAppAndStream(sendRtpItem.getApp(), sendRtpItem.getStream());
|
||||
if (mediaServerItem != null) {
|
||||
logger.info("[redis-rpc] 监听流上线时发现流已存在直接返回: {}/{}", sendRtpItem.getApp(), sendRtpItem.getStream() );
|
||||
RedisRpcResponse response = request.getResponse();
|
||||
response.setBody(sendRtpItem);
|
||||
response.setStatusCode(200);
|
||||
}
|
||||
// 监听流上线。 流上线直接发送sendRtpItem消息给实际的信令处理者
|
||||
HookSubscribeForStreamChange hook = HookSubscribeFactory.on_stream_changed(
|
||||
sendRtpItem.getApp(), sendRtpItem.getStream(), true, "rtsp", null);
|
||||
|
||||
hookSubscribe.addSubscribe(hook, (MediaServerItem mediaServerItemInUse, HookParam hookParam) -> {
|
||||
logger.info("[redis-rpc] 监听流上线,流已上线: {}/{}", sendRtpItem.getApp(), sendRtpItem.getStream() );
|
||||
// 读取redis中的上级点播信息,生成sendRtpItm发送出去
|
||||
if (sendRtpItem.getSsrc() == null) {
|
||||
// 上级平台点播时不使用上级平台指定的ssrc,使用自定义的ssrc,参考国标文档-点播外域设备媒体流SSRC处理方式
|
||||
String ssrc = "Play".equalsIgnoreCase(sendRtpItem.getSessionName()) ? ssrcFactory.getPlaySsrc(mediaServerItemInUse.getId()) : ssrcFactory.getPlayBackSsrc(mediaServerItemInUse.getId());
|
||||
sendRtpItem.setSsrc(ssrc);
|
||||
}
|
||||
sendRtpItem.setMediaServerId(mediaServerItemInUse.getId());
|
||||
sendRtpItem.setLocalIp(mediaServerItemInUse.getSdpIp());
|
||||
sendRtpItem.setServerId(userSetting.getServerId());
|
||||
RedisRpcResponse response = request.getResponse();
|
||||
response.setBody(sendRtpItem);
|
||||
response.setStatusCode(200);
|
||||
// 手动发送结果
|
||||
sendResponse(response);
|
||||
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 开始发流
|
||||
*/
|
||||
public RedisRpcResponse startSendRtp(RedisRpcRequest request) {
|
||||
SendRtpItem sendRtpItem = JSON.parseObject(request.getParam().toString(), SendRtpItem.class);
|
||||
MediaServerItem mediaServerItem = mediaServerService.getOne(sendRtpItem.getMediaServerId());
|
||||
if (mediaServerItem == null) {
|
||||
logger.info("[redis-rpc] startSendRtp->未找到MediaServer: {}", sendRtpItem.getMediaServerId() );
|
||||
RedisRpcResponse response = request.getResponse();
|
||||
response.setStatusCode(200);
|
||||
}
|
||||
|
||||
Boolean streamReady = zlmServerFactory.isStreamReady(mediaServerItem, sendRtpItem.getApp(), sendRtpItem.getStream());
|
||||
if (!streamReady) {
|
||||
logger.info("[redis-rpc] startSendRtp->流不在线: {}/{}", sendRtpItem.getApp(), sendRtpItem.getStream() );
|
||||
RedisRpcResponse response = request.getResponse();
|
||||
response.setStatusCode(200);
|
||||
}
|
||||
JSONObject jsonObject = zlmServerFactory.startSendRtp(mediaServerItem, sendRtpItem);
|
||||
RedisRpcResponse response = request.getResponse();
|
||||
response.setStatusCode(200);
|
||||
if (jsonObject.getInteger("code") == 0) {
|
||||
WVPResult wvpResult = WVPResult.success();
|
||||
response.setBody(wvpResult);
|
||||
}else {
|
||||
WVPResult wvpResult = WVPResult.fail(jsonObject.getInteger("code"), jsonObject.getString("msg"));
|
||||
response.setBody(wvpResult);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止发流
|
||||
*/
|
||||
public RedisRpcResponse stopSendRtp(RedisRpcRequest request) {
|
||||
SendRtpItem sendRtpItem = JSON.parseObject(request.getParam().toString(), SendRtpItem.class);
|
||||
logger.info("[redis-rpc] 停止推流: {}/{}", sendRtpItem.getApp(), sendRtpItem.getStream() );
|
||||
MediaServerItem mediaServerItem = mediaServerService.getOne(sendRtpItem.getMediaServerId());
|
||||
if (mediaServerItem == null) {
|
||||
logger.info("[redis-rpc] stopSendRtp->未找到MediaServer: {}", sendRtpItem.getMediaServerId() );
|
||||
RedisRpcResponse response = request.getResponse();
|
||||
response.setStatusCode(200);
|
||||
}
|
||||
JSONObject jsonObject = zlmServerFactory.stopSendRtpStream(mediaServerItem, sendRtpItem);
|
||||
RedisRpcResponse response = request.getResponse();
|
||||
response.setStatusCode(200);
|
||||
if (jsonObject.getInteger("code") == 0) {
|
||||
logger.info("[redis-rpc] 停止推流成功: {}/{}", sendRtpItem.getApp(), sendRtpItem.getStream() );
|
||||
WVPResult wvpResult = WVPResult.success();
|
||||
response.setBody(wvpResult);
|
||||
}else {
|
||||
int code = jsonObject.getInteger("code");
|
||||
String msg = jsonObject.getString("msg");
|
||||
logger.info("[redis-rpc] 停止推流失败: {}/{}, code: {}, msg: {}", sendRtpItem.getApp(), sendRtpItem.getStream(),code, msg );
|
||||
WVPResult wvpResult = WVPResult.fail(code, msg);
|
||||
response.setBody(wvpResult);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private void sendResponse(RedisRpcResponse response){
|
||||
response.setToId(userSetting.getServerId());
|
||||
RedisRpcMessage message = new RedisRpcMessage();
|
||||
message.setResponse(response);
|
||||
redisTemplate.convertAndSend(RedisRpcConfig.REDIS_REQUEST_CHANNEL_KEY, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.genersoft.iot.vmp.service.redisMsg.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.genersoft.iot.vmp.common.CommonCallback;
|
||||
import com.genersoft.iot.vmp.conf.UserSetting;
|
||||
import com.genersoft.iot.vmp.conf.redis.RedisRpcConfig;
|
||||
import com.genersoft.iot.vmp.conf.redis.bean.RedisRpcRequest;
|
||||
import com.genersoft.iot.vmp.conf.redis.bean.RedisRpcResponse;
|
||||
import com.genersoft.iot.vmp.gb28181.bean.SendRtpItem;
|
||||
import com.genersoft.iot.vmp.gb28181.session.SSRCFactory;
|
||||
import com.genersoft.iot.vmp.media.zlm.ZlmHttpHookSubscribe;
|
||||
import com.genersoft.iot.vmp.media.zlm.dto.HookSubscribeFactory;
|
||||
import com.genersoft.iot.vmp.media.zlm.dto.HookSubscribeForStreamChange;
|
||||
import com.genersoft.iot.vmp.media.zlm.dto.MediaServerItem;
|
||||
import com.genersoft.iot.vmp.media.zlm.dto.hook.HookParam;
|
||||
import com.genersoft.iot.vmp.service.redisMsg.IRedisRpcService;
|
||||
import com.genersoft.iot.vmp.vmanager.bean.WVPResult;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class RedisRpcServiceImpl implements IRedisRpcService {
|
||||
|
||||
|
||||
@Autowired
|
||||
private RedisRpcConfig redisRpcConfig;
|
||||
|
||||
@Autowired
|
||||
private UserSetting userSetting;
|
||||
|
||||
@Autowired
|
||||
private ZlmHttpHookSubscribe hookSubscribe;
|
||||
|
||||
@Autowired
|
||||
private SSRCFactory ssrcFactory;
|
||||
|
||||
private RedisRpcRequest buildRequest(String uri, Object param) {
|
||||
RedisRpcRequest request = new RedisRpcRequest();
|
||||
request.setFromId(userSetting.getServerId());
|
||||
request.setParam(param);
|
||||
request.setUri(uri);
|
||||
return request;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SendRtpItem getSendRtpItem(SendRtpItem sendRtpItem) {
|
||||
|
||||
RedisRpcRequest request = buildRequest("getSendRtpItem", sendRtpItem);
|
||||
RedisRpcResponse response = redisRpcConfig.request(request, 10);
|
||||
return JSON.parseObject(response.getBody().toString(), SendRtpItem.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public WVPResult startSendRtp(SendRtpItem sendRtpItem) {
|
||||
RedisRpcRequest request = buildRequest("startSendRtp", sendRtpItem);
|
||||
request.setToId(sendRtpItem.getServerId());
|
||||
RedisRpcResponse response = redisRpcConfig.request(request, 10);
|
||||
return JSON.parseObject(response.getBody().toString(), WVPResult.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public WVPResult stopSendRtp(SendRtpItem sendRtpItem) {
|
||||
RedisRpcRequest request = buildRequest("stopSendRtp", sendRtpItem);
|
||||
request.setToId(sendRtpItem.getServerId());
|
||||
RedisRpcResponse response = redisRpcConfig.request(request, 10);
|
||||
return JSON.parseObject(response.getBody().toString(), WVPResult.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void waitePushStreamOnline(SendRtpItem sendRtpItem, CommonCallback<SendRtpItem> callback) {
|
||||
// 监听流上线。 流上线直接发送sendRtpItem消息给实际的信令处理者
|
||||
HookSubscribeForStreamChange hook = HookSubscribeFactory.on_stream_changed(
|
||||
sendRtpItem.getApp(), sendRtpItem.getStream(), true, "rtsp", null);
|
||||
hookSubscribe.addSubscribe(hook, (MediaServerItem mediaServerItemInUse, HookParam hookParam) -> {
|
||||
|
||||
// 读取redis中的上级点播信息,生成sendRtpItm发送出去
|
||||
if (sendRtpItem.getSsrc() == null) {
|
||||
// 上级平台点播时不使用上级平台指定的ssrc,使用自定义的ssrc,参考国标文档-点播外域设备媒体流SSRC处理方式
|
||||
String ssrc = "Play".equalsIgnoreCase(sendRtpItem.getSessionName()) ? ssrcFactory.getPlaySsrc(mediaServerItemInUse.getId()) : ssrcFactory.getPlayBackSsrc(mediaServerItemInUse.getId());
|
||||
sendRtpItem.setSsrc(ssrc);
|
||||
}
|
||||
sendRtpItem.setMediaServerId(mediaServerItemInUse.getId());
|
||||
sendRtpItem.setLocalIp(mediaServerItemInUse.getSdpIp());
|
||||
sendRtpItem.setServerId(userSetting.getServerId());
|
||||
if (callback != null) {
|
||||
callback.run(sendRtpItem);
|
||||
}
|
||||
hookSubscribe.removeSubscribe(hook);
|
||||
});
|
||||
RedisRpcRequest request = buildRequest("waitePushStreamOnline", sendRtpItem);
|
||||
request.setToId(sendRtpItem.getServerId());
|
||||
redisRpcConfig.request(request, response -> {
|
||||
SendRtpItem sendRtpItemFromOther = JSON.parseObject(response.getBody().toString(), SendRtpItem.class);
|
||||
if (callback != null) {
|
||||
callback.run(sendRtpItemFromOther);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user