流媒体接口逻辑优化
This commit is contained in:
@@ -21,4 +21,6 @@ public class ZLMediaKitConfig {
|
||||
|
||||
private String vhost;
|
||||
|
||||
private String pushStreamUrl;
|
||||
|
||||
}
|
||||
|
@@ -108,6 +108,7 @@ public class SisDeviceManageController extends BaseController {
|
||||
return toAjax(sisDeviceManageService.deleteWithValidByIds(List.of(ids), true));
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/tree")
|
||||
public R<List<TreeNode<Long>>> tree() {
|
||||
return R.ok(sisDeviceManageService.tree());
|
||||
|
@@ -1,14 +1,11 @@
|
||||
package org.dromara.sis.controller.zkmedia;
|
||||
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.sis.api.enums.FactoryNoEnum;
|
||||
import org.dromara.sis.sdk.zkmedia.MediaServerUtils;
|
||||
import org.dromara.sis.sdk.zkmedia.ZLMediaKitService;
|
||||
import org.dromara.sis.sdk.zkmedia.model.AddStreamProxy;
|
||||
import org.dromara.sis.sdk.zkmedia.model.AddStreamProxyResp;
|
||||
import org.dromara.sis.sdk.zkmedia.model.StartStreamProxy;
|
||||
import org.dromara.sis.sdk.zkmedia.model.StreamPlay;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
@@ -31,35 +28,15 @@ public class ZKLmediaController {
|
||||
@Resource
|
||||
private ZLMediaKitService zlMediaKitService;
|
||||
|
||||
|
||||
private static final String HIK_REALTIME_RTSP_TEMPLATE = "rtsp://%s:%s@%s:%s/Streaming/Channels/%s";
|
||||
private static final String DAHUA_REALTIME_RTSP_TEMPLATE = "rtsp://%s:%s@%s:%s/cam/realmonitor?channel=%s&subtype=0";
|
||||
|
||||
private static final String HIK_HISTORY_RTSP_TEMPLATE = "rtsp://%s:%s@%s:%s/Streaming/tracks/%s?starttime=%s&endtime=%s";
|
||||
private static final String DAHUA_HISTORY_RTSP_TEMPLATE = "rtsp://%s:%s@%s:%s/cam/playback?channel=%s&subtype=0&starttime=%s&endtime=%s";
|
||||
|
||||
/**
|
||||
* 创建拉流任务,返回null代表创建拉流任务失败
|
||||
*
|
||||
* @param data 创建拉流设备信息(如果外网不建议使用这种方式)
|
||||
* @return 返回拉流任务信息
|
||||
* @return 返回播放地址
|
||||
*/
|
||||
@PostMapping("/realtime/add")
|
||||
public R<AddStreamProxyResp> alarm(@RequestBody @Validated AddStreamProxy data) {
|
||||
StartStreamProxy proxy = new StartStreamProxy();
|
||||
proxy.setApp("realtime");
|
||||
// 实时流不用每次都去拉流,流不存在的情况下在拉取
|
||||
String streanStr = data.getVideoIp() + "_" + data.getChannelId();
|
||||
// proxy.setStream(SecureUtil.md5(streanStr));
|
||||
proxy.setStream(IdUtil.fastSimpleUUID());
|
||||
if (FactoryNoEnum.HIK.getCode().equals(data.getFactoryNo())) {
|
||||
proxy.setUrl(String.format(HIK_REALTIME_RTSP_TEMPLATE, data.getAccount(), data.getPwd(), data.getVideoIp(), data.getVideoPort(), data.getChannelId()));
|
||||
} else if (FactoryNoEnum.DAHUA.getCode().equals(data.getFactoryNo())) {
|
||||
proxy.setUrl(String.format(DAHUA_REALTIME_RTSP_TEMPLATE, data.getAccount(), data.getPwd(), data.getVideoIp(), data.getVideoPort(), data.getChannelId()));
|
||||
} else {
|
||||
throw new RuntimeException("未知的设备类型!");
|
||||
}
|
||||
AddStreamProxyResp addStreamProxyResp = zlMediaKitService.addStreamProxy(proxy);
|
||||
public R<AddStreamProxyResp> addStreamProxy(@RequestBody @Validated AddStreamProxy data) {
|
||||
AddStreamProxyResp addStreamProxyResp = zlMediaKitService.addStreamProxy(data);
|
||||
if (addStreamProxyResp != null) {
|
||||
return R.ok(addStreamProxyResp);
|
||||
}
|
||||
@@ -67,23 +44,29 @@ public class ZKLmediaController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建拉流任务,返回null代表创建拉流任务失败
|
||||
* 新增ffmpeg拉流代理
|
||||
*
|
||||
* @param data 创建拉流设备信息(如果外网不建议使用这种方式)
|
||||
* @return 返回拉流任务信息
|
||||
* @return 返回播放地址
|
||||
*/
|
||||
@PostMapping("/realtime/addFfmpeg")
|
||||
public R<AddStreamProxyResp> addFfmpegTask(@RequestBody @Validated AddStreamProxy data) {
|
||||
String sourceUrl = "";
|
||||
if (FactoryNoEnum.HIK.getCode().equals(data.getFactoryNo())) {
|
||||
sourceUrl = String.format(HIK_REALTIME_RTSP_TEMPLATE, data.getAccount(), data.getPwd(), data.getVideoIp(), data.getVideoPort(), data.getChannelId());
|
||||
} else if (FactoryNoEnum.DAHUA.getCode().equals(data.getFactoryNo())) {
|
||||
sourceUrl = String.format(DAHUA_REALTIME_RTSP_TEMPLATE, data.getAccount(), data.getPwd(), data.getVideoIp(), data.getVideoPort(), data.getChannelId());
|
||||
} else {
|
||||
throw new RuntimeException("未知的设备类型!");
|
||||
@PostMapping("/f/proxy")
|
||||
public R<AddStreamProxyResp> addFfmpegStreamProxy(@RequestBody @Validated AddStreamProxy data) {
|
||||
AddStreamProxyResp addStreamProxyResp = zlMediaKitService.addFfmpegStreamProxy(data);
|
||||
if (addStreamProxyResp != null) {
|
||||
return R.ok(addStreamProxyResp);
|
||||
}
|
||||
return R.fail();
|
||||
}
|
||||
|
||||
AddStreamProxyResp addStreamProxyResp = zlMediaKitService.addFFmpegSource(sourceUrl);
|
||||
/**
|
||||
* 通过是设备ip和通道新增拉流代理
|
||||
*
|
||||
* @param streamPlay 拉流参数
|
||||
* @return 返回播放地址
|
||||
*/
|
||||
@PostMapping("/proxy")
|
||||
public R<AddStreamProxyResp> addStreamProxy(@RequestBody @Validated StreamPlay streamPlay) {
|
||||
AddStreamProxyResp addStreamProxyResp = zlMediaKitService.addStreamProxy(streamPlay);
|
||||
if (addStreamProxyResp != null) {
|
||||
return R.ok(addStreamProxyResp);
|
||||
}
|
||||
@@ -92,62 +75,17 @@ public class ZKLmediaController {
|
||||
|
||||
|
||||
/**
|
||||
* 创建历史回放拉流任务,返回null代表创建拉流任务失败
|
||||
* 通过是设备ip和通道新增ffmpeg拉流代理
|
||||
*
|
||||
* @param data 创建拉流设备信息(如果外网不建议使用这种方式)
|
||||
* @return 返回拉流任务信息
|
||||
* @param data 拉流参数
|
||||
* @return 返回播放地址
|
||||
*/
|
||||
@PostMapping("/history/add")
|
||||
public R<AddStreamProxyResp> history(@RequestBody @Validated AddStreamProxy data) throws InterruptedException {
|
||||
StartStreamProxy proxy = new StartStreamProxy();
|
||||
proxy.setApp("history");
|
||||
String s = IdUtil.fastSimpleUUID();
|
||||
proxy.setStream(s);
|
||||
if ("DS1010".equals(data.getFactoryNo())) {
|
||||
String pattern = "yyyyMMdd'T'HHmmss'Z'";
|
||||
String startTime = MediaServerUtils.formatTimestamp(data.getStartTime(), "yyyyMMdd'T'HHmmss'Z'");
|
||||
String endTime = MediaServerUtils.formatTimestamp(data.getEndTime(), "yyyyMMdd'T'HHmmss'Z'");
|
||||
proxy.setUrl(String.format(HIK_HISTORY_RTSP_TEMPLATE, data.getAccount(), data.getPwd(), data.getVideoIp(), data.getVideoPort(), data.getChannelId(), startTime, endTime));
|
||||
} else if ("DS1014".equals(data.getFactoryNo())) {
|
||||
String startTime = MediaServerUtils.formatTimestamp(data.getStartTime(), "yyyy_MM_dd_HH_mm_ss");
|
||||
String endTime = MediaServerUtils.formatTimestamp(data.getEndTime(), "yyyy_MM_dd_HH_mm_ss");
|
||||
proxy.setUrl(String.format(DAHUA_HISTORY_RTSP_TEMPLATE, data.getAccount(), data.getPwd(), data.getVideoIp(), data.getVideoPort(), data.getChannelId(), startTime, endTime));
|
||||
} else {
|
||||
throw new RuntimeException("未知的设备类型!");
|
||||
}
|
||||
AddStreamProxyResp addStreamProxyResp = zlMediaKitService.addStreamProxy(proxy);
|
||||
@PostMapping("/f/proxy")
|
||||
public R<AddStreamProxyResp> addFfmpegStreamProxy(@RequestBody @Validated StreamPlay data) {
|
||||
AddStreamProxyResp addStreamProxyResp = zlMediaKitService.addFfmpegStreamProxy(data);
|
||||
if (addStreamProxyResp != null) {
|
||||
return R.ok(addStreamProxyResp);
|
||||
}
|
||||
return R.fail();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询设备的信息
|
||||
*
|
||||
* @param data 设备ip
|
||||
* @return
|
||||
*/
|
||||
/*@PostMapping("/queryMediaInfo")
|
||||
public R<TpEqpAcquisitionDTO> queryMediaInfo(@RequestBody VideoConfigTreeDTO data) {
|
||||
if (StringUtils.isEmpty(data.getVideoIp())) {
|
||||
throw new BizException(ErrorType.VIDEOIP_FAIL, "视频IP不能为空");
|
||||
}
|
||||
logger.info("查询视频信息,参数:{}", JSONObject.toJSON(data.getVideoIp()));
|
||||
TpEqpAcquisitionDTO result = tpEqpAcquisitionService.queryConfigByEqpNo(data.getVideoIp());
|
||||
logger.info("查询视频信息,返回信息:{}", JSONObject.toJSON(result));
|
||||
return BizResultVO.success(result);
|
||||
}
|
||||
|
||||
@PostMapping("/history/delete/{stream}")
|
||||
public BizResultVO<String> delete(@PathVariable("stream") String stream) throws InterruptedException {
|
||||
StartStreamProxy proxy = new StartStreamProxy();
|
||||
proxy.setApp("history");
|
||||
proxy.setStream(stream);
|
||||
String ss = zlMediaKitService.delStreamProxy(proxy);
|
||||
if (ss != null) {
|
||||
return BizResultVO.success(ss);
|
||||
}
|
||||
return BizResultVO.fail(ErrorType.ADD_STREAMPROXY_FAIL, null);
|
||||
}*/
|
||||
}
|
||||
|
@@ -1,64 +1,46 @@
|
||||
package org.dromara.sis.sdk.zkmedia;
|
||||
|
||||
|
||||
import org.dromara.sis.sdk.zkmedia.model.AddStreamProxy;
|
||||
import org.dromara.sis.sdk.zkmedia.model.AddStreamProxyResp;
|
||||
import org.dromara.sis.sdk.zkmedia.model.R;
|
||||
import org.dromara.sis.sdk.zkmedia.model.StartStreamProxy;
|
||||
import org.dromara.sis.sdk.zkmedia.model.ThreadsLoadDelay;
|
||||
|
||||
import java.util.List;
|
||||
import org.dromara.sis.sdk.zkmedia.model.StreamPlay;
|
||||
|
||||
/**
|
||||
* 拉流服务
|
||||
* @author lxj
|
||||
*/
|
||||
public interface ZLMediaKitService {
|
||||
|
||||
/**
|
||||
* 获取各后台 epoll(或 select)线程负载以及延时
|
||||
* 创建视频流拉流代理
|
||||
*
|
||||
* @param addStreamProxy 拉流参数
|
||||
* @return 视频播放地址
|
||||
*/
|
||||
R<List<ThreadsLoadDelay>> getWorkThreadsLoad();
|
||||
AddStreamProxyResp addStreamProxy(AddStreamProxy addStreamProxy);
|
||||
|
||||
/**
|
||||
* 获取ZLMediaKit服务器配置信息
|
||||
* 通过 fork FFmpeg 进程的方式拉流代理,支持任意协议
|
||||
*/
|
||||
Object getServerConfig();
|
||||
AddStreamProxyResp addFfmpegStreamProxy(AddStreamProxy proxy);
|
||||
|
||||
|
||||
/**
|
||||
* 设置服务器配置
|
||||
* 创建视频流拉流代理
|
||||
*
|
||||
* @param streamPlay 拉流参数
|
||||
* @return 视频播放地址
|
||||
*/
|
||||
Object setServerConfig();
|
||||
AddStreamProxyResp addStreamProxy(StreamPlay streamPlay);
|
||||
|
||||
/**
|
||||
* 重启服务器,只有 Daemon 方式才能重启,否则是直接关闭!
|
||||
* 增加FFmpeg 拉流代理
|
||||
*
|
||||
* @param streamPlay 拉流参数
|
||||
* @return 视频流播放地址
|
||||
*/
|
||||
Object restartServer();
|
||||
|
||||
/**
|
||||
* 获取流列表,可选筛选参数
|
||||
*/
|
||||
Object getMediaList();
|
||||
|
||||
/**
|
||||
* 关闭流(目前所有类型的流都支持关闭)
|
||||
*/
|
||||
Object closeStreams();
|
||||
|
||||
/**
|
||||
* 获取所有 TcpSession 列表(获取所有 tcp 客户端相关信息)
|
||||
*/
|
||||
Object getAllSession();
|
||||
|
||||
/**
|
||||
* 断开 tcp 连接,比如说可以断开 rtsp、rtmp 播放器等
|
||||
*/
|
||||
Object kickSession();
|
||||
|
||||
/**
|
||||
* 断开 tcp 连接,比如说可以断开 rtsp、rtmp 播放器等
|
||||
*/
|
||||
Object kickSessions();
|
||||
|
||||
/**
|
||||
* 动态添加 rtsp/rtmp/hls/http-ts/http-flv 拉流代理(只支持 H264/H265/aac/G711/opus 负载)
|
||||
*/
|
||||
AddStreamProxyResp addStreamProxy(StartStreamProxy startStreamProxy);
|
||||
AddStreamProxyResp addFfmpegStreamProxy(StreamPlay streamPlay);
|
||||
|
||||
/**
|
||||
* (流注册成功后,也可以使用close_streams接口替代)
|
||||
@@ -66,94 +48,14 @@ public interface ZLMediaKitService {
|
||||
*/
|
||||
String delStreamProxy(StartStreamProxy startStreamProxy);
|
||||
|
||||
/**
|
||||
* 通过 fork FFmpeg 进程的方式拉流代理,支持任意协议
|
||||
*/
|
||||
AddStreamProxyResp addFFmpegSource(String src_url);
|
||||
|
||||
/**
|
||||
* 流注册成功后,也可以使用close_streams接口替代
|
||||
* 删除ffmpeg 拉流任务
|
||||
*
|
||||
* @param key 流id
|
||||
* @return
|
||||
*/
|
||||
Boolean delFFmpegSource(String key);
|
||||
Boolean delFfmpegSource(String key);
|
||||
|
||||
/**
|
||||
* 获取 rtp 代理时的某路 ssrc rtp 信息
|
||||
*/
|
||||
Object getRtpInfo();
|
||||
|
||||
/**
|
||||
* 搜索文件系统,获取流对应的录像文件列表或日期文件夹列表
|
||||
*/
|
||||
Object getMp4RecordFile();
|
||||
|
||||
/**
|
||||
* 开始录制 hls 或 MP4
|
||||
*/
|
||||
Object startRecord();
|
||||
|
||||
/**
|
||||
* 停止录制流
|
||||
*/
|
||||
Object stopRecord();
|
||||
|
||||
/**
|
||||
* 获取流录制状态
|
||||
*/
|
||||
Object isRecording();
|
||||
|
||||
/**
|
||||
* 获取截图或生成实时截图并返回
|
||||
*/
|
||||
Object getSnap();
|
||||
|
||||
/**
|
||||
* 创建 GB28181 RTP 接收端口,如果该端口接收数据超时,则会自动被回收(不用调用 closeRtpServer 接口)
|
||||
*/
|
||||
Object openRtpServer();
|
||||
|
||||
/**
|
||||
* 关闭 GB28181 RTP 接收端口
|
||||
*/
|
||||
Object closeRtpServer();
|
||||
|
||||
/**
|
||||
* 获取 openRtpServer 接口创建的所有 RTP 服务器
|
||||
*/
|
||||
Object listRtpServer();
|
||||
|
||||
/**
|
||||
* 作为 GB28181 客户端,启动 ps-rtp 推流,支持 rtp/udp 方式;该接口支持 rtsp/rtmp 等协议转 ps-rtp 推流。第一次推流失败会直接返回错误,成功一次后,后续失败也将无限重试。
|
||||
*/
|
||||
Object startSendRtp();
|
||||
|
||||
/**
|
||||
* 停止 GB28181 ps-rtp 推流
|
||||
*/
|
||||
Object stopSendRtp();
|
||||
|
||||
/**
|
||||
* 获取主要对象个数统计,主要用于分析内存性能
|
||||
*/
|
||||
Object getStatistic();
|
||||
|
||||
/**
|
||||
* 添加 rtsp/rtmp 主动推流(把本服务器的直播流推送到其他服务器去)
|
||||
*/
|
||||
Object addStreamPusherProxy();
|
||||
|
||||
/**
|
||||
* 关闭推流,可以使用close_streams接口关闭源直播流也可以停止推流)
|
||||
*/
|
||||
Object delStreamPusherProxy();
|
||||
|
||||
/**
|
||||
* 获取版本信息,如分支,commit id, 编译时间
|
||||
*/
|
||||
Object getVersion();
|
||||
|
||||
/**
|
||||
* 获取某个流观看者列表
|
||||
*/
|
||||
Object getMediaPlayerList();
|
||||
|
||||
}
|
||||
|
@@ -1,18 +1,18 @@
|
||||
package org.dromara.sis.sdk.zkmedia;
|
||||
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import com.alibaba.fastjson2.TypeReference;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.sis.api.enums.FactoryNoEnum;
|
||||
import org.dromara.sis.config.ZLMediaKitConfig;
|
||||
import org.dromara.sis.sdk.zkmedia.model.AddStreamProxyResp;
|
||||
import org.dromara.sis.sdk.zkmedia.model.R;
|
||||
import org.dromara.sis.sdk.zkmedia.model.StartStreamProxy;
|
||||
import org.dromara.sis.sdk.zkmedia.model.ThreadsLoadDelay;
|
||||
import org.dromara.sis.domain.SisDeviceChannel;
|
||||
import org.dromara.sis.sdk.zkmedia.model.*;
|
||||
import org.dromara.sis.service.ISisDeviceChannelService;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -26,13 +26,41 @@ public class ZLMediaKitServiceImpl implements ZLMediaKitService {
|
||||
|
||||
@Resource
|
||||
private ZLMediaKitConfig zlmConfig;
|
||||
@Resource
|
||||
private ISisDeviceChannelService deviceChannelService;
|
||||
|
||||
// 海康实时流取流模板
|
||||
private static final String HIK_REALTIME_RTSP_TEMPLATE = "rtsp://%s:%s@%s:%s/Streaming/Channels/%s";
|
||||
// 大华实时流取流模板
|
||||
private static final String DAHUA_REALTIME_RTSP_TEMPLATE = "rtsp://%s:%s@%s:%s/cam/realmonitor?channel=%s&subtype=0";
|
||||
// 海康历史流取流模板
|
||||
private static final String HIK_HISTORY_RTSP_TEMPLATE = "rtsp://%s:%s@%s:%s/Streaming/tracks/%s?starttime=%s&endtime=%s";
|
||||
// 大华历史流取流模板
|
||||
private static final String DAHUA_HISTORY_RTSP_TEMPLATE = "rtsp://%s:%s@%s:%s/cam/playback?channel=%s&subtype=0&starttime=%s&endtime=%s";
|
||||
//流媒体请求模板
|
||||
private static final String STREAM_REQUEST_TEMLATE = "http://%s:%d/index/api/";
|
||||
// RTMP 视频流播放模板
|
||||
private static final String RTMP_PLAY_URL = "rtmp://%s:%d/%s/%s";
|
||||
// RTSP 视频流播放模板
|
||||
private static final String RTSP_PLAY_URL = "rtsp://%s:%d/%s/%s";
|
||||
// HTTP-FLV 视频流播放模板
|
||||
private static final String HTTP_FLV_PLAY_URL = "http://%s:%d/%s/%s.live.flv";
|
||||
// WS-FLV 视频流播放模板
|
||||
private static final String WS_FLV_PLAY_URL = "ws://%s:%d/%s/%s.live.flv";
|
||||
// HLS 视频流播放模板
|
||||
private static final String HLS_FLV_PLAY_URL = "http://%s:%d/%s/%s/hls.m3u8";
|
||||
// MP4 视频流播放模板
|
||||
private static final String MP4_FLV_PLAY_URL = "http://%s:%d/%s/%s.live.mp4";
|
||||
// 推流地址
|
||||
|
||||
|
||||
private static volatile String ZLM_REQUEST_PREFIX = null;
|
||||
|
||||
public String getRequestUrl(String uri) {
|
||||
if (ZLM_REQUEST_PREFIX == null) {
|
||||
synchronized (ZLMediaKitServiceImpl.class) {
|
||||
if (ZLM_REQUEST_PREFIX == null) {
|
||||
ZLM_REQUEST_PREFIX = String.format("http://%s:%d/index/api/", zlmConfig.getIp(), zlmConfig.getHttpPort());
|
||||
ZLM_REQUEST_PREFIX = String.format(STREAM_REQUEST_TEMLATE, zlmConfig.getIp(), zlmConfig.getHttpPort());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,84 +73,96 @@ public class ZLMediaKitServiceImpl implements ZLMediaKitService {
|
||||
return params;
|
||||
}
|
||||
|
||||
@Override
|
||||
public R<List<ThreadsLoadDelay>> getWorkThreadsLoad() {
|
||||
String url = getRequestUrl("getThreadsLoad");
|
||||
Map<String, Object> commonParams = getCommonParams();
|
||||
return HttpClientUtil.get(url, commonParams, new TypeReference<R<List<ThreadsLoadDelay>>>() {
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getServerConfig() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object setServerConfig() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object restartServer() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getMediaList() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object closeStreams() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getAllSession() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object kickSession() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object kickSessions() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取拉流地址
|
||||
* 设置返回参数的视频播放地址
|
||||
*/
|
||||
private AddStreamProxyResp setPlayerUrl(String app, String streamId, AddStreamProxyResp resp) {
|
||||
if (resp == null) {
|
||||
resp = new AddStreamProxyResp();
|
||||
}
|
||||
// RTMP 播放地址
|
||||
resp.setRtmp(String.format("rtmp://%s:%d/%s/%s", zlmConfig.getIp(), zlmConfig.getRtmpPort(), app, streamId));
|
||||
resp.setRtmp(String.format(RTMP_PLAY_URL, zlmConfig.getIp(), zlmConfig.getRtmpPort(), app, streamId));
|
||||
// RTSP 播放地址
|
||||
resp.setRtsp(String.format("rtsp://%s:%d/%s/%s", zlmConfig.getIp(), zlmConfig.getRtspPort(), app, streamId));
|
||||
resp.setRtsp(String.format(RTSP_PLAY_URL, zlmConfig.getIp(), zlmConfig.getRtspPort(), app, streamId));
|
||||
// HTTP-FLV 播放地址
|
||||
resp.setFlv(String.format("http://%s:%d/%s/%s.live.flv", zlmConfig.getIp(), zlmConfig.getHttpPort(), app, streamId));
|
||||
resp.setWsFlv(String.format("ws://%s:%d/%s/%s.live.flv", zlmConfig.getIp(), zlmConfig.getHttpPort(), app, streamId));
|
||||
resp.setFlv(String.format(HTTP_FLV_PLAY_URL, zlmConfig.getIp(), zlmConfig.getHttpPort(), app, streamId));
|
||||
resp.setWsFlv(String.format(WS_FLV_PLAY_URL, zlmConfig.getIp(), zlmConfig.getHttpPort(), app, streamId));
|
||||
// HLS 播放地址
|
||||
resp.setHls(String.format("http://%s:%d/%s/%s/hls.m3u8", zlmConfig.getIp(), zlmConfig.getHttpPort(), app, streamId));
|
||||
resp.setHls(String.format(HLS_FLV_PLAY_URL, zlmConfig.getIp(), zlmConfig.getHttpPort(), app, streamId));
|
||||
// MP4 播放地址
|
||||
resp.setMp4(String.format("http://%s:%d/%s/%s.live.mp4", zlmConfig.getIp(), zlmConfig.getHttpPort(), app, streamId));
|
||||
resp.setMp4(String.format(MP4_FLV_PLAY_URL, zlmConfig.getIp(), zlmConfig.getHttpPort(), app, streamId));
|
||||
return resp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成视频流地址
|
||||
*
|
||||
* @param factoryNo 厂商
|
||||
* @param account 账号
|
||||
* @param pwd 密码
|
||||
* @param ip ip
|
||||
* @param port 端口
|
||||
* @param channel 通道
|
||||
* @return 返回视频流播放地址
|
||||
*/
|
||||
private String getRealTimeStreamUrl(String factoryNo, String account, String pwd, String ip, Integer port, String channel) {
|
||||
if (FactoryNoEnum.HIK.getCode().equals(factoryNo)) {
|
||||
return String.format(HIK_REALTIME_RTSP_TEMPLATE, account, pwd, ip, port, channel);
|
||||
} else if (FactoryNoEnum.DAHUA.getCode().equals(factoryNo)) {
|
||||
return String.format(DAHUA_REALTIME_RTSP_TEMPLATE, account, pwd, ip, port, channel);
|
||||
} else {
|
||||
throw new RuntimeException("未知的设备类型!");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public AddStreamProxyResp addStreamProxy(StartStreamProxy startStreamProxy) {
|
||||
private String getPlayBackStreamUrl(String factoryNo, String account, String pwd, String ip, Integer port, String channel, String startTime, String endTime) {
|
||||
if (FactoryNoEnum.HIK.getCode().equals(factoryNo)) {
|
||||
String pattern = "yyyyMMdd'T'HHmmss'Z'";
|
||||
String st = MediaServerUtils.formatTimestamp(startTime, pattern);
|
||||
String et = MediaServerUtils.formatTimestamp(endTime, pattern);
|
||||
return String.format(HIK_HISTORY_RTSP_TEMPLATE, account, pwd, ip, port, channel, st, et);
|
||||
} else if (FactoryNoEnum.DAHUA.getCode().equals(factoryNo)) {
|
||||
String pattern = "yyyy_MM_dd_HH_mm_ss";
|
||||
String st = MediaServerUtils.formatTimestamp(startTime, pattern);
|
||||
String et = MediaServerUtils.formatTimestamp(endTime, pattern);
|
||||
return String.format(DAHUA_HISTORY_RTSP_TEMPLATE, account, pwd, ip, port, channel, st, et);
|
||||
} else {
|
||||
throw new RuntimeException("未知的设备类型!");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取拉流地址
|
||||
* streamType=1 是实时流, streamType=2 是历史流
|
||||
* factoryNo = 1 是海康视频流, factoryNo= 2 是大华视频流
|
||||
*
|
||||
* @param factoryNo 厂商编码
|
||||
* @param streamType 流类型
|
||||
* @param account 账号
|
||||
* @param pwd 密码
|
||||
* @param ip ip
|
||||
* @param port 端口
|
||||
* @param channel 通道
|
||||
* @param startTime 开始时间
|
||||
* @param endTime 结束时间
|
||||
* @return
|
||||
*/
|
||||
private String getPullStreamUrl(String factoryNo, Integer streamType, String account, String pwd, String ip, Integer port, String channel, String startTime, String endTime) {
|
||||
if (streamType == 1) {
|
||||
return getRealTimeStreamUrl(factoryNo, account, pwd, ip, port, channel);
|
||||
} else {
|
||||
return getPlayBackStreamUrl(factoryNo, account, pwd, ip, port, channel, startTime, endTime);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private AddStreamProxyResp addStreamProxy(String app, String stream, String url) {
|
||||
Map<String, Object> commonParams = getCommonParams();
|
||||
commonParams.put("vhost", zlmConfig.getVhost());
|
||||
commonParams.put("app", startStreamProxy.getApp());
|
||||
commonParams.put("stream", startStreamProxy.getStream());
|
||||
commonParams.put("url", startStreamProxy.getUrl());
|
||||
commonParams.put("rtp_type", startStreamProxy.getRtpType());
|
||||
commonParams.put("app", app);
|
||||
commonParams.put("stream", stream);
|
||||
commonParams.put("url", url);
|
||||
commonParams.put("rtp_type", 1);
|
||||
R<AddStreamProxyResp> result = HttpClientUtil.get(getRequestUrl("addStreamProxy"), commonParams, AddStreamProxyResp.class);
|
||||
if (result != null) {
|
||||
if (result.getCode() == 0) {
|
||||
@@ -132,11 +172,132 @@ public class ZLMediaKitServiceImpl implements ZLMediaKitService {
|
||||
if (result.getCode() == -1) {
|
||||
log.info("拉流任务已存在,返回播放地址。");
|
||||
}
|
||||
return setPlayerUrl(startStreamProxy.getApp(), startStreamProxy.getStream(), result.getData());
|
||||
return setPlayerUrl(app, stream, result.getData());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询设备通道信息
|
||||
*
|
||||
* @param deviceIp 设备ip
|
||||
* @param channelId 设备通道id
|
||||
* @return 返回通道信息
|
||||
*/
|
||||
private SisDeviceChannel getDeviceChannel(String deviceIp, String channelId) {
|
||||
SisDeviceChannel channel = deviceChannelService.queryChannels(deviceIp, channelId);
|
||||
if (channel == null) {
|
||||
throw new RuntimeException("设备通道不存在!");
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AddStreamProxyResp addStreamProxy(AddStreamProxy proxy) {
|
||||
// 实时流
|
||||
String app = proxy.getStreamType() == 1 ? "realtime" : "history";
|
||||
String url = getPullStreamUrl(proxy.getFactoryNo(), proxy.getStreamType(), proxy.getAccount(), proxy.getPwd(),
|
||||
proxy.getVideoIp(), proxy.getVideoPort(), proxy.getChannelId(), proxy.getStartTime(), proxy.getEndTime());
|
||||
String stream = IdUtil.fastSimpleUUID();
|
||||
return addStreamProxy(app, stream, url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AddStreamProxyResp addStreamProxy(StreamPlay streamPlay) {
|
||||
// 查询设备通道信息
|
||||
SisDeviceChannel channel = getDeviceChannel(streamPlay.getDeviceIp(), streamPlay.getChannelNo());
|
||||
// 构建拉流地址
|
||||
String app = null;
|
||||
String url = null;
|
||||
// 判断是走录像机拉流还是设备直接拉流
|
||||
if (streamPlay.getStreamType() == 1) {
|
||||
app = "realtime";
|
||||
// 当前如果配置了录像机会默认走录像机拉流
|
||||
if (StrUtil.isNotEmpty(channel.getNvrIp())) {
|
||||
url = getRealTimeStreamUrl(channel.getNvrFactoryNo(), channel.getNvrAccount(), channel.getNvrPwd(), channel.getNvrIp(), channel.getNvrPort(), channel.getNvrChannelNo());
|
||||
} else {
|
||||
url = getRealTimeStreamUrl(channel.getFactoryNo(), channel.getDeviceAccount(), channel.getDevicePwd(), channel.getDeviceIp(), channel.getDevicePort(), channel.getChannelNo());
|
||||
}
|
||||
} else {
|
||||
app = "history";
|
||||
// 校验通道是否配置了nvr和cvr
|
||||
if (StrUtil.isNotEmpty(channel.getNvrIp())) {
|
||||
url = getPlayBackStreamUrl(channel.getNvrFactoryNo(), channel.getNvrAccount(), channel.getNvrPwd(), channel.getNvrIp(), channel.getNvrPort(), channel.getNvrChannelNo()
|
||||
, streamPlay.getStartTime(), streamPlay.getEndTime());
|
||||
} else {
|
||||
throw new RuntimeException("设备机未配置存储设备,无法拉取视频流。");
|
||||
}
|
||||
}
|
||||
String stream = IdUtil.fastSimpleUUID();
|
||||
return addStreamProxy(app, stream, url);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private AddStreamProxyResp getAddStreamProxyResp(String url) {
|
||||
// 生成拉流任务key
|
||||
String taskKey = IdUtil.fastSimpleUUID();
|
||||
// String targetUrl = "rtmp://127.0.0.1/live/" + taskKey;
|
||||
// ffmpeg 推流地址
|
||||
String targetUrl = zlmConfig.getPushStreamUrl() + taskKey;
|
||||
Map<String, Object> commonParams = getCommonParams();
|
||||
commonParams.put("src_url", url);
|
||||
commonParams.put("dst_url", targetUrl);
|
||||
commonParams.put("timeout_ms", 10000);
|
||||
commonParams.put("enable_hls", false);
|
||||
commonParams.put("enable_mp4", false);
|
||||
R<AddStreamProxyResp> result = HttpClientUtil.get(getRequestUrl("addFFmpegSource"), commonParams, AddStreamProxyResp.class);
|
||||
if (result != null) {
|
||||
if (result.getCode() == 0) {
|
||||
log.info("创建FFMPEG拉流任务成功.");
|
||||
}
|
||||
// 此处代表拉流任务已存在
|
||||
if (result.getCode() == -1) {
|
||||
log.info("FFMPEG拉流任务已存在,返回播放地址。");
|
||||
}
|
||||
if (result.getData() != null) {
|
||||
|
||||
return setPlayerUrl("live", taskKey, result.getData());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public AddStreamProxyResp addFfmpegStreamProxy(AddStreamProxy proxy) {
|
||||
String url = "";
|
||||
if (proxy.getStreamType() == 1) {
|
||||
url = getRealTimeStreamUrl(proxy.getFactoryNo(), proxy.getAccount(), proxy.getPwd(), proxy.getVideoIp(), proxy.getVideoPort(), proxy.getChannelId());
|
||||
} else {
|
||||
url = getPlayBackStreamUrl(proxy.getFactoryNo(), proxy.getAccount(), proxy.getPwd(), proxy.getVideoIp(), proxy.getVideoPort(), proxy.getChannelId(), proxy.getStartTime(), proxy.getEndTime());
|
||||
}
|
||||
return getAddStreamProxyResp(url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AddStreamProxyResp addFfmpegStreamProxy(StreamPlay streamPlay) {
|
||||
// 查询设备通道信息
|
||||
SisDeviceChannel channel = getDeviceChannel(streamPlay.getDeviceIp(), streamPlay.getChannelNo());
|
||||
String url = null;
|
||||
if (streamPlay.getStreamType() == 1) {
|
||||
// 当前如果配置了录像机会默认走录像机拉流
|
||||
if (StrUtil.isNotEmpty(channel.getNvrIp())) {
|
||||
url = getRealTimeStreamUrl(channel.getNvrFactoryNo(), channel.getNvrAccount(), channel.getNvrPwd(), channel.getNvrIp(), channel.getNvrPort(), channel.getNvrChannelNo());
|
||||
} else {
|
||||
url = getRealTimeStreamUrl(channel.getFactoryNo(), channel.getDeviceAccount(), channel.getDevicePwd(), channel.getDeviceIp(), channel.getDevicePort(), channel.getChannelNo());
|
||||
}
|
||||
} else {
|
||||
// 校验通道是否配置了nvr和cvr
|
||||
if (StrUtil.isNotEmpty(channel.getNvrIp())) {
|
||||
url = getPlayBackStreamUrl(channel.getNvrFactoryNo(), channel.getNvrAccount(), channel.getNvrPwd(), channel.getNvrIp(), channel.getNvrPort(), channel.getNvrChannelNo()
|
||||
, streamPlay.getStartTime(), streamPlay.getEndTime());
|
||||
} else {
|
||||
throw new RuntimeException("设备机未配置存储设备,无法拉取视频流。");
|
||||
}
|
||||
}
|
||||
return getAddStreamProxyResp(url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String delStreamProxy(StartStreamProxy startStreamProxy) {
|
||||
Map<String, Object> commonParams = getCommonParams();
|
||||
@@ -161,132 +322,12 @@ public class ZLMediaKitServiceImpl implements ZLMediaKitService {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AddStreamProxyResp addFFmpegSource(String src_url) {
|
||||
|
||||
// 生成拉流任务key
|
||||
String taskKey = IdUtil.fastSimpleUUID();
|
||||
String targetUrl = "rtmp://127.0.0.1/live/" + taskKey;
|
||||
Map<String, Object> commonParams = getCommonParams();
|
||||
commonParams.put("src_url", src_url);
|
||||
commonParams.put("dst_url", targetUrl);
|
||||
commonParams.put("timeout_ms", 10000);
|
||||
commonParams.put("enable_hls", false);
|
||||
commonParams.put("enable_mp4", false);
|
||||
R<AddStreamProxyResp> result = HttpClientUtil.get(getRequestUrl("addFFmpegSource"), commonParams, AddStreamProxyResp.class);
|
||||
if (result != null) {
|
||||
if (result.getCode() == 0) {
|
||||
log.info("创建FFMPEG拉流任务成功.");
|
||||
}
|
||||
// 此处代表拉流任务已存在
|
||||
if (result.getCode() == -1) {
|
||||
log.info("FFMPEG拉流任务已存在,返回播放地址。");
|
||||
}
|
||||
|
||||
// RTMP 播放地址
|
||||
result.getData().setRtmp(String.format("rtmp://%s:%d/live/%s", zlmConfig.getIp(), zlmConfig.getRtmpPort(), taskKey));
|
||||
// RTSP 播放地址
|
||||
result.getData().setRtsp(String.format("rtsp://%s:%d/live/%s", zlmConfig.getIp(), zlmConfig.getRtspPort(), taskKey));
|
||||
// HTTP-FLV 播放地址
|
||||
result.getData().setFlv(String.format("http://%s:%d/live/%s.live.flv", zlmConfig.getIp(), zlmConfig.getHttpPort(), taskKey));
|
||||
result.getData().setWsFlv(String.format("ws://%s:%d/live/%s.live.flv", zlmConfig.getIp(), zlmConfig.getHttpPort(), taskKey));
|
||||
// HLS 播放地址
|
||||
result.getData().setHls(String.format("http://%s:%d/live/%s/hls.m3u8", zlmConfig.getIp(), zlmConfig.getHttpPort(), taskKey));
|
||||
// MP4 播放地址
|
||||
result.getData().setMp4(String.format("http://%s:%d/live/%s.live.mp4", zlmConfig.getIp(), zlmConfig.getHttpPort(), taskKey));
|
||||
return result.getData();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Boolean delFFmpegSource(String key) {
|
||||
public Boolean delFfmpegSource(String key) {
|
||||
Map<String, Object> commonParams = getCommonParams();
|
||||
R<String> result = HttpClientUtil.get(getRequestUrl("addFFmpegSource?key=" + key), commonParams, String.class);
|
||||
if (result != null && result.getCode() == 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getRtpInfo() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getMp4RecordFile() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object startRecord() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object stopRecord() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object isRecording() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getSnap() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object openRtpServer() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object closeRtpServer() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object listRtpServer() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object startSendRtp() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object stopSendRtp() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getStatistic() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object addStreamPusherProxy() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object delStreamPusherProxy() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getVersion() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getMediaPlayerList() {
|
||||
return null;
|
||||
return result != null && result.getCode() == 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -5,31 +5,68 @@ import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
|
||||
/**
|
||||
* 增加拉流代理
|
||||
*
|
||||
* @author lxj
|
||||
*/
|
||||
@Data
|
||||
public class AddStreamProxy {
|
||||
|
||||
/**
|
||||
* 设备ip
|
||||
*/
|
||||
@NotBlank
|
||||
private String videoIp;
|
||||
|
||||
/**
|
||||
* 设备端口
|
||||
*/
|
||||
private Integer videoPort;
|
||||
|
||||
/**
|
||||
* 厂商
|
||||
*/
|
||||
@NotBlank
|
||||
private String factoryNo;
|
||||
|
||||
/**
|
||||
* 账号
|
||||
*/
|
||||
@NotBlank
|
||||
private String account;
|
||||
|
||||
/**
|
||||
* 设备密码
|
||||
*/
|
||||
@NotBlank
|
||||
private String pwd;
|
||||
|
||||
/**
|
||||
* 通道id
|
||||
*/
|
||||
@NotNull
|
||||
private String channelId;
|
||||
|
||||
|
||||
private String startTime;
|
||||
|
||||
private String endTime;
|
||||
|
||||
/**
|
||||
* 流应用名称
|
||||
*/
|
||||
private String stream;
|
||||
|
||||
/**
|
||||
* 流类型1:实时流,2:历史流
|
||||
*/
|
||||
private Integer streamType = 1;
|
||||
|
||||
/**
|
||||
* 开始时间
|
||||
*/
|
||||
private String startTime;
|
||||
|
||||
/**
|
||||
* 结束时间
|
||||
*/
|
||||
private String endTime;
|
||||
|
||||
|
||||
}
|
||||
|
@@ -0,0 +1,42 @@
|
||||
package org.dromara.sis.sdk.zkmedia.model;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 实时视频流播放请求参数
|
||||
*
|
||||
* @author lxj
|
||||
*/
|
||||
@Data
|
||||
public class StreamPlay {
|
||||
|
||||
/**
|
||||
* 设备编号
|
||||
*/
|
||||
@NotEmpty
|
||||
private String deviceIp;
|
||||
|
||||
/**
|
||||
* 设备通道号
|
||||
*/
|
||||
@NotEmpty
|
||||
private String channelNo;
|
||||
|
||||
/**
|
||||
* 流类型1:实时流,2:历史流
|
||||
*/
|
||||
private Integer streamType = 1;
|
||||
|
||||
/**
|
||||
* 开始时间
|
||||
*/
|
||||
private String startTime;
|
||||
|
||||
/**
|
||||
* 结束时间
|
||||
*/
|
||||
private String endTime;
|
||||
|
||||
|
||||
}
|
@@ -1,5 +1,6 @@
|
||||
package org.dromara.sis.service;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import org.dromara.common.core.domain.TreeNode;
|
||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||
import org.dromara.common.mybatis.core.page.TableDataInfo;
|
||||
@@ -129,5 +130,12 @@ public interface ISisDeviceChannelService {
|
||||
*/
|
||||
Boolean updateDeviceChannelState(String deviceIp, Integer onLineState);
|
||||
|
||||
|
||||
/**
|
||||
* 通过设备ip和通道编码查询设备通道信息
|
||||
*
|
||||
* @param deviceIp 设备ip
|
||||
* @param channelNo 设备通道号
|
||||
* @return 返回通道信息
|
||||
*/
|
||||
SisDeviceChannel queryChannels(@NotEmpty String deviceIp, @NotEmpty String channelNo);
|
||||
}
|
||||
|
@@ -328,4 +328,14 @@ public class SisDeviceChannelServiceImpl implements ISisDeviceChannelService {
|
||||
lqw.eq(SisDeviceChannel::getDeviceIp, deviceIp);
|
||||
return baseMapper.update(lqw) > 0;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public SisDeviceChannel queryChannels(String deviceIp, String channelNo) {
|
||||
LambdaQueryWrapper<SisDeviceChannel> lqw = new LambdaQueryWrapper<>();
|
||||
lqw.eq(SisDeviceChannel::getDeviceIp, deviceIp);
|
||||
lqw.eq(SisDeviceChannel::getChannelNo, channelNo)
|
||||
.or().eq(SisDeviceChannel::getNvrChannelNo, channelNo);
|
||||
return baseMapper.selectOne(lqw);
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user