How to Stream Local Video with Spring Boot, FFmpeg, and ZLMediaKit
This guide walks through setting up ZLMediaKit in Docker, installing FFmpeg, adding a Spring Boot backend with configuration and service classes to launch and manage RTMP streams, and provides front‑end HTML/JavaScript for playing the live FLV or HLS streams.
Environment Preparation
ZLMediaKit installation
Pull the official Docker image and run a container exposing the required ports and mounting a custom config.ini file.
# Pull image
docker pull zlmediakit/zlmediakit:master
# Run container
docker run -d \
--name zlm-server \
-p 1935:1935 \
-p 8099:80 \
-p 8554:554 \
-p 10000:10000 \
-p 10000:10000/udp \
-p 8000:8000/udp \
-v /docker-volumes/zlmediakit/conf/config.ini:/opt/media/conf/config.ini \
zlmediakit/zlmediakit:masterKey config.ini settings control HLS segment length and retention.
[hls]
broadcastRecordTs=0
deleteDelaySec=300 # video retained for 5 minutes
fileBufSize=65536
filePath=./www # storage path
segDur=2 # segment duration (seconds)
segNum=1000 # max segments in .m3u8
segRetain=9999 # actual retained segments on diskFFmpeg installation
Download a Windows build from https://www.gyan.dev/ffmpeg/builds/ and add its bin directory (e.g., C:\ffmpeg\ffmpeg-7.0.2-essentials_build\bin) to the system PATH environment variable.
Spring Boot Backend Implementation
Maven dependency
<dependencies>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-exec</artifactId>
<version>1.3</version>
</dependency>
</dependencies>Stream configuration class
package com.lyk.plugflow.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "stream")
public class StreamConfig {
private String zlmHost; // ZLMediaKit service address
private Integer rtmpPort; // RTMP port
private Integer httpPort; // HTTP‑FLV port
private String ffmpegPath; // FFmpeg executable path
private String videoPath; // Video storage path
}Stream service class
package com.lyk.plugflow.service;
import com.lyk.plugflow.config.StreamConfig;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.exec.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Service
public class StreamService {
@Autowired
private StreamConfig streamConfig;
// Store running processes
private final Map<String, DefaultExecutor> streamProcesses = new ConcurrentHashMap<>();
// Manual stop flags
private final Map<String, Boolean> manualStopFlags = new ConcurrentHashMap<>();
/** Start streaming */
public boolean startStream(String videoPath, String streamKey) {
try {
File videoFile = new File(videoPath);
if (!videoFile.exists()) {
log.error("视频文件不存在: {}", videoPath);
return false;
}
String rtmpUrl = String.format("rtmp://%s:%d/live/%s",
streamConfig.getZlmHost(), streamConfig.getRtmpPort(), streamKey);
CommandLine cmdLine = getCommandLine(videoPath, rtmpUrl);
DefaultExecutor executor = new DefaultExecutor();
executor.setExitValue(0);
ExecuteWatchdog watchdog = new ExecuteWatchdog(ExecuteWatchdog.INFINITE_TIMEOUT);
executor.setWatchdog(watchdog);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
executor.setStreamHandler(new PumpStreamHandler(outputStream));
executor.execute(cmdLine, new ExecuteResultHandler() {
@Override
public void onProcessComplete(int exitValue) {
log.info("推流完成, streamKey: {}, exitValue: {}", streamKey, exitValue);
streamProcesses.remove(streamKey);
}
@Override
public void onProcessFailed(ExecuteException e) {
boolean isManualStop = manualStopFlags.remove(streamKey);
if (isManualStop) {
log.info("推流已手动停止, streamKey: {}", streamKey);
} else {
log.error("推流失败, streamKey: {}, error: {}", streamKey, e.getMessage());
}
streamProcesses.remove(streamKey);
}
});
streamProcesses.put(streamKey, executor);
log.info("开始推流, streamKey: {}, rtmpUrl: {}", streamKey, rtmpUrl);
return true;
} catch (Exception e) {
log.error("推流启动失败", e);
return false;
}
}
private CommandLine getCommandLine(String videoPath, String rtmpUrl) {
CommandLine cmdLine = new CommandLine(streamConfig.getFfmpegPath());
cmdLine.addArgument("-re"); // read at native frame rate
cmdLine.addArgument("-i");
cmdLine.addArgument(videoPath);
cmdLine.addArgument("-c:v");
cmdLine.addArgument("libx264"); // video codec
cmdLine.addArgument("-c:a");
cmdLine.addArgument("aac"); // audio codec
cmdLine.addArgument("-f");
cmdLine.addArgument("flv"); // output format
cmdLine.addArgument("-flvflags");
cmdLine.addArgument("no_duration_filesize");
cmdLine.addArgument(rtmpUrl);
return cmdLine;
}
/** Stop streaming */
public boolean stopStream(String streamKey) {
try {
DefaultExecutor executor = streamProcesses.get(streamKey);
if (executor != null) {
manualStopFlags.put(streamKey, true);
ExecuteWatchdog watchdog = executor.getWatchdog();
if (watchdog != null) {
watchdog.destroyProcess();
} else {
log.warn("进程没有watchdog,无法强制终止, streamKey: {}", streamKey);
}
streamProcesses.remove(streamKey);
log.info("停止推流成功, streamKey: {}", streamKey);
return true;
}
return false;
} catch (Exception e) {
log.error("停止推流失败", e);
return false;
}
}
/** Get playback URL */
public String getPlayUrl(String streamKey, String protocol) {
switch (protocol.toLowerCase()) {
case "flv":
return String.format("http://%s:%d/live/%s.live.flv",
streamConfig.getZlmHost(), streamConfig.getHttpPort(), streamKey);
case "hls":
return String.format("http://%s:%d/live/%s/hls.m3u8",
streamConfig.getZlmHost(), streamConfig.getHttpPort(), streamKey);
default:
return null;
}
}
/** Check if a stream is active */
public boolean isStreaming(String streamKey) {
return streamProcesses.containsKey(streamKey);
}
}Application configuration (application.yml)
stream:
zlm-host: 192.168.159.129
rtmp-port: 1935
http-port: 8099
ffmpeg-path: ffmpeg
video-path: \videos\
spring:
servlet:
multipart:
max-file-size: 1GB
max-request-size: 1GBUsage Instructions
Streaming workflow
Start the ZLMediaKit Docker container.
Upload the source video file to the server.
Call the startStream API with the video path and a unique stream key.
Spring Boot launches an FFmpeg command that pushes the video to ZLMediaKit via RTMP.
Playback workflow
Obtain a playback URL (HTTP‑FLV or HLS) via getPlayUrl.
Use a front‑end player (e.g., flv.js) to play the live stream or on‑demand replay.
Example FFmpeg command used by the service:
ffmpeg -re -i "C:\Users\lyk19\Videos\8月9日.mp4" -c:v libx264 -preset ultrafast -tune zerolatency -c:a aac -ar 44100 -b:a 128k -f flv rtmp://192.168.159.129:1935/live/streamFront‑end player (HTML + JavaScript)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>FLV Live Player</title>
<!-- Include flv.js library from CDN -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/flv.min.js"></script>
</head>
<body>
<div class="player-container">
<h2>FLV Live Player</h2>
<video id="videoElement" controls muted>Your browser does not support video playback</video>
<div class="controls">
<button id="playBtn">Play</button>
<button id="pauseBtn" disabled>Pause</button>
<button id="stopBtn" disabled>Stop</button>
<button id="muteBtn">Mute</button>
</div>
<div id="status" class="status info">Ready, click Play to start</div>
</div>
<script>
const videoElement = document.getElementById('videoElement');
const playBtn = document.getElementById('playBtn');
const pauseBtn = document.getElementById('pauseBtn');
const stopBtn = document.getElementById('stopBtn');
const muteBtn = document.getElementById('muteBtn');
const statusDiv = document.getElementById('status');
const streamUrl = 'http://192.168.159.129:8099/live/stream.live.flv';
let flvPlayer = null;
function updateStatus(message, type) {
statusDiv.textContent = message;
statusDiv.className = `status ${type}`;
console.log(`[${type.toUpperCase()}] ${message}`);
}
function updateButtons(play, pause, stop) {
playBtn.disabled = !play;
pauseBtn.disabled = !pause;
stopBtn.disabled = !stop;
}
if (!flvjs.isSupported()) {
updateStatus('Your browser does not support FLV playback.', 'error');
playBtn.disabled = true;
}
playBtn.addEventListener('click', function() {
try {
if (flvPlayer) flvPlayer.destroy();
flvPlayer = flvjs.createPlayer({type: 'flv', url: streamUrl, isLive: true}, {
enableWorker: false,
lazyLoad: true,
lazyLoadMaxDuration: 180,
deferLoadAfterSourceOpen: false,
autoCleanupSourceBuffer: true,
enableStashBuffer: false
});
flvPlayer.attachMediaElement(videoElement);
flvPlayer.load();
flvPlayer.on(flvjs.Events.ERROR, (type, detail, info) => {
console.error('FLV player error:', type, detail, info);
updateStatus(`Play error: ${detail}`, 'error');
});
flvPlayer.on(flvjs.Events.LOADING_COMPLETE, () => {
updateStatus('Stream loaded', 'success');
});
videoElement.play().then(() => {
updateStatus('Playing live stream', 'success');
updateButtons(false, true, true);
}).catch(err => {
console.error('Play failed:', err);
updateStatus('Play failed: ' + err.message, 'error');
});
} catch (e) {
console.error('Create player failed:', e);
updateStatus('Create player failed: ' + e.message, 'error');
}
});
pauseBtn.addEventListener('click', function() {
if (videoElement && !videoElement.paused) {
videoElement.pause();
updateStatus('Playback paused', 'info');
updateButtons(true, false, true);
}
});
stopBtn.addEventListener('click', function() {
if (flvPlayer) {
flvPlayer.pause();
flvPlayer.unload();
flvPlayer.destroy();
flvPlayer = null;
}
videoElement.src = '';
videoElement.load();
updateStatus('Playback stopped', 'info');
updateButtons(true, false, false);
});
muteBtn.addEventListener('click', function() {
videoElement.muted = !videoElement.muted;
muteBtn.textContent = videoElement.muted ? 'Unmute' : 'Mute';
updateStatus(videoElement.muted ? 'Muted' : 'Unmuted', 'info');
});
videoElement.addEventListener('loadstart', () => updateStatus('Loading video stream...', 'info'));
videoElement.addEventListener('canplay', () => updateStatus('Video ready', 'success'));
videoElement.addEventListener('playing', () => {
updateStatus('Playing live stream', 'success');
updateButtons(false, true, true);
});
videoElement.addEventListener('pause', () => {
updateStatus('Playback paused', 'info');
updateButtons(true, false, true);
});
videoElement.addEventListener('error', () => {
updateStatus('Video playback error', 'error');
updateButtons(true, false, false);
});
</script>
</body>
</html>How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
