init
This commit is contained in:
281
ruoyi-video/src/main/java/com/ruoyi/video/server/FlvHandler.java
Normal file
281
ruoyi-video/src/main/java/com/ruoyi/video/server/FlvHandler.java
Normal file
@@ -0,0 +1,281 @@
|
||||
package com.ruoyi.video.server;
|
||||
|
||||
import cn.hutool.core.convert.Convert;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.crypto.digest.MD5;
|
||||
import com.ruoyi.video.common.MediaConstant;
|
||||
import com.ruoyi.video.domain.dto.CameraDto;
|
||||
import com.ruoyi.video.service.MediaService;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.channel.ChannelFutureListener;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.DefaultChannelPromise;
|
||||
import io.netty.channel.SimpleChannelInboundHandler;
|
||||
import io.netty.channel.ChannelHandler.Sharable;
|
||||
import io.netty.handler.codec.http.DefaultFullHttpResponse;
|
||||
import io.netty.handler.codec.http.DefaultHttpResponse;
|
||||
import io.netty.handler.codec.http.FullHttpRequest;
|
||||
import io.netty.handler.codec.http.FullHttpResponse;
|
||||
import io.netty.handler.codec.http.HttpHeaderNames;
|
||||
import io.netty.handler.codec.http.HttpHeaderValues;
|
||||
import io.netty.handler.codec.http.HttpResponse;
|
||||
import io.netty.handler.codec.http.HttpResponseStatus;
|
||||
import io.netty.handler.codec.http.HttpVersion;
|
||||
import io.netty.handler.codec.http.QueryStringDecoder;
|
||||
import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame;
|
||||
import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame;
|
||||
import io.netty.handler.codec.http.websocketx.PingWebSocketFrame;
|
||||
import io.netty.handler.codec.http.websocketx.PongWebSocketFrame;
|
||||
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
|
||||
import io.netty.handler.codec.http.websocketx.WebSocketFrame;
|
||||
import io.netty.handler.codec.http.websocketx.WebSocketServerHandshaker;
|
||||
import io.netty.handler.codec.http.websocketx.WebSocketServerHandshakerFactory;
|
||||
import io.netty.util.CharsetUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* @Author: orange
|
||||
* @CreateTime: 2025-01-16
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Sharable
|
||||
public class FlvHandler extends SimpleChannelInboundHandler<Object> {
|
||||
|
||||
@Autowired
|
||||
private MediaService mediaService;
|
||||
|
||||
private WebSocketServerHandshaker handshaker;
|
||||
|
||||
/**
|
||||
* 网络超时
|
||||
*/
|
||||
@Value("${mediaserver.netTimeout:15000000}")
|
||||
private String netTimeout;
|
||||
/**
|
||||
* 读写超时
|
||||
*/
|
||||
@Value("${mediaserver.readOrWriteTimeout:15000000}")
|
||||
private String readOrWriteTimeout;
|
||||
|
||||
/**
|
||||
* 无人拉流观看是否自动关闭流
|
||||
*/
|
||||
@Value("${mediaserver.autoClose:true}")
|
||||
private boolean autoClose;
|
||||
|
||||
/**
|
||||
* 无人拉流观看持续多久自动关闭,1分钟
|
||||
*/
|
||||
@Value("${mediaserver.autoClose.noClientsDuration:60000}")
|
||||
private long noClientsDuration;
|
||||
|
||||
@Override
|
||||
protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
|
||||
|
||||
if (msg instanceof FullHttpRequest) {
|
||||
FullHttpRequest req = (FullHttpRequest) msg;
|
||||
// Map<String, String> parmMap = new RequestParser(msg).parse();
|
||||
QueryStringDecoder decoder = new QueryStringDecoder(req.uri());
|
||||
|
||||
// 判断请求uri
|
||||
if (!"/live".equals(decoder.path())) {
|
||||
log.info("uri有误");
|
||||
sendError(ctx, HttpResponseStatus.BAD_REQUEST);
|
||||
return;
|
||||
}
|
||||
|
||||
CameraDto cameraDto = buildCamera(req.uri());
|
||||
|
||||
if (StrUtil.isBlank(cameraDto.getUrl())) {
|
||||
log.info("url有误");
|
||||
sendError(ctx, HttpResponseStatus.BAD_REQUEST);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!req.decoderResult().isSuccess() || (!"websocket".equals(req.headers().get("Upgrade")))) {
|
||||
// http请求
|
||||
sendFlvReqHeader(ctx);
|
||||
mediaService.playForHttp(cameraDto, ctx);
|
||||
|
||||
} else {
|
||||
// websocket握手,请求升级
|
||||
|
||||
// 参数分别是ws地址,子协议,是否扩展,最大frame长度
|
||||
WebSocketServerHandshakerFactory factory = new WebSocketServerHandshakerFactory(
|
||||
getWebSocketLocation(req), null, true, 5 * 1024 * 1024);
|
||||
handshaker = factory.newHandshaker(req);
|
||||
if (handshaker == null) {
|
||||
WebSocketServerHandshakerFactory.sendUnsupportedVersionResponse(ctx.channel());
|
||||
} else {
|
||||
HttpResponse rsp = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
|
||||
rsp.headers().set(HttpHeaderNames.SERVER, MediaConstant.serverName);
|
||||
DefaultChannelPromise channelPromise = new DefaultChannelPromise(ctx.channel());
|
||||
|
||||
handshaker.handshake(ctx.channel(), req, rsp.headers(), channelPromise);
|
||||
mediaService.playForWs(cameraDto, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
} else if (msg instanceof WebSocketFrame) {
|
||||
handleWebSocketRequest(ctx, (WebSocketFrame) msg);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelActive(ChannelHandlerContext ctx) throws Exception {
|
||||
// 添加连接
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
|
||||
// 断开连接
|
||||
}
|
||||
|
||||
/**
|
||||
* ws握手地址
|
||||
*/
|
||||
private String getWebSocketLocation(FullHttpRequest request) {
|
||||
String location = request.headers().get(HttpHeaderNames.HOST) + request.uri();
|
||||
return "ws://" + location;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送req header,告知浏览器是flv格式
|
||||
*
|
||||
* @param ctx
|
||||
*/
|
||||
private void sendFlvReqHeader(ChannelHandlerContext ctx) {
|
||||
HttpResponse rsp = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
|
||||
|
||||
rsp.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE)
|
||||
.set(HttpHeaderNames.CONTENT_TYPE, "video/x-flv").set(HttpHeaderNames.ACCEPT_RANGES, "bytes")
|
||||
.set(HttpHeaderNames.PRAGMA, "no-cache").set(HttpHeaderNames.CACHE_CONTROL, "no-cache")
|
||||
.set(HttpHeaderNames.TRANSFER_ENCODING, HttpHeaderValues.CHUNKED).set(HttpHeaderNames.SERVER, MediaConstant.serverName);
|
||||
ctx.writeAndFlush(rsp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误请求响应
|
||||
*
|
||||
* @param ctx
|
||||
* @param status
|
||||
*/
|
||||
private void sendError(ChannelHandlerContext ctx, HttpResponseStatus status) {
|
||||
FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status,
|
||||
Unpooled.copiedBuffer("请求地址有误: " + status + "\r\n", CharsetUtil.UTF_8));
|
||||
response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=UTF-8");
|
||||
|
||||
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
|
||||
}
|
||||
|
||||
/**
|
||||
* websocket处理
|
||||
*
|
||||
* @param ctx
|
||||
* @param frame
|
||||
*/
|
||||
private void handleWebSocketRequest(ChannelHandlerContext ctx, WebSocketFrame frame) {
|
||||
// 关闭
|
||||
if (frame instanceof CloseWebSocketFrame) {
|
||||
handshaker.close(ctx.channel(), (CloseWebSocketFrame) frame.retain());
|
||||
return;
|
||||
}
|
||||
|
||||
// 握手PING/PONG
|
||||
if (frame instanceof PingWebSocketFrame) {
|
||||
ctx.write(new PongWebSocketFrame(frame.content().retain()));
|
||||
return;
|
||||
}
|
||||
|
||||
// 文本
|
||||
if (frame instanceof TextWebSocketFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame instanceof BinaryWebSocketFrame) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析参数,构建camera,&&&参数必须加在url参数值后面,&&&autoClose=false&&&hls=true
|
||||
* ws://localhost:8866/live?url=rtsp://admin:VZCDOY@192.168.2.84:554/Streaming/Channels/102&&&autoClose=false
|
||||
* @param url
|
||||
* @return
|
||||
*/
|
||||
private CameraDto buildCamera(String url) {
|
||||
CameraDto cameraDto = new CameraDto();
|
||||
setConfig(cameraDto);
|
||||
|
||||
String[] split = url.split("url=");
|
||||
String urlParent = split[1];
|
||||
|
||||
String[] split2 = urlParent.split("&&&");
|
||||
if (split2.length > 0) {
|
||||
for (String string : split2) {
|
||||
if (string.indexOf("autoClose=") != -1) {
|
||||
String[] as = string.split("=");
|
||||
if (as.length <= 1) {
|
||||
throw new RuntimeException("autoClose参数有误");
|
||||
}
|
||||
cameraDto.setAutoClose(Convert.toBool(as[1], false));
|
||||
} else if (string.indexOf("ffmpeg=") != -1) {
|
||||
String[] as = string.split("=");
|
||||
if (as.length <= 1) {
|
||||
throw new RuntimeException("ffmpeg参数有误");
|
||||
}
|
||||
cameraDto.setEnabledFFmpeg(Convert.toBool(as[1], false));
|
||||
} else if (string.indexOf("hls=") != -1) {
|
||||
String[] as = string.split("=");
|
||||
if (as.length <= 1) {
|
||||
throw new RuntimeException("hls参数有误");
|
||||
}
|
||||
cameraDto.setEnabledHls(Convert.toBool(as[1], false));
|
||||
} else {
|
||||
cameraDto.setUrl(string);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isLocalFile(cameraDto.getUrl())) {
|
||||
cameraDto.setType(1);
|
||||
}
|
||||
|
||||
// 区分不同媒体
|
||||
String mediaKey = MD5.create().digestHex(cameraDto.getUrl());
|
||||
cameraDto.setMediaKey(mediaKey);
|
||||
|
||||
return cameraDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置默认参数
|
||||
*/
|
||||
private void setConfig(CameraDto cameraDto) {
|
||||
cameraDto.setNetTimeout(netTimeout);
|
||||
cameraDto.setReadOrWriteTimeout(readOrWriteTimeout);
|
||||
cameraDto.setAutoClose(autoClose);
|
||||
cameraDto.setNoClientsDuration(noClientsDuration);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否是本地文件,判断前面长度是不是小于1个字符,认为是盘符
|
||||
* @return
|
||||
*/
|
||||
private boolean isLocalFile(String streamUrl) {
|
||||
String[] split = streamUrl.trim().split("\\:");
|
||||
if (split.length > 0) {
|
||||
if (split[0].length() <= 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.ruoyi.video.server;
|
||||
|
||||
import io.netty.bootstrap.ServerBootstrap;
|
||||
import io.netty.channel.ChannelFuture;
|
||||
import io.netty.channel.ChannelHandler;
|
||||
import io.netty.channel.ChannelInitializer;
|
||||
import io.netty.channel.ChannelOption;
|
||||
import io.netty.channel.EventLoopGroup;
|
||||
import io.netty.channel.WriteBufferWaterMark;
|
||||
import io.netty.channel.nio.NioEventLoopGroup;
|
||||
import io.netty.channel.socket.SocketChannel;
|
||||
import io.netty.channel.socket.nio.NioServerSocketChannel;
|
||||
import io.netty.channel.unix.PreferredDirectByteBufAllocator;
|
||||
import io.netty.handler.codec.http.HttpObjectAggregator;
|
||||
import io.netty.handler.codec.http.HttpRequestDecoder;
|
||||
import io.netty.handler.codec.http.HttpResponseEncoder;
|
||||
import io.netty.handler.codec.http.cors.CorsConfig;
|
||||
import io.netty.handler.codec.http.cors.CorsConfigBuilder;
|
||||
import io.netty.handler.codec.http.cors.CorsHandler;
|
||||
import io.netty.handler.stream.ChunkedWriteHandler;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* flv流媒体服务
|
||||
*
|
||||
* @Author: orange
|
||||
* @CreateTime: 2025-01-16
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class MediaServer {
|
||||
|
||||
@Autowired
|
||||
private FlvHandler flvHandler;
|
||||
|
||||
public void start(InetSocketAddress socketAddress) {
|
||||
//new 一个主线程组
|
||||
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
|
||||
//new 一个工作线程组
|
||||
EventLoopGroup workGroup = new NioEventLoopGroup(200);
|
||||
ServerBootstrap bootstrap = new ServerBootstrap()
|
||||
.group(bossGroup, workGroup)
|
||||
.channel(NioServerSocketChannel.class)
|
||||
.childHandler(new ChannelInitializer<SocketChannel>() {
|
||||
@Override
|
||||
protected void initChannel(SocketChannel socketChannel) throws Exception {
|
||||
CorsConfig corsConfig = CorsConfigBuilder.forAnyOrigin().allowNullOrigin().allowCredentials().build();
|
||||
|
||||
socketChannel.pipeline()
|
||||
.addLast(new HttpResponseEncoder())
|
||||
.addLast(new HttpRequestDecoder())
|
||||
.addLast(new ChunkedWriteHandler())
|
||||
.addLast(new HttpObjectAggregator(64 * 1024))
|
||||
.addLast(new CorsHandler(corsConfig))
|
||||
.addLast(flvHandler);
|
||||
}
|
||||
})
|
||||
.localAddress(socketAddress)
|
||||
.option(ChannelOption.SO_BACKLOG, 128)
|
||||
//首选直接内存
|
||||
.option(ChannelOption.ALLOCATOR, PreferredDirectByteBufAllocator.DEFAULT)
|
||||
// 两小时内没有数据的通信时,TCP会自动发送一个活动探测数据报文
|
||||
.childOption(ChannelOption.TCP_NODELAY, true)
|
||||
.childOption(ChannelOption.SO_KEEPALIVE, true)
|
||||
.childOption(ChannelOption.SO_RCVBUF, 128 * 1024)
|
||||
.childOption(ChannelOption.SO_SNDBUF, 1024 * 1024)
|
||||
.childOption(ChannelOption.WRITE_BUFFER_WATER_MARK, new WriteBufferWaterMark(1024 * 1024 / 2, 1024 * 1024));
|
||||
//绑定端口,开始接收进来的连接
|
||||
try {
|
||||
ChannelFuture future = bootstrap.bind(socketAddress).sync();
|
||||
future.channel().closeFuture().sync();
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
//关闭主线程组
|
||||
bossGroup.shutdownGracefully();
|
||||
//关闭工作线程组
|
||||
workGroup.shutdownGracefully();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user