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; } } }