huangfei 3 лет назад
Родитель
Сommit
d268e3cc46

+ 2 - 0
.gitignore

@@ -1,3 +1,5 @@
+target/
+.idea
 # Compiled class file
 *.class
 

+ 113 - 0
pom.xml

@@ -0,0 +1,113 @@
+<?xml version="1.0"?>
+<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"
+         xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>com.coyee</groupId>
+    <artifactId>coyee-stream-converter</artifactId>
+    <name>coyee-stream-converter</name>
+    <url>http://maven.apache.org</url>
+    <repositories>
+        <repository>
+            <id>aliyun-repo</id>
+            <name>Aliyun Repository</name>
+            <url>http://maven.aliyun.com/nexus/content/groups/public/</url>
+        </repository>
+        <repository>
+            <id>springsource-repo</id>
+            <name>SpringSource Repository</name>
+            <url>http://repo.springsource.org/release</url>
+        </repository>
+        <repository>
+            <id>spring-snapshots</id>
+            <name>Spring Snapshots</name>
+            <url>https://repo.spring.io/libs-snapshot</url>
+            <snapshots>
+                <enabled>true</enabled>
+            </snapshots>
+        </repository>
+    </repositories>
+    <properties>
+        <project.version>0.0.1-SNAPSHOT</project.version>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+        <java.version>1.8</java.version>
+        <commons.io.version>2.5</commons.io.version>
+        <commons-lang3.version>3.6</commons-lang3.version>
+        <fastjson.version>1.2.4</fastjson.version>
+        <javacv.version>1.5.4</javacv.version>
+    </properties>
+    <parent>
+        <groupId>org.springframework.boot</groupId>
+        <artifactId>spring-boot-starter-parent</artifactId>
+        <version>2.0.5.RELEASE</version>
+        <relativePath/>
+    </parent>
+    <dependencies>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter</artifactId>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.springframework.boot</groupId>
+                    <artifactId>spring-boot-starter-logging</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-log4j2</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-websocket</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-lang3</artifactId>
+            <version>${commons-lang3.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>commons-io</groupId>
+            <artifactId>commons-io</artifactId>
+            <version>${commons.io.version}</version>
+        </dependency>
+
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>fastjson</artifactId>
+            <version>${fastjson.version}</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.bytedeco</groupId>
+            <artifactId>javacv-platform</artifactId>
+            <version>${javacv.version}</version>
+        </dependency>
+    </dependencies>
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 51 - 0
src/main/java/com/coyee/stream/StreamApplication.java

@@ -0,0 +1,51 @@
+package com.coyee.stream;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.context.annotation.Bean;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+import org.springframework.web.filter.CorsFilter;
+
+/**
+ * @author hxfein
+ * @className: StreamApplication
+ * @description: 流服务启动类
+ * @date 2022/5/12 14:32
+ * @version:1.0
+ */
+@EnableCaching // 开启缓存
+@EnableConfigurationProperties
+@SpringBootApplication(scanBasePackages = "com.coyee.stream")
+public class StreamApplication {
+
+	public static void main(String[] args) {
+		SpringApplication.run(StreamApplication.class, args);
+	}
+
+	/**
+	 * 允许跨域访问
+	 * 
+	 * @return
+	 */
+	@Bean
+	public CorsFilter corsFilter() {
+		final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+		final CorsConfiguration config = new CorsConfiguration();
+		config.setAllowCredentials(true); // 允许cookies跨域
+		config.addAllowedOrigin("*");// #允许向该服务器提交请求的URI,*表示全部允许,在SpringMVC中,如果设成*,会自动转成当前请求头中的Origin
+		config.addAllowedHeader("*");// #允许访问的头信息,*表示全部
+		config.setMaxAge(18000L);// 预检请求的缓存时间(秒),即在这个时间段里,对于相同的跨域请求不会再预检了
+		config.addAllowedMethod("OPTIONS");// 允许提交请求的方法,*表示全部允许
+		config.addAllowedMethod("HEAD");
+		config.addAllowedMethod("GET");// 允许Get的请求方法
+		config.addAllowedMethod("PUT");
+		config.addAllowedMethod("POST");
+		config.addAllowedMethod("DELETE");
+		config.addAllowedMethod("PATCH");
+		source.registerCorsConfiguration("/**", config);
+		return new CorsFilter(source);
+	}
+}

+ 25 - 0
src/main/java/com/coyee/stream/config/ResourceConfig.java

@@ -0,0 +1,25 @@
+package com.coyee.stream.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
+
+/**
+ * @author hxfein
+ * @className: ResourceConfig
+ * @description: 静态资源配置
+ * @date 2022/5/12 14:32
+ * @version:1.0
+ */
+@Configuration
+public class ResourceConfig
+        extends WebMvcConfigurationSupport {
+
+    @Override
+    public void addResourceHandlers(ResourceHandlerRegistry registry) {
+        registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/");
+        registry.addResourceHandler("/favicon.ico").addResourceLocations("classpath:/static/");
+        super.addResourceHandlers(registry);
+    }
+}

+ 29 - 0
src/main/java/com/coyee/stream/config/SchedulerConfig.java

@@ -0,0 +1,29 @@
+package com.coyee.stream.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.TaskScheduler;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
+
+/**
+ * @author hxfein
+ * @className: SchedulerConfig
+ * @description: 定时任务配置
+ * @date 2022/5/12 14:32
+ * @version:1.0
+ */
+@Configuration
+@EnableScheduling
+public class SchedulerConfig {
+	@Bean
+	public TaskScheduler taskScheduler() {
+		ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
+		// 线程池大小
+		scheduler.setPoolSize(3);
+		// 线程名字前缀
+		scheduler.setThreadNamePrefix("task-thread-");
+		return scheduler;
+	}
+
+}

+ 64 - 0
src/main/java/com/coyee/stream/config/StreamServerConfig.java

@@ -0,0 +1,64 @@
+package com.coyee.stream.config;
+
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.io.FileUtils;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.PostConstruct;
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * @author hxfein
+ * @className: StreamServerConfig
+ * @description: 流服务配置
+ * @date 2022/5/12 14:32
+ * @version:1.0
+ */
+@Component
+@Data
+@ConfigurationProperties("streamserver")
+@Slf4j
+public class StreamServerConfig {
+    /**
+     * 加密key
+     */
+    private String desKey;
+    /**
+     * 转为hls协议时m3u8、TS文件的存储目录
+     */
+    private String hlsStoreDir;
+    /**
+     * 单个分片播放时间
+     */
+    private int hlsTime=5;
+    /**
+     * 最大分片数
+     */
+    private int hlsWrap=10;
+    /**
+     * 播放列表数
+     */
+    private int hlsListSize=0;
+
+    /**
+     * 系统启动时,先把hlsStoreDir里面的文件清除掉
+     */
+    @PostConstruct
+    public void onStreamServerStart() throws IOException {
+        File dir=new File(hlsStoreDir);
+        log.info("准备清除hls目录的残留文件");
+        File[] children=dir.listFiles();
+        for(File child:children){
+            if(child.isDirectory()) {
+                FileUtils.deleteDirectory(child);
+                log.info("目录 已被清除:{}",child.getAbsolutePath());
+            }else{
+                FileUtils.deleteQuietly(child);
+                log.info("文件 已被清除:{}",child.getAbsolutePath());
+            }
+        }
+    }
+}

+ 180 - 0
src/main/java/com/coyee/stream/controller/StreamController.java

@@ -0,0 +1,180 @@
+package com.coyee.stream.controller;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import com.coyee.stream.config.StreamServerConfig;
+import com.coyee.stream.result.JsonResult;
+import com.coyee.stream.service.IStreamService;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.io.FilenameUtils;
+import org.apache.commons.io.IOUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.io.*;
+
+
+/**
+ * @author hxfein
+ * @className: StreamController
+ * @description: 流服务controller
+ * @date 2022/5/12 14:32
+ * @version:1.0
+ */
+@Slf4j
+@RestController
+public class StreamController {
+
+    @Resource
+    private StreamServerConfig streamServerConfig;
+
+    @Autowired
+    private IStreamService service;
+
+    /**
+     * 开启hls流的转换,返回播放地址
+     *
+     * @param key  加密后的流地址
+     * @param response
+     * @param request
+     */
+    @GetMapping(value = "/live/openHls/{key}")
+    @ResponseBody
+    public JsonResult openHls(@PathVariable(value = "key") String key, HttpServletResponse response,
+                              HttpServletRequest request) {
+        String realUrl = service.decode(key);
+        String playUrl = service.open(realUrl, "hls", response, request);
+        return JsonResult.ok().data(playUrl);
+    }
+
+    /**
+     * 保持转换器为运行状态
+     *
+     * @param key
+     * @return
+     */
+    @GetMapping(value = "/live/ping/{key}")
+    @ResponseBody
+    public String ping(@PathVariable(value = "key") String key) {
+        boolean success = service.activeStream(key);
+        if (success == true) {
+            return "ok";
+        } else {
+            return "not found";
+        }
+    }
+
+    /**
+     * 加密地址
+     *
+     * @param url
+     * @return
+     */
+    @PostMapping(value = "/url/encode")
+    @ResponseBody
+    public JsonResult encode(String url) {
+        String key = service.encode(url);
+        return JsonResult.ok().data(key);
+    }
+
+    /**
+     * 加密地址
+     *
+     * @param urlHex
+     * @return
+     */
+    @PostMapping(value = "/url/decode")
+    @ResponseBody
+    public JsonResult decode(String urlHex) {
+        String url = service.decode(urlHex);
+        return JsonResult.ok().data(url);
+    }
+
+
+    /**
+     * 返回m3u8文件流
+     *
+     * @param key
+     * @param response
+     * @param request
+     * @return
+     */
+    @GetMapping(value = "/live/{url}/play.m3u8")
+    public String playM3U8(@PathVariable(value = "url") String key, HttpServletResponse response,
+                           HttpServletRequest request) throws IOException {
+        InputStream inputStream = null;
+        try {
+            String hlsStoreDir = streamServerConfig.getHlsStoreDir();
+            String m3u8Path = FilenameUtils.separatorsToSystem(hlsStoreDir + File.separator + key + File.separator + "play.m3u8");
+            File m3u8File = new File(m3u8Path);
+            if (m3u8File.exists() == false) {
+                response.sendError(404);
+                return null;
+            }
+            response.setContentType("audio/x-mpegur");
+            OutputStream output = response.getOutputStream();
+            inputStream = new FileInputStream(m3u8File);
+            IOUtils.copy(inputStream, output);
+            return null;
+        } catch (Exception er) {
+            log.error("获取m3u8出错", er);
+            return null;
+        } finally {
+            if (inputStream != null) {
+                IOUtils.closeQuietly(inputStream);
+            }
+        }
+    }
+
+    /**
+     * 返回ts文件流
+     *
+     * @param key
+     * @param response
+     * @param request
+     * @return
+     */
+    @GetMapping(value = "/live/{url}/{index}.ts")
+    public String playTS(@PathVariable(value = "url") String key, @PathVariable(value = "index") String index, HttpServletResponse response,
+                         HttpServletRequest request) throws IOException {
+        InputStream inputStream = null;
+        try {
+            String hlsStoreDir = streamServerConfig.getHlsStoreDir();
+            String tsPath = FilenameUtils.separatorsToSystem(hlsStoreDir + File.separator + key + File.separator + index + ".ts");
+            File tsFile = new File(tsPath);
+            if (tsFile.exists() == false) {
+                response.sendError(404);
+                return null;
+            }
+            response.setContentType("application/x-linguist");
+            OutputStream output = response.getOutputStream();
+            inputStream = new FileInputStream(tsFile);
+            IOUtils.copy(inputStream, output);
+            return null;
+        } catch (Exception er) {
+            log.error("获取ts出错", er);
+            return null;
+        } finally {
+            if (inputStream != null) {
+                IOUtils.closeQuietly(inputStream);
+            }
+        }
+    }
+
+    /**
+     * 打开一个flv流
+     *
+     * @param key 加密后的流地址
+     * @param response
+     * @param request
+     */
+    @GetMapping(value = "/live/{key}.flv")
+    public void flvLive(@PathVariable(value = "key") String key, HttpServletResponse response,
+                        HttpServletRequest request) {
+        String realUrl = service.decode(key);
+        service.open(realUrl, "flv", response, request);
+    }
+
+}

+ 133 - 0
src/main/java/com/coyee/stream/controller/advice/GlobleExceptionHandler.java

@@ -0,0 +1,133 @@
+package com.coyee.stream.controller.advice;
+
+import com.coyee.stream.result.JsonResult;
+import org.springframework.boot.context.properties.bind.validation.BindValidationException;
+import org.springframework.http.converter.HttpMessageConversionException;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.validation.BindException;
+import org.springframework.web.HttpMediaTypeException;
+import org.springframework.web.HttpRequestMethodNotSupportedException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * @author hxfein
+ * @className: GlobleExceptionHandler
+ * @description: 全局异常处理
+ * @date 2022/5/12 14:32
+ * @version:1.0
+ */
+@Slf4j
+@RestControllerAdvice
+public class GlobleExceptionHandler {
+
+	/**
+	 * 404 异常捕捉处理
+	 * 
+	 * @param ex
+	 * @return
+	 */
+	@ExceptionHandler(value = org.springframework.web.servlet.NoHandlerFoundException.class)
+	public JsonResult errorHandler(org.springframework.web.servlet.NoHandlerFoundException ex) {
+		log.error(ex.getMessage(), ex);
+		return JsonResult.result(404, "服务接口不存在。");
+	}
+
+	/**
+	 * 参数校验出错
+	 * 
+	 * @param ex
+	 * @return
+	 */
+	@ExceptionHandler(value = BindException.class)
+	public JsonResult errorHandler(BindException ex) {
+		log.error(ex.getMessage(), ex);
+		return JsonResult.error().put("message", "参数不正确," + ex.getMessage());
+	}
+
+	/**
+	 * 参数校验出错
+	 * 
+	 * @param ex
+	 * @return
+	 */
+	@ExceptionHandler(value = BindValidationException.class)
+	public JsonResult errorHandler(BindValidationException ex) {
+		log.error(ex.getMessage(), ex);
+		return JsonResult.error().put("message", "参数不正确," + ex.getMessage());
+	}
+
+	/**
+	 * 请求的方式不支持
+	 * 
+	 * @param ex
+	 * @return
+	 */
+	@ExceptionHandler(value = HttpRequestMethodNotSupportedException.class)
+	public JsonResult errorHandler(HttpRequestMethodNotSupportedException ex) {
+		log.error(ex.getMessage(), ex);
+		return JsonResult.error().put("message", "Http 方式不正确," + ex.getMessage());
+	}
+
+	/**
+	 * 非法的参数异常
+	 * 
+	 * @param ex
+	 * @return
+	 */
+	@ExceptionHandler(value = IllegalArgumentException.class)
+	public JsonResult errorHandler(IllegalArgumentException ex) {
+		log.error(ex.getMessage(), ex);
+		return JsonResult.error(500, "非法的参数," + ex.getMessage());
+	}
+
+	/**
+	 * 类型异常
+	 * 
+	 * @param ex
+	 * @return
+	 */
+	@ExceptionHandler(value = HttpMediaTypeException.class)
+	public JsonResult errorHandler(HttpMediaTypeException ex) {
+		log.error(ex.getMessage(), ex);
+		return JsonResult.error().put("message", "服务异常,请检查参数及调用方式。详情内容为: " + ex.getMessage());
+	}
+
+	/**
+	 * 拦截捕捉 CMSException.class
+	 * 
+	 * @param ex
+	 * @return
+	 */
+	@ExceptionHandler(value = HttpMessageConversionException.class)
+	public JsonResult errorHandler(HttpMessageConversionException ex) {
+		log.error(ex.getMessage(), ex);
+		return JsonResult.error().put("message", "参数不正确。详情内容为:" + ex.getMessage());
+	}
+
+	/**
+	 * 全局异常捕捉处理
+	 * 
+	 * @param ex
+	 * @return
+	 */
+	@ExceptionHandler(value = Exception.class)
+	public JsonResult errorHandler(Exception ex) {
+		log.error(ex.getMessage(), ex);
+		return JsonResult.error().put("message", "服务异常,暂时不可用。" + ex.getMessage());
+	}
+
+	/**
+	 * 参数不能解析
+	 * 
+	 * @param ex
+	 * @return
+	 */
+	@ExceptionHandler(value = HttpMessageNotReadableException.class)
+	public JsonResult errorHandler(HttpMessageNotReadableException ex) {
+		log.error(ex.getMessage(), ex);
+		return JsonResult.error().put("message", "参数解析异常,请检查参数格式。" + ex.getMessage());
+	}
+}

+ 29 - 0
src/main/java/com/coyee/stream/converter/Converter.java

@@ -0,0 +1,29 @@
+package com.coyee.stream.converter;
+
+import java.io.IOException;
+
+import javax.servlet.AsyncContext;
+
+/**
+ * @author hxfein
+ * @className: Converter
+ * @description: 转换器接口
+ * @date 2022/5/12 14:32
+ * @version:1.0
+ */
+public interface Converter {
+
+	/**
+	 * 添加一个流输出
+	 *
+	 * @param entity
+	 */
+	void addOutputStreamEntity(String key, AsyncContext entity) throws IOException;
+
+	/**
+	 * 要求关闭转换器
+	 */
+	void softClose();
+
+
+}

+ 255 - 0
src/main/java/com/coyee/stream/converter/FlvConverter.java

@@ -0,0 +1,255 @@
+package com.coyee.stream.converter;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.Iterator;
+import java.util.List;
+
+import javax.servlet.AsyncContext;
+
+import org.bytedeco.ffmpeg.avcodec.AVPacket;
+import org.bytedeco.ffmpeg.global.avcodec;
+import org.bytedeco.javacv.*;
+
+import com.alibaba.fastjson.util.IOUtils;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * @author hxfein
+ * @className: FlvConverter
+ * @description: 将流转为rtmp格式
+ * @date 2022/5/12 14:32
+ * @version:1.0
+ */
+@Slf4j
+public class FlvConverter extends Thread implements Converter {
+    public volatile boolean running = true;
+    /**
+     * 读流器
+     */
+    private FFmpegFrameGrabber grabber;
+    /**
+     * 转码器
+     */
+    private FFmpegFrameRecorder recorder;
+    /**
+     * 转FLV格式的头信息<br/>
+     * 如果有第二个客户端播放首先要返回头信息
+     */
+    private byte[] headers;
+    /**
+     * 保存转换好的流
+     */
+    private ByteArrayOutputStream stream;
+    /**
+     * 流地址,h264,aac
+     */
+    private String url;
+    /**
+     * 流输出
+     */
+    private List<AsyncContext> outEntitys;
+
+
+    public FlvConverter(String url, List<AsyncContext> outEntitys) {
+        this.url = url;
+        this.outEntitys = outEntitys;
+    }
+
+    @Override
+    public void run() {
+        try {
+            log.info("开始转换FLV任务:{}。", url);
+            grabber = new FFmpegFrameGrabber(url);
+            if ("rtsp".equals(url.substring(0, 4))) {
+                grabber.setOption("rtsp_transport", "tcp");
+                grabber.setOption("stimeout", "5000000");
+            }
+            grabber.start();
+            if (avcodec.AV_CODEC_ID_H264 == grabber.getVideoCodec()
+                    && (grabber.getAudioChannels() == 0 || avcodec.AV_CODEC_ID_AAC == grabber.getAudioCodec())) {
+                simpleTransFlv();
+            }else{
+                transFlv();
+            }
+        } catch (Exception e) {
+            log.error(e.getMessage(), e);
+        } finally {
+            closeConverter();
+            completeResponse();
+            log.info("FLV转换任务退出:{}", url);
+        }
+    }
+
+    private void transFlv() throws FrameRecorder.Exception, FrameGrabber.Exception, InterruptedException {
+        log.info("FLV(complex)转换任务启动,可以立即播放:{}。", url);
+        grabber.setFrameRate(25);
+        if (grabber.getImageWidth() > 1920) {
+            grabber.setImageWidth(1920);
+        }
+        if (grabber.getImageHeight() > 1080) {
+            grabber.setImageHeight(1080);
+        }
+        stream = new ByteArrayOutputStream();
+        recorder = new FFmpegFrameRecorder(stream, grabber.getImageWidth(), grabber.getImageHeight(),
+                grabber.getAudioChannels());
+        recorder.setInterleaved(true);
+        recorder.setVideoOption("preset", "ultrafast");
+        recorder.setVideoOption("tune", "zerolatency");
+        recorder.setVideoOption("crf", "25");
+        recorder.setGopSize(50);
+        recorder.setFrameRate(25);
+        recorder.setSampleRate(grabber.getSampleRate());
+        if (grabber.getAudioChannels() > 0) {
+            recorder.setAudioChannels(grabber.getAudioChannels());
+            recorder.setAudioBitrate(grabber.getAudioBitrate());
+            recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC);
+        }
+        recorder.setFormat("flv");
+        recorder.setVideoBitrate(grabber.getVideoBitrate());
+        recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264);
+        recorder.start();
+        if (headers == null) {
+            headers = stream.toByteArray();
+            stream.reset();
+            writeResponse(headers);
+        }
+        int nullNumber = 0;
+        while (running) {
+            // 抓取一帧
+            Frame f = grabber.grab();
+            if (f != null) {
+                try {
+                    // 转码
+                    recorder.record(f);
+                } catch (Exception e) {
+                }
+                if (stream.size() > 0) {
+                    byte[] b = stream.toByteArray();
+                    stream.reset();
+                    writeResponse(b);
+                    if (outEntitys.isEmpty()) {
+                        log.info("没有输出退出");
+                        break;
+                    }
+                }
+            } else {
+                nullNumber++;
+                if (nullNumber > 200) {
+                    break;
+                }
+            }
+            Thread.sleep(5);
+        }
+    }
+
+    private void simpleTransFlv() throws FrameRecorder.Exception, FrameGrabber.Exception, InterruptedException {
+        // 来源视频H264格式,音频AAC格式
+        // 无须转码,更低的资源消耗,更低的延迟
+        log.info("FLV(simple)转换任务启动,可以立即播放:{}。", url);
+        stream = new ByteArrayOutputStream();
+        recorder = new FFmpegFrameRecorder(stream, grabber.getImageWidth(), grabber.getImageHeight(),
+                grabber.getAudioChannels());
+        recorder.setInterleaved(true);
+        recorder.setVideoOption("preset", "ultrafast");
+        recorder.setVideoOption("tune", "zerolatency");
+        recorder.setVideoOption("crf", "25");
+        recorder.setFrameRate(grabber.getFrameRate());
+        recorder.setSampleRate(grabber.getSampleRate());
+        if (grabber.getAudioChannels() > 0) {
+            recorder.setAudioChannels(grabber.getAudioChannels());
+            recorder.setAudioBitrate(grabber.getAudioBitrate());
+            recorder.setAudioCodec(grabber.getAudioCodec());
+        }
+        recorder.setFormat("flv");
+        recorder.setVideoBitrate(grabber.getVideoBitrate());
+        recorder.setVideoCodec(grabber.getVideoCodec());
+        recorder.start(grabber.getFormatContext());
+        if (headers == null) {
+            headers = stream.toByteArray();
+            stream.reset();
+            writeResponse(headers);
+        }
+        int nullNumber = 0;
+        while (running) {
+            AVPacket k = grabber.grabPacket();
+            if (k != null) {
+                try {
+                    recorder.recordPacket(k);
+                } catch (Exception e) {
+                }
+                if (stream.size() > 0) {
+                    byte[] b = stream.toByteArray();
+                    stream.reset();
+                    writeResponse(b);
+                    if (outEntitys.isEmpty()) {
+                        log.info("没有输出退出");
+                        break;
+                    }
+                }
+                avcodec.av_packet_unref(k);
+            } else {
+                nullNumber++;
+                if (nullNumber > 200) {
+                    break;
+                }
+            }
+            Thread.sleep(5);
+        }
+    }
+
+    /**
+     * 输出FLV视频流
+     *
+     * @param b
+     */
+    public void writeResponse(byte[] b) {
+        Iterator<AsyncContext> it = outEntitys.iterator();
+        while (it.hasNext()) {
+            AsyncContext o = it.next();
+            try {
+                o.getResponse().getOutputStream().write(b);
+            } catch (Exception e) {
+                log.info("移除一个输出");
+                it.remove();
+            }
+        }
+    }
+
+    /**
+     * 退出转换
+     */
+    public void closeConverter() {
+        IOUtils.close(grabber);
+        IOUtils.close(recorder);
+        IOUtils.close(stream);
+    }
+
+    /**
+     * 关闭异步响应
+     */
+    public void completeResponse() {
+        Iterator<AsyncContext> it = outEntitys.iterator();
+        while (it.hasNext()) {
+            AsyncContext o = it.next();
+            o.complete();
+        }
+    }
+
+    @Override
+    public void addOutputStreamEntity(String key, AsyncContext entity) throws IOException {
+        if (headers == null) {
+            outEntitys.add(entity);
+        } else {
+            entity.getResponse().getOutputStream().write(headers);
+            entity.getResponse().getOutputStream().flush();
+            outEntitys.add(entity);
+        }
+    }
+
+    @Override
+    public void softClose() {
+        this.running =false;
+    }
+}

+ 193 - 0
src/main/java/com/coyee/stream/converter/HlsConverter.java

@@ -0,0 +1,193 @@
+package com.coyee.stream.converter;
+
+import com.alibaba.fastjson.util.IOUtils;
+import com.coyee.stream.config.StreamServerConfig;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.io.FilenameUtils;
+import org.bytedeco.ffmpeg.avcodec.AVPacket;
+import org.bytedeco.ffmpeg.avformat.AVFormatContext;
+import org.bytedeco.ffmpeg.global.avcodec;
+import org.bytedeco.ffmpeg.global.avutil;
+import org.bytedeco.javacv.FFmpegFrameGrabber;
+import org.bytedeco.javacv.FFmpegFrameRecorder;
+
+import javax.servlet.AsyncContext;
+import java.io.File;
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Condition;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+import static org.bytedeco.ffmpeg.global.avcodec.av_packet_unref;
+
+/**
+ * @author hxfein
+ * @className: HlsConverter
+ * @description: 将流转为hls格式
+ * @date 2022/5/12 14:32
+ * @version:1.0
+ */
+@Slf4j
+public class HlsConverter extends Thread implements Converter {
+    public volatile boolean running = true;
+    private Lock lock = new ReentrantLock();
+    private Condition condition = lock.newCondition();
+    /**
+     * 读流器
+     */
+    private FFmpegFrameGrabber grabber;
+    /**
+     * 转码器
+     */
+    private FFmpegFrameRecorder recorder;
+    /**
+     * 流服务配置
+     */
+    private StreamServerConfig streamServerConfig;
+
+    /**
+     * 流地址,h264,aac
+     */
+    private String url;
+
+    private String key;
+
+
+    public HlsConverter(StreamServerConfig config, String url, String key) {
+        this.streamServerConfig = config;
+        this.url = url;
+        this.key = key;
+    }
+
+    @Override
+    public void run() {
+        try {
+            lock.lock();
+            log.info("开始转换HLS任务:{}。", url);
+            grabber = new FFmpegFrameGrabber(url);
+            if ("rtsp".equals(url.substring(0, 4))) {
+                grabber.setOption("rtsp_transport", "tcp");
+                grabber.setOption("stimeout", "5000000");
+            }
+            grabber.start();
+
+            int bitrate = grabber.getVideoBitrate();// 比特率
+            double framerate = 25.0;// 帧率
+            int timebase;// 时钟基
+            int err_index = 0, no_pkt_index = 0;//错误帧数、没有包的帧数
+            long dts = 0, pts = 0;// pkt的dts、pts时间戳
+            // 异常的framerate,强制使用25帧
+            if (grabber.getFrameRate() > 0 && grabber.getFrameRate() < 100) {
+                framerate = grabber.getFrameRate();
+            }
+
+            File m3u8File = this.getM3u8File();
+            recorder = new FFmpegFrameRecorder(m3u8File, grabber.getImageWidth(), grabber.getImageHeight(),
+                    grabber.getAudioChannels());
+            // 设置比特率
+            recorder.setVideoBitrate(bitrate);
+            // h264编/解码器
+            recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264);
+            // 设置音频编码
+            recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC);
+            // 视频帧率(保证视频质量的情况下最低25,低于25会出现闪屏)
+            recorder.setFrameRate(framerate);
+            // 关键帧间隔,一般与帧率相同或者是视频帧率的两倍
+            recorder.setGopSize((int) framerate);
+            // 解码器格式
+            recorder.setFormat("hls");
+            // 单个切片时长,单位是s,默认为5s
+            int hlsTime=streamServerConfig.getHlsTime();
+            recorder.setOption("hls_time", String.valueOf(hlsTime));
+            // HLS播放的列表长度,0标识不做限制
+            int hlsListSize=streamServerConfig.getHlsListSize();
+            recorder.setOption("hls_list_size", String.valueOf(hlsListSize));
+            // TS文件数量限制
+            int hlsWrap=streamServerConfig.getHlsWrap();
+            recorder.setOption("hls_wrap", String.valueOf(hlsWrap));
+            // 设置切片的ts文件序号起始值,默认从0开始,可以通过此项更改
+            recorder.setOption("start_number", "100");
+            /////开始转码
+            AVFormatContext fc = grabber.getFormatContext();
+            recorder.start(fc);
+            boolean canPlay = false;
+            while (running) {
+                AVPacket pkt = grabber.grabPacket();
+                if (pkt == null || pkt.size() <= 0 || pkt.data() == null) {
+                    Thread.sleep(1);
+                    no_pkt_index++;
+                    continue;
+                }
+                // 获取到的pkt的dts,pts异常,将此包丢弃掉。
+                if (pkt.dts() == avutil.AV_NOPTS_VALUE && pkt.pts() == avutil.AV_NOPTS_VALUE || pkt.pts() < dts) {
+                    av_packet_unref(pkt);
+                    continue;
+                }
+                // 矫正dts,pts
+                pkt.pts(pts);
+                pkt.dts(dts);
+                err_index += (recorder.recordPacket(pkt) ? 0 : 1);
+                // pts,dts累加
+                timebase = grabber.getFormatContext().streams(pkt.stream_index()).time_base().den();
+
+                pts += (timebase / (int) framerate);
+                dts += (timebase / (int) framerate);
+                if (canPlay == false && m3u8File.exists()) {
+                    condition.signal();
+                    lock.unlock();
+                    canPlay = true;
+                    log.info("HLS转换的文件可以播放了:{}。", url);
+                }
+            }
+        } catch (Exception e) {
+            log.error(e.getMessage(), e);
+        } finally {
+            closeConverter();
+            log.info("HLS转换{}的任务结束。", url);
+        }
+    }
+
+    /**
+     * 获取m3u8文件存储地址
+     *
+     * @return
+     */
+    public File getM3u8File() {
+        String hlsStoreDir = streamServerConfig.getHlsStoreDir();
+        String hlsUrl = FilenameUtils.separatorsToSystem(hlsStoreDir + File.separator + key + File.separator + "play.m3u8");
+        File hlsFile = new File(hlsUrl);
+        File hlsParentFile = hlsFile.getParentFile();
+        if (hlsParentFile.exists() == false) {
+            hlsParentFile.mkdirs();
+        }
+        return hlsFile;
+    }
+
+    /**
+     * 退出转换
+     */
+    public void closeConverter() {
+        IOUtils.close(grabber);
+        IOUtils.close(recorder);
+    }
+
+    @Override
+    public void addOutputStreamEntity(String key, AsyncContext entity) throws IOException {
+
+    }
+
+    @Override
+    public void softClose() {
+        this.running = false;
+    }
+
+
+    public String getPlayUrl() throws InterruptedException {
+        lock.lock();
+        condition.await(10, TimeUnit.SECONDS);
+        lock.unlock();
+        return String.format("/live/%s/play.m3u8", key);
+    }
+
+}

+ 149 - 0
src/main/java/com/coyee/stream/result/JsonResult.java

@@ -0,0 +1,149 @@
+package com.coyee.stream.result;
+
+import java.io.Serializable;
+import java.util.HashMap;
+
+import com.alibaba.fastjson.JSONObject;
+
+/**
+ * @author hxfein
+ * @className: JsonResult
+ * @description: 接口返回结果结果封装
+ * @date 2022/5/12 14:32
+ * @version:1.0
+ */
+public class JsonResult extends HashMap<String, Object> implements Serializable {
+
+	private static final long serialVersionUID = 1L;
+	public static final int SUCCESS = 200;
+
+	public JsonResult() {
+	}
+
+	/**
+	 * 返回成功
+	 */
+	public static JsonResult ok() {
+		return ok("操作成功");
+	}
+
+	/**
+	 * 返回成功
+	 */
+	public static JsonResult okFallBack() {
+		return okFallBack("操作成功");
+	}
+
+	/**
+	 * 返回成功
+	 */
+	public JsonResult put(Object obj) {
+		return this.put("data", obj);
+	}
+
+	/**
+	 * 返回成功
+	 */
+	public static JsonResult ok(String message) {
+		return result(200, message);
+	}
+
+	/**
+	 * 降级函数 - 返回成功
+	 */
+	public static JsonResult okFallBack(String message) {
+		return result(205, message);
+	}
+
+	/**
+	 * 返回成功
+	 */
+	public static JsonResult result(int code, String message) {
+		JsonResult jsonResult = new JsonResult();
+		jsonResult.put("timestamp", System.currentTimeMillis());
+		jsonResult.put("status", code);
+		jsonResult.put("message", message);
+		return jsonResult;
+	}
+
+	/**
+	 * 设置数据
+	 * @param data
+	 * @return
+	 */
+	public JsonResult data(Object data){
+		this.put("data",data);
+		return this;
+	}
+
+	/**
+	 * 返回失败
+	 */
+	public static JsonResult error() {
+		return error("操作失败");
+	}
+
+	/**
+	 * 返回失败
+	 */
+	public static JsonResult error(String message) {
+		return error(500, message);
+	}
+
+	/**
+	 * 返回失败
+	 */
+	public static JsonResult error(int code, String message) {
+		JsonResult jsonResult = new JsonResult();
+		jsonResult.put("timestamp", System.currentTimeMillis());
+		jsonResult.put("status", code);
+		jsonResult.put("message", message);
+		return jsonResult;
+	}
+
+	/**
+	 * 设置code
+	 */
+	public JsonResult setCode(int code) {
+		super.put("status", code);
+		return this;
+	}
+
+	/**
+	 * 设置message
+	 */
+	public JsonResult setMessage(String message) {
+		super.put("message", message);
+		return this;
+	}
+
+	/**
+	 * 放入object
+	 */
+	@Override
+	public JsonResult put(String key, Object object) {
+		super.put(key, object);
+		return this;
+	}
+
+	/**
+	 * 权限禁止
+	 */
+	public static JsonResult forbidden(String message) {
+		JsonResult jsonResult = new JsonResult();
+		jsonResult.put("timestamp", System.currentTimeMillis());
+		jsonResult.put("status", 401);
+		jsonResult.put("message", message);
+		return jsonResult;
+	}
+
+	@Override
+	public String toString() {
+		return JSONObject.toJSONString(this);
+	}
+
+	public JSONObject toJSONObject() {
+		return JSONObject.parseObject(toString());
+	}
+
+}

+ 50 - 0
src/main/java/com/coyee/stream/service/IStreamService.java

@@ -0,0 +1,50 @@
+package com.coyee.stream.service;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.util.Date;
+/**
+ * @author hxfein
+ * @className: IStreamService
+ * @description: 流服务相关接口
+ * @date 2022/5/12 14:32
+ * @version:1.0
+ */
+public interface IStreamService {
+
+	/**
+	 * 打开转换流
+	 * @param url
+	 * @param format
+	 * @param response
+	 * @param request
+	 * @return 播放地址
+	 */
+	String open(String url,String format, HttpServletResponse response, HttpServletRequest request);
+
+	/**
+	 * 更新流的上次访问时间
+	 * @param key
+	 */
+	boolean activeStream(String key);
+	/**
+	 * 流地址加密
+	 * @param url
+	 * @return
+	 */
+	String encode(String url);
+	
+	/**
+	 * 流地址解密
+	 * @param url
+	 * @return
+	 */
+	String decode(String url);
+
+	/**
+	 * 定时任务关闭长期未使用的转换器
+	 * 每5分钟执行一次
+	 */
+	void manageConverters();
+
+}

+ 145 - 0
src/main/java/com/coyee/stream/service/impl/StreamServiceImpl.java

@@ -0,0 +1,145 @@
+package com.coyee.stream.service.impl;
+
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.util.*;
+
+import javax.annotation.Resource;
+import javax.servlet.AsyncContext;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import com.coyee.stream.config.StreamServerConfig;
+import com.coyee.stream.converter.HlsConverter;
+import com.coyee.stream.converter.Converter;
+import com.coyee.stream.converter.FlvConverter;
+import com.coyee.stream.service.IStreamService;
+import com.coyee.stream.util.Des;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * @author hxfein
+ * @className: StreamServiceImpl
+ * @description: 流服务相关接口实现
+ * @date 2022/5/12 14:32
+ * @version:1.0
+ */
+@Slf4j
+@Service
+public class StreamServiceImpl implements IStreamService {
+
+	private Map<String, Converter> flvConverters = new HashMap<>();
+	private Map<String, Converter> hlsConverters = new HashMap<>();
+	private Map<String,Date> activeStreamMap=new HashMap<>();
+	@Resource
+	private StreamServerConfig streamServerConfig;
+	/**
+	 * 编码
+	 */
+	private static Charset charset = Charset.forName("utf-8");
+
+	@Override
+	public String open(String url,String format, HttpServletResponse response, HttpServletRequest request) {
+		String key=encode(url);
+		this.activeStream(key);
+		//如果是hls协议,开启转换,转换成功以后返回播放地址给客户端
+		//如果是flv协议,开启转换,并保持与客户端的链接,不断的输出视频流给客户端
+		if(StringUtils.equals(format,"hls")){
+			if (hlsConverters.containsKey(key)==false) {
+				HlsConverter hlsConverter=new HlsConverter(streamServerConfig,url,key);
+				hlsConverter.start();
+				hlsConverters.put(key,hlsConverter);
+				try {
+					String playUrl = hlsConverter.getPlayUrl();
+					return playUrl;
+				}catch(InterruptedException er){
+					throw new RuntimeException("获取播放地址失败!");
+				}
+			}
+		}else {
+			AsyncContext async = request.startAsync();
+			async.setTimeout(0);
+			if (flvConverters.containsKey(key)) {
+				Converter c = flvConverters.get(key);
+				try {
+					c.addOutputStreamEntity(key, async);
+				} catch (IOException e) {
+					log.error(e.getMessage(), e);
+					throw new IllegalArgumentException(e.getMessage());
+				}
+			} else {
+				List<AsyncContext> outs = new ArrayList<>();
+				outs.add(async);
+				FlvConverter c = new FlvConverter(url, outs);
+				c.start();
+				flvConverters.put(key, c);
+			}
+			response.setContentType("video/x-flv");
+			response.setHeader("Connection", "keep-alive");
+			response.setStatus(HttpServletResponse.SC_OK);
+			try {
+				response.flushBuffer();
+			} catch (IOException e) {
+				log.error(e.getMessage(), e);
+			}
+		}
+		return null;
+	}
+
+	@Override
+	public boolean activeStream(String key) {
+		if(hlsConverters.containsKey(key)||flvConverters.containsKey(key)) {
+			activeStreamMap.put(key, new Date());
+			return true;
+		}else{
+			activeStreamMap.remove(key);
+			return false;
+		}
+	}
+
+	@Override
+	public String encode(String url) {
+		String desKey=streamServerConfig.getDesKey();
+		return Des.encryptString(url, charset, desKey);
+	}
+
+	@Override
+	public String decode(String url) {
+		String desKey=streamServerConfig.getDesKey();
+		return Des.decryptString(url, charset, desKey);
+	}
+
+	@Scheduled(fixedDelay = 1*60*1000)
+	@Override
+	public void manageConverters() {
+		log.info("管理任务开始运行:{}",new Date());
+		activeStreamMap.forEach((key,lastAccessTime)->{
+			if(lastAccessTime==null){
+				return ;
+			}
+			long accessTime=lastAccessTime.getTime();
+			long currentTime=System.currentTimeMillis();
+			long diff=currentTime-accessTime;
+			if(diff/(60*1000)>5){//上次访问时间大于5分钟
+				Converter flvConverter=flvConverters.get(key);
+				if(flvConverter!=null){
+					flvConverter.softClose();
+					flvConverters.remove(key);
+					log.info("管理任务移去FLV转流任务:{}",key);
+				}
+				Converter hlsConverter=hlsConverters.get(key);
+				if(hlsConverter!=null){
+					hlsConverter.softClose();
+					hlsConverters.remove(key);
+					log.info("管理任务移去HLS转流任务:{}",key);
+				}
+				activeStreamMap.remove(key);
+			}
+		});
+	}
+}

+ 132 - 0
src/main/java/com/coyee/stream/util/Des.java

@@ -0,0 +1,132 @@
+package com.coyee.stream.util;
+
+import java.nio.charset.Charset;
+
+import javax.crypto.Cipher;
+import javax.crypto.SecretKey;
+import javax.crypto.SecretKeyFactory;
+import javax.crypto.spec.DESKeySpec;
+import javax.crypto.spec.IvParameterSpec;
+
+/**
+ * @author hxfein
+ * @className: Des
+ * @description: 地址编码/解码封装
+ * @date 2022/5/12 14:32
+ * @version:1.0
+ */
+public class Des {
+
+	/**
+	 * 对给定的字符串以指定的编码方式和密钥进行加密
+	 * 
+	 * @param srcStr  待加密的字符串
+	 * @param charset 字符集,如utf8
+	 * @param sKey    密钥
+	 */
+	public static String encryptString(String srcStr, Charset charset, String sKey) {
+		byte[] src = srcStr.getBytes(charset);
+		byte[] buf = Des.encrypt(src, sKey);
+		return Des.parseByte2HexStr(buf);
+	}
+
+	/**
+	 * 对给定的密文以指定的编码方式和密钥进行解密
+	 * 
+	 * @param hexStr  需要解密的密文
+	 * @param charset 字符集
+	 * @param sKey    密钥
+	 * @return 解密后的原文
+	 */
+	public static String decryptString(String hexStr, Charset charset, String sKey) {
+		byte[] src = Des.parseHexStr2Byte(hexStr);
+		byte[] buf = Des.decrypt(src, sKey);
+		return new String(buf, charset);
+	}
+
+	public static byte[] encrypt(byte[] data, String sKey) {
+		try {
+			byte[] key = sKey.getBytes();
+
+			IvParameterSpec iv = new IvParameterSpec(key);
+			DESKeySpec desKey = new DESKeySpec(key);
+
+			SecretKeyFactory keyFactory = SecretKeyFactory.getInstance("DES");
+			SecretKey securekey = keyFactory.generateSecret(desKey);
+
+			Cipher cipher = Cipher.getInstance("DES/CBC/PKCS5Padding");
+
+			cipher.init(Cipher.ENCRYPT_MODE, securekey, iv);
+
+			return cipher.doFinal(data);
+		} catch (Throwable e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	/**
+	 * 解密
+	 * 
+	 * @param src
+	 * @param sKey
+	 * @return
+	 * @throws Exception
+	 */
+	public static byte[] decrypt(byte[] src, String sKey) {
+		try {
+			byte[] key = sKey.getBytes();
+			// 初始化向量
+			IvParameterSpec iv = new IvParameterSpec(key);
+			// 创建一个DESKeySpec对象
+			DESKeySpec desKey = new DESKeySpec(key);
+			// 创建一个密匙工厂
+			SecretKeyFactory keyFactory = SecretKeyFactory.getInstance("DES");
+			// 将DESKeySpec对象转换成SecretKey对象
+			SecretKey securekey = keyFactory.generateSecret(desKey);
+			// Cipher对象实际完成解密操作
+			Cipher cipher = Cipher.getInstance("DES/CBC/PKCS5Padding");
+			// 用密匙初始化Cipher对象
+			cipher.init(Cipher.DECRYPT_MODE, securekey, iv);
+			// 真正开始解密操作
+			return cipher.doFinal(src);
+		} catch (Throwable e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	/**
+	 * 将二进制转换成16进制
+	 *
+	 * @param buf
+	 * @return
+	 */
+	public static String parseByte2HexStr(byte buf[]) {
+		StringBuffer sb = new StringBuffer();
+		for (int i = 0; i < buf.length; i++) {
+			String hex = Integer.toHexString(buf[i] & 0xFF);
+			if (hex.length() == 1) {
+				hex = '0' + hex;
+			}
+			sb.append(hex.toUpperCase());
+		}
+		return sb.toString();
+	}
+
+	/**
+	 * 将16进制转换为二进制
+	 *
+	 * @param hexStr
+	 * @return
+	 */
+	public static byte[] parseHexStr2Byte(String hexStr) {
+		if (hexStr.length() < 1)
+			return null;
+		byte[] result = new byte[hexStr.length() / 2];
+		for (int i = 0; i < hexStr.length() / 2; i++) {
+			int high = Integer.parseInt(hexStr.substring(i * 2, i * 2 + 1), 16);
+			int low = Integer.parseInt(hexStr.substring(i * 2 + 1, i * 2 + 2), 16);
+			result[i] = (byte) (high * 16 + low);
+		}
+		return result;
+	}
+}

+ 27 - 0
src/main/resources/application.yml

@@ -0,0 +1,27 @@
+spring:
+  profiles:
+    active: dev
+  mvc:
+    throw-exception-if-no-handler-found: true
+    static-path-pattern: /**
+  resources:
+    add-mappings: true
+  application:
+    name: coyee-stream-converter
+  jackson:
+    date-format: yyyy-MM-dd HH:mm:ss
+    time-zone: GMT+8
+  servlet:
+    multipart:
+      maxFileSize: 50Mb
+      maxRequestSize: 50Mb
+server:
+  port: 8081
+  tomcat:
+    uri-encoding: UTF-8
+streamserver:
+  desKey: W1ses0ft
+  hlsStoreDir: /home/hls
+  hlsTime: 5
+  hlsWrap: 10
+  hlsListSize: 0

BIN
src/main/resources/static/favicon.ico


+ 109 - 0
src/main/resources/static/hls.html

@@ -0,0 +1,109 @@
+<!DOCTYPE html>
+<html lang="en" dir="ltr">
+<head>
+	<meta charset="utf-8">
+	<title>hls测试</title>
+	<script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
+	<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
+</head>
+<body>
+<div>
+	<video id="video" controls></video>
+</div>
+<div>
+	<h3>1.地址编码</h3>
+	<p>
+		<label>源地址:</label>
+		<input type="text" size="120" value="rtsp://wowzaec2demo.streamlock.net/vod/mp4" id="sourceUrl"/>
+		<input type="button" onclick="createKey()" value="编码"/>
+	</p>
+	<p><label>编码:</label><label id="keyText"></label></p>
+	<p><label>打开流地址:</label><label id="openHlsUrlText"></label></p>
+</div>
+<div>
+	<h3>2.打开流转换</h3>
+	<p>
+		<input type="text" size="120" value="" id="openHlsUrl"/>
+		<input type="button" onclick="openHlsConvert()" value="打开流转换"/>
+	</p>
+	<p><label>播放地址:</label><label id="playUrlText"></label></p>
+</div>
+<div>
+	<h3>3.播放流</h3>
+	<input type="text" size="120" value="" id="hlsUrl"/>
+	<input type="button" onclick="playHls()" value="播放"/>
+</div>
+
+<div>
+	<h3>4.保持会话</h3>
+	<p>
+		<input type="text" size="120" value="" id="key"/>
+		<input type="button" onclick="keepActive()" value="保持"/>
+	</p>
+	<p id="keepMsg"></p>
+</div>
+<script type="text/javascript">
+	var baseUrl="http://localhost:8081";
+	function createKey(){
+		var sourceUrl=$("#sourceUrl").val()||'';
+		if(sourceUrl==''){
+			alert("源地址不能为空!");
+			return;
+		}
+		var url="/url/encode";
+		$.post(url,{url:sourceUrl},function(json){
+			if(json.status==200){
+				var key=json.data;
+				$("#keyText").text(key);
+				$("#key").val(key);
+				var openHlsUrl=baseUrl+"/live/openHls/"+key;
+				$("#openHlsUrlText").text(openHlsUrl);
+				$("#openHlsUrl").val(openHlsUrl);
+			}else{
+				alert("错误:"+json.message);
+			}
+		});
+	}
+	function openHlsConvert(){
+		var openHlsUrl=$("#openHlsUrl").val()||'';
+		if(openHlsUrl==''){
+			alert("接口地址不能为空!");
+			return;
+		}
+		$.get(openHlsUrl,function(json){
+			if(json.status==200){
+				var playUrl=baseUrl+json.data;
+				$("#playUrlText").text(playUrl);
+				$("#hlsUrl").val(playUrl);
+			}else{
+				alert("打开流转换失败:"+json.message);
+			}
+		});
+	}
+	function playHls(){
+		var url=$("#hlsUrl").val()||'';
+		if(url==''){
+			alert("请输入hls流播放地址!");
+			return;
+		}
+		var video = document.getElementById('video');
+		var hls = new Hls();
+		hls.loadSource(url);
+		hls.attachMedia(video);
+		hls.on(Hls.Events.MANIFEST_PARSED, function() {
+			video.play();
+		});
+	}
+	function keepActive(){
+		var key=$("#key").val()||"";
+		var index=0;
+		setInterval(function(){
+			var url=baseUrl+"/live/ping/"+key;
+			$.get(url,function(text){
+				$("#keepMsg").text("第"+(index++)+"次保持会话:"+text);
+			});
+		},5*1000);
+	}
+</script>
+</body>
+</html>

+ 97 - 0
src/main/resources/static/rtmp.html

@@ -0,0 +1,97 @@
+<!DOCTYPE html>
+<html lang="en" dir="ltr">
+<head>
+    <meta charset="utf-8">
+    <title>rtmp测试</title>
+    <script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
+    <script src="https://cdn.bootcss.com/flv.js/1.4.0/flv.min.js"></script>
+</head>
+<body>
+<div>
+    <video id="rtmpPlayer"
+           class="video-js vjs-default-skin vjs-big-play-centered" controls
+           preload="auto" autoplay="autoplay" width="500" height="400"
+           data-setup='{}'>
+    </video>
+</div>
+<div>
+    <h3>1.地址编码</h3>
+    <p>
+        <label>源地址:</label>
+        <input type="text" size="120" value="rtsp://wowzaec2demo.streamlock.net/vod/mp4" id="sourceUrl"/>
+        <input type="button" onclick="createKey()" value="编码"/>
+    </p>
+    <p><label>编码:</label><label id="keyText"></label></p>
+    <p><label>打开流地址:</label><label id="rtmpUrlText"></label></p>
+</div>
+<div>
+    <h3>2.播放RTMP流</h3>
+    <input type="text" size="120" value="" id="rtmpUrl"/>
+    <input type="button" onclick="playRtmp()" value="播放"/>
+</div>
+
+<div>
+    <h3>3.保持会话</h3>
+    <p>
+        <input type="text" size="120" value="" id="key"/>
+        <input type="button" onclick="keepActive()" value="保持"/>
+    </p>
+    <p id="keepMsg"></p>
+</div>
+<script type="text/javascript">
+    var baseUrl = "http://localhost:8081";
+
+    function createKey() {
+        var sourceUrl = $("#sourceUrl").val() || '';
+        if (sourceUrl == '') {
+            alert("源地址不能为空!");
+            return;
+        }
+        var url = "/url/encode";
+        $.post(url, {url: sourceUrl}, function (json) {
+            if (json.status == 200) {
+                var key = json.data;
+                $("#keyText").text(key);
+                $("#key").val(key);
+                var rtmpUrl = baseUrl + "/live/" + key + ".flv";
+                $("#rtmpUrlText").text(rtmpUrl);
+                $("#rtmpUrl").val(rtmpUrl);
+            } else {
+                alert("错误:" + json.message);
+            }
+        });
+    }
+
+
+
+    function playRtmp() {
+        var url = $("#rtmpUrl").val() || '';
+        if (url == '') {
+            alert("请输入rtmp流播放地址!");
+            return;
+        }
+        if (flvjs.isSupported()) {
+            var videoElement = document.getElementById('rtmpPlayer');
+            var flvPlayer = flvjs.createPlayer({
+                type: 'flv',
+                url:url
+            });
+            flvPlayer.attachMediaElement(videoElement);
+            flvPlayer.load();
+            flvPlayer.play();
+        }
+    }
+
+    function keepActive() {
+        var key = $("#key").val() || "";
+        var index = 0;
+        setInterval(function () {
+            var url = baseUrl + "/live/ping/" + key;
+            $.get(url, function (text) {
+                $("#keepMsg").text("第" + (index++) + "次保持会话:" + text);
+            });
+        }, 5 * 1000);
+    }
+</script>
+</body>
+</html>

+ 38 - 0
src/test/java/com/coyee/stream/AppTest.java

@@ -0,0 +1,38 @@
+package com.coyee.stream;
+
+import junit.framework.Test;
+import junit.framework.TestCase;
+import junit.framework.TestSuite;
+
+/**
+ * Unit test for simple App.
+ */
+public class AppTest 
+    extends TestCase
+{
+    /**
+     * Create the test case
+     *
+     * @param testName name of the test case
+     */
+    public AppTest( String testName )
+    {
+        super( testName );
+    }
+
+    /**
+     * @return the suite of tests being tested
+     */
+    public static Test suite()
+    {
+        return new TestSuite( AppTest.class );
+    }
+
+    /**
+     * Rigourous Test :-)
+     */
+    public void testApp()
+    {
+        assertTrue( true );
+    }
+}