package com.tcm.training.admin.upload;
|
|
import org.springframework.stereotype.Service;
|
import org.springframework.util.StringUtils;
|
|
import java.io.ByteArrayOutputStream;
|
import java.io.IOException;
|
import java.io.InputStream;
|
import java.net.HttpURLConnection;
|
import java.net.URL;
|
import java.nio.charset.StandardCharsets;
|
|
/**
|
* 远程视频时长识别服务。
|
*/
|
@Service
|
public class RemoteVideoDurationService {
|
|
private static final int CONNECT_TIMEOUT_MILLIS = 5000;
|
private static final int READ_TIMEOUT_MILLIS = 20000;
|
private static final int INITIAL_TAIL_PROBE_BYTES = 256 * 1024;
|
private static final int RANGE_CHUNK_BYTES = 2 * 1024 * 1024;
|
private static final int MAX_MOOV_BOX_BYTES = 64 * 1024 * 1024;
|
private static final int MAX_RANGE_DETECT_BYTES = 32 * 1024 * 1024;
|
private static final int MAX_DOWNLOAD_BYTES = 200 * 1024 * 1024;
|
private static final String TYPE_MOOV = "moov";
|
private static final String TYPE_MVHD = "mvhd";
|
|
/**
|
* 识别远程视频时长。
|
*/
|
public VideoDurationDetectVO detectDuration(String videoUrl) {
|
String normalizedUrl = normalizeVideoUrl(videoUrl);
|
Long contentLength = fetchContentLength(normalizedUrl);
|
Integer moovRangeDetectedDuration = detectDurationByMoovRange(normalizedUrl, contentLength);
|
if (moovRangeDetectedDuration != null) {
|
return new VideoDurationDetectVO(moovRangeDetectedDuration);
|
}
|
Integer windowRangeDetectedDuration = detectDurationByRange(normalizedUrl, contentLength);
|
if (windowRangeDetectedDuration != null) {
|
return new VideoDurationDetectVO(windowRangeDetectedDuration);
|
}
|
byte[] videoBytes = downloadVideoBytes(normalizedUrl);
|
return new VideoDurationDetectVO(parseDurationSeconds(videoBytes));
|
}
|
|
/**
|
* 通过尾部分段定位 moov 盒子并按最小范围拉取目标字节。
|
*/
|
private Integer detectDurationByMoovRange(String videoUrl, Long contentLength) {
|
if (contentLength == null || contentLength <= 0L) {
|
return null;
|
}
|
long tailProbeLength = Math.min(INITIAL_TAIL_PROBE_BYTES, contentLength);
|
long tailStart = Math.max(0L, contentLength - tailProbeLength);
|
try {
|
byte[] tailProbeBytes = downloadRangeBytes(videoUrl, tailStart, contentLength - 1L);
|
ByteRange moovRange = resolveMoovRangeFromTailChunk(tailProbeBytes, tailStart, contentLength);
|
if (moovRange == null) {
|
return null;
|
}
|
long moovSize = moovRange.end - moovRange.start + 1L;
|
if (moovSize <= 0L || moovSize > MAX_MOOV_BOX_BYTES) {
|
return null;
|
}
|
|
byte[] moovBytes = extractBytesIfInsideChunk(tailProbeBytes, tailStart, moovRange);
|
if (moovBytes == null) {
|
moovBytes = downloadRangeBytes(videoUrl, moovRange.start, moovRange.end);
|
}
|
int durationSeconds = parseDurationSecondsSafely(moovBytes);
|
return durationSeconds > 0 ? durationSeconds : null;
|
} catch (Exception ex) {
|
return null;
|
}
|
}
|
|
/**
|
* 通过 HTTP Range 先读取文件头尾,尽量避免整文件下载。
|
*/
|
private Integer detectDurationByRange(String videoUrl, Long contentLength) {
|
try {
|
if (contentLength == null || contentLength <= 0L) {
|
return null;
|
}
|
long detectLimit = Math.min(contentLength, MAX_RANGE_DETECT_BYTES);
|
if (detectLimit <= 0L) {
|
return null;
|
}
|
|
long headLength = Math.min(RANGE_CHUNK_BYTES, detectLimit);
|
byte[] headBytes = downloadRangeBytes(videoUrl, 0L, headLength - 1L);
|
int headDuration = parseDurationSecondsSafely(headBytes);
|
if (headDuration > 0) {
|
return headDuration;
|
}
|
|
long tailWindowLength = Math.min(RANGE_CHUNK_BYTES, detectLimit);
|
while (tailWindowLength <= detectLimit) {
|
long start = Math.max(0L, contentLength - tailWindowLength);
|
byte[] tailBytes = downloadRangeBytes(videoUrl, start, contentLength - 1L);
|
int tailDuration = parseDurationSecondsFromTailChunk(tailBytes);
|
if (tailDuration > 0) {
|
return tailDuration;
|
}
|
if (tailWindowLength == detectLimit) {
|
break;
|
}
|
tailWindowLength = Math.min(detectLimit, tailWindowLength + RANGE_CHUNK_BYTES);
|
}
|
return null;
|
} catch (Exception ex) {
|
return null;
|
}
|
}
|
|
/**
|
* 归一化并校验远程视频地址。
|
*/
|
private String normalizeVideoUrl(String videoUrl) {
|
if (!StringUtils.hasText(videoUrl)) {
|
throw new IllegalArgumentException("请输入视频地址");
|
}
|
String normalizedUrl = videoUrl.trim();
|
try {
|
URL url = new URL(normalizedUrl);
|
String protocol = url.getProtocol();
|
if (!"http".equalsIgnoreCase(protocol) && !"https".equalsIgnoreCase(protocol)) {
|
throw new IllegalArgumentException("视频地址仅支持 HTTP 或 HTTPS");
|
}
|
return normalizedUrl;
|
} catch (IllegalArgumentException ex) {
|
throw ex;
|
} catch (Exception ex) {
|
throw new IllegalArgumentException("视频地址格式不正确");
|
}
|
}
|
|
/**
|
* 通过 HEAD 请求探测资源大小。
|
*/
|
private Long fetchContentLength(String videoUrl) {
|
HttpURLConnection connection = null;
|
try {
|
connection = (HttpURLConnection) new URL(videoUrl).openConnection();
|
connection.setRequestMethod("HEAD");
|
applyConnectionConfig(connection);
|
int responseCode = connection.getResponseCode();
|
if (responseCode < 200 || responseCode >= 300) {
|
return null;
|
}
|
long contentLength = connection.getContentLengthLong();
|
return contentLength > 0L ? contentLength : null;
|
} catch (Exception ex) {
|
return null;
|
} finally {
|
if (connection != null) {
|
connection.disconnect();
|
}
|
}
|
}
|
|
/**
|
* 若目标区间已包含在当前分段中,直接在内存切片避免二次请求。
|
*/
|
private byte[] extractBytesIfInsideChunk(byte[] chunkBytes, long chunkStart, ByteRange targetRange) {
|
if (chunkBytes == null || targetRange == null) {
|
return null;
|
}
|
long chunkEnd = chunkStart + chunkBytes.length - 1L;
|
if (targetRange.start < chunkStart || targetRange.end > chunkEnd) {
|
return null;
|
}
|
int offsetStart = (int) (targetRange.start - chunkStart);
|
int offsetEnd = (int) (targetRange.end - chunkStart);
|
byte[] targetBytes = new byte[offsetEnd - offsetStart + 1];
|
System.arraycopy(chunkBytes, offsetStart, targetBytes, 0, targetBytes.length);
|
return targetBytes;
|
}
|
|
/**
|
* 下载指定字节区间,优先用于头尾分段识别时长。
|
*/
|
private byte[] downloadRangeBytes(String videoUrl, long start, long end) throws IOException {
|
if (start < 0 || end < start) {
|
throw new IllegalArgumentException("视频地址范围不合法");
|
}
|
HttpURLConnection connection = null;
|
try {
|
connection = (HttpURLConnection) new URL(videoUrl).openConnection();
|
connection.setRequestMethod("GET");
|
applyConnectionConfig(connection);
|
connection.setRequestProperty("Range", "bytes=" + start + "-" + end);
|
int responseCode = connection.getResponseCode();
|
if (responseCode != HttpURLConnection.HTTP_PARTIAL
|
&& (responseCode < 200 || responseCode >= 300)) {
|
throw new IOException("range request failed");
|
}
|
try (InputStream inputStream = connection.getInputStream();
|
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
byte[] buffer = new byte[8192];
|
int readLength;
|
int totalLength = 0;
|
while ((readLength = inputStream.read(buffer)) != -1) {
|
totalLength += readLength;
|
if (totalLength > MAX_RANGE_DETECT_BYTES) {
|
throw new IOException("range response too large");
|
}
|
outputStream.write(buffer, 0, readLength);
|
}
|
return outputStream.toByteArray();
|
}
|
} finally {
|
if (connection != null) {
|
connection.disconnect();
|
}
|
}
|
}
|
|
/**
|
* 统一设置 HTTP 请求超时和请求头。
|
*/
|
private void applyConnectionConfig(HttpURLConnection connection) {
|
connection.setConnectTimeout(CONNECT_TIMEOUT_MILLIS);
|
connection.setReadTimeout(READ_TIMEOUT_MILLIS);
|
connection.setInstanceFollowRedirects(true);
|
connection.setRequestProperty("User-Agent", "tcm-training-admin/1.0");
|
}
|
|
/**
|
* 下载远程视频字节流,供服务端解析媒体元数据。
|
*/
|
private byte[] downloadVideoBytes(String videoUrl) {
|
HttpURLConnection connection = null;
|
try {
|
connection = (HttpURLConnection) new URL(videoUrl).openConnection();
|
connection.setRequestMethod("GET");
|
applyConnectionConfig(connection);
|
int responseCode = connection.getResponseCode();
|
if (responseCode < 200 || responseCode >= 300) {
|
throw new IllegalArgumentException("无法获取视频文件,请检查视频地址");
|
}
|
try (InputStream inputStream = connection.getInputStream();
|
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
byte[] buffer = new byte[8192];
|
int readLength;
|
int totalLength = 0;
|
while ((readLength = inputStream.read(buffer)) != -1) {
|
totalLength += readLength;
|
if (totalLength > MAX_DOWNLOAD_BYTES) {
|
throw new IllegalArgumentException("视频文件过大,无法自动识别时长,请手动填写");
|
}
|
outputStream.write(buffer, 0, readLength);
|
}
|
return outputStream.toByteArray();
|
}
|
} catch (IllegalArgumentException ex) {
|
throw ex;
|
} catch (IOException ex) {
|
throw new IllegalArgumentException("无法自动获取视频时长,请手动填写");
|
} finally {
|
if (connection != null) {
|
connection.disconnect();
|
}
|
}
|
}
|
|
/**
|
* 解析 MP4/QuickTime 文件中的 mvhd 盒子,换算为秒级时长。
|
*/
|
int parseDurationSeconds(byte[] videoBytes) {
|
if (videoBytes == null || videoBytes.length < 8) {
|
throw new IllegalArgumentException("无法自动获取视频时长,请手动填写");
|
}
|
long durationSeconds = findDurationSecondsInRange(videoBytes, 0, videoBytes.length);
|
if (durationSeconds <= 0L || durationSeconds > Integer.MAX_VALUE) {
|
throw new IllegalArgumentException("无法自动获取视频时长,请手动填写");
|
}
|
return (int) durationSeconds;
|
}
|
|
/**
|
* 尝试解析完整字节流时长,失败时返回 -1,供 Range 探测兜底流程使用。
|
*/
|
private int parseDurationSecondsSafely(byte[] videoBytes) {
|
try {
|
return parseDurationSeconds(videoBytes);
|
} catch (IllegalArgumentException ex) {
|
return -1;
|
}
|
}
|
|
/**
|
* 在尾部分段中按 moov 盒子位置解析时长。
|
*/
|
int parseDurationSecondsFromTailChunk(byte[] chunkBytes) {
|
if (chunkBytes == null || chunkBytes.length < 8) {
|
return -1;
|
}
|
long durationSeconds = findDurationSecondsFromChunkByMoov(chunkBytes);
|
if (durationSeconds <= 0L || durationSeconds > Integer.MAX_VALUE) {
|
return -1;
|
}
|
return (int) durationSeconds;
|
}
|
|
/**
|
* 在尾部分段中解析 moov 盒子的绝对字节区间。
|
*/
|
ByteRange resolveMoovRangeFromTailChunk(byte[] chunkBytes, long chunkStart, long totalLength) {
|
if (chunkBytes == null || chunkBytes.length < 8 || chunkStart < 0L || totalLength <= 0L) {
|
return null;
|
}
|
for (int typeOffset = chunkBytes.length - 4; typeOffset >= 4; typeOffset--) {
|
if (!isBoxType(chunkBytes, typeOffset, TYPE_MOOV)) {
|
continue;
|
}
|
int headerOffset = typeOffset - 4;
|
long boxSize = readUnsignedInt(chunkBytes, headerOffset);
|
int headerSize = 8;
|
if (boxSize == 1L) {
|
if (headerOffset + 16 > chunkBytes.length) {
|
continue;
|
}
|
boxSize = readUnsignedLong(chunkBytes, headerOffset + 8);
|
headerSize = 16;
|
}
|
if (boxSize != 0L && boxSize < headerSize) {
|
continue;
|
}
|
long boxStart = chunkStart + headerOffset;
|
long boxEnd = boxSize == 0L ? totalLength - 1L : boxStart + boxSize - 1L;
|
if (boxStart < 0L || boxEnd < boxStart || boxEnd >= totalLength) {
|
continue;
|
}
|
return new ByteRange(boxStart, boxEnd);
|
}
|
return null;
|
}
|
|
/**
|
* 在指定字节区间内递归查找 moov/mvhd 盒子。
|
*/
|
private long findDurationSecondsInRange(byte[] videoBytes, int start, int end) {
|
int offset = start;
|
while (offset + 8 <= end) {
|
long boxSize = readUnsignedInt(videoBytes, offset);
|
int headerSize = 8;
|
if (boxSize == 1L) {
|
if (offset + 16 > end) {
|
break;
|
}
|
boxSize = readUnsignedLong(videoBytes, offset + 8);
|
headerSize = 16;
|
} else if (boxSize == 0L) {
|
boxSize = end - offset;
|
}
|
if (boxSize < headerSize) {
|
break;
|
}
|
long boxEndLong = offset + boxSize;
|
if (boxEndLong > end) {
|
break;
|
}
|
int boxEnd = (int) boxEndLong;
|
String type = readBoxType(videoBytes, offset + 4);
|
if (TYPE_MOOV.equals(type)) {
|
long durationSeconds = findMovieHeaderDuration(videoBytes, offset + headerSize, boxEnd);
|
if (durationSeconds > 0L) {
|
return durationSeconds;
|
}
|
}
|
offset = boxEnd;
|
}
|
return -1L;
|
}
|
|
/**
|
* 从任意偏移分段中按 moov 盒子头定位并解析时长。
|
*/
|
private long findDurationSecondsFromChunkByMoov(byte[] chunkBytes) {
|
for (int typeOffset = 4; typeOffset + 4 <= chunkBytes.length; typeOffset++) {
|
if (!isBoxType(chunkBytes, typeOffset, TYPE_MOOV)) {
|
continue;
|
}
|
int boxHeaderOffset = typeOffset - 4;
|
long boxSize = readUnsignedInt(chunkBytes, boxHeaderOffset);
|
int headerSize = 8;
|
if (boxSize == 1L) {
|
if (boxHeaderOffset + 16 > chunkBytes.length) {
|
continue;
|
}
|
boxSize = readUnsignedLong(chunkBytes, boxHeaderOffset + 8);
|
headerSize = 16;
|
} else if (boxSize == 0L) {
|
boxSize = chunkBytes.length - boxHeaderOffset;
|
}
|
if (boxSize < headerSize) {
|
continue;
|
}
|
long boxEndLong = boxHeaderOffset + boxSize;
|
if (boxEndLong > chunkBytes.length) {
|
continue;
|
}
|
long durationSeconds = findMovieHeaderDuration(
|
chunkBytes,
|
boxHeaderOffset + headerSize,
|
(int) boxEndLong
|
);
|
if (durationSeconds > 0L) {
|
return durationSeconds;
|
}
|
}
|
return -1L;
|
}
|
|
/**
|
* 在 moov 盒子中查找 mvhd 头并解析时长。
|
*/
|
private long findMovieHeaderDuration(byte[] videoBytes, int start, int end) {
|
int offset = start;
|
while (offset + 8 <= end) {
|
long boxSize = readUnsignedInt(videoBytes, offset);
|
int headerSize = 8;
|
if (boxSize == 1L) {
|
if (offset + 16 > end) {
|
break;
|
}
|
boxSize = readUnsignedLong(videoBytes, offset + 8);
|
headerSize = 16;
|
} else if (boxSize == 0L) {
|
boxSize = end - offset;
|
}
|
if (boxSize < headerSize) {
|
break;
|
}
|
long boxEndLong = offset + boxSize;
|
if (boxEndLong > end) {
|
break;
|
}
|
int boxEnd = (int) boxEndLong;
|
String type = readBoxType(videoBytes, offset + 4);
|
if (TYPE_MVHD.equals(type)) {
|
return parseMovieHeaderDuration(videoBytes, offset + headerSize, boxEnd);
|
}
|
offset = boxEnd;
|
}
|
return -1L;
|
}
|
|
/**
|
* 解析 mvhd 盒子中的 timescale 与 duration。
|
*/
|
private long parseMovieHeaderDuration(byte[] videoBytes, int start, int end) {
|
if (start + 4 > end) {
|
return -1L;
|
}
|
int version = videoBytes[start] & 0xff;
|
long timescale;
|
long duration;
|
if (version == 1) {
|
if (start + 32 > end) {
|
return -1L;
|
}
|
timescale = readUnsignedInt(videoBytes, start + 20);
|
duration = readUnsignedLong(videoBytes, start + 24);
|
} else {
|
if (start + 20 > end) {
|
return -1L;
|
}
|
timescale = readUnsignedInt(videoBytes, start + 12);
|
duration = readUnsignedInt(videoBytes, start + 16);
|
}
|
if (timescale <= 0L || duration < 0L) {
|
return -1L;
|
}
|
return Math.round((double) duration / (double) timescale);
|
}
|
|
/**
|
* 读取 4 字节无符号整数。
|
*/
|
private long readUnsignedInt(byte[] bytes, int offset) {
|
return ((long) bytes[offset] & 0xffL) << 24
|
| ((long) bytes[offset + 1] & 0xffL) << 16
|
| ((long) bytes[offset + 2] & 0xffL) << 8
|
| ((long) bytes[offset + 3] & 0xffL);
|
}
|
|
/**
|
* 读取 8 字节无符号整数的长整型表示。
|
*/
|
private long readUnsignedLong(byte[] bytes, int offset) {
|
return ((long) bytes[offset] & 0xffL) << 56
|
| ((long) bytes[offset + 1] & 0xffL) << 48
|
| ((long) bytes[offset + 2] & 0xffL) << 40
|
| ((long) bytes[offset + 3] & 0xffL) << 32
|
| ((long) bytes[offset + 4] & 0xffL) << 24
|
| ((long) bytes[offset + 5] & 0xffL) << 16
|
| ((long) bytes[offset + 6] & 0xffL) << 8
|
| ((long) bytes[offset + 7] & 0xffL);
|
}
|
|
/**
|
* 读取盒子类型。
|
*/
|
private String readBoxType(byte[] bytes, int offset) {
|
return new String(bytes, offset, 4, StandardCharsets.US_ASCII);
|
}
|
|
/**
|
* 判断指定偏移是否匹配目标盒子类型。
|
*/
|
private boolean isBoxType(byte[] bytes, int typeOffset, String expectedType) {
|
if (typeOffset < 0 || typeOffset + 4 > bytes.length || expectedType == null || expectedType.length() != 4) {
|
return false;
|
}
|
return bytes[typeOffset] == (byte) expectedType.charAt(0)
|
&& bytes[typeOffset + 1] == (byte) expectedType.charAt(1)
|
&& bytes[typeOffset + 2] == (byte) expectedType.charAt(2)
|
&& bytes[typeOffset + 3] == (byte) expectedType.charAt(3);
|
}
|
|
/**
|
* 表示远程文件中的闭区间字节范围。
|
*/
|
static final class ByteRange {
|
private final long start;
|
private final long end;
|
|
ByteRange(long start, long end) {
|
this.start = start;
|
this.end = end;
|
}
|
|
long getStart() {
|
return start;
|
}
|
|
long getEnd() {
|
return end;
|
}
|
}
|
}
|